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
|
@@ -0,0 +1,542 @@
|
|
|
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
|
+
import { autopilotSnapshot, disabledAutopilot, } from '../domain/autopilot-session.js';
|
|
7
|
+
import { decideAutopilot, executionComplete } from './policy.js';
|
|
8
|
+
export class AutopilotCoordinator {
|
|
9
|
+
deps;
|
|
10
|
+
timers = new Map();
|
|
11
|
+
completionTimers = new Map();
|
|
12
|
+
publishedSnapshots = new Map();
|
|
13
|
+
/** Serializes asynchronous watchdog and timer work per relay session. */
|
|
14
|
+
operations = new Map();
|
|
15
|
+
constructor(deps) {
|
|
16
|
+
this.deps = deps;
|
|
17
|
+
}
|
|
18
|
+
snapshot(sessionId) {
|
|
19
|
+
return autopilotSnapshot(this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, this.deps.now()), this.deps.policy.retryLimit);
|
|
20
|
+
}
|
|
21
|
+
controlIds(sessionId) {
|
|
22
|
+
return this.deps.store.controlIds(sessionId);
|
|
23
|
+
}
|
|
24
|
+
acceptedControlTurns(sessionId) {
|
|
25
|
+
return this.deps.store.acceptedControlTurns?.(sessionId) ?? new Map();
|
|
26
|
+
}
|
|
27
|
+
/** Rehydrates only actionable durable state; terminal rows intentionally create no work. */
|
|
28
|
+
restore(sessionId) {
|
|
29
|
+
this.flushOutbox(sessionId);
|
|
30
|
+
const state = this.deps.store.find(sessionId);
|
|
31
|
+
const session = this.deps.session(sessionId);
|
|
32
|
+
if (!state ||
|
|
33
|
+
!session ||
|
|
34
|
+
!session.threadId ||
|
|
35
|
+
!state.requestedEnabled ||
|
|
36
|
+
['disabled', 'attentionRequired', 'completed'].includes(state.state))
|
|
37
|
+
return;
|
|
38
|
+
// A relay restart deliberately drops its writer before rehydrating the
|
|
39
|
+
// coordinator. The plan-status watcher is authoritative and asynchronous,
|
|
40
|
+
// so retaining enabled durable state until it supplies the projection is
|
|
41
|
+
// safer than interpreting this short bootstrap gap as plan removal.
|
|
42
|
+
if (!this.deps.plan(sessionId))
|
|
43
|
+
return;
|
|
44
|
+
const control = state.lastControlId
|
|
45
|
+
? this.deps.store.findControl(sessionId, state.lastControlId)
|
|
46
|
+
: null;
|
|
47
|
+
// An issued command may have crossed the app-server acceptance boundary
|
|
48
|
+
// immediately before process loss. Replaying it risks a second logical turn.
|
|
49
|
+
if (control?.status === 'issued') {
|
|
50
|
+
// The runtime persists the accepted active turn before returning from its
|
|
51
|
+
// start capability. Recover that durable acceptance as a single audited
|
|
52
|
+
// control result; the outbox/journal key makes a crash before ack replay
|
|
53
|
+
// the same event rather than manufacture a second turn or audit record.
|
|
54
|
+
if (session.activeTurnId) {
|
|
55
|
+
this.updateControl(sessionId, control.controlId, 'started', null, session.activeTurnId, 'autopilot.turn-started');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.persist({
|
|
59
|
+
...state,
|
|
60
|
+
state: 'attentionRequired',
|
|
61
|
+
requestedEnabled: false,
|
|
62
|
+
generation: state.generation + 1,
|
|
63
|
+
nextEvaluationAt: null,
|
|
64
|
+
stopReason: 'reconcileFailed',
|
|
65
|
+
updatedAt: this.deps.now(),
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (state.state === 'backoff' && state.nextEvaluationAt) {
|
|
70
|
+
this.arm(sessionId, state.generation, state.nextEvaluationAt);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.evaluate(sessionId);
|
|
74
|
+
}
|
|
75
|
+
dispose(sessionId) {
|
|
76
|
+
this.cancelTimer(sessionId);
|
|
77
|
+
}
|
|
78
|
+
enable(sessionId) {
|
|
79
|
+
const session = this.deps.session(sessionId);
|
|
80
|
+
if (!session || !session.threadId || !['ready', 'turnActive'].includes(session.state))
|
|
81
|
+
return { code: 'AUTOPILOT_SESSION_UNAVAILABLE' };
|
|
82
|
+
const currentPlan = this.deps.plan(sessionId);
|
|
83
|
+
if (!currentPlan)
|
|
84
|
+
return { code: 'AUTOPILOT_PLAN_REQUIRED' };
|
|
85
|
+
if (executionComplete(currentPlan.plan))
|
|
86
|
+
return { code: 'AUTOPILOT_PLAN_COMPLETE' };
|
|
87
|
+
const now = this.deps.now();
|
|
88
|
+
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
89
|
+
const nextFingerprint = fingerprint(currentPlan.plan);
|
|
90
|
+
if (prior.requestedEnabled &&
|
|
91
|
+
prior.planIdentity === currentPlan.identity &&
|
|
92
|
+
prior.planFingerprint === nextFingerprint &&
|
|
93
|
+
prior.state !== 'attentionRequired')
|
|
94
|
+
return this.snapshot(sessionId);
|
|
95
|
+
const next = {
|
|
96
|
+
...prior,
|
|
97
|
+
state: 'monitoring',
|
|
98
|
+
requestedEnabled: true,
|
|
99
|
+
planIdentity: currentPlan.identity,
|
|
100
|
+
planFingerprint: nextFingerprint,
|
|
101
|
+
generation: prior.generation + 1,
|
|
102
|
+
consecutiveNoProgress: 0,
|
|
103
|
+
nextEvaluationAt: null,
|
|
104
|
+
stopReason: null,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
};
|
|
107
|
+
this.persist(next);
|
|
108
|
+
this.evaluate(sessionId);
|
|
109
|
+
return this.snapshot(sessionId);
|
|
110
|
+
}
|
|
111
|
+
disable(sessionId) {
|
|
112
|
+
const now = this.deps.now();
|
|
113
|
+
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
114
|
+
if (!prior.requestedEnabled && prior.state === 'disabled')
|
|
115
|
+
return this.snapshot(sessionId);
|
|
116
|
+
const next = {
|
|
117
|
+
...prior,
|
|
118
|
+
state: 'disabled',
|
|
119
|
+
requestedEnabled: false,
|
|
120
|
+
generation: prior.generation + 1,
|
|
121
|
+
nextEvaluationAt: null,
|
|
122
|
+
stopReason: 'manualDisabled',
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
};
|
|
125
|
+
this.persist(next);
|
|
126
|
+
this.cancelTimer(sessionId);
|
|
127
|
+
return this.snapshot(sessionId);
|
|
128
|
+
}
|
|
129
|
+
cancel(sessionId, reason) {
|
|
130
|
+
const prior = this.deps.store.find(sessionId);
|
|
131
|
+
if (!prior)
|
|
132
|
+
return;
|
|
133
|
+
this.persist({
|
|
134
|
+
...prior,
|
|
135
|
+
state: 'disabled',
|
|
136
|
+
requestedEnabled: false,
|
|
137
|
+
generation: prior.generation + 1,
|
|
138
|
+
nextEvaluationAt: null,
|
|
139
|
+
stopReason: reason,
|
|
140
|
+
updatedAt: this.deps.now(),
|
|
141
|
+
});
|
|
142
|
+
this.cancelTimer(sessionId);
|
|
143
|
+
}
|
|
144
|
+
evaluate(sessionId) {
|
|
145
|
+
const prior = this.deps.store.find(sessionId);
|
|
146
|
+
if (!prior)
|
|
147
|
+
return this.snapshot(sessionId);
|
|
148
|
+
const plan = this.deps.plan(sessionId);
|
|
149
|
+
const session = this.deps.session(sessionId);
|
|
150
|
+
const decision = decideAutopilot({
|
|
151
|
+
state: prior,
|
|
152
|
+
plan: plan?.plan ?? null,
|
|
153
|
+
planIdentity: plan?.identity,
|
|
154
|
+
planFingerprint: plan ? fingerprint(plan.plan) : null,
|
|
155
|
+
activity: this.deps.activity(sessionId),
|
|
156
|
+
hasPendingInteraction: this.deps.pendingInteraction(sessionId),
|
|
157
|
+
hasActiveAttention: prior.state === 'attentionRequired',
|
|
158
|
+
lastTurnOutcome: this.lastTurnOutcome(sessionId, prior.lastControlId),
|
|
159
|
+
now: this.deps.now(),
|
|
160
|
+
policy: this.deps.policy,
|
|
161
|
+
});
|
|
162
|
+
const now = this.deps.now();
|
|
163
|
+
this.deps.diagnostic?.(sessionId, decision.kind);
|
|
164
|
+
let next = prior;
|
|
165
|
+
switch (decision.kind) {
|
|
166
|
+
case 'observe':
|
|
167
|
+
break;
|
|
168
|
+
case 'reconcile':
|
|
169
|
+
next = { ...prior, state: 'monitoring', nextEvaluationAt: null, updatedAt: now };
|
|
170
|
+
this.enqueue(sessionId, () => this.reconcile(sessionId, prior.generation));
|
|
171
|
+
break;
|
|
172
|
+
case 'scheduleContinuation':
|
|
173
|
+
if (session?.activeTurnId || this.deps.pendingInteraction(sessionId))
|
|
174
|
+
break;
|
|
175
|
+
const scheduledControlId = this.deps.nextControlId(sessionId, prior.generation);
|
|
176
|
+
next = {
|
|
177
|
+
...prior,
|
|
178
|
+
state: 'backoff',
|
|
179
|
+
nextEvaluationAt: decision.at,
|
|
180
|
+
lastControlId: scheduledControlId,
|
|
181
|
+
updatedAt: now,
|
|
182
|
+
};
|
|
183
|
+
const control = {
|
|
184
|
+
sessionId,
|
|
185
|
+
controlId: scheduledControlId,
|
|
186
|
+
status: 'scheduled',
|
|
187
|
+
createdAt: now,
|
|
188
|
+
updatedAt: now,
|
|
189
|
+
failureCode: null,
|
|
190
|
+
turnId: null,
|
|
191
|
+
};
|
|
192
|
+
this.persist(next, control, [
|
|
193
|
+
{
|
|
194
|
+
sessionId,
|
|
195
|
+
type: 'autopilot.continuation-scheduled',
|
|
196
|
+
payload: { controlId: scheduledControlId },
|
|
197
|
+
occurredAt: now,
|
|
198
|
+
},
|
|
199
|
+
]);
|
|
200
|
+
next = prior;
|
|
201
|
+
this.arm(sessionId, prior.generation, decision.at);
|
|
202
|
+
break;
|
|
203
|
+
case 'requestAttention':
|
|
204
|
+
next = {
|
|
205
|
+
...prior,
|
|
206
|
+
state: 'attentionRequired',
|
|
207
|
+
requestedEnabled: false,
|
|
208
|
+
generation: prior.generation + 1,
|
|
209
|
+
nextEvaluationAt: null,
|
|
210
|
+
stopReason: decision.reason,
|
|
211
|
+
updatedAt: now,
|
|
212
|
+
};
|
|
213
|
+
break;
|
|
214
|
+
case 'complete':
|
|
215
|
+
next = {
|
|
216
|
+
...prior,
|
|
217
|
+
state: 'completed',
|
|
218
|
+
requestedEnabled: false,
|
|
219
|
+
generation: prior.generation + 1,
|
|
220
|
+
nextEvaluationAt: null,
|
|
221
|
+
stopReason: 'planComplete',
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
};
|
|
224
|
+
break;
|
|
225
|
+
case 'disable':
|
|
226
|
+
next = {
|
|
227
|
+
...prior,
|
|
228
|
+
state: 'disabled',
|
|
229
|
+
requestedEnabled: false,
|
|
230
|
+
generation: prior.generation + 1,
|
|
231
|
+
nextEvaluationAt: null,
|
|
232
|
+
stopReason: decision.reason,
|
|
233
|
+
updatedAt: now,
|
|
234
|
+
};
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (decision.kind === 'requestAttention' ||
|
|
238
|
+
decision.kind === 'complete' ||
|
|
239
|
+
decision.kind === 'disable')
|
|
240
|
+
this.cancelTimer(sessionId);
|
|
241
|
+
if (next !== prior)
|
|
242
|
+
this.persist(next);
|
|
243
|
+
return this.snapshot(sessionId);
|
|
244
|
+
}
|
|
245
|
+
planUpdated(sessionId) {
|
|
246
|
+
const prior = this.deps.store.find(sessionId);
|
|
247
|
+
const plan = this.deps.plan(sessionId);
|
|
248
|
+
if (!prior || !plan)
|
|
249
|
+
return;
|
|
250
|
+
if (prior.planIdentity && prior.planIdentity !== plan.identity) {
|
|
251
|
+
this.cancel(sessionId, 'planReplaced');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const nextFingerprint = fingerprint(plan.plan);
|
|
255
|
+
if (prior.planFingerprint !== nextFingerprint) {
|
|
256
|
+
const now = this.deps.now();
|
|
257
|
+
this.persist({
|
|
258
|
+
...prior,
|
|
259
|
+
planIdentity: plan.identity,
|
|
260
|
+
planFingerprint: nextFingerprint,
|
|
261
|
+
consecutiveNoProgress: 0,
|
|
262
|
+
updatedAt: now,
|
|
263
|
+
}, undefined, [
|
|
264
|
+
{
|
|
265
|
+
sessionId,
|
|
266
|
+
type: 'autopilot.progress-reset',
|
|
267
|
+
payload: { reason: 'planUpdated' },
|
|
268
|
+
occurredAt: now,
|
|
269
|
+
},
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
this.evaluate(sessionId);
|
|
273
|
+
}
|
|
274
|
+
turnCompleted(sessionId) {
|
|
275
|
+
this.completionTimers.get(sessionId)?.();
|
|
276
|
+
this.completionTimers.set(sessionId, this.deps.schedule(() => {
|
|
277
|
+
this.completionTimers.delete(sessionId);
|
|
278
|
+
this.evaluate(sessionId);
|
|
279
|
+
}, this.deps.policy.quiescenceMs));
|
|
280
|
+
}
|
|
281
|
+
manualSend(sessionId) {
|
|
282
|
+
this.cancelTimer(sessionId);
|
|
283
|
+
const prior = this.deps.store.find(sessionId);
|
|
284
|
+
if (!prior || !prior.requestedEnabled)
|
|
285
|
+
return;
|
|
286
|
+
this.persist({
|
|
287
|
+
...prior,
|
|
288
|
+
state: 'monitoring',
|
|
289
|
+
generation: prior.generation + 1,
|
|
290
|
+
nextEvaluationAt: null,
|
|
291
|
+
lastControlId: null,
|
|
292
|
+
updatedAt: this.deps.now(),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
recordControlIssued(sessionId, controlId) {
|
|
296
|
+
const prior = this.deps.store.find(sessionId);
|
|
297
|
+
if (!prior ||
|
|
298
|
+
!prior.requestedEnabled ||
|
|
299
|
+
prior.lastControlId !== controlId ||
|
|
300
|
+
prior.state !== 'backoff')
|
|
301
|
+
return false;
|
|
302
|
+
const now = this.deps.now();
|
|
303
|
+
const next = {
|
|
304
|
+
...prior,
|
|
305
|
+
lastControlId: controlId,
|
|
306
|
+
state: 'monitoring',
|
|
307
|
+
nextEvaluationAt: null,
|
|
308
|
+
consecutiveNoProgress: prior.consecutiveNoProgress + 1,
|
|
309
|
+
updatedAt: now,
|
|
310
|
+
};
|
|
311
|
+
const events = [
|
|
312
|
+
...this.snapshotEvents(next),
|
|
313
|
+
{ sessionId, type: 'autopilot.control-issued', payload: { controlId }, occurredAt: now },
|
|
314
|
+
];
|
|
315
|
+
const control = this.deps.store.claimControlIssued
|
|
316
|
+
? this.deps.store.claimControlIssued(sessionId, controlId, now, next, events)
|
|
317
|
+
: this.legacyClaim(sessionId, controlId, next, events);
|
|
318
|
+
if (!control)
|
|
319
|
+
return false;
|
|
320
|
+
this.publishedSnapshots.set(sessionId, this.semanticSnapshot(next));
|
|
321
|
+
this.flushOutbox(sessionId);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
persist(next, control, events = []) {
|
|
325
|
+
const snapshotEvents = this.snapshotEvents(next);
|
|
326
|
+
this.commit({
|
|
327
|
+
state: next,
|
|
328
|
+
...(control ? { control } : {}),
|
|
329
|
+
events: [...snapshotEvents, ...events],
|
|
330
|
+
});
|
|
331
|
+
if (snapshotEvents.length)
|
|
332
|
+
this.publishedSnapshots.set(next.sessionId, this.semanticSnapshot(next));
|
|
333
|
+
this.flushOutbox(next.sessionId);
|
|
334
|
+
}
|
|
335
|
+
arm(sessionId, generation, at) {
|
|
336
|
+
this.cancelTimer(sessionId);
|
|
337
|
+
this.timers.set(sessionId, this.deps.schedule(() => this.enqueue(sessionId, () => this.fire(sessionId, generation)), Math.max(0, Date.parse(at) - Date.parse(this.deps.now()))));
|
|
338
|
+
}
|
|
339
|
+
async fire(sessionId, generation) {
|
|
340
|
+
this.timers.delete(sessionId);
|
|
341
|
+
const current = this.deps.store.find(sessionId);
|
|
342
|
+
const session = this.deps.session(sessionId);
|
|
343
|
+
if (!current ||
|
|
344
|
+
current.generation !== generation ||
|
|
345
|
+
!current.requestedEnabled ||
|
|
346
|
+
session?.activeTurnId ||
|
|
347
|
+
this.deps.pendingInteraction(sessionId))
|
|
348
|
+
return;
|
|
349
|
+
const id = current.lastControlId;
|
|
350
|
+
if (!id)
|
|
351
|
+
return;
|
|
352
|
+
if (!this.recordControlIssued(sessionId, id))
|
|
353
|
+
return;
|
|
354
|
+
try {
|
|
355
|
+
await this.deps.turnStarter.start(sessionId, id, generation);
|
|
356
|
+
this.updateControl(sessionId, id, 'started', null, this.deps.session(sessionId)?.activeTurnId ?? null, 'autopilot.turn-started');
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
// The runtime persists an accepted turn before returning to this coordinator.
|
|
360
|
+
// A process-loss/fault seam after that durability point is not a failed start:
|
|
361
|
+
// promote the existing control and let its outbox audit replay exactly once.
|
|
362
|
+
const acceptedTurnId = this.deps.session(sessionId)?.activeTurnId;
|
|
363
|
+
if (acceptedTurnId) {
|
|
364
|
+
this.updateControl(sessionId, id, 'started', null, acceptedTurnId, 'autopilot.turn-started');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const failureCode = startFailureCode(error);
|
|
368
|
+
this.updateControl(sessionId, id, 'failed', failureCode, null, 'autopilot.turn-failed');
|
|
369
|
+
// Availability, permission, and dependency failures cannot be repaired
|
|
370
|
+
// by another identical synthetic turn. They remain a durable, explicit
|
|
371
|
+
// human stop; unknown transport failures still consume the retry budget.
|
|
372
|
+
if (failureCode === 'START_UNAVAILABLE') {
|
|
373
|
+
const latest = this.deps.store.find(sessionId);
|
|
374
|
+
if (latest?.requestedEnabled)
|
|
375
|
+
this.persist({
|
|
376
|
+
...latest,
|
|
377
|
+
state: 'attentionRequired',
|
|
378
|
+
requestedEnabled: false,
|
|
379
|
+
generation: latest.generation + 1,
|
|
380
|
+
nextEvaluationAt: null,
|
|
381
|
+
stopReason: 'attentionRequired',
|
|
382
|
+
updatedAt: this.deps.now(),
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.evaluate(sessionId);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
cancelTimer(sessionId) {
|
|
390
|
+
this.timers.get(sessionId)?.();
|
|
391
|
+
this.timers.delete(sessionId);
|
|
392
|
+
this.completionTimers.get(sessionId)?.();
|
|
393
|
+
this.completionTimers.delete(sessionId);
|
|
394
|
+
}
|
|
395
|
+
updateControl(sessionId, controlId, status, failureCode, turnId = null, eventType) {
|
|
396
|
+
const control = this.deps.store.findControl(sessionId, controlId);
|
|
397
|
+
if (!control)
|
|
398
|
+
return;
|
|
399
|
+
const updated = {
|
|
400
|
+
...control,
|
|
401
|
+
status,
|
|
402
|
+
failureCode,
|
|
403
|
+
turnId,
|
|
404
|
+
updatedAt: this.deps.now(),
|
|
405
|
+
};
|
|
406
|
+
const occurredAt = this.deps.now();
|
|
407
|
+
this.commit({
|
|
408
|
+
control: updated,
|
|
409
|
+
events: eventType
|
|
410
|
+
? [
|
|
411
|
+
{
|
|
412
|
+
sessionId,
|
|
413
|
+
type: eventType,
|
|
414
|
+
payload: eventType === 'autopilot.turn-failed'
|
|
415
|
+
? { controlId, code: failureCode }
|
|
416
|
+
: { controlId },
|
|
417
|
+
occurredAt,
|
|
418
|
+
},
|
|
419
|
+
]
|
|
420
|
+
: [],
|
|
421
|
+
});
|
|
422
|
+
this.flushOutbox(sessionId);
|
|
423
|
+
}
|
|
424
|
+
semanticSnapshot(next) {
|
|
425
|
+
return JSON.stringify({
|
|
426
|
+
...autopilotSnapshot(next, this.deps.policy.retryLimit),
|
|
427
|
+
updatedAt: '',
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
snapshotEvents(next) {
|
|
431
|
+
const semantic = this.semanticSnapshot(next);
|
|
432
|
+
if (this.publishedSnapshots.get(next.sessionId) === semantic)
|
|
433
|
+
return [];
|
|
434
|
+
return [
|
|
435
|
+
{
|
|
436
|
+
sessionId: next.sessionId,
|
|
437
|
+
type: 'autopilot.updated',
|
|
438
|
+
payload: autopilotSnapshot(next, this.deps.policy.retryLimit),
|
|
439
|
+
occurredAt: next.updatedAt,
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
flushOutbox(sessionId) {
|
|
444
|
+
for (const event of this.deps.store.drainOutbox?.(sessionId) ?? []) {
|
|
445
|
+
this.deps.publish(event.sessionId, event.type, event.payload, event.occurredAt, event.id);
|
|
446
|
+
this.deps.store.acknowledgeOutbox?.(event.id);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
commit(input) {
|
|
450
|
+
if (this.deps.store.commit) {
|
|
451
|
+
this.deps.store.commit(input);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Narrow in-memory test-double adapter. Real composition always supplies
|
|
455
|
+
// the transactional implementation above; this path cannot be reached by SQLite.
|
|
456
|
+
if (input.state)
|
|
457
|
+
this.deps.store.save(input.state);
|
|
458
|
+
if (input.control)
|
|
459
|
+
this.deps.store.saveControl?.(input.control);
|
|
460
|
+
for (const event of input.events)
|
|
461
|
+
this.deps.publish(event.sessionId, event.type, event.payload, event.occurredAt);
|
|
462
|
+
}
|
|
463
|
+
legacyClaim(sessionId, controlId, state, events) {
|
|
464
|
+
const control = this.deps.store.findControl(sessionId, controlId);
|
|
465
|
+
if (!control || control.status !== 'scheduled')
|
|
466
|
+
return null;
|
|
467
|
+
this.commit({
|
|
468
|
+
state,
|
|
469
|
+
control: { ...control, status: 'issued', updatedAt: state.updatedAt },
|
|
470
|
+
events,
|
|
471
|
+
});
|
|
472
|
+
return { ...control, status: 'issued', updatedAt: state.updatedAt };
|
|
473
|
+
}
|
|
474
|
+
lastTurnOutcome(sessionId, controlId) {
|
|
475
|
+
if (!controlId)
|
|
476
|
+
return undefined;
|
|
477
|
+
const control = this.deps.store.findControl(sessionId, controlId);
|
|
478
|
+
if (control?.status === 'failed')
|
|
479
|
+
return 'failed';
|
|
480
|
+
if (control?.status === 'started')
|
|
481
|
+
return 'completed';
|
|
482
|
+
return 'unknown';
|
|
483
|
+
}
|
|
484
|
+
async reconcile(sessionId, generation) {
|
|
485
|
+
try {
|
|
486
|
+
const result = await this.deps.reconcile(sessionId);
|
|
487
|
+
const current = this.deps.store.find(sessionId);
|
|
488
|
+
if (!result.compatible || !current || current.generation !== generation)
|
|
489
|
+
throw new Error('INCOMPATIBLE');
|
|
490
|
+
// A compatible reconciliation normally refreshes the activity projection.
|
|
491
|
+
// Do not feed a still-stale projection straight back into reconciliation:
|
|
492
|
+
// that would create an unbounded microtask loop with no timer or event to
|
|
493
|
+
// yield to. A later activity/watchdog event will evaluate it again.
|
|
494
|
+
if (this.deps.activity(sessionId)?.confidence === 'fresh')
|
|
495
|
+
this.evaluate(sessionId);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
const current = this.deps.store.find(sessionId);
|
|
499
|
+
if (!current || current.generation !== generation)
|
|
500
|
+
return;
|
|
501
|
+
this.persist({
|
|
502
|
+
...current,
|
|
503
|
+
state: 'attentionRequired',
|
|
504
|
+
requestedEnabled: false,
|
|
505
|
+
generation: current.generation + 1,
|
|
506
|
+
nextEvaluationAt: null,
|
|
507
|
+
stopReason: 'reconcileFailed',
|
|
508
|
+
updatedAt: this.deps.now(),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
enqueue(sessionId, operation) {
|
|
513
|
+
const previous = this.operations.get(sessionId);
|
|
514
|
+
// Start the first operation synchronously through its pre-await safety
|
|
515
|
+
// checks; later operations serialize behind it for this session.
|
|
516
|
+
const next = previous ? previous.catch(() => undefined).then(operation) : operation();
|
|
517
|
+
this.operations.set(sessionId, next);
|
|
518
|
+
void next.finally(() => {
|
|
519
|
+
if (this.operations.get(sessionId) === next)
|
|
520
|
+
this.operations.delete(sessionId);
|
|
521
|
+
});
|
|
522
|
+
return next;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function startFailureCode(error) {
|
|
526
|
+
const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
|
527
|
+
? error.code
|
|
528
|
+
: error instanceof Error
|
|
529
|
+
? error.message
|
|
530
|
+
: '';
|
|
531
|
+
return /(?:UNAVAILABLE|PERMISSION|DEPENDENCY|CODEX_SESSION_NOT_RUNNING|WRITER_)/.test(code)
|
|
532
|
+
? 'START_UNAVAILABLE'
|
|
533
|
+
: 'START_FAILED';
|
|
534
|
+
}
|
|
535
|
+
function fingerprint(plan) {
|
|
536
|
+
return JSON.stringify(plan.steps.map((step) => [
|
|
537
|
+
step.id,
|
|
538
|
+
step.state,
|
|
539
|
+
step.reviewStatus,
|
|
540
|
+
step.children.map((child) => [child.id, child.state]),
|
|
541
|
+
]));
|
|
542
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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 function disabledAutopilot(sessionId, now) {
|
|
7
|
+
return {
|
|
8
|
+
sessionId,
|
|
9
|
+
state: 'disabled',
|
|
10
|
+
requestedEnabled: false,
|
|
11
|
+
planIdentity: null,
|
|
12
|
+
planFingerprint: null,
|
|
13
|
+
generation: 0,
|
|
14
|
+
consecutiveNoProgress: 0,
|
|
15
|
+
nextEvaluationAt: null,
|
|
16
|
+
lastControlId: null,
|
|
17
|
+
stopReason: 'manualDisabled',
|
|
18
|
+
updatedAt: now,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function autopilotSnapshot(state, retryLimit) {
|
|
22
|
+
return {
|
|
23
|
+
state: state.state,
|
|
24
|
+
enabled: state.requestedEnabled,
|
|
25
|
+
...(state.stopReason ? { reason: state.stopReason } : {}),
|
|
26
|
+
retry: { position: state.consecutiveNoProgress, limit: retryLimit },
|
|
27
|
+
...(state.lastControlId
|
|
28
|
+
? {
|
|
29
|
+
lastAutomaticAction: {
|
|
30
|
+
controlId: state.lastControlId,
|
|
31
|
+
summary: state.state === 'backoff'
|
|
32
|
+
? 'Automatic continuation scheduled.'
|
|
33
|
+
: 'Automatic continuation issued.',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
...(state.nextEvaluationAt ? { nextEvaluationAt: state.nextEvaluationAt } : {}),
|
|
38
|
+
updatedAt: state.updatedAt,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
import { registerAutopilotToggle } from './toggle/endpoint.js';
|
|
7
|
+
export function registerAutopilotRoutes(app, coordinator, idempotency) {
|
|
8
|
+
registerAutopilotToggle(app, coordinator, idempotency);
|
|
9
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
export function registerAutopilotToggle(app, coordinator, idempotency) {
|
|
8
|
+
app.put('/api/sessions/:id/autopilot', async (request, reply) => {
|
|
9
|
+
const enabled = request.body?.enabled;
|
|
10
|
+
if (typeof enabled !== 'boolean')
|
|
11
|
+
return reply.code(400).send({ code: 'AUTOPILOT_ENABLED_REQUIRED' });
|
|
12
|
+
const key = request.headers['idempotency-key'];
|
|
13
|
+
const sessionId = request.params.id;
|
|
14
|
+
const scope = `autopilot-toggle:${sessionId}`;
|
|
15
|
+
const fingerprint = createHash('sha256').update(String(enabled)).digest('hex');
|
|
16
|
+
if (typeof key === 'string' && key && idempotency) {
|
|
17
|
+
const prior = idempotency.get(scope, key);
|
|
18
|
+
if (prior) {
|
|
19
|
+
const stored = JSON.parse(prior.body);
|
|
20
|
+
if (stored.fingerprint !== fingerprint)
|
|
21
|
+
return reply.code(409).send({ code: 'IDEMPOTENCY_KEY_REUSED' });
|
|
22
|
+
return reply.code(prior.statusCode).send(stored.response);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const result = enabled ? coordinator.enable(sessionId) : coordinator.disable(sessionId);
|
|
26
|
+
const statusCode = 'code' in result ? 409 : 200;
|
|
27
|
+
const response = 'code' in result ? result : { autopilot: result };
|
|
28
|
+
if (typeof key === 'string' && key && idempotency)
|
|
29
|
+
idempotency.put(scope, key, statusCode, JSON.stringify({ fingerprint, response }));
|
|
30
|
+
return reply.code(statusCode).send(response);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
/** Feature entry point for consumers that need only the active typed alert. */
|
|
7
|
+
export function registerGetActiveOrgPlanAttention(app, deps) {
|
|
8
|
+
app.get('/api/sessions/:id/attention', async (request, reply) => {
|
|
9
|
+
const sessionId = request.params.id;
|
|
10
|
+
if (!deps.exists(sessionId))
|
|
11
|
+
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
12
|
+
const attention = deps.reader.active(sessionId);
|
|
13
|
+
if (!attention)
|
|
14
|
+
return reply.code(404).type('application/problem+json').send({
|
|
15
|
+
type: 'urn:gestalt-mobile:error:org-plan-attention-not-active',
|
|
16
|
+
title: 'No active Org Plan attention request',
|
|
17
|
+
status: 404,
|
|
18
|
+
code: 'ORG_PLAN_ATTENTION_NOT_ACTIVE',
|
|
19
|
+
});
|
|
20
|
+
return reply.send(attention);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
import { registerGetActiveOrgPlanAttention } from './get-active/endpoint.js';
|
|
7
|
+
import { registerResolveOrgPlanAttention } from './resolve/endpoint.js';
|
|
8
|
+
export function registerOrgPlanAttentionRoutes(app, deps) {
|
|
9
|
+
registerGetActiveOrgPlanAttention(app, deps);
|
|
10
|
+
registerResolveOrgPlanAttention(app, deps);
|
|
11
|
+
}
|