@playcraft/cli 0.1.17 → 0.1.19

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