@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.
- package/dist/workspace-runtime/codex/app-server.js +24 -62
- package/dist/workspace-runtime/codex/loopback.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +1 -26
- package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -13
- package/dist/workspace-runtime/server/dispatch.js +115 -413
- package/dist/workspace-runtime/server/execution-group.js +0 -3
- package/dist/workspace-runtime/server/http.js +5 -58
- package/dist/workspace-runtime/workspaces/files.js +2 -33
- 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
- package/dist/workspace-runtime/workspaces/file-snapshot.js +0 -354
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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.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.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
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
|
-
}
|