Skip to content

Commit e147f2d

Browse files
mkceichelbeckMichael Eichelbeck
andauthored
feat: require titles for hosted runs (#1022)
## What - require `--title` for private hosted `zeroshot run --target` submissions - validate titles as 1–100 Unicode characters and preserve them across exact-key retries - send the title through the existing RunIntent `label` field for Zero Cloud display - keep the stable/public CLI surface unchanged ## Why Cloud runs currently arrive with the hardcoded label `zeroshot-cli`, so operators cannot identify them in the dashboard. ## Impact Private hosted callers must now pass `--title <title>`. The Zero Cloud wire contract remains unchanged because `label` already exists and is persisted/displayed end to end. ## Checks - `npm run test` — 3,001 passing, 17 pending - `npm run test:hosted` — 61 private candidate tests and 44 hosted-target tests passing - `npm run lint` — 0 errors (existing warnings only) - touched-file Prettier check — passing - `opcore check --changed` — passing - `opcore-zero check --staged` — clean - Opcore Zero Sense reported no introduced cycles, duplicates, or interface findings; its repository-wide duplicate scan remained incomplete at the configured resource bound Co-authored-by: Michael Eichelbeck <michael@theopenengine.com>
1 parent eabac71 commit e147f2d

11 files changed

Lines changed: 126 additions & 35 deletions

private/hosted-cli-candidate/README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
This guide applies only to the unpublished private candidate. The public Zeroshot package does not
44
contain these commands, this guide, or the accompanying examples.
55

6-
A remote run has four inputs:
6+
A remote run has five inputs:
77

88
- A **target** names the remote Zeroshot service and holds your authenticated session.
9+
- A **title** of 1 to 100 Unicode characters identifies the run in Zeroshot Cloud.
910
- A **runtime config** selects the model API, Zeroshot harness, model, credentials, and normal
1011
Zeroshot settings.
1112
- The supplied **graph** is the candidate's fixed delivery contract. The **input** contains the work
@@ -69,6 +70,7 @@ Start a run with Zeroshot's built-in coordinator by omitting `--config`:
6970
```bash
7071
zeroshot run \
7172
--target team \
73+
--title 'Review checkout flow' \
7274
--graph examples/graph.json \
7375
--input examples/input.json \
7476
--ship
@@ -153,6 +155,7 @@ control the agent topology, pass a declarative Zeroshot cluster config:
153155
```bash
154156
zeroshot run \
155157
--target team \
158+
--title 'Review checkout flow' \
156159
--graph examples/graph.json \
157160
--input examples/input.json \
158161
--config examples/cluster.json \
@@ -182,10 +185,10 @@ zeroshot list --target team
182185

183186
If submission ended without a definite response, the error includes the submission key that was
184187
printed before the request. The key is an idempotency key, not a lookup: retry only when the fully
185-
resolved request is unchanged. That means the graph, input, custom cluster, runtime file and its
186-
referenced environment values and files, GitHub token, size, delivery mode, and resolved repository
187-
revision must all match the first request. A changed request with the same key is rejected rather
188-
than creating a second run.
188+
resolved request is unchanged. That means the title, graph, input, custom cluster, runtime file and
189+
its referenced environment values and files, GitHub token, size, delivery mode, and resolved
190+
repository revision must all match the first request. A changed request with the same key is
191+
rejected rather than creating a second run.
189192

190193
For a recoverable run, configure an exact commit base and keep all referenced inputs unchanged
191194
until submission is confirmed. Then repeat the exact run with the canonical UUID as
@@ -195,6 +198,7 @@ ask the target operator to determine whether the original request was accepted.
195198
```bash
196199
zeroshot run \
197200
--target team \
201+
--title 'Review checkout flow' \
198202
--graph examples/graph.json \
199203
--input examples/input.json \
200204
--ship \
@@ -205,6 +209,7 @@ zeroshot run \
205209

206210
- Remote runs accept explicit JSON graph and input files; general text or issue positionals remain
207211
local-only.
212+
- Remote runs require `--title`; Zeroshot Cloud displays the value without rewriting it.
208213
- The remote graph is the included single-worker delivery graph with one attempt; keep it unchanged.
209214
- Remote delivery always uses `--pr` or `--ship`; there is no delivery-free remote run.
210215
- `logs --target`, the `ls` alias with `--target`, and cross-target listing are not available.

private/hosted-cli-candidate/default-run-intent-services.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ function isDeterministicSubmissionError(error) {
3434
function submissionUncertain(submissionKey, cause) {
3535
return new Error(
3636
'RunIntent submission outcome is uncertain. Do not create a replacement. ' +
37-
'Retry only if the graph, input, cluster config, runtime references, credentials, size, ' +
38-
'delivery mode, and resolved repository revision are unchanged. ' +
37+
'Retry only if the title, graph, input, cluster config, runtime references, credentials, ' +
38+
'size, delivery mode, and resolved repository revision are unchanged. ' +
3939
`Then rerun with --submission-key ${submissionKey}; a changed payload is rejected.`,
4040
{ cause }
4141
);
@@ -211,6 +211,7 @@ async function submitRun(service, options, prepared, signal) {
211211
envelope: buildRunIntentEnvelope(execution.graph, execution.input),
212212
runtime,
213213
submissionKey,
214+
title: options.title,
214215
...(options.size === undefined ? {} : { size: options.size }),
215216
signal,
216217
});

private/hosted-cli-candidate/help-text.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Workflow:
55
1. zeroshot target add <name> --url <https-origin>
66
2. zeroshot target login <name>
77
3. zeroshot target setup <name> --repository <owner/name> --runtime-config <file>
8-
4. zeroshot run --target <name> --graph <file> --input <file> --ship
8+
4. zeroshot run --target <name> --title <title> --graph <file> --input <file> --ship
99
1010
Run \`zeroshot target <command> --help\` for command details.
1111
`;
@@ -22,6 +22,7 @@ The runtime file is read for every run. Setup stores its path, not resolved secr
2222
`;
2323
const HOSTED_RUN_HELP = `
2424
Remote execution with --target:
25+
--title is required and is shown in Zeroshot Cloud.
2526
--graph and --input are required explicit JSON files.
2627
Keep the candidate graph unchanged; put the work request in the input.
2728
--pr or --ship is required for Git delivery.
@@ -31,8 +32,8 @@ Remote execution with --target:
3132
--submission-key retries only an unchanged, fully resolved request.
3233
3334
Examples:
34-
zeroshot run --target team --graph graph.json --input input.json --ship
35-
zeroshot run --target team --graph graph.json --input input.json --config cluster.json --ship
35+
zeroshot run --target team --title "Review" --graph graph.json --input input.json --ship
36+
zeroshot run --target team --title "Review" --graph graph.json --input input.json --config cluster.json --ship
3637
`;
3738

3839
module.exports = { HOSTED_RUN_HELP, TARGET_HELP, TARGET_SETUP_HELP };

private/hosted-cli-candidate/manifest.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const COMMAND_MANIFEST = Object.freeze([
1212
'target cancel <name> <intent-id>',
1313
'capsule create --target <name> [--label <label>] [--size <size>]',
1414
'capsule terminate <capsule-id> --target <name>',
15-
'run --graph <graph.json> --input <input.json> --target <name> (--pr|--ship) ' +
15+
'run --title <title> --graph <graph.json> --input <input.json> --target <name> (--pr|--ship) ' +
1616
'[--config <cluster.json>] [--size <size>] [--submission-key <uuid>] [-d]',
1717
'attach <intent-id> --target <name>',
1818
'list --target <name> [--limit <n>] [--json]',

private/hosted-cli-candidate/register.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const HOSTED_RUN_OPTIONS = new Set([
2222
'detach',
2323
'submissionKey',
2424
'size',
25+
'title',
2526
'pr',
2627
'ship',
2728
'config',
@@ -121,6 +122,7 @@ function registerHostedRun(program, service) {
121122
.option('--graph <file>', 'Candidate delivery GraphSpec JSON')
122123
.option('--input <file>', 'Explicit hosted JSON input')
123124
.option('--target <name>', 'Named private hosted target')
125+
.option('--title <title>', 'Run title shown in Zeroshot Cloud')
124126
.option('--size <size>', 'Advertised capsule size')
125127
.option(
126128
'--submission-key <uuid>',
@@ -135,11 +137,12 @@ function registerHostedRun(program, service) {
135137
options.graph !== undefined ||
136138
options.input !== undefined ||
137139
options.size !== undefined ||
138-
options.submissionKey !== undefined
140+
options.submissionKey !== undefined ||
141+
options.title !== undefined
139142
) {
140143
return failClosed(() =>
141144
Promise.reject(
142-
new Error('--graph, --input, --size, and --submission-key require --target')
145+
new Error('--graph, --input, --size, --submission-key, and --title require --target')
143146
)
144147
);
145148
}
@@ -178,6 +181,10 @@ function validateHostedRunInput(options) {
178181
if (!options.graph || !options.input) {
179182
throw new Error('hosted run requires both --graph and --input');
180183
}
184+
const characters = typeof options.title === 'string' ? Array.from(options.title).length : 0;
185+
if (characters < 1 || characters > 100) {
186+
throw new Error('hosted run title must be between 1 and 100 characters');
187+
}
181188
}
182189

183190
function validateHostedRunDelivery(options) {

private/hosted-cli-candidate/run-intent-http.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ function serializeOpaqueJson(value, maximum, label) {
3434
return bytes;
3535
}
3636

37+
function validatedRunTitle(value) {
38+
const characters = typeof value === 'string' ? Array.from(value).length : 0;
39+
if (characters < 1 || characters > 100) {
40+
throw new RunIntentRequestError('RunIntent title must be between 1 and 100 characters');
41+
}
42+
return value;
43+
}
44+
3745
class RunIntentHttpError extends Error {
3846
constructor(status) {
3947
super(`RunIntent request failed with HTTP ${status}`);
@@ -145,8 +153,9 @@ class RunIntentClient {
145153
this.#fetch = options.fetch;
146154
}
147155

148-
submit({ envelope, runtime, submissionKey, size, signal }) {
156+
submit({ envelope, runtime, submissionKey, size, title, signal }) {
149157
assertUuid(submissionKey, 'submission key');
158+
const label = validatedRunTitle(title);
150159
if (size !== undefined && !['tiny', 'small', 'standard', 'large'].includes(size)) {
151160
throw new RunIntentRequestError('RunIntent size is invalid');
152161
}
@@ -162,7 +171,7 @@ class RunIntentClient {
162171
throw new RunIntentRequestError('RunIntent payloads exceed the decoded dispatch size bound');
163172
}
164173
const body = encodeBoundedJson({
165-
label: 'zeroshot-cli',
174+
label,
166175
...(size === undefined ? {} : { size }),
167176
intent: intentBytes.toString('base64url'),
168177
runtime: runtimeBytes.toString('base64url'),

tests/private-hosted-cli/candidate-fixtures.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function detachedRunOptions(submissionKey, overrides = {}) {
8383
target: 'prod',
8484
graph: 'graph.json',
8585
input: 'input.json',
86+
title: 'Review checkout flow',
8687
submissionKey,
8788
detach: true,
8889
...overrides,

tests/private-hosted-cli/parser.test.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const { afterEach, describe, it } = require('node:test');
66
const { COMMAND_MANIFEST } = require('../../private/hosted-cli-candidate/manifest');
77
const { registerPrivateHostedCandidate } = require('../../private/hosted-cli-candidate/register');
88

9+
const RUN_TITLE = 'Review checkout flow';
10+
911
function harness() {
1012
const calls = [];
1113
const program = new Command();
@@ -76,7 +78,18 @@ async function parse(program, argv) {
7678
}
7779

7880
function hostedRun(...options) {
79-
return ['run', '--target', 'prod', '--graph', 'g.json', '--input', 'i.json', ...options];
81+
return [
82+
'run',
83+
'--target',
84+
'prod',
85+
'--graph',
86+
'g.json',
87+
'--input',
88+
'i.json',
89+
'--title',
90+
RUN_TITLE,
91+
...options,
92+
];
8093
}
8194

8295
afterEach(() => {
@@ -122,12 +135,35 @@ async function rejectsGeneralTextRunWithTarget() {
122135
'g.json',
123136
'--input',
124137
'i.json',
138+
'--title',
139+
RUN_TITLE,
125140
'--pr',
126141
]);
127142
assert.deepEqual(calls, []);
128143
assert.equal(process.exitCode, 1);
129144
}
130145

146+
async function requiresBoundedHostedRunTitles() {
147+
for (const argv of [
148+
['run', '--target', 'prod', '--graph', 'g.json', '--input', 'i.json', '--ship'],
149+
hostedRun('--title', '', '--ship'),
150+
hostedRun('--title', '🚀'.repeat(101), '--ship'),
151+
['run', 'local-task', '--title', RUN_TITLE],
152+
]) {
153+
const rejected = harness();
154+
await parse(rejected.program, argv);
155+
assert.deepEqual(rejected.calls, []);
156+
assert.equal(process.exitCode, 1);
157+
process.exitCode = 0;
158+
}
159+
160+
const accepted = harness();
161+
const title = '🚀'.repeat(100);
162+
await parse(accepted.program, hostedRun('--title', title, '--ship'));
163+
assert.equal(accepted.calls[0][0], 'remoteRun');
164+
assert.equal(accepted.calls[0][1].title, title);
165+
}
166+
131167
async function usesOnlyRunIntentAndKeepsRecoverySyntax() {
132168
const runIntent = harness();
133169
await parse(
@@ -196,6 +232,7 @@ async function dispatchesRemoteLifecycleRoutes() {
196232
);
197233
assert.equal(calls[3][2].force, true);
198234
assert.equal(calls[0][1].config, 'cluster.json');
235+
assert.equal(calls[0][1].title, RUN_TITLE);
199236
}
200237

201238
async function exposesPrivateTargetRunIntentRoutes() {
@@ -238,6 +275,7 @@ function registerPrivateCandidateParserTests() {
238275
],
239276
['rejects incompatible hosted run syntax', rejectsIncompatibleHostedRunSyntax],
240277
['rejects general text run with a target', rejectsGeneralTextRunWithTarget],
278+
['requires bounded hosted run titles', requiresBoundedHostedRunTitles],
241279
['uses only RunIntent and keeps recovery syntax', usesOnlyRunIntentAndKeepsRecoverySyntax],
242280
['rejects empty targets and hosted ls aliases', rejectsEmptyTargetsAndHostedLsAliases],
243281
['preserves local ls after a global option', preservesLocalLsAliasAfterGlobalOption],

tests/private-hosted-cli/run-intent-client.test.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const ORGANIZATION_ID = '019fd17e-5e50-7c66-a68c-3fcf4d8f06c0';
2525
const SUBMISSION_KEY = '019fd17e-8406-41b4-8730-1c54fd44c70e';
2626
const CAPSULE_ID = '019fd17e-b9c4-7ef1-99da-cc0ef3905402';
2727
const OTHER_INTENT_ID = '019fd184-52c3-7e1f-a567-4ecb6fc6a0ec';
28+
const RUN_TITLE = 'Review checkout flow';
2829

2930
function jsonResponse(value, status = 200) {
3031
return new globalThis.Response(JSON.stringify(value), {
@@ -85,6 +86,7 @@ describe('bounded authenticated RunIntent client', () => {
8586
runtime: RUNTIME_BUNDLE,
8687
submissionKey: SUBMISSION_KEY,
8788
size: 'standard',
89+
title: RUN_TITLE,
8890
});
8991
assert.equal(result.intent_id, INTENT_ID);
9092
assert.equal(h.requests.length, 1);
@@ -95,7 +97,7 @@ describe('bounded authenticated RunIntent client', () => {
9597
assert.equal(request.init.headers['idempotency-key'], SUBMISSION_KEY);
9698
const wrapper = JSON.parse(request.init.body);
9799
assert.deepEqual(Object.keys(wrapper), ['label', 'size', 'intent', 'runtime']);
98-
assert.equal(wrapper.label, 'zeroshot-cli');
100+
assert.equal(wrapper.label, RUN_TITLE);
99101
assert.equal(wrapper.size, 'standard');
100102
for (const field of ['intent', 'runtime']) {
101103
assert.match(wrapper[field], /^[A-Za-z0-9_-]+$/);
@@ -170,14 +172,39 @@ describe('bounded RunIntent authentication and bodies', () => {
170172
});
171173

172174
describe('opaque RunIntent submit serialization', () => {
175+
it('requires a title of at most 100 Unicode characters before sending', async () => {
176+
for (const title of [undefined, '', 'x'.repeat(101), '🚀'.repeat(101)]) {
177+
const rejected = clientHarness([]);
178+
assert.throws(
179+
() =>
180+
rejected.client.submit({
181+
envelope: {},
182+
runtime: {},
183+
submissionKey: SUBMISSION_KEY,
184+
title,
185+
}),
186+
/title must be between 1 and 100 characters/
187+
);
188+
assert.equal(rejected.requests.length, 0);
189+
}
190+
const accepted = clientHarness([jsonResponse(runIntent(), 202)]);
191+
const title = '🚀'.repeat(100);
192+
await accepted.client.submit({
193+
envelope: {},
194+
runtime: {},
195+
submissionKey: SUBMISSION_KEY,
196+
title,
197+
});
198+
assert.equal(JSON.parse(accepted.requests[0].init.body).label, title);
199+
});
173200
it('omits capsule size when the caller preserves the target default', async () => {
174201
const h = clientHarness([jsonResponse(runIntent(), 202)]);
175202
await h.client.submit({
176203
envelope: buildRunIntentEnvelope(GRAPH, { source: 'prompt' }),
177204
runtime: RUNTIME_BUNDLE,
178205
submissionKey: SUBMISSION_KEY,
206+
title: RUN_TITLE,
179207
});
180-
181208
assert.equal(Object.hasOwn(JSON.parse(h.requests[0].init.body), 'size'), false);
182209
});
183210

@@ -199,6 +226,7 @@ describe('opaque RunIntent submit serialization', () => {
199226
},
200227
},
201228
submissionKey: SUBMISSION_KEY,
229+
title: RUN_TITLE,
202230
});
203231
assert.equal(intentSerializations, 1);
204232
assert.equal(runtimeSerializations, 1);
@@ -216,6 +244,7 @@ describe('bounded RunIntent submit bodies', () => {
216244
envelope: intentAtCombinedLimit,
217245
runtime: runtimeAtLimit,
218246
submissionKey: SUBMISSION_KEY,
247+
title: RUN_TITLE,
219248
});
220249
assert.ok(Buffer.byteLength(boundary.requests[0].init.body) <= MAX_RUN_INTENT_REQUEST_BYTES);
221250

@@ -226,6 +255,7 @@ describe('bounded RunIntent submit bodies', () => {
226255
envelope: exactJsonObject(MAX_RUN_INTENT_DISPATCH_BYTES - MAX_RUNTIME_BUNDLE_BYTES - 3),
227256
runtime: runtimeAtLimit,
228257
submissionKey: SUBMISSION_KEY,
258+
title: RUN_TITLE,
229259
}),
230260
/payloads exceed the decoded dispatch size bound/
231261
);
@@ -239,6 +269,7 @@ describe('bounded RunIntent submit bodies', () => {
239269
runtime: RUNTIME_BUNDLE,
240270
submissionKey: SUBMISSION_KEY,
241271
size: 'standard',
272+
title: RUN_TITLE,
242273
}),
243274
/intent exceeds the decoded size bound/
244275
);
@@ -252,6 +283,7 @@ describe('bounded RunIntent submit bodies', () => {
252283
runtime: exactJsonObject(MAX_RUNTIME_BUNDLE_BYTES + 1),
253284
submissionKey: SUBMISSION_KEY,
254285
size: 'standard',
286+
title: RUN_TITLE,
255287
}),
256288
/runtime bundle exceeds the decoded size bound/
257289
);

0 commit comments

Comments
 (0)