@playcraft/cli 0.1.17 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/workspace-runtime/codex/app-server.js +0 -16
- package/dist/workspace-runtime/server/dispatch.js +54 -69
- package/dist/workspace-runtime/workspaces/file-snapshot.js +20 -161
- package/dist/workspace-runtime/workspaces/git.js +67 -81
- package/package.json +3 -3
- package/dist/workspace-runtime/codex/transcripts.js +0 -94
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +0 -70
|
@@ -8,7 +8,6 @@ import { portableCodexConfig, writePortableCodexCatalog } from './config-toml.js
|
|
|
8
8
|
import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
|
|
9
9
|
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
10
10
|
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
11
|
-
import { exportTranscript } from './transcripts.js';
|
|
12
11
|
import { readProjectSkillsLockFile } from '@playcraft/common/project-skills';
|
|
13
12
|
import { freezeWorkspaceSkills } from '../workspaces/skills.js';
|
|
14
13
|
function asRecord(value) {
|
|
@@ -75,18 +74,6 @@ export function createCodexAppServerAdapter(options) {
|
|
|
75
74
|
release();
|
|
76
75
|
}
|
|
77
76
|
}
|
|
78
|
-
function transcriptHome() {
|
|
79
|
-
if (!launcherSocket)
|
|
80
|
-
return { homeDir };
|
|
81
|
-
return {
|
|
82
|
-
homeDir,
|
|
83
|
-
launcher: {
|
|
84
|
-
socketPath: launcherSocket,
|
|
85
|
-
runtimeEpoch: executionEpoch,
|
|
86
|
-
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
77
|
async function ensureStarted() {
|
|
91
78
|
if (closed)
|
|
92
79
|
throw new Error('codex_not_started');
|
|
@@ -436,9 +423,6 @@ export function createCodexAppServerAdapter(options) {
|
|
|
436
423
|
return undefined;
|
|
437
424
|
}
|
|
438
425
|
},
|
|
439
|
-
exportThread(input) {
|
|
440
|
-
return exportTranscript(transcriptHome(), input.threadId);
|
|
441
|
-
},
|
|
442
426
|
async captureNativeCheckpoint(input) {
|
|
443
427
|
const thread = threads.get(input.threadId);
|
|
444
428
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
@@ -3,7 +3,7 @@ import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/r
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
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';
|
|
6
|
+
import { PLATFORM_ROUTES, platformEndpoint, checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
8
8
|
import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
|
|
9
9
|
import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
|
|
@@ -12,10 +12,9 @@ import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-s
|
|
|
12
12
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
13
13
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
14
14
|
import { disposeDesktopHost } from './desktop-host.js';
|
|
15
|
-
import { captureWorkspaceTree,
|
|
16
|
-
import { objectStoreHeaders, oneSaveAtATime, rememberFileCheckpoint, unchangedCheckpoint, workspaceSaves, } from '../workspaces/checkpoint-saves.js';
|
|
15
|
+
import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
|
|
17
16
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
18
|
-
import { GitWorkspaceError,
|
|
17
|
+
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
19
18
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
20
19
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
21
20
|
import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
|
|
@@ -38,6 +37,7 @@ const LOCAL_KINDS = new Set([
|
|
|
38
37
|
'git.stage',
|
|
39
38
|
'git.unstage',
|
|
40
39
|
'git.discard',
|
|
40
|
+
'workspace.save',
|
|
41
41
|
'terminal.create',
|
|
42
42
|
'parameter.patch',
|
|
43
43
|
'preview.publish',
|
|
@@ -89,8 +89,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
89
89
|
return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'workspace_not_ready' }));
|
|
90
90
|
}
|
|
91
91
|
if (operation.kind === 'checkpoint.create') {
|
|
92
|
-
|
|
93
|
-
return oneSaveAtATime(state, workspaceId, () => executeCheckpoint(state, scopeKey, current, payload));
|
|
92
|
+
return executeCheckpoint(state, scopeKey, current, payload);
|
|
94
93
|
}
|
|
95
94
|
if (operation.kind === 'checkpoint.restore') {
|
|
96
95
|
return executeRestore(state, scopeKey, current, payload);
|
|
@@ -110,6 +109,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
110
109
|
if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
|
|
111
110
|
return executeGitIndex(state, scopeKey, current, payload);
|
|
112
111
|
}
|
|
112
|
+
if (operation.kind === 'workspace.save') {
|
|
113
|
+
return executeWorkspaceSave(state, scopeKey, current, payload);
|
|
114
|
+
}
|
|
113
115
|
if (operation.kind === 'terminal.create') {
|
|
114
116
|
return executeTerminalCreate(state, scopeKey, current, payload);
|
|
115
117
|
}
|
|
@@ -353,9 +355,9 @@ async function executeApproval(state, scopeKey, operation, payload) {
|
|
|
353
355
|
}
|
|
354
356
|
async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
355
357
|
const body = asRecord(payload);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
358
|
+
const checkpointId = typeof body.checkpointId === 'string' && body.checkpointId.trim()
|
|
359
|
+
? body.checkpointId.trim()
|
|
360
|
+
: `ckpt_${randomUUID()}`;
|
|
359
361
|
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(checkpointId) || state.journal.getCheckpoint(checkpointId)) {
|
|
360
362
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'checkpoint_id_conflict' }));
|
|
361
363
|
}
|
|
@@ -387,29 +389,8 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
387
389
|
const revisions = state.journal.fileRevisions(revisionScope(state, operation.workspaceId));
|
|
388
390
|
let files;
|
|
389
391
|
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
392
|
if (state.config.checkpointObjects) {
|
|
393
|
+
const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions);
|
|
413
394
|
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
414
395
|
if (stillActive.length > 0) {
|
|
415
396
|
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
@@ -421,10 +402,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
421
402
|
workspaceId,
|
|
422
403
|
generation: state.config.generation,
|
|
423
404
|
tree,
|
|
424
|
-
|
|
425
|
-
uploaded: workspaceSaves(state, workspaceId).uploaded,
|
|
426
|
-
uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
|
|
427
|
-
headers: objectStoreHeaders(state),
|
|
405
|
+
headers: checkpointStoreHeaders(state),
|
|
428
406
|
});
|
|
429
407
|
}
|
|
430
408
|
catch {
|
|
@@ -485,26 +463,8 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
485
463
|
await state.journal.setFileCheckpointId(checkpointId);
|
|
486
464
|
if (nativeReady)
|
|
487
465
|
await state.journal.setConsistentCheckpointId(checkpointId);
|
|
488
|
-
if (tree.status === 'complete') {
|
|
489
|
-
await rememberFileCheckpoint(state, workspaceId, { checkpointId, digest, sessions: captured.sessions });
|
|
490
|
-
}
|
|
491
466
|
return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
|
|
492
467
|
}
|
|
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
468
|
async function executeRestore(state, scopeKey, operation, payload) {
|
|
509
469
|
const body = asRecord(payload);
|
|
510
470
|
const checkpointId = String(body.checkpointId ?? operation.checkpointId ?? '');
|
|
@@ -730,6 +690,37 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
730
690
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
731
691
|
}
|
|
732
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Saves the workspace to Git: commit everything that changed, then push a Remix branch. A
|
|
695
|
+
* standalone workspace has no remote, and the Git gateway never lets a workspace write its
|
|
696
|
+
* project's main branch, so those two only commit.
|
|
697
|
+
*/
|
|
698
|
+
async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
699
|
+
const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
|
|
700
|
+
if (!parsed.success) {
|
|
701
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_save' }));
|
|
702
|
+
}
|
|
703
|
+
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
704
|
+
try {
|
|
705
|
+
const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId), {
|
|
706
|
+
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
707
|
+
push: binding?.targetKind === 'remix',
|
|
708
|
+
boundBranch: boundBranch(state, operation),
|
|
709
|
+
});
|
|
710
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
711
|
+
await recordNativeEvents(state, accepted, [
|
|
712
|
+
{
|
|
713
|
+
type: 'workspace.saved',
|
|
714
|
+
payload: { ...(saved.commit ? { sha: saved.commit } : {}), pushed: saved.pushed, withheld: saved.withheld },
|
|
715
|
+
occurredAt: new Date().toISOString(),
|
|
716
|
+
},
|
|
717
|
+
]);
|
|
718
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
733
724
|
async function executeGitPush(state, scopeKey, operation, payload) {
|
|
734
725
|
const parsed = gitPushPayloadSchema.safeParse(payload ?? {});
|
|
735
726
|
if (!parsed.success) {
|
|
@@ -927,22 +918,6 @@ async function finishCheckpoint(state, scopeKey, operation, checkpoint, activeWr
|
|
|
927
918
|
...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
|
|
928
919
|
}));
|
|
929
920
|
}
|
|
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
921
|
async function captureNativeSessions(state, workspaceId) {
|
|
947
922
|
const live = workspaceSessions(state, workspaceId);
|
|
948
923
|
const sessions = [];
|
|
@@ -1093,6 +1068,16 @@ function nativeIds(params) {
|
|
|
1093
1068
|
itemId: typeof record.itemId === 'string' ? record.itemId : typeof record.approvalId === 'string' ? record.approvalId : undefined,
|
|
1094
1069
|
};
|
|
1095
1070
|
}
|
|
1071
|
+
/** The checkpoint route is on this deployment, so uploads carry the control credential. */
|
|
1072
|
+
function checkpointStoreHeaders(state) {
|
|
1073
|
+
return {
|
|
1074
|
+
authorization: `Bearer ${state.config.controlToken}`,
|
|
1075
|
+
'playcraft-env': state.config.environment,
|
|
1076
|
+
'x-playcraft-runtime-id': state.config.runtimeId,
|
|
1077
|
+
'x-playcraft-generation': String(state.config.generation),
|
|
1078
|
+
'x-playcraft-runtime-epoch': String(state.runtimeEpoch),
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1096
1081
|
async function notifyCallback(state, events) {
|
|
1097
1082
|
await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
1098
1083
|
method: 'POST',
|
|
@@ -1,77 +1,7 @@
|
|
|
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,
|
|
4
|
-
import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
|
|
3
|
+
import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
4
|
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 (isWorkspaceAttachmentPath(path))
|
|
50
|
-
return true;
|
|
51
|
-
if (isDisposableBackupPath(path))
|
|
52
|
-
return false;
|
|
53
|
-
if (path.startsWith('.git/'))
|
|
54
|
-
return true;
|
|
55
|
-
if (UNSTORED_ROOT_DIRECTORIES.some((prefix) => path.startsWith(prefix)))
|
|
56
|
-
return false;
|
|
57
|
-
if (UNSTORED_RUN_OUTPUT.some((pattern) => pattern.test(path)))
|
|
58
|
-
return false;
|
|
59
|
-
const segments = path.split('/');
|
|
60
|
-
if (UNSTORED_FILE.test(segments[segments.length - 1]))
|
|
61
|
-
return false;
|
|
62
|
-
return !segments.slice(0, -1).some((segment) => UNSTORED_DIRECTORY_NAMES.has(segment));
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Under `.git/`, what a `local` capture leaves to the remote: packs and their indexes, submodule
|
|
66
|
-
* repositories (`git submodule update` fetches them again), LFS objects, and template hooks.
|
|
67
|
-
*/
|
|
68
|
-
const REMOTE_GIT_DIRECTORIES = new Set([
|
|
69
|
-
'.git/objects/pack',
|
|
70
|
-
'.git/objects/info',
|
|
71
|
-
'.git/modules',
|
|
72
|
-
'.git/lfs',
|
|
73
|
-
'.git/hooks',
|
|
74
|
-
]);
|
|
75
5
|
const READ_ATTEMPTS = 3;
|
|
76
6
|
const realIo = {
|
|
77
7
|
async readdir(path) {
|
|
@@ -83,28 +13,13 @@ const realIo = {
|
|
|
83
13
|
readFile,
|
|
84
14
|
readlink,
|
|
85
15
|
};
|
|
86
|
-
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo
|
|
16
|
+
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
|
|
87
17
|
const entries = [];
|
|
88
18
|
const files = [];
|
|
89
19
|
const bodies = new Map();
|
|
90
20
|
const seen = new Set();
|
|
91
21
|
let status = 'complete';
|
|
92
|
-
|
|
93
|
-
if (name === '.' || name === '..')
|
|
94
|
-
return true;
|
|
95
|
-
if (directory && name === '.git')
|
|
96
|
-
return !(options.git && relativePath === '.git');
|
|
97
|
-
// A submodule's `.git` file points into `.git/modules`, which is not kept either.
|
|
98
|
-
if (!directory && name === '.git' && relativePath !== '.git')
|
|
99
|
-
return true;
|
|
100
|
-
if (directory && SKIP_DIRECTORY_NAMES.has(name))
|
|
101
|
-
return true;
|
|
102
|
-
if (directory && options.git === 'local' && REMOTE_GIT_DIRECTORIES.has(relativePath))
|
|
103
|
-
return true;
|
|
104
|
-
// Git's own lock files only exist while a command runs and must not come back with a restore.
|
|
105
|
-
return !directory && relativePath.startsWith('.git/') && name.endsWith('.lock');
|
|
106
|
-
};
|
|
107
|
-
await walk(root, root, io, revisions, entries, files, bodies, seen, skip, () => {
|
|
22
|
+
await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
|
|
108
23
|
status = 'incomplete';
|
|
109
24
|
});
|
|
110
25
|
for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
|
|
@@ -147,12 +62,6 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
147
62
|
if (hashSnapshotBytes(body) !== entry.contentHash)
|
|
148
63
|
throw new Error('snapshot_mismatch');
|
|
149
64
|
}
|
|
150
|
-
if (applicable.some((entry) => entry.path === '.git/HEAD')) {
|
|
151
|
-
const bundle = applicable.find((entry) => entry.path === UNPUSHED_BUNDLE && entry.kind === 'file');
|
|
152
|
-
if (bundle)
|
|
153
|
-
await addBundleObjects(rootReal, Buffer.from(bundle.contentBase64 ?? '', 'base64'));
|
|
154
|
-
await clearClonedGitMetadata(rootReal);
|
|
155
|
-
}
|
|
156
65
|
for (const entry of applicable) {
|
|
157
66
|
const target = resolve(rootReal, ...entry.path.split('/'));
|
|
158
67
|
const rel = relative(rootReal, target);
|
|
@@ -176,100 +85,50 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
176
85
|
if (entry.mode !== undefined)
|
|
177
86
|
await chmod(target, entry.mode);
|
|
178
87
|
}
|
|
88
|
+
let repositoryReady = false;
|
|
89
|
+
try {
|
|
90
|
+
const git = await lstat(join(rootReal, '.git'));
|
|
91
|
+
repositoryReady = git.isDirectory() && !git.isSymbolicLink();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
repositoryReady = false;
|
|
95
|
+
}
|
|
179
96
|
return {
|
|
180
97
|
skipped,
|
|
181
98
|
directoryReady: true,
|
|
182
|
-
repositoryReady
|
|
99
|
+
repositoryReady,
|
|
183
100
|
environmentReady: false,
|
|
184
101
|
};
|
|
185
102
|
}
|
|
186
|
-
/**
|
|
187
|
-
* The snapshot's `.git` replaces the one a new clone made, objects aside: a ref the clone wrote
|
|
188
|
-
* would otherwise outrank the snapshot's packed refs.
|
|
189
|
-
*/
|
|
190
|
-
async function clearClonedGitMetadata(rootReal) {
|
|
191
|
-
const gitDir = join(rootReal, '.git');
|
|
192
|
-
const info = await lstat(gitDir).catch(() => undefined);
|
|
193
|
-
if (!info?.isDirectory())
|
|
194
|
-
return;
|
|
195
|
-
for (const name of await readdir(gitDir)) {
|
|
196
|
-
if (name !== 'objects')
|
|
197
|
-
await rm(join(gitDir, name), { recursive: true, force: true });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Content goes to the workspace's shared objects, keyed by hash. A hash in `uploaded` was stored by
|
|
202
|
-
* this process before and is not sent again; hashes stored now are added to it. The manifest still
|
|
203
|
-
* lists every stored file, so each snapshot restores on its own. Paths not `keptInStorage` are left
|
|
204
|
-
* out of both the objects and the manifest.
|
|
205
|
-
*/
|
|
206
103
|
export async function publishFileSnapshot(input) {
|
|
207
|
-
const
|
|
208
|
-
const entries = input.tree.entries.filter((entry) => keptInStorage(entry.path));
|
|
209
|
-
for (const entry of entries) {
|
|
104
|
+
for (const entry of input.tree.entries) {
|
|
210
105
|
if (entry.kind !== 'file' || !entry.contentHash)
|
|
211
106
|
continue;
|
|
212
|
-
if (input.uploaded?.has(entry.contentHash) || stored.has(entry.contentHash))
|
|
213
|
-
continue;
|
|
214
107
|
const body = input.tree.bodies.get(entry.path);
|
|
215
108
|
if (!body || hashSnapshotBytes(body) !== entry.contentHash)
|
|
216
109
|
throw new Error('snapshot_body_missing');
|
|
217
|
-
await putObjectBytes(input.baseUrl,
|
|
218
|
-
stored.add(entry.contentHash);
|
|
219
|
-
}
|
|
220
|
-
const native = [];
|
|
221
|
-
for (const transcript of input.native ?? []) {
|
|
222
|
-
const contentHash = hashSnapshotBytes(transcript.body);
|
|
223
|
-
if (input.uploadedTranscripts?.get(transcript.path) !== contentHash) {
|
|
224
|
-
await putObjectBytes(input.baseUrl, nativeTranscriptKey(input.workspaceId, transcript.path), transcript.body, 'cli', input.headers);
|
|
225
|
-
input.uploadedTranscripts?.set(transcript.path, contentHash);
|
|
226
|
-
}
|
|
227
|
-
native.push({
|
|
228
|
-
sessionId: transcript.sessionId,
|
|
229
|
-
threadId: transcript.threadId,
|
|
230
|
-
path: transcript.path,
|
|
231
|
-
contentHash,
|
|
232
|
-
size: transcript.body.byteLength,
|
|
233
|
-
capturedAt: transcript.capturedAt,
|
|
234
|
-
});
|
|
110
|
+
await putObjectBytes(input.baseUrl, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli', input.headers);
|
|
235
111
|
}
|
|
236
|
-
const digest = fileSnapshotDigest(entries);
|
|
112
|
+
const digest = fileSnapshotDigest(input.tree.entries);
|
|
237
113
|
const manifest = fileSnapshotManifestSchema.parse({
|
|
238
114
|
snapshotId: input.snapshotId,
|
|
239
115
|
workspaceId: input.workspaceId,
|
|
240
116
|
generation: input.generation,
|
|
241
117
|
status: input.tree.status,
|
|
242
|
-
entries,
|
|
118
|
+
entries: input.tree.entries,
|
|
243
119
|
digest,
|
|
244
|
-
objectLayout: 'workspace',
|
|
245
|
-
...(native.length > 0 ? { native } : {}),
|
|
246
120
|
});
|
|
247
121
|
const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
|
|
248
122
|
await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
|
|
249
|
-
for (const hash of stored)
|
|
250
|
-
input.uploaded?.add(hash);
|
|
251
123
|
return { manifestKey, checksum: digest, status: input.tree.status };
|
|
252
124
|
}
|
|
253
|
-
|
|
254
|
-
* Digest of what is on disk now, without the deletions recorded against the previous snapshot.
|
|
255
|
-
* Transcripts count too: a turn that only talked still changes what a restore can resume.
|
|
256
|
-
*/
|
|
257
|
-
export function liveTreeDigest(tree, native = []) {
|
|
258
|
-
const files = fileSnapshotDigest(tree.entries.filter((entry) => entry.kind !== 'deleted'));
|
|
259
|
-
if (native.length === 0)
|
|
260
|
-
return files;
|
|
261
|
-
const transcripts = native
|
|
262
|
-
.map((item) => `${item.sessionId}:${item.threadId}:${item.path}:${hashSnapshotBytes(item.body)}`)
|
|
263
|
-
.sort();
|
|
264
|
-
return hashSnapshotBytes(Buffer.from([files, ...transcripts].join('\n')));
|
|
265
|
-
}
|
|
266
|
-
async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, skip, markIncomplete) {
|
|
125
|
+
async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
|
|
267
126
|
const listed = await io.readdir(current);
|
|
268
127
|
for (const entry of listed) {
|
|
128
|
+
if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
|
|
129
|
+
continue;
|
|
269
130
|
const child = join(current, entry.name);
|
|
270
131
|
const relativePath = toPosixRelative(rootReal, child);
|
|
271
|
-
if (skip(relativePath, entry.name, entry.isDirectory()))
|
|
272
|
-
continue;
|
|
273
132
|
if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
|
|
274
133
|
markIncomplete();
|
|
275
134
|
continue;
|
|
@@ -286,7 +145,7 @@ async function walk(rootReal, current, io, revisions, entries, files, bodies, se
|
|
|
286
145
|
continue;
|
|
287
146
|
}
|
|
288
147
|
if (entry.isDirectory()) {
|
|
289
|
-
await walk(rootReal, child, io, revisions, entries, files, bodies, seen,
|
|
148
|
+
await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
|
|
290
149
|
continue;
|
|
291
150
|
}
|
|
292
151
|
if (!entry.isFile())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { excludeFromGit } from './dependencies.js';
|
|
3
|
-
import { lstat,
|
|
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
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
|
299
|
-
const rootReal = await
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
/**
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.18",
|
|
52
|
+
"@playcraft/common": "^0.1.18",
|
|
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
|
-
}
|