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.
Files changed (44) hide show
  1. package/README.md +5 -1
  2. package/dist/client/assets/index-B0Uq4OB0.css +1 -0
  3. package/dist/client/assets/index-orfZBEaD.js +27 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +6 -0
  6. package/dist/server/server/composition.js +413 -21
  7. package/dist/server/server/features/agent-activity/model.js +197 -0
  8. package/dist/server/server/features/agent-activity/registry.js +155 -0
  9. package/dist/server/server/features/autopilot/application/policy.js +55 -0
  10. package/dist/server/server/features/autopilot/application/ports.js +6 -0
  11. package/dist/server/server/features/autopilot/application/service.js +542 -0
  12. package/dist/server/server/features/autopilot/domain/autopilot-session.js +40 -0
  13. package/dist/server/server/features/autopilot/register-routes.js +9 -0
  14. package/dist/server/server/features/autopilot/toggle/endpoint.js +32 -0
  15. package/dist/server/server/features/org-plan-attention/application/ports.js +6 -0
  16. package/dist/server/server/features/org-plan-attention/get-active/endpoint.js +22 -0
  17. package/dist/server/server/features/org-plan-attention/register-routes.js +11 -0
  18. package/dist/server/server/features/org-plan-attention/resolve/endpoint.js +42 -0
  19. package/dist/server/server/features/plans/application/parse-supervised-plan.js +4 -0
  20. package/dist/server/server/features/sessions/get-history/endpoint.js +62 -2
  21. package/dist/server/server/features/sessions/get-history/history-mapper.js +17 -5
  22. package/dist/server/server/features/sessions/get-session/endpoint.js +3 -1
  23. package/dist/server/server/features/sessions/interaction/kind.js +1 -0
  24. package/dist/server/server/features/sessions/interaction/response-validator.js +5 -1
  25. package/dist/server/server/features/sessions/list-sessions/endpoint.js +2 -0
  26. package/dist/server/server/features/sessions/model/relay-session.js +3 -0
  27. package/dist/server/server/features/sessions/refresh-activity/endpoint.js +14 -0
  28. package/dist/server/server/features/sessions/register-routes.js +15 -2
  29. package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
  30. package/dist/server/server/features/sessions/restore-session/endpoint.js +21 -5
  31. package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
  32. package/dist/server/server/features/skills/model/skill-profile.js +13 -5
  33. package/dist/server/server/platform/codex/activity-facts.js +88 -0
  34. package/dist/server/server/platform/codex/server-request.js +21 -4
  35. package/dist/server/server/platform/codex/session-runtime.js +114 -22
  36. package/dist/server/server/platform/persistence/migrate.js +16 -1
  37. package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
  38. package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
  39. package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +62 -3
  40. package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
  41. package/dist/server/shared/contracts/org-plan-attention.js +101 -0
  42. package/package.json +1 -1
  43. package/dist/client/assets/index-DgD-0I3M.js +0 -25
  44. 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, requestTimeoutMs = 30_000, maxPendingRequests = 64, readerCwd) {
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).bindThread(startedThreadId, now).snapshot;
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 || !this.onServerRequest?.(resource.sessionId, request)) {
341
- return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
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
- const timer = setTimeout(() => {
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
- const notificationUnsubscribe = process.rpc.onNotification((notification) => {
397
- if (resource.active)
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' || value === 'denied' || value === 'answered' ? value : 'answered';
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('INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at) VALUES (?,?,?,?,?,?)')
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')