@flowdular/sdk 0.1.0 → 0.2.1
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/.ai/skills/test-hardening/SKILL.md +1 -1
- package/README.md +69 -12
- package/assets/flowdular-banner.png +0 -0
- package/modules/agents/package.json +1 -1
- package/modules/auth/package.json +1 -1
- package/modules/auth/src/client/AuthenticationCore.tsrx +3 -1
- package/modules/auth/src/server/endpoints.ts +3 -1
- package/modules/auth/src/server/runtime.ts +4 -0
- package/modules/automations/package.json +1 -1
- package/modules/automations-workflows-integration/package.json +1 -1
- package/modules/profile/package.json +1 -1
- package/modules/sandbox/package.json +1 -1
- package/modules/system/package.json +1 -1
- package/modules/users/package.json +1 -1
- package/modules/workflows/package.json +1 -1
- package/package.json +5 -13
- package/packages/ai-provider/package.json +1 -1
- package/packages/cli-protocol/package.json +1 -1
- package/packages/client/package.json +1 -1
- package/packages/client/src/routing.ts +39 -2
- package/packages/client/src/shell/BrandMark.tsrx +5 -1
- package/packages/client/src/shell/navigation.ts +3 -1
- package/packages/client/src/state.ts +6 -2
- package/packages/contracts/package.json +1 -1
- package/packages/contracts/schemas/project.schema.json +16 -42
- package/packages/database/package.json +1 -1
- package/packages/database-pglite/package.json +2 -2
- package/packages/database-pglite/src/driver.ts +21 -3
- package/packages/database-testing/package.json +1 -1
- package/packages/dev-console/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/kernel/package.json +1 -1
- package/packages/server/package.json +1 -1
- package/packages/server/src/application-routes.ts +80 -8
- package/packages/server/src/index.ts +4 -1
- package/packages/server/src/web-html.ts +9 -5
- package/packages/server/src/web.ts +59 -7
- package/packages/ui/package.json +1 -1
- package/packages/coding-agent/package.json +0 -44
- package/packages/coding-agent/src/drivers/byok.ts +0 -322
- package/packages/coding-agent/src/drivers/claude-code.ts +0 -290
- package/packages/coding-agent/src/drivers/codex.ts +0 -281
- package/packages/coding-agent/src/index.ts +0 -56
- package/packages/coding-agent/src/registry.ts +0 -119
- package/packages/coding-agent/src/roles/contract.ts +0 -60
- package/packages/coding-agent/src/roles/defaults.ts +0 -134
- package/packages/coding-agent/src/roles/handoff.ts +0 -41
- package/packages/coding-agent/src/roles/registry.ts +0 -156
- package/packages/coding-agent/src/roles/skills.ts +0 -72
- package/packages/coding-agent/src/roles/sync.ts +0 -88
- package/packages/coding-agent/src/types.ts +0 -102
- package/packages/coding-agent/src/workspace.ts +0 -275
- package/packages/sandbox/README.md +0 -691
- package/packages/sandbox/bin/flowdular-sandbox.mjs +0 -393
- package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +0 -11
- package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
- package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +0 -3
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +0 -4
- package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +0 -3
- package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +0 -1
- package/packages/sandbox/dist/client/favicon.svg +0 -1
- package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +0 -12785
- package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
- package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
- package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
- package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +0 -55
- package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
- package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +0 -494
- package/packages/sandbox/dist/server/entry.js +0 -91671
- package/packages/sandbox/dist/server/favicon.svg +0 -1
- package/packages/sandbox/dist/server/index.html +0 -161
- package/packages/sandbox/index.html +0 -159
- package/packages/sandbox/octane.config.ts +0 -36
- package/packages/sandbox/package.json +0 -83
- package/packages/sandbox/public/favicon.svg +0 -1
- package/packages/sandbox/public/og.png +0 -0
- package/packages/sandbox/src/App.tsrx +0 -924
- package/packages/sandbox/src/client/ApprovalHandoff.tsrx +0 -39
- package/packages/sandbox/src/client/ChatPane.tsrx +0 -694
- package/packages/sandbox/src/client/ConnectView.tsrx +0 -86
- package/packages/sandbox/src/client/DiffPane.tsrx +0 -150
- package/packages/sandbox/src/client/EjectModal.tsrx +0 -571
- package/packages/sandbox/src/client/GateResults.tsrx +0 -60
- package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +0 -219
- package/packages/sandbox/src/client/NewSessionForm.tsrx +0 -214
- package/packages/sandbox/src/client/SessionBar.tsrx +0 -151
- package/packages/sandbox/src/client/SessionPicker.tsrx +0 -472
- package/packages/sandbox/src/client/SpecEditor.tsrx +0 -109
- package/packages/sandbox/src/client/SpecReviewCard.tsrx +0 -321
- package/packages/sandbox/src/client/WorkCard.tsrx +0 -128
- package/packages/sandbox/src/client/WorkbenchModal.tsrx +0 -161
- package/packages/sandbox/src/client/WorkspaceMenu.tsrx +0 -100
- package/packages/sandbox/src/client/api.ts +0 -797
- package/packages/sandbox/src/client/attachments.ts +0 -73
- package/packages/sandbox/src/client/dashboard.ts +0 -46
- package/packages/sandbox/src/client/github-settings.ts +0 -48
- package/packages/sandbox/src/client/highlight.ts +0 -105
- package/packages/sandbox/src/client/i18n.ts +0 -32
- package/packages/sandbox/src/client/locales/en.json +0 -466
- package/packages/sandbox/src/client/locales/pl.json +0 -466
- package/packages/sandbox/src/client/session-labels.ts +0 -77
- package/packages/sandbox/src/client/state.ts +0 -108
- package/packages/sandbox/src/index.ts +0 -10
- package/packages/sandbox/src/preview/PreviewHost.tsrx +0 -373
- package/packages/sandbox/src/preview/load-module.ts +0 -16
- package/packages/sandbox/src/server/attachments.ts +0 -308
- package/packages/sandbox/src/server/auto-review.ts +0 -181
- package/packages/sandbox/src/server/checkpoints.ts +0 -90
- package/packages/sandbox/src/server/config.ts +0 -460
- package/packages/sandbox/src/server/dashboard.ts +0 -245
- package/packages/sandbox/src/server/delivery/configuration.ts +0 -193
- package/packages/sandbox/src/server/delivery/git-pr.ts +0 -1473
- package/packages/sandbox/src/server/delivery/index.ts +0 -83
- package/packages/sandbox/src/server/delivery/local.ts +0 -151
- package/packages/sandbox/src/server/delivery/official-modules.ts +0 -516
- package/packages/sandbox/src/server/delivery/plan.ts +0 -218
- package/packages/sandbox/src/server/delivery/policies.ts +0 -239
- package/packages/sandbox/src/server/delivery/record.ts +0 -41
- package/packages/sandbox/src/server/delivery/steps.ts +0 -464
- package/packages/sandbox/src/server/delivery/types.ts +0 -158
- package/packages/sandbox/src/server/dependencies.ts +0 -88
- package/packages/sandbox/src/server/diff.ts +0 -230
- package/packages/sandbox/src/server/gates.ts +0 -388
- package/packages/sandbox/src/server/index.ts +0 -139
- package/packages/sandbox/src/server/path-guard.ts +0 -282
- package/packages/sandbox/src/server/planning.ts +0 -749
- package/packages/sandbox/src/server/platform-client.ts +0 -189
- package/packages/sandbox/src/server/preview-database-host.ts +0 -340
- package/packages/sandbox/src/server/preview-database-protocol.ts +0 -163
- package/packages/sandbox/src/server/preview-database-proxy.ts +0 -356
- package/packages/sandbox/src/server/preview-database.ts +0 -109
- package/packages/sandbox/src/server/preview-ipc.ts +0 -15
- package/packages/sandbox/src/server/preview-modules.ts +0 -83
- package/packages/sandbox/src/server/preview-revision.ts +0 -44
- package/packages/sandbox/src/server/preview-runtime.ts +0 -467
- package/packages/sandbox/src/server/preview-worker-manager.ts +0 -436
- package/packages/sandbox/src/server/preview-worker.ts +0 -168
- package/packages/sandbox/src/server/reference.ts +0 -251
- package/packages/sandbox/src/server/routes.ts +0 -2258
- package/packages/sandbox/src/server/runtime.ts +0 -188
- package/packages/sandbox/src/server/session-owner.ts +0 -23
- package/packages/sandbox/src/server/sessions.ts +0 -849
- package/packages/sandbox/src/server/spec.ts +0 -471
- package/packages/sandbox/src/server/turn-lifecycle.ts +0 -56
- package/packages/sandbox/src/server/turns.ts +0 -993
- package/packages/sandbox/src/server/workspace-install.ts +0 -266
- package/packages/sandbox/src/server/workspace-root.ts +0 -73
- package/packages/sandbox/src/styles.css +0 -1573
- package/packages/sandbox/vite.config.ts +0 -133
|
@@ -1,849 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
access,
|
|
4
|
-
appendFile,
|
|
5
|
-
cp,
|
|
6
|
-
mkdir,
|
|
7
|
-
readFile,
|
|
8
|
-
readdir,
|
|
9
|
-
realpath,
|
|
10
|
-
rename,
|
|
11
|
-
rm,
|
|
12
|
-
writeFile,
|
|
13
|
-
} from 'node:fs/promises';
|
|
14
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
15
|
-
import type { CodingAgentEvent } from '@flowdular/sdk/coding-agent';
|
|
16
|
-
import { sandboxDirectory } from './config.ts';
|
|
17
|
-
import { materializeModuleGraph, materializeReference } from './reference.ts';
|
|
18
|
-
import { hashSpec } from './spec.ts';
|
|
19
|
-
import { forgetDiffs } from './turns.ts';
|
|
20
|
-
import {
|
|
21
|
-
ensureSessionDependencies,
|
|
22
|
-
materializeSessionWorkspace,
|
|
23
|
-
type InstallResult,
|
|
24
|
-
} from './workspace-install.ts';
|
|
25
|
-
import { SandboxSetupError } from './workspace-root.ts';
|
|
26
|
-
|
|
27
|
-
export type SandboxSessionKind = 'new-module' | 'edit-module';
|
|
28
|
-
|
|
29
|
-
export type SandboxSessionState =
|
|
30
|
-
| 'draft'
|
|
31
|
-
| 'classified'
|
|
32
|
-
| 'planned'
|
|
33
|
-
| 'editing'
|
|
34
|
-
| 'validating'
|
|
35
|
-
| 'previewing'
|
|
36
|
-
| 'awaiting-approval'
|
|
37
|
-
| 'accepted'
|
|
38
|
-
| 'failed'
|
|
39
|
-
| 'blocked'
|
|
40
|
-
| 'deleted';
|
|
41
|
-
|
|
42
|
-
/* What happens after a turn. The orchestrator writes one of these at the end of
|
|
43
|
-
every turn, so the session always says who continues and why. */
|
|
44
|
-
export interface HandoffPlan {
|
|
45
|
-
readonly kind: 'continue' | 'approval' | 'question' | 'review' | 'blocked';
|
|
46
|
-
readonly role: string;
|
|
47
|
-
readonly roleName: string;
|
|
48
|
-
readonly reason: string;
|
|
49
|
-
readonly prompt: string;
|
|
50
|
-
/* The draft module directory the next turn works in. Absent on handoffs
|
|
51
|
-
written before a session could target one module of several. */
|
|
52
|
-
readonly module?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/* One draft module of a session. The directory is the module directory under
|
|
56
|
-
modules/ in both the session workspace and, for an edit, the host
|
|
57
|
-
workspace it was copied from. */
|
|
58
|
-
export interface SessionModule {
|
|
59
|
-
readonly id: string;
|
|
60
|
-
readonly directory: string;
|
|
61
|
-
readonly kind: 'new' | 'edit';
|
|
62
|
-
/* When the operator approved this module's specification, and the hash of
|
|
63
|
-
the exact text they approved. A later edit changes the hash, so the
|
|
64
|
-
approval belongs to that version of the document and nothing else. */
|
|
65
|
-
readonly specApprovedAt?: number;
|
|
66
|
-
readonly specHash?: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/* A file the operator attached to the session to show what they want changed.
|
|
70
|
-
The bytes live under the session directory; the same file is copied into the
|
|
71
|
-
workspace so the coding agent, which may only read inside the workspace, can
|
|
72
|
-
open it. `name` is the sanitised filename and doubles as the workspace copy's
|
|
73
|
-
name, so it is unique within the session. */
|
|
74
|
-
export interface SessionAttachment {
|
|
75
|
-
readonly id: string;
|
|
76
|
-
readonly name: string;
|
|
77
|
-
readonly kind: 'image' | 'file';
|
|
78
|
-
readonly size: number;
|
|
79
|
-
readonly addedAt: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/* A restore point the operator can roll the workspace back to. The snapshot of
|
|
83
|
-
each draft module tree lives under checkpoints/<sequence>/; the metadata here
|
|
84
|
-
says which turn produced it. `sequence` is the chat entry it belongs to, so
|
|
85
|
-
the transcript line and its restore point share one identity. */
|
|
86
|
-
export interface SessionCheckpoint {
|
|
87
|
-
readonly sequence: number;
|
|
88
|
-
readonly at: number;
|
|
89
|
-
readonly label: string;
|
|
90
|
-
readonly role: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface SandboxSession {
|
|
94
|
-
/* Historical records have no owner. Never infer one from the next login. */
|
|
95
|
-
readonly owner?: SessionOwner;
|
|
96
|
-
readonly rejectedAt?: number | null;
|
|
97
|
-
readonly id: string;
|
|
98
|
-
readonly kind: SandboxSessionKind;
|
|
99
|
-
/* The primary module: modules[0]. Kept as fields for the current UI. */
|
|
100
|
-
readonly moduleId: string;
|
|
101
|
-
readonly moduleSuffix: string;
|
|
102
|
-
readonly modules: readonly SessionModule[];
|
|
103
|
-
readonly title: string;
|
|
104
|
-
/* The request the session started from, replayed to every specialist that
|
|
105
|
-
picks the work up later. */
|
|
106
|
-
readonly brief: string;
|
|
107
|
-
readonly blueprint: string;
|
|
108
|
-
readonly role: string;
|
|
109
|
-
readonly driver: string;
|
|
110
|
-
readonly model: string | null;
|
|
111
|
-
readonly resumeIds: Readonly<Record<string, string>>;
|
|
112
|
-
/* When true, the orchestrator starts the handed-off turn without waiting for
|
|
113
|
-
the operator. Approval and failure always stop, whatever this says. */
|
|
114
|
-
readonly autoContinue: boolean;
|
|
115
|
-
/* Automatic turns run since the operator last spoke. */
|
|
116
|
-
readonly chainDepth: number;
|
|
117
|
-
/* Files the operator attached to show the desired change. Bounded per
|
|
118
|
-
session; see attachments.ts for the limits. */
|
|
119
|
-
readonly attachments: readonly SessionAttachment[];
|
|
120
|
-
/* Restore points, oldest first. Bounded; see checkpoints.ts for the cap and
|
|
121
|
-
the pruning that never drops the start. */
|
|
122
|
-
readonly checkpoints: readonly SessionCheckpoint[];
|
|
123
|
-
readonly state: SandboxSessionState;
|
|
124
|
-
readonly createdAt: number;
|
|
125
|
-
readonly updatedAt: number;
|
|
126
|
-
readonly ejectedAt: number | null;
|
|
127
|
-
readonly archivedAt: number | null;
|
|
128
|
-
readonly registeredWithPlatform: boolean;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export interface SessionOwner {
|
|
132
|
-
readonly platformUrl: string;
|
|
133
|
-
readonly tenantId: string;
|
|
134
|
-
readonly accountId: string;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export interface ChatEntry {
|
|
138
|
-
readonly decision?: 'approved' | 'changes-requested';
|
|
139
|
-
readonly sequence: number;
|
|
140
|
-
readonly at: number;
|
|
141
|
-
readonly kind: 'user' | 'agent' | 'event' | 'system';
|
|
142
|
-
readonly role: string;
|
|
143
|
-
/* The draft module directory the entry belongs to. Absent on entries that
|
|
144
|
-
are about the session itself, and on entries written before a session
|
|
145
|
-
could carry several modules. */
|
|
146
|
-
readonly module?: string;
|
|
147
|
-
readonly text?: string;
|
|
148
|
-
readonly event?: CodingAgentEvent;
|
|
149
|
-
readonly handoff?: HandoffPlan;
|
|
150
|
-
/* Attachments included with this turn, echoed onto the user entry so the
|
|
151
|
-
transcript records exactly what the agent was shown. */
|
|
152
|
-
readonly attachments?: readonly SessionAttachment[];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export interface SessionPaths {
|
|
156
|
-
readonly root: string;
|
|
157
|
-
readonly workspace: string;
|
|
158
|
-
/* The primary module directory. */
|
|
159
|
-
readonly modulePath: string;
|
|
160
|
-
readonly base: string;
|
|
161
|
-
readonly data: string;
|
|
162
|
-
/* Where module-tree snapshots live, one directory per checkpoint sequence. */
|
|
163
|
-
readonly checkpoints: string;
|
|
164
|
-
readonly chatLog: string;
|
|
165
|
-
readonly record: string;
|
|
166
|
-
/* Where attachment bytes are stored, and the workspace copy the coding
|
|
167
|
-
agent reads from. */
|
|
168
|
-
readonly attachments: string;
|
|
169
|
-
readonly workspaceAttachments: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const SESSION_ID =
|
|
173
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
174
|
-
|
|
175
|
-
export function isSessionId(value: string): boolean {
|
|
176
|
-
return SESSION_ID.test(value);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/* Session ids are the only client-chosen segment that reaches the file
|
|
180
|
-
system, so anything that is not an id the sandbox itself minted is refused
|
|
181
|
-
before a path is built from it. */
|
|
182
|
-
export function assertSessionId(value: string): string {
|
|
183
|
-
if (!isSessionId(value)) {
|
|
184
|
-
throw new SandboxSetupError(
|
|
185
|
-
'INVALID_SESSION_ID',
|
|
186
|
-
'The session id is not a sandbox session identifier.',
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
return value;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function moduleSuffixOf(moduleId: string): string {
|
|
193
|
-
const parts = moduleId.split('.');
|
|
194
|
-
return (parts.at(-1) === 'core' ? parts.slice(0, -1) : parts).join('-');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function sessionsRoot(workspaceRoot: string): string {
|
|
198
|
-
return join(sandboxDirectory(workspaceRoot), 'sessions');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function sessionRoot(workspaceRoot: string, sessionId: string): string {
|
|
202
|
-
const parent = sessionsRoot(workspaceRoot);
|
|
203
|
-
const root = resolve(parent, sessionId);
|
|
204
|
-
const inside = relative(parent, root);
|
|
205
|
-
if (!inside || inside.startsWith('..') || isAbsolute(inside)) {
|
|
206
|
-
throw new SandboxSetupError(
|
|
207
|
-
'INVALID_SESSION_ID',
|
|
208
|
-
'The session id is not a sandbox session identifier.',
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
return root;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function sessionPaths(
|
|
215
|
-
workspaceRoot: string,
|
|
216
|
-
sessionId: string,
|
|
217
|
-
moduleSuffix: string,
|
|
218
|
-
): SessionPaths {
|
|
219
|
-
const root = sessionRoot(workspaceRoot, sessionId);
|
|
220
|
-
const workspace = join(root, 'workspace');
|
|
221
|
-
return {
|
|
222
|
-
root,
|
|
223
|
-
workspace,
|
|
224
|
-
modulePath: join(workspace, 'modules', moduleSuffix),
|
|
225
|
-
base: join(root, 'base'),
|
|
226
|
-
data: join(root, 'data'),
|
|
227
|
-
checkpoints: join(root, 'checkpoints'),
|
|
228
|
-
chatLog: join(root, 'chat.jsonl'),
|
|
229
|
-
record: join(root, 'session.json'),
|
|
230
|
-
attachments: join(root, 'attachments'),
|
|
231
|
-
workspaceAttachments: join(workspace, 'reference', 'attachments'),
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function modulePathOf(paths: SessionPaths, directory: string): string {
|
|
236
|
-
return join(paths.workspace, 'modules', directory);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function basePathOf(paths: SessionPaths, directory: string): string {
|
|
240
|
-
return join(paths.base, 'modules', directory);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function checkpointModulePath(
|
|
244
|
-
paths: SessionPaths,
|
|
245
|
-
sequence: number,
|
|
246
|
-
directory: string,
|
|
247
|
-
): string {
|
|
248
|
-
return join(paths.checkpoints, String(sequence), 'modules', directory);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function exists(path: string): Promise<boolean> {
|
|
252
|
-
try {
|
|
253
|
-
await access(path);
|
|
254
|
-
return true;
|
|
255
|
-
} catch {
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const COPY_EXCLUDED = new Set(['node_modules', 'dist', '.turbo']);
|
|
261
|
-
|
|
262
|
-
export async function copyModuleTree(
|
|
263
|
-
source: string,
|
|
264
|
-
target: string,
|
|
265
|
-
): Promise<void> {
|
|
266
|
-
await cp(source, target, {
|
|
267
|
-
recursive: true,
|
|
268
|
-
filter: (path) => {
|
|
269
|
-
const segments = path.split('/');
|
|
270
|
-
return !segments.some((segment) => COPY_EXCLUDED.has(segment));
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/* A session workspace is a Flowdular workspace of its own: the draft modules,
|
|
276
|
-
the manifests of every other enabled module, the shared TypeScript and
|
|
277
|
-
formatting contracts, and a pnpm workspace that links the live framework
|
|
278
|
-
packages so gates run against the code the platform runs. */
|
|
279
|
-
async function prepareSessionWorkspace(options: {
|
|
280
|
-
readonly workspaceRoot: string;
|
|
281
|
-
readonly paths: SessionPaths;
|
|
282
|
-
readonly modules: readonly SessionModule[];
|
|
283
|
-
}): Promise<void> {
|
|
284
|
-
await mkdir(join(options.paths.workspace, 'modules'), { recursive: true });
|
|
285
|
-
await mkdir(options.paths.data, { recursive: true });
|
|
286
|
-
|
|
287
|
-
const graph = await materializeModuleGraph(
|
|
288
|
-
options.workspaceRoot,
|
|
289
|
-
options.paths.workspace,
|
|
290
|
-
options.modules.map((module) => module.id),
|
|
291
|
-
);
|
|
292
|
-
await materializeReference(options.workspaceRoot, options.paths.workspace);
|
|
293
|
-
await writeFile(
|
|
294
|
-
join(options.paths.workspace, 'flowdular.json'),
|
|
295
|
-
`${JSON.stringify(
|
|
296
|
-
{
|
|
297
|
-
schemaVersion: 1,
|
|
298
|
-
architectureVersion: '0.2.0',
|
|
299
|
-
specs: { platformRoot: 'specs', moduleDirectory: 'spec' },
|
|
300
|
-
modules: {
|
|
301
|
-
roots: ['modules'],
|
|
302
|
-
enabled: [
|
|
303
|
-
...graph,
|
|
304
|
-
...options.modules.map((module) => module.id),
|
|
305
|
-
].sort(),
|
|
306
|
-
},
|
|
307
|
-
locales: ['en', 'pl'],
|
|
308
|
-
},
|
|
309
|
-
null,
|
|
310
|
-
'\t',
|
|
311
|
-
)}\n`,
|
|
312
|
-
'utf8',
|
|
313
|
-
);
|
|
314
|
-
for (const shared of ['tsconfig.base.json', '.prettierrc.json']) {
|
|
315
|
-
await cp(
|
|
316
|
-
join(options.workspaceRoot, shared),
|
|
317
|
-
join(options.paths.workspace, shared),
|
|
318
|
-
).catch(() => undefined);
|
|
319
|
-
}
|
|
320
|
-
await mkdir(join(options.paths.workspace, 'specs'), { recursive: true });
|
|
321
|
-
await materializeSessionWorkspace({
|
|
322
|
-
workspaceRoot: options.workspaceRoot,
|
|
323
|
-
sessionWorkspace: options.paths.workspace,
|
|
324
|
-
modules: options.modules,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/* Installs the draft modules' dependencies when they changed. The result is
|
|
329
|
-
what the caller reports: a failed install is a gate failure the agent has to
|
|
330
|
-
see, not a silent typecheck error. */
|
|
331
|
-
export function installSessionDependencies(
|
|
332
|
-
workspaceRoot: string,
|
|
333
|
-
session: SandboxSession,
|
|
334
|
-
force = false,
|
|
335
|
-
): Promise<InstallResult> {
|
|
336
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
337
|
-
return ensureSessionDependencies({
|
|
338
|
-
sessionRoot: paths.root,
|
|
339
|
-
sessionWorkspace: paths.workspace,
|
|
340
|
-
modules: session.modules,
|
|
341
|
-
force,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export interface CreateSessionInput {
|
|
346
|
-
readonly owner?: SessionOwner;
|
|
347
|
-
readonly workspaceRoot: string;
|
|
348
|
-
readonly kind: SandboxSessionKind;
|
|
349
|
-
readonly moduleId: string;
|
|
350
|
-
readonly title: string;
|
|
351
|
-
readonly brief: string;
|
|
352
|
-
readonly blueprint: string;
|
|
353
|
-
readonly role: string;
|
|
354
|
-
readonly driver: string;
|
|
355
|
-
readonly model?: string | null;
|
|
356
|
-
/* Module directory name inside the workspace, for an edit session. */
|
|
357
|
-
readonly sourceModule?: string;
|
|
358
|
-
/* Every module of the session; the first one is primary. Defaults to the
|
|
359
|
-
single module named by moduleId. */
|
|
360
|
-
readonly modules?: readonly SessionModule[];
|
|
361
|
-
/* Run the dependency install right away. The routes run it themselves so
|
|
362
|
-
they can report a failure into the transcript. */
|
|
363
|
-
readonly install?: boolean;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const MODULE_ID = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
367
|
-
const MODULE_DIRECTORY = /^[a-z][a-z0-9-]*$/;
|
|
368
|
-
|
|
369
|
-
function assertModule(module: SessionModule): void {
|
|
370
|
-
if (!MODULE_ID.test(module.id)) {
|
|
371
|
-
throw new SandboxSetupError(
|
|
372
|
-
'INVALID_MODULE_ID',
|
|
373
|
-
'Module id must use lowercase dot-separated segments, for example sales.orders.',
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
if (!MODULE_DIRECTORY.test(module.directory)) {
|
|
377
|
-
throw new SandboxSetupError(
|
|
378
|
-
'INVALID_MODULE_ID',
|
|
379
|
-
`Module directory ${module.directory} is not a module directory name.`,
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export async function createSession(
|
|
385
|
-
input: CreateSessionInput,
|
|
386
|
-
): Promise<SandboxSession> {
|
|
387
|
-
const moduleSuffix = moduleSuffixOf(input.moduleId);
|
|
388
|
-
const modules: readonly SessionModule[] = input.modules ?? [
|
|
389
|
-
{
|
|
390
|
-
id: input.moduleId,
|
|
391
|
-
directory:
|
|
392
|
-
input.kind === 'edit-module'
|
|
393
|
-
? (input.sourceModule ?? moduleSuffix)
|
|
394
|
-
: moduleSuffix,
|
|
395
|
-
kind: input.kind === 'edit-module' ? 'edit' : 'new',
|
|
396
|
-
},
|
|
397
|
-
];
|
|
398
|
-
if (modules.length === 0 || modules[0]!.id !== input.moduleId) {
|
|
399
|
-
throw new SandboxSetupError(
|
|
400
|
-
'INVALID_MODULE_ID',
|
|
401
|
-
'The primary module must be the first module of the session.',
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
for (const module of modules) assertModule(module);
|
|
405
|
-
const primaryDirectory = modules[0]!.directory;
|
|
406
|
-
const id = randomUUID();
|
|
407
|
-
const paths = sessionPaths(input.workspaceRoot, id, primaryDirectory);
|
|
408
|
-
|
|
409
|
-
for (const module of modules) {
|
|
410
|
-
const target = modulePathOf(paths, module.directory);
|
|
411
|
-
const base = basePathOf(paths, module.directory);
|
|
412
|
-
if (module.kind === 'edit') {
|
|
413
|
-
const source = join(input.workspaceRoot, 'modules', module.directory);
|
|
414
|
-
await copyModuleTree(source, target);
|
|
415
|
-
await copyModuleTree(source, base);
|
|
416
|
-
} else {
|
|
417
|
-
await mkdir(target, { recursive: true });
|
|
418
|
-
await mkdir(base, { recursive: true });
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
await prepareSessionWorkspace({
|
|
423
|
-
workspaceRoot: input.workspaceRoot,
|
|
424
|
-
paths,
|
|
425
|
-
modules,
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
/* The pristine state is the first restore point, so an operator can always
|
|
429
|
-
roll back to before any agent touched the module. */
|
|
430
|
-
for (const module of modules) {
|
|
431
|
-
await copyModuleTree(
|
|
432
|
-
modulePathOf(paths, module.directory),
|
|
433
|
-
checkpointModulePath(paths, 0, module.directory),
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const now = Date.now();
|
|
438
|
-
const session: SandboxSession = {
|
|
439
|
-
...(input.owner ? { owner: input.owner } : {}),
|
|
440
|
-
id,
|
|
441
|
-
kind: input.kind,
|
|
442
|
-
moduleId: input.moduleId,
|
|
443
|
-
moduleSuffix: primaryDirectory,
|
|
444
|
-
modules,
|
|
445
|
-
title: input.title,
|
|
446
|
-
brief: input.brief,
|
|
447
|
-
blueprint: input.blueprint,
|
|
448
|
-
role: input.role,
|
|
449
|
-
driver: input.driver,
|
|
450
|
-
model: input.model ?? null,
|
|
451
|
-
resumeIds: {},
|
|
452
|
-
autoContinue: true,
|
|
453
|
-
chainDepth: 0,
|
|
454
|
-
attachments: [],
|
|
455
|
-
checkpoints: [
|
|
456
|
-
{ sequence: 0, at: now, label: 'the starting point', role: input.role },
|
|
457
|
-
],
|
|
458
|
-
state: 'draft',
|
|
459
|
-
createdAt: now,
|
|
460
|
-
updatedAt: now,
|
|
461
|
-
ejectedAt: null,
|
|
462
|
-
archivedAt: null,
|
|
463
|
-
registeredWithPlatform: false,
|
|
464
|
-
};
|
|
465
|
-
await writeSession(input.workspaceRoot, session);
|
|
466
|
-
if (input.install === true) {
|
|
467
|
-
await installSessionDependencies(input.workspaceRoot, session);
|
|
468
|
-
}
|
|
469
|
-
return session;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/* A module joins a session after it started: it is materialized into the
|
|
473
|
-
workspace (with its pristine base, for an edit), appended to modules[], and
|
|
474
|
-
the workspace manifests are regenerated so the new draft is a project of the
|
|
475
|
-
session's pnpm workspace instead of a link to the host checkout. The caller
|
|
476
|
-
installs afterwards; the install only runs when the new package.json changed
|
|
477
|
-
the session's dependency signature. */
|
|
478
|
-
export async function addSessionModule(
|
|
479
|
-
workspaceRoot: string,
|
|
480
|
-
session: SandboxSession,
|
|
481
|
-
module: SessionModule,
|
|
482
|
-
): Promise<SandboxSession> {
|
|
483
|
-
assertModule(module);
|
|
484
|
-
if (
|
|
485
|
-
session.modules.some(
|
|
486
|
-
(entry) => entry.id === module.id || entry.directory === module.directory,
|
|
487
|
-
)
|
|
488
|
-
) {
|
|
489
|
-
throw new SandboxSetupError(
|
|
490
|
-
'MODULE_ALREADY_IN_SESSION',
|
|
491
|
-
`${module.id} is already part of this session.`,
|
|
492
|
-
);
|
|
493
|
-
}
|
|
494
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
495
|
-
const target = modulePathOf(paths, module.directory);
|
|
496
|
-
const base = basePathOf(paths, module.directory);
|
|
497
|
-
if (module.kind === 'edit') {
|
|
498
|
-
const source = join(workspaceRoot, 'modules', module.directory);
|
|
499
|
-
await copyModuleTree(source, target);
|
|
500
|
-
await copyModuleTree(source, base);
|
|
501
|
-
} else {
|
|
502
|
-
await mkdir(target, { recursive: true });
|
|
503
|
-
await mkdir(base, { recursive: true });
|
|
504
|
-
}
|
|
505
|
-
const modules = [...session.modules, module];
|
|
506
|
-
await prepareSessionWorkspace({ workspaceRoot, paths, modules });
|
|
507
|
-
return updateSession(workspaceRoot, session.id, {
|
|
508
|
-
modules,
|
|
509
|
-
moduleId: modules[0]!.id,
|
|
510
|
-
moduleSuffix: modules[0]!.directory,
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/* The record is replaced by rename, so a reader racing a running turn never
|
|
515
|
-
sees a half-written file. */
|
|
516
|
-
export async function writeSession(
|
|
517
|
-
workspaceRoot: string,
|
|
518
|
-
session: SandboxSession,
|
|
519
|
-
): Promise<SandboxSession> {
|
|
520
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
521
|
-
await mkdir(paths.root, { recursive: true });
|
|
522
|
-
const staging = `${paths.record}.${process.pid}.${randomUUID().slice(0, 8)}`;
|
|
523
|
-
await writeFile(staging, `${JSON.stringify(session, null, '\t')}\n`, 'utf8');
|
|
524
|
-
await rename(staging, paths.record);
|
|
525
|
-
return session;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export async function readSession(
|
|
529
|
-
workspaceRoot: string,
|
|
530
|
-
sessionId: string,
|
|
531
|
-
): Promise<SandboxSession> {
|
|
532
|
-
const root = sessionRoot(workspaceRoot, assertSessionId(sessionId));
|
|
533
|
-
try {
|
|
534
|
-
const record = JSON.parse(
|
|
535
|
-
await readFile(join(root, 'session.json'), 'utf8'),
|
|
536
|
-
) as SandboxSession;
|
|
537
|
-
/* Sessions written before a field existed stay readable, so an older
|
|
538
|
-
session opens instead of disappearing from the list. */
|
|
539
|
-
return {
|
|
540
|
-
...record,
|
|
541
|
-
brief: record.brief ?? '',
|
|
542
|
-
modules: record.modules ?? [
|
|
543
|
-
{
|
|
544
|
-
id: record.moduleId,
|
|
545
|
-
directory: record.moduleSuffix,
|
|
546
|
-
kind: record.kind === 'edit-module' ? 'edit' : 'new',
|
|
547
|
-
},
|
|
548
|
-
],
|
|
549
|
-
autoContinue: record.autoContinue !== false,
|
|
550
|
-
chainDepth: record.chainDepth ?? 0,
|
|
551
|
-
attachments: record.attachments ?? [],
|
|
552
|
-
checkpoints: record.checkpoints ?? [],
|
|
553
|
-
ejectedAt: record.ejectedAt ?? null,
|
|
554
|
-
archivedAt: record.archivedAt ?? null,
|
|
555
|
-
};
|
|
556
|
-
} catch (error) {
|
|
557
|
-
if (error instanceof SandboxSetupError) throw error;
|
|
558
|
-
throw new SandboxSetupError(
|
|
559
|
-
'SESSION_NOT_FOUND',
|
|
560
|
-
`No sandbox session ${sessionId} exists in this workspace.`,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/* Every session that still has a record, archived ones included, so the
|
|
566
|
-
client decides what to show. Deleted sessions are tombstones kept for their
|
|
567
|
-
transcript and never listed. */
|
|
568
|
-
export async function listSessions(
|
|
569
|
-
workspaceRoot: string,
|
|
570
|
-
includeDeleted = false,
|
|
571
|
-
): Promise<readonly SandboxSession[]> {
|
|
572
|
-
let entries: readonly string[];
|
|
573
|
-
try {
|
|
574
|
-
entries = await readdir(sessionsRoot(workspaceRoot));
|
|
575
|
-
} catch {
|
|
576
|
-
return [];
|
|
577
|
-
}
|
|
578
|
-
const sessions: SandboxSession[] = [];
|
|
579
|
-
for (const entry of entries) {
|
|
580
|
-
if (!isSessionId(entry)) continue;
|
|
581
|
-
try {
|
|
582
|
-
const session = await readSession(workspaceRoot, entry);
|
|
583
|
-
if (includeDeleted || session.state !== 'deleted') sessions.push(session);
|
|
584
|
-
} catch {
|
|
585
|
-
continue;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
return sessions.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
export async function updateSession(
|
|
592
|
-
workspaceRoot: string,
|
|
593
|
-
sessionId: string,
|
|
594
|
-
patch: Partial<Omit<SandboxSession, 'id' | 'createdAt'>>,
|
|
595
|
-
): Promise<SandboxSession> {
|
|
596
|
-
const current = await readSession(workspaceRoot, sessionId);
|
|
597
|
-
return writeSession(workspaceRoot, {
|
|
598
|
-
...current,
|
|
599
|
-
...patch,
|
|
600
|
-
updatedAt: Date.now(),
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export function archiveSession(
|
|
605
|
-
workspaceRoot: string,
|
|
606
|
-
sessionId: string,
|
|
607
|
-
): Promise<SandboxSession> {
|
|
608
|
-
return updateSession(workspaceRoot, sessionId, { archivedAt: Date.now() });
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
export function restoreSession(
|
|
612
|
-
workspaceRoot: string,
|
|
613
|
-
sessionId: string,
|
|
614
|
-
): Promise<SandboxSession> {
|
|
615
|
-
return updateSession(workspaceRoot, sessionId, {
|
|
616
|
-
archivedAt: null,
|
|
617
|
-
rejectedAt: null,
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/* Replaces a draft module directory with a checkpoint snapshot: the current
|
|
622
|
-
contents step aside, then the snapshot is copied over the emptied directory.
|
|
623
|
-
node_modules stays so the session's install survives the rollback. */
|
|
624
|
-
async function replaceModuleContents(
|
|
625
|
-
target: string,
|
|
626
|
-
snapshot: string,
|
|
627
|
-
): Promise<void> {
|
|
628
|
-
await mkdir(target, { recursive: true });
|
|
629
|
-
for (const entry of await readdir(target)) {
|
|
630
|
-
if (entry === 'node_modules') continue;
|
|
631
|
-
await rm(join(target, entry), { recursive: true, force: true });
|
|
632
|
-
}
|
|
633
|
-
await copyModuleTree(snapshot, target);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/* Rolls the session workspace back to a captured checkpoint. The transcript is
|
|
637
|
-
append-only: the rollback is recorded as a marker, never by rewriting the
|
|
638
|
-
history, so the record of what the agents did stays intact. */
|
|
639
|
-
export async function restoreCheckpoint(
|
|
640
|
-
workspaceRoot: string,
|
|
641
|
-
sessionId: string,
|
|
642
|
-
sequence: number,
|
|
643
|
-
): Promise<SandboxSession> {
|
|
644
|
-
const session = await readSession(workspaceRoot, assertSessionId(sessionId));
|
|
645
|
-
const checkpoint = session.checkpoints.find(
|
|
646
|
-
(entry) => entry.sequence === sequence,
|
|
647
|
-
);
|
|
648
|
-
if (!checkpoint) {
|
|
649
|
-
throw new SandboxSetupError(
|
|
650
|
-
'CHECKPOINT_NOT_FOUND',
|
|
651
|
-
`No checkpoint for turn ${sequence} exists in this session.`,
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
655
|
-
for (const module of session.modules) {
|
|
656
|
-
const snapshot = checkpointModulePath(paths, sequence, module.directory);
|
|
657
|
-
/* A checkpoint older than the module says nothing about it, so that
|
|
658
|
-
module keeps its current files instead of failing the rollback. */
|
|
659
|
-
if (!(await exists(snapshot))) continue;
|
|
660
|
-
await replaceModuleContents(
|
|
661
|
-
modulePathOf(paths, module.directory),
|
|
662
|
-
snapshot,
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
|
-
forgetDiffs(session.id);
|
|
666
|
-
await appendChatEntry(workspaceRoot, session, {
|
|
667
|
-
kind: 'system',
|
|
668
|
-
role: checkpoint.role,
|
|
669
|
-
text: `Restored the workspace to the state after ${checkpoint.label} (turn ${sequence}).`,
|
|
670
|
-
});
|
|
671
|
-
return updateSession(workspaceRoot, session.id, { state: 'editing' });
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
export interface DeleteSessionOptions {
|
|
675
|
-
/* Keep session.json and chat.jsonl as a tombstone. Default true. */
|
|
676
|
-
readonly keepTranscript?: boolean;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/* Deleting removes what the session materialized: the workspace, the base
|
|
680
|
-
copy and the ephemeral data. The record and the transcript stay by default,
|
|
681
|
-
because they are the evidence of what the agents did. */
|
|
682
|
-
export async function deleteSession(
|
|
683
|
-
workspaceRoot: string,
|
|
684
|
-
sessionId: string,
|
|
685
|
-
options: DeleteSessionOptions = {},
|
|
686
|
-
): Promise<void> {
|
|
687
|
-
const session = await readSession(workspaceRoot, sessionId);
|
|
688
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
689
|
-
/* A symlink planted inside the sessions directory must not turn the
|
|
690
|
-
removal into one outside it. */
|
|
691
|
-
const canonicalParent = await realpath(sessionsRoot(workspaceRoot));
|
|
692
|
-
const canonicalRoot = await realpath(paths.root);
|
|
693
|
-
const inside = relative(canonicalParent, canonicalRoot);
|
|
694
|
-
if (!inside || inside.startsWith('..') || isAbsolute(inside)) {
|
|
695
|
-
throw new SandboxSetupError(
|
|
696
|
-
'INVALID_SESSION_ID',
|
|
697
|
-
'The session directory is not inside the sandbox sessions directory.',
|
|
698
|
-
);
|
|
699
|
-
}
|
|
700
|
-
lastSequence.delete(session.id);
|
|
701
|
-
if (options.keepTranscript === false) {
|
|
702
|
-
await rm(paths.root, { recursive: true, force: true });
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
for (const directory of [
|
|
706
|
-
paths.workspace,
|
|
707
|
-
paths.base,
|
|
708
|
-
paths.data,
|
|
709
|
-
paths.attachments,
|
|
710
|
-
paths.checkpoints,
|
|
711
|
-
]) {
|
|
712
|
-
await rm(directory, { recursive: true, force: true });
|
|
713
|
-
}
|
|
714
|
-
await writeSession(workspaceRoot, {
|
|
715
|
-
...session,
|
|
716
|
-
state: 'deleted',
|
|
717
|
-
updatedAt: Date.now(),
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/* Approving a draft specification is a one-line, reviewable edit: only the
|
|
722
|
-
status field moves, so the operator's decision cannot rewrite the document.
|
|
723
|
-
The hash of the approved text is recorded on the module, which is what makes
|
|
724
|
-
the approval belong to this version of the specification and not to the file
|
|
725
|
-
name: an edit after it re-opens the gate. */
|
|
726
|
-
export async function approveSpecification(
|
|
727
|
-
workspaceRoot: string,
|
|
728
|
-
session: SandboxSession,
|
|
729
|
-
module: SessionModule = session.modules[0]!,
|
|
730
|
-
): Promise<{
|
|
731
|
-
readonly session: SandboxSession;
|
|
732
|
-
readonly status: string;
|
|
733
|
-
readonly module: string;
|
|
734
|
-
}> {
|
|
735
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
736
|
-
const specPath = join(
|
|
737
|
-
modulePathOf(paths, module.directory),
|
|
738
|
-
'spec',
|
|
739
|
-
'module.yaml',
|
|
740
|
-
);
|
|
741
|
-
let spec: string;
|
|
742
|
-
try {
|
|
743
|
-
spec = await readFile(specPath, 'utf8');
|
|
744
|
-
} catch {
|
|
745
|
-
throw new SandboxSetupError(
|
|
746
|
-
'SPEC_NOT_FOUND',
|
|
747
|
-
`${module.id} has no specification to approve yet.`,
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
const status = /^status:[ \t]*(\S+)[ \t]*$/m.exec(spec);
|
|
751
|
-
if (!status) {
|
|
752
|
-
throw new SandboxSetupError(
|
|
753
|
-
'SPEC_STATUS_MISSING',
|
|
754
|
-
'The specification has no status field, so it cannot be approved.',
|
|
755
|
-
);
|
|
756
|
-
}
|
|
757
|
-
if (status[1] !== 'approved') {
|
|
758
|
-
spec = spec.replace(/^status:[ \t]*\S+[ \t]*$/m, 'status: approved');
|
|
759
|
-
await writeFile(specPath, spec, 'utf8');
|
|
760
|
-
}
|
|
761
|
-
const approvedAt = Date.now();
|
|
762
|
-
const specHash = hashSpec(spec);
|
|
763
|
-
const current = await readSession(workspaceRoot, session.id);
|
|
764
|
-
return {
|
|
765
|
-
session: await updateSession(workspaceRoot, session.id, {
|
|
766
|
-
modules: current.modules.map((entry) =>
|
|
767
|
-
entry.directory === module.directory
|
|
768
|
-
? { ...entry, specApprovedAt: approvedAt, specHash }
|
|
769
|
-
: entry,
|
|
770
|
-
),
|
|
771
|
-
state: 'planned',
|
|
772
|
-
}),
|
|
773
|
-
status: 'approved',
|
|
774
|
-
module: module.directory,
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/* The module a request names, by directory or by id. A name the session does
|
|
779
|
-
not have is refused rather than silently redirected to the primary module,
|
|
780
|
-
because everything downstream builds a path from it. */
|
|
781
|
-
export function findSessionModule(
|
|
782
|
-
session: SandboxSession,
|
|
783
|
-
requested: string | null | undefined,
|
|
784
|
-
): SessionModule {
|
|
785
|
-
if (!requested) return session.modules[0]!;
|
|
786
|
-
const found = session.modules.find(
|
|
787
|
-
(module) => module.directory === requested || module.id === requested,
|
|
788
|
-
);
|
|
789
|
-
if (!found) {
|
|
790
|
-
throw new SandboxSetupError(
|
|
791
|
-
'MODULE_NOT_IN_SESSION',
|
|
792
|
-
`This session has no module ${requested}. Add it to the session first.`,
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
return found;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/* The next sequence per session, so an append does not re-read the whole
|
|
799
|
-
transcript. Bounded: the map only holds sessions this process appended to. */
|
|
800
|
-
const lastSequence = new Map<string, number>();
|
|
801
|
-
const SEQUENCE_CACHE_LIMIT = 256;
|
|
802
|
-
|
|
803
|
-
export async function appendChatEntry(
|
|
804
|
-
workspaceRoot: string,
|
|
805
|
-
session: SandboxSession,
|
|
806
|
-
entry: Omit<ChatEntry, 'sequence' | 'at'>,
|
|
807
|
-
): Promise<ChatEntry> {
|
|
808
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
809
|
-
let previous = lastSequence.get(session.id);
|
|
810
|
-
if (previous === undefined) {
|
|
811
|
-
previous = (await readChat(workspaceRoot, session)).at(-1)?.sequence ?? 0;
|
|
812
|
-
if (lastSequence.size >= SEQUENCE_CACHE_LIMIT) {
|
|
813
|
-
lastSequence.delete(lastSequence.keys().next().value!);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
const record: ChatEntry = {
|
|
817
|
-
...entry,
|
|
818
|
-
sequence: previous + 1,
|
|
819
|
-
at: Date.now(),
|
|
820
|
-
};
|
|
821
|
-
lastSequence.set(session.id, record.sequence);
|
|
822
|
-
await appendFile(paths.chatLog, `${JSON.stringify(record)}\n`, 'utf8');
|
|
823
|
-
return record;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
export async function readChat(
|
|
827
|
-
workspaceRoot: string,
|
|
828
|
-
session: SandboxSession,
|
|
829
|
-
after = 0,
|
|
830
|
-
): Promise<readonly ChatEntry[]> {
|
|
831
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
832
|
-
let raw: string;
|
|
833
|
-
try {
|
|
834
|
-
raw = await readFile(paths.chatLog, 'utf8');
|
|
835
|
-
} catch {
|
|
836
|
-
return [];
|
|
837
|
-
}
|
|
838
|
-
const entries: ChatEntry[] = [];
|
|
839
|
-
for (const line of raw.split('\n')) {
|
|
840
|
-
if (!line.trim()) continue;
|
|
841
|
-
try {
|
|
842
|
-
const entry = JSON.parse(line) as ChatEntry;
|
|
843
|
-
if (entry.sequence > after) entries.push(entry);
|
|
844
|
-
} catch {
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
return entries;
|
|
849
|
-
}
|