@playcraft/cli 0.1.18 → 0.1.20
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 -46
- 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/previews/single-file.js +11 -3
- package/dist/workspace-runtime/server/dispatch.js +79 -362
- 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/package.json +3 -3
- package/dist/workspace-runtime/workspaces/file-snapshot.js +0 -213
|
@@ -369,44 +369,6 @@ export function createCodexAppServerAdapter(options) {
|
|
|
369
369
|
throw new Error('codex_not_started');
|
|
370
370
|
return { pid: next.pid, startedAt: next.startedAt };
|
|
371
371
|
},
|
|
372
|
-
async canContinueThread(input) {
|
|
373
|
-
return withPreparation(async () => {
|
|
374
|
-
await ensureStarted();
|
|
375
|
-
if (!rpc)
|
|
376
|
-
return false;
|
|
377
|
-
try {
|
|
378
|
-
const existing = threads.get(input.threadId);
|
|
379
|
-
if (existing && existing.sessionId !== input.sessionId)
|
|
380
|
-
return false;
|
|
381
|
-
if (existing?.turnActive)
|
|
382
|
-
return false;
|
|
383
|
-
const prepared = await lockedThreadConfig(input.cwd ?? '', input.modelConfigId, input.sessionId);
|
|
384
|
-
if (existing?.needsRecreate)
|
|
385
|
-
return false;
|
|
386
|
-
if (existing?.loaded) {
|
|
387
|
-
if (existing.skillsHash !== prepared.skillsHash || existing.discoveryHash !== prepared.discoveryHash) {
|
|
388
|
-
if (!existing.hasNativeHistory)
|
|
389
|
-
return false;
|
|
390
|
-
await restartForSkills(input.threadId, prepared.modelConfigId);
|
|
391
|
-
}
|
|
392
|
-
prepared.activate();
|
|
393
|
-
return threads.get(input.threadId)?.sessionId === input.sessionId;
|
|
394
|
-
}
|
|
395
|
-
const result = await rpc.request('thread/resume', { threadId: input.threadId, cwd: input.cwd,
|
|
396
|
-
...prepared.params }, 30_000);
|
|
397
|
-
const resumedId = result.thread?.id ?? result.threadId;
|
|
398
|
-
if (resumedId !== input.threadId)
|
|
399
|
-
return false;
|
|
400
|
-
prepared.activate();
|
|
401
|
-
threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
|
|
402
|
-
discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: true, loaded: true });
|
|
403
|
-
return true;
|
|
404
|
-
}
|
|
405
|
-
catch {
|
|
406
|
-
return false;
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
},
|
|
410
372
|
async readThread(input) {
|
|
411
373
|
try {
|
|
412
374
|
await ensureStarted();
|
|
@@ -423,14 +385,6 @@ export function createCodexAppServerAdapter(options) {
|
|
|
423
385
|
return undefined;
|
|
424
386
|
}
|
|
425
387
|
},
|
|
426
|
-
async captureNativeCheckpoint(input) {
|
|
427
|
-
const thread = threads.get(input.threadId);
|
|
428
|
-
if (!thread || thread.sessionId !== input.sessionId)
|
|
429
|
-
return undefined;
|
|
430
|
-
if (!thread.lastTurnId)
|
|
431
|
-
return { threadId: input.threadId };
|
|
432
|
-
return { threadId: input.threadId, nativeDurableTurnId: thread.lastTurnId };
|
|
433
|
-
},
|
|
434
388
|
async start() {
|
|
435
389
|
await ensureStarted();
|
|
436
390
|
},
|
|
@@ -454,6 +408,30 @@ export function createCodexAppServerAdapter(options) {
|
|
|
454
408
|
return { threadId, modelConfigId: prepared.modelConfigId };
|
|
455
409
|
});
|
|
456
410
|
},
|
|
411
|
+
async resumeThread(input) {
|
|
412
|
+
return withPreparation(async () => {
|
|
413
|
+
await ensureStarted();
|
|
414
|
+
if (!rpc)
|
|
415
|
+
throw new Error('codex_not_started');
|
|
416
|
+
const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
|
|
417
|
+
let resumedId;
|
|
418
|
+
try {
|
|
419
|
+
const resumed = await rpc.request('thread/resume', {
|
|
420
|
+
threadId: input.threadId, cwd: input.cwd, ...prepared.params,
|
|
421
|
+
}, 30_000);
|
|
422
|
+
resumedId = resumed.thread?.id;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
if (resumedId !== input.threadId)
|
|
428
|
+
return undefined;
|
|
429
|
+
prepared.activate();
|
|
430
|
+
threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
|
|
431
|
+
discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: true, loaded: true });
|
|
432
|
+
return { modelConfigId: prepared.modelConfigId };
|
|
433
|
+
});
|
|
434
|
+
},
|
|
457
435
|
async startTurn(input) {
|
|
458
436
|
return withPreparation(async () => {
|
|
459
437
|
await ensureStarted();
|
|
@@ -26,6 +26,9 @@ export function createLoopbackNativeAdapter() {
|
|
|
26
26
|
threads.set(threadId, { sessionId: input.sessionId, items: [] });
|
|
27
27
|
return { threadId };
|
|
28
28
|
},
|
|
29
|
+
async resumeThread(input) {
|
|
30
|
+
return threads.get(input.threadId)?.sessionId === input.sessionId ? {} : undefined;
|
|
31
|
+
},
|
|
29
32
|
async startTurn(input) {
|
|
30
33
|
const thread = threads.get(input.threadId);
|
|
31
34
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { workspaceRegistrationSchema,
|
|
4
|
+
import { workspaceRegistrationSchema, operationRecordSchema, pendingSourceEventSchema, replayPinResultSchema, retainedRangeHasGap, sourceAckSchema, sourceEventKey, sourceEventReceiptSchema, terminalRecordSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { SqliteWorkerClient } from './sqlite-client.js';
|
|
6
6
|
export const RUNTIME_STATE_FILE = 'runtime-state.sqlite';
|
|
7
7
|
export const LEGACY_JOURNAL_FILE = 'journal.json';
|
|
@@ -116,21 +116,6 @@ export class RuntimeJournal {
|
|
|
116
116
|
listTerminals(workspaceId) {
|
|
117
117
|
return Object.values(this.snapshot.terminals).filter((item) => !workspaceId || item.workspaceId === workspaceId);
|
|
118
118
|
}
|
|
119
|
-
getCheckpoint(checkpointId) {
|
|
120
|
-
return this.snapshot.checkpoints[checkpointId];
|
|
121
|
-
}
|
|
122
|
-
fileCheckpointId() {
|
|
123
|
-
return this.snapshot.fileCheckpointId;
|
|
124
|
-
}
|
|
125
|
-
consistentCheckpointId() {
|
|
126
|
-
return this.snapshot.consistentCheckpointId;
|
|
127
|
-
}
|
|
128
|
-
async setFileCheckpointId(checkpointId) {
|
|
129
|
-
await this.run('setCheckpointId', { field: 'file', id: checkpointId });
|
|
130
|
-
}
|
|
131
|
-
async setConsistentCheckpointId(checkpointId) {
|
|
132
|
-
await this.run('setCheckpointId', { field: 'consistent', id: checkpointId });
|
|
133
|
-
}
|
|
134
119
|
listManagedWrites(workspaceId) {
|
|
135
120
|
return Object.values(this.snapshot.managedWrites).filter((item) => !workspaceId || item.workspaceId === workspaceId);
|
|
136
121
|
}
|
|
@@ -266,11 +251,6 @@ export class RuntimeJournal {
|
|
|
266
251
|
await this.run('upsertRecord', { bucket: 'terminals', key: parsed.terminalId, body: parsed });
|
|
267
252
|
return parsed;
|
|
268
253
|
}
|
|
269
|
-
async putCheckpoint(checkpoint) {
|
|
270
|
-
const parsed = checkpointRecordSchema.parse(checkpoint);
|
|
271
|
-
await this.run('upsertRecord', { bucket: 'checkpoints', key: parsed.checkpointId, body: parsed });
|
|
272
|
-
return parsed;
|
|
273
|
-
}
|
|
274
254
|
async putFileRevision(path, record, workspaceId) {
|
|
275
255
|
if (workspaceId) {
|
|
276
256
|
await this.run('upsertRecord', { bucket: `workspace_files:${workspaceId}`, key: path, body: record });
|
|
@@ -278,10 +258,6 @@ export class RuntimeJournal {
|
|
|
278
258
|
}
|
|
279
259
|
await this.run('upsertRecord', { bucket: 'file_revisions', key: path, body: record });
|
|
280
260
|
}
|
|
281
|
-
async replaceFileRevisions(records, workspaceId) {
|
|
282
|
-
const entries = Object.entries(records).map(([key, body]) => ({ key, body }));
|
|
283
|
-
await this.run('replaceBucket', { bucket: workspaceId ? `workspace_files:${workspaceId}` : 'file_revisions', entries });
|
|
284
|
-
}
|
|
285
261
|
async markDestroyed() {
|
|
286
262
|
await this.run('setDestroyed', {});
|
|
287
263
|
}
|
|
@@ -367,7 +343,6 @@ function emptySnapshot() {
|
|
|
367
343
|
events: {},
|
|
368
344
|
sessions: {},
|
|
369
345
|
terminals: {},
|
|
370
|
-
checkpoints: {},
|
|
371
346
|
fileRevisions: {},
|
|
372
347
|
approvals: {},
|
|
373
348
|
managedWrites: {},
|
|
@@ -12,7 +12,7 @@ import { chmodSync, mkdirSync } from 'node:fs';
|
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
import { parentPort } from 'node:worker_threads';
|
|
14
14
|
import Database from 'better-sqlite3-pinned';
|
|
15
|
-
import { hashOperationPayload, operationRecordSchema, pendingSourceEventSchema,
|
|
15
|
+
import { hashOperationPayload, operationRecordSchema, pendingSourceEventSchema, terminalRecordSchema, workspaceRegistrationSchema, transitionOperation, canTransitionTurn, } from '@playcraft/common/cloud-workspace/runtime';
|
|
16
16
|
const SCHEMA_VERSION = 1;
|
|
17
17
|
const MIN_SQLITE = [3, 51, 3];
|
|
18
18
|
const BINDING = 'better-sqlite3@13.0.3';
|
|
@@ -68,9 +68,6 @@ function dispatch(type, payload) {
|
|
|
68
68
|
case 'setDestroyed':
|
|
69
69
|
setMeta(database, 'destroyed', '1');
|
|
70
70
|
return;
|
|
71
|
-
case 'setCheckpointId':
|
|
72
|
-
setMeta(database, body.field === 'consistent' ? 'consistent_checkpoint_id' : 'file_checkpoint_id', String(body.id));
|
|
73
|
-
return;
|
|
74
71
|
case 'noteExecutionGroup':
|
|
75
72
|
setMeta(database, 'execution_group', JSON.stringify({ ...(executionGroup(database)), ...asRecord(body.group) }));
|
|
76
73
|
return;
|
|
@@ -298,7 +295,6 @@ function importLegacy(database, legacy) {
|
|
|
298
295
|
return typeof session.sessionId === 'string' && typeof session.threadId === 'string';
|
|
299
296
|
});
|
|
300
297
|
importBucket(database, 'terminals', asRecord(snapshot.terminals), (value) => terminalRecordSchema.safeParse(value).success);
|
|
301
|
-
importBucket(database, 'checkpoints', asRecord(snapshot.checkpoints), (value) => checkpointRecordSchema.safeParse(value).success);
|
|
302
298
|
importBucket(database, 'approvals', asRecord(snapshot.approvals), (value) => isApproval(value));
|
|
303
299
|
importBucket(database, 'managed_writes', asRecord(snapshot.managedWrites), () => true);
|
|
304
300
|
importBucket(database, 'file_revisions', asRecord(snapshot.fileRevisions), () => true);
|
|
@@ -307,11 +303,6 @@ function importLegacy(database, legacy) {
|
|
|
307
303
|
for (const [workspaceId, files] of Object.entries(workspaceFiles)) {
|
|
308
304
|
importBucket(database, `workspace_files:${workspaceId}`, asRecord(files), () => true);
|
|
309
305
|
}
|
|
310
|
-
if (typeof snapshot.fileCheckpointId === 'string')
|
|
311
|
-
setMeta(database, 'file_checkpoint_id', snapshot.fileCheckpointId);
|
|
312
|
-
if (typeof snapshot.consistentCheckpointId === 'string') {
|
|
313
|
-
setMeta(database, 'consistent_checkpoint_id', snapshot.consistentCheckpointId);
|
|
314
|
-
}
|
|
315
306
|
setMeta(database, 'accepted_lease_fence', String(Number(snapshot.acceptedLeaseFence) || 0));
|
|
316
307
|
setMeta(database, 'destroyed', snapshot.destroyed === true ? '1' : '0');
|
|
317
308
|
setMeta(database, 'runtime_epoch', String(runtimeEpoch));
|
|
@@ -441,6 +432,7 @@ function bindTurn(database, body) {
|
|
|
441
432
|
turnBlock: undefined,
|
|
442
433
|
activeTurnId: String(body.turnId),
|
|
443
434
|
threadId: String(body.threadId),
|
|
435
|
+
recoveryContextPending: undefined,
|
|
444
436
|
});
|
|
445
437
|
}
|
|
446
438
|
const threadId = String(body.threadId);
|
|
@@ -701,13 +693,10 @@ function dump(database) {
|
|
|
701
693
|
events: eventsObject(database),
|
|
702
694
|
sessions: bucketObject(database, 'sessions'),
|
|
703
695
|
terminals: bucketObject(database, 'terminals'),
|
|
704
|
-
checkpoints: bucketObject(database, 'checkpoints'),
|
|
705
696
|
fileRevisions: bucketObject(database, 'file_revisions'),
|
|
706
697
|
approvals: bucketObject(database, 'approvals'),
|
|
707
698
|
managedWrites: bucketObject(database, 'managed_writes'),
|
|
708
699
|
workspaceDependencies: bucketObject(database, 'workspace_dependencies'),
|
|
709
|
-
fileCheckpointId: metaString(database, 'file_checkpoint_id') || undefined,
|
|
710
|
-
consistentCheckpointId: metaString(database, 'consistent_checkpoint_id') || undefined,
|
|
711
700
|
executionGroup: executionGroup(database),
|
|
712
701
|
runtimeEpoch: metaNumber(database, 'runtime_epoch'),
|
|
713
702
|
lastSourceSequence: currentCounter(database),
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
const LOCAL_ATTR = /<(?:script|img|link|source|video|audio|iframe)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["']/gi;
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** A CSS `url(`, not the tail of a name such as `createObjectURL(` or `getAudioURL(`. */
|
|
3
|
+
const CSS_URL = /(?<![\w$.-])url\(\s*["']?([^"')]+)["']?\s*\)/gi;
|
|
4
|
+
/** An inline script's body, kept apart from its tag so `<script src>` is still checked. */
|
|
5
|
+
const SCRIPT_BODY = /(<script\b[^>]*>)[\s\S]*?(<\/script\s*>)/gi;
|
|
6
|
+
/**
|
|
7
|
+
* A reference the sealed document would load from the local build, not from the network or inline
|
|
8
|
+
* data. Inline script code is not read: inlined libraries call functions named `…URL(` and build
|
|
9
|
+
* `"url(" + src + ")"` strings at run time, and neither is a file the document loads.
|
|
10
|
+
*/
|
|
4
11
|
export function findExternalLocalReferences(html) {
|
|
12
|
+
const markup = html.replace(SCRIPT_BODY, '$1$2');
|
|
5
13
|
const found = new Set();
|
|
6
14
|
for (const expression of [LOCAL_ATTR, CSS_URL]) {
|
|
7
15
|
expression.lastIndex = 0;
|
|
8
|
-
for (const match of
|
|
16
|
+
for (const match of markup.matchAll(expression)) {
|
|
9
17
|
const value = (match[1] ?? '').trim();
|
|
10
18
|
if (isLocalReference(value))
|
|
11
19
|
found.add(value);
|
|
@@ -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,14 +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 { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
14
|
+
import { InvalidWorkspacePathError, resolveWorkspaceFile, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
17
15
|
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
18
16
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
19
17
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
20
18
|
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
21
|
-
import {
|
|
22
|
-
import { probeProcess, spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
19
|
+
import { spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
23
20
|
const CONVERSATION_KINDS = new Set([
|
|
24
21
|
'session.create',
|
|
25
22
|
'session.turn',
|
|
@@ -29,8 +26,6 @@ const CONVERSATION_KINDS = new Set([
|
|
|
29
26
|
const LOCAL_KINDS = new Set([
|
|
30
27
|
'runtime.ensure',
|
|
31
28
|
'runtime.destroy',
|
|
32
|
-
'checkpoint.create',
|
|
33
|
-
'checkpoint.restore',
|
|
34
29
|
'file.write',
|
|
35
30
|
'git.commit',
|
|
36
31
|
'git.push',
|
|
@@ -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,15 +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
|
-
return executeCheckpoint(state, scopeKey, current, payload);
|
|
93
|
-
}
|
|
94
|
-
if (operation.kind === 'checkpoint.restore') {
|
|
95
|
-
return executeRestore(state, scopeKey, current, payload);
|
|
96
|
-
}
|
|
97
72
|
if (operation.kind === 'file.write') {
|
|
98
73
|
return executeFileWrite(state, scopeKey, current, payload);
|
|
99
74
|
}
|
|
@@ -129,7 +104,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
129
104
|
return executeCreate(state, scopeKey, current, payload);
|
|
130
105
|
}
|
|
131
106
|
if (operation.kind === 'session.turn') {
|
|
132
|
-
return executeTurn(state, scopeKey, current, payload);
|
|
107
|
+
return executeTurn(state, scopeKey, current, payload, recovery);
|
|
133
108
|
}
|
|
134
109
|
if (operation.kind === 'session.interrupt') {
|
|
135
110
|
return executeInterrupt(state, scopeKey, current);
|
|
@@ -214,9 +189,23 @@ async function executeCreate(state, scopeKey, operation, payload) {
|
|
|
214
189
|
]);
|
|
215
190
|
return persist(state, scopeKey, transitionOperation(accepted, 'completed', { nativeThreadId: started.threadId }));
|
|
216
191
|
}
|
|
217
|
-
async function executeTurn(state, scopeKey, operation, payload) {
|
|
192
|
+
async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
218
193
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
219
|
-
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 : '';
|
|
220
209
|
if (!binding) {
|
|
221
210
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
222
211
|
}
|
|
@@ -226,10 +215,6 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
226
215
|
if (sessionTurnBusy(binding)) {
|
|
227
216
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
228
217
|
}
|
|
229
|
-
const body = asRecord(payload);
|
|
230
|
-
const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
|
|
231
|
-
if (!selected.success)
|
|
232
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
233
218
|
let attachments;
|
|
234
219
|
try {
|
|
235
220
|
attachments = await resolveWorkspaceAttachments(await workspaceRoot(state, operation.workspaceId), body.attachmentIds);
|
|
@@ -242,7 +227,7 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
242
227
|
const result = await native(state).startTurn({
|
|
243
228
|
sessionId,
|
|
244
229
|
threadId: binding.threadId,
|
|
245
|
-
text: String(body.text ?? '') + attachmentText,
|
|
230
|
+
text: context + String(body.text ?? '') + attachmentText,
|
|
246
231
|
images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
|
|
247
232
|
clientRequestId: operation.clientRequestId,
|
|
248
233
|
modelConfigId: selected.data ?? binding.modelConfigId,
|
|
@@ -312,6 +297,60 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
312
297
|
}
|
|
313
298
|
return state.journal.getOperation(scopeKey) ?? latest;
|
|
314
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
|
+
}
|
|
315
354
|
async function executeInterrupt(state, scopeKey, operation) {
|
|
316
355
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
317
356
|
const binding = state.journal.getSession(sessionId);
|
|
@@ -353,215 +392,6 @@ async function executeApproval(state, scopeKey, operation, payload) {
|
|
|
353
392
|
error: terminal === 'failed' ? 'native_failed' : undefined,
|
|
354
393
|
}));
|
|
355
394
|
}
|
|
356
|
-
async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
357
|
-
const body = asRecord(payload);
|
|
358
|
-
const checkpointId = typeof body.checkpointId === 'string' && body.checkpointId.trim()
|
|
359
|
-
? body.checkpointId.trim()
|
|
360
|
-
: `ckpt_${randomUUID()}`;
|
|
361
|
-
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(checkpointId) || state.journal.getCheckpoint(checkpointId)) {
|
|
362
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'checkpoint_id_conflict' }));
|
|
363
|
-
}
|
|
364
|
-
const workspaceId = operation.workspaceId ?? String(body.workspaceId ?? 'workspace');
|
|
365
|
-
const active = await activeManagedWrites(state, workspaceId);
|
|
366
|
-
const createdAt = new Date().toISOString();
|
|
367
|
-
if (active.length > 0) {
|
|
368
|
-
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
369
|
-
sessionId: item.sessionId,
|
|
370
|
-
nativeThreadId: item.threadId,
|
|
371
|
-
modelConfigId: item.modelConfigId,
|
|
372
|
-
recoverability: 'history_only',
|
|
373
|
-
}));
|
|
374
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
375
|
-
checkpointId,
|
|
376
|
-
kind: 'best_effort',
|
|
377
|
-
workspaceId,
|
|
378
|
-
generation: state.config.generation,
|
|
379
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
380
|
-
sessions,
|
|
381
|
-
createdAt,
|
|
382
|
-
nativeRecoverable: false,
|
|
383
|
-
consistent: false,
|
|
384
|
-
});
|
|
385
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
386
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, active.map((item) => item.source));
|
|
387
|
-
}
|
|
388
|
-
const root = await workspaceRoot(state, operation.workspaceId);
|
|
389
|
-
const revisions = state.journal.fileRevisions(revisionScope(state, operation.workspaceId));
|
|
390
|
-
let files;
|
|
391
|
-
let published;
|
|
392
|
-
if (state.config.checkpointObjects) {
|
|
393
|
-
const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions);
|
|
394
|
-
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
395
|
-
if (stillActive.length > 0) {
|
|
396
|
-
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
397
|
-
}
|
|
398
|
-
try {
|
|
399
|
-
published = await publishFileSnapshot({
|
|
400
|
-
baseUrl: platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.checkpointStore),
|
|
401
|
-
snapshotId: checkpointId,
|
|
402
|
-
workspaceId,
|
|
403
|
-
generation: state.config.generation,
|
|
404
|
-
tree,
|
|
405
|
-
headers: checkpointStoreHeaders(state),
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
catch {
|
|
409
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'snapshot_upload_failed' }));
|
|
410
|
-
}
|
|
411
|
-
if (tree.status === 'incomplete') {
|
|
412
|
-
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
413
|
-
sessionId: item.sessionId,
|
|
414
|
-
nativeThreadId: item.threadId,
|
|
415
|
-
modelConfigId: item.modelConfigId,
|
|
416
|
-
recoverability: 'history_only',
|
|
417
|
-
}));
|
|
418
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
419
|
-
checkpointId,
|
|
420
|
-
kind: 'file',
|
|
421
|
-
workspaceId,
|
|
422
|
-
generation: state.config.generation,
|
|
423
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
424
|
-
sessions,
|
|
425
|
-
createdAt,
|
|
426
|
-
nativeRecoverable: false,
|
|
427
|
-
consistent: false,
|
|
428
|
-
manifestKey: published.manifestKey,
|
|
429
|
-
checksum: published.checksum,
|
|
430
|
-
snapshotStatus: 'incomplete',
|
|
431
|
-
});
|
|
432
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
433
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
|
|
434
|
-
}
|
|
435
|
-
await materializeSnapshot(fileCheckpointDir(state, checkpointId), tree);
|
|
436
|
-
files = tree.files;
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
439
|
-
files = await snapshotWorkspaceFiles(root, fileCheckpointDir(state, checkpointId), revisions);
|
|
440
|
-
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
441
|
-
if (stillActive.length > 0) {
|
|
442
|
-
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
const captured = await captureNativeSessions(state, operation.workspaceId);
|
|
446
|
-
const nativeReady = captured.nativeReady;
|
|
447
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
448
|
-
checkpointId,
|
|
449
|
-
kind: nativeReady ? 'coordinated' : 'file',
|
|
450
|
-
workspaceId,
|
|
451
|
-
generation: state.config.generation,
|
|
452
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
453
|
-
sessions: captured.sessions,
|
|
454
|
-
files,
|
|
455
|
-
createdAt,
|
|
456
|
-
nativeRecoverable: nativeReady,
|
|
457
|
-
consistent: nativeReady,
|
|
458
|
-
...(published
|
|
459
|
-
? { manifestKey: published.manifestKey, checksum: published.checksum, snapshotStatus: published.status }
|
|
460
|
-
: {}),
|
|
461
|
-
});
|
|
462
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
463
|
-
await state.journal.setFileCheckpointId(checkpointId);
|
|
464
|
-
if (nativeReady)
|
|
465
|
-
await state.journal.setConsistentCheckpointId(checkpointId);
|
|
466
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
|
|
467
|
-
}
|
|
468
|
-
async function executeRestore(state, scopeKey, operation, payload) {
|
|
469
|
-
const body = asRecord(payload);
|
|
470
|
-
const checkpointId = String(body.checkpointId ?? operation.checkpointId ?? '');
|
|
471
|
-
const checkpoint = state.journal.getCheckpoint(checkpointId);
|
|
472
|
-
if (!checkpoint || checkpoint.workspaceId !== (operation.workspaceId ?? 'workspace')) {
|
|
473
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_checkpoint' }));
|
|
474
|
-
}
|
|
475
|
-
if (checkpoint.snapshotStatus === 'incomplete') {
|
|
476
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'snapshot_incomplete' }));
|
|
477
|
-
}
|
|
478
|
-
const restarted = executionGroupRestarted(state, checkpoint.runtimeEpoch);
|
|
479
|
-
const stopConfirmed = state.journal.executionGroup().stopConfirmed;
|
|
480
|
-
const cwd = await workspaceRoot(state, operation.workspaceId);
|
|
481
|
-
const next = workspaceSessions(state, operation.workspaceId).map((item) => {
|
|
482
|
-
const snap = checkpoint.sessions.find((session) => session.sessionId === item.sessionId);
|
|
483
|
-
if (!snap)
|
|
484
|
-
return { ...item, recoverability: 'history_only' };
|
|
485
|
-
const threadId = item.threadId;
|
|
486
|
-
const threadReplaced = Boolean(snap.nativeThreadId && snap.nativeThreadId !== item.threadId);
|
|
487
|
-
if (!checkpoint.nativeRecoverable || threadReplaced) {
|
|
488
|
-
return {
|
|
489
|
-
...item,
|
|
490
|
-
modelConfigId: snap.modelConfigId ?? item.modelConfigId,
|
|
491
|
-
threadId,
|
|
492
|
-
recoverability: restarted ? 'history_only' : item.recoverability,
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
if (restarted)
|
|
496
|
-
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId, recoverability: 'history_only' };
|
|
497
|
-
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
|
|
498
|
-
});
|
|
499
|
-
for (const snap of checkpoint.sessions) {
|
|
500
|
-
if (!next.some((item) => item.sessionId === snap.sessionId)) {
|
|
501
|
-
next.push({
|
|
502
|
-
sessionId: snap.sessionId,
|
|
503
|
-
workspaceId: operation.workspaceId,
|
|
504
|
-
threadId: snap.nativeThreadId ?? `thread_${snap.sessionId}`,
|
|
505
|
-
modelConfigId: snap.modelConfigId,
|
|
506
|
-
cwd,
|
|
507
|
-
recoverability: checkpoint.nativeRecoverable && !restarted ? 'native' : 'history_only',
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
let nativeContinuable = checkpoint.nativeRecoverable
|
|
512
|
-
? next.some((item) => item.recoverability === 'native')
|
|
513
|
-
: false;
|
|
514
|
-
if (checkpoint.nativeRecoverable && restarted && stopConfirmed) {
|
|
515
|
-
for (const snap of checkpoint.sessions) {
|
|
516
|
-
const threadId = snap.nativeThreadId;
|
|
517
|
-
const found = next.find((item) => item.sessionId === snap.sessionId);
|
|
518
|
-
if (!threadId || !found || !state.native?.canContinueThread)
|
|
519
|
-
continue;
|
|
520
|
-
const continued = await state.native.canContinueThread({
|
|
521
|
-
threadId,
|
|
522
|
-
sessionId: snap.sessionId,
|
|
523
|
-
cwd: found.cwd,
|
|
524
|
-
modelConfigId: found.modelConfigId,
|
|
525
|
-
});
|
|
526
|
-
if (!continued || found.threadId !== threadId)
|
|
527
|
-
continue;
|
|
528
|
-
found.recoverability = 'native';
|
|
529
|
-
nativeContinuable = true;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
if (!checkpoint.nativeRecoverable || (restarted && !stopConfirmed))
|
|
533
|
-
nativeContinuable = false;
|
|
534
|
-
const others = state.config.workspaceMode === 'multi'
|
|
535
|
-
? state.journal.listSessions().filter((item) => item.workspaceId !== operation.workspaceId) : [];
|
|
536
|
-
if (others.some((item) => next.some((restored) => restored.sessionId === item.sessionId))) {
|
|
537
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'session_workspace_mismatch' }));
|
|
538
|
-
}
|
|
539
|
-
await state.journal.replaceSessions([...others, ...next]);
|
|
540
|
-
let filesRestored = false;
|
|
541
|
-
if (checkpoint.files) {
|
|
542
|
-
const restoredFiles = await restoreWorkspaceFiles(cwd, fileCheckpointDir(state, checkpointId), checkpoint.files);
|
|
543
|
-
await state.journal.replaceFileRevisions(restoredFiles, revisionScope(state, operation.workspaceId));
|
|
544
|
-
filesRestored = true;
|
|
545
|
-
}
|
|
546
|
-
const historyVisible = state.journal.getCheckpoint(checkpointId) !== undefined &&
|
|
547
|
-
state.journal.listEvents().some((event) => event.type === 'checkpoint.created' || event.type === 'session.created');
|
|
548
|
-
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId }));
|
|
549
|
-
await recordNativeEvents(state, accepted, [
|
|
550
|
-
{
|
|
551
|
-
type: 'checkpoint.restored',
|
|
552
|
-
payload: {
|
|
553
|
-
checkpointId,
|
|
554
|
-
historyVisible,
|
|
555
|
-
filesRestored,
|
|
556
|
-
nativeContinuable,
|
|
557
|
-
nativeSessionCount: next.filter((item) => item.recoverability === 'native').length,
|
|
558
|
-
historyOnlyCount: next.filter((item) => item.recoverability === 'history_only').length,
|
|
559
|
-
},
|
|
560
|
-
occurredAt: new Date().toISOString(),
|
|
561
|
-
},
|
|
562
|
-
]);
|
|
563
|
-
return persist(state, scopeKey, transitionOperation(accepted, 'completed', { checkpointId }));
|
|
564
|
-
}
|
|
565
395
|
async function executeDestroy(state, scopeKey, operation) {
|
|
566
396
|
await state.journal.markDestroyed();
|
|
567
397
|
await closeCliCredentials(state);
|
|
@@ -889,79 +719,6 @@ function parameterPatchError(error) {
|
|
|
889
719
|
return 'invalid_workspace_path';
|
|
890
720
|
return 'parameter_patch_failed';
|
|
891
721
|
}
|
|
892
|
-
async function finishCheckpoint(state, scopeKey, operation, checkpoint, activeWriters) {
|
|
893
|
-
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId: checkpoint.checkpointId }));
|
|
894
|
-
await recordNativeEvents(state, accepted, [
|
|
895
|
-
{
|
|
896
|
-
type: 'checkpoint.created',
|
|
897
|
-
payload: {
|
|
898
|
-
checkpointId: checkpoint.checkpointId,
|
|
899
|
-
workspaceId: checkpoint.workspaceId,
|
|
900
|
-
kind: checkpoint.kind,
|
|
901
|
-
consistent: checkpoint.consistent === true,
|
|
902
|
-
fileCheckpointId: state.journal.fileCheckpointId(),
|
|
903
|
-
consistentCheckpointId: state.journal.consistentCheckpointId(),
|
|
904
|
-
nativeRecoverable: checkpoint.nativeRecoverable,
|
|
905
|
-
sessionCount: checkpoint.sessions.length,
|
|
906
|
-
fileCount: checkpoint.files?.length ?? 0,
|
|
907
|
-
activeWriters,
|
|
908
|
-
},
|
|
909
|
-
occurredAt: checkpoint.createdAt,
|
|
910
|
-
},
|
|
911
|
-
]);
|
|
912
|
-
return persist(state, scopeKey, transitionOperation(accepted, 'completed', {
|
|
913
|
-
checkpointId: checkpoint.checkpointId,
|
|
914
|
-
checkpointKind: checkpoint.kind,
|
|
915
|
-
checkpointConsistent: checkpoint.consistent === true,
|
|
916
|
-
...(checkpoint.manifestKey ? { snapshotManifestKey: checkpoint.manifestKey } : {}),
|
|
917
|
-
...(checkpoint.checksum ? { snapshotChecksum: checkpoint.checksum } : {}),
|
|
918
|
-
...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
|
|
919
|
-
}));
|
|
920
|
-
}
|
|
921
|
-
async function captureNativeSessions(state, workspaceId) {
|
|
922
|
-
const live = workspaceSessions(state, workspaceId);
|
|
923
|
-
const sessions = [];
|
|
924
|
-
let nativeReady = live.length > 0;
|
|
925
|
-
for (const item of live) {
|
|
926
|
-
const captured = await state.native?.captureNativeCheckpoint?.({
|
|
927
|
-
threadId: item.threadId,
|
|
928
|
-
sessionId: item.sessionId,
|
|
929
|
-
});
|
|
930
|
-
const durable = captured?.threadId === item.threadId && typeof captured.nativeDurableTurnId === 'string'
|
|
931
|
-
? captured.nativeDurableTurnId
|
|
932
|
-
: undefined;
|
|
933
|
-
if (!durable)
|
|
934
|
-
nativeReady = false;
|
|
935
|
-
sessions.push(durable
|
|
936
|
-
? {
|
|
937
|
-
sessionId: item.sessionId,
|
|
938
|
-
nativeThreadId: item.threadId,
|
|
939
|
-
modelConfigId: item.modelConfigId,
|
|
940
|
-
nativeDurableTurnId: durable,
|
|
941
|
-
recoverability: 'native',
|
|
942
|
-
}
|
|
943
|
-
: {
|
|
944
|
-
sessionId: item.sessionId,
|
|
945
|
-
nativeThreadId: item.threadId,
|
|
946
|
-
modelConfigId: item.modelConfigId,
|
|
947
|
-
recoverability: 'history_only',
|
|
948
|
-
});
|
|
949
|
-
}
|
|
950
|
-
return { sessions, nativeReady };
|
|
951
|
-
}
|
|
952
|
-
async function activeManagedWrites(state, workspaceId) {
|
|
953
|
-
const active = [];
|
|
954
|
-
for (const lease of state.journal.listManagedWrites(workspaceId)) {
|
|
955
|
-
if (lease.stoppedAt)
|
|
956
|
-
continue;
|
|
957
|
-
if (lease.pid !== undefined && probeProcess(lease.pid) === false) {
|
|
958
|
-
await state.journal.putManagedWrite({ ...lease, stoppedAt: new Date().toISOString() });
|
|
959
|
-
continue;
|
|
960
|
-
}
|
|
961
|
-
active.push(lease);
|
|
962
|
-
}
|
|
963
|
-
return active;
|
|
964
|
-
}
|
|
965
722
|
async function releaseManagedWrite(state, writeId, workspaceId) {
|
|
966
723
|
const lease = state.journal.getManagedWrite(writeId);
|
|
967
724
|
if (!lease || lease.stoppedAt || lease.workspaceId !== workspaceId)
|
|
@@ -974,36 +731,6 @@ async function releaseManagedWrite(state, writeId, workspaceId) {
|
|
|
974
731
|
await state.journal.putManagedWrite({ ...lease, stoppedAt: new Date().toISOString() });
|
|
975
732
|
return true;
|
|
976
733
|
}
|
|
977
|
-
async function finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, activeWriters) {
|
|
978
|
-
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
979
|
-
sessionId: item.sessionId,
|
|
980
|
-
nativeThreadId: item.threadId,
|
|
981
|
-
modelConfigId: item.modelConfigId,
|
|
982
|
-
recoverability: 'history_only',
|
|
983
|
-
}));
|
|
984
|
-
const checkpoint = checkpointRecordSchema.parse({
|
|
985
|
-
checkpointId,
|
|
986
|
-
kind: 'best_effort',
|
|
987
|
-
workspaceId,
|
|
988
|
-
generation: state.config.generation,
|
|
989
|
-
runtimeEpoch: state.runtimeEpoch,
|
|
990
|
-
sessions,
|
|
991
|
-
createdAt,
|
|
992
|
-
nativeRecoverable: false,
|
|
993
|
-
consistent: false,
|
|
994
|
-
});
|
|
995
|
-
await state.journal.putCheckpoint(checkpoint);
|
|
996
|
-
return finishCheckpoint(state, scopeKey, operation, checkpoint, activeWriters);
|
|
997
|
-
}
|
|
998
|
-
function previousFilePaths(state) {
|
|
999
|
-
const checkpointId = state.journal.fileCheckpointId();
|
|
1000
|
-
if (!checkpointId)
|
|
1001
|
-
return [];
|
|
1002
|
-
return state.journal.getCheckpoint(checkpointId)?.files?.map((file) => file.path) ?? [];
|
|
1003
|
-
}
|
|
1004
|
-
function fileCheckpointDir(state, checkpointId) {
|
|
1005
|
-
return join(state.config.stateDir, 'file-checkpoints', checkpointId);
|
|
1006
|
-
}
|
|
1007
734
|
export function attachNativeCollector(state) {
|
|
1008
735
|
state.native?.onCollected?.((event) => {
|
|
1009
736
|
void ingestCollected(state, event);
|
|
@@ -1068,16 +795,6 @@ function nativeIds(params) {
|
|
|
1068
795
|
itemId: typeof record.itemId === 'string' ? record.itemId : typeof record.approvalId === 'string' ? record.approvalId : undefined,
|
|
1069
796
|
};
|
|
1070
797
|
}
|
|
1071
|
-
/** The checkpoint route is on this deployment, so uploads carry the control credential. */
|
|
1072
|
-
function checkpointStoreHeaders(state) {
|
|
1073
|
-
return {
|
|
1074
|
-
authorization: `Bearer ${state.config.controlToken}`,
|
|
1075
|
-
'playcraft-env': state.config.environment,
|
|
1076
|
-
'x-playcraft-runtime-id': state.config.runtimeId,
|
|
1077
|
-
'x-playcraft-generation': String(state.config.generation),
|
|
1078
|
-
'x-playcraft-runtime-epoch': String(state.runtimeEpoch),
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
798
|
async function notifyCallback(state, events) {
|
|
1082
799
|
await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
1083
800
|
method: 'POST',
|
|
@@ -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);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload,
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
7
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
8
8
|
import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
|
|
9
|
-
import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
|
|
10
9
|
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
11
10
|
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
12
11
|
import { readDependencies } from '../workspaces/dependencies.js';
|
|
@@ -107,24 +106,6 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
107
106
|
writeError(response, runtimeError('capability_unavailable', 'capability_not_implemented'));
|
|
108
107
|
return;
|
|
109
108
|
}
|
|
110
|
-
if (request.method === 'POST' && url.pathname === '/runtime/instance/rebuild') {
|
|
111
|
-
await rebuildInstanceFiles(state, request, response);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (request.method === 'GET' && url.pathname === '/runtime/checkpoint') {
|
|
115
|
-
const checkpointId = url.searchParams.get('checkpointId') ?? '';
|
|
116
|
-
const checkpoint = state.journal.getCheckpoint(checkpointId);
|
|
117
|
-
if (!checkpoint) {
|
|
118
|
-
writeJson(response, 404, { error: 'unknown_checkpoint' });
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
writeJson(response, 200, {
|
|
122
|
-
checkpoint,
|
|
123
|
-
fileCheckpointId: state.journal.fileCheckpointId() ?? null,
|
|
124
|
-
consistentCheckpointId: state.journal.consistentCheckpointId() ?? null,
|
|
125
|
-
});
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
109
|
if (url.pathname === '/runtime/events' || url.pathname.startsWith('/runtime/events/')) {
|
|
129
110
|
await handleSourceEvents(state, request, response, url);
|
|
130
111
|
return;
|
|
@@ -440,7 +421,9 @@ async function acceptOperation(state, request, response) {
|
|
|
440
421
|
}
|
|
441
422
|
else if (parsed.data.kind.startsWith('session.') && state.config.workspaceMode === 'multi') {
|
|
442
423
|
const session = state.journal.getSession(parsed.data.sessionId);
|
|
443
|
-
|
|
424
|
+
// A replaced sandbox has no binding yet; the recovering turn creates it.
|
|
425
|
+
const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
|
|
426
|
+
if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId)) {
|
|
444
427
|
writeJson(response, 409, { error: 'session_workspace_mismatch' });
|
|
445
428
|
return;
|
|
446
429
|
}
|
|
@@ -482,7 +465,7 @@ async function acceptOperation(state, request, response) {
|
|
|
482
465
|
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, incomingFence);
|
|
483
466
|
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
484
467
|
const queued = await state.journal.putOperation(scopeKey, operation);
|
|
485
|
-
const executed = await executeOperation(state, scopeKey, queued, parsed.data.payload);
|
|
468
|
+
const executed = await executeOperation(state, scopeKey, queued, parsed.data.payload, parsed.data.sessionRecovery);
|
|
486
469
|
if (executed.status === 'uncertain') {
|
|
487
470
|
writeError(response, runtimeError('uncertain_operation', executed.error ?? 'native_receipt_lost', {
|
|
488
471
|
operationId: executed.operationId,
|
|
@@ -524,42 +507,6 @@ async function writeGitRead(state, response, read) {
|
|
|
524
507
|
writeError(response, runtimeError('capability_unavailable', reason));
|
|
525
508
|
}
|
|
526
509
|
}
|
|
527
|
-
async function rebuildInstanceFiles(state, request, response) {
|
|
528
|
-
if (writeDestroyed(state, response))
|
|
529
|
-
return;
|
|
530
|
-
if (state.journal.fileCheckpointId() || state.journal.listSessions().length > 0) {
|
|
531
|
-
writeJson(response, 409, { status: 'failed', error: 'instance_still_alive', nativeContinuable: false });
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
const parsed = instanceRebuildRequestSchema.safeParse(await readJson(request));
|
|
535
|
-
if (!parsed.success) {
|
|
536
|
-
writeJson(response, 400, { status: 'failed', error: 'snapshot_mismatch', nativeContinuable: false });
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
try {
|
|
540
|
-
const root = await workspaceRoot(state, parsed.data.workspaceId);
|
|
541
|
-
const materialized = await materializeRemoteSnapshot(root, parsed.data.entries);
|
|
542
|
-
state.rebuildGate = {
|
|
543
|
-
directoryReady: materialized.directoryReady,
|
|
544
|
-
repositoryReady: materialized.repositoryReady,
|
|
545
|
-
environmentReady: materialized.environmentReady,
|
|
546
|
-
};
|
|
547
|
-
writeJson(response, 200, {
|
|
548
|
-
status: 'completed',
|
|
549
|
-
snapshotId: parsed.data.snapshotId,
|
|
550
|
-
nativeContinuable: false,
|
|
551
|
-
filesRestored: true,
|
|
552
|
-
skipped: materialized.skipped,
|
|
553
|
-
directoryReady: materialized.directoryReady,
|
|
554
|
-
repositoryReady: materialized.repositoryReady,
|
|
555
|
-
environmentReady: materialized.environmentReady,
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
catch (error) {
|
|
559
|
-
const reason = error instanceof Error && error.message === 'snapshot_mismatch' ? 'snapshot_mismatch' : 'snapshot_mismatch';
|
|
560
|
-
writeJson(response, 409, { status: 'failed', error: reason, nativeContinuable: false });
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
510
|
async function readJson(request) {
|
|
564
511
|
const chunks = [];
|
|
565
512
|
for await (const chunk of request) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, realpath,
|
|
1
|
+
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { hashWorkspaceFileContent,
|
|
3
|
+
import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
5
5
|
export class InvalidWorkspacePathError extends Error {
|
|
6
6
|
code = 'invalid_workspace_path';
|
|
@@ -66,37 +66,6 @@ export async function listWorkspaceFiles(root, revisions) {
|
|
|
66
66
|
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
67
67
|
return workspaceFileListSchema.parse({ files });
|
|
68
68
|
}
|
|
69
|
-
export async function snapshotWorkspaceFiles(root, snapshotDir, revisions) {
|
|
70
|
-
const listed = await listWorkspaceFiles(root, revisions);
|
|
71
|
-
await rm(snapshotDir, { recursive: true, force: true });
|
|
72
|
-
const records = [];
|
|
73
|
-
for (const file of listed.files) {
|
|
74
|
-
const content = await readFile(await resolveWorkspaceFile(root, file.path));
|
|
75
|
-
const target = join(snapshotDir, ...file.path.split('/'));
|
|
76
|
-
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
77
|
-
await writeFile(target, content, { mode: 0o600 });
|
|
78
|
-
records.push({ path: file.path, contentHash: hashSnapshotBytes(content), revision: file.revision });
|
|
79
|
-
}
|
|
80
|
-
return records;
|
|
81
|
-
}
|
|
82
|
-
export async function restoreWorkspaceFiles(root, snapshotDir, files) {
|
|
83
|
-
const rootReal = await ensureWorkspaceRoot(root);
|
|
84
|
-
const keep = new Set(files.map((item) => item.path));
|
|
85
|
-
for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
|
|
86
|
-
if (!keep.has(relativePath) && !isWorkspaceAttachmentPath(relativePath)) {
|
|
87
|
-
await rm(join(rootReal, ...relativePath.split('/')), { force: true });
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const next = {};
|
|
91
|
-
for (const file of files) {
|
|
92
|
-
const source = join(snapshotDir, ...file.path.split('/'));
|
|
93
|
-
const content = await readFile(source);
|
|
94
|
-
const target = await resolveWorkspaceFile(root, file.path);
|
|
95
|
-
await writeFile(target, content, { mode: isWorkspaceAttachmentPath(file.path) ? 0o644 : 0o600 });
|
|
96
|
-
next[file.path] = { revision: file.revision, contentHash: hashSnapshotBytes(content) };
|
|
97
|
-
}
|
|
98
|
-
return next;
|
|
99
|
-
}
|
|
100
69
|
async function walkWorkspaceFiles(rootReal, current) {
|
|
101
70
|
const entries = await readdir(current, { withFileTypes: true });
|
|
102
71
|
const paths = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.20",
|
|
52
|
+
"@playcraft/common": "^0.1.20",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
-
const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
5
|
-
const READ_ATTEMPTS = 3;
|
|
6
|
-
const realIo = {
|
|
7
|
-
async readdir(path) {
|
|
8
|
-
return readdir(path, { withFileTypes: true });
|
|
9
|
-
},
|
|
10
|
-
async lstat(path) {
|
|
11
|
-
return lstat(path);
|
|
12
|
-
},
|
|
13
|
-
readFile,
|
|
14
|
-
readlink,
|
|
15
|
-
};
|
|
16
|
-
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
|
|
17
|
-
const entries = [];
|
|
18
|
-
const files = [];
|
|
19
|
-
const bodies = new Map();
|
|
20
|
-
const seen = new Set();
|
|
21
|
-
let status = 'complete';
|
|
22
|
-
await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
|
|
23
|
-
status = 'incomplete';
|
|
24
|
-
});
|
|
25
|
-
for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
|
|
26
|
-
if (seen.has(path) || !workspaceRelativePathSchema.safeParse(path).success)
|
|
27
|
-
continue;
|
|
28
|
-
entries.push({ path, kind: 'deleted' });
|
|
29
|
-
}
|
|
30
|
-
entries.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
|
|
31
|
-
return { status, entries, files, bodies };
|
|
32
|
-
}
|
|
33
|
-
export async function materializeSnapshot(snapshotDir, tree) {
|
|
34
|
-
await rm(snapshotDir, { recursive: true, force: true });
|
|
35
|
-
for (const file of tree.files) {
|
|
36
|
-
const body = tree.bodies.get(file.path);
|
|
37
|
-
if (!body || hashSnapshotBytes(body) !== file.contentHash)
|
|
38
|
-
throw new Error('snapshot_body_missing');
|
|
39
|
-
const target = join(snapshotDir, ...file.path.split('/'));
|
|
40
|
-
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
41
|
-
await writeFile(target, body, { mode: 0o600 });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
export async function materializeRemoteSnapshot(root, entries) {
|
|
45
|
-
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
46
|
-
const rootReal = await realpath(root);
|
|
47
|
-
const skipped = [];
|
|
48
|
-
const applicable = [];
|
|
49
|
-
for (const entry of entries) {
|
|
50
|
-
if (isDisposableBackupPath(entry.path)) {
|
|
51
|
-
skipped.push(entry.path);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
applicable.push(entry);
|
|
55
|
-
}
|
|
56
|
-
for (const entry of applicable) {
|
|
57
|
-
if (entry.kind !== 'file')
|
|
58
|
-
continue;
|
|
59
|
-
if (!entry.contentBase64 || !entry.contentHash)
|
|
60
|
-
throw new Error('snapshot_mismatch');
|
|
61
|
-
const body = Buffer.from(entry.contentBase64, 'base64');
|
|
62
|
-
if (hashSnapshotBytes(body) !== entry.contentHash)
|
|
63
|
-
throw new Error('snapshot_mismatch');
|
|
64
|
-
}
|
|
65
|
-
for (const entry of applicable) {
|
|
66
|
-
const target = resolve(rootReal, ...entry.path.split('/'));
|
|
67
|
-
const rel = relative(rootReal, target);
|
|
68
|
-
if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith('..')) {
|
|
69
|
-
throw new Error('snapshot_mismatch');
|
|
70
|
-
}
|
|
71
|
-
if (entry.kind === 'deleted') {
|
|
72
|
-
await rm(target, { force: true });
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
await mkdir(dirname(target), { recursive: true, mode: isWorkspaceAttachmentPath(entry.path) ? 0o755 : 0o700 });
|
|
76
|
-
if (entry.kind === 'symlink') {
|
|
77
|
-
if (!entry.target)
|
|
78
|
-
throw new Error('snapshot_mismatch');
|
|
79
|
-
await rm(target, { force: true });
|
|
80
|
-
await symlink(entry.target, target);
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
const body = Buffer.from(entry.contentBase64 ?? '', 'base64');
|
|
84
|
-
await writeFile(target, body, { mode: entry.mode ?? 0o600 });
|
|
85
|
-
if (entry.mode !== undefined)
|
|
86
|
-
await chmod(target, entry.mode);
|
|
87
|
-
}
|
|
88
|
-
let repositoryReady = false;
|
|
89
|
-
try {
|
|
90
|
-
const git = await lstat(join(rootReal, '.git'));
|
|
91
|
-
repositoryReady = git.isDirectory() && !git.isSymbolicLink();
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
repositoryReady = false;
|
|
95
|
-
}
|
|
96
|
-
return {
|
|
97
|
-
skipped,
|
|
98
|
-
directoryReady: true,
|
|
99
|
-
repositoryReady,
|
|
100
|
-
environmentReady: false,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
export async function publishFileSnapshot(input) {
|
|
104
|
-
for (const entry of input.tree.entries) {
|
|
105
|
-
if (entry.kind !== 'file' || !entry.contentHash)
|
|
106
|
-
continue;
|
|
107
|
-
const body = input.tree.bodies.get(entry.path);
|
|
108
|
-
if (!body || hashSnapshotBytes(body) !== entry.contentHash)
|
|
109
|
-
throw new Error('snapshot_body_missing');
|
|
110
|
-
await putObjectBytes(input.baseUrl, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli', input.headers);
|
|
111
|
-
}
|
|
112
|
-
const digest = fileSnapshotDigest(input.tree.entries);
|
|
113
|
-
const manifest = fileSnapshotManifestSchema.parse({
|
|
114
|
-
snapshotId: input.snapshotId,
|
|
115
|
-
workspaceId: input.workspaceId,
|
|
116
|
-
generation: input.generation,
|
|
117
|
-
status: input.tree.status,
|
|
118
|
-
entries: input.tree.entries,
|
|
119
|
-
digest,
|
|
120
|
-
});
|
|
121
|
-
const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
|
|
122
|
-
await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
|
|
123
|
-
return { manifestKey, checksum: digest, status: input.tree.status };
|
|
124
|
-
}
|
|
125
|
-
async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
|
|
126
|
-
const listed = await io.readdir(current);
|
|
127
|
-
for (const entry of listed) {
|
|
128
|
-
if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
|
|
129
|
-
continue;
|
|
130
|
-
const child = join(current, entry.name);
|
|
131
|
-
const relativePath = toPosixRelative(rootReal, child);
|
|
132
|
-
if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
|
|
133
|
-
markIncomplete();
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (entry.isSymbolicLink()) {
|
|
137
|
-
const sampled = await sampleLink(child, io);
|
|
138
|
-
seen.add(relativePath);
|
|
139
|
-
if (!sampled) {
|
|
140
|
-
markIncomplete();
|
|
141
|
-
entries.push({ path: relativePath, kind: 'symlink' });
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
entries.push({ path: relativePath, kind: 'symlink', mode: sampled.mode, target: sampled.target });
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (entry.isDirectory()) {
|
|
148
|
-
await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (!entry.isFile())
|
|
152
|
-
continue;
|
|
153
|
-
const sampled = await sampleFile(child, io);
|
|
154
|
-
seen.add(relativePath);
|
|
155
|
-
if (!sampled) {
|
|
156
|
-
markIncomplete();
|
|
157
|
-
entries.push({ path: relativePath, kind: 'file' });
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
const record = {
|
|
161
|
-
path: relativePath,
|
|
162
|
-
contentHash: sampled.hash,
|
|
163
|
-
revision: revisions[relativePath]?.revision ?? 0,
|
|
164
|
-
};
|
|
165
|
-
entries.push({
|
|
166
|
-
path: relativePath,
|
|
167
|
-
kind: 'file',
|
|
168
|
-
mode: sampled.mode,
|
|
169
|
-
contentHash: sampled.hash,
|
|
170
|
-
size: sampled.body.byteLength,
|
|
171
|
-
});
|
|
172
|
-
files.push(record);
|
|
173
|
-
bodies.set(relativePath, sampled.body);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
async function sampleFile(path, io) {
|
|
177
|
-
let previous;
|
|
178
|
-
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
|
|
179
|
-
try {
|
|
180
|
-
const body = await io.readFile(path);
|
|
181
|
-
const mode = (await io.lstat(path)).mode & 0o777;
|
|
182
|
-
const hash = hashSnapshotBytes(body);
|
|
183
|
-
if (previous && previous.hash === hash && previous.mode === mode)
|
|
184
|
-
return { body, mode, hash };
|
|
185
|
-
previous = { hash, mode, body };
|
|
186
|
-
}
|
|
187
|
-
catch {
|
|
188
|
-
return undefined;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return undefined;
|
|
192
|
-
}
|
|
193
|
-
async function sampleLink(path, io) {
|
|
194
|
-
let previous;
|
|
195
|
-
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
|
|
196
|
-
try {
|
|
197
|
-
const target = await io.readlink(path);
|
|
198
|
-
const mode = (await io.lstat(path)).mode & 0o777;
|
|
199
|
-
if (target.includes('\0') || target.length === 0 || target.length > 512)
|
|
200
|
-
return undefined;
|
|
201
|
-
if (previous && previous.target === target && previous.mode === mode)
|
|
202
|
-
return { mode, target };
|
|
203
|
-
previous = { mode, target };
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
function toPosixRelative(rootReal, target) {
|
|
212
|
-
return relative(rootReal, target).split(sep).join('/');
|
|
213
|
-
}
|