@playcraft/cli 0.1.17 → 0.1.19
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/workspace-runtime/codex/app-server.js +24 -62
- package/dist/workspace-runtime/codex/loopback.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +1 -26
- package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -13
- package/dist/workspace-runtime/server/dispatch.js +115 -413
- package/dist/workspace-runtime/server/execution-group.js +0 -3
- package/dist/workspace-runtime/server/http.js +5 -58
- package/dist/workspace-runtime/workspaces/files.js +2 -33
- package/dist/workspace-runtime/workspaces/git.js +67 -81
- package/package.json +3 -3
- package/dist/workspace-runtime/codex/transcripts.js +0 -94
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +0 -70
- package/dist/workspace-runtime/workspaces/file-snapshot.js +0 -354
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
4
|
import { z } from 'zod';
|
|
6
|
-
import { PLATFORM_ROUTES, platformEndpoint,
|
|
5
|
+
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
6
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
8
7
|
import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
|
|
9
8
|
import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
|
|
@@ -12,15 +11,12 @@ import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-s
|
|
|
12
11
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
13
12
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
14
13
|
import { disposeDesktopHost } from './desktop-host.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
18
|
-
import { GitWorkspaceError, bundleUnpushedCommits, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
14
|
+
import { InvalidWorkspacePathError, resolveWorkspaceFile, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
15
|
+
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
19
16
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
20
17
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
21
18
|
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
22
|
-
import {
|
|
23
|
-
import { probeProcess, spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
19
|
+
import { spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
24
20
|
const CONVERSATION_KINDS = new Set([
|
|
25
21
|
'session.create',
|
|
26
22
|
'session.turn',
|
|
@@ -30,14 +26,13 @@ const CONVERSATION_KINDS = new Set([
|
|
|
30
26
|
const LOCAL_KINDS = new Set([
|
|
31
27
|
'runtime.ensure',
|
|
32
28
|
'runtime.destroy',
|
|
33
|
-
'checkpoint.create',
|
|
34
|
-
'checkpoint.restore',
|
|
35
29
|
'file.write',
|
|
36
30
|
'git.commit',
|
|
37
31
|
'git.push',
|
|
38
32
|
'git.stage',
|
|
39
33
|
'git.unstage',
|
|
40
34
|
'git.discard',
|
|
35
|
+
'workspace.save',
|
|
41
36
|
'terminal.create',
|
|
42
37
|
'parameter.patch',
|
|
43
38
|
'preview.publish',
|
|
@@ -46,34 +41,23 @@ const LOCAL_KINDS = new Set([
|
|
|
46
41
|
export function canExecuteKind(state, kind) {
|
|
47
42
|
if (LOCAL_KINDS.has(kind))
|
|
48
43
|
return true;
|
|
49
|
-
if (kind === 'session.turn' && !workspaceTurnAllowed(state))
|
|
50
|
-
return true;
|
|
51
44
|
if (state.journal.destroyed() && CONVERSATION_KINDS.has(kind))
|
|
52
45
|
return false;
|
|
53
46
|
return Boolean(state.native?.ready()) && CONVERSATION_KINDS.has(kind);
|
|
54
47
|
}
|
|
55
|
-
export async function executeOperation(state, scopeKey, operation, payload) {
|
|
48
|
+
export async function executeOperation(state, scopeKey, operation, payload, recovery) {
|
|
56
49
|
const run = async () => {
|
|
57
50
|
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed()) {
|
|
58
51
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
|
|
59
52
|
}
|
|
60
|
-
return executeScopedOperation(state, scopeKey, operation, payload);
|
|
53
|
+
return executeScopedOperation(state, scopeKey, operation, payload, recovery);
|
|
61
54
|
};
|
|
62
|
-
const checkpointId = asRecord(payload).checkpointId;
|
|
63
|
-
const checkpointRun = () => operation.kind.startsWith('checkpoint.') && typeof checkpointId === 'string'
|
|
64
|
-
? state.journal.exclusive(`checkpoint:${checkpointId}`, run) : run();
|
|
65
55
|
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'preview.publish'].includes(operation.kind)) {
|
|
66
56
|
return run();
|
|
67
57
|
}
|
|
68
|
-
return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId) ?? 'single'}`,
|
|
69
|
-
}
|
|
70
|
-
function workspaceTurnAllowed(state) {
|
|
71
|
-
const gate = state.rebuildGate;
|
|
72
|
-
if (!gate)
|
|
73
|
-
return true;
|
|
74
|
-
return gate.directoryReady && gate.repositoryReady && gate.environmentReady;
|
|
58
|
+
return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId) ?? 'single'}`, run);
|
|
75
59
|
}
|
|
76
|
-
async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
60
|
+
async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
|
|
77
61
|
let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
|
|
78
62
|
if (operation.kind === 'runtime.ensure') {
|
|
79
63
|
current = await persist(state, scopeKey, transitionOperation(current, 'accepted'));
|
|
@@ -85,16 +69,6 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
85
69
|
if (state.journal.destroyed()) {
|
|
86
70
|
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'runtime_destroyed' }));
|
|
87
71
|
}
|
|
88
|
-
if (operation.kind === 'session.turn' && !workspaceTurnAllowed(state)) {
|
|
89
|
-
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'workspace_not_ready' }));
|
|
90
|
-
}
|
|
91
|
-
if (operation.kind === 'checkpoint.create') {
|
|
92
|
-
const workspaceId = current.workspaceId ?? String(asRecord(payload).workspaceId ?? 'workspace');
|
|
93
|
-
return oneSaveAtATime(state, workspaceId, () => executeCheckpoint(state, scopeKey, current, payload));
|
|
94
|
-
}
|
|
95
|
-
if (operation.kind === 'checkpoint.restore') {
|
|
96
|
-
return executeRestore(state, scopeKey, current, payload);
|
|
97
|
-
}
|
|
98
72
|
if (operation.kind === 'file.write') {
|
|
99
73
|
return executeFileWrite(state, scopeKey, current, payload);
|
|
100
74
|
}
|
|
@@ -110,6 +84,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
110
84
|
if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
|
|
111
85
|
return executeGitIndex(state, scopeKey, current, payload);
|
|
112
86
|
}
|
|
87
|
+
if (operation.kind === 'workspace.save') {
|
|
88
|
+
return executeWorkspaceSave(state, scopeKey, current, payload);
|
|
89
|
+
}
|
|
113
90
|
if (operation.kind === 'terminal.create') {
|
|
114
91
|
return executeTerminalCreate(state, scopeKey, current, payload);
|
|
115
92
|
}
|
|
@@ -127,7 +104,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
127
104
|
return executeCreate(state, scopeKey, current, payload);
|
|
128
105
|
}
|
|
129
106
|
if (operation.kind === 'session.turn') {
|
|
130
|
-
return executeTurn(state, scopeKey, current, payload);
|
|
107
|
+
return executeTurn(state, scopeKey, current, payload, recovery);
|
|
131
108
|
}
|
|
132
109
|
if (operation.kind === 'session.interrupt') {
|
|
133
110
|
return executeInterrupt(state, scopeKey, current);
|
|
@@ -212,9 +189,23 @@ async function executeCreate(state, scopeKey, operation, payload) {
|
|
|
212
189
|
]);
|
|
213
190
|
return persist(state, scopeKey, transitionOperation(accepted, 'completed', { nativeThreadId: started.threadId }));
|
|
214
191
|
}
|
|
215
|
-
async function executeTurn(state, scopeKey, operation, payload) {
|
|
192
|
+
async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
216
193
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
217
|
-
const
|
|
194
|
+
const body = asRecord(payload);
|
|
195
|
+
const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
|
|
196
|
+
if (!selected.success)
|
|
197
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
198
|
+
let found = state.journal.getSession(sessionId);
|
|
199
|
+
if (recovery && found?.recoverability !== 'native') {
|
|
200
|
+
const rebound = await rebindSession(state, operation, found, selected.data ?? recovery.modelConfigId);
|
|
201
|
+
if (typeof rebound === 'string') {
|
|
202
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: rebound }));
|
|
203
|
+
}
|
|
204
|
+
found = rebound;
|
|
205
|
+
}
|
|
206
|
+
const binding = found;
|
|
207
|
+
// Also covers a new thread whose first turn failed before starting: the next turn still carries it.
|
|
208
|
+
const context = recovery && binding?.recoveryContextPending ? recovery.context : '';
|
|
218
209
|
if (!binding) {
|
|
219
210
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
220
211
|
}
|
|
@@ -224,10 +215,6 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
224
215
|
if (sessionTurnBusy(binding)) {
|
|
225
216
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
226
217
|
}
|
|
227
|
-
const body = asRecord(payload);
|
|
228
|
-
const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
|
|
229
|
-
if (!selected.success)
|
|
230
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
231
218
|
let attachments;
|
|
232
219
|
try {
|
|
233
220
|
attachments = await resolveWorkspaceAttachments(await workspaceRoot(state, operation.workspaceId), body.attachmentIds);
|
|
@@ -240,7 +227,7 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
240
227
|
const result = await native(state).startTurn({
|
|
241
228
|
sessionId,
|
|
242
229
|
threadId: binding.threadId,
|
|
243
|
-
text: String(body.text ?? '') + attachmentText,
|
|
230
|
+
text: context + String(body.text ?? '') + attachmentText,
|
|
244
231
|
images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
|
|
245
232
|
clientRequestId: operation.clientRequestId,
|
|
246
233
|
modelConfigId: selected.data ?? binding.modelConfigId,
|
|
@@ -310,6 +297,60 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
310
297
|
}
|
|
311
298
|
return state.journal.getOperation(scopeKey) ?? latest;
|
|
312
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Gives a session whose native thread was lost a thread in this process. It keeps its own thread
|
|
302
|
+
* when Codex still has that record, but only once the previous execution group is confirmed
|
|
303
|
+
* stopped, so two processes never drive one thread. Otherwise it gets a new thread, marked to
|
|
304
|
+
* carry the earlier conversation until a turn starts on it.
|
|
305
|
+
* Returns an error code when the thread cannot be prepared.
|
|
306
|
+
*/
|
|
307
|
+
async function rebindSession(state, operation, binding, modelConfigId) {
|
|
308
|
+
const sessionId = required(operation.sessionId, 'sessionId');
|
|
309
|
+
const adapter = native(state);
|
|
310
|
+
const cwd = await workspaceRoot(state, operation.workspaceId);
|
|
311
|
+
const group = state.journal.executionGroup();
|
|
312
|
+
try {
|
|
313
|
+
if (binding && adapter.resumeThread && (!group.replaced || group.stopConfirmed)) {
|
|
314
|
+
const resumed = await adapter.resumeThread({
|
|
315
|
+
sessionId,
|
|
316
|
+
threadId: binding.threadId,
|
|
317
|
+
cwd,
|
|
318
|
+
modelConfigId: modelConfigId ?? binding.modelConfigId,
|
|
319
|
+
});
|
|
320
|
+
if (resumed) {
|
|
321
|
+
const next = {
|
|
322
|
+
...binding,
|
|
323
|
+
cwd,
|
|
324
|
+
recoverability: 'native',
|
|
325
|
+
modelConfigId: resumed.modelConfigId ?? binding.modelConfigId,
|
|
326
|
+
turnStatus: undefined,
|
|
327
|
+
turnBlock: undefined,
|
|
328
|
+
activeTurnId: undefined,
|
|
329
|
+
recoveryContextPending: undefined,
|
|
330
|
+
};
|
|
331
|
+
await state.journal.putSession(next);
|
|
332
|
+
return next;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const started = await adapter.startThread({ sessionId, cwd, modelConfigId: modelConfigId ?? binding?.modelConfigId });
|
|
336
|
+
const next = {
|
|
337
|
+
sessionId,
|
|
338
|
+
workspaceId: operation.workspaceId,
|
|
339
|
+
threadId: started.threadId,
|
|
340
|
+
cwd,
|
|
341
|
+
recoverability: 'native',
|
|
342
|
+
modelConfigId: started.modelConfigId,
|
|
343
|
+
recoveryContextPending: true,
|
|
344
|
+
};
|
|
345
|
+
await state.journal.putSession(next);
|
|
346
|
+
return next;
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error instanceof NativePreparationError)
|
|
350
|
+
return error.message;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
313
354
|
async function executeInterrupt(state, scopeKey, operation) {
|
|
314
355
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
315
356
|
const binding = state.journal.getSession(sessionId);
|
|
@@ -351,257 +392,6 @@ async function executeApproval(state, scopeKey, operation, payload) {
|
|
|
351
392
|
error: terminal === 'failed' ? 'native_failed' : undefined,
|
|
352
393
|
}));
|
|
353
394
|
}
|
|
354
|
-
async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
355
|
-
const body = asRecord(payload);
|
|
356
|
-
// Only a save without a requested identity may answer with an earlier, unchanged checkpoint.
|
|
357
|
-
const requested = typeof body.checkpointId === 'string' && body.checkpointId.trim() !== '';
|
|
358
|
-
const checkpointId = requested ? String(body.checkpointId).trim() : `ckpt_${randomUUID()}`;
|
|
359
|
-
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(checkpointId) || state.journal.getCheckpoint(checkpointId)) {
|
|
360
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'checkpoint_id_conflict' }));
|
|
361
|
-
}
|
|
362
|
-
const workspaceId = operation.workspaceId ?? String(body.workspaceId ?? 'workspace');
|
|
363
|
-
const active = await activeManagedWrites(state, workspaceId);
|
|
364
|
-
const createdAt = new Date().toISOString();
|
|
365
|
-
if (active.length > 0) {
|
|
366
|
-
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
367
|
-
sessionId: item.sessionId,
|
|
368
|
-
nativeThreadId: item.threadId,
|
|
369
|
-
modelConfigId: item.modelConfigId,
|
|
370
|
-
recoverability: 'history_only',
|
|
371
|
-
}));
|
|
372
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
373
|
-
checkpointId,
|
|
374
|
-
kind: 'best_effort',
|
|
375
|
-
workspaceId,
|
|
376
|
-
generation: state.config.generation,
|
|
377
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
378
|
-
sessions,
|
|
379
|
-
createdAt,
|
|
380
|
-
nativeRecoverable: false,
|
|
381
|
-
consistent: false,
|
|
382
|
-
});
|
|
383
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
384
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, active.map((item) => item.source));
|
|
385
|
-
}
|
|
386
|
-
const root = await workspaceRoot(state, operation.workspaceId);
|
|
387
|
-
const revisions = state.journal.fileRevisions(revisionScope(state, operation.workspaceId));
|
|
388
|
-
let files;
|
|
389
|
-
let published;
|
|
390
|
-
// A standalone workspace has no remote, so all of its `.git` is kept. A project workspace's packs
|
|
391
|
-
// come back with a new clone; what the remote lacks goes into the unpushed bundle.
|
|
392
|
-
const standalone = state.journal.getWorkspace(workspaceId)?.targetKind === 'standalone';
|
|
393
|
-
if (!standalone) {
|
|
394
|
-
// Without a fresh bundle the loose objects still hold commits made since the last repack.
|
|
395
|
-
await bundleUnpushedCommits(root).catch(() => undefined);
|
|
396
|
-
}
|
|
397
|
-
const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions, undefined, {
|
|
398
|
-
git: standalone ? 'full' : 'local',
|
|
399
|
-
});
|
|
400
|
-
// Transcripts only matter where another instance can read them back.
|
|
401
|
-
const transcripts = state.config.checkpointObjects ? await captureTranscripts(state, operation.workspaceId) : [];
|
|
402
|
-
const digest = liveTreeDigest(tree, transcripts);
|
|
403
|
-
if (!requested && tree.status === 'complete') {
|
|
404
|
-
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
405
|
-
if (stillActive.length === 0) {
|
|
406
|
-
const captured = await captureNativeSessions(state, operation.workspaceId);
|
|
407
|
-
const previous = unchangedCheckpoint(state, workspaceId, digest, captured.sessions);
|
|
408
|
-
if (previous)
|
|
409
|
-
return finishUnchanged(state, scopeKey, operation, previous);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
if (state.config.checkpointObjects) {
|
|
413
|
-
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
414
|
-
if (stillActive.length > 0) {
|
|
415
|
-
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
416
|
-
}
|
|
417
|
-
try {
|
|
418
|
-
published = await publishFileSnapshot({
|
|
419
|
-
baseUrl: platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.checkpointStore),
|
|
420
|
-
snapshotId: checkpointId,
|
|
421
|
-
workspaceId,
|
|
422
|
-
generation: state.config.generation,
|
|
423
|
-
tree,
|
|
424
|
-
native: transcripts,
|
|
425
|
-
uploaded: workspaceSaves(state, workspaceId).uploaded,
|
|
426
|
-
uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
|
|
427
|
-
headers: objectStoreHeaders(state),
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
catch {
|
|
431
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'snapshot_upload_failed' }));
|
|
432
|
-
}
|
|
433
|
-
if (tree.status === 'incomplete') {
|
|
434
|
-
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
435
|
-
sessionId: item.sessionId,
|
|
436
|
-
nativeThreadId: item.threadId,
|
|
437
|
-
modelConfigId: item.modelConfigId,
|
|
438
|
-
recoverability: 'history_only',
|
|
439
|
-
}));
|
|
440
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
441
|
-
checkpointId,
|
|
442
|
-
kind: 'file',
|
|
443
|
-
workspaceId,
|
|
444
|
-
generation: state.config.generation,
|
|
445
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
446
|
-
sessions,
|
|
447
|
-
createdAt,
|
|
448
|
-
nativeRecoverable: false,
|
|
449
|
-
consistent: false,
|
|
450
|
-
manifestKey: published.manifestKey,
|
|
451
|
-
checksum: published.checksum,
|
|
452
|
-
snapshotStatus: 'incomplete',
|
|
453
|
-
});
|
|
454
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
455
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
|
|
456
|
-
}
|
|
457
|
-
await materializeSnapshot(fileCheckpointDir(state, checkpointId), tree);
|
|
458
|
-
files = tree.files;
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
files = await snapshotWorkspaceFiles(root, fileCheckpointDir(state, checkpointId), revisions);
|
|
462
|
-
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
463
|
-
if (stillActive.length > 0) {
|
|
464
|
-
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
const captured = await captureNativeSessions(state, operation.workspaceId);
|
|
468
|
-
const nativeReady = captured.nativeReady;
|
|
469
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
470
|
-
checkpointId,
|
|
471
|
-
kind: nativeReady ? 'coordinated' : 'file',
|
|
472
|
-
workspaceId,
|
|
473
|
-
generation: state.config.generation,
|
|
474
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
475
|
-
sessions: captured.sessions,
|
|
476
|
-
files,
|
|
477
|
-
createdAt,
|
|
478
|
-
nativeRecoverable: nativeReady,
|
|
479
|
-
consistent: nativeReady,
|
|
480
|
-
...(published
|
|
481
|
-
? { manifestKey: published.manifestKey, checksum: published.checksum, snapshotStatus: published.status }
|
|
482
|
-
: {}),
|
|
483
|
-
});
|
|
484
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
485
|
-
await state.journal.setFileCheckpointId(checkpointId);
|
|
486
|
-
if (nativeReady)
|
|
487
|
-
await state.journal.setConsistentCheckpointId(checkpointId);
|
|
488
|
-
if (tree.status === 'complete') {
|
|
489
|
-
await rememberFileCheckpoint(state, workspaceId, { checkpointId, digest, sessions: captured.sessions });
|
|
490
|
-
}
|
|
491
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Nothing changed since the last file checkpoint: the save reports that checkpoint again and
|
|
495
|
-
* writes no new one, so periodic saves of an idle workspace cost one read of the tree.
|
|
496
|
-
*/
|
|
497
|
-
async function finishUnchanged(state, scopeKey, operation, checkpoint) {
|
|
498
|
-
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId: checkpoint.checkpointId }));
|
|
499
|
-
return persist(state, scopeKey, transitionOperation(accepted, 'completed', {
|
|
500
|
-
checkpointId: checkpoint.checkpointId,
|
|
501
|
-
checkpointKind: checkpoint.kind,
|
|
502
|
-
checkpointConsistent: checkpoint.consistent === true,
|
|
503
|
-
...(checkpoint.manifestKey ? { snapshotManifestKey: checkpoint.manifestKey } : {}),
|
|
504
|
-
...(checkpoint.checksum ? { snapshotChecksum: checkpoint.checksum } : {}),
|
|
505
|
-
...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
|
|
506
|
-
}));
|
|
507
|
-
}
|
|
508
|
-
async function executeRestore(state, scopeKey, operation, payload) {
|
|
509
|
-
const body = asRecord(payload);
|
|
510
|
-
const checkpointId = String(body.checkpointId ?? operation.checkpointId ?? '');
|
|
511
|
-
const checkpoint = state.journal.getCheckpoint(checkpointId);
|
|
512
|
-
if (!checkpoint || checkpoint.workspaceId !== (operation.workspaceId ?? 'workspace')) {
|
|
513
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_checkpoint' }));
|
|
514
|
-
}
|
|
515
|
-
if (checkpoint.snapshotStatus === 'incomplete') {
|
|
516
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'snapshot_incomplete' }));
|
|
517
|
-
}
|
|
518
|
-
const restarted = executionGroupRestarted(state, checkpoint.runtimeEpoch);
|
|
519
|
-
const stopConfirmed = state.journal.executionGroup().stopConfirmed;
|
|
520
|
-
const cwd = await workspaceRoot(state, operation.workspaceId);
|
|
521
|
-
const next = workspaceSessions(state, operation.workspaceId).map((item) => {
|
|
522
|
-
const snap = checkpoint.sessions.find((session) => session.sessionId === item.sessionId);
|
|
523
|
-
if (!snap)
|
|
524
|
-
return { ...item, recoverability: 'history_only' };
|
|
525
|
-
const threadId = item.threadId;
|
|
526
|
-
const threadReplaced = Boolean(snap.nativeThreadId && snap.nativeThreadId !== item.threadId);
|
|
527
|
-
if (!checkpoint.nativeRecoverable || threadReplaced) {
|
|
528
|
-
return {
|
|
529
|
-
...item,
|
|
530
|
-
modelConfigId: snap.modelConfigId ?? item.modelConfigId,
|
|
531
|
-
threadId,
|
|
532
|
-
recoverability: restarted ? 'history_only' : item.recoverability,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
if (restarted)
|
|
536
|
-
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId, recoverability: 'history_only' };
|
|
537
|
-
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
|
|
538
|
-
});
|
|
539
|
-
for (const snap of checkpoint.sessions) {
|
|
540
|
-
if (!next.some((item) => item.sessionId === snap.sessionId)) {
|
|
541
|
-
next.push({
|
|
542
|
-
sessionId: snap.sessionId,
|
|
543
|
-
workspaceId: operation.workspaceId,
|
|
544
|
-
threadId: snap.nativeThreadId ?? `thread_${snap.sessionId}`,
|
|
545
|
-
modelConfigId: snap.modelConfigId,
|
|
546
|
-
cwd,
|
|
547
|
-
recoverability: checkpoint.nativeRecoverable && !restarted ? 'native' : 'history_only',
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
let nativeContinuable = checkpoint.nativeRecoverable
|
|
552
|
-
? next.some((item) => item.recoverability === 'native')
|
|
553
|
-
: false;
|
|
554
|
-
if (checkpoint.nativeRecoverable && restarted && stopConfirmed) {
|
|
555
|
-
for (const snap of checkpoint.sessions) {
|
|
556
|
-
const threadId = snap.nativeThreadId;
|
|
557
|
-
const found = next.find((item) => item.sessionId === snap.sessionId);
|
|
558
|
-
if (!threadId || !found || !state.native?.canContinueThread)
|
|
559
|
-
continue;
|
|
560
|
-
const continued = await state.native.canContinueThread({
|
|
561
|
-
threadId,
|
|
562
|
-
sessionId: snap.sessionId,
|
|
563
|
-
cwd: found.cwd,
|
|
564
|
-
modelConfigId: found.modelConfigId,
|
|
565
|
-
});
|
|
566
|
-
if (!continued || found.threadId !== threadId)
|
|
567
|
-
continue;
|
|
568
|
-
found.recoverability = 'native';
|
|
569
|
-
nativeContinuable = true;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (!checkpoint.nativeRecoverable || (restarted && !stopConfirmed))
|
|
573
|
-
nativeContinuable = false;
|
|
574
|
-
const others = state.config.workspaceMode === 'multi'
|
|
575
|
-
? state.journal.listSessions().filter((item) => item.workspaceId !== operation.workspaceId) : [];
|
|
576
|
-
if (others.some((item) => next.some((restored) => restored.sessionId === item.sessionId))) {
|
|
577
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'session_workspace_mismatch' }));
|
|
578
|
-
}
|
|
579
|
-
await state.journal.replaceSessions([...others, ...next]);
|
|
580
|
-
let filesRestored = false;
|
|
581
|
-
if (checkpoint.files) {
|
|
582
|
-
const restoredFiles = await restoreWorkspaceFiles(cwd, fileCheckpointDir(state, checkpointId), checkpoint.files);
|
|
583
|
-
await state.journal.replaceFileRevisions(restoredFiles, revisionScope(state, operation.workspaceId));
|
|
584
|
-
filesRestored = true;
|
|
585
|
-
}
|
|
586
|
-
const historyVisible = state.journal.getCheckpoint(checkpointId) !== undefined &&
|
|
587
|
-
state.journal.listEvents().some((event) => event.type === 'checkpoint.created' || event.type === 'session.created');
|
|
588
|
-
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId }));
|
|
589
|
-
await recordNativeEvents(state, accepted, [
|
|
590
|
-
{
|
|
591
|
-
type: 'checkpoint.restored',
|
|
592
|
-
payload: {
|
|
593
|
-
checkpointId,
|
|
594
|
-
historyVisible,
|
|
595
|
-
filesRestored,
|
|
596
|
-
nativeContinuable,
|
|
597
|
-
nativeSessionCount: next.filter((item) => item.recoverability === 'native').length,
|
|
598
|
-
historyOnlyCount: next.filter((item) => item.recoverability === 'history_only').length,
|
|
599
|
-
},
|
|
600
|
-
occurredAt: new Date().toISOString(),
|
|
601
|
-
},
|
|
602
|
-
]);
|
|
603
|
-
return persist(state, scopeKey, transitionOperation(accepted, 'completed', { checkpointId }));
|
|
604
|
-
}
|
|
605
395
|
async function executeDestroy(state, scopeKey, operation) {
|
|
606
396
|
await state.journal.markDestroyed();
|
|
607
397
|
await closeCliCredentials(state);
|
|
@@ -730,6 +520,37 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
730
520
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
731
521
|
}
|
|
732
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Saves the workspace to Git: commit everything that changed, then push a Remix branch. A
|
|
525
|
+
* standalone workspace has no remote, and the Git gateway never lets a workspace write its
|
|
526
|
+
* project's main branch, so those two only commit.
|
|
527
|
+
*/
|
|
528
|
+
async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
529
|
+
const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
|
|
530
|
+
if (!parsed.success) {
|
|
531
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_save' }));
|
|
532
|
+
}
|
|
533
|
+
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
534
|
+
try {
|
|
535
|
+
const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId), {
|
|
536
|
+
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
537
|
+
push: binding?.targetKind === 'remix',
|
|
538
|
+
boundBranch: boundBranch(state, operation),
|
|
539
|
+
});
|
|
540
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
541
|
+
await recordNativeEvents(state, accepted, [
|
|
542
|
+
{
|
|
543
|
+
type: 'workspace.saved',
|
|
544
|
+
payload: { ...(saved.commit ? { sha: saved.commit } : {}), pushed: saved.pushed, withheld: saved.withheld },
|
|
545
|
+
occurredAt: new Date().toISOString(),
|
|
546
|
+
},
|
|
547
|
+
]);
|
|
548
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
733
554
|
async function executeGitPush(state, scopeKey, operation, payload) {
|
|
734
555
|
const parsed = gitPushPayloadSchema.safeParse(payload ?? {});
|
|
735
556
|
if (!parsed.success) {
|
|
@@ -898,95 +719,6 @@ function parameterPatchError(error) {
|
|
|
898
719
|
return 'invalid_workspace_path';
|
|
899
720
|
return 'parameter_patch_failed';
|
|
900
721
|
}
|
|
901
|
-
async function finishCheckpoint(state, scopeKey, operation, checkpoint, activeWriters) {
|
|
902
|
-
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId: checkpoint.checkpointId }));
|
|
903
|
-
await recordNativeEvents(state, accepted, [
|
|
904
|
-
{
|
|
905
|
-
type: 'checkpoint.created',
|
|
906
|
-
payload: {
|
|
907
|
-
checkpointId: checkpoint.checkpointId,
|
|
908
|
-
workspaceId: checkpoint.workspaceId,
|
|
909
|
-
kind: checkpoint.kind,
|
|
910
|
-
consistent: checkpoint.consistent === true,
|
|
911
|
-
fileCheckpointId: state.journal.fileCheckpointId(),
|
|
912
|
-
consistentCheckpointId: state.journal.consistentCheckpointId(),
|
|
913
|
-
nativeRecoverable: checkpoint.nativeRecoverable,
|
|
914
|
-
sessionCount: checkpoint.sessions.length,
|
|
915
|
-
fileCount: checkpoint.files?.length ?? 0,
|
|
916
|
-
activeWriters,
|
|
917
|
-
},
|
|
918
|
-
occurredAt: checkpoint.createdAt,
|
|
919
|
-
},
|
|
920
|
-
]);
|
|
921
|
-
return persist(state, scopeKey, transitionOperation(accepted, 'completed', {
|
|
922
|
-
checkpointId: checkpoint.checkpointId,
|
|
923
|
-
checkpointKind: checkpoint.kind,
|
|
924
|
-
checkpointConsistent: checkpoint.consistent === true,
|
|
925
|
-
...(checkpoint.manifestKey ? { snapshotManifestKey: checkpoint.manifestKey } : {}),
|
|
926
|
-
...(checkpoint.checksum ? { snapshotChecksum: checkpoint.checksum } : {}),
|
|
927
|
-
...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
|
|
928
|
-
}));
|
|
929
|
-
}
|
|
930
|
-
/** Transcripts of the workspace's resumable threads. A thread whose file cannot be read is left out. */
|
|
931
|
-
async function captureTranscripts(state, workspaceId) {
|
|
932
|
-
const native = state.native;
|
|
933
|
-
if (!native?.exportThread)
|
|
934
|
-
return [];
|
|
935
|
-
const captured = [];
|
|
936
|
-
for (const session of workspaceSessions(state, workspaceId)) {
|
|
937
|
-
if (session.recoverability !== 'native')
|
|
938
|
-
continue;
|
|
939
|
-
const capturedAt = new Date().toISOString();
|
|
940
|
-
const file = await native.exportThread({ threadId: session.threadId }).catch(() => undefined);
|
|
941
|
-
if (file)
|
|
942
|
-
captured.push({ sessionId: session.sessionId, threadId: session.threadId, capturedAt, ...file });
|
|
943
|
-
}
|
|
944
|
-
return captured;
|
|
945
|
-
}
|
|
946
|
-
async function captureNativeSessions(state, workspaceId) {
|
|
947
|
-
const live = workspaceSessions(state, workspaceId);
|
|
948
|
-
const sessions = [];
|
|
949
|
-
let nativeReady = live.length > 0;
|
|
950
|
-
for (const item of live) {
|
|
951
|
-
const captured = await state.native?.captureNativeCheckpoint?.({
|
|
952
|
-
threadId: item.threadId,
|
|
953
|
-
sessionId: item.sessionId,
|
|
954
|
-
});
|
|
955
|
-
const durable = captured?.threadId === item.threadId && typeof captured.nativeDurableTurnId === 'string'
|
|
956
|
-
? captured.nativeDurableTurnId
|
|
957
|
-
: undefined;
|
|
958
|
-
if (!durable)
|
|
959
|
-
nativeReady = false;
|
|
960
|
-
sessions.push(durable
|
|
961
|
-
? {
|
|
962
|
-
sessionId: item.sessionId,
|
|
963
|
-
nativeThreadId: item.threadId,
|
|
964
|
-
modelConfigId: item.modelConfigId,
|
|
965
|
-
nativeDurableTurnId: durable,
|
|
966
|
-
recoverability: 'native',
|
|
967
|
-
}
|
|
968
|
-
: {
|
|
969
|
-
sessionId: item.sessionId,
|
|
970
|
-
nativeThreadId: item.threadId,
|
|
971
|
-
modelConfigId: item.modelConfigId,
|
|
972
|
-
recoverability: 'history_only',
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
return { sessions, nativeReady };
|
|
976
|
-
}
|
|
977
|
-
async function activeManagedWrites(state, workspaceId) {
|
|
978
|
-
const active = [];
|
|
979
|
-
for (const lease of state.journal.listManagedWrites(workspaceId)) {
|
|
980
|
-
if (lease.stoppedAt)
|
|
981
|
-
continue;
|
|
982
|
-
if (lease.pid !== undefined && probeProcess(lease.pid) === false) {
|
|
983
|
-
await state.journal.putManagedWrite({ ...lease, stoppedAt: new Date().toISOString() });
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
active.push(lease);
|
|
987
|
-
}
|
|
988
|
-
return active;
|
|
989
|
-
}
|
|
990
722
|
async function releaseManagedWrite(state, writeId, workspaceId) {
|
|
991
723
|
const lease = state.journal.getManagedWrite(writeId);
|
|
992
724
|
if (!lease || lease.stoppedAt || lease.workspaceId !== workspaceId)
|
|
@@ -999,36 +731,6 @@ async function releaseManagedWrite(state, writeId, workspaceId) {
|
|
|
999
731
|
await state.journal.putManagedWrite({ ...lease, stoppedAt: new Date().toISOString() });
|
|
1000
732
|
return true;
|
|
1001
733
|
}
|
|
1002
|
-
async function finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, activeWriters) {
|
|
1003
|
-
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
1004
|
-
sessionId: item.sessionId,
|
|
1005
|
-
nativeThreadId: item.threadId,
|
|
1006
|
-
modelConfigId: item.modelConfigId,
|
|
1007
|
-
recoverability: 'history_only',
|
|
1008
|
-
}));
|
|
1009
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
1010
|
-
checkpointId,
|
|
1011
|
-
kind: 'best_effort',
|
|
1012
|
-
workspaceId,
|
|
1013
|
-
generation: state.config.generation,
|
|
1014
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
1015
|
-
sessions,
|
|
1016
|
-
createdAt,
|
|
1017
|
-
nativeRecoverable: false,
|
|
1018
|
-
consistent: false,
|
|
1019
|
-
});
|
|
1020
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
1021
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, activeWriters);
|
|
1022
|
-
}
|
|
1023
|
-
function previousFilePaths(state) {
|
|
1024
|
-
const checkpointId = state.journal.fileCheckpointId();
|
|
1025
|
-
if (!checkpointId)
|
|
1026
|
-
return [];
|
|
1027
|
-
return state.journal.getCheckpoint(checkpointId)?.files?.map((file) => file.path) ?? [];
|
|
1028
|
-
}
|
|
1029
|
-
function fileCheckpointDir(state, checkpointId) {
|
|
1030
|
-
return join(state.config.stateDir, 'file-checkpoints', checkpointId);
|
|
1031
|
-
}
|
|
1032
734
|
export function attachNativeCollector(state) {
|
|
1033
735
|
state.native?.onCollected?.((event) => {
|
|
1034
736
|
void ingestCollected(state, event);
|
|
@@ -34,9 +34,6 @@ async function advanceEpoch(state, input) {
|
|
|
34
34
|
state.runtimeEpoch = runtimeEpoch;
|
|
35
35
|
await demoteSessions(state);
|
|
36
36
|
}
|
|
37
|
-
export function executionGroupRestarted(state, checkpointEpoch) {
|
|
38
|
-
return state.runtimeEpoch !== checkpointEpoch;
|
|
39
|
-
}
|
|
40
37
|
export async function replaceExecutionGroup(state, options) {
|
|
41
38
|
return enqueue(state, async () => {
|
|
42
39
|
const native = requireRestartable(state);
|