firedrill-run 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -9
- package/dist/assembly.json +1 -1
- package/dist/cli/src/auth.js +41 -0
- package/dist/cli/src/main.js +7 -2
- package/dist/cli/src/runner-identity.js +29 -4
- package/dist/cli/src/simple/init.js +37 -2
- package/dist/cli/src/simple/native-tools.js +42 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,19 +9,17 @@ Use the CLI yourself, or let a coding agent operate it for you. Local execution
|
|
|
9
9
|
is not an operating-system sandbox. Approved commands run with your own user
|
|
10
10
|
permissions.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
the intended commands are:
|
|
12
|
+
## Get started
|
|
13
|
+
|
|
14
|
+
Use this version explicitly while the unversioned package still points to the
|
|
15
|
+
previous platform:
|
|
17
16
|
|
|
18
17
|
```sh
|
|
19
|
-
npx
|
|
20
|
-
npx
|
|
18
|
+
npx firedrill-run@3.0.1 init
|
|
19
|
+
npx firedrill-run@3.0.1 --help
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
`firedrill --help` of that exact installed candidate is authoritative.
|
|
22
|
+
For automation, `firedrill --help` and `--json` describe the installed version.
|
|
25
23
|
|
|
26
24
|
## Requirements
|
|
27
25
|
|
package/dist/assembly.json
CHANGED
package/dist/cli/src/auth.js
CHANGED
|
@@ -130,6 +130,15 @@ export async function login(options) {
|
|
|
130
130
|
throw new CliError('authorization_exchange_uncertain', 'The original token exchange was interrupted and cannot be safely repeated. Review Account access in the browser. Clear the local attempt with logout --local-only before starting another.', 2);
|
|
131
131
|
if (!pending) {
|
|
132
132
|
const grant = await jsonRequest(new URL(config.device_authorization_endpoint), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: config.client_id }), signal: options.signal });
|
|
133
|
+
if (grant.status === 429) {
|
|
134
|
+
// No grant exists yet. Report a definitive refusal without creating a
|
|
135
|
+
// second grant or replaying an unknown device-authorization POST.
|
|
136
|
+
const wait = grant.retryAfterMs;
|
|
137
|
+
const advice = wait !== undefined && Number.isFinite(wait) && wait <= 3_600_000
|
|
138
|
+
? ` Wait at least ${Math.max(1, Math.ceil(wait / 1000))} seconds before starting sign-in again.`
|
|
139
|
+
: ' Wait before starting sign-in again; do not repeatedly restart it.';
|
|
140
|
+
throw new CliError('device_authorization_rate_limited', 'Browser sign-in is temporarily busy.' + advice, 2);
|
|
141
|
+
}
|
|
133
142
|
if (grant.status !== 200)
|
|
134
143
|
throw new CliError('device_authorization_failed', 'Browser sign-in could not be started.');
|
|
135
144
|
const value = grant.body;
|
|
@@ -166,6 +175,7 @@ export async function login(options) {
|
|
|
166
175
|
: 'Run the same command to resume if interrupted.'}\n`);
|
|
167
176
|
if (launchBrowser)
|
|
168
177
|
await openBrowser(pending.verification_url);
|
|
178
|
+
let rateRefusals = 0;
|
|
169
179
|
while (Date.now() < pending.expires_at * 1000) {
|
|
170
180
|
await sleep(Math.min(Math.max(0, pending.next_poll_at - Date.now()), Math.max(0, pending.expires_at * 1000 - Date.now())), undefined, { signal: options.signal });
|
|
171
181
|
options.signal.throwIfAborted();
|
|
@@ -183,7 +193,38 @@ export async function login(options) {
|
|
|
183
193
|
return finishAuthorization(saved);
|
|
184
194
|
}
|
|
185
195
|
const error = result.body && typeof result.body === 'object' ? result.body.error : undefined;
|
|
196
|
+
if (result.status === 429) {
|
|
197
|
+
// A complete, parsed HTTP refusal is distinct from transport/parse
|
|
198
|
+
// failure or a response that may contain exchanged credentials. Those
|
|
199
|
+
// uncertain cases keep the polling marker and are never replayed.
|
|
200
|
+
const body = result.body;
|
|
201
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
202
|
+
|| ['access_token', 'refresh_token', 'id_token', 'user'].some(key => key in body))
|
|
203
|
+
throw new CliError('authorization_exchange_uncertain', 'The authorization response was not confirmed. Review Account access before clearing the original local attempt. No exchange was retried.', 2);
|
|
204
|
+
rateRefusals += 1;
|
|
205
|
+
const interval = error === 'slow_down' ? Math.min(3600, pending.interval + 5) : pending.interval;
|
|
206
|
+
const backoff = Math.min(60_000, 5_000 * 2 ** Math.min(rateRefusals - 1, 4));
|
|
207
|
+
const minimumWait = Math.max(interval * 1000, result.retryAfterMs ?? backoff);
|
|
208
|
+
// Never retry earlier than Retry-After or extend the original grant.
|
|
209
|
+
// An advertised wait beyond that lifetime reaches expiry, not a retry.
|
|
210
|
+
const deadline = pending.expires_at * 1000;
|
|
211
|
+
const nextPollAt = Math.min(deadline, Date.now() + minimumWait + Math.floor(Math.random() * 1000));
|
|
212
|
+
pending = { ...pending, state: 'waiting', interval, next_poll_at: nextPollAt };
|
|
213
|
+
await savePendingDevice(pending, options.signal);
|
|
214
|
+
const waitSeconds = Math.max(1, Math.ceil((nextPollAt - Date.now()) / 1000));
|
|
215
|
+
if (options.json)
|
|
216
|
+
process.stdout.write(JSON.stringify({ type: 'authorization_waiting', authorization_id: pending.id,
|
|
217
|
+
reason: 'rate_limited', retry_in_seconds: nextPollAt < deadline ? waitSeconds : null,
|
|
218
|
+
expires_in: Math.max(0, Math.ceil((deadline - Date.now()) / 1000)),
|
|
219
|
+
same_authorization: true, new_device_grant: false }) + '\n');
|
|
220
|
+
else
|
|
221
|
+
process.stderr.write(nextPollAt < deadline
|
|
222
|
+
? `Sign-in is temporarily busy. Waiting ${waitSeconds} seconds before checking the same authorization again.\n`
|
|
223
|
+
: 'Sign-in is temporarily busy. Its required wait reaches the original authorization expiry; no new authorization will be started automatically.\n');
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
186
226
|
if (error === 'authorization_pending' || error === 'slow_down') {
|
|
227
|
+
rateRefusals = 0;
|
|
187
228
|
// RFC 8628: slow_down permanently adds 5 seconds to the polling interval.
|
|
188
229
|
const interval = error === 'slow_down' ? Math.min(3600, pending.interval + 5) : pending.interval;
|
|
189
230
|
pending = { ...pending, state: 'waiting', interval, next_poll_at: Date.now() + interval * 1000 };
|
package/dist/cli/src/main.js
CHANGED
|
@@ -745,12 +745,17 @@ export async function main() {
|
|
|
745
745
|
if (args[0] === 'init') {
|
|
746
746
|
if (Object.keys(parsed.values).some(key => !['json', 'help', 'workspace', 'no-browser', 'open'].includes(key)))
|
|
747
747
|
throw new CliError('arguments_invalid', 'Use firedrill init with optional --session-only or --persistent, --workspace <id>, --no-browser, --open and --json.', 2);
|
|
748
|
-
await initCommand(args.slice(1), { json, signal: controller.signal, workspace: parsed.values.workspace,
|
|
748
|
+
const initialization = await initCommand(args.slice(1), { json, signal: controller.signal, workspace: parsed.values.workspace,
|
|
749
749
|
openBrowser: !parsed.values['no-browser'], explicitOpen, mode: credentialMode.mode }, emit);
|
|
750
750
|
if (companion) {
|
|
751
751
|
// Session-only credentials live only in this process: keep serving until
|
|
752
752
|
// a signal or logout (companion shutdown) stops it.
|
|
753
|
-
emit('companion_running', { endpoint_ready: true, mode: 'session'
|
|
753
|
+
emit('companion_running', { endpoint_ready: true, mode: 'session', initialization_state: initialization.state,
|
|
754
|
+
initialization_complete: initialization.state === 'ready',
|
|
755
|
+
command_phase_complete: true,
|
|
756
|
+
workspace_id: initialization.state === 'ready' ? initialization.workspace_id : null,
|
|
757
|
+
process_lifetime: 'until_logout_or_signal',
|
|
758
|
+
next_command: initialization.state === 'workspace_pending' ? 'firedrill workspace list --session-only --json' : null }, 'Session-only companion running. Keep this terminal open; other firedrill commands use it. Stop it with Ctrl-C or firedrill logout.');
|
|
754
759
|
if (!controller.signal.aborted)
|
|
755
760
|
await once(controller.signal, 'abort');
|
|
756
761
|
emit('companion_stopping', { reason: signalled ? 'signal' : 'shutdown' }, 'Stopping the companion. In-memory credentials are erased on close.');
|
|
@@ -101,7 +101,11 @@ async function lock(scope, key, signal, work) {
|
|
|
101
101
|
const directory = join(await privateDirectory(), `runner-lock-${identifier(scope.owner)}-${identifier(scope.workspace)}-${identifier(key)}`);
|
|
102
102
|
return withBootstrapProcessLock(directory, signal, work);
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
/** Read only the original signing material and verify its retained public identity.
|
|
105
|
+
* A metadata journal can outlive a session-only process's in-memory key.
|
|
106
|
+
* Reading that journal alone therefore does not make a runner usable.
|
|
107
|
+
*/
|
|
108
|
+
async function privateSigningKey(value) {
|
|
105
109
|
try {
|
|
106
110
|
const encoded = MEMORY_KEYS.get(value.key) ?? (await store(value, value.key)).getPassword();
|
|
107
111
|
if (!encoded || encoded.length > 2048)
|
|
@@ -112,6 +116,15 @@ async function proof(value, payload) {
|
|
|
112
116
|
const publicKey = createPublicKey(key).export({ format: 'jwk' });
|
|
113
117
|
if (!publicKey.x || Buffer.from(publicKey.x, 'base64url').toString('base64') !== value.enrollment.public_key)
|
|
114
118
|
throw new Error();
|
|
119
|
+
return key;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new CliError('runner_key_unavailable', 'This runner key could not be read from the OS credential store. It was not regenerated.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function proof(value, payload) {
|
|
126
|
+
try {
|
|
127
|
+
const key = await privateSigningKey(value);
|
|
115
128
|
return { payload, signature: sign(null, Buffer.from('firedrill.local-runner-proof.v1\n' + canonical(payload)), key).toString('base64') };
|
|
116
129
|
}
|
|
117
130
|
catch {
|
|
@@ -513,17 +526,29 @@ export async function withNativeExecutor(scope, key, signal, work) {
|
|
|
513
526
|
* identity on this machine. Enrollment stays an explicit, consented step.
|
|
514
527
|
*/
|
|
515
528
|
export async function nativeExecutorKey(scope, requested, signal) {
|
|
529
|
+
const usableKey = async (key) => {
|
|
530
|
+
const retained = await currentLocalRunner(scope, key, signal);
|
|
531
|
+
// Recheck the local journal against the exact validated server receipt.
|
|
532
|
+
// No key is generated, persisted, signed or sent while selecting a device.
|
|
533
|
+
const value = await saved(scope, retained.device_key);
|
|
534
|
+
matches(value, retained);
|
|
535
|
+
signal.throwIfAborted();
|
|
536
|
+
await privateSigningKey(value);
|
|
537
|
+
signal.throwIfAborted();
|
|
538
|
+
return retained.device_key;
|
|
539
|
+
};
|
|
540
|
+
// Explicit requests never fall back to a different runner or enroll a key.
|
|
516
541
|
if (requested !== undefined)
|
|
517
|
-
return (
|
|
542
|
+
return usableKey(identifier(requested));
|
|
518
543
|
const usable = [];
|
|
519
544
|
for (let page = 1; page <= 5; page++) {
|
|
520
545
|
const { directory, candidates } = await localRunnerSelectionPage(scope, page, signal);
|
|
521
546
|
for (const candidate of candidates) {
|
|
522
547
|
try {
|
|
523
|
-
usable.push(
|
|
548
|
+
usable.push(await usableKey(candidate.device_key));
|
|
524
549
|
}
|
|
525
550
|
catch (error) {
|
|
526
|
-
if (!(error instanceof CliError) || !['runner_identity_not_local', 'runner_scope_changed', 'runner_access_removed'].includes(error.code))
|
|
551
|
+
if (!(error instanceof CliError) || !['runner_identity_not_local', 'runner_scope_changed', 'runner_access_removed', 'runner_key_unavailable'].includes(error.code))
|
|
527
552
|
throw error;
|
|
528
553
|
}
|
|
529
554
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { api, login } from '../auth.js';
|
|
2
|
-
import { CliError } from '../http.js';
|
|
2
|
+
import { APP_ORIGIN, CliError } from '../http.js';
|
|
3
3
|
import { terminalText } from '../prompts.js';
|
|
4
4
|
import { selectedWorkspace } from '../world-access.js';
|
|
5
5
|
import { machineReport, machineText } from './status.js';
|
|
@@ -40,7 +40,41 @@ export async function initCommand(args, options, emit) {
|
|
|
40
40
|
throw error;
|
|
41
41
|
session = await login({ json: options.json, open: options.openBrowser, explicitOpen: options.explicitOpen, signal: options.signal });
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
let scope;
|
|
44
|
+
try {
|
|
45
|
+
scope = await selectedWorkspace({ workspace: options.workspace, interactive, signal: options.signal, openBrowser });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Device authorization already succeeded. A durable workspace discovery or
|
|
49
|
+
// explicit setup step must not erase the only copy of that signed-in session.
|
|
50
|
+
// Only session-only init owns a companion lifetime; persistent commands and
|
|
51
|
+
// authorization/transport/cancellation failures retain their normal errors.
|
|
52
|
+
if (mode !== 'session' || options.signal.aborted || !(error instanceof CliError)
|
|
53
|
+
|| !['workspace_discovery_pending', 'workspace_setup_required'].includes(error.code))
|
|
54
|
+
throw error;
|
|
55
|
+
const reason = error.code === 'workspace_discovery_pending' ? 'workspace_discovery_pending' : 'workspace_setup_required';
|
|
56
|
+
emit('init_pending', {
|
|
57
|
+
state: 'workspace_pending', reason, authenticated: true, initialization_complete: false,
|
|
58
|
+
account: { id: session.user.id, email: session.user.email }, workspace_id: null, credential_mode: 'session',
|
|
59
|
+
companion_required: true, process_lifetime: 'until_logout_or_signal',
|
|
60
|
+
next_command: 'firedrill workspace list --session-only --json',
|
|
61
|
+
select_command_template: 'firedrill workspace use <workspace-id> --session-only --json',
|
|
62
|
+
status_command: 'firedrill status --session-only --json',
|
|
63
|
+
...(reason === 'workspace_setup_required' ? { browser_url: new URL('/onboarding', APP_ORIGIN).href,
|
|
64
|
+
action_required: 'create_or_select_workspace_with_person_approval' } : {}),
|
|
65
|
+
uploaded: false, model_called: false,
|
|
66
|
+
}, [
|
|
67
|
+
`Signed in as ${terminalText(session.user.email)}.`,
|
|
68
|
+
reason === 'workspace_discovery_pending' ? 'Workspace access is still being checked.' : 'A workspace still needs to be created or selected.',
|
|
69
|
+
'Initialization is not complete. Keep this terminal open to retain the session-only sign-in.',
|
|
70
|
+
'In another terminal, run firedrill workspace list --session-only, then firedrill workspace use <workspace-id> --session-only.',
|
|
71
|
+
'Use only a workspace returned for this account. Normal organization authorization and person approvals still apply.',
|
|
72
|
+
...(reason === 'workspace_setup_required' ? [`Workspace setup: ${new URL('/onboarding', APP_ORIGIN).href}`] : []),
|
|
73
|
+
'After selecting it, firedrill status --session-only shows the current account, workspace and machine.',
|
|
74
|
+
'Do not start another init while this companion is running. Stop with Ctrl-C or firedrill logout.',
|
|
75
|
+
].join('\n'));
|
|
76
|
+
return { state: 'workspace_pending', reason };
|
|
77
|
+
}
|
|
44
78
|
const machine = await machineReport(options.signal);
|
|
45
79
|
emit('init', {
|
|
46
80
|
account: { id: session.user.id, email: session.user.email }, workspace_id: scope.workspace, credential_mode: mode, machine,
|
|
@@ -58,4 +92,5 @@ export async function initCommand(args, options, emit) {
|
|
|
58
92
|
...NEXT.map((step, index) => ` ${index + 1}. ${step.command}\n ${step.note}`),
|
|
59
93
|
'Nothing was uploaded and no model was called.',
|
|
60
94
|
].join('\n'));
|
|
95
|
+
return { state: 'ready', workspace_id: scope.workspace };
|
|
61
96
|
}
|
|
@@ -265,11 +265,17 @@ async function start(args, options, emit) {
|
|
|
265
265
|
}
|
|
266
266
|
const reviewed = setup;
|
|
267
267
|
let children = items ?? await setupItems(scope, { setup: reviewed, signal });
|
|
268
|
-
|
|
268
|
+
const recoveringAdmission = options.request !== undefined && setup.state === 'needs_attention'
|
|
269
|
+
&& setup.approved_at !== null && setup.stop_requested_at === null;
|
|
270
|
+
if ((setup.state === 'reviewed' && setup.approved_at === null && setup.stop_requested_at === null) || recoveringAdmission) {
|
|
269
271
|
// Quoting reserves nothing. Root keeps one estimate per session and replays it for its quote key only,
|
|
270
272
|
// so a later run admits exactly the estimate the human saw. An approval holds only for that key.
|
|
271
273
|
const original = setup.quote_request_key;
|
|
272
274
|
const given = options.quote?.toLowerCase();
|
|
275
|
+
if (recoveringAdmission && (original === null || setup.bundle_digest === null))
|
|
276
|
+
throw new CliError('tool_start_unconfirmed', 'The original approved estimate could not be confirmed. Inspect this same tool session; do not create a replacement request.', 2);
|
|
277
|
+
if (recoveringAdmission && (ids.length || options.file !== undefined || options.seconds !== undefined || options.service !== undefined))
|
|
278
|
+
throw new CliError('arguments_invalid', `Continue this original tool session without changing its selection or duration: ${recovery} --usage-quote ${original} --approve-tool-change.`, 2);
|
|
273
279
|
if (given !== undefined && original !== null && given !== original)
|
|
274
280
|
throw new CliError('idempotency_conflict', 'That quote key is not this setup’s original estimate. Read the original setup before continuing. Nothing was admitted.', 2);
|
|
275
281
|
const quoteKey = original ?? given ?? randomUUID();
|
|
@@ -280,24 +286,37 @@ async function start(args, options, emit) {
|
|
|
280
286
|
notice = `This session's estimate was quoted under ${original}, not the key given, so it needs its own approval. `;
|
|
281
287
|
const quoted = children;
|
|
282
288
|
const quotes = await retryTransient(() => quoteSetup(scope, { setup: reviewed, items: quoted, requestKey: quoteKey, signal }), { signal, attempts: 3 });
|
|
283
|
-
if (
|
|
289
|
+
if (recoveringAdmission && (quotes.quote_key !== original || quotes.bundle_digest !== setup.bundle_digest))
|
|
290
|
+
throw new CliError('idempotency_conflict', 'The retained approved estimate changed. Nothing was retried. Inspect the original tool session.', 2);
|
|
291
|
+
// The server replays an existing approval without replacing its quote.
|
|
292
|
+
// Only a never-approved review must still meet the initial quote deadline.
|
|
293
|
+
if (!recoveringAdmission && !quotes.quotes.every(live))
|
|
284
294
|
throw expiredReview(setup.id);
|
|
285
|
-
let approved = options.approve === true && (
|
|
295
|
+
let approved = options.approve === true && (recoveringAdmission ? given === original
|
|
296
|
+
: !quotes.quotes.some(quote => quote.credit_approval_required) || (original !== null && given === original));
|
|
286
297
|
const interactive = tty(options);
|
|
287
298
|
if (!approved && interactive)
|
|
288
299
|
process.stderr.write(setupText(setup, children) + '\n');
|
|
289
|
-
emit('tool_start_quote', quotes, `${notice}${quoteText(quotes, children)}`);
|
|
300
|
+
emit('tool_start_quote', quotes, `${recoveringAdmission ? 'Continuing the original approved tool session with its retained estimate. Existing tools are not replaced.\n' : notice}${quoteText(quotes, children)}`);
|
|
290
301
|
if (!approved && interactive) {
|
|
291
|
-
approved = await selectOne(
|
|
292
|
-
{
|
|
293
|
-
{ value:
|
|
302
|
+
approved = await selectOne(recoveringAdmission ? 'Continue this original approved tool session?'
|
|
303
|
+
: `Start ${children.length} tool${children.length === 1 ? '' : 's'} for ${duration(setup.duration_seconds)} each?`, [
|
|
304
|
+
{ value: false, label: 'Not now', detail: recoveringAdmission ? 'The original session and existing allocations stay unchanged.' : 'Nothing is reserved or started.' },
|
|
305
|
+
{ value: true, label: recoveringAdmission ? 'Continue original session' : 'Start tools',
|
|
306
|
+
detail: recoveringAdmission ? 'Uses the same approved selection and estimate. Existing tool allocations are retained.'
|
|
307
|
+
: 'Reserves up to the estimate above. The tools run in the cloud, not on this machine.' },
|
|
294
308
|
], signal);
|
|
295
309
|
}
|
|
296
310
|
if (!approved) {
|
|
297
|
-
throw new CliError('tool_change_consent_required',
|
|
311
|
+
throw new CliError('tool_change_consent_required', (recoveringAdmission
|
|
312
|
+
? 'The original approval and any existing allocations were left unchanged. After the human approves continuing this same session: '
|
|
313
|
+
: `${notice}Nothing was reserved or started. Show the human this estimate; after they approve it: `)
|
|
298
314
|
+ `${recovery} --usage-quote ${quoteKey} --approve-tool-change`, 2);
|
|
299
315
|
}
|
|
300
|
-
|
|
316
|
+
// Recovery does not invent another credit approval. The backend verifies
|
|
317
|
+
// and uses its durable original approval_digest and per-child request keys.
|
|
318
|
+
const creditApproval = recoveringAdmission ? false
|
|
319
|
+
: await approveCredits(quotes.quotes, options, interactive, `${recovery} --usage-quote ${quoteKey} --approve-tool-change`);
|
|
301
320
|
try {
|
|
302
321
|
setup = await retryTransient(() => admitSetup(scope, { quotes, signal, creditApproval }), { signal, attempts: 3 });
|
|
303
322
|
}
|
|
@@ -340,6 +359,20 @@ async function start(args, options, emit) {
|
|
|
340
359
|
}
|
|
341
360
|
emit('tool_session', { ...setup, items: children }, setupText(setup, children)
|
|
342
361
|
+ (settled(setup, children) ? '' : `\nFollow it: firedrill tools session ${setup.id}`));
|
|
362
|
+
if (options.follow) {
|
|
363
|
+
// A completed follow is an observation, not proof that admission or every
|
|
364
|
+
// child succeeded. Preserve its full final receipt before the typed error.
|
|
365
|
+
// Normal stopped/expired history and user cancellation are not failures.
|
|
366
|
+
const failed = children.filter(item => item.instance_state === 'failed' || item.instance_state === 'rejected'
|
|
367
|
+
|| item.failure_code !== null && item.admission_state !== 'cancelled'
|
|
368
|
+
&& !['stopped', 'canceled', 'cancelled', 'expired'].includes(item.instance_state ?? ''));
|
|
369
|
+
if (failed.length)
|
|
370
|
+
throw new CliError('tool_start_failed', `${failed.length} tool${failed.length === 1 ? '' : 's'} failed in this original session. Inspect its retained result with firedrill tools session ${setup.id}. Nothing was restarted.`, 1);
|
|
371
|
+
if (setup.stop_requested_at === null && (setup.state === 'needs_attention' || children.some(item => item.admission_state === 'needs_attention')))
|
|
372
|
+
throw new CliError('tool_start_needs_attention', `This original tool session needs attention. Inspect it with firedrill tools session ${setup.id} before explicitly continuing its original request. Nothing was retried.`, 1);
|
|
373
|
+
if (!settled(setup, children))
|
|
374
|
+
throw new CliError('tool_start_pending', `This original tool session is still preparing after the follow window. Read its progress with firedrill tools session ${setup.id}. This is not a startup failure, and nothing was restarted.`, 1);
|
|
375
|
+
}
|
|
343
376
|
}
|
|
344
377
|
/** `tools runnable [--search <text>] [--page <n>]`: saved versions and the lane each service would start in. */
|
|
345
378
|
async function runnable(options, emit) {
|