@awak-app/simy-cli 0.5.37 → 0.5.39

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,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readSession, isSessionValid } from '../session-store.js';
3
+ import { webApiHeaders, webApiUrl } from '../web-api.js';
4
+
5
+ const MAX_REQUEST_BYTES = 24 * 1024;
6
+ const MAX_RESPONSE_BYTES = 96 * 1024;
7
+ const TASK_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ export class ActionAssociationApiError extends Error {
10
+ constructor(code) {
11
+ super('Action association service is unavailable.');
12
+ this.name = 'ActionAssociationApiError';
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ async function boundedJson(response) {
18
+ const advertised = response.headers?.get?.('content-length');
19
+ if (advertised !== null && advertised !== undefined && (!/^\d+$/.test(advertised) || Number(advertised) > MAX_RESPONSE_BYTES)) {
20
+ throw new ActionAssociationApiError('invalid_response');
21
+ }
22
+ if (!response.body) throw new ActionAssociationApiError('invalid_response');
23
+ const chunks = []; let size = 0;
24
+ for await (const chunk of response.body) {
25
+ size += chunk.byteLength;
26
+ if (size > MAX_RESPONSE_BYTES) {
27
+ throw new ActionAssociationApiError('invalid_response');
28
+ }
29
+ chunks.push(Buffer.from(chunk));
30
+ }
31
+ try {
32
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
33
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== 1 ||
34
+ typeof value.scope?.organizationId !== 'string' || typeof value.scope?.userId !== 'string') {
35
+ throw new Error('Invalid envelope.');
36
+ }
37
+ return value;
38
+ } catch { throw new ActionAssociationApiError('invalid_response'); }
39
+ }
40
+
41
+ // Tokens and transcript fragments never appear in exceptions or desktop state.
42
+ // Authentication is reread for every request; a late response from a previous
43
+ // login cannot associate work in the newly selected account.
44
+ export function createActionAssociationApi({ webOrigin, sessionRoot,
45
+ fetchImpl = fetch, readSessionImpl = readSession, now = Date.now, timeoutMs = 10_000 } = {}) {
46
+ const identity = session => createHash('sha256').update(`${webOrigin}\0${session.token}`).digest('hex');
47
+ const currentSession = async () => {
48
+ const session = await readSessionImpl(webOrigin, sessionRoot);
49
+ if (!isSessionValid(session, now(), webOrigin)) throw new ActionAssociationApiError('signed_out');
50
+ return session;
51
+ };
52
+ const request = async (path, method, payload, signal) => {
53
+ let response, session, url, body;
54
+ try {
55
+ session = await currentSession();
56
+ url = webApiUrl(path, { webOrigin, apiBaseUrl: session.api_base_url });
57
+ body = payload === undefined ? undefined : JSON.stringify(payload);
58
+ if (body && Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new ActionAssociationApiError('invalid_request');
59
+ const timeout = AbortSignal.timeout(timeoutMs);
60
+ response = await fetchImpl(url, {
61
+ method, headers: webApiHeaders(session, body ? { 'Content-Type': 'application/json' } : {}),
62
+ body, redirect: 'error', signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
63
+ });
64
+ if (!response.ok) {
65
+ // Do not decode errors: upstream proxy/provider messages can contain
66
+ // private query context or credentials.
67
+ await response.body?.cancel?.().catch(() => {});
68
+ throw new ActionAssociationApiError(response.status === 409 ? 'conflict' :
69
+ response.status === 401 || response.status === 403 ? 'unauthorized' :
70
+ response.status === 404 ? 'unavailable' : 'offline');
71
+ }
72
+ const value = await boundedJson(response);
73
+ if (identity(await currentSession()) !== identity(session)) throw new ActionAssociationApiError('account_changed');
74
+ return value;
75
+ } catch (error) {
76
+ if (error instanceof ActionAssociationApiError) throw error;
77
+ throw new ActionAssociationApiError(signal?.aborted ? 'cancelled' : 'offline');
78
+ }
79
+ };
80
+ const taskPath = taskId => {
81
+ if (!TASK_ID.test(taskId || '')) throw new ActionAssociationApiError('invalid_request');
82
+ return `action-bindings/${taskId}`;
83
+ };
84
+ return {
85
+ async authRevision() {
86
+ try { return identity(await currentSession()); } catch { return null; }
87
+ },
88
+ candidates(payload, { signal } = {}) {
89
+ taskPath(payload?.taskId);
90
+ return request('actions/candidates', 'POST', payload, signal);
91
+ },
92
+ readBinding(taskId, { signal } = {}) {
93
+ return request(taskPath(taskId), 'GET', undefined, signal);
94
+ },
95
+ saveBinding(taskId, payload, { signal } = {}) {
96
+ return request(taskPath(taskId), 'PUT', payload, signal);
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,213 @@
1
+ import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
3
+ import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
4
+ settleActionAssociation, cancelActionAssociation } from './action-association.js';
5
+
6
+ const namespaceFor = (origin, scope) => JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
7
+ const identity = record => ({ provider: record.provider, session: record.session, taskId: record.run.managed.taskId });
8
+ const eligible = record => record.run?.managed?.taskId && (!record.run.managed.handoff || record.run.managed.handoff.status === 'received');
9
+ const publicCandidate = item => ({ id: item.id, revision: item.revision, title: item.title.slice(0,120) });
10
+ const publicBinding = binding => binding ? { ...binding, reason: (binding.reason || '').slice(0,60), coveredWork: (binding.coveredWork || '').slice(0,60), evidence: [],
11
+ action: binding.action ? { ...binding.action, title: (binding.action.title || '').slice(0,120), description: null, projectName: (binding.action.projectName || '').slice(0,60) } : null } : null;
12
+ const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', 'invalid_response'].includes(code);
13
+
14
+ // Association is optional metadata. Network failures never control the task's
15
+ // work state; provider turns and their budgets remain owned by the supervisor.
16
+ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
17
+ now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
18
+ const abort = new AbortController(), views = new Map();
19
+ let auth = null, closed = false, running = null, serial = Promise.resolve();
20
+ const enqueue = work => {
21
+ const result = serial.then(work); serial = result.catch(() => {}); return result;
22
+ };
23
+ const current = async () => {
24
+ const next = await api.authRevision();
25
+ if (next !== auth) {
26
+ auth = next; views.clear();
27
+ for (const record of (await list(root)).filter(eligible)) {
28
+ const association = record.run.managed.actionAssociation;
29
+ if (!association || association.authRevision === next) continue;
30
+ await updateRecord(root, record.provider, record.session, latest => {
31
+ const local = latest?.run?.managed?.actionAssociation;
32
+ if (!local || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
33
+ local.invalidated = true; local.binding = null; delete local.outbox; local.status = 'unavailable';
34
+ return latest;
35
+ });
36
+ }
37
+ }
38
+ return next;
39
+ };
40
+ const fresh = async target => (await list(root)).find(record => eligible(record) && record.provider === target.provider && record.session === target.session && record.run.managed.taskId === target.taskId);
41
+ const valid = async expected => !closed && expected && await current() === expected;
42
+ const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
43
+ if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
44
+ if (envelope.binding && envelope.binding.taskId !== record.run.managed.taskId) throw new ActionAssociationApiError('invalid_response');
45
+ const namespace = namespaceFor(webOrigin, envelope.scope);
46
+ const local = record.run.managed.actionAssociation;
47
+ // An empty remote binding must not erase a cached ambiguous/no-match result.
48
+ if (userCorrection || envelope.binding || local?.binding || local?.namespace && local.namespace !== namespace) {
49
+ await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now() });
50
+ }
51
+ await updateRecord(root, record.provider, record.session, latest => {
52
+ const local = latest?.run?.managed?.actionAssociation;
53
+ if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
54
+ latest.run.managed.actionAssociation = { ...local, authRevision: expected };
55
+ if (!local?.job) delete latest.run.managed.actionAssociation.invalidated;
56
+ return latest;
57
+ });
58
+ return namespace;
59
+ };
60
+ async function sync(record, expected, force = false, queue = true) {
61
+ const target = identity(record), association = record.run.managed.actionAssociation;
62
+ let view = views.get(target.taskId);
63
+ const request = actionCandidateRequest(record);
64
+ const contextRevision = request?.context.revision || null;
65
+ if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
66
+ if (queue && !view.error && view.snapshot && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
67
+ return;
68
+ }
69
+ view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
70
+ views.set(target.taskId, view);
71
+ try {
72
+ // Never publish an outbox from an earlier organization or login before a
73
+ // scoped server response confirms who owns the current request.
74
+ const snapshot = request ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
75
+ const namespace = await importEnvelope(record, snapshot, expected);
76
+ view.namespace = namespace; view.error = null;
77
+ view.candidates = (snapshot.candidates || []).map(publicCandidate);
78
+ view.candidateTruncated = snapshot.retrieval?.truncated === true || view.candidates.length > 5;
79
+ view.snapshot = request ? snapshot : null;
80
+ let latest = await fresh(target);
81
+ if (!latest || !await valid(expected)) return;
82
+ const pending = latest.run.managed.actionAssociation?.outbox;
83
+ if (pending && pending.namespace === namespace && latest.run.managed.actionAssociation.authRevision === expected) {
84
+ if (snapshot.binding?.origin === 'automatic' && snapshot.binding.action?.id === pending.actionId && snapshot.binding.contextRevision === pending.contextRevision && snapshot.binding.candidateRevision === pending.candidateRevision) {
85
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now() }); return;
86
+ }
87
+ if (snapshot.contextRevision !== pending.contextRevision || snapshot.candidateRevision !== pending.candidateRevision) {
88
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now() });
89
+ view.nextRefresh = 0; return;
90
+ }
91
+ const payload = Object.fromEntries(['expectedRevision', 'contextRevision', 'candidateRevision', 'actionId', 'actionRevision', 'relation', 'coveredWork', 'reason', 'evidence'].map(key => [key, pending[key]]));
92
+ const saved = await api.saveBinding(target.taskId, { ...payload, origin: 'automatic' }, { signal: abort.signal });
93
+ if (!await valid(expected) || namespaceFor(webOrigin, saved.scope) !== namespace) return;
94
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now() });
95
+ return;
96
+ }
97
+ if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
98
+ // A read turn is queued only at the original session's safe boundary.
99
+ // Blocked tasks retain candidates for auxiliary correction in the UI.
100
+ await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now() });
101
+ await updateRecord(root, latest.provider, latest.session, currentRecord => {
102
+ if (currentRecord?.run?.managed?.taskId === target.taskId && currentRecord.run.managed.actionAssociation) currentRecord.run.managed.actionAssociation.authRevision = expected;
103
+ return currentRecord;
104
+ });
105
+ }
106
+ } catch (error) {
107
+ if (!await valid(expected)) return;
108
+ view.error = error.code || 'offline';
109
+ const latest = await fresh(target);
110
+ if (!latest) return;
111
+ if (error.code === 'conflict') {
112
+ try {
113
+ const remote = await api.readBinding(target.taskId, { signal: abort.signal });
114
+ const namespace = await importEnvelope(latest, remote, expected);
115
+ const box = latest.run.managed.actionAssociation?.outbox;
116
+ if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now() });
117
+ else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now() });
118
+ view.namespace = namespace; view.nextRefresh = 0;
119
+ } catch { view.error = 'offline'; }
120
+ } else if (hardFailure(error.code)) {
121
+ view.candidates = []; view.snapshot = null;
122
+ await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now() });
123
+ } else view.nextRefresh = now() + Math.min(refreshMs, 15_000);
124
+ }
125
+ }
126
+ async function reconcile() {
127
+ const expected = await current(), records = (await list(root)).filter(eligible).slice(0, 30);
128
+ let cursor = 0;
129
+ await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
130
+ while (!closed && cursor < records.length) {
131
+ const record = records[cursor++];
132
+ const managed = record.run.managed, job = managed.actionAssociation?.job;
133
+ if (job && !managed.resumeLease && (!expected || TERMINAL.has(record.run.state) || ['paused', 'blocked'].includes(record.run.state) || record.run.state === 'recovery_required' && record.run.recoveryCause !== 'session_supervision' ||
134
+ managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
135
+ managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
136
+ await cancelActionAssociation({ root, ...identity(record), now: now() });
137
+ }
138
+ if (!expected) {
139
+ if (managed.actionAssociation?.namespace) await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now() });
140
+ continue;
141
+ }
142
+ try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
143
+ }
144
+ }));
145
+ }
146
+ return {
147
+ async prepareCached(record) {
148
+ const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
149
+ if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
150
+ try {
151
+ const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
152
+ return prepared || record;
153
+ } catch { return record; }
154
+ },
155
+ tick() {
156
+ if (closed) return Promise.resolve();
157
+ if (!running) running = reconcile().finally(() => { running = null; });
158
+ return running;
159
+ },
160
+ async decorate(status) {
161
+ const expected = await current();
162
+ let remainingBytes = 24 * 1024;
163
+ const perRunBytes = Math.floor(remainingBytes / Math.max(1,(status.runs || []).filter(run => run.managed).length));
164
+ for (const run of status.runs || []) {
165
+ if (!run.managed) continue;
166
+ const local = run.actionAssociation, view = views.get(run.taskId);
167
+ // Durable caches alone are not authorization to disclose Action text.
168
+ run.actionAssociation = !expected ? { status: 'signed_out', binding: null, candidates: [], canCorrect: false } :
169
+ !view || view.auth !== expected ? { status: 'loading', binding: null, candidates: [], canCorrect: false } : {
170
+ status: view.error || local?.status || 'waiting_boundary',
171
+ binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
172
+ candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
173
+ retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
174
+ };
175
+ let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
176
+ if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
177
+ if (bytes > Math.min(remainingBytes,perRunBytes) && run.actionAssociation.binding) {
178
+ const binding = run.actionAssociation.binding;
179
+ run.actionAssociation.binding = { taskId: binding.taskId, revision: binding.revision, origin: binding.origin, locked: binding.locked, relation: binding.relation, action: binding.action ? { id: binding.action.id, revision: binding.action.revision, title: binding.action.title.slice(0,40), status: binding.action.status, dueAt: binding.action.dueAt, timeZone: binding.action.timeZone } : null };
180
+ bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
181
+ }
182
+ remainingBytes = Math.max(0,remainingBytes - bytes);
183
+ }
184
+ return status;
185
+ },
186
+ correct(command) {
187
+ return enqueue(async () => {
188
+ const expected = await current(), record = await fresh(command);
189
+ if (!expected) throw new ActionAssociationApiError('signed_out');
190
+ if (!record) throw new ActionAssociationApiError('invalid_request');
191
+ await sync(record, expected, true, false);
192
+ const view = views.get(command.taskId);
193
+ if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
194
+ if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
195
+ const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
196
+ if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
197
+ const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
198
+ if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
199
+ const envelope = await api.saveBinding(command.taskId, {
200
+ expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
201
+ candidateRevision: view.snapshot?.candidateRevision || null, origin: 'user', actionId: selected?.id || null,
202
+ actionRevision: selected?.revision || null, relation: selected ? 'part_of' : null,
203
+ coveredWork: latest.run.managed.sessionUnderstanding?.objective || latest.run.objective,
204
+ reason: selected ? 'Selected by the user.' : 'Unlinked by the user.', evidence: [],
205
+ }, { signal: abort.signal });
206
+ if (namespaceFor(webOrigin, envelope.scope) !== view.namespace) throw new ActionAssociationApiError('account_changed');
207
+ await importEnvelope(latest, envelope, expected, true);
208
+ view.error = null; view.nextRefresh = 0;
209
+ });
210
+ },
211
+ async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
212
+ };
213
+ }
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
4
+ export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
5
+
6
+ function stable(run) {
7
+ return run?.managed && (run.state === 'active' || run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' && run.managed.autoRecovery === true) && !TERMINAL.has(run.state) && run.managed.phase === 'active' && !run.managed.resumeLease && (!run.managed.handoff || run.managed.handoff.status === 'received') && !run.managed.pendingApproval && !run.managed.approvedAction && !run.managed.providerWarning && !run.managed.executionUncertain && (!run.managed.executionAttempt || run.managed.executionAttempt.phase === 'checkpointed');
8
+ }
9
+ function restoreAssociationWork(run, saved) {
10
+ const managed = run.managed;
11
+ managed.phase = saved.phase; managed.plan = saved.plan; managed.decision = saved.decision;
12
+ run.nextStep = saved.nextStep; run.checkpoint = saved.checkpoint;
13
+ if (run.state === saved.state) run.reason = saved.reason;
14
+ if (run.state === 'recovery_required' && run.recoveryCause === 'provider_error' && !managed.providerWarning && !managed.attempts?.at(-1)?.restorationUnverified && run.failures < run.maxFailures) {
15
+ run.state = saved.state; run.reason = saved.reason; run.recoveryCause = saved.recoveryCause; run.blocker = saved.blocker; managed.autoRecovery = saved.autoRecovery;
16
+ }
17
+ managed.awaitingAssociationHook = false;
18
+ }
19
+ export async function queueActionAssociation({ root, provider, session, taskId, expectedRevision, candidates, candidateRevision, scope, namespace = null, remoteBinding = null, now = Date.now() }) {
20
+ return updateRecord(root, provider, session, record => {
21
+ const run = record?.run, managed = run?.managed;
22
+ if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
23
+ if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
24
+ if (!managed.actionAssociation?.job && !managed.actionAssociation?.outbox && !managed.actionAssociation?.manualOverride) managed.actionAssociation = { ...managed.actionAssociation, status: 'waiting_boundary', retryOn: 'original_session_safe_boundary', updatedAt: now };
25
+ return record;
26
+ }
27
+ if (managed.actionAssociation?.manualOverride && !managed.actionAssociation.namespace) return record;
28
+ if (managed.actionAssociation?.namespace !== namespace) managed.actionAssociation = { status: 'unavailable', namespace };
29
+ if (managed.actionAssociation.manualOverride || managed.actionAssociation.binding?.locked) return record;
30
+ if (remoteBinding && managed.actionAssociation.binding?.revision > remoteBinding.revision) return record;
31
+ if (remoteBinding?.taskId === taskId) { managed.actionAssociation.binding = remoteBinding; managed.actionAssociation.manualOverride = remoteBinding.locked === true; managed.actionAssociation.status = remoteBinding.action ? 'bound' : 'unlinked'; if (remoteBinding.action && remoteBinding.contextRevision === associationSignals(managed.sessionUnderstanding)?.revision) return record; }
32
+ if (managed.actionAssociation?.manualOverride || managed.actionAssociation?.job || managed.actionAssociation?.outbox) return record;
33
+ const signals = associationSignals(managed.sessionUnderstanding);
34
+ if (!signals || !Array.isArray(candidates) || candidates.length > 10 || !candidateRevision || !scope) return record;
35
+ const key = associationRevision({ context: signals, candidateRevision, scope, namespace });
36
+ if (managed.actionAssociation?.cacheKey === key) return record;
37
+ const job = { id: randomUUID(), taskId, provider, session, runRevision: run.revision, understandingRevision: managed.understandingRevision, context: signals, candidates, candidateRevision, scope, namespace, cacheKey: key,
38
+ saved: { phase: managed.phase, plan: managed.plan, decision: managed.decision, nextStep: run.nextStep, state: run.state, reason: run.reason, checkpoint: run.checkpoint, recoveryCause: run.recoveryCause, blocker: run.blocker, autoRecovery: managed.autoRecovery }, createdAt: now };
39
+ managed.actionAssociation = { ...managed.actionAssociation, status: candidates.length ? 'assessing' : 'no_match', namespace, cacheKey: key, ...(candidates.length ? { job } : {}) };
40
+ if (!candidates.length) return record;
41
+ managed.phase = 'action_association';
42
+ managed.decision = { id: job.id, outcome: 'proceed', action: { kind: 'read', scope: 'task', summary: 'Compare scoped Action candidates with the original user request' } };
43
+ run.nextStep = managed.decision.action.summary;
44
+ if (managed.capability === 'live_hook') managed.awaitingAssociationHook = true;
45
+ return record;
46
+ });
47
+ }
48
+ export async function importActionBinding({ root, provider, session, taskId, namespace, binding = null, unavailable = false, userCorrection = false, now = Date.now() }) {
49
+ return updateRecord(root, provider, session, record => {
50
+ const run = record?.run, managed = run?.managed;
51
+ if (managed?.taskId !== taskId || managed.handoff && managed.handoff.status !== 'received') return record;
52
+ const previous = managed.actionAssociation || {};
53
+ if (previous.namespace && previous.namespace !== namespace && previous.job && managed.resumeLease) { previous.namespace = namespace; previous.binding = null; previous.status = 'unavailable'; previous.invalidated = true; delete previous.outbox; return record; }
54
+ if (userCorrection && previous.job && !managed.resumeLease) {
55
+ const saved = previous.job.saved;
56
+ restoreAssociationWork(run, saved);
57
+ delete previous.job;
58
+ }
59
+ if (binding && binding.taskId !== taskId) throw new Error('Remote binding task differs.');
60
+ const association = previous.namespace && previous.namespace !== namespace ? {} : previous;
61
+ association.namespace = namespace;
62
+ if (binding && association.binding?.revision > binding.revision) return record;
63
+ if (binding) {
64
+ association.binding = binding; association.manualOverride = binding.locked === true;
65
+ association.status = binding.action ? 'bound' : 'unlinked';
66
+ if (userCorrection || binding.locked) delete association.outbox;
67
+ } else if (unavailable) { association.binding = null; association.status = 'unavailable'; delete association.outbox; }
68
+ else { association.binding = null; association.manualOverride = false; if (!association.job && !association.outbox) association.status = 'waiting_boundary'; }
69
+ association.updatedAt = now; managed.actionAssociation = association;
70
+ return record;
71
+ });
72
+ }
73
+ export const correctActionAssociation = options => importActionBinding({ ...options, userCorrection: true });
74
+ export async function pendingAssociationTasks(root) {
75
+ return (await listRecords(root)).filter(record => stable(record.run) && !record.run.managed.actionAssociation?.manualOverride && !record.run.managed.actionAssociation?.job && !record.run.managed.actionAssociation?.outbox && associationSignals(record.run.managed.sessionUnderstanding));
76
+ }
77
+
78
+ export function actionCandidateRequest(record) {
79
+ const context = associationSignals(record?.run?.managed?.sessionUnderstanding);
80
+ return context ? { taskId: record.run.managed.taskId, context, limit: 10 } : null;
81
+ }
82
+ export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now() }) {
83
+ const request = actionCandidateRequest(record);
84
+ if (!request || snapshot?.schemaVersion !== 1 || snapshot.taskId !== request.taskId || snapshot.contextRevision !== request.context.revision || !snapshot.scope?.organizationId || !snapshot.scope?.userId) return null;
85
+ if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length > 10 || new Set(snapshot.candidates.map(item => item.id)).size !== snapshot.candidates.length) return null;
86
+ for (const item of snapshot.candidates) {
87
+ if (typeof item.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(item.id) || typeof item.revision !== 'string' || item.revision.length > 200 || typeof item.title !== 'string' || item.title.length > 500 || typeof item.description === 'string' && item.description.length > 2000 || !Array.isArray(item.evidence) || item.evidence.length > 10 || new Set(item.evidence.map(fact => fact.ref)).size !== item.evidence.length || item.evidence.some(fact => typeof fact.ref !== 'string' || fact.ref.length > 200 || !['goal','deliverable','project','source'].includes(fact.kind) || typeof fact.text !== 'string' || fact.text.length > 2000)) return null;
88
+ }
89
+ const namespace = JSON.stringify([new URL(webOrigin).origin,snapshot.scope.organizationId,snapshot.scope.userId]);
90
+ return queueActionAssociation({ root, provider: record.provider, session: record.session, taskId: request.taskId, expectedRevision: record.run.revision,
91
+ candidates: snapshot.candidates, candidateRevision: snapshot.candidateRevision, scope: snapshot.scope, namespace, remoteBinding: snapshot.binding, now });
92
+ }
93
+ export async function settleActionAssociation({ root, provider, session, taskId, outboxId, namespace, binding = null, unavailable = false, now = Date.now() }) {
94
+ return updateRecord(root, provider, session, record => {
95
+ const association = record?.run?.managed?.actionAssociation;
96
+ if (record?.run?.managed?.taskId !== taskId || association?.outbox?.id !== outboxId || association.outbox.namespace !== namespace || record.run.managed.handoff && record.run.managed.handoff.status !== 'received') return record;
97
+ if (binding && binding.taskId === taskId) {
98
+ association.binding = binding; association.manualOverride = binding.locked === true;
99
+ association.status = binding.action ? 'bound' : 'unlinked'; delete association.outbox;
100
+ } else if (unavailable) { association.status = 'unavailable'; association.binding = null; delete association.outbox; }
101
+ association.updatedAt = now;
102
+ return record;
103
+ });
104
+ }
105
+ export async function cancelActionAssociation({ root, provider, session, taskId, now = Date.now() }) {
106
+ return updateRecord(root, provider, session, record => {
107
+ const run = record?.run, managed = run?.managed, association = managed?.actionAssociation;
108
+ if (managed?.taskId !== taskId || managed.resumeLease || !association?.job) return record;
109
+ const saved = association.job.saved;
110
+ restoreAssociationWork(run, saved);
111
+ managed.awaitingAssociationHook = false; association.status = 'unavailable'; delete association.job; association.updatedAt = now;
112
+ return record;
113
+ });
114
+ }
@@ -0,0 +1,187 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { analyzeContinuation } from './continuation-analysis.js';
5
+
6
+ const BACKFILL_LIMIT = 10;
7
+ const MAX_CACHE_ENTRIES = 2000;
8
+ const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
9
+ const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
10
+ const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
11
+ export const continuationAnalysisRevision = candidate => createHash('sha256')
12
+ .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate)]))
13
+ .digest('hex');
14
+ const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
15
+ ['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged');
16
+
17
+ // Recommendations are metadata only. This worker never enrolls a conversation
18
+ // or dispatches its task; the normal managed bootstrap still checks context.
19
+ export async function createContinuationAnalysisManager({ root, analyze = analyzeContinuation,
20
+ now = Date.now, onChange = () => {}, timeoutMs = 120_000 } = {}) {
21
+ await mkdir(root, { recursive: true, mode: 0o700 });
22
+ if ((await lstat(root)).isSymbolicLink()) throw new Error('Invalid session analysis cache.');
23
+ const path = join(root, 'cache.json');
24
+ let document, cacheInfo;
25
+ try {
26
+ cacheInfo = await lstat(path);
27
+ if (!cacheInfo.isFile() || cacheInfo.isSymbolicLink() || cacheInfo.size > 4 * 1024 * 1024) throw new Error('Invalid session analysis cache.');
28
+ document = JSON.parse(await readFile(path, 'utf8'));
29
+ const pairs = list => Array.isArray(list) && list.every(pair => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === 'string' && pair[1] && typeof pair[1] === 'object');
30
+ if (document.schemaVersion !== 1 || typeof document.initialized !== 'boolean' || !pairs(document.known) || !pairs(document.entries) ||
31
+ !Number.isFinite(document.baselineAt) || document.entries.some(([, entry]) => !/^[a-f0-9]{64}$/.test(entry.revision) ||
32
+ !['queued', 'analyzing', 'failed', ...RESULT_STATES].includes(entry.result?.state))) throw new Error('Invalid session analysis cache.');
33
+ } catch (error) {
34
+ if (error.code !== 'ENOENT') {
35
+ if (!cacheInfo?.isFile() || cacheInfo.isSymbolicLink()) throw error;
36
+ // Keep corrupt metadata recoverable and avoid treating all history as a
37
+ // new cohort. Manual analysis and genuinely new messages still work.
38
+ await rename(path, `${path}.unreadable-${randomUUID()}`);
39
+ }
40
+ document = { schemaVersion: 1, baselineAt: now(), initialized: error.code !== 'ENOENT', known: [], entries: [] };
41
+ }
42
+ const known = new Map(document.known.slice(-MAX_CACHE_ENTRIES));
43
+ const entries = new Map(document.entries.slice(-MAX_CACHE_ENTRIES));
44
+ const candidates = new Map();
45
+ for (const [key, entry] of entries) {
46
+ if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
47
+ }
48
+ let serial = Promise.resolve(), draining = null, closed = false, controller = null;
49
+ const exclusive = fn => {
50
+ const next = serial.then(fn);
51
+ serial = next.catch(() => {});
52
+ return next;
53
+ };
54
+ const persist = async () => {
55
+ for (const map of [known, entries]) while (map.size > MAX_CACHE_ENTRIES) map.delete(map.keys().next().value);
56
+ const temporary = `${path}.${randomUUID()}.tmp`;
57
+ try {
58
+ await writeFile(temporary, JSON.stringify({ ...document, known: [...known], entries: [...entries] }) + '\n', { mode: 0o600 });
59
+ await rename(temporary, path);
60
+ } finally { await rm(temporary, { force: true }); }
61
+ };
62
+ const changed = () => { void Promise.resolve().then(onChange).catch(() => {}); };
63
+ const enqueue = (candidate, force = false) => {
64
+ const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate);
65
+ const entry = entries.get(key);
66
+ if (entry?.revision === revision && (['queued', 'analyzing'].includes(entry.result?.state) ||
67
+ !force && !['failed', 'uncertain'].includes(entry.result?.state))) return false;
68
+ entries.delete(key);
69
+ entries.set(key, { revision, result: { state: 'queued' } });
70
+ return true;
71
+ };
72
+ const startDrain = () => {
73
+ if (closed || draining) return;
74
+ draining = (async () => {
75
+ while (!closed) {
76
+ const selected = await exclusive(async () => {
77
+ if (closed) return null;
78
+ for (const [key, entry] of entries) {
79
+ const candidate = candidates.get(key);
80
+ if (entry.result?.state !== 'queued' || !candidate || !eligible(candidate) || entry.revision !== continuationAnalysisRevision(candidate)) continue;
81
+ entry.result = { state: 'analyzing' };
82
+ await persist(); changed();
83
+ return { key, revision: entry.revision, candidate };
84
+ }
85
+ return null;
86
+ });
87
+ if (!selected || closed) break;
88
+ controller = new AbortController();
89
+ const signal = controller.signal;
90
+ const timeout = setTimeout(() => controller?.abort(), timeoutMs);
91
+ timeout.unref?.();
92
+ let aborted;
93
+ const cancellation = new Promise((_, reject) => {
94
+ aborted = () => reject(new Error('Session analysis cancelled.'));
95
+ signal.addEventListener('abort', aborted, { once: true });
96
+ });
97
+ let result, inference;
98
+ try {
99
+ inference = Promise.resolve(analyze(selected.candidate, { signal }));
100
+ result = await Promise.race([inference, cancellation]);
101
+ if (!RESULT_STATES.has(result?.state) || typeof result.reason !== 'string' ||
102
+ (result.state === 'continue' && (!result.objective || !result.remaining?.length || !result.nextAction || !result.basis?.some(item => item.role === 'user')))) {
103
+ throw new Error('Invalid session analysis result.');
104
+ }
105
+ } catch {
106
+ result = { state: 'failed', reason: 'The conversation could not be analyzed. Retry when its provider is available.' };
107
+ } finally {
108
+ // Provider cancellation includes its process-exit receipt and scratch
109
+ // cleanup. Do not start the next job or update runtime before it ends.
110
+ if (signal.aborted) await inference?.catch(() => {});
111
+ clearTimeout(timeout); signal.removeEventListener('abort', aborted); controller = null;
112
+ }
113
+ await exclusive(async () => {
114
+ const current = candidates.get(selected.key), entry = entries.get(selected.key);
115
+ if (closed) return;
116
+ if (!current || !eligible(current) || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision) {
117
+ if (entry?.revision === selected.revision && entry.result?.state === 'analyzing') {
118
+ entry.result = { state: 'queued' }; await persist(); changed();
119
+ }
120
+ return;
121
+ }
122
+ entries.set(selected.key, { revision: selected.revision, result: { ...result, analyzedAt: now() } });
123
+ await persist(); changed();
124
+ });
125
+ }
126
+ })().catch(() => {}).finally(() => {
127
+ draining = null;
128
+ if (!closed && [...entries].some(([key, entry]) => entry.result?.state === 'queued' &&
129
+ candidates.has(key) && eligible(candidates.get(key)) && entry.revision === continuationAnalysisRevision(candidates.get(key)))) startDrain();
130
+ });
131
+ };
132
+ return {
133
+ async update(sessions) {
134
+ await exclusive(async () => {
135
+ if (closed) return;
136
+ candidates.clear();
137
+ for (const candidate of sessions) candidates.set(keyOf(candidate), candidate);
138
+ const sorted = [...candidates.values()].sort((a, b) => activityOf(b) - activityOf(a) || keyOf(a).localeCompare(keyOf(b)));
139
+ const backfill = document.initialized ? new Set() : new Set(sorted.slice(0, BACKFILL_LIMIT).map(keyOf));
140
+ let dirty = !document.initialized;
141
+ for (const candidate of sorted) {
142
+ const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate), previous = known.get(key);
143
+ const active = activityOf(candidate);
144
+ const newActivity = document.initialized && (previous ? active > previous.activity : active > document.baselineAt);
145
+ if (eligible(candidate) && (backfill.has(key) || newActivity)) dirty = enqueue(candidate) || dirty;
146
+ if (previous?.revision !== revision) {
147
+ known.delete(key); known.set(key, { revision, activity: active }); dirty = true;
148
+ }
149
+ }
150
+ // Empty discovery during provider startup must not consume backfill.
151
+ if (sorted.length) document.initialized = true;
152
+ if (dirty) await persist();
153
+ });
154
+ startDrain();
155
+ },
156
+ project(candidate) {
157
+ const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
158
+ return { ...candidate, analysis: entry?.revision === revision ? { ...entry.result, revision } : { state: 'unanalysed', revision } };
159
+ },
160
+ async request(candidate) {
161
+ if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
162
+ await exclusive(async () => {
163
+ if (closed) throw new Error('Session analysis is unavailable.');
164
+ candidates.set(keyOf(candidate), candidate);
165
+ if (enqueue(candidate, true)) { await persist(); changed(); }
166
+ });
167
+ startDrain();
168
+ },
169
+ async invalidate(candidate) {
170
+ await exclusive(async () => {
171
+ if (closed) throw new Error('Session analysis is unavailable.');
172
+ entries.delete(keyOf(candidate));
173
+ candidates.set(keyOf(candidate), candidate);
174
+ enqueue(candidate); await persist(); changed();
175
+ });
176
+ startDrain();
177
+ },
178
+ requireRecommendation(candidate) {
179
+ if (closed) throw new Error('Session analysis is unavailable.');
180
+ const analysis = this.project(candidate).analysis;
181
+ if (!eligible(candidate) || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
182
+ return analysis;
183
+ },
184
+ async whenIdle() { do { await serial; await draining; } while (draining); await serial; },
185
+ async close() { closed = true; controller?.abort(); await this.whenIdle(); },
186
+ };
187
+ }