@playcraft/cli 0.1.10 → 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.
- package/dist/workspace-runtime/codex/app-server.js +16 -0
- package/dist/workspace-runtime/codex/transcripts.js +94 -0
- package/dist/workspace-runtime/previews/publish.js +18 -0
- package/dist/workspace-runtime/server/dispatch.js +68 -7
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +79 -0
- package/dist/workspace-runtime/workspaces/file-snapshot.js +160 -21
- package/dist/workspace-runtime/workspaces/git.js +91 -1
- package/package.json +3 -3
|
@@ -7,6 +7,7 @@ import { writePlaycraftCodexConfig } from './config-toml.js';
|
|
|
7
7
|
import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
|
|
8
8
|
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
9
|
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
10
|
+
import { exportTranscript } from './transcripts.js';
|
|
10
11
|
function asRecord(value) {
|
|
11
12
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
12
13
|
? value
|
|
@@ -68,6 +69,18 @@ export function createCodexAppServerAdapter(options) {
|
|
|
68
69
|
for (const listener of collected)
|
|
69
70
|
listener(event);
|
|
70
71
|
}
|
|
72
|
+
function transcriptHome() {
|
|
73
|
+
if (!launcherSocket)
|
|
74
|
+
return { homeDir };
|
|
75
|
+
return {
|
|
76
|
+
homeDir,
|
|
77
|
+
launcher: {
|
|
78
|
+
socketPath: launcherSocket,
|
|
79
|
+
runtimeEpoch: executionEpoch,
|
|
80
|
+
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
71
84
|
async function ensureStarted() {
|
|
72
85
|
if (readyFlag && rpc)
|
|
73
86
|
return;
|
|
@@ -272,6 +285,9 @@ export function createCodexAppServerAdapter(options) {
|
|
|
272
285
|
return undefined;
|
|
273
286
|
}
|
|
274
287
|
},
|
|
288
|
+
exportThread(input) {
|
|
289
|
+
return exportTranscript(transcriptHome(), input.threadId);
|
|
290
|
+
},
|
|
275
291
|
async captureNativeCheckpoint(input) {
|
|
276
292
|
const thread = threads.get(input.threadId);
|
|
277
293
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
}
|
|
@@ -33,6 +33,12 @@ export async function previewWorkspaceRoot(state, operation) {
|
|
|
33
33
|
if (!operation.workspaceId || !/^[A-Za-z0-9_-]+$/.test(operation.operationId)) {
|
|
34
34
|
throw new PreviewFailure('invalid_preview_publish');
|
|
35
35
|
}
|
|
36
|
+
if (operation.sessionId !== undefined) {
|
|
37
|
+
const session = state.journal.getSession(operation.sessionId);
|
|
38
|
+
if (!session || (state.config.workspaceMode === 'multi' && session.workspaceId !== operation.workspaceId)) {
|
|
39
|
+
throw new PreviewFailure('session_workspace_mismatch');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
36
42
|
return workspaceRoot(state, operation.workspaceId);
|
|
37
43
|
}
|
|
38
44
|
/** Tracks a background publish so tests and shutdown can wait for it. */
|
|
@@ -55,6 +61,9 @@ export async function recoverInterruptedPreviews(journal) {
|
|
|
55
61
|
}
|
|
56
62
|
export async function runPreviewPublish(state, operation, root) {
|
|
57
63
|
const workspaceId = operation.workspaceId;
|
|
64
|
+
const scope = operation.sessionId === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { sessionId: operation.sessionId, clientRequestId: operation.clientRequestId };
|
|
58
67
|
const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
|
|
59
68
|
const output = { log: '' };
|
|
60
69
|
try {
|
|
@@ -73,6 +82,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
73
82
|
const source = await sourceRevision(root);
|
|
74
83
|
const reserved = previewReservationSchema.parse(await postControl(state, '/runtime/internal/preview-reservations', {
|
|
75
84
|
workspaceId,
|
|
85
|
+
...scope,
|
|
76
86
|
operationId: operation.operationId,
|
|
77
87
|
contentDigest,
|
|
78
88
|
contentMd5,
|
|
@@ -81,6 +91,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
81
91
|
dirty: source.dirty,
|
|
82
92
|
cliVersion: state.config.cliVersion,
|
|
83
93
|
}));
|
|
94
|
+
if (reserved.sessionId !== operation.sessionId)
|
|
95
|
+
throw new PreviewFailure('preview_scope_mismatch');
|
|
84
96
|
if (!reserved.alreadyUploaded) {
|
|
85
97
|
const uploaded = await fetch(reserved.uploadUrl, {
|
|
86
98
|
method: 'PUT',
|
|
@@ -99,11 +111,16 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
99
111
|
}
|
|
100
112
|
const published = previewPublishResultSchema.parse(await postControl(state, '/runtime/internal/preview-completions', {
|
|
101
113
|
workspaceId,
|
|
114
|
+
...scope,
|
|
102
115
|
operationId: operation.operationId,
|
|
103
116
|
artifactId: reserved.artifactId,
|
|
104
117
|
expectedPublishRevision: reserved.expectedPublishRevision,
|
|
105
118
|
logTail: tail(output.log),
|
|
106
119
|
}));
|
|
120
|
+
if (published.sessionId !== operation.sessionId || published.previewId !== reserved.previewId ||
|
|
121
|
+
published.artifactId !== reserved.artifactId) {
|
|
122
|
+
throw new PreviewFailure('preview_scope_mismatch');
|
|
123
|
+
}
|
|
107
124
|
if (published.status === 'conflict')
|
|
108
125
|
throw new PreviewFailure('publish_conflict');
|
|
109
126
|
return published;
|
|
@@ -113,6 +130,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
113
130
|
if (code !== 'publish_conflict') {
|
|
114
131
|
await postControl(state, '/runtime/internal/preview-failures', {
|
|
115
132
|
workspaceId,
|
|
133
|
+
...scope,
|
|
116
134
|
operationId: operation.operationId,
|
|
117
135
|
error: code,
|
|
118
136
|
logTail: tail(output.log || (error instanceof Error ? error.message : '')),
|
|
@@ -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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 ?? '');
|
|
@@ -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,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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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())
|
|
@@ -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))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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.11",
|
|
52
|
+
"@playcraft/common": "^0.1.11",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|