gestalt-mobile 0.17.2 → 0.18.0
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 +5 -1
- package/dist/client/assets/index-B0Uq4OB0.css +1 -0
- package/dist/client/assets/index-orfZBEaD.js +27 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +6 -0
- package/dist/server/server/composition.js +413 -21
- package/dist/server/server/features/agent-activity/model.js +197 -0
- package/dist/server/server/features/agent-activity/registry.js +155 -0
- package/dist/server/server/features/autopilot/application/policy.js +55 -0
- package/dist/server/server/features/autopilot/application/ports.js +6 -0
- package/dist/server/server/features/autopilot/application/service.js +542 -0
- package/dist/server/server/features/autopilot/domain/autopilot-session.js +40 -0
- package/dist/server/server/features/autopilot/register-routes.js +9 -0
- package/dist/server/server/features/autopilot/toggle/endpoint.js +32 -0
- package/dist/server/server/features/org-plan-attention/application/ports.js +6 -0
- package/dist/server/server/features/org-plan-attention/get-active/endpoint.js +22 -0
- package/dist/server/server/features/org-plan-attention/register-routes.js +11 -0
- package/dist/server/server/features/org-plan-attention/resolve/endpoint.js +42 -0
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +4 -0
- package/dist/server/server/features/sessions/get-history/endpoint.js +62 -2
- package/dist/server/server/features/sessions/get-history/history-mapper.js +17 -5
- package/dist/server/server/features/sessions/get-session/endpoint.js +3 -1
- package/dist/server/server/features/sessions/interaction/kind.js +1 -0
- package/dist/server/server/features/sessions/interaction/response-validator.js +5 -1
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +2 -0
- package/dist/server/server/features/sessions/model/relay-session.js +3 -0
- package/dist/server/server/features/sessions/refresh-activity/endpoint.js +14 -0
- package/dist/server/server/features/sessions/register-routes.js +15 -2
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
- package/dist/server/server/features/sessions/restore-session/endpoint.js +21 -5
- package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
- package/dist/server/server/features/skills/model/skill-profile.js +13 -5
- package/dist/server/server/platform/codex/activity-facts.js +88 -0
- package/dist/server/server/platform/codex/server-request.js +21 -4
- package/dist/server/server/platform/codex/session-runtime.js +114 -22
- package/dist/server/server/platform/persistence/migrate.js +16 -1
- package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
- package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
- package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +62 -3
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
- package/dist/server/shared/contracts/org-plan-attention.js +101 -0
- package/package.json +1 -1
- package/dist/client/assets/index-DgD-0I3M.js +0 -25
- package/dist/client/assets/index-e2lJ5XqL.css +0 -1
|
@@ -8,9 +8,11 @@ import { randomUUID } from 'node:crypto';
|
|
|
8
8
|
import { createPlanMeasurementSnapshot, } from '../../features/plans/application/measurement-snapshot.js';
|
|
9
9
|
import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/sessions/restore-session/use-case.js';
|
|
10
10
|
import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
|
|
11
|
+
import { gestaltOrgPlanAttentionDynamicTool } from '../../../shared/contracts/org-plan-attention.js';
|
|
11
12
|
import { threadPlanName } from './thread-plan-name.js';
|
|
12
13
|
import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
|
|
13
14
|
import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
|
|
15
|
+
import { resolvedServerRequestId } from './server-request.js';
|
|
14
16
|
/** One private owner for every resource acquired for a live Codex child. */
|
|
15
17
|
class SessionResource {
|
|
16
18
|
sessionId;
|
|
@@ -38,7 +40,6 @@ class SessionResource {
|
|
|
38
40
|
return false;
|
|
39
41
|
this.disposed = true;
|
|
40
42
|
for (const pending of this.pendingRequests.values()) {
|
|
41
|
-
clearTimeout(pending.timer);
|
|
42
43
|
pending.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
|
|
43
44
|
}
|
|
44
45
|
this.pendingRequests.clear();
|
|
@@ -65,13 +66,14 @@ export class CodexSessionRuntime {
|
|
|
65
66
|
planStatusSource;
|
|
66
67
|
onPlanStatus;
|
|
67
68
|
planMeasurementBaseUrl;
|
|
68
|
-
requestTimeoutMs;
|
|
69
69
|
maxPendingRequests;
|
|
70
70
|
readerCwd;
|
|
71
71
|
constructor(launch,
|
|
72
72
|
// Kept as an ignored compatibility slot while callers migrate from the old
|
|
73
73
|
// correlated-map constructor shape. Live ownership is `sessions` only.
|
|
74
|
-
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
|
|
74
|
+
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
|
|
75
|
+
// Compatibility slot: blocking app-server requests wait for explicit input.
|
|
76
|
+
_legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd) {
|
|
75
77
|
this.launch = launch;
|
|
76
78
|
this.onNotification = onNotification;
|
|
77
79
|
this.onServerRequest = onServerRequest;
|
|
@@ -80,10 +82,10 @@ export class CodexSessionRuntime {
|
|
|
80
82
|
this.planStatusSource = planStatusSource;
|
|
81
83
|
this.onPlanStatus = onPlanStatus;
|
|
82
84
|
this.planMeasurementBaseUrl = planMeasurementBaseUrl;
|
|
83
|
-
this.requestTimeoutMs = requestTimeoutMs;
|
|
84
85
|
this.maxPendingRequests = maxPendingRequests;
|
|
85
86
|
this.readerCwd = readerCwd;
|
|
86
87
|
void _legacyProcesses;
|
|
88
|
+
void _legacyRequestTimeoutMs;
|
|
87
89
|
}
|
|
88
90
|
sessions = new Map();
|
|
89
91
|
historyReads = new Map();
|
|
@@ -99,7 +101,9 @@ export class CodexSessionRuntime {
|
|
|
99
101
|
resource.threadId = startedThreadId;
|
|
100
102
|
this.sessions.set(session.id, resource);
|
|
101
103
|
await this.writePendingThreadName(session.id);
|
|
102
|
-
return RelaySession.rehydrate(session)
|
|
104
|
+
return RelaySession.rehydrate(session)
|
|
105
|
+
.bindThread(startedThreadId, now)
|
|
106
|
+
.supportsAttentionTool(now).snapshot;
|
|
103
107
|
}
|
|
104
108
|
catch (error) {
|
|
105
109
|
resource.dispose();
|
|
@@ -138,10 +142,16 @@ export class CodexSessionRuntime {
|
|
|
138
142
|
if (!resource || !pending)
|
|
139
143
|
return false;
|
|
140
144
|
resource.pendingRequests.delete(requestId);
|
|
141
|
-
clearTimeout(pending.timer);
|
|
142
145
|
pending.resolve(result);
|
|
143
146
|
return true;
|
|
144
147
|
}
|
|
148
|
+
/** Distinguishes an offline relay writer from a live writer that cleared a request. */
|
|
149
|
+
attentionWriterState(sessionId, requestId) {
|
|
150
|
+
const resource = this.sessions.get(sessionId);
|
|
151
|
+
if (!resource || !resource.active)
|
|
152
|
+
return 'unavailable';
|
|
153
|
+
return resource.pendingRequests.has(requestId) ? 'available' : 'cleared';
|
|
154
|
+
}
|
|
145
155
|
async startTurn(session, text, clientUserMessageId, now) {
|
|
146
156
|
const resource = this.sessions.get(session.id);
|
|
147
157
|
if (!resource || !session.threadId)
|
|
@@ -229,6 +239,67 @@ export class CodexSessionRuntime {
|
|
|
229
239
|
this.historyReads.delete(session.id);
|
|
230
240
|
}
|
|
231
241
|
}
|
|
242
|
+
/** Bounded reconciliation port for direct spawned children only. */
|
|
243
|
+
async listDirectChildren(session) {
|
|
244
|
+
if (!session.threadId)
|
|
245
|
+
throw new Error('CODEX_THREAD_ID_MISSING');
|
|
246
|
+
const owned = this.sessions.get(session.id);
|
|
247
|
+
if (!owned)
|
|
248
|
+
return [];
|
|
249
|
+
const children = [];
|
|
250
|
+
const cursors = new Set();
|
|
251
|
+
let cursor;
|
|
252
|
+
for (let page = 0; page < 4 && children.length < 64; page += 1) {
|
|
253
|
+
const result = await owned.process.rpc.request('thread/list', {
|
|
254
|
+
parentThreadId: session.threadId,
|
|
255
|
+
...(cursor ? { cursor } : {}),
|
|
256
|
+
});
|
|
257
|
+
const response = result && typeof result === 'object' ? result : null;
|
|
258
|
+
const data = response && Array.isArray(response.data) ? response.data : [];
|
|
259
|
+
children.push(...data.flatMap((candidate) => {
|
|
260
|
+
if (!candidate || typeof candidate !== 'object')
|
|
261
|
+
return [];
|
|
262
|
+
const value = candidate;
|
|
263
|
+
if (typeof value.id !== 'string' || value.id.length === 0 || value.id.length > 256)
|
|
264
|
+
return [];
|
|
265
|
+
const rawStatus = value.status && typeof value.status === 'object'
|
|
266
|
+
? value.status.type
|
|
267
|
+
: undefined;
|
|
268
|
+
// `thread/list` is our bounded child-authority read. A row without
|
|
269
|
+
// one of its documented statuses cannot prove a child is healthy.
|
|
270
|
+
const status = rawStatus === 'active' ||
|
|
271
|
+
rawStatus === 'idle' ||
|
|
272
|
+
rawStatus === 'notLoaded' ||
|
|
273
|
+
rawStatus === 'systemError'
|
|
274
|
+
? rawStatus
|
|
275
|
+
: undefined;
|
|
276
|
+
return [
|
|
277
|
+
{
|
|
278
|
+
id: value.id,
|
|
279
|
+
...(status ? { status } : { status: 'notLoaded', qualified: false }),
|
|
280
|
+
...(typeof value.agentNickname === 'string' && value.agentNickname.length <= 128
|
|
281
|
+
? { nickname: value.agentNickname }
|
|
282
|
+
: {}),
|
|
283
|
+
...(typeof value.agentRole === 'string' && value.agentRole.length <= 128
|
|
284
|
+
? { role: value.agentRole }
|
|
285
|
+
: {}),
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
}));
|
|
289
|
+
const next = typeof response?.nextCursor === 'string' && response.nextCursor.length <= 256
|
|
290
|
+
? response.nextCursor
|
|
291
|
+
: undefined;
|
|
292
|
+
if (!next)
|
|
293
|
+
return children;
|
|
294
|
+
if (cursors.has(next) || children.length >= 64)
|
|
295
|
+
throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
|
|
296
|
+
cursors.add(next);
|
|
297
|
+
cursor = next;
|
|
298
|
+
}
|
|
299
|
+
if (cursor)
|
|
300
|
+
throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
|
|
301
|
+
return children;
|
|
302
|
+
}
|
|
232
303
|
async readDetachedHistory(session) {
|
|
233
304
|
// A reader is intentionally not a SessionResource: it owns no subscriptions,
|
|
234
305
|
// runtime registration, plan lease, or writer state and is closed on every path.
|
|
@@ -278,10 +349,10 @@ export class CodexSessionRuntime {
|
|
|
278
349
|
await resource.process.rpc.request('thread/resume', {
|
|
279
350
|
threadId: session.threadId,
|
|
280
351
|
cwd: session.workspacePath,
|
|
281
|
-
dynamicTools: [gestaltQuizDynamicTool],
|
|
352
|
+
dynamicTools: [gestaltQuizDynamicTool, gestaltOrgPlanAttentionDynamicTool],
|
|
282
353
|
});
|
|
283
354
|
result = {
|
|
284
|
-
session: RelaySession.rehydrate(session).restore(now).snapshot,
|
|
355
|
+
session: RelaySession.rehydrate(session).restore(now).supportsAttentionTool(now).snapshot,
|
|
285
356
|
historyUnavailable: false,
|
|
286
357
|
replacementCreated: false,
|
|
287
358
|
};
|
|
@@ -337,18 +408,17 @@ export class CodexSessionRuntime {
|
|
|
337
408
|
}
|
|
338
409
|
}
|
|
339
410
|
holdServerRequest(resource, request) {
|
|
340
|
-
if (!resource.active
|
|
341
|
-
return Promise.reject(new Error('
|
|
342
|
-
}
|
|
411
|
+
if (!resource.active)
|
|
412
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
|
|
343
413
|
if (resource.pendingRequests.size >= this.maxPendingRequests)
|
|
344
414
|
return Promise.reject(new Error('CODEX_SERVER_REQUEST_LIMIT'));
|
|
345
415
|
const requestId = String(request.id);
|
|
416
|
+
if (resource.pendingRequests.has(requestId))
|
|
417
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_DUPLICATE'));
|
|
418
|
+
if (!this.onServerRequest?.(resource.sessionId, request))
|
|
419
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
|
|
346
420
|
return new Promise((resolve, reject) => {
|
|
347
|
-
|
|
348
|
-
if (resource.pendingRequests.delete(requestId))
|
|
349
|
-
reject(new Error('CODEX_SERVER_REQUEST_TIMEOUT'));
|
|
350
|
-
}, this.requestTimeoutMs);
|
|
351
|
-
resource.pendingRequests.set(requestId, { resolve, reject, timer });
|
|
421
|
+
resource.pendingRequests.set(requestId, { resolve, reject });
|
|
352
422
|
});
|
|
353
423
|
}
|
|
354
424
|
async startThread(process, session, settings = {}) {
|
|
@@ -358,7 +428,7 @@ export class CodexSessionRuntime {
|
|
|
358
428
|
return {
|
|
359
429
|
cwd: session.workspacePath,
|
|
360
430
|
approvalPolicy: settings.approvalPolicy ?? 'on-request',
|
|
361
|
-
dynamicTools: [gestaltQuizDynamicTool],
|
|
431
|
+
dynamicTools: [gestaltQuizDynamicTool, gestaltOrgPlanAttentionDynamicTool],
|
|
362
432
|
...(settings.model ? { model: settings.model } : {}),
|
|
363
433
|
...(settings.sandbox ? { sandbox: settings.sandbox } : {}),
|
|
364
434
|
};
|
|
@@ -393,15 +463,37 @@ export class CodexSessionRuntime {
|
|
|
393
463
|
if (this.sessions.get(session.id) === resource)
|
|
394
464
|
this.sessions.delete(session.id);
|
|
395
465
|
});
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
this.onNotification?.(session.id, notification);
|
|
399
|
-
});
|
|
400
|
-
const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
|
|
466
|
+
// Own process exit before resume/initialization can make this resource appear healthy.
|
|
467
|
+
// A short-lived child could otherwise leave a durable ready session without a writer.
|
|
401
468
|
const exitUnsubscribe = process.onExit?.(() => {
|
|
402
469
|
if (resource.dispose())
|
|
403
470
|
this.onProcessExit?.(session.id);
|
|
404
471
|
}) ?? (() => { });
|
|
472
|
+
resource.attach([exitUnsubscribe]);
|
|
473
|
+
if (!resource.active) {
|
|
474
|
+
exitUnsubscribe();
|
|
475
|
+
throw new Error('CODEX_SESSION_PROCESS_EXITED');
|
|
476
|
+
}
|
|
477
|
+
const notificationUnsubscribe = process.rpc.onNotification((notification) => {
|
|
478
|
+
if (!resource.active)
|
|
479
|
+
return;
|
|
480
|
+
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
481
|
+
if (resolvedRequestId) {
|
|
482
|
+
const pending = resource.pendingRequests.get(resolvedRequestId);
|
|
483
|
+
resource.pendingRequests.delete(resolvedRequestId);
|
|
484
|
+
// App-server has already cleared the request, so settle the local handler too.
|
|
485
|
+
// The late JSON-RPC error response is harmless and prevents an orphaned promise.
|
|
486
|
+
// The notification callback reconciles the durable interaction record.
|
|
487
|
+
pending?.reject(new Error('CODEX_SERVER_REQUEST_CLEARED'));
|
|
488
|
+
}
|
|
489
|
+
this.onNotification?.(session.id, notification);
|
|
490
|
+
});
|
|
491
|
+
const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
|
|
492
|
+
if (!resource.active) {
|
|
493
|
+
notificationUnsubscribe();
|
|
494
|
+
requestUnsubscribe();
|
|
495
|
+
throw new Error('CODEX_SESSION_PROCESS_EXITED');
|
|
496
|
+
}
|
|
405
497
|
// The resource's private unsubscribe list is populated before it is published.
|
|
406
498
|
resource.attach([notificationUnsubscribe, requestUnsubscribe, exitUnsubscribe]);
|
|
407
499
|
return resource;
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
-
const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
|
|
6
|
+
const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
|
|
7
7
|
export function migrate(database) {
|
|
8
8
|
database.exec(schema);
|
|
9
|
+
database.exec("CREATE INDEX IF NOT EXISTS session_events_autopilot_audit_tail_v2 ON session_events(session_id, type, sequence DESC) WHERE type IN ('autopilot.continuation-scheduled','autopilot.control-issued','autopilot.turn-started','autopilot.turn-failed','autopilot.progress-reset','autopilot.updated','org-plan.attention-required','org-plan.attention-resolved')");
|
|
9
10
|
const columns = database.prepare('PRAGMA table_info(relay_sessions)').all();
|
|
10
11
|
if (!columns.some((column) => column.name === 'effective_skill_selection_json'))
|
|
11
12
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
|
|
@@ -15,6 +16,8 @@ export function migrate(database) {
|
|
|
15
16
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN model TEXT');
|
|
16
17
|
if (!columns.some((column) => column.name === 'branch'))
|
|
17
18
|
database.exec('ALTER TABLE relay_sessions ADD COLUMN branch TEXT');
|
|
19
|
+
if (!columns.some((column) => column.name === 'attention_tool_capability'))
|
|
20
|
+
database.exec('ALTER TABLE relay_sessions ADD COLUMN attention_tool_capability TEXT');
|
|
18
21
|
const interactionColumns = database
|
|
19
22
|
.prepare('PRAGMA table_info(pending_interactions)')
|
|
20
23
|
.all();
|
|
@@ -24,4 +27,16 @@ export function migrate(database) {
|
|
|
24
27
|
database.exec('ALTER TABLE pending_interactions ADD COLUMN requested_at TEXT');
|
|
25
28
|
if (!interactionColumns.some((column) => column.name === 'outcome'))
|
|
26
29
|
database.exec('ALTER TABLE pending_interactions ADD COLUMN outcome TEXT');
|
|
30
|
+
if (!interactionColumns.some((column) => column.name === 'operation_key'))
|
|
31
|
+
database.exec('ALTER TABLE pending_interactions ADD COLUMN operation_key TEXT');
|
|
32
|
+
if (!interactionColumns.some((column) => column.name === 'resolution_state'))
|
|
33
|
+
database.exec("ALTER TABLE pending_interactions ADD COLUMN resolution_state TEXT NOT NULL DEFAULT 'active'");
|
|
34
|
+
const eventColumns = database.prepare('PRAGMA table_info(session_events)').all();
|
|
35
|
+
if (!eventColumns.some((column) => column.name === 'autopilot_outbox_id')) {
|
|
36
|
+
database.exec('ALTER TABLE session_events ADD COLUMN autopilot_outbox_id INTEGER');
|
|
37
|
+
database.exec('CREATE UNIQUE INDEX IF NOT EXISTS session_events_autopilot_outbox ON session_events(session_id, autopilot_outbox_id)');
|
|
38
|
+
}
|
|
39
|
+
const controlColumns = database.prepare('PRAGMA table_info(autopilot_controls)').all();
|
|
40
|
+
if (!controlColumns.some((column) => column.name === 'turn_id'))
|
|
41
|
+
database.exec('ALTER TABLE autopilot_controls ADD COLUMN turn_id TEXT');
|
|
27
42
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export class SqliteAutopilotStore {
|
|
7
|
+
db;
|
|
8
|
+
constructor(db) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
}
|
|
11
|
+
find(sessionId) {
|
|
12
|
+
const row = this.db
|
|
13
|
+
.prepare('SELECT * FROM autopilot_sessions WHERE session_id = ?')
|
|
14
|
+
.get(sessionId);
|
|
15
|
+
if (!row)
|
|
16
|
+
return null;
|
|
17
|
+
if (!['disabled', 'monitoring', 'backoff', 'attentionRequired', 'completed'].includes(String(row.state)) ||
|
|
18
|
+
![
|
|
19
|
+
'manualDisabled',
|
|
20
|
+
'planRequired',
|
|
21
|
+
'planComplete',
|
|
22
|
+
'sessionUnavailable',
|
|
23
|
+
'attentionRequired',
|
|
24
|
+
'noPlanProgress',
|
|
25
|
+
'reconcileFailed',
|
|
26
|
+
'planRemoved',
|
|
27
|
+
'planReplaced',
|
|
28
|
+
'sessionEnded',
|
|
29
|
+
'null',
|
|
30
|
+
].includes(String(row.stop_reason)) ||
|
|
31
|
+
!Number.isSafeInteger(Number(row.generation)) ||
|
|
32
|
+
Number(row.generation) < 0 ||
|
|
33
|
+
!Number.isSafeInteger(Number(row.no_progress_count)) ||
|
|
34
|
+
Number(row.no_progress_count) < 0 ||
|
|
35
|
+
![0, 1].includes(Number(row.requested_enabled)))
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
sessionId: String(row.session_id),
|
|
39
|
+
state: row.state,
|
|
40
|
+
requestedEnabled: Number(row.requested_enabled) === 1,
|
|
41
|
+
planIdentity: row.plan_identity === null ? null : String(row.plan_identity),
|
|
42
|
+
planFingerprint: row.plan_fingerprint === null ? null : String(row.plan_fingerprint),
|
|
43
|
+
generation: Number(row.generation),
|
|
44
|
+
consecutiveNoProgress: Number(row.no_progress_count),
|
|
45
|
+
nextEvaluationAt: row.next_evaluation_at === null ? null : String(row.next_evaluation_at),
|
|
46
|
+
lastControlId: row.last_control_id === null ? null : String(row.last_control_id),
|
|
47
|
+
stopReason: row.stop_reason,
|
|
48
|
+
updatedAt: String(row.updated_at),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
save(state) {
|
|
52
|
+
this.db
|
|
53
|
+
.prepare('INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET state=excluded.state,requested_enabled=excluded.requested_enabled,plan_identity=excluded.plan_identity,plan_fingerprint=excluded.plan_fingerprint,generation=excluded.generation,no_progress_count=excluded.no_progress_count,next_evaluation_at=excluded.next_evaluation_at,last_control_id=excluded.last_control_id,stop_reason=excluded.stop_reason,updated_at=excluded.updated_at')
|
|
54
|
+
.run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.updatedAt);
|
|
55
|
+
}
|
|
56
|
+
findControl(sessionId, controlId) {
|
|
57
|
+
const row = this.db
|
|
58
|
+
.prepare('SELECT * FROM autopilot_controls WHERE session_id = ? AND control_id = ?')
|
|
59
|
+
.get(sessionId, controlId);
|
|
60
|
+
if (!row || !['scheduled', 'issued', 'started', 'failed'].includes(String(row.status)))
|
|
61
|
+
return null;
|
|
62
|
+
const failureCode = row.failure_code === null ? null : String(row.failure_code);
|
|
63
|
+
if (failureCode !== null && !['START_FAILED', 'START_UNAVAILABLE'].includes(failureCode))
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
sessionId: String(row.session_id),
|
|
67
|
+
controlId: String(row.control_id),
|
|
68
|
+
status: row.status,
|
|
69
|
+
createdAt: String(row.created_at),
|
|
70
|
+
updatedAt: String(row.updated_at),
|
|
71
|
+
failureCode: failureCode,
|
|
72
|
+
turnId: row.turn_id === null ? null : String(row.turn_id),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
saveControl(control) {
|
|
76
|
+
this.db
|
|
77
|
+
.prepare("INSERT INTO autopilot_controls (session_id,control_id,status,created_at,updated_at,failure_code,turn_id) VALUES (?,?,?,?,?,?,?) ON CONFLICT(session_id,control_id) DO UPDATE SET status=excluded.status,updated_at=excluded.updated_at,failure_code=excluded.failure_code,turn_id=COALESCE(excluded.turn_id,autopilot_controls.turn_id) WHERE CASE autopilot_controls.status WHEN 'scheduled' THEN 0 WHEN 'issued' THEN 1 WHEN 'started' THEN 2 WHEN 'failed' THEN 2 END <= CASE excluded.status WHEN 'scheduled' THEN 0 WHEN 'issued' THEN 1 WHEN 'started' THEN 2 WHEN 'failed' THEN 2 END")
|
|
78
|
+
.run(control.sessionId, control.controlId, control.status, control.createdAt, control.updatedAt, control.failureCode, control.turnId ?? null);
|
|
79
|
+
}
|
|
80
|
+
commit(input) {
|
|
81
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
82
|
+
try {
|
|
83
|
+
if (input.state)
|
|
84
|
+
this.save(input.state);
|
|
85
|
+
if (input.control)
|
|
86
|
+
this.saveControl(input.control);
|
|
87
|
+
this.appendOutbox(input.events);
|
|
88
|
+
this.db.exec('COMMIT');
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.db.exec('ROLLBACK');
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
claimControlIssued(sessionId, controlId, updatedAt, state, events = []) {
|
|
96
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
97
|
+
try {
|
|
98
|
+
const result = this.db
|
|
99
|
+
.prepare("UPDATE autopilot_controls SET status = 'issued', updated_at = ? WHERE session_id = ? AND control_id = ? AND status = 'scheduled'")
|
|
100
|
+
.run(updatedAt, sessionId, controlId);
|
|
101
|
+
if (result.changes !== 1) {
|
|
102
|
+
this.db.exec('ROLLBACK');
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const control = this.findControl(sessionId, controlId);
|
|
106
|
+
if (!control)
|
|
107
|
+
throw new Error('AUTOPILOT_CONTROL_MISSING_AFTER_CLAIM');
|
|
108
|
+
if (state)
|
|
109
|
+
this.save(state);
|
|
110
|
+
this.appendOutbox(events);
|
|
111
|
+
this.db.exec('COMMIT');
|
|
112
|
+
return control;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.db.exec('ROLLBACK');
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
drainOutbox(sessionId) {
|
|
120
|
+
return this.db
|
|
121
|
+
.prepare('SELECT id,session_id,type,payload_json,occurred_at FROM autopilot_outbox WHERE session_id = ? ORDER BY id')
|
|
122
|
+
.all(sessionId).map((row) => ({
|
|
123
|
+
id: row.id,
|
|
124
|
+
sessionId: row.session_id,
|
|
125
|
+
type: row.type,
|
|
126
|
+
payload: JSON.parse(row.payload_json),
|
|
127
|
+
occurredAt: row.occurred_at,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
acknowledgeOutbox(id) {
|
|
131
|
+
this.db.prepare('DELETE FROM autopilot_outbox WHERE id = ?').run(id);
|
|
132
|
+
}
|
|
133
|
+
appendOutbox(events) {
|
|
134
|
+
const statement = this.db.prepare('INSERT INTO autopilot_outbox (session_id,type,payload_json,occurred_at) VALUES (?,?,?,?)');
|
|
135
|
+
for (const event of events)
|
|
136
|
+
statement.run(event.sessionId, event.type, JSON.stringify(event.payload), event.occurredAt);
|
|
137
|
+
}
|
|
138
|
+
acceptedControlTurns(sessionId) {
|
|
139
|
+
return new Map(this.db
|
|
140
|
+
.prepare("SELECT turn_id,control_id FROM autopilot_controls WHERE session_id = ? AND status = 'started' AND turn_id IS NOT NULL")
|
|
141
|
+
.all(sessionId).map((row) => [row.turn_id, row.control_id]));
|
|
142
|
+
}
|
|
143
|
+
controlIds(sessionId) {
|
|
144
|
+
return new Set(this.db
|
|
145
|
+
.prepare('SELECT control_id FROM autopilot_controls WHERE session_id = ?')
|
|
146
|
+
.all(sessionId).map((row) => row.control_id));
|
|
147
|
+
}
|
|
148
|
+
remove(sessionId) {
|
|
149
|
+
this.db.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?').run(sessionId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
|
+
export const autopilotAuditEventTypes = [
|
|
7
|
+
'autopilot.continuation-scheduled',
|
|
8
|
+
'autopilot.control-issued',
|
|
9
|
+
'autopilot.turn-started',
|
|
10
|
+
'autopilot.turn-failed',
|
|
11
|
+
'autopilot.progress-reset',
|
|
12
|
+
'autopilot.updated',
|
|
13
|
+
'org-plan.attention-required',
|
|
14
|
+
'org-plan.attention-resolved',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Not every coordinator state publication is useful as a timeline record.
|
|
18
|
+
* Keep this predicate in SQL so a burst of routine `monitoring` updates cannot
|
|
19
|
+
* consume the bounded, user-visible audit tail.
|
|
20
|
+
*/
|
|
21
|
+
const renderableAutopilotAuditWhere = `
|
|
22
|
+
type IN (${autopilotAuditEventTypes.map(() => '?').join(',')})
|
|
23
|
+
AND (
|
|
24
|
+
type <> 'autopilot.updated'
|
|
25
|
+
OR json_extract(payload_json, '$.state') IN ('backoff', 'attentionRequired', 'completed')
|
|
26
|
+
OR (
|
|
27
|
+
json_extract(payload_json, '$.state') = 'disabled'
|
|
28
|
+
AND json_extract(payload_json, '$.reason') = 'planRequired'
|
|
29
|
+
)
|
|
30
|
+
)`;
|
|
6
31
|
export class SqliteEventJournal {
|
|
7
32
|
db;
|
|
8
33
|
retain;
|
|
@@ -10,7 +35,7 @@ export class SqliteEventJournal {
|
|
|
10
35
|
this.db = db;
|
|
11
36
|
this.retain = retain;
|
|
12
37
|
}
|
|
13
|
-
append(sessionId, type, payload, occurredAt) {
|
|
38
|
+
append(sessionId, type, payload, occurredAt, autopilotOutboxId) {
|
|
14
39
|
this.db.exec('BEGIN IMMEDIATE');
|
|
15
40
|
try {
|
|
16
41
|
const row = this.db
|
|
@@ -18,9 +43,24 @@ export class SqliteEventJournal {
|
|
|
18
43
|
.get(sessionId);
|
|
19
44
|
if (!row)
|
|
20
45
|
throw new Error('SESSION_NOT_FOUND');
|
|
21
|
-
this.db
|
|
22
|
-
.prepare('INSERT INTO session_events (session_id,sequence,occurred_at,type,payload_json) VALUES (
|
|
23
|
-
.run(sessionId, row.sequence, occurredAt, type, JSON.stringify(payload));
|
|
46
|
+
const inserted = this.db
|
|
47
|
+
.prepare('INSERT OR IGNORE INTO session_events (session_id,sequence,occurred_at,type,payload_json,autopilot_outbox_id) VALUES (?,?,?,?,?,?)')
|
|
48
|
+
.run(sessionId, row.sequence, occurredAt, type, JSON.stringify(payload), autopilotOutboxId ?? null);
|
|
49
|
+
if (inserted.changes === 0 && autopilotOutboxId !== undefined) {
|
|
50
|
+
const existing = this.db
|
|
51
|
+
.prepare('SELECT sequence,type,occurred_at,payload_json FROM session_events WHERE session_id = ? AND autopilot_outbox_id = ?')
|
|
52
|
+
.get(sessionId, autopilotOutboxId);
|
|
53
|
+
if (existing) {
|
|
54
|
+
this.db.exec('COMMIT');
|
|
55
|
+
return {
|
|
56
|
+
sessionId,
|
|
57
|
+
sequence: existing.sequence,
|
|
58
|
+
type: existing.type,
|
|
59
|
+
occurredAt: existing.occurred_at,
|
|
60
|
+
payload: JSON.parse(existing.payload_json),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
24
64
|
this.db
|
|
25
65
|
.prepare('UPDATE relay_sessions SET next_sequence = next_sequence + 1 WHERE id = ?')
|
|
26
66
|
.run(sessionId);
|
|
@@ -47,4 +87,52 @@ export class SqliteEventJournal {
|
|
|
47
87
|
payload: JSON.parse(row.payload_json),
|
|
48
88
|
}));
|
|
49
89
|
}
|
|
90
|
+
/** Bounded chronological tail for redacted timeline projections. */
|
|
91
|
+
tail(sessionId, limit) {
|
|
92
|
+
const safeLimit = Math.max(1, Math.min(limit, 200));
|
|
93
|
+
return this.db
|
|
94
|
+
.prepare('SELECT sequence,type,occurred_at,payload_json FROM session_events WHERE session_id = ? ORDER BY sequence DESC LIMIT ?')
|
|
95
|
+
.all(sessionId, safeLimit)
|
|
96
|
+
.reverse()
|
|
97
|
+
.map((row) => ({
|
|
98
|
+
sessionId,
|
|
99
|
+
sequence: row.sequence,
|
|
100
|
+
type: row.type,
|
|
101
|
+
occurredAt: row.occurred_at,
|
|
102
|
+
payload: JSON.parse(row.payload_json),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
tailWithTruncation(sessionId, limit) {
|
|
106
|
+
const safeLimit = Math.max(1, Math.min(limit, 200));
|
|
107
|
+
const events = this.tail(sessionId, safeLimit);
|
|
108
|
+
const oldest = events.at(0)?.sequence;
|
|
109
|
+
const truncated = oldest !== undefined &&
|
|
110
|
+
Boolean(this.db
|
|
111
|
+
.prepare('SELECT 1 AS present FROM session_events WHERE session_id = ? AND sequence < ? LIMIT 1')
|
|
112
|
+
.get(sessionId, oldest));
|
|
113
|
+
return { events, truncated };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Reads only audit-bearing rows. A generic journal tail can otherwise be
|
|
117
|
+
* filled with chat deltas and incorrectly report an empty audit history.
|
|
118
|
+
*/
|
|
119
|
+
autopilotAuditTail(sessionId, limit) {
|
|
120
|
+
const safeLimit = Math.max(1, Math.min(limit, 200));
|
|
121
|
+
const rows = this.db
|
|
122
|
+
.prepare(`SELECT sequence,type,occurred_at,payload_json FROM session_events WHERE session_id = ? AND ${renderableAutopilotAuditWhere} ORDER BY sequence DESC LIMIT ?`)
|
|
123
|
+
.all(sessionId, ...autopilotAuditEventTypes, safeLimit);
|
|
124
|
+
const events = rows.reverse().map((row) => ({
|
|
125
|
+
sessionId,
|
|
126
|
+
sequence: row.sequence,
|
|
127
|
+
type: row.type,
|
|
128
|
+
occurredAt: row.occurred_at,
|
|
129
|
+
payload: JSON.parse(row.payload_json),
|
|
130
|
+
}));
|
|
131
|
+
const oldest = events.at(0)?.sequence;
|
|
132
|
+
const truncated = oldest !== undefined &&
|
|
133
|
+
Boolean(this.db
|
|
134
|
+
.prepare(`SELECT 1 AS present FROM session_events WHERE session_id = ? AND sequence < ? AND ${renderableAutopilotAuditWhere} LIMIT 1`)
|
|
135
|
+
.get(sessionId, oldest, ...autopilotAuditEventTypes));
|
|
136
|
+
return { events, truncated };
|
|
137
|
+
}
|
|
50
138
|
}
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
function safeInteractionOutcome(value) {
|
|
7
|
-
return value === 'approved' ||
|
|
7
|
+
return value === 'approved' ||
|
|
8
|
+
value === 'denied' ||
|
|
9
|
+
value === 'answered' ||
|
|
10
|
+
value === 'dismissed' ||
|
|
11
|
+
value === 'failed'
|
|
12
|
+
? value
|
|
13
|
+
: 'answered';
|
|
8
14
|
}
|
|
9
15
|
export class SqlitePendingInteractionStore {
|
|
10
16
|
db;
|
|
@@ -13,7 +19,13 @@ export class SqlitePendingInteractionStore {
|
|
|
13
19
|
}
|
|
14
20
|
add(sessionId, interaction) {
|
|
15
21
|
this.db
|
|
16
|
-
.prepare(
|
|
22
|
+
.prepare(`INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at)
|
|
23
|
+
VALUES (?,?,?,?,?,?)
|
|
24
|
+
ON CONFLICT(session_id,request_id) DO UPDATE SET
|
|
25
|
+
kind = excluded.kind, payload_json = excluded.payload_json, turn_id = excluded.turn_id,
|
|
26
|
+
requested_at = excluded.requested_at, resolved_at = NULL, outcome = NULL, operation_key = NULL,
|
|
27
|
+
resolution_state = 'active'
|
|
28
|
+
WHERE pending_interactions.kind != 'orgPlanAttention'`)
|
|
17
29
|
.run(sessionId, interaction.requestId, interaction.kind, JSON.stringify(interaction.payload), interaction.turnId ?? null, interaction.requestedAt ?? null);
|
|
18
30
|
}
|
|
19
31
|
resolve(sessionId, requestId, resolvedAt, outcome = 'answered') {
|
|
@@ -21,6 +33,44 @@ export class SqlitePendingInteractionStore {
|
|
|
21
33
|
.prepare('UPDATE pending_interactions SET resolved_at = ?, outcome = ? WHERE session_id = ? AND request_id = ? AND resolved_at IS NULL')
|
|
22
34
|
.run(resolvedAt, outcome, sessionId, requestId).changes === 1);
|
|
23
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The operation key is claimed before a writer is touched. A matching retry
|
|
38
|
+
* keeps that claim, whereas another key is permanently stale.
|
|
39
|
+
*/
|
|
40
|
+
claimOperation(sessionId, requestId, operationKey) {
|
|
41
|
+
const row = this.db
|
|
42
|
+
.prepare('SELECT resolved_at,operation_key FROM pending_interactions WHERE session_id = ? AND request_id = ?')
|
|
43
|
+
.get(sessionId, requestId);
|
|
44
|
+
if (!row)
|
|
45
|
+
return 'missing';
|
|
46
|
+
if (row.resolved_at)
|
|
47
|
+
return row.operation_key === operationKey ? 'resolved' : 'stale';
|
|
48
|
+
if (row.operation_key)
|
|
49
|
+
return row.operation_key === operationKey ? 'same' : 'stale';
|
|
50
|
+
return this.db
|
|
51
|
+
.prepare('UPDATE pending_interactions SET operation_key = ? WHERE session_id = ? AND request_id = ? AND operation_key IS NULL AND resolved_at IS NULL')
|
|
52
|
+
.run(operationKey, sessionId, requestId).changes === 1
|
|
53
|
+
? 'claimed'
|
|
54
|
+
: 'stale';
|
|
55
|
+
}
|
|
56
|
+
/** Persist the in-flight delivery boundary before the app-server is notified. */
|
|
57
|
+
beginDelivery(sessionId, requestId, operationKey) {
|
|
58
|
+
return (this.db
|
|
59
|
+
.prepare("UPDATE pending_interactions SET resolution_state = 'delivering' WHERE session_id = ? AND request_id = ? AND operation_key = ? AND resolved_at IS NULL AND resolution_state IN ('active','delivering')")
|
|
60
|
+
.run(sessionId, requestId, operationKey).changes === 1);
|
|
61
|
+
}
|
|
62
|
+
/** A local writer outage is retryable only by the already-claimed key. */
|
|
63
|
+
retryDelivery(sessionId, requestId, operationKey) {
|
|
64
|
+
return (this.db
|
|
65
|
+
.prepare("UPDATE pending_interactions SET resolution_state = 'active' WHERE session_id = ? AND request_id = ? AND operation_key = ? AND resolved_at IS NULL AND resolution_state = 'delivering'")
|
|
66
|
+
.run(sessionId, requestId, operationKey).changes === 1);
|
|
67
|
+
}
|
|
68
|
+
/** Terminal state is durable before publishing a success/failure event. */
|
|
69
|
+
settleOperation(sessionId, requestId, operationKey, resolvedAt, outcome) {
|
|
70
|
+
return (this.db
|
|
71
|
+
.prepare("UPDATE pending_interactions SET resolved_at = ?, outcome = ?, resolution_state = ? WHERE session_id = ? AND request_id = ? AND operation_key = ? AND resolved_at IS NULL AND resolution_state = 'delivering'")
|
|
72
|
+
.run(resolvedAt, outcome, outcome === 'failed' ? 'failed' : 'resolved', sessionId, requestId, operationKey).changes === 1);
|
|
73
|
+
}
|
|
24
74
|
list(sessionId) {
|
|
25
75
|
return this.db
|
|
26
76
|
.prepare('SELECT request_id,kind,payload_json,turn_id,requested_at FROM pending_interactions WHERE session_id = ? AND resolved_at IS NULL ORDER BY rowid')
|
|
@@ -37,7 +87,7 @@ export class SqlitePendingInteractionStore {
|
|
|
37
87
|
}
|
|
38
88
|
resolved(sessionId, requestId) {
|
|
39
89
|
const row = this.db
|
|
40
|
-
.prepare('SELECT resolved_at,outcome FROM pending_interactions WHERE session_id = ? AND request_id = ? AND resolved_at IS NOT NULL')
|
|
90
|
+
.prepare('SELECT resolved_at,outcome,operation_key FROM pending_interactions WHERE session_id = ? AND request_id = ? AND resolved_at IS NOT NULL')
|
|
41
91
|
.get(sessionId, requestId);
|
|
42
92
|
return row
|
|
43
93
|
? {
|
|
@@ -46,6 +96,15 @@ export class SqlitePendingInteractionStore {
|
|
|
46
96
|
}
|
|
47
97
|
: null;
|
|
48
98
|
}
|
|
99
|
+
terminalOperation(sessionId, requestId) {
|
|
100
|
+
const resolved = this.resolved(sessionId, requestId);
|
|
101
|
+
if (!resolved)
|
|
102
|
+
return null;
|
|
103
|
+
const row = this.db
|
|
104
|
+
.prepare('SELECT operation_key FROM pending_interactions WHERE session_id = ? AND request_id = ?')
|
|
105
|
+
.get(sessionId, requestId);
|
|
106
|
+
return row ? { ...resolved, operationKey: row.operation_key } : null;
|
|
107
|
+
}
|
|
49
108
|
snapshot(sessionId) {
|
|
50
109
|
return this.db
|
|
51
110
|
.prepare('SELECT request_id,kind,payload_json,turn_id,requested_at,resolved_at,outcome FROM pending_interactions WHERE session_id = ? ORDER BY CASE WHEN resolved_at IS NULL THEN 0 ELSE 1 END, requested_at, rowid')
|