@playcraft/cli 0.1.9 → 0.1.11

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.
@@ -5,9 +5,10 @@ import { randomUUID } from 'node:crypto';
5
5
  import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { NativePreparationError } from '../codex/native-adapter.js';
7
7
  import { disposeTerminalHost } from './terminal-host.js';
8
- import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
8
+ import { captureWorkspaceTree, liveTreeDigest, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
9
+ import { objectStoreHeaders, oneSaveAtATime, rememberFileCheckpoint, unchangedCheckpoint, workspaceSaves, } from '../workspaces/checkpoint-saves.js';
9
10
  import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
10
- import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
11
+ import { GitWorkspaceError, bundleUnpushedCommits, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
11
12
  import { ParameterEditError } from '../workspaces/json5-edit.js';
12
13
  import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
13
14
  import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
@@ -80,7 +81,8 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
80
81
  return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'workspace_not_ready' }));
81
82
  }
82
83
  if (operation.kind === 'checkpoint.create') {
83
- return executeCheckpoint(state, scopeKey, current, payload);
84
+ const workspaceId = current.workspaceId ?? String(asRecord(payload).workspaceId ?? 'workspace');
85
+ return oneSaveAtATime(state, workspaceId, () => executeCheckpoint(state, scopeKey, current, payload));
84
86
  }
85
87
  if (operation.kind === 'checkpoint.restore') {
86
88
  return executeRestore(state, scopeKey, current, payload);
@@ -288,9 +290,9 @@ async function executeApproval(state, scopeKey, operation, payload) {
288
290
  }
289
291
  async function executeCheckpoint(state, scopeKey, operation, payload) {
290
292
  const body = asRecord(payload);
291
- const checkpointId = typeof body.checkpointId === 'string' && body.checkpointId.trim()
292
- ? body.checkpointId.trim()
293
- : `ckpt_${randomUUID()}`;
293
+ // Only a save without a requested identity may answer with an earlier, unchanged checkpoint.
294
+ const requested = typeof body.checkpointId === 'string' && body.checkpointId.trim() !== '';
295
+ const checkpointId = requested ? String(body.checkpointId).trim() : `ckpt_${randomUUID()}`;
294
296
  if (!/^[a-zA-Z0-9_-]{1,128}$/.test(checkpointId) || state.journal.getCheckpoint(checkpointId)) {
295
297
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'checkpoint_id_conflict' }));
296
298
  }
@@ -321,8 +323,29 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
321
323
  const revisions = state.journal.fileRevisions(revisionScope(state, operation.workspaceId));
322
324
  let files;
323
325
  let published;
326
+ // A standalone workspace has no remote, so all of its `.git` is kept. A project workspace's packs
327
+ // come back with a new clone; what the remote lacks goes into the unpushed bundle.
328
+ const standalone = state.journal.getWorkspace(workspaceId)?.targetKind === 'standalone';
329
+ if (!standalone) {
330
+ // Without a fresh bundle the loose objects still hold commits made since the last repack.
331
+ await bundleUnpushedCommits(root).catch(() => undefined);
332
+ }
333
+ const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions, undefined, {
334
+ git: standalone ? 'full' : 'local',
335
+ });
336
+ // Transcripts only matter where another instance can read them back.
337
+ const transcripts = state.config.fileObjectStoreUrl ? await captureTranscripts(state, operation.workspaceId) : [];
338
+ const digest = liveTreeDigest(tree, transcripts);
339
+ if (!requested && tree.status === 'complete') {
340
+ const stillActive = await activeManagedWrites(state, workspaceId);
341
+ if (stillActive.length === 0) {
342
+ const captured = await captureNativeSessions(state, operation.workspaceId);
343
+ const previous = unchangedCheckpoint(state, workspaceId, digest, captured.sessions);
344
+ if (previous)
345
+ return finishUnchanged(state, scopeKey, operation, previous);
346
+ }
347
+ }
324
348
  if (state.config.fileObjectStoreUrl) {
325
- const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions);
326
349
  const stillActive = await activeManagedWrites(state, workspaceId);
327
350
  if (stillActive.length > 0) {
328
351
  return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
@@ -334,6 +357,10 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
334
357
  workspaceId,
335
358
  generation: state.config.generation,
336
359
  tree,
360
+ native: transcripts,
361
+ uploaded: workspaceSaves(state, workspaceId).uploaded,
362
+ uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
363
+ headers: objectStoreHeaders(state, state.config.fileObjectStoreUrl),
337
364
  });
338
365
  }
339
366
  catch {
@@ -393,8 +420,26 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
393
420
  await state.journal.setFileCheckpointId(checkpointId);
394
421
  if (nativeReady)
395
422
  await state.journal.setConsistentCheckpointId(checkpointId);
423
+ if (tree.status === 'complete') {
424
+ await rememberFileCheckpoint(state, workspaceId, { checkpointId, digest, sessions: captured.sessions });
425
+ }
396
426
  return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
397
427
  }
428
+ /**
429
+ * Nothing changed since the last file checkpoint: the save reports that checkpoint again and
430
+ * writes no new one, so periodic saves of an idle workspace cost one read of the tree.
431
+ */
432
+ async function finishUnchanged(state, scopeKey, operation, checkpoint) {
433
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId: checkpoint.checkpointId }));
434
+ return persist(state, scopeKey, transitionOperation(accepted, 'completed', {
435
+ checkpointId: checkpoint.checkpointId,
436
+ checkpointKind: checkpoint.kind,
437
+ checkpointConsistent: checkpoint.consistent === true,
438
+ ...(checkpoint.manifestKey ? { snapshotManifestKey: checkpoint.manifestKey } : {}),
439
+ ...(checkpoint.checksum ? { snapshotChecksum: checkpoint.checksum } : {}),
440
+ ...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
441
+ }));
442
+ }
398
443
  async function executeRestore(state, scopeKey, operation, payload) {
399
444
  const body = asRecord(payload);
400
445
  const checkpointId = String(body.checkpointId ?? operation.checkpointId ?? '');
@@ -767,7 +812,7 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
767
812
  }
768
813
  /** The branch a registered workspace is bound to; the single-workspace mode has none. */
769
814
  function boundBranch(state, operation) {
770
- return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName : undefined;
815
+ return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName ?? undefined : undefined;
771
816
  }
772
817
  function gitErrorCode(error) {
773
818
  return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
@@ -808,6 +853,22 @@ async function finishCheckpoint(state, scopeKey, operation, checkpoint, activeWr
808
853
  ...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
809
854
  }));
810
855
  }
856
+ /** Transcripts of the workspace's resumable threads. A thread whose file cannot be read is left out. */
857
+ async function captureTranscripts(state, workspaceId) {
858
+ const native = state.native;
859
+ if (!native?.exportThread)
860
+ return [];
861
+ const captured = [];
862
+ for (const session of workspaceSessions(state, workspaceId)) {
863
+ if (session.recoverability !== 'native')
864
+ continue;
865
+ const capturedAt = new Date().toISOString();
866
+ const file = await native.exportThread({ threadId: session.threadId }).catch(() => undefined);
867
+ if (file)
868
+ captured.push({ sessionId: session.sessionId, threadId: session.threadId, capturedAt, ...file });
869
+ }
870
+ return captured;
871
+ }
811
872
  async function captureNativeSessions(state, workspaceId) {
812
873
  const live = workspaceSessions(state, workspaceId);
813
874
  const sessions = [];
@@ -0,0 +1,19 @@
1
+ import { chmod, rename, writeFile } from 'node:fs/promises';
2
+ import { applySandboxEndpointPatch } from '@playcraft/common/cloud-workspace/runtime';
3
+ /** Persist the new addresses, then publish them to the running process. A restart is not required. */
4
+ export async function applyRuntimeEndpointPatch(state, patch) {
5
+ const next = applySandboxEndpointPatch(state.config, patch);
6
+ const changed = next.callbackUrl !== state.config.callbackUrl || next.modelProxyUrl !== state.config.modelProxyUrl;
7
+ const result = { changed, callbackUrl: next.callbackUrl, modelProxyUrl: next.modelProxyUrl };
8
+ if (!changed)
9
+ return result;
10
+ if (!state.configPath) {
11
+ throw new Error('endpoint_config_path_missing');
12
+ }
13
+ const temporary = `${state.configPath}.${process.pid}.tmp`;
14
+ await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600 });
15
+ await chmod(temporary, 0o600);
16
+ await rename(temporary, state.configPath);
17
+ state.config = next;
18
+ return result;
19
+ }
@@ -3,6 +3,7 @@ import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
+ import { applyRuntimeEndpointPatch } from './endpoints.js';
6
7
  import { canExecuteKind, executeOperation } from './dispatch.js';
7
8
  import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
8
9
  import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
@@ -61,6 +62,15 @@ export async function handleRuntimeRequest(state, request, response) {
61
62
  writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
62
63
  return;
63
64
  }
65
+ if (request.method === 'POST' && url.pathname === '/runtime/endpoints') {
66
+ try {
67
+ writeJson(response, 200, await applyRuntimeEndpointPatch(state, await readJson(request)));
68
+ }
69
+ catch {
70
+ writeError(response, runtimeError('invalid_config', 'endpoint_patch_rejected'));
71
+ }
72
+ return;
73
+ }
64
74
  if (request.method === 'GET' && url.pathname === '/runtime/handshake') {
65
75
  writeJson(response, 200, handshake(state));
66
76
  return;
@@ -101,7 +111,11 @@ export async function handleRuntimeRequest(state, request, response) {
101
111
  if (request.method === 'GET' && url.pathname === '/runtime/files') {
102
112
  if (writeDestroyed(state, response))
103
113
  return;
104
- writeJson(response, 200, await listWorkspaceFiles(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), state.journal.fileRevisions(revisionScope(state, url.searchParams.get('workspaceId') ?? undefined))));
114
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
115
+ const root = await workspaceRoot(state, workspaceId);
116
+ const listed = await listWorkspaceFiles(root, state.journal.fileRevisions(revisionScope(state, workspaceId)));
117
+ console.log(`[workspace-files] GET /runtime/files workspaceId=${workspaceId ?? '-'} root=${root} files=${listed.files.length}`);
118
+ writeJson(response, 200, listed);
105
119
  return;
106
120
  }
107
121
  if (request.method === 'GET' && url.pathname === '/runtime/file') {
@@ -0,0 +1,79 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /** Local copies kept per workspace. Older ones are removed; the remote snapshots stay. */
4
+ const LOCAL_COPIES_KEPT = 3;
5
+ const saves = new WeakMap();
6
+ /** Kept in memory: after a restart the first save writes a new checkpoint and uploads again. */
7
+ export function workspaceSaves(state, workspaceId) {
8
+ let byWorkspace = saves.get(state);
9
+ if (!byWorkspace) {
10
+ byWorkspace = new Map();
11
+ saves.set(state, byWorkspace);
12
+ }
13
+ let entry = byWorkspace.get(workspaceId);
14
+ if (!entry) {
15
+ entry = { uploaded: new Set(), transcripts: new Map(), local: [], queue: Promise.resolve() };
16
+ byWorkspace.set(workspaceId, entry);
17
+ }
18
+ return entry;
19
+ }
20
+ /**
21
+ * Saves of one workspace run one after another. Overlapping saves (the page's timer, Runtime's
22
+ * save after a change, the Save button) would otherwise finish in any order, and the last to finish
23
+ * becomes the recovery point even when it captured the older tree.
24
+ */
25
+ export function oneSaveAtATime(state, workspaceId, run) {
26
+ const entry = workspaceSaves(state, workspaceId);
27
+ const next = entry.queue.then(run, run);
28
+ entry.queue = next.then(() => undefined, () => undefined);
29
+ return next;
30
+ }
31
+ export function sessionsKey(sessions) {
32
+ return JSON.stringify([...sessions]
33
+ .sort((left, right) => left.sessionId.localeCompare(right.sessionId))
34
+ .map((item) => [item.sessionId, item.nativeThreadId ?? null, item.nativeDurableTurnId ?? null, item.recoverability]));
35
+ }
36
+ /**
37
+ * The checkpoint a save can answer with instead of writing a new one: same files on disk, same
38
+ * native session state, and the journal still has it.
39
+ */
40
+ export function unchangedCheckpoint(state, workspaceId, digest, sessions) {
41
+ const last = workspaceSaves(state, workspaceId).last;
42
+ if (!last || last.digest !== digest || last.sessions !== sessionsKey(sessions))
43
+ return undefined;
44
+ const checkpoint = state.journal.getCheckpoint(last.checkpointId);
45
+ return checkpoint?.workspaceId === workspaceId ? checkpoint : undefined;
46
+ }
47
+ export async function rememberFileCheckpoint(state, workspaceId, saved) {
48
+ const entry = workspaceSaves(state, workspaceId);
49
+ entry.last = { checkpointId: saved.checkpointId, digest: saved.digest, sessions: sessionsKey(saved.sessions) };
50
+ entry.local.push(saved.checkpointId);
51
+ // The journal's current file and consistent checkpoints keep their copy until they are replaced.
52
+ const kept = new Set([state.journal.fileCheckpointId(), state.journal.consistentCheckpointId()]);
53
+ const removable = entry.local
54
+ .slice(0, Math.max(0, entry.local.length - LOCAL_COPIES_KEPT))
55
+ .filter((checkpointId) => !kept.has(checkpointId));
56
+ entry.local = entry.local.filter((checkpointId) => !removable.includes(checkpointId));
57
+ for (const checkpointId of removable) {
58
+ await rm(join(state.config.stateDir, 'file-checkpoints', checkpointId), { recursive: true, force: true }).catch(() => undefined);
59
+ }
60
+ }
61
+ /**
62
+ * The control credential goes only to Runtime's own object endpoint, never to another store.
63
+ */
64
+ export function objectStoreHeaders(state, storeUrl) {
65
+ try {
66
+ if (new URL(storeUrl).origin !== new URL(state.config.callbackUrl).origin)
67
+ return {};
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ return {
73
+ authorization: `Bearer ${state.config.controlToken}`,
74
+ 'playcraft-env': state.config.environment,
75
+ 'x-playcraft-runtime-id': state.config.runtimeId,
76
+ 'x-playcraft-generation': String(state.config.generation),
77
+ 'x-playcraft-runtime-epoch': String(state.runtimeEpoch),
78
+ };
79
+ }
@@ -1,7 +1,75 @@
1
1
  import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
- import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotContentKey, nativeTranscriptKey, fileSnapshotManifestSchema, hashSnapshotBytes, isDisposableBackupPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
4
5
  const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
6
+ /** Build output, caches and scratch folders, at any depth: regenerated, so not kept in storage. */
7
+ const UNSTORED_DIRECTORY_NAMES = new Set([
8
+ 'dist',
9
+ 'dist-all',
10
+ 'coverage',
11
+ '.vite',
12
+ '.cache',
13
+ '.turbo',
14
+ '.parcel-cache',
15
+ '.sass-cache',
16
+ '.next',
17
+ '.nuxt',
18
+ '.svelte-kit',
19
+ 'tmp',
20
+ '.tmp',
21
+ '.temp',
22
+ ]);
23
+ /** Scratch and cache files, at any depth. */
24
+ const UNSTORED_FILE = /(?:\.tmp|\.log|\.tsbuildinfo|^\.eslintcache)$/;
25
+ /**
26
+ * At the workspace root: the preview build's output, the builder's scratch folder, and skill
27
+ * artifacts the sandbox pulls from the registry by `.playcraft/skills.lock.json`.
28
+ */
29
+ const UNSTORED_ROOT_DIRECTORIES = [
30
+ '.playcraft-preview-out/',
31
+ '.playcraft-temp/',
32
+ '.playcraft/skills/',
33
+ 'ta-workspace/logs/',
34
+ ];
35
+ /**
36
+ * Recordings, frames and reports the template's verify and record scripts write again on each run.
37
+ * The ring JSON evidence the build gate reads stays, as do the agent-edited profile and catalogs.
38
+ */
39
+ const UNSTORED_RUN_OUTPUT = [
40
+ /^recordings\/(?:.+\.(?:mp4|webm)$|\.tmp-[^/]+\/|focus\/|clean\/)/,
41
+ /^validate\/(?:[^/]+\.md$|(?:ring\d+\/)?frames\/|ring\d+\/[^/]+\.mp4$)/,
42
+ ];
43
+ /**
44
+ * Whether a path goes to checkpoint storage. Credentials, lock files, build output, caches and
45
+ * scratch files stay in the instance: its local copy keeps them, so restoring inside the instance
46
+ * does not remove them.
47
+ */
48
+ export function keptInStorage(path) {
49
+ if (isDisposableBackupPath(path))
50
+ return false;
51
+ if (path.startsWith('.git/'))
52
+ return true;
53
+ if (UNSTORED_ROOT_DIRECTORIES.some((prefix) => path.startsWith(prefix)))
54
+ return false;
55
+ if (UNSTORED_RUN_OUTPUT.some((pattern) => pattern.test(path)))
56
+ return false;
57
+ const segments = path.split('/');
58
+ if (UNSTORED_FILE.test(segments[segments.length - 1]))
59
+ return false;
60
+ return !segments.slice(0, -1).some((segment) => UNSTORED_DIRECTORY_NAMES.has(segment));
61
+ }
62
+ /**
63
+ * Under `.git/`, what a `local` capture leaves to the remote: packs and their indexes, submodule
64
+ * repositories (`git submodule update` fetches them again), LFS objects, and template hooks.
65
+ */
66
+ const REMOTE_GIT_DIRECTORIES = new Set([
67
+ '.git/objects/pack',
68
+ '.git/objects/info',
69
+ '.git/modules',
70
+ '.git/lfs',
71
+ '.git/hooks',
72
+ ]);
5
73
  const READ_ATTEMPTS = 3;
6
74
  const realIo = {
7
75
  async readdir(path) {
@@ -13,13 +81,28 @@ const realIo = {
13
81
  readFile,
14
82
  readlink,
15
83
  };
16
- export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
84
+ export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo, options = {}) {
17
85
  const entries = [];
18
86
  const files = [];
19
87
  const bodies = new Map();
20
88
  const seen = new Set();
21
89
  let status = 'complete';
22
- await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
90
+ const skip = (relativePath, name, directory) => {
91
+ if (name === '.' || name === '..')
92
+ return true;
93
+ if (directory && name === '.git')
94
+ return !(options.git && relativePath === '.git');
95
+ // A submodule's `.git` file points into `.git/modules`, which is not kept either.
96
+ if (!directory && name === '.git' && relativePath !== '.git')
97
+ return true;
98
+ if (directory && SKIP_DIRECTORY_NAMES.has(name))
99
+ return true;
100
+ if (directory && options.git === 'local' && REMOTE_GIT_DIRECTORIES.has(relativePath))
101
+ return true;
102
+ // Git's own lock files only exist while a command runs and must not come back with a restore.
103
+ return !directory && relativePath.startsWith('.git/') && name.endsWith('.lock');
104
+ };
105
+ await walk(root, root, io, revisions, entries, files, bodies, seen, skip, () => {
23
106
  status = 'incomplete';
24
107
  });
25
108
  for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
@@ -62,6 +145,12 @@ export async function materializeRemoteSnapshot(root, entries) {
62
145
  if (hashSnapshotBytes(body) !== entry.contentHash)
63
146
  throw new Error('snapshot_mismatch');
64
147
  }
148
+ if (applicable.some((entry) => entry.path === '.git/HEAD')) {
149
+ const bundle = applicable.find((entry) => entry.path === UNPUSHED_BUNDLE && entry.kind === 'file');
150
+ if (bundle)
151
+ await addBundleObjects(rootReal, Buffer.from(bundle.contentBase64 ?? '', 'base64'));
152
+ await clearClonedGitMetadata(rootReal);
153
+ }
65
154
  for (const entry of applicable) {
66
155
  const target = resolve(rootReal, ...entry.path.split('/'));
67
156
  const rel = relative(rootReal, target);
@@ -85,50 +174,100 @@ export async function materializeRemoteSnapshot(root, entries) {
85
174
  if (entry.mode !== undefined)
86
175
  await chmod(target, entry.mode);
87
176
  }
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
177
  return {
97
178
  skipped,
98
179
  directoryReady: true,
99
- repositoryReady,
180
+ repositoryReady: await repositoryResolves(rootReal),
100
181
  environmentReady: false,
101
182
  };
102
183
  }
184
+ /**
185
+ * The snapshot's `.git` replaces the one a new clone made, objects aside: a ref the clone wrote
186
+ * would otherwise outrank the snapshot's packed refs.
187
+ */
188
+ async function clearClonedGitMetadata(rootReal) {
189
+ const gitDir = join(rootReal, '.git');
190
+ const info = await lstat(gitDir).catch(() => undefined);
191
+ if (!info?.isDirectory())
192
+ return;
193
+ for (const name of await readdir(gitDir)) {
194
+ if (name !== 'objects')
195
+ await rm(join(gitDir, name), { recursive: true, force: true });
196
+ }
197
+ }
198
+ /**
199
+ * Content goes to the workspace's shared objects, keyed by hash. A hash in `uploaded` was stored by
200
+ * this process before and is not sent again; hashes stored now are added to it. The manifest still
201
+ * lists every stored file, so each snapshot restores on its own. Paths not `keptInStorage` are left
202
+ * out of both the objects and the manifest.
203
+ */
103
204
  export async function publishFileSnapshot(input) {
104
- for (const entry of input.tree.entries) {
205
+ const stored = new Set();
206
+ const entries = input.tree.entries.filter((entry) => keptInStorage(entry.path));
207
+ for (const entry of entries) {
105
208
  if (entry.kind !== 'file' || !entry.contentHash)
106
209
  continue;
210
+ if (input.uploaded?.has(entry.contentHash) || stored.has(entry.contentHash))
211
+ continue;
107
212
  const body = input.tree.bodies.get(entry.path);
108
213
  if (!body || hashSnapshotBytes(body) !== entry.contentHash)
109
214
  throw new Error('snapshot_body_missing');
110
- await putObjectBytes(input.baseUrl, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli');
215
+ await putObjectBytes(input.baseUrl, fileSnapshotContentKey(input.workspaceId, entry.contentHash), body, 'cli', input.headers);
216
+ stored.add(entry.contentHash);
217
+ }
218
+ const native = [];
219
+ for (const transcript of input.native ?? []) {
220
+ const contentHash = hashSnapshotBytes(transcript.body);
221
+ if (input.uploadedTranscripts?.get(transcript.path) !== contentHash) {
222
+ await putObjectBytes(input.baseUrl, nativeTranscriptKey(input.workspaceId, transcript.path), transcript.body, 'cli', input.headers);
223
+ input.uploadedTranscripts?.set(transcript.path, contentHash);
224
+ }
225
+ native.push({
226
+ sessionId: transcript.sessionId,
227
+ threadId: transcript.threadId,
228
+ path: transcript.path,
229
+ contentHash,
230
+ size: transcript.body.byteLength,
231
+ capturedAt: transcript.capturedAt,
232
+ });
111
233
  }
112
- const digest = fileSnapshotDigest(input.tree.entries);
234
+ const digest = fileSnapshotDigest(entries);
113
235
  const manifest = fileSnapshotManifestSchema.parse({
114
236
  snapshotId: input.snapshotId,
115
237
  workspaceId: input.workspaceId,
116
238
  generation: input.generation,
117
239
  status: input.tree.status,
118
- entries: input.tree.entries,
240
+ entries,
119
241
  digest,
242
+ objectLayout: 'workspace',
243
+ ...(native.length > 0 ? { native } : {}),
120
244
  });
121
245
  const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
122
- await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli');
246
+ await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
247
+ for (const hash of stored)
248
+ input.uploaded?.add(hash);
123
249
  return { manifestKey, checksum: digest, status: input.tree.status };
124
250
  }
125
- async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
251
+ /**
252
+ * Digest of what is on disk now, without the deletions recorded against the previous snapshot.
253
+ * Transcripts count too: a turn that only talked still changes what a restore can resume.
254
+ */
255
+ export function liveTreeDigest(tree, native = []) {
256
+ const files = fileSnapshotDigest(tree.entries.filter((entry) => entry.kind !== 'deleted'));
257
+ if (native.length === 0)
258
+ return files;
259
+ const transcripts = native
260
+ .map((item) => `${item.sessionId}:${item.threadId}:${item.path}:${hashSnapshotBytes(item.body)}`)
261
+ .sort();
262
+ return hashSnapshotBytes(Buffer.from([files, ...transcripts].join('\n')));
263
+ }
264
+ async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, skip, markIncomplete) {
126
265
  const listed = await io.readdir(current);
127
266
  for (const entry of listed) {
128
- if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
129
- continue;
130
267
  const child = join(current, entry.name);
131
268
  const relativePath = toPosixRelative(rootReal, child);
269
+ if (skip(relativePath, entry.name, entry.isDirectory()))
270
+ continue;
132
271
  if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
133
272
  markIncomplete();
134
273
  continue;
@@ -145,7 +284,7 @@ async function walk(rootReal, current, io, revisions, entries, files, bodies, se
145
284
  continue;
146
285
  }
147
286
  if (entry.isDirectory()) {
148
- await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
287
+ await walk(rootReal, child, io, revisions, entries, files, bodies, seen, skip, markIncomplete);
149
288
  continue;
150
289
  }
151
290
  if (!entry.isFile())
@@ -77,6 +77,8 @@ export class WorkspaceGitCredentialBroker {
77
77
  }
78
78
  async configure(binding, preparation) {
79
79
  preparation = workspacePreparationSchema.parse(preparation);
80
+ if (binding.targetKind === 'standalone' || preparation.mode !== 'clone')
81
+ throw new Error('git_access_denied');
80
82
  const transport = preparation.transport;
81
83
  const isDirect = isLocalWorkspaceGitAuthMode(preparation.authMode);
82
84
  if (!isDirect && transport?.kind !== 'git-gateway-v2')
@@ -118,7 +120,7 @@ export class WorkspaceGitCredentialBroker {
118
120
  || typeof input.path !== 'string')
119
121
  throw new Error('git_access_denied');
120
122
  const binding = this.state.journal.getWorkspace(input.workspaceId);
121
- if (!binding)
123
+ if (!binding || binding.targetKind === 'standalone')
122
124
  throw new Error('git_access_denied');
123
125
  const cloneUrl = `${input.protocol}://${input.host}/${input.path}`;
124
126
  const direct = await this.direct.get(binding);
@@ -177,6 +179,8 @@ export class WorkspaceGitCredentialBroker {
177
179
  throw new Error('git_broker_unavailable');
178
180
  }
179
181
  const preparation = workspacePreparationSchema.parse(await response.json());
182
+ if (preparation.mode !== 'clone')
183
+ throw new Error('git_access_denied');
180
184
  const matched = preparation.transport && matchWorkspaceGitGatewayCloneUrl(preparation.transport.cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
181
185
  if (this.closed || this.state.journal.destroyed() || this.state.runtimeEpoch !== epoch
182
186
  || JSON.stringify(this.state.journal.getWorkspace(binding.workspaceId)) !== JSON.stringify(binding)
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
2
+ import { lstat, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
5
  import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -245,6 +245,96 @@ export async function pushWorkspaceGit(root, boundBranch) {
245
245
  catch { /* Preserve uncertainty; never replay a write automatically. */ }
246
246
  throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
247
247
  }
248
+ const UNPUSHED_DIRECTORY = '.git/playcraft';
249
+ export const UNPUSHED_BUNDLE = `${UNPUSHED_DIRECTORY}/unpushed.bundle`;
250
+ const UNPUSHED_TIPS = `${UNPUSHED_DIRECTORY}/unpushed.tips`;
251
+ /**
252
+ * Keeps a bundle of the commits on local branches and the latest stash that no remote-tracking
253
+ * branch holds, so a backup without the packs still has them. It is rewritten only when one of
254
+ * those refs moves, and removed when nothing is unpushed. Without remote-tracking refs there is no
255
+ * baseline, and the whole history is left out rather than bundled.
256
+ */
257
+ export async function bundleUnpushedCommits(root) {
258
+ const rootReal = await realpath(root);
259
+ const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
260
+ if (!marker?.isDirectory())
261
+ return;
262
+ const tips = await git(rootReal, [
263
+ 'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes', 'refs/stash',
264
+ ]);
265
+ const tipsPath = join(rootReal, UNPUSHED_TIPS);
266
+ if ((await readFile(tipsPath, 'utf8').catch(() => undefined)) === tips)
267
+ return;
268
+ const refs = tips.split('\n').map((line) => line.split(' ')[1]).filter((ref) => Boolean(ref));
269
+ const local = ['--branches', ...(refs.includes('refs/stash') ? ['refs/stash'] : [])];
270
+ const unpushed = refs.some((ref) => ref.startsWith('refs/remotes/'))
271
+ ? Number.parseInt((await git(rootReal, ['rev-list', '--count', ...local, '--not', '--remotes'], 60000)).trim(), 10)
272
+ : 0;
273
+ const bundlePath = join(rootReal, UNPUSHED_BUNDLE);
274
+ await mkdir(join(rootReal, UNPUSHED_DIRECTORY), { recursive: true, mode: 0o700 });
275
+ if (unpushed > 0) {
276
+ // The `.lock` name keeps a half-written bundle out of any snapshot.
277
+ const temp = `${bundlePath}.lock`;
278
+ try {
279
+ await git(rootReal, ['bundle', 'create', temp, ...local, '--not', '--remotes'], 120000);
280
+ await rename(temp, bundlePath);
281
+ }
282
+ finally {
283
+ await rm(temp, { force: true });
284
+ }
285
+ }
286
+ else {
287
+ await rm(bundlePath, { force: true });
288
+ }
289
+ await writeFile(tipsPath, tips, { mode: 0o600 });
290
+ }
291
+ /**
292
+ * Adds a bundle's objects to the repository. `unbundle` walks history from the current refs, so this
293
+ * runs on the fresh clone before the snapshot's refs, which point at these very commits, replace its
294
+ * own. It fails when the remote lost the commits the bundle was built on; `repositoryResolves` then
295
+ * reports what is missing.
296
+ */
297
+ export async function addBundleObjects(root, body) {
298
+ const rootReal = await realpath(root);
299
+ const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
300
+ if (!marker?.isDirectory())
301
+ return;
302
+ const temp = join(rootReal, '.git', 'playcraft-restore.bundle.lock');
303
+ try {
304
+ await writeFile(temp, body, { mode: 0o600 });
305
+ await gitAllowFail(rootReal, ['bundle', 'unbundle', temp], 120000);
306
+ }
307
+ catch {
308
+ /* Reported by repositoryResolves. */
309
+ }
310
+ finally {
311
+ await rm(temp, { force: true });
312
+ }
313
+ }
314
+ /** Whether HEAD and the index resolve to objects the repository holds. */
315
+ export async function repositoryResolves(root) {
316
+ const rootReal = await realpath(root);
317
+ const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
318
+ if (!marker?.isDirectory())
319
+ return false;
320
+ try {
321
+ // Snapshots hold files only; Git does not recognise a repository whose refs folder is gone.
322
+ for (const directory of ['refs/heads', 'refs/tags', 'objects']) {
323
+ await mkdir(join(rootReal, '.git', directory), { recursive: true, mode: 0o755 });
324
+ }
325
+ const head = await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{tree}']);
326
+ if (head.code === 0) {
327
+ const index = await gitAllowFail(rootReal, ['diff-index', '--cached', '--quiet', 'HEAD'], 60000);
328
+ return index.code === 0 || index.code === 1;
329
+ }
330
+ // Before the first commit there is no branch to resolve.
331
+ const branches = await gitAllowFail(rootReal, ['for-each-ref', '--count=1', 'refs/heads']);
332
+ return branches.code === 0 && branches.stdout.trim() === '';
333
+ }
334
+ catch {
335
+ return false;
336
+ }
337
+ }
248
338
  export async function ensureWorkspaceGit(root) {
249
339
  const rootReal = await realpath(root);
250
340
  if (!(await gitMarkerExists(rootReal))) {