@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,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 = [];
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { excludeFromGit } from './dependencies.js';
3
- import { lstat, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
6
  import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -246,95 +246,81 @@ export async function pushWorkspaceGit(root, boundBranch) {
246
246
  catch { /* Preserve uncertainty; never replay a write automatically. */ }
247
247
  throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
248
248
  }
249
- const UNPUSHED_DIRECTORY = '.git/playcraft';
250
- export const UNPUSHED_BUNDLE = `${UNPUSHED_DIRECTORY}/unpushed.bundle`;
251
- const UNPUSHED_TIPS = `${UNPUSHED_DIRECTORY}/unpushed.tips`;
252
249
  /**
253
- * Keeps a bundle of the commits on local branches and the latest stash that no remote-tracking
254
- * branch holds, so a backup without the packs still has them. It is rewritten only when one of
255
- * those refs moves, and removed when nothing is unpushed. Without remote-tracking refs there is no
256
- * baseline, and the whole history is left out rather than bundled.
250
+ * Credential files a save never commits, even when `.gitignore` misses them: env files (their
251
+ * `.example` / `.sample` / `.template` stay), SSH keys, netrc and Git credential stores, and key
252
+ * or certificate bundles.
257
253
  */
258
- export async function bundleUnpushedCommits(root) {
259
- const rootReal = await realpath(root);
260
- const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
261
- if (!marker?.isDirectory())
262
- return;
263
- const tips = await git(rootReal, [
264
- 'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes', 'refs/stash',
265
- ]);
266
- const tipsPath = join(rootReal, UNPUSHED_TIPS);
267
- if ((await readFile(tipsPath, 'utf8').catch(() => undefined)) === tips)
268
- return;
269
- const refs = tips.split('\n').map((line) => line.split(' ')[1]).filter((ref) => Boolean(ref));
270
- const local = ['--branches', ...(refs.includes('refs/stash') ? ['refs/stash'] : [])];
271
- const unpushed = refs.some((ref) => ref.startsWith('refs/remotes/'))
272
- ? Number.parseInt((await git(rootReal, ['rev-list', '--count', ...local, '--not', '--remotes'], 60000)).trim(), 10)
273
- : 0;
274
- const bundlePath = join(rootReal, UNPUSHED_BUNDLE);
275
- await mkdir(join(rootReal, UNPUSHED_DIRECTORY), { recursive: true, mode: 0o700 });
276
- if (unpushed > 0) {
277
- // The `.lock` name keeps a half-written bundle out of any snapshot.
278
- const temp = `${bundlePath}.lock`;
279
- try {
280
- await git(rootReal, ['bundle', 'create', temp, ...local, '--not', '--remotes'], 120000);
281
- await rename(temp, bundlePath);
282
- }
283
- finally {
284
- await rm(temp, { force: true });
285
- }
286
- }
287
- else {
288
- await rm(bundlePath, { force: true });
289
- }
290
- await writeFile(tipsPath, tips, { mode: 0o600 });
254
+ const CREDENTIAL_FILE = /^(?:\.env(?:\.(?!(?:example|sample|template)$)[^/]+)?|id_(?:rsa|dsa|ecdsa|ed25519)|\.netrc|\.git-credentials|[^/]*\.(?:pem|key|p12|pfx))$/i;
255
+ export function isCredentialFile(path) {
256
+ return CREDENTIAL_FILE.test(path.split('/').pop() ?? '');
291
257
  }
292
258
  /**
293
- * Adds a bundle's objects to the repository. `unbundle` walks history from the current refs, so this
294
- * runs on the fresh clone before the snapshot's refs, which point at these very commits, replace its
295
- * own. It fails when the remote lost the commits the bundle was built on; `repositoryResolves` then
296
- * reports what is missing.
259
+ * Commits every change in the work tree (`git add -A`, credential files aside) and, when asked,
260
+ * pushes the branch. A save with nothing new commits nothing and pushes only what the remote
261
+ * lacks, so a commit whose push failed goes out with the next save.
297
262
  */
298
- export async function addBundleObjects(root, body) {
299
- const rootReal = await realpath(root);
300
- const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
301
- if (!marker?.isDirectory())
263
+ export async function saveWorkspaceGit(root, input) {
264
+ const rootReal = await ensureWorkspaceGit(root);
265
+ await assertBoundBranch(rootReal, input.boundBranch);
266
+ await git(rootReal, ['add', '-A'], 60000);
267
+ const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
268
+ const withheld = staged.filter(isCredentialFile);
269
+ await unstage(rootReal, withheld);
270
+ const cached = await gitAllowFail(rootReal, ['diff', '--cached', '--quiet'], 60000);
271
+ if (cached.code !== 0 && cached.code !== 1) {
272
+ throw new GitWorkspaceError('git_command_failed', cached.stderr.trim() || 'git_diff_failed');
273
+ }
274
+ let commit;
275
+ if (cached.code === 1) {
276
+ await git(rootReal, [...(await commitIdentity(rootReal)), 'commit', '-q', '-m', input.message], 60000);
277
+ commit = (await git(rootReal, ['rev-parse', 'HEAD'])).trim();
278
+ }
279
+ if (!input.push || (await remoteHasHead(rootReal)))
280
+ return { ...(commit ? { commit } : {}), pushed: false, withheld };
281
+ await pushWorkspaceGit(rootReal, input.boundBranch);
282
+ await recordPushedHead(rootReal);
283
+ return { ...(commit ? { commit } : {}), pushed: true, withheld };
284
+ }
285
+ async function unstage(rootReal, paths) {
286
+ if (paths.length === 0)
302
287
  return;
303
- const temp = join(rootReal, '.git', 'playcraft-restore.bundle.lock');
304
- try {
305
- await writeFile(temp, body, { mode: 0o600 });
306
- await gitAllowFail(rootReal, ['bundle', 'unbundle', temp], 120000);
307
- }
308
- catch {
309
- /* Reported by repositoryResolves. */
310
- }
311
- finally {
312
- await rm(temp, { force: true });
288
+ const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD'])).code === 0;
289
+ for (let start = 0; start < paths.length; start += 100) {
290
+ const chunk = paths.slice(start, start + 100);
291
+ // Before the first commit there is no HEAD to reset to; dropping the entries does the same.
292
+ await git(rootReal, born ? ['reset', '-q', '--', ...chunk] : ['rm', '-q', '--cached', '--', ...chunk]);
313
293
  }
314
294
  }
315
- /** Whether HEAD and the index resolve to objects the repository holds. */
316
- export async function repositoryResolves(root) {
317
- const rootReal = await realpath(root);
318
- const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
319
- if (!marker?.isDirectory())
320
- return false;
321
- try {
322
- // Snapshots hold files only; Git does not recognise a repository whose refs folder is gone.
323
- for (const directory of ['refs/heads', 'refs/tags', 'objects']) {
324
- await mkdir(join(rootReal, '.git', directory), { recursive: true, mode: 0o755 });
325
- }
326
- const head = await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{tree}']);
327
- if (head.code === 0) {
328
- const index = await gitAllowFail(rootReal, ['diff-index', '--cached', '--quiet', 'HEAD'], 60000);
329
- return index.code === 0 || index.code === 1;
330
- }
331
- // Before the first commit there is no branch to resolve.
332
- const branches = await gitAllowFail(rootReal, ['for-each-ref', '--count=1', 'refs/heads']);
333
- return branches.code === 0 && branches.stdout.trim() === '';
334
- }
335
- catch {
295
+ /** A clone carries no committer; the sandbox image sets none either. */
296
+ async function commitIdentity(rootReal) {
297
+ const email = await gitAllowFail(rootReal, ['config', 'user.email']);
298
+ if (email.code === 0 && email.stdout.trim())
299
+ return [];
300
+ return ['-c', 'user.name=PlayCraft Workspace', '-c', 'user.email=workspace@playcraft.local'];
301
+ }
302
+ /**
303
+ * The remote-tracking ref of the current branch. A fork clone has no fetch refspec for its own
304
+ * branch, so the ref is written here after a confirmed push.
305
+ */
306
+ async function trackingRef(rootReal) {
307
+ const branch = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
308
+ return branch ? `refs/remotes/origin/${branch}` : undefined;
309
+ }
310
+ async function remoteHasHead(rootReal) {
311
+ const ref = await trackingRef(rootReal);
312
+ if (!ref)
336
313
  return false;
337
- }
314
+ const [head, tracked] = await Promise.all([
315
+ gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}']),
316
+ gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', `${ref}^{commit}`]),
317
+ ]);
318
+ return head.code === 0 && tracked.code === 0 && head.stdout.trim() === tracked.stdout.trim();
319
+ }
320
+ async function recordPushedHead(rootReal) {
321
+ const ref = await trackingRef(rootReal);
322
+ if (ref)
323
+ await gitAllowFail(rootReal, ['update-ref', ref, 'HEAD']);
338
324
  }
339
325
  export async function ensureWorkspaceGit(root) {
340
326
  const rootReal = await realpath(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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.17",
52
- "@playcraft/common": "^0.1.17",
51
+ "@playcraft/build": "^0.1.19",
52
+ "@playcraft/common": "^0.1.19",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",
@@ -1,94 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { readdir, readFile, stat } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { startLauncherProcess } from './launcher-client.js';
5
- export const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
6
- const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
7
- const TRANSCRIPT_PATH = /^sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[A-Za-z0-9_.-]{1,200}\.jsonl$/;
8
- const COMMAND_TIMEOUT_MS = 60_000;
9
- /**
10
- * Reads the transcript Codex keeps for one thread. Codex appends to it while a turn runs, so a
11
- * trailing partial line is dropped; what is returned always parses line by line.
12
- */
13
- export async function exportTranscript(home, threadId) {
14
- if (!THREAD_ID.test(threadId))
15
- return undefined;
16
- const found = home.launcher
17
- ? await exportAsDeveloper(home.homeDir, home.launcher, threadId)
18
- : await exportDirect(home.homeDir, threadId);
19
- if (!found || !transcriptPathFor(found.path, threadId))
20
- return undefined;
21
- const end = found.body.lastIndexOf(0x0a);
22
- if (end < 0)
23
- return undefined;
24
- return { path: found.path, body: found.body.subarray(0, end + 1) };
25
- }
26
- function transcriptPathFor(path, threadId) {
27
- return TRANSCRIPT_PATH.test(path) && path.endsWith(`-${threadId}.jsonl`);
28
- }
29
- async function exportDirect(homeDir, threadId) {
30
- const suffix = `-${threadId}.jsonl`;
31
- for (const year of await names(join(homeDir, 'sessions'))) {
32
- for (const month of await names(join(homeDir, 'sessions', year))) {
33
- for (const day of await names(join(homeDir, 'sessions', year, month))) {
34
- const directory = join('sessions', year, month, day);
35
- const match = (await names(join(homeDir, directory))).find((name) => name.endsWith(suffix));
36
- if (!match)
37
- continue;
38
- const path = `${directory}/${match}`;
39
- const absolute = join(homeDir, path);
40
- if ((await stat(absolute)).size > TRANSCRIPT_MAX_BYTES)
41
- return undefined;
42
- return { path, body: await readFile(absolute) };
43
- }
44
- }
45
- }
46
- return undefined;
47
- }
48
- async function exportAsDeveloper(homeDir, launcher, threadId) {
49
- const limitKib = TRANSCRIPT_MAX_BYTES / 1024;
50
- const outcome = await runAsDeveloper(launcher, [
51
- `home=${quote(homeDir)}`,
52
- `f=$(find "$home/sessions" -mindepth 4 -maxdepth 4 -type f -name ${quote(`rollout-*-${threadId}.jsonl`)} -size -${limitKib}k 2>/dev/null | head -n 1)`,
53
- '[ -n "$f" ] || exit 3',
54
- 'printf \'%s\\n\' "${f#"$home"/}"',
55
- 'base64 -w 0 "$f"',
56
- ].join('; '));
57
- if (outcome.code !== 0)
58
- return undefined;
59
- const text = outcome.stdout.toString('utf8');
60
- const newline = text.indexOf('\n');
61
- if (newline < 0)
62
- return undefined;
63
- return { path: text.slice(0, newline), body: Buffer.from(text.slice(newline + 1).trim(), 'base64') };
64
- }
65
- async function runAsDeveloper(launcher, command) {
66
- const child = await startLauncherProcess({
67
- socketPath: launcher.socketPath,
68
- processId: `transcript-${randomUUID()}`,
69
- runtimeEpoch: launcher.runtimeEpoch,
70
- argv: ['/usr/bin/bash', '-c', `{ ${command}; } </dev/null 2>/dev/null`],
71
- cwd: launcher.cwd,
72
- env: {},
73
- });
74
- child.stdin?.end();
75
- const chunks = [];
76
- child.stdout?.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
77
- const code = await new Promise((resolve) => {
78
- const timer = setTimeout(() => {
79
- void child.stop().catch(() => undefined);
80
- resolve(null);
81
- }, COMMAND_TIMEOUT_MS);
82
- child.onExit((value) => {
83
- clearTimeout(timer);
84
- resolve(value);
85
- });
86
- });
87
- return { code, stdout: Buffer.concat(chunks) };
88
- }
89
- async function names(directory) {
90
- return readdir(directory).then((entries) => entries.sort(), () => []);
91
- }
92
- function quote(value) {
93
- return `'${value.replaceAll("'", "'\\''")}'`;
94
- }
@@ -1,70 +0,0 @@
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
- /** Checkpoint uploads always go to this deployment, so they always carry the control credential. */
62
- export function objectStoreHeaders(state) {
63
- return {
64
- authorization: `Bearer ${state.config.controlToken}`,
65
- 'playcraft-env': state.config.environment,
66
- 'x-playcraft-runtime-id': state.config.runtimeId,
67
- 'x-playcraft-generation': String(state.config.generation),
68
- 'x-playcraft-runtime-epoch': String(state.runtimeEpoch),
69
- };
70
- }