@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.
@@ -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, checkpointRecordSchema, operationRecordSchema, pendingSourceEventSchema, replayPinResultSchema, retainedRangeHasGap, sourceAckSchema, sourceEventKey, sourceEventReceiptSchema, terminalRecordSchema, } from '@playcraft/common/cloud-workspace/runtime';
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, checkpointRecordSchema, terminalRecordSchema, workspaceRegistrationSchema, transitionOperation, canTransitionTurn, } from '@playcraft/common/cloud-workspace/runtime';
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
- const CSS_URL = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
3
- /** A reference the sealed document would load from the local build, not from the network or inline data. */
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 html.matchAll(expression)) {
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, checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
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 { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
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 { executionGroupRestarted } from './execution-group.js';
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'}`, checkpointRun);
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 binding = state.journal.getSession(sessionId);
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, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
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
- if (!session || session.workspaceId !== parsed.data.workspaceId) {
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, rm, writeFile } from 'node:fs/promises';
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, hashSnapshotBytes, isWorkspaceAttachmentPath, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
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.18",
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.18",
52
- "@playcraft/common": "^0.1.18",
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
- }