@userreason/cli 0.2.0 → 0.2.1

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.
@@ -0,0 +1,120 @@
1
+ const terminal = new Set(['complete', 'cancelled', 'expired']);
2
+ /** Stop waiting when this run ends, including user-supplied preparations. */
3
+ export function setupAbortable(work, signal) {
4
+ if (!signal)
5
+ return work;
6
+ signal.throwIfAborted();
7
+ return new Promise((resolve, reject) => {
8
+ const abort = () => reject(signal.reason);
9
+ signal.addEventListener('abort', abort, { once: true });
10
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
11
+ });
12
+ }
13
+ /** One lease-aware heartbeat for the entire run, independent of its current stage. */
14
+ export class SetupHeartbeat {
15
+ client;
16
+ options;
17
+ session;
18
+ timer;
19
+ pending;
20
+ stopped = false;
21
+ paused = 0;
22
+ failure;
23
+ lease;
24
+ lifetime = new AbortController();
25
+ signal;
26
+ constructor(client, options = {}) {
27
+ this.client = client;
28
+ this.options = options;
29
+ this.signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]);
30
+ }
31
+ observe(next) {
32
+ const current = this.session;
33
+ if (!current || BigInt(next.revision) >= BigInt(current.revision)) {
34
+ const heartbeat = current?.heartbeat_at && (!next.heartbeat_at || Date.parse(current.heartbeat_at) > Date.parse(next.heartbeat_at)) ? current.heartbeat_at : next.heartbeat_at;
35
+ this.session = { ...next, heartbeat_at: heartbeat };
36
+ }
37
+ if (terminal.has(this.session.phase) && !this.signal.aborted)
38
+ this.lifetime.abort(new Error('Setup ended'));
39
+ if (!this.stopped && !this.timer && !this.pending && !this.paused && !terminal.has(this.session.phase))
40
+ this.schedule(0);
41
+ return this.session;
42
+ }
43
+ get current() { return this.session; }
44
+ setLease(lease) { this.lease = lease; }
45
+ latest(fallback) { return this.observe(fallback); }
46
+ check() { this.options.signal?.throwIfAborted(); if (this.failure)
47
+ throw this.failure; }
48
+ schedule(delay = this.options.heartbeatMs ?? 10000) {
49
+ if (this.stopped || this.paused || !this.session || terminal.has(this.session.phase) || this.signal.aborted)
50
+ return;
51
+ this.timer = setTimeout(() => { this.timer = undefined; void this.pulse(); }, delay);
52
+ }
53
+ async pulse() {
54
+ if (this.pending)
55
+ return this.pending;
56
+ if (this.stopped || this.paused || this.signal.aborted || !this.session || terminal.has(this.session.phase))
57
+ return;
58
+ if (this.timer) {
59
+ clearTimeout(this.timer);
60
+ this.timer = undefined;
61
+ }
62
+ const session = this.session;
63
+ this.pending = setupAbortable(this.client.heartbeat({ schema_version: 1, setup_id: this.client.identity.setup_id, executor_generation: session.executor_generation,
64
+ operation_id: this.lease?.operation_id ?? null, lease_generation: this.lease?.lease_generation ?? null }), this.signal)
65
+ .then(value => { this.observe(value); })
66
+ .catch(error => { if (!this.stopped && !this.options.signal?.aborted) {
67
+ this.failure = error;
68
+ this.lifetime.abort(error);
69
+ } })
70
+ .finally(() => { this.pending = undefined; this.schedule(); });
71
+ return this.pending;
72
+ }
73
+ /** Finish the old lease's in-flight heartbeat before closing it in a report. */
74
+ async duringReport(operation) {
75
+ this.paused++;
76
+ if (this.timer) {
77
+ clearTimeout(this.timer);
78
+ this.timer = undefined;
79
+ }
80
+ try {
81
+ await this.pending;
82
+ this.lease = undefined;
83
+ return await operation();
84
+ }
85
+ finally {
86
+ this.paused--;
87
+ this.schedule();
88
+ }
89
+ }
90
+ async stop() {
91
+ this.stopped = true;
92
+ if (this.timer)
93
+ clearTimeout(this.timer);
94
+ this.lifetime.abort(new Error('Setup run ended'));
95
+ await this.pending;
96
+ }
97
+ }
98
+ /** Observe all responses; cancel outstanding reads/source work when the run ends. */
99
+ export function observeSetupClient(client, heartbeat, signal) {
100
+ return new Proxy(client, { get(target, key, receiver) {
101
+ const value = Reflect.get(target, key, target);
102
+ if (typeof value !== 'function')
103
+ return value;
104
+ if (key === 'withSignal')
105
+ return value.bind(target);
106
+ return async (...args) => {
107
+ // Reports settle already-recorded effects, including a cancelled operation.
108
+ if (key !== 'report')
109
+ signal?.throwIfAborted();
110
+ // Acknowledged mutations remain acknowledged if cancellation races their
111
+ // response. Reads and the long source upload can stop waiting immediately.
112
+ const requestSignal = ['poll', 'discovery', 'sourceInfo', 'sourceUpload'].includes(String(key)) ? signal : undefined;
113
+ const result = await setupAbortable(Reflect.apply(value, target, args), requestSignal);
114
+ const view = result && typeof result === 'object' && 'session' in result ? result.session : result;
115
+ if (view && typeof view === 'object' && 'phase' in view && 'revision' in view && typeof view.phase === 'string' && typeof view.revision === 'string')
116
+ heartbeat.observe(view);
117
+ return result;
118
+ };
119
+ } });
120
+ }
@@ -3,12 +3,14 @@ import { type SetupExecutorOptions, type SetupExecutorRuntime } from './setup-ex
3
3
  import { type PreparedSetupSource } from './setup-source.js';
4
4
  import { type SetupSessionView } from './setup-wire.js';
5
5
  export interface BrowserSetupRunnerOptions extends SetupExecutorOptions {
6
+ automaticSetup?: boolean;
7
+ preparedSource?: Promise<PreparedSetupSource | null>;
6
8
  onSourceIssue?: (code: string) => void;
7
9
  /** Local display only. Never forward this callback's source content to telemetry. */
8
10
  onSourcePrepared?: (packet: PreparedSetupSource) => void;
9
11
  onSample?: (session: SetupSessionView) => void;
10
12
  }
11
- export type BrowserSetupRunnerClient = Pick<BrowserSetupClient, 'identity' | 'poll' | 'plan' | 'claim' | 'startCommand' | 'report' | 'heartbeat' | 'reconcile' | 'discovery' | 'sourceInfo' | 'sourcePlan' | 'sourceUpload'>;
13
+ export type BrowserSetupRunnerClient = Pick<BrowserSetupClient, 'identity' | 'poll' | 'plan' | 'claim' | 'startCommand' | 'report' | 'heartbeat' | 'reconcile' | 'discovery' | 'sourceInfo' | 'sourcePlan' | 'sourceUpload'> & Partial<Pick<BrowserSetupClient, 'withSignal'>>;
12
14
  /** Browser controls consent and drafts. This runner holds only the setup capability,
13
15
  * executes reviewed local effects, and uploads the separately approved source packet. */
14
16
  export declare function runBrowserSetup(client: BrowserSetupRunnerClient, directory: string, runtime: SetupExecutorRuntime, options?: BrowserSetupRunnerOptions): Promise<SetupSessionView>;
@@ -4,6 +4,7 @@ import { BrowserSetupUnavailable } from './setup.js';
4
4
  import { runSetupExecutor } from './setup-executor.js';
5
5
  import { prepareSetupSource, restoreSetupSource, verifySetupSource } from './setup-source.js';
6
6
  import { SetupError, withSetupProjectLock } from './setup-store.js';
7
+ import { SetupHeartbeat, observeSetupClient, setupAbortable } from './setup-heartbeat.js';
7
8
  const terminal = new Set(['complete', 'cancelled', 'expired']);
8
9
  const localPhases = new Set(['configuring', 'awaiting_local_approval', 'applying']);
9
10
  function pause(milliseconds, options) {
@@ -16,127 +17,175 @@ function pause(milliseconds, options) {
16
17
  options.signal?.addEventListener('abort', abort, { once: true });
17
18
  });
18
19
  }
20
+ /** Start repository analysis as soon as the authenticated setup is bound. It
21
+ * intentionally does not hold the installation lock while a provider works. */
22
+ async function prepareEarly(client, directory, options) {
23
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
24
+ try {
25
+ let session = await client.poll();
26
+ if (session.source || !(await client.discovery()).browser_setup.source_analysis)
27
+ return;
28
+ const packet = options.preparedSource ? await setupAbortable(options.preparedSource, options.signal) ?? await prepareSetupSource(directory, randomUUID(), options.signal, true) : await prepareSetupSource(directory, randomUUID(), options.signal, true);
29
+ let conflicts = 0;
30
+ while (true) {
31
+ session = await client.poll();
32
+ if (session.source || terminal.has(session.phase))
33
+ return;
34
+ try {
35
+ session = await client.sourcePlan({ ...scope, request_id: packet.preparation_id, expected_revision: session.revision, manifest: packet.manifest, packages: packet.packages });
36
+ break;
37
+ }
38
+ catch (error) {
39
+ if (!(error instanceof SetupError) || error.code !== 'revision_conflict' || ++conflicts >= 5)
40
+ throw error;
41
+ }
42
+ }
43
+ if (session.source?.status !== 'approved' || session.source.preparation_id !== packet.preparation_id)
44
+ return;
45
+ await verifySetupSource(directory, packet, options.signal);
46
+ options.onSession?.(session);
47
+ await client.sourceUpload({ ...scope, preparation_id: packet.preparation_id, project_fingerprint: client.identity.project_fingerprint, evidence: packet.evidence });
48
+ }
49
+ catch (error) {
50
+ options.signal?.throwIfAborted();
51
+ options.onSourceIssue?.(error instanceof SetupError ? error.code : 'source_unavailable');
52
+ }
53
+ }
19
54
  /** Browser controls consent and drafts. This runner holds only the setup capability,
20
55
  * executes reviewed local effects, and uploads the separately approved source packet. */
21
56
  export async function runBrowserSetup(client, directory, runtime, options = {}) {
22
57
  // Canonicalize platform parent aliases before the executor verifies the
23
58
  // approved fingerprint. Source descendants still reject every symlink.
24
59
  directory = await realpath(directory);
60
+ const lifetime = new AbortController();
61
+ const runSignal = AbortSignal.any([lifetime.signal, ...(options.signal ? [options.signal] : [])]);
62
+ const base = client.withSignal?.(runSignal) ?? client;
63
+ const heartbeat = new SetupHeartbeat(base, { ...options, signal: runSignal });
64
+ client = observeSetupClient(base.withSignal?.(heartbeat.signal) ?? base, heartbeat, heartbeat.signal);
65
+ const suppliedSignal = options.signal;
66
+ options = { ...options, signal: heartbeat.signal };
25
67
  let polls = 0;
26
- while (true) {
27
- let session = await runSetupExecutor(client, directory, runtime, options);
28
- if (terminal.has(session.phase) || localPhases.has(session.phase))
29
- return session;
30
- const result = await withSetupProjectLock(directory, async () => {
31
- let packet, observedPreparation;
32
- let preparationAttempted = false, failedPreparationRevision, notifiedSample = false, heartbeatError;
33
- const uploadAttempts = new Set(), issues = new Set();
34
- const scope = { schema_version: 1, setup_id: client.identity.setup_id };
35
- const accept = (next) => { if (BigInt(next.revision) >= BigInt(session.revision))
36
- session = next; };
37
- const issue = (code) => { if (!issues.has(code)) {
38
- issues.add(code);
39
- options.onSourceIssue?.(code);
40
- } };
41
- let sourceAvailable = false;
42
- try {
43
- sourceAvailable = (await client.discovery()).browser_setup.source_analysis && session.capabilities.source_review;
44
- }
45
- catch (error) {
46
- if (!(error instanceof BrowserSetupUnavailable))
47
- throw error;
48
- }
49
- let heartbeat;
50
- const timer = setInterval(() => {
51
- if (heartbeat)
52
- return;
53
- heartbeat = client.heartbeat({ ...scope, executor_generation: session.executor_generation, operation_id: null, lease_generation: null })
54
- .then(accept, error => { heartbeatError = error; }).finally(() => { heartbeat = undefined; });
55
- }, options.heartbeatMs ?? 10_000);
56
- try {
57
- while (true) {
58
- options.signal?.throwIfAborted();
59
- if (heartbeatError)
60
- throw heartbeatError;
61
- options.onSession?.(session);
62
- if (terminal.has(session.phase) || localPhases.has(session.phase) || options.maxPolls !== undefined && polls++ >= options.maxPolls)
63
- return session;
64
- if (session.phase === 'agent_test' && session.sample.status === 'waiting' && !notifiedSample) {
65
- notifiedSample = true;
66
- options.onSample?.(session);
67
- }
68
- const source = session.source;
69
- // A browser retry with no preparation (for example provider config
70
- // unavailable) changes revision. Heartbeat never changes revision.
71
- if (!source && failedPreparationRevision && session.revision !== failedPreparationRevision) {
72
- preparationAttempted = false;
73
- failedPreparationRevision = undefined;
74
- issues.clear();
75
- }
76
- if (source?.preparation_id !== observedPreparation) {
77
- // Retry is a new browser decision, never an automatic second spend.
78
- if (observedPreparation && !source)
79
- preparationAttempted = false;
80
- packet = undefined;
81
- observedPreparation = source?.preparation_id;
82
- issues.clear();
83
- }
84
- if (session.phase === 'ready_for_recommendations' && !source && sourceAvailable && !preparationAttempted) {
85
- preparationAttempted = true;
86
- try {
87
- packet = await prepareSetupSource(directory, randomUUID(), options.signal);
88
- options.onSourcePrepared?.(packet);
89
- accept(await client.sourcePlan({ ...scope, request_id: packet.preparation_id, expected_revision: session.revision,
90
- manifest: packet.manifest, packages: packet.packages }));
91
- observedPreparation = session.source?.preparation_id;
68
+ const early = options.automaticSetup ? prepareEarly(client, directory, options) : Promise.resolve();
69
+ void early.catch(() => { }); // Cleanup awaits it; never leave an unhandled background rejection.
70
+ try {
71
+ while (true) {
72
+ let session;
73
+ session = await runSetupExecutor(client, directory, runtime, options, heartbeat);
74
+ await early;
75
+ if (options.automaticSetup)
76
+ session = await client.poll();
77
+ if (terminal.has(session.phase) || localPhases.has(session.phase))
78
+ return session;
79
+ const result = await withSetupProjectLock(directory, async () => {
80
+ let packet, observedPreparation;
81
+ let preparationAttempted = false, failedPreparationRevision, notifiedSample = false;
82
+ const uploadAttempts = new Set(), issues = new Set();
83
+ const scope = { schema_version: 1, setup_id: client.identity.setup_id };
84
+ const accept = (next) => { if (BigInt(next.revision) >= BigInt(session.revision))
85
+ session = next; };
86
+ const issue = (code) => { if (!issues.has(code)) {
87
+ issues.add(code);
88
+ options.onSourceIssue?.(code);
89
+ } };
90
+ let sourceAvailable = false;
91
+ try {
92
+ sourceAvailable = (await client.discovery()).browser_setup.source_analysis && session.capabilities.source_review;
93
+ }
94
+ catch (error) {
95
+ if (!(error instanceof BrowserSetupUnavailable))
96
+ throw error;
97
+ }
98
+ try {
99
+ while (true) {
100
+ heartbeat.check();
101
+ session = heartbeat.latest(session);
102
+ options.onSession?.(session);
103
+ if (terminal.has(session.phase) || localPhases.has(session.phase) || options.maxPolls !== undefined && polls++ >= options.maxPolls)
104
+ return session;
105
+ if (session.phase === 'agent_test' && session.sample.status === 'waiting' && !notifiedSample) {
106
+ notifiedSample = true;
107
+ options.onSample?.(session);
92
108
  }
93
- catch (error) {
94
- options.signal?.throwIfAborted();
95
- failedPreparationRevision = session.revision;
96
- issue(error instanceof SetupError ? error.code : 'source_unavailable');
109
+ const source = session.source;
110
+ // A browser retry with no preparation (for example provider config
111
+ // unavailable) changes revision. Heartbeat never changes revision.
112
+ if (!source && failedPreparationRevision && session.revision !== failedPreparationRevision) {
113
+ preparationAttempted = false;
114
+ failedPreparationRevision = undefined;
115
+ issues.clear();
97
116
  }
98
- }
99
- else if (source?.status === 'approved' && sourceAvailable && !uploadAttempts.has(source.preparation_id)) {
100
- uploadAttempts.add(source.preparation_id);
101
- try {
102
- const info = await client.sourceInfo();
103
- accept(info.session);
104
- const approved = info.preparation, current = session.source;
105
- if (!approved || current?.status !== 'approved' || approved.preparation_id !== source.preparation_id ||
106
- approved.manifest.packet_digest !== current.packet_digest || approved.configuration.configuration_fingerprint !== current.provider_fingerprint)
107
- throw new SetupError('setup_source_changed', 'Review a new source packet in the browser.');
108
- if (Date.parse(current.expires_at) <= Date.now())
109
- throw new SetupError('planning_consent_expired', 'Review source consent again in the browser.');
110
- // Resume never trusts a stale in-memory packet or stores raw source.
111
- packet = packet?.preparation_id === approved.preparation_id ? packet : await restoreSetupSource(directory, approved, options.signal);
112
- options.onSourcePrepared?.(packet);
113
- await verifySetupSource(directory, packet, options.signal);
114
- if (heartbeatError)
115
- throw heartbeatError;
116
- if (terminal.has(session.phase) || session.source?.preparation_id !== approved.preparation_id || session.source.status !== 'approved')
117
- throw new SetupError('setup_source_changed', 'Source approval changed before upload.');
118
- accept(await client.sourceUpload({ ...scope, preparation_id: packet.preparation_id,
119
- project_fingerprint: client.identity.project_fingerprint, evidence: packet.evidence }));
117
+ if (source?.preparation_id !== observedPreparation) {
118
+ // Retry is a new browser decision, never an automatic second spend.
119
+ if (observedPreparation && !source)
120
+ preparationAttempted = false;
120
121
  packet = undefined;
122
+ observedPreparation = source?.preparation_id;
123
+ issues.clear();
124
+ }
125
+ if (session.phase === 'ready_for_recommendations' && !source && sourceAvailable && !preparationAttempted) {
126
+ preparationAttempted = true;
127
+ try {
128
+ packet = await prepareSetupSource(directory, randomUUID(), options.signal, options.automaticSetup);
129
+ options.onSourcePrepared?.(packet);
130
+ accept(await client.sourcePlan({ ...scope, request_id: packet.preparation_id, expected_revision: session.revision,
131
+ manifest: packet.manifest, packages: packet.packages }));
132
+ observedPreparation = session.source?.preparation_id;
133
+ }
134
+ catch (error) {
135
+ options.signal?.throwIfAborted();
136
+ failedPreparationRevision = session.revision;
137
+ issue(error instanceof SetupError ? error.code : 'source_unavailable');
138
+ }
121
139
  }
122
- catch (error) {
123
- options.signal?.throwIfAborted();
124
- issue(error instanceof SetupError ? error.code : 'source_unavailable');
125
- // A dropped reply can hide an accepted upload. Poll instead of
126
- // resending or collecting a different packet under old consent.
140
+ else if (source?.status === 'approved' && sourceAvailable && !uploadAttempts.has(source.preparation_id)) {
141
+ uploadAttempts.add(source.preparation_id);
142
+ try {
143
+ const info = await client.sourceInfo();
144
+ accept(info.session);
145
+ const approved = info.preparation, current = session.source;
146
+ if (!approved || current?.status !== 'approved' || approved.preparation_id !== source.preparation_id ||
147
+ approved.manifest.packet_digest !== current.packet_digest || approved.configuration.configuration_fingerprint !== current.provider_fingerprint)
148
+ throw new SetupError('setup_source_changed', 'Review a new source packet in the browser.');
149
+ if (Date.parse(current.expires_at) <= Date.now())
150
+ throw new SetupError('planning_consent_expired', 'Review source consent again in the browser.');
151
+ // Resume never trusts a stale in-memory packet or stores raw source.
152
+ packet = packet?.preparation_id === approved.preparation_id ? packet : await restoreSetupSource(directory, approved, options.signal, options.automaticSetup);
153
+ options.onSourcePrepared?.(packet);
154
+ await verifySetupSource(directory, packet, options.signal);
155
+ heartbeat.check();
156
+ if (terminal.has(session.phase) || session.source?.preparation_id !== approved.preparation_id || session.source.status !== 'approved')
157
+ throw new SetupError('setup_source_changed', 'Source approval changed before upload.');
158
+ accept(await client.sourceUpload({ ...scope, preparation_id: packet.preparation_id,
159
+ project_fingerprint: client.identity.project_fingerprint, evidence: packet.evidence }));
160
+ packet = undefined;
161
+ }
162
+ catch (error) {
163
+ options.signal?.throwIfAborted();
164
+ issue(error instanceof SetupError ? error.code : 'source_unavailable');
165
+ // A dropped reply can hide an accepted upload. Poll instead of
166
+ // resending or collecting a different packet under old consent.
167
+ }
127
168
  }
169
+ await pause(options.pollMs ?? 2_000, options);
170
+ accept(await client.poll());
128
171
  }
129
- await pause(options.pollMs ?? 2_000, options);
130
- accept(await client.poll());
131
172
  }
132
- }
133
- finally {
134
- clearInterval(timer);
135
- await heartbeat;
136
- packet = undefined;
137
- }
138
- });
139
- if (!localPhases.has(result.phase) || options.maxPolls !== undefined)
140
- return result;
173
+ finally {
174
+ packet = undefined;
175
+ }
176
+ });
177
+ if (!localPhases.has(result.phase) || options.maxPolls !== undefined)
178
+ return result;
179
+ }
180
+ }
181
+ catch (error) {
182
+ const current = heartbeat.current;
183
+ if (!suppliedSignal?.aborted && current && terminal.has(current.phase))
184
+ return current;
185
+ throw error;
186
+ }
187
+ finally {
188
+ lifetime.abort(new Error('Setup run ended'));
189
+ await Promise.allSettled([early, heartbeat.stop()]);
141
190
  }
142
191
  }
@@ -1,6 +1,7 @@
1
1
  import { type LocalEvidence } from './first-survey-source.js';
2
2
  /** One memory-only packet. The wire manifest contains hashes/ranges, never source text. */
3
3
  export interface PreparedSetupSource {
4
+ expanded?: boolean;
4
5
  preparation_id: string;
5
6
  manifest: {
6
7
  packet_digest: string;
@@ -24,8 +25,8 @@ export interface PreparedSetupSource {
24
25
  evidence: LocalEvidence[];
25
26
  }
26
27
  /** Reuse the bounded application collector without Git, provider calls or local packet persistence. */
27
- export declare function prepareSetupSource(directory: string, preparationId?: string, signal?: AbortSignal): Promise<PreparedSetupSource>;
28
+ export declare function prepareSetupSource(directory: string, preparationId?: string, signal?: AbortSignal, expanded?: boolean): Promise<PreparedSetupSource>;
28
29
  /** An approved preparation is rebuilt with its original snapshot ID; no raw source needs a receipt. */
29
- export declare function restoreSetupSource(directory: string, approved: Pick<PreparedSetupSource, 'preparation_id' | 'manifest' | 'packages'>, signal?: AbortSignal): Promise<PreparedSetupSource>;
30
+ export declare function restoreSetupSource(directory: string, approved: Pick<PreparedSetupSource, 'preparation_id' | 'manifest' | 'packages'>, signal?: AbortSignal, expanded?: boolean): Promise<PreparedSetupSource>;
30
31
  /** Revalidate immediately before upload; only the immutable reviewed memory packet may leave the process. */
31
32
  export declare function verifySetupSource(directory: string, packet: PreparedSetupSource, signal?: AbortSignal): Promise<void>;
@@ -4,13 +4,13 @@ import { setupDigest } from './setup-files.js';
4
4
  import { SetupError } from './setup-store.js';
5
5
  import { parseSetupSourceManifest, parseSetupSourceUploadInput } from './setup-wire.js';
6
6
  /** Reuse the bounded application collector without Git, provider calls or local packet persistence. */
7
- export async function prepareSetupSource(directory, preparationId = randomUUID(), signal) {
7
+ export async function prepareSetupSource(directory, preparationId = randomUUID(), signal, expanded = false) {
8
8
  signal?.throwIfAborted();
9
- const preview = await collectFirstSurveySource({ root: directory, snapshotId: preparationId, recommendations: true, signal });
9
+ const preview = await collectFirstSurveySource({ root: directory, snapshotId: preparationId, recommendations: true, benchmark: expanded, signal });
10
10
  await verifyFirstSurveySource(directory, preview.evidence);
11
11
  const evidence = preview.evidence, packages = preview.packages;
12
12
  const prepared = {
13
- preparation_id: preparationId, evidence, packages,
13
+ preparation_id: preparationId, evidence, packages, expanded,
14
14
  manifest: {
15
15
  packet_digest: setupDigest({ evidence, packages }), snapshot_id: preparationId,
16
16
  files: evidence.map(item => ({ path: item.path, file_hash: item.file_hash, excerpt_hash: item.excerpt_hash,
@@ -26,8 +26,8 @@ export async function prepareSetupSource(directory, preparationId = randomUUID()
26
26
  return prepared;
27
27
  }
28
28
  /** An approved preparation is rebuilt with its original snapshot ID; no raw source needs a receipt. */
29
- export async function restoreSetupSource(directory, approved, signal) {
30
- const prepared = await prepareSetupSource(directory, approved.preparation_id, signal);
29
+ export async function restoreSetupSource(directory, approved, signal, expanded) {
30
+ const prepared = await prepareSetupSource(directory, approved.preparation_id, signal, expanded ?? approved.manifest.files.reduce((n, file) => n + file.bytes, 0) > 10000);
31
31
  if (setupDigest(prepared.manifest) !== setupDigest(approved.manifest) || setupDigest(prepared.packages) !== setupDigest(approved.packages))
32
32
  throw new SetupError('setup_source_changed', 'Application source or installed package metadata changed. Review a new source packet in the browser before analysis.');
33
33
  return prepared;
@@ -43,6 +43,6 @@ export async function verifySetupSource(directory, packet, signal) {
43
43
  catch {
44
44
  throw new SetupError('setup_source_changed', 'Application files changed. Review a new source packet before analysis.');
45
45
  }
46
- await restoreSetupSource(directory, packet, signal);
46
+ await restoreSetupSource(directory, packet, signal, packet.expanded);
47
47
  signal?.throwIfAborted();
48
48
  }
@@ -31,7 +31,7 @@ export declare function loadSetupSession(origin: string, setupId: string, direct
31
31
  export declare function clearSetupSession(origin: string, setupId: string): Promise<void>;
32
32
  /** Create one private level at a time; never traverse a replaced setup ancestor. */
33
33
  export declare function setupPrivateDirectory(...parts: string[]): Promise<string>;
34
- /** Explicit recovery only after a crashed process is proven gone; never steals a live lock. */
34
+ /** Recover only after a crashed process is proven gone; never steals a live lock. */
35
35
  export declare function recoverSetupProjectLock(directory: string): Promise<boolean>;
36
- /** Fail closed on an existing lock. A stale process requires reconciliation, not a timed takeover. */
36
+ /** Recover a proven-dead owner, then acquire exclusively. Live/unknown owners stay protected. */
37
37
  export declare function withSetupProjectLock<T>(directory: string, operation: () => Promise<T>): Promise<T>;
@@ -188,7 +188,7 @@ export async function setupPrivateDirectory(...parts) {
188
188
  }
189
189
  return path;
190
190
  }
191
- /** Explicit recovery only after a crashed process is proven gone; never steals a live lock. */
191
+ /** Recover only after a crashed process is proven gone; never steals a live lock. */
192
192
  export async function recoverSetupProjectLock(directory) {
193
193
  const locks = await setupPrivateDirectory('project-locks');
194
194
  const path = join(locks, `${await setupProjectFingerprint(directory)}.lock`);
@@ -213,7 +213,7 @@ export async function recoverSetupProjectLock(directory) {
213
213
  throw new Error('invalid lock');
214
214
  try {
215
215
  process.kill(value.pid, 0);
216
- throw new SetupError('setup_project_locked', 'The project lock owner is still running.');
216
+ throw new SetupError('setup_project_locked', `Another wizard is still running for this project (PID ${value.pid}). Return to its terminal and finish or stop that run before retrying.`);
217
217
  }
218
218
  catch (error) {
219
219
  if (error.code !== 'ESRCH')
@@ -225,7 +225,7 @@ export async function recoverSetupProjectLock(directory) {
225
225
  await file.close();
226
226
  }
227
227
  }
228
- /** Fail closed on an existing lock. A stale process requires reconciliation, not a timed takeover. */
228
+ /** Recover a proven-dead owner, then acquire exclusively. Live/unknown owners stay protected. */
229
229
  export async function withSetupProjectLock(directory, operation) {
230
230
  const fingerprint = await setupProjectFingerprint(directory);
231
231
  const locks = await setupPrivateDirectory('project-locks');
@@ -235,9 +235,19 @@ export async function withSetupProjectLock(directory, operation) {
235
235
  file = await open(path, 'wx', 0o600);
236
236
  }
237
237
  catch (error) {
238
- if (error.code === 'EEXIST')
239
- throw new SetupError('setup_project_locked', 'Another setup owns this project. Reconcile its local results before retrying; an expired server lease does not release this lock.');
240
- throw new SetupError('setup_lock_failed', 'Unable to lock this project for setup. Check local configuration-directory access.');
238
+ if (error.code !== 'EEXIST')
239
+ throw new SetupError('setup_lock_failed', 'Unable to lock this project for setup. Check local configuration-directory access.');
240
+ try {
241
+ await recoverSetupProjectLock(directory);
242
+ file = await open(path, 'wx', 0o600);
243
+ }
244
+ catch (recoveryError) {
245
+ if (recoveryError instanceof SetupError)
246
+ throw recoveryError;
247
+ if (recoveryError.code === 'EEXIST')
248
+ throw new SetupError('setup_project_locked', 'Another wizard acquired this project. Return to its terminal and finish or stop that run before retrying.');
249
+ throw new SetupError('setup_lock_failed', 'The existing project lock could not be verified safely. No lock or installation receipt was removed.');
250
+ }
241
251
  }
242
252
  const lockInfo = await file.stat();
243
253
  try {