@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.
- package/README.md +12 -2
- package/dist/first-survey-source.d.ts +1 -0
- package/dist/first-survey-source.js +15 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/profile.js +1 -1
- package/dist/recommendation-source.d.ts +13 -0
- package/dist/recommendation-source.js +211 -0
- package/dist/setup-diagnostics.d.ts +2 -0
- package/dist/setup-diagnostics.js +26 -0
- package/dist/setup-executor.d.ts +8 -1
- package/dist/setup-executor.js +153 -132
- package/dist/setup-heartbeat.d.ts +268 -0
- package/dist/setup-heartbeat.js +120 -0
- package/dist/setup-runner.d.ts +3 -1
- package/dist/setup-runner.js +157 -108
- package/dist/setup-source.d.ts +3 -2
- package/dist/setup-source.js +6 -6
- package/dist/setup-store.d.ts +2 -2
- package/dist/setup-store.js +16 -6
- package/dist/setup-wire.d.ts +130 -0
- package/dist/setup-wire.js +22 -7
- package/dist/setup.d.ts +211 -11
- package/dist/setup.js +10 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/setup-runner.d.ts
CHANGED
|
@@ -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>;
|
package/dist/setup-runner.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
session
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
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
|
}
|
package/dist/setup-source.d.ts
CHANGED
|
@@ -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>;
|
package/dist/setup-source.js
CHANGED
|
@@ -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
|
}
|
package/dist/setup-store.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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>;
|
package/dist/setup-store.js
CHANGED
|
@@ -188,7 +188,7 @@ export async function setupPrivateDirectory(...parts) {
|
|
|
188
188
|
}
|
|
189
189
|
return path;
|
|
190
190
|
}
|
|
191
|
-
/**
|
|
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',
|
|
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
|
-
/**
|
|
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
|
|
239
|
-
throw new SetupError('
|
|
240
|
-
|
|
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 {
|