@awak-app/simy-cli 0.5.60 → 0.5.62
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 +17 -0
- package/package.json +2 -2
- package/src/agent.js +27 -7
- package/src/backend-executable.js +36 -21
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +105 -16
- package/src/delivery-hooks/desktop.js +4 -2
- package/src/delivery-hooks/supervisor.js +28 -19
- package/src/desktop-host.js +69 -24
- package/src/runner.js +8 -2
- package/src/session-supervision/action-association-api.js +1 -1
- package/src/session-supervision/action-association-host.js +65 -35
- package/src/session-supervision/action-association.js +17 -10
- package/src/session-supervision/action-deadline.js +9 -1
- package/src/session-supervision/association-store.js +78 -21
- package/src/session-supervision/automatic-continuation.js +18 -1
- package/src/session-supervision/claude-sessions.js +48 -17
- package/src/session-supervision/codex-app-server.js +35 -14
- package/src/session-supervision/index.js +34 -10
- package/src/sqm/command.js +55 -45
- package/src/sqm/history-store.js +108 -0
- package/src/sqm/index.js +11 -1
- package/src/sqm/review-worker.js +26 -8
package/src/desktop-host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
2
2
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
3
3
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
4
|
-
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
4
|
+
import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
5
5
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
6
6
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
7
7
|
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
@@ -10,7 +10,7 @@ import { analyzeContinuation, readContinuationContext } from "./session-supervis
|
|
|
10
10
|
import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
|
|
11
11
|
import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
|
|
12
12
|
import { spawn, execFile } from "node:child_process";
|
|
13
|
-
import { promisify } from "node:util";
|
|
13
|
+
import { promisify, isDeepStrictEqual } from "node:util";
|
|
14
14
|
import { createInterface } from "node:readline";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { realpathSync } from "node:fs";
|
|
@@ -59,6 +59,19 @@ function desktopActionError(action, error) {
|
|
|
59
59
|
export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
|
|
60
60
|
dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
|
|
61
61
|
const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
|
|
62
|
+
// A heartbeat remains a complete native frame. Only its observation time is
|
|
63
|
+
// volatile; reuse the encoded body after comparing freshly authorized data.
|
|
64
|
+
// Detach the comparison value because registries may mutate nested objects.
|
|
65
|
+
let encodedStatus = null, lastStatus = null;
|
|
66
|
+
const emitStatus = status => {
|
|
67
|
+
const { sampledAt, ...content } = status;
|
|
68
|
+
if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
|
|
69
|
+
encodedStatus = JSON.stringify(content).slice(1, -1);
|
|
70
|
+
lastStatus = structuredClone(content);
|
|
71
|
+
}
|
|
72
|
+
const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
|
|
73
|
+
output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
|
|
74
|
+
};
|
|
62
75
|
const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
63
76
|
const webOrigin = resolveWebOrigin(null, env);
|
|
64
77
|
const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
|
|
@@ -67,8 +80,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
67
80
|
}
|
|
68
81
|
const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
|
|
69
82
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
83
|
+
const deliveryRoot = join(root, "delivery-hooks");
|
|
84
|
+
const recordReader = createRecordReader(deliveryRoot);
|
|
70
85
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
|
|
71
|
-
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
86
|
+
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
72
87
|
let actionAssociations = makeActionAssociations();
|
|
73
88
|
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
74
89
|
const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
|
|
@@ -76,6 +91,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
76
91
|
const previousScope = accessScope(currentAccess);
|
|
77
92
|
currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
|
|
78
93
|
if (previousScope !== accessScope(currentAccess)) {
|
|
94
|
+
discoveryController?.abort();
|
|
95
|
+
sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
|
|
79
96
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
80
97
|
sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
|
|
81
98
|
await analysisManager?.update([]);
|
|
@@ -99,28 +116,37 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
99
116
|
|
|
100
117
|
const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
|
|
101
118
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
102
|
-
authorizeDispatch: authorizeWork,
|
|
119
|
+
authorizeDispatch: authorizeWork, readRecords: () => recordReader.list(),
|
|
103
120
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
104
121
|
let shuttingDown = null;
|
|
105
122
|
let commands;
|
|
106
123
|
let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
|
|
107
|
-
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
|
|
124
|
+
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
|
|
125
|
+
const stopDiscovery = async ({ drain = false } = {}) => {
|
|
126
|
+
discoveryController?.abort();
|
|
127
|
+
sessionRefreshAgain = false;
|
|
128
|
+
if (drain && !dependencies.discoverSessions) await sessionRefresh;
|
|
129
|
+
};
|
|
108
130
|
const refreshSessions = (force = false) => {
|
|
109
|
-
if (
|
|
131
|
+
if (discoveryStopped && !closed && !relay) return sessionStatus;
|
|
132
|
+
if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
110
133
|
if (force && sessionRefresh) sessionRefreshAgain = true;
|
|
111
134
|
if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
|
|
112
135
|
const requestedScope = accessScope(currentAccess);
|
|
113
|
-
|
|
136
|
+
const controller = discoveryController = new AbortController();
|
|
137
|
+
sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal }); })
|
|
114
138
|
.then(async value => {
|
|
139
|
+
if (closed || relay || controller.signal.aborted) return;
|
|
115
140
|
const latestAccess = await accessSnapshot();
|
|
141
|
+
if (closed || relay || controller.signal.aborted) return;
|
|
116
142
|
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
|
|
117
143
|
await analysisManager.update([]);
|
|
118
144
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
119
145
|
return;
|
|
120
146
|
}
|
|
121
147
|
await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
|
|
122
|
-
.catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
123
|
-
.finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
148
|
+
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
149
|
+
.finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
124
150
|
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
125
151
|
}
|
|
126
152
|
return sessionStatus;
|
|
@@ -171,8 +197,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
171
197
|
if (!scheduledPublish) {
|
|
172
198
|
let resolve, reject;
|
|
173
199
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
174
|
-
scheduledPublish = { promise };
|
|
175
|
-
setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75);
|
|
200
|
+
scheduledPublish = { promise, resolve, timer: setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75) };
|
|
176
201
|
}
|
|
177
202
|
return scheduledPublish.promise;
|
|
178
203
|
};
|
|
@@ -180,6 +205,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
180
205
|
if (closed || relay || !agent) return Promise.resolve();
|
|
181
206
|
if (publishing) { publishAgain = true; return publishing; }
|
|
182
207
|
publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
|
|
208
|
+
if (closed || relay) return;
|
|
183
209
|
await setAccessSnapshot(status.access);
|
|
184
210
|
status.access = currentAccess;
|
|
185
211
|
const requestedScope = accessScope(currentAccess);
|
|
@@ -188,39 +214,49 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
188
214
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
189
215
|
if (currentAccess.state !== 'checking') await analysisManager.update([]);
|
|
190
216
|
status.tasks = { activeCount: status.tasks?.activeCount || 0, items: [] };
|
|
217
|
+
status.sqmChecks = { activeCount: 0, items: [] };
|
|
191
218
|
status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
|
|
192
219
|
status.deliveryHooks = { runs: [], providers: {} };
|
|
193
220
|
status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
|
|
194
|
-
if (!closed && !relay)
|
|
221
|
+
if (!closed && !relay) emitStatus(status);
|
|
195
222
|
return;
|
|
196
223
|
}
|
|
197
|
-
|
|
198
|
-
const projection = await actionAssociations.beginProjection?.();
|
|
199
|
-
await
|
|
224
|
+
const records = await recordReader.list();
|
|
225
|
+
const projection = await actionAssociations.beginProjection?.({ records });
|
|
226
|
+
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
227
|
+
await actionAssociations.decorate(status.deliveryHooks, projection);
|
|
200
228
|
const discovery = refreshSessions();
|
|
201
229
|
automaticContinuation?.tick(discovery.sessions);
|
|
202
230
|
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
203
231
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
232
|
+
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
204
233
|
for (const session of sessions) {
|
|
205
234
|
const context = await actionAssociations.deadlineContext?.(session, projection);
|
|
206
|
-
if (context) session.actionDeadline = await deadlines.reconcile(context);
|
|
235
|
+
if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
|
|
207
236
|
else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
|
|
208
|
-
const run =
|
|
237
|
+
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
209
238
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
210
239
|
}
|
|
211
240
|
status.sessionSupervision = { ...discovery, sessions };
|
|
212
241
|
const latestAccess = await accessSnapshot();
|
|
213
|
-
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope
|
|
214
|
-
|
|
242
|
+
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
243
|
+
actionAssociations.validateProjection && !await actionAssociations.validateProjection(projection)) { publishAgain = true; return; }
|
|
244
|
+
if (!closed && !relay) emitStatus(status);
|
|
215
245
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
216
246
|
return publishing;
|
|
217
247
|
};
|
|
218
248
|
const stopAgent = async () => {
|
|
219
249
|
clearInterval(timer);
|
|
250
|
+
discoveryStopped = true;
|
|
251
|
+
await stopDiscovery({ drain: true });
|
|
252
|
+
encodedStatus = null; lastStatus = null;
|
|
253
|
+
if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
|
|
254
|
+
await publishing?.catch(() => {});
|
|
220
255
|
await actionAssociations.close();
|
|
221
256
|
await automaticContinuation?.close();
|
|
222
257
|
await analysisManager?.close();
|
|
223
258
|
await managedSupervisor.close();
|
|
259
|
+
await recordReader.close();
|
|
224
260
|
await stopWorkflows();
|
|
225
261
|
if (agent) await createAgentShutdown(agent)();
|
|
226
262
|
};
|
|
@@ -262,6 +298,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
262
298
|
updater.on?.("change", () => { void publish().catch(() => {}); });
|
|
263
299
|
updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
|
|
264
300
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
301
|
+
discoveryStopped = true;
|
|
302
|
+
await stopDiscovery({ drain: true });
|
|
265
303
|
// The candidate shares this cache. Stop inference before it starts so a
|
|
266
304
|
// signed handoff cannot create two analysis writers or duplicate calls.
|
|
267
305
|
await automaticContinuation.close();
|
|
@@ -276,7 +314,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
276
314
|
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
277
315
|
}); } catch (error) {
|
|
278
316
|
actionAssociations = makeActionAssociations();
|
|
279
|
-
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
|
|
317
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
|
|
280
318
|
}
|
|
281
319
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
282
320
|
let accepted = false;
|
|
@@ -321,18 +359,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
321
359
|
lines.close(); await stopUpdateCandidate(child);
|
|
322
360
|
if (!closed && !relay) {
|
|
323
361
|
actionAssociations = makeActionAssociations();
|
|
324
|
-
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
|
|
362
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
|
|
325
363
|
}
|
|
326
364
|
throw error;
|
|
327
365
|
}
|
|
328
366
|
} });
|
|
329
367
|
await refreshPlugins();
|
|
330
368
|
await publish();
|
|
331
|
-
|
|
369
|
+
const maintenance = new Map();
|
|
370
|
+
const tick = worker => {
|
|
371
|
+
if (closed || relay || maintenance.has(worker)) return;
|
|
372
|
+
const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
|
|
373
|
+
maintenance.set(worker, pending);
|
|
374
|
+
};
|
|
375
|
+
tick(actionAssociations);
|
|
376
|
+
tick(managedSupervisor);
|
|
332
377
|
timer = setInterval(() => {
|
|
333
378
|
if (desktopAccessReady(currentAccess)) {
|
|
334
|
-
|
|
335
|
-
|
|
379
|
+
tick(actionAssociations);
|
|
380
|
+
tick(managedSupervisor);
|
|
336
381
|
}
|
|
337
382
|
void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
|
|
338
383
|
}, 3_000);
|
package/src/runner.js
CHANGED
|
@@ -86,7 +86,7 @@ const PR_PREPARATION_IDS = new Set(PR_PREPARATION_CHECKS.map((row) => row.id));
|
|
|
86
86
|
|
|
87
87
|
export { LocalRunRegistry };
|
|
88
88
|
|
|
89
|
-
export function createRun({ runId, request, session, apiOrigin, localStateRoot = null }) {
|
|
89
|
+
export function createRun({ runId, request, session, apiOrigin, localStateRoot = null, sqmHistoryRoot = null }) {
|
|
90
90
|
const emitter = new EventEmitter();
|
|
91
91
|
const normalizedRequest = {
|
|
92
92
|
...request,
|
|
@@ -111,6 +111,7 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
|
|
|
111
111
|
session,
|
|
112
112
|
apiOrigin,
|
|
113
113
|
localStateRoot,
|
|
114
|
+
sqmHistoryRoot,
|
|
114
115
|
status: "queued",
|
|
115
116
|
lastOutput: "",
|
|
116
117
|
startedAt: null,
|
|
@@ -160,7 +161,7 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
|
|
|
160
161
|
};
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
export function restoreRun({ snapshot, session, apiOrigin, localPath = null, localStateRoot = null }) {
|
|
164
|
+
export function restoreRun({ snapshot, session, apiOrigin, localPath = null, localStateRoot = null, sqmHistoryRoot = null }) {
|
|
164
165
|
if (!snapshot || typeof snapshot !== "object" || !snapshot.charter) {
|
|
165
166
|
throw new Error("Cannot restore an invalid agentic loop snapshot.");
|
|
166
167
|
}
|
|
@@ -170,6 +171,7 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
|
|
|
170
171
|
session,
|
|
171
172
|
apiOrigin,
|
|
172
173
|
localStateRoot,
|
|
174
|
+
sqmHistoryRoot,
|
|
173
175
|
request: {
|
|
174
176
|
backend: charter.backend === "claude" ? "claude" : "codex",
|
|
175
177
|
execution_target: normalizeDesktopExecutionTarget(charter.execution_target),
|
|
@@ -678,6 +680,8 @@ async function runLocalCodingRun(
|
|
|
678
680
|
token: run.session?.token,
|
|
679
681
|
endpoint: sqmBundleEndpoint(run.session),
|
|
680
682
|
evidenceEndpoint: sqmEvidenceEndpoint(run.session),
|
|
683
|
+
webOrigin: run.apiOrigin,
|
|
684
|
+
historyRoot: run.sqmHistoryRoot,
|
|
681
685
|
accountId: run.session?.account_id || run.session?.auth_user_id || run.session?.device_id,
|
|
682
686
|
organizationId: run.session?.organization_id || run.session?.org_id,
|
|
683
687
|
deviceId: run.session?.device_id,
|
|
@@ -751,6 +755,8 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
|
|
|
751
755
|
token: run.session?.token,
|
|
752
756
|
endpoint: sqmBundleEndpoint(run.session),
|
|
753
757
|
evidenceEndpoint: sqmEvidenceEndpoint(run.session),
|
|
758
|
+
webOrigin: run.apiOrigin,
|
|
759
|
+
historyRoot: run.sqmHistoryRoot,
|
|
754
760
|
accountId: run.session?.account_id || run.session?.auth_user_id || run.session?.device_id,
|
|
755
761
|
organizationId: run.session?.organization_id || run.session?.org_id,
|
|
756
762
|
deviceId: run.session?.device_id,
|
|
@@ -43,7 +43,7 @@ async function boundedJson(response) {
|
|
|
43
43
|
// login cannot associate work in the newly selected account.
|
|
44
44
|
export function createActionAssociationApi({ webOrigin, sessionRoot,
|
|
45
45
|
fetchImpl = fetch, readSessionImpl = readSession, now = Date.now, timeoutMs = 10_000 } = {}) {
|
|
46
|
-
const identity = session => createHash('sha256').update(
|
|
46
|
+
const identity = session => createHash('sha256').update(JSON.stringify([webOrigin, session.token, session.organization_id ?? null, session.user_id ?? null, session.account_id ?? null, session.device_id ?? null, session.api_base_url ?? null, session.expires_at])).digest('hex');
|
|
47
47
|
const currentSession = async () => {
|
|
48
48
|
const session = await readSessionImpl(webOrigin, sessionRoot);
|
|
49
49
|
if (!isSessionValid(session, now(), webOrigin)) throw new ActionAssociationApiError('signed_out');
|
|
@@ -28,10 +28,11 @@ const deadlineBlocker = (record, candidate) => {
|
|
|
28
28
|
// Association is optional metadata. Network failures never control the task's
|
|
29
29
|
// work state; provider turns and their budgets remain owned by the supervisor.
|
|
30
30
|
export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
|
|
31
|
-
now = Date.now, refreshMs = 60_000, list = listRecords, read = readRecord, onChange = () => {} } = {}) {
|
|
31
|
+
now = Date.now, refreshMs = 60_000, list = listRecords, readRecords, read = readRecord, onChange = () => {} } = {}) {
|
|
32
32
|
const abort = new AbortController(), views = new Map();
|
|
33
|
+
if (readRecords) list = () => readRecords();
|
|
33
34
|
const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
|
|
34
|
-
let auth = null, closed = false, running = null, serial = Promise.resolve();
|
|
35
|
+
let auth = null, closed = false, running = null, serial = Promise.resolve(), activeCursor = 0, historyCursor = 0;
|
|
35
36
|
const enqueue = work => {
|
|
36
37
|
const result = serial.then(work); serial = result.catch(() => {}); return result;
|
|
37
38
|
};
|
|
@@ -44,7 +45,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
44
45
|
if (!association || association.authRevision === next) continue;
|
|
45
46
|
await updateRecord(root, record.provider, record.session, latest => {
|
|
46
47
|
const local = latest?.run?.managed?.actionAssociation;
|
|
47
|
-
if (!local || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
|
|
48
|
+
if (!local || local.authRevision === next || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
|
|
48
49
|
local.invalidated = true; local.binding = null; delete local.outbox; local.status = 'unavailable';
|
|
49
50
|
return latest;
|
|
50
51
|
});
|
|
@@ -57,6 +58,9 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
57
58
|
return eligible(record || {}) && record.run.managed.taskId === target.taskId ? record : null;
|
|
58
59
|
};
|
|
59
60
|
const valid = async expected => !closed && expected && await current() === expected;
|
|
61
|
+
// Called inside record locks: reading auth directly cannot recursively take
|
|
62
|
+
// the same lock through current()'s account-change invalidation pass.
|
|
63
|
+
const validateScope = expected => async () => !closed && await api.authRevision() === expected;
|
|
60
64
|
const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
|
|
61
65
|
if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
|
|
62
66
|
if (envelope.binding && envelope.binding.taskId !== record.run.managed.taskId) throw new ActionAssociationApiError('invalid_response');
|
|
@@ -64,9 +68,10 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
64
68
|
const local = record.run.managed.actionAssociation;
|
|
65
69
|
// An empty remote binding must not erase a cached ambiguous/no-match result.
|
|
66
70
|
if (userCorrection || envelope.binding || local?.binding || local?.namespace && local.namespace !== namespace) {
|
|
67
|
-
await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now() });
|
|
71
|
+
await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now(), validate: validateScope(expected) });
|
|
68
72
|
}
|
|
69
|
-
await updateRecord(root, record.provider, record.session, latest => {
|
|
73
|
+
await updateRecord(root, record.provider, record.session, async latest => {
|
|
74
|
+
if (!await validateScope(expected)()) throw new ActionAssociationApiError('account_changed');
|
|
70
75
|
const local = latest?.run?.managed?.actionAssociation;
|
|
71
76
|
if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
|
|
72
77
|
latest.run.managed.actionAssociation = { ...local, authRevision: expected };
|
|
@@ -81,11 +86,12 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
81
86
|
const request = actionCandidateRequest(record);
|
|
82
87
|
const contextRevision = request?.context.revision || null;
|
|
83
88
|
if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
|
|
84
|
-
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() });
|
|
89
|
+
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(), validate: validateScope(expected) });
|
|
85
90
|
return;
|
|
86
91
|
}
|
|
87
92
|
view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
|
|
88
|
-
views.set(target.taskId, view);
|
|
93
|
+
views.delete(target.taskId); views.set(target.taskId, view);
|
|
94
|
+
while (views.size > 500) views.delete(views.keys().next().value);
|
|
89
95
|
try {
|
|
90
96
|
// Never publish an outbox from an earlier organization or login before a
|
|
91
97
|
// scoped server response confirms who owns the current request.
|
|
@@ -100,23 +106,24 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
100
106
|
const pending = latest.run.managed.actionAssociation?.outbox;
|
|
101
107
|
if (pending && pending.namespace === namespace && latest.run.managed.actionAssociation.authRevision === expected) {
|
|
102
108
|
if (snapshot.binding?.origin === 'automatic' && snapshot.binding.action?.id === pending.actionId && snapshot.binding.contextRevision === pending.contextRevision && snapshot.binding.candidateRevision === pending.candidateRevision) {
|
|
103
|
-
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now() }); return;
|
|
109
|
+
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now(), validate: validateScope(expected) }); return;
|
|
104
110
|
}
|
|
105
111
|
if (snapshot.contextRevision !== pending.contextRevision || snapshot.candidateRevision !== pending.candidateRevision) {
|
|
106
|
-
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now() });
|
|
112
|
+
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now(), validate: validateScope(expected) });
|
|
107
113
|
view.nextRefresh = 0; return;
|
|
108
114
|
}
|
|
109
115
|
const payload = Object.fromEntries(['expectedRevision', 'contextRevision', 'candidateRevision', 'actionId', 'actionRevision', 'relation', 'coveredWork', 'reason', 'evidence'].map(key => [key, pending[key]]));
|
|
110
116
|
const saved = await api.saveBinding(target.taskId, { ...payload, origin: 'automatic' }, { signal: abort.signal });
|
|
111
117
|
if (!await valid(expected) || namespaceFor(webOrigin, saved.scope) !== namespace) return;
|
|
112
|
-
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now() });
|
|
118
|
+
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now(), validate: validateScope(expected) });
|
|
113
119
|
return;
|
|
114
120
|
}
|
|
115
121
|
if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
|
|
116
122
|
// A read turn is queued only at the original session's safe boundary.
|
|
117
123
|
// Blocked tasks retain candidates for auxiliary correction in the UI.
|
|
118
|
-
await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now() });
|
|
119
|
-
await updateRecord(root, latest.provider, latest.session, currentRecord => {
|
|
124
|
+
await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
125
|
+
await updateRecord(root, latest.provider, latest.session, async currentRecord => {
|
|
126
|
+
if (!await validateScope(expected)()) throw new ActionAssociationApiError('account_changed');
|
|
120
127
|
if (currentRecord?.run?.managed?.taskId === target.taskId && currentRecord.run.managed.actionAssociation) currentRecord.run.managed.actionAssociation.authRevision = expected;
|
|
121
128
|
return currentRecord;
|
|
122
129
|
});
|
|
@@ -131,43 +138,64 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
131
138
|
const remote = await api.readBinding(target.taskId, { signal: abort.signal });
|
|
132
139
|
const namespace = await importEnvelope(latest, remote, expected);
|
|
133
140
|
const box = latest.run.managed.actionAssociation?.outbox;
|
|
134
|
-
if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now() });
|
|
135
|
-
else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now() });
|
|
141
|
+
if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now(), validate: validateScope(expected) });
|
|
142
|
+
else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now(), validate: validateScope(expected) });
|
|
136
143
|
view.namespace = namespace; view.nextRefresh = 0;
|
|
137
144
|
} catch { view.error = 'offline'; }
|
|
138
145
|
} else if (hardFailure(error.code)) {
|
|
139
146
|
view.candidates = []; view.snapshot = null;
|
|
140
|
-
await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now() });
|
|
147
|
+
await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now(), validate: validateScope(expected) });
|
|
141
148
|
} else view.nextRefresh = now() + Math.min(refreshMs, 15_000);
|
|
142
149
|
}
|
|
143
150
|
}
|
|
151
|
+
function window(records, cursor, limit) {
|
|
152
|
+
const count = Math.min(limit, records.length);
|
|
153
|
+
return { records: Array.from({ length: count }, (_, i) => records[(cursor + i) % records.length]), cursor: records.length ? (cursor + count) % records.length : 0 };
|
|
154
|
+
}
|
|
144
155
|
async function reconcile() {
|
|
145
|
-
const expected = await current(),
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
const expected = await current(), all = (await list(root)).filter(eligible);
|
|
157
|
+
// Cancellation is a safety pass, independent of the optional network work
|
|
158
|
+
// budget. A large history must never hide an expired/paused matching job.
|
|
159
|
+
for (const record of all) {
|
|
160
|
+
if (closed) return;
|
|
150
161
|
const managed = record.run.managed, job = managed.actionAssociation?.job;
|
|
151
162
|
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' ||
|
|
152
163
|
managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
|
|
153
164
|
managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
|
|
154
|
-
await cancelActionAssociation({ root, ...identity(record), now: now() });
|
|
165
|
+
await cancelActionAssociation({ root, ...identity(record), expectedJobId: job.id, now: now() });
|
|
155
166
|
}
|
|
156
|
-
if (!expected) {
|
|
157
|
-
|
|
158
|
-
continue;
|
|
167
|
+
if (!expected && managed.actionAssociation?.namespace && managed.actionAssociation.status !== 'unavailable') {
|
|
168
|
+
await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now(), validate: validateScope(expected) });
|
|
159
169
|
}
|
|
160
|
-
try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
|
|
161
170
|
}
|
|
171
|
+
if (!expected) return;
|
|
172
|
+
const active = all.filter(record => !TERMINAL.has(record.run.state)), history = all.filter(record => TERMINAL.has(record.run.state));
|
|
173
|
+
// Prefer live work while reserving a small history window. Both cursors
|
|
174
|
+
// rotate so the first hash-sorted records cannot monopolize every tick.
|
|
175
|
+
const nextActive = window(active, activeCursor, history.length ? 24 : 30);
|
|
176
|
+
const nextHistory = window(history, historyCursor, 30 - nextActive.records.length);
|
|
177
|
+
activeCursor = nextActive.cursor; historyCursor = nextHistory.cursor;
|
|
178
|
+
const records = [...nextActive.records, ...nextHistory.records];
|
|
179
|
+
let cursor = 0;
|
|
180
|
+
await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
|
|
181
|
+
while (!closed && cursor < records.length) {
|
|
182
|
+
const record = records[cursor++];
|
|
183
|
+
try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
|
|
184
|
+
}
|
|
162
185
|
}));
|
|
163
186
|
}
|
|
164
187
|
return {
|
|
165
188
|
// A projection is private to one desktop frame. Mutations and deadline
|
|
166
189
|
// validation still reread the exact record and current authentication.
|
|
167
|
-
async beginProjection() {
|
|
168
|
-
await current();
|
|
169
|
-
const records = await list(root);
|
|
170
|
-
|
|
190
|
+
async beginProjection({ records: suppliedRecords } = {}) {
|
|
191
|
+
const authRevision = await current();
|
|
192
|
+
const records = suppliedRecords || await list(root);
|
|
193
|
+
const taskIds = new Set(records.map(record => record.run?.managed?.taskId).filter(Boolean));
|
|
194
|
+
for (const taskId of views.keys()) if (!taskIds.has(taskId)) views.delete(taskId);
|
|
195
|
+
return { records, authRevision, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
|
|
196
|
+
},
|
|
197
|
+
async validateProjection(projection) {
|
|
198
|
+
return !closed && await current() === projection.authRevision;
|
|
171
199
|
},
|
|
172
200
|
async decorateSessions(sessions, projection) {
|
|
173
201
|
await ordinary.decorate(sessions, projection);
|
|
@@ -176,16 +204,16 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
176
204
|
const record = records.get(`${item.provider}:${item.session}`);
|
|
177
205
|
return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
|
|
178
206
|
}) };
|
|
179
|
-
await this.decorate(status);
|
|
207
|
+
await this.decorate(status, projection);
|
|
180
208
|
for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
|
|
181
209
|
return sessions;
|
|
182
210
|
},
|
|
183
211
|
async deadlineContext(candidate, projection) {
|
|
184
|
-
const expected = await current(), record = projection
|
|
212
|
+
const expected = projection ? projection.authRevision : await current(), record = projection
|
|
185
213
|
? projection.byKey.get(`${candidate.provider}:${candidate.session}`)
|
|
186
214
|
: await read(root, candidate.provider, candidate.session);
|
|
187
215
|
const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
|
|
188
|
-
if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
|
|
216
|
+
if (!expected || projection && auth !== expected || local?.authRevision !== expected || !(candidate.actionAssociation?.canCorrect || candidate.actionAssociation?.canReadBinding)) return null;
|
|
189
217
|
const binding = candidate.actionAssociation.binding;
|
|
190
218
|
const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
|
|
191
219
|
return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
|
|
@@ -195,15 +223,17 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
195
223
|
if (!await valid(expected)) return false;
|
|
196
224
|
const latest = await read(root, candidate.provider, candidate.session);
|
|
197
225
|
const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
|
|
226
|
+
if (!await valid(expected)) return false;
|
|
198
227
|
return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
|
|
199
228
|
} };
|
|
200
229
|
|
|
201
230
|
},
|
|
202
231
|
async prepareCached(record) {
|
|
232
|
+
const expected = await current();
|
|
203
233
|
const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
|
|
204
234
|
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;
|
|
205
235
|
try {
|
|
206
|
-
const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
|
|
236
|
+
const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
207
237
|
return prepared || record;
|
|
208
238
|
} catch { return record; }
|
|
209
239
|
},
|
|
@@ -212,8 +242,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
212
242
|
if (!running) running = reconcile().finally(() => { running = null; });
|
|
213
243
|
return running;
|
|
214
244
|
},
|
|
215
|
-
async decorate(status) {
|
|
216
|
-
const expected = await current();
|
|
245
|
+
async decorate(status, projection) {
|
|
246
|
+
const expected = projection ? projection.authRevision : await current();
|
|
217
247
|
let remainingBytes = 24 * 1024;
|
|
218
248
|
const perRunBytes = Math.floor(remainingBytes / Math.max(1,(status.runs || []).filter(run => run.managed).length));
|
|
219
249
|
for (const run of status.runs || []) {
|
|
@@ -3,6 +3,10 @@ import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks
|
|
|
3
3
|
import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
4
4
|
export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
5
5
|
|
|
6
|
+
async function assertScope(validate) {
|
|
7
|
+
if (validate && !await validate()) throw Object.assign(new Error('Action association account changed.'), { code: 'account_changed' });
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
function stable(run) {
|
|
7
11
|
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
12
|
}
|
|
@@ -16,8 +20,9 @@ function restoreAssociationWork(run, saved) {
|
|
|
16
20
|
}
|
|
17
21
|
managed.awaitingAssociationHook = false;
|
|
18
22
|
}
|
|
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 => {
|
|
23
|
+
export async function queueActionAssociation({ root, provider, session, taskId, expectedRevision, candidates, candidateRevision, scope, namespace = null, remoteBinding = null, now = Date.now(), validate }) {
|
|
24
|
+
return updateRecord(root, provider, session, async record => {
|
|
25
|
+
await assertScope(validate);
|
|
21
26
|
const run = record?.run, managed = run?.managed;
|
|
22
27
|
if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
|
|
23
28
|
if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
|
|
@@ -45,8 +50,9 @@ export async function queueActionAssociation({ root, provider, session, taskId,
|
|
|
45
50
|
return record;
|
|
46
51
|
});
|
|
47
52
|
}
|
|
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 => {
|
|
53
|
+
export async function importActionBinding({ root, provider, session, taskId, namespace, binding = null, unavailable = false, userCorrection = false, now = Date.now(), validate }) {
|
|
54
|
+
return updateRecord(root, provider, session, async record => {
|
|
55
|
+
await assertScope(validate);
|
|
50
56
|
const run = record?.run, managed = run?.managed;
|
|
51
57
|
if (managed?.taskId !== taskId || managed.handoff && managed.handoff.status !== 'received') return record;
|
|
52
58
|
const previous = managed.actionAssociation || {};
|
|
@@ -79,7 +85,7 @@ export function actionCandidateRequest(record) {
|
|
|
79
85
|
const context = associationSignals(record?.run?.managed?.sessionUnderstanding);
|
|
80
86
|
return context ? { taskId: record.run.managed.taskId, context, limit: 10 } : null;
|
|
81
87
|
}
|
|
82
|
-
export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now() }) {
|
|
88
|
+
export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now(), validate }) {
|
|
83
89
|
const request = actionCandidateRequest(record);
|
|
84
90
|
if (!request || snapshot?.schemaVersion !== 1 || snapshot.taskId !== request.taskId || snapshot.contextRevision !== request.context.revision || !snapshot.scope?.organizationId || !snapshot.scope?.userId) return null;
|
|
85
91
|
if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length > 10 || new Set(snapshot.candidates.map(item => item.id)).size !== snapshot.candidates.length) return null;
|
|
@@ -88,10 +94,11 @@ export async function prepareActionAssociation({ root, record, snapshot, webOrig
|
|
|
88
94
|
}
|
|
89
95
|
const namespace = JSON.stringify([new URL(webOrigin).origin,snapshot.scope.organizationId,snapshot.scope.userId]);
|
|
90
96
|
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 });
|
|
97
|
+
candidates: snapshot.candidates, candidateRevision: snapshot.candidateRevision, scope: snapshot.scope, namespace, remoteBinding: snapshot.binding, now, validate });
|
|
92
98
|
}
|
|
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 => {
|
|
99
|
+
export async function settleActionAssociation({ root, provider, session, taskId, outboxId, namespace, binding = null, unavailable = false, now = Date.now(), validate }) {
|
|
100
|
+
return updateRecord(root, provider, session, async record => {
|
|
101
|
+
await assertScope(validate);
|
|
95
102
|
const association = record?.run?.managed?.actionAssociation;
|
|
96
103
|
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
104
|
if (binding && binding.taskId === taskId) {
|
|
@@ -102,10 +109,10 @@ export async function settleActionAssociation({ root, provider, session, taskId,
|
|
|
102
109
|
return record;
|
|
103
110
|
});
|
|
104
111
|
}
|
|
105
|
-
export async function cancelActionAssociation({ root, provider, session, taskId, now = Date.now() }) {
|
|
112
|
+
export async function cancelActionAssociation({ root, provider, session, taskId, expectedJobId, now = Date.now() }) {
|
|
106
113
|
return updateRecord(root, provider, session, record => {
|
|
107
114
|
const run = record?.run, managed = run?.managed, association = managed?.actionAssociation;
|
|
108
|
-
if (managed?.taskId !== taskId || managed.resumeLease || !association?.job) return record;
|
|
115
|
+
if (managed?.taskId !== taskId || managed.resumeLease || !association?.job || expectedJobId && association.job.id !== expectedJobId) return record;
|
|
109
116
|
const saved = association.job.saved;
|
|
110
117
|
restoreAssociationWork(run, saved);
|
|
111
118
|
managed.awaitingAssociationHook = false; association.status = 'unavailable'; delete association.job; association.updatedAt = now;
|
|
@@ -150,7 +150,7 @@ export function createActionDeadlineService({
|
|
|
150
150
|
plan: context.plan ?? null,
|
|
151
151
|
recommendation: deadlineRecommendation(context),
|
|
152
152
|
});
|
|
153
|
-
async function reconcile(context) {
|
|
153
|
+
async function reconcile(context, { displayOnly = false } = {}) {
|
|
154
154
|
const key = identity(context),
|
|
155
155
|
action = context.binding?.action;
|
|
156
156
|
const bindingRevision =
|
|
@@ -164,6 +164,14 @@ export function createActionDeadlineService({
|
|
|
164
164
|
action?.status,
|
|
165
165
|
terminal.has(context.taskStatus),
|
|
166
166
|
]);
|
|
167
|
+
if (displayOnly) {
|
|
168
|
+
// Host display already owns a frame-scoped record/auth snapshot and an
|
|
169
|
+
// authorization exit fence. An unchanged durable deadline needs no write
|
|
170
|
+
// transaction or per-session authorization read; project still evaluates
|
|
171
|
+
// the current time and fresh local decisions on every heartbeat.
|
|
172
|
+
const previous = await store.read(key);
|
|
173
|
+
if (previous?.fingerprint === fingerprint && previous.status !== 'unavailable') return project(previous, context);
|
|
174
|
+
}
|
|
167
175
|
const state = await transact(context, (previous) => {
|
|
168
176
|
if (context.online === false) return previous;
|
|
169
177
|
// Tombstones retain the last authoritative revision, so old snapshots
|