gestalt-mobile 0.20.1 → 0.22.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/dist/client/assets/index-Db9p-xm2.css +1 -0
- package/dist/client/assets/index-NHzJXg1w.js +29 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +2 -0
- package/dist/server/server/composition.contracts.js +142 -6
- package/dist/server/server/composition.js +39 -8
- package/dist/server/server/features/agent-activity/model.js +5 -0
- package/dist/server/server/features/agent-activity/registry.js +12 -1
- package/dist/server/server/features/autopilot/application/service.js +53 -1
- package/dist/server/server/features/files/application/ports.js +6 -0
- package/dist/server/server/features/files/copy-entry/endpoint.js +48 -0
- package/dist/server/server/features/files/delete-entry/endpoint.js +23 -0
- package/dist/server/server/features/files/domain/relative-directory.js +15 -0
- package/dist/server/server/features/files/domain/workspace-directory.js +6 -0
- package/dist/server/server/features/files/list-directory/endpoint.js +49 -0
- package/dist/server/server/features/files/move-entry/endpoint.js +4 -0
- package/dist/server/server/features/files/register-routes.js +19 -0
- package/dist/server/server/features/files/upload-file/endpoint.js +35 -0
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +10 -6
- package/dist/server/server/features/sessions/model/relay-session.js +19 -0
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +13 -3
- package/dist/server/server/features/sessions/register-routes.js +1 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +3 -5
- package/dist/server/server/platform/auth/sqlite-authorization-store.js +98 -205
- package/dist/server/server/platform/auth/sqlite.js +54 -0
- package/dist/server/server/platform/codex/activity-facts.js +4 -0
- package/dist/server/server/platform/codex/recent-thread-lister.js +37 -11
- package/dist/server/server/platform/codex/rollout-execution-policy.js +68 -0
- package/dist/server/server/platform/codex/session-runtime.js +76 -17
- package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +559 -0
- package/dist/server/server/platform/persistence/migrate.js +5 -1
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -3
- package/package.json +1 -1
- package/dist/client/assets/index-CnXbn39K.js +0 -29
- package/dist/client/assets/index-DYERmevO.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
16
16
|
<link rel="icon" href="/icons/gestalt-mobile-192.png" />
|
|
17
17
|
<link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
|
|
18
18
|
<title>Gestalt Mobile</title>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-NHzJXg1w.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Db9p-xm2.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="app"></div>
|
|
@@ -17,6 +17,7 @@ import { registerAuthorizationBoundary } from './platform/http/authorization-bou
|
|
|
17
17
|
import { registerSkillRoutes } from './features/skills/register-routes.js';
|
|
18
18
|
import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/register-routes.js';
|
|
19
19
|
import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
|
|
20
|
+
import { registerFileRoutes } from './features/files/register-routes.js';
|
|
20
21
|
export async function buildApp(deps) {
|
|
21
22
|
// Keep the default boundary finite even when an endpoint forgot a narrower schema.
|
|
22
23
|
const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
|
|
@@ -48,6 +49,7 @@ export async function buildApp(deps) {
|
|
|
48
49
|
if (deps.orgPlanAttention)
|
|
49
50
|
registerOrgPlanAttentionRoutes(app, deps.orgPlanAttention);
|
|
50
51
|
registerPlanRoutes(app, deps);
|
|
52
|
+
registerFileRoutes(app, deps);
|
|
51
53
|
registerGitRoutes(app, deps);
|
|
52
54
|
registerSkillRoutes(app, deps);
|
|
53
55
|
registerProblemHandler(app, Boolean(deps.staticDir));
|
|
@@ -565,6 +565,9 @@ describe('production composition', () => {
|
|
|
565
565
|
});
|
|
566
566
|
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
567
567
|
const timestamp = new Date().toISOString();
|
|
568
|
+
database
|
|
569
|
+
.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?')
|
|
570
|
+
.run(fixture.sessionId);
|
|
568
571
|
database
|
|
569
572
|
.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 (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-without-turn', NULL, ?)")
|
|
570
573
|
.run(fixture.sessionId, timestamp, timestamp);
|
|
@@ -595,6 +598,9 @@ describe('production composition', () => {
|
|
|
595
598
|
},
|
|
596
599
|
});
|
|
597
600
|
const acceptedDatabase = new DatabaseSync(join(accepted.dataDir, 'relay.sqlite'));
|
|
601
|
+
acceptedDatabase
|
|
602
|
+
.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?')
|
|
603
|
+
.run(accepted.sessionId);
|
|
598
604
|
acceptedDatabase
|
|
599
605
|
.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 (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-with-turn', NULL, ?)")
|
|
600
606
|
.run(accepted.sessionId, timestamp, timestamp);
|
|
@@ -715,20 +721,22 @@ describe('production composition', () => {
|
|
|
715
721
|
// absence is the durable proof that no queued control can be reopened.
|
|
716
722
|
expect(row).toBeUndefined();
|
|
717
723
|
}
|
|
724
|
+
else if (action === 'replace') {
|
|
725
|
+
await vi.waitFor(() => expect(staleTimer.cancelled).toBe(true));
|
|
726
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
727
|
+
autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
718
730
|
else {
|
|
719
731
|
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
720
732
|
autopilot: {
|
|
721
733
|
enabled: false,
|
|
722
734
|
state: 'disabled',
|
|
723
|
-
reason: action === '
|
|
724
|
-
? 'planReplaced'
|
|
725
|
-
: action === 'close'
|
|
726
|
-
? 'planRemoved'
|
|
727
|
-
: 'sessionEnded',
|
|
735
|
+
reason: action === 'close' ? 'planRemoved' : 'sessionEnded',
|
|
728
736
|
},
|
|
729
737
|
}));
|
|
730
738
|
}
|
|
731
|
-
expect(staleTimer.cancelled).toBe(true);
|
|
739
|
+
expect(staleTimer.cancelled, action).toBe(true);
|
|
732
740
|
staleTimer.callback();
|
|
733
741
|
await Promise.resolve();
|
|
734
742
|
expect(fixture.handles.flatMap((candidate) => candidate.calls)).not.toContain('turn/start');
|
|
@@ -804,6 +812,12 @@ describe('production composition', () => {
|
|
|
804
812
|
};
|
|
805
813
|
},
|
|
806
814
|
});
|
|
815
|
+
expect((await fixture.app.inject({
|
|
816
|
+
method: 'PUT',
|
|
817
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
818
|
+
payload: { enabled: false },
|
|
819
|
+
})).statusCode).toBe(200);
|
|
820
|
+
timers.length = 0;
|
|
807
821
|
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
808
822
|
handle.notify({
|
|
809
823
|
method: 'thread/started',
|
|
@@ -868,6 +882,12 @@ describe('production composition', () => {
|
|
|
868
882
|
};
|
|
869
883
|
},
|
|
870
884
|
});
|
|
885
|
+
expect((await fixture.app.inject({
|
|
886
|
+
method: 'PUT',
|
|
887
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
888
|
+
payload: { enabled: false },
|
|
889
|
+
})).statusCode).toBe(200);
|
|
890
|
+
timers.length = 0;
|
|
871
891
|
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
872
892
|
handle.notify({
|
|
873
893
|
method: 'thread/started',
|
|
@@ -1356,6 +1376,86 @@ describe('production composition', () => {
|
|
|
1356
1376
|
});
|
|
1357
1377
|
});
|
|
1358
1378
|
describeCompositionConcern('lifecycle', () => {
|
|
1379
|
+
it('forgets a session while activity reconciliation is pending without publishing late activity', async () => {
|
|
1380
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1381
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1382
|
+
ownTemporaryPaths(root, dataDir);
|
|
1383
|
+
await mkdir(join(root, 'workspace'));
|
|
1384
|
+
const writers = [];
|
|
1385
|
+
const unhandled = [];
|
|
1386
|
+
const onUnhandled = (reason) => unhandled.push(reason);
|
|
1387
|
+
process.on('unhandledRejection', onUnhandled);
|
|
1388
|
+
onTestFinished(() => {
|
|
1389
|
+
process.off('unhandledRejection', onUnhandled);
|
|
1390
|
+
});
|
|
1391
|
+
const app = await composeAuthorizedApp({
|
|
1392
|
+
root,
|
|
1393
|
+
dataDir,
|
|
1394
|
+
relyingParty,
|
|
1395
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1396
|
+
startAppServers: true,
|
|
1397
|
+
profiles: {
|
|
1398
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1399
|
+
require: async () => ({
|
|
1400
|
+
name: 'default',
|
|
1401
|
+
state: 'ok',
|
|
1402
|
+
status: 'ready',
|
|
1403
|
+
}),
|
|
1404
|
+
},
|
|
1405
|
+
launchAppServer: () => {
|
|
1406
|
+
const writer = {};
|
|
1407
|
+
return {
|
|
1408
|
+
rpc: {
|
|
1409
|
+
request: async (method, params) => {
|
|
1410
|
+
if (method === 'thread/start') {
|
|
1411
|
+
writers.push(writer);
|
|
1412
|
+
return { thread: { id: `thread-${writers.length}` } };
|
|
1413
|
+
}
|
|
1414
|
+
if (method === 'thread/read')
|
|
1415
|
+
return await new Promise((resolve, reject) => {
|
|
1416
|
+
writer.releaseRead = () => resolve({ thread: { turns: [] } });
|
|
1417
|
+
writer.rejectRead = () => reject(new Error('LATE_READ_FAILURE'));
|
|
1418
|
+
});
|
|
1419
|
+
if (method === 'thread/list')
|
|
1420
|
+
return { data: [] };
|
|
1421
|
+
if (method === 'model/list')
|
|
1422
|
+
return { data: [{ id: 'gpt-5.6-terra' }] };
|
|
1423
|
+
if (method === 'skills/list')
|
|
1424
|
+
return {
|
|
1425
|
+
data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
|
|
1426
|
+
};
|
|
1427
|
+
return {};
|
|
1428
|
+
},
|
|
1429
|
+
onNotification: (listener) => {
|
|
1430
|
+
writer.notify = listener;
|
|
1431
|
+
return () => { };
|
|
1432
|
+
},
|
|
1433
|
+
onServerRequest: () => () => { },
|
|
1434
|
+
},
|
|
1435
|
+
close: () => { },
|
|
1436
|
+
onExit: () => () => { },
|
|
1437
|
+
};
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
const forgottenId = await createComposedSession(app);
|
|
1441
|
+
await vi.waitFor(() => expect(writers[0]?.releaseRead).toBeTypeOf('function'));
|
|
1442
|
+
expect((await app.inject({ method: 'DELETE', url: `/api/sessions/${forgottenId}` })).statusCode).toBe(204);
|
|
1443
|
+
writers[0].rejectRead();
|
|
1444
|
+
await Promise.resolve();
|
|
1445
|
+
await Promise.resolve();
|
|
1446
|
+
expect(unhandled).toEqual([]);
|
|
1447
|
+
expect((await app.inject('/api/bootstrap')).statusCode).toBe(200);
|
|
1448
|
+
const liveId = await createComposedSession(app);
|
|
1449
|
+
await vi.waitFor(() => expect(writers[1]?.notify).toBeTypeOf('function'));
|
|
1450
|
+
writers[1].notify({
|
|
1451
|
+
method: 'thread/started',
|
|
1452
|
+
params: { thread: { id: 'thread-2', status: { type: 'active' } } },
|
|
1453
|
+
});
|
|
1454
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${liveId}`)).json()).toMatchObject({
|
|
1455
|
+
agentActivity: { root: { state: 'working' } },
|
|
1456
|
+
}));
|
|
1457
|
+
await app.close();
|
|
1458
|
+
});
|
|
1359
1459
|
it('uses the relay root for detached history reads and Open never resumes', async () => {
|
|
1360
1460
|
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1361
1461
|
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
@@ -1512,6 +1612,42 @@ describe('production composition', () => {
|
|
|
1512
1612
|
['GET', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1513
1613
|
['HEAD', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1514
1614
|
['DELETE', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1615
|
+
[
|
|
1616
|
+
'GET',
|
|
1617
|
+
'/api/workspaces/:workspaceId/files',
|
|
1618
|
+
'/api/workspaces/workspace-1/files',
|
|
1619
|
+
'protected',
|
|
1620
|
+
],
|
|
1621
|
+
[
|
|
1622
|
+
'HEAD',
|
|
1623
|
+
'/api/workspaces/:workspaceId/files',
|
|
1624
|
+
'/api/workspaces/workspace-1/files',
|
|
1625
|
+
'protected',
|
|
1626
|
+
],
|
|
1627
|
+
[
|
|
1628
|
+
'POST',
|
|
1629
|
+
'/api/workspaces/:workspaceId/files/copy',
|
|
1630
|
+
'/api/workspaces/workspace-1/files/copy',
|
|
1631
|
+
'protected',
|
|
1632
|
+
],
|
|
1633
|
+
[
|
|
1634
|
+
'POST',
|
|
1635
|
+
'/api/workspaces/:workspaceId/files/move',
|
|
1636
|
+
'/api/workspaces/workspace-1/files/move',
|
|
1637
|
+
'protected',
|
|
1638
|
+
],
|
|
1639
|
+
[
|
|
1640
|
+
'PUT',
|
|
1641
|
+
'/api/workspaces/:workspaceId/files/upload',
|
|
1642
|
+
'/api/workspaces/workspace-1/files/upload',
|
|
1643
|
+
'protected',
|
|
1644
|
+
],
|
|
1645
|
+
[
|
|
1646
|
+
'DELETE',
|
|
1647
|
+
'/api/workspaces/:workspaceId/files',
|
|
1648
|
+
'/api/workspaces/workspace-1/files',
|
|
1649
|
+
'protected',
|
|
1650
|
+
],
|
|
1515
1651
|
[
|
|
1516
1652
|
'GET',
|
|
1517
1653
|
'/api/workspaces/:workspaceId/plans',
|
|
@@ -9,6 +9,7 @@ import { homedir } from 'node:os';
|
|
|
9
9
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { buildApp } from './app.js';
|
|
11
11
|
import { FilesystemWorkspaceCatalog } from './platform/catalog/filesystem-workspace-catalog.js';
|
|
12
|
+
import { FilesystemWorkspaceFiles } from './platform/filesystem/filesystem-workspace-files.js';
|
|
12
13
|
import { protocolCompatibility } from './platform/codex/protocol-compatibility.js';
|
|
13
14
|
import { launchCodexAppServer } from './platform/codex/codex-process-launcher.js';
|
|
14
15
|
import { CodexModelCatalog } from './platform/codex/codex-model-catalog.js';
|
|
@@ -31,7 +32,7 @@ import { GitFetchCoordinator } from './platform/git/git-fetch-coordinator.js';
|
|
|
31
32
|
import { GitSummaryCache } from './platform/git/git-summary-cache.js';
|
|
32
33
|
import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
|
|
33
34
|
import { mapWithConcurrency } from './platform/runtime/concurrency.js';
|
|
34
|
-
import { RelaySession } from './features/sessions/model/relay-session.js';
|
|
35
|
+
import { RelaySession, } from './features/sessions/model/relay-session.js';
|
|
35
36
|
import { AgentActivityRegistry } from './features/agent-activity/registry.js';
|
|
36
37
|
import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
|
|
37
38
|
import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
|
|
@@ -129,6 +130,11 @@ export async function composeRelayApp(options) {
|
|
|
129
130
|
if (!session || !runtime)
|
|
130
131
|
return;
|
|
131
132
|
const history = await runtime.readHistory(session);
|
|
133
|
+
// Forget may commit while the detached history reader is still in
|
|
134
|
+
// flight. Revalidate ownership before handing its result to the
|
|
135
|
+
// registry, whose publisher intentionally keeps the journal strict.
|
|
136
|
+
if (!sessions.find(sessionId))
|
|
137
|
+
return;
|
|
132
138
|
const occurredAt = new Date().toISOString();
|
|
133
139
|
activity.observe({
|
|
134
140
|
sessionId,
|
|
@@ -137,7 +143,12 @@ export async function composeRelayApp(options) {
|
|
|
137
143
|
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
138
144
|
...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
|
|
139
145
|
});
|
|
140
|
-
|
|
146
|
+
const children = await runtime.listDirectChildren(session);
|
|
147
|
+
// The writer read is also asynchronous; it cannot publish after the
|
|
148
|
+
// durable owner has gone away either.
|
|
149
|
+
if (!sessions.find(sessionId))
|
|
150
|
+
return;
|
|
151
|
+
activity.childrenReconciled(sessionId, occurredAt, children);
|
|
141
152
|
},
|
|
142
153
|
});
|
|
143
154
|
const autopilot = new AutopilotCoordinator({
|
|
@@ -216,6 +227,7 @@ export async function composeRelayApp(options) {
|
|
|
216
227
|
notifyAutopilotActivity = (sessionId) => autopilot.activityChanged(sessionId);
|
|
217
228
|
options.onAutopilotCoordinator?.(autopilot);
|
|
218
229
|
const workspaces = new FilesystemWorkspaceCatalog(root);
|
|
230
|
+
const workspaceFiles = new FilesystemWorkspaceFiles();
|
|
219
231
|
const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
|
|
220
232
|
const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
|
|
221
233
|
const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
|
|
@@ -242,6 +254,14 @@ export async function composeRelayApp(options) {
|
|
|
242
254
|
profiles: options.profiles,
|
|
243
255
|
launch: options.launchAppServer ?? launchCodexAppServer,
|
|
244
256
|
});
|
|
257
|
+
const recoverExecutionPolicy = async (session) => {
|
|
258
|
+
if (session.executionPolicy || !session.threadId)
|
|
259
|
+
return session;
|
|
260
|
+
const executionPolicy = await recentThreads.executionPolicy(session.threadId);
|
|
261
|
+
return executionPolicy
|
|
262
|
+
? { ...session, executionPolicy, updatedAt: new Date().toISOString() }
|
|
263
|
+
: session;
|
|
264
|
+
};
|
|
245
265
|
const protocol = protocolCompatibility(options.installedCodexVersion, generatedProtocolVersion);
|
|
246
266
|
const gitFetches = new GitFetchCoordinator(fetchUpstream);
|
|
247
267
|
const gitSummaries = new GitSummaryCache(inspectGit);
|
|
@@ -295,6 +315,8 @@ export async function composeRelayApp(options) {
|
|
|
295
315
|
}
|
|
296
316
|
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
297
317
|
}
|
|
318
|
+
if (update.kind === 'updated' && update.reason === 'supervision-start')
|
|
319
|
+
autopilot.supervisionStarted(sessionId);
|
|
298
320
|
autopilot.planStatusChanged(sessionId);
|
|
299
321
|
};
|
|
300
322
|
runtime = options.startAppServers
|
|
@@ -484,7 +506,13 @@ export async function composeRelayApp(options) {
|
|
|
484
506
|
profiles: options.profiles,
|
|
485
507
|
models,
|
|
486
508
|
sessions: {
|
|
487
|
-
list: () => sessions.list().map((session) =>
|
|
509
|
+
list: () => sessions.list().map((session) => {
|
|
510
|
+
const plan = supervisedPlans.find(session.id);
|
|
511
|
+
return {
|
|
512
|
+
...withPendingInteractions(session),
|
|
513
|
+
...(plan ? { plan } : {}),
|
|
514
|
+
};
|
|
515
|
+
}),
|
|
488
516
|
},
|
|
489
517
|
protocolCompatible: protocol.compatible,
|
|
490
518
|
},
|
|
@@ -494,16 +522,17 @@ export async function composeRelayApp(options) {
|
|
|
494
522
|
save: saveSession,
|
|
495
523
|
find: (id) => withPendingInteractions(sessions.find(id)),
|
|
496
524
|
list: () => sessions.list().map((session) => withPendingInteractions(session)),
|
|
525
|
+
plan: (id) => supervisedPlans.find(id),
|
|
497
526
|
workspaces,
|
|
498
527
|
profiles: options.profiles,
|
|
499
528
|
skillProfiles,
|
|
500
529
|
skillCatalog,
|
|
501
530
|
defaultSkillProfile: options.explicitSkillProfile,
|
|
502
531
|
activate: runtime
|
|
503
|
-
? async (session
|
|
532
|
+
? async (session) => {
|
|
504
533
|
const now = new Date().toISOString();
|
|
505
534
|
dismissPendingInteractions(session.id, now);
|
|
506
|
-
const started = await runtime.start(session, now
|
|
535
|
+
const started = await runtime.start(session, now);
|
|
507
536
|
return started;
|
|
508
537
|
}
|
|
509
538
|
: undefined,
|
|
@@ -514,9 +543,9 @@ export async function composeRelayApp(options) {
|
|
|
514
543
|
}
|
|
515
544
|
: undefined,
|
|
516
545
|
ensureWriter: runtime
|
|
517
|
-
? (session) => {
|
|
546
|
+
? async (session) => {
|
|
518
547
|
dismissPendingInteractions(session.id, new Date().toISOString());
|
|
519
|
-
return runtime.ensureWriter(session, new Date().toISOString());
|
|
548
|
+
return runtime.ensureWriter(await recoverExecutionPolicy(session), new Date().toISOString());
|
|
520
549
|
}
|
|
521
550
|
: undefined,
|
|
522
551
|
releaseWriter: runtime
|
|
@@ -554,7 +583,7 @@ export async function composeRelayApp(options) {
|
|
|
554
583
|
: undefined,
|
|
555
584
|
restore: runtime
|
|
556
585
|
? async (session) => {
|
|
557
|
-
const restored = await runtime.restoreWithOutcome(session, new Date().toISOString());
|
|
586
|
+
const restored = await runtime.restoreWithOutcome(await recoverExecutionPolicy(session), new Date().toISOString());
|
|
558
587
|
// An exit can be reported while resume is resolving. Do not let the
|
|
559
588
|
// route persist a stale ready snapshot over that recovered exit.
|
|
560
589
|
if (!runtime.ownsWriter(session.id))
|
|
@@ -574,6 +603,7 @@ export async function composeRelayApp(options) {
|
|
|
574
603
|
list: () => sessions.list(),
|
|
575
604
|
save: saveSession,
|
|
576
605
|
read: (session) => runtime.readHistory(session),
|
|
606
|
+
executionPolicy: (candidate) => recentThreads.executionPolicy(candidate.id),
|
|
577
607
|
})
|
|
578
608
|
: undefined,
|
|
579
609
|
release: (session) => {
|
|
@@ -654,6 +684,7 @@ export async function composeRelayApp(options) {
|
|
|
654
684
|
},
|
|
655
685
|
},
|
|
656
686
|
workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
|
|
687
|
+
workspaceFileRoutes: { workspaces, files: workspaceFiles },
|
|
657
688
|
autopilot,
|
|
658
689
|
...(runtime
|
|
659
690
|
? {
|
|
@@ -94,6 +94,11 @@ export function projectAgentActivity(current, fact) {
|
|
|
94
94
|
? { nickname: before.nickname }
|
|
95
95
|
: {}),
|
|
96
96
|
...(fact.childRole ? { role: fact.childRole } : before?.role ? { role: before.role } : {}),
|
|
97
|
+
...(fact.childModel
|
|
98
|
+
? { model: fact.childModel }
|
|
99
|
+
: before?.model
|
|
100
|
+
? { model: before.model }
|
|
101
|
+
: {}),
|
|
97
102
|
state: childState(fact.childStatus, fact.collaborationAction, before?.state),
|
|
98
103
|
reason: childReason(fact.childStatus, fact.collaborationAction),
|
|
99
104
|
observedAt: fact.occurredAt,
|
|
@@ -12,6 +12,7 @@ export class AgentActivityRegistry {
|
|
|
12
12
|
#timers = new Map();
|
|
13
13
|
#generation = new Map();
|
|
14
14
|
#inFlight = new Map();
|
|
15
|
+
#disposed = new Set();
|
|
15
16
|
constructor(publish, options = {}) {
|
|
16
17
|
this.publish = publish;
|
|
17
18
|
this.options = options;
|
|
@@ -20,6 +21,8 @@ export class AgentActivityRegistry {
|
|
|
20
21
|
return this.#snapshots.get(sessionId) ?? createAgentActivitySnapshot(sessionId, now);
|
|
21
22
|
}
|
|
22
23
|
observe(fact) {
|
|
24
|
+
if (this.#disposed.has(fact.sessionId))
|
|
25
|
+
return this.snapshot(fact.sessionId, fact.occurredAt);
|
|
23
26
|
const current = this.snapshot(fact.sessionId, fact.occurredAt);
|
|
24
27
|
const next = withActivityConfidence(projectAgentActivity(current, fact), fact.kind === 'collaboration' && !fact.childId ? 'stale' : 'fresh');
|
|
25
28
|
if (next !== current) {
|
|
@@ -34,6 +37,8 @@ export class AgentActivityRegistry {
|
|
|
34
37
|
return this.#qualify(sessionId, occurredAt, 'reconciling');
|
|
35
38
|
}
|
|
36
39
|
disconnected(sessionId, occurredAt) {
|
|
40
|
+
if (this.#disposed.has(sessionId))
|
|
41
|
+
return this.snapshot(sessionId, occurredAt);
|
|
37
42
|
this.suspend(sessionId);
|
|
38
43
|
const current = this.snapshot(sessionId, occurredAt);
|
|
39
44
|
const next = withActivityConfidence(clearAgentActivityChildren(projectAgentActivity(current, { sessionId, occurredAt, kind: 'processExited' })), 'stale');
|
|
@@ -55,7 +60,7 @@ export class AgentActivityRegistry {
|
|
|
55
60
|
return this.#qualify(sessionId, occurredAt, 'fresh');
|
|
56
61
|
}
|
|
57
62
|
refresh(sessionId) {
|
|
58
|
-
if (!this.options.reconcile)
|
|
63
|
+
if (this.#disposed.has(sessionId) || !this.options.reconcile)
|
|
59
64
|
return Promise.resolve();
|
|
60
65
|
const existing = this.#inFlight.get(sessionId);
|
|
61
66
|
if (existing)
|
|
@@ -71,6 +76,8 @@ export class AgentActivityRegistry {
|
|
|
71
76
|
}
|
|
72
77
|
/** Replaces the observed direct-child set; absent children are disconnected. */
|
|
73
78
|
childrenReconciled(sessionId, occurredAt, children) {
|
|
79
|
+
if (this.#disposed.has(sessionId))
|
|
80
|
+
return this.snapshot(sessionId, occurredAt);
|
|
74
81
|
let next = this.snapshot(sessionId, occurredAt);
|
|
75
82
|
const seen = new Set(children.map((child) => child.id));
|
|
76
83
|
for (const child of children)
|
|
@@ -83,6 +90,7 @@ export class AgentActivityRegistry {
|
|
|
83
90
|
...(child.status ? { childStatus: child.status } : {}),
|
|
84
91
|
...(child.nickname ? { childNickname: child.nickname } : {}),
|
|
85
92
|
...(child.role ? { childRole: child.role } : {}),
|
|
93
|
+
...(child.model ? { childModel: child.model } : {}),
|
|
86
94
|
});
|
|
87
95
|
for (const child of next.subagents)
|
|
88
96
|
if (!seen.has(child.id))
|
|
@@ -105,6 +113,7 @@ export class AgentActivityRegistry {
|
|
|
105
113
|
return next;
|
|
106
114
|
}
|
|
107
115
|
dispose(sessionId) {
|
|
116
|
+
this.#disposed.add(sessionId);
|
|
108
117
|
this.#snapshots.delete(sessionId);
|
|
109
118
|
this.#timers.get(sessionId)?.();
|
|
110
119
|
this.#timers.delete(sessionId);
|
|
@@ -142,6 +151,8 @@ export class AgentActivityRegistry {
|
|
|
142
151
|
}
|
|
143
152
|
}
|
|
144
153
|
#qualify(sessionId, occurredAt, confidence, fact) {
|
|
154
|
+
if (this.#disposed.has(sessionId))
|
|
155
|
+
return this.snapshot(sessionId, occurredAt);
|
|
145
156
|
const current = this.snapshot(sessionId, occurredAt);
|
|
146
157
|
const next = fact
|
|
147
158
|
? withActivityConfidence(projectAgentActivity(current, { sessionId, occurredAt, ...fact }), confidence)
|
|
@@ -110,13 +110,22 @@ export class AutopilotCoordinator {
|
|
|
110
110
|
disable(sessionId) {
|
|
111
111
|
const now = this.deps.now();
|
|
112
112
|
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
113
|
+
// Retain the current plan identity even when Autopilot has not previously
|
|
114
|
+
// been enabled. That makes an explicit Off a durable, plan-scoped choice
|
|
115
|
+
// instead of a generic disabled default that a later supervision signal
|
|
116
|
+
// could not distinguish from never having been configured.
|
|
117
|
+
const currentPlan = this.deps.plan(sessionId);
|
|
113
118
|
const cancelled = this.cancelScheduledControl(prior, now);
|
|
114
|
-
if (!prior.requestedEnabled &&
|
|
119
|
+
if (!prior.requestedEnabled &&
|
|
120
|
+
prior.state === 'disabled' &&
|
|
121
|
+
!cancelled &&
|
|
122
|
+
(!currentPlan || prior.planIdentity === currentPlan.identity))
|
|
115
123
|
return this.snapshot(sessionId);
|
|
116
124
|
const next = {
|
|
117
125
|
...prior,
|
|
118
126
|
state: 'disabled',
|
|
119
127
|
requestedEnabled: false,
|
|
128
|
+
...(currentPlan ? { planIdentity: currentPlan.identity } : {}),
|
|
120
129
|
generation: prior.generation + 1,
|
|
121
130
|
nextEvaluationAt: null,
|
|
122
131
|
...(cancelled ? { lastControlId: null } : {}),
|
|
@@ -275,6 +284,49 @@ export class AutopilotCoordinator {
|
|
|
275
284
|
if (executionComplete(plan.plan))
|
|
276
285
|
this.evaluate(sessionId);
|
|
277
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Records a validated, session-private supervision request. It intentionally
|
|
289
|
+
* does not discover plans: composition must first bind the update to this
|
|
290
|
+
* relay session. A manual Off is authoritative for the retained identity,
|
|
291
|
+
* while a new identity can receive its own explicit supervision request.
|
|
292
|
+
*/
|
|
293
|
+
supervisionStarted(sessionId) {
|
|
294
|
+
const currentPlan = this.deps.plan(sessionId);
|
|
295
|
+
if (!currentPlan)
|
|
296
|
+
return { code: 'AUTOPILOT_PLAN_REQUIRED' };
|
|
297
|
+
if (executionComplete(currentPlan.plan))
|
|
298
|
+
return { code: 'AUTOPILOT_PLAN_COMPLETE' };
|
|
299
|
+
const now = this.deps.now();
|
|
300
|
+
const prior = this.deps.store.find(sessionId) ?? disabledAutopilot(sessionId, now);
|
|
301
|
+
if (prior.planIdentity === currentPlan.identity &&
|
|
302
|
+
!prior.requestedEnabled &&
|
|
303
|
+
prior.stopReason === 'manualDisabled')
|
|
304
|
+
return this.snapshot(sessionId);
|
|
305
|
+
if (prior.requestedEnabled && prior.planIdentity === currentPlan.identity)
|
|
306
|
+
return this.snapshot(sessionId);
|
|
307
|
+
const replacing = Boolean(prior.planIdentity && prior.planIdentity !== currentPlan.identity);
|
|
308
|
+
const cancelled = replacing ? this.cancelScheduledControl(prior, now) : undefined;
|
|
309
|
+
if (replacing)
|
|
310
|
+
this.cancelTimer(sessionId);
|
|
311
|
+
const next = {
|
|
312
|
+
...prior,
|
|
313
|
+
state: 'monitoring',
|
|
314
|
+
requestedEnabled: true,
|
|
315
|
+
planIdentity: currentPlan.identity,
|
|
316
|
+
planFingerprint: fingerprint(currentPlan.plan),
|
|
317
|
+
generation: prior.generation + 1,
|
|
318
|
+
consecutiveNoProgress: 0,
|
|
319
|
+
nextEvaluationAt: null,
|
|
320
|
+
...(cancelled ? { lastControlId: null } : {}),
|
|
321
|
+
stopReason: null,
|
|
322
|
+
updatedAt: now,
|
|
323
|
+
};
|
|
324
|
+
this.persist(next, cancelled);
|
|
325
|
+
const session = this.deps.session(sessionId);
|
|
326
|
+
if (session?.threadId && ['ready', 'turnActive'].includes(session.state))
|
|
327
|
+
this.evaluate(sessionId);
|
|
328
|
+
return this.snapshot(sessionId);
|
|
329
|
+
}
|
|
278
330
|
/** Reacts only to fresh actor status; plan mutations are not scheduling signals. */
|
|
279
331
|
activityChanged(sessionId) {
|
|
280
332
|
const prior = this.deps.store.find(sessionId);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function registerCopyEntry(app, deps) {
|
|
2
|
+
app.post('/api/workspaces/:workspaceId/files/copy', async (request, reply) => mutation(request, reply, deps, 'copy'));
|
|
3
|
+
}
|
|
4
|
+
export async function mutation(request, reply, deps, operation) {
|
|
5
|
+
const body = request.body;
|
|
6
|
+
if (!body ||
|
|
7
|
+
typeof body.source !== 'string' ||
|
|
8
|
+
typeof body.destinationDirectory !== 'string' ||
|
|
9
|
+
!['reject', 'replace', 'keep-both'].includes(body.conflict ?? ''))
|
|
10
|
+
return reply.code(400).send({ code: 'INVALID_FILE_MUTATION' });
|
|
11
|
+
const method = deps.files[operation];
|
|
12
|
+
if (!method)
|
|
13
|
+
return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
|
|
14
|
+
try {
|
|
15
|
+
const workspace = await deps.workspaces.resolve(request.params.workspaceId);
|
|
16
|
+
const result = await method.call(deps.files, workspace.realPath, body);
|
|
17
|
+
return map(reply, result);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return reply
|
|
21
|
+
.code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
|
|
22
|
+
.send({
|
|
23
|
+
code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
|
|
24
|
+
? 'WORKSPACE_NOT_FOUND'
|
|
25
|
+
: 'WORKSPACE_FILES_UNAVAILABLE',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function map(reply, result) {
|
|
30
|
+
if (result.kind === 'available')
|
|
31
|
+
return reply.send(result);
|
|
32
|
+
const mapped = {
|
|
33
|
+
missing: [404, 'FILE_NOT_FOUND'],
|
|
34
|
+
protected: [403, 'FILE_PROTECTED'],
|
|
35
|
+
symlink: [409, 'SYMLINK_UNSUPPORTED'],
|
|
36
|
+
'invalid-destination': [400, 'INVALID_FILE_DESTINATION'],
|
|
37
|
+
'source-inside-destination': [409, 'SOURCE_INSIDE_DESTINATION'],
|
|
38
|
+
'same-parent': [409, 'SAME_PARENT_MOVE'],
|
|
39
|
+
conflict: [409, 'FILE_CONFLICT'],
|
|
40
|
+
'replace-unsupported': [409, 'FILE_REPLACE_UNSUPPORTED'],
|
|
41
|
+
unreadable: [503, 'WORKSPACE_FILES_UNAVAILABLE'],
|
|
42
|
+
};
|
|
43
|
+
const [status, code] = mapped[result.kind] ?? [503, 'WORKSPACE_FILES_UNAVAILABLE'];
|
|
44
|
+
return reply.code(status).send({
|
|
45
|
+
code,
|
|
46
|
+
...(result.kind === 'conflict' ? { replaceAllowed: result.replaceAllowed } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { map } from '../copy-entry/endpoint.js';
|
|
2
|
+
export function registerDeleteEntry(app, deps) {
|
|
3
|
+
app.delete('/api/workspaces/:workspaceId/files', async (request, reply) => {
|
|
4
|
+
const body = request.body;
|
|
5
|
+
if (typeof body?.path !== 'string' || body.recursive !== true)
|
|
6
|
+
return reply.code(400).send({ code: 'INVALID_DELETE_REQUEST' });
|
|
7
|
+
if (!deps.files.delete)
|
|
8
|
+
return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
|
|
9
|
+
try {
|
|
10
|
+
const w = await deps.workspaces.resolve(request.params.workspaceId);
|
|
11
|
+
return map(reply, await deps.files.delete(w.realPath, { path: body.path, recursive: true }));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return reply
|
|
15
|
+
.code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
|
|
16
|
+
.send({
|
|
17
|
+
code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
|
|
18
|
+
? 'WORKSPACE_NOT_FOUND'
|
|
19
|
+
: 'WORKSPACE_FILES_UNAVAILABLE',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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 parseRelativeDirectory(value) {
|
|
7
|
+
if (value === '')
|
|
8
|
+
return value;
|
|
9
|
+
if (value.includes('\\') || value.includes('\0') || value.startsWith('/') || value.endsWith('/'))
|
|
10
|
+
return null;
|
|
11
|
+
const parts = value.split('/');
|
|
12
|
+
if (parts.some((part) => part === '' || part === '.' || part === '..' || part === '.git'))
|
|
13
|
+
return null;
|
|
14
|
+
return value;
|
|
15
|
+
}
|