@playcraft/cli 0.1.46-beta → 0.1.47-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,14 @@
1
- import { lstat, rm, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
1
+ import { rm, writeFile } from 'node:fs/promises';
3
2
  import { statfs } from 'node:fs/promises';
4
3
  import { batchSourceFreezeResultSchema, batchSourceRemoteRef, sandboxBatchSourceFreezeRequestSchema, workspaceDiskAdmission, } from '@playcraft/common/cloud-workspace/runtime';
5
4
  import { stopBackgroundWriter } from '../processes/managed-writes.js';
6
5
  import { workspaceRoot } from '../workspaces/registry.js';
7
- import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
8
- import { archiveSource, captureIndependentSource } from './source-capture.js';
6
+ import { gitRead } from './git.js';
7
+ import { captureIndependentSource } from './source-capture.js';
9
8
  import { filesystemObjectStore, readSnapshot, serializeFreeze, sourceCacheDir, writeSnapshot, } from './records.js';
9
+ import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from './source-candidate.js';
10
+ import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
11
+ import { publishSourceCandidate } from './source-publish.js';
10
12
  async function usagePercent(path) {
11
13
  const disk = await statfs(path);
12
14
  if (disk.blocks <= 0)
@@ -27,76 +29,73 @@ async function stopManagedWriters(state, workspaceId, directoryName) {
27
29
  await state.journal.putManagedWrite({ ...write, stoppedAt: new Date().toISOString() });
28
30
  }
29
31
  }
30
- async function pushHiddenRef(cache, sourceRoot, remote, commit, ref) {
31
- await copyCredentialHelpers(sourceRoot, cache);
32
- try {
33
- await gitWrite(cache, ['push', remote, `${commit}:${ref}`]);
34
- }
35
- catch {
36
- throw new Error('batch_source_push_failed');
37
- }
38
- const remoteHash = (await gitWrite(cache, ['ls-remote', remote, ref]).catch(() => '')).slice(0, commit.length);
39
- if (remoteHash !== commit)
40
- throw new Error('batch_source_push_failed');
41
- }
42
32
  export async function freezeBatchSource(state, body, options) {
43
33
  const parsed = sandboxBatchSourceFreezeRequestSchema.safeParse(body);
44
34
  if (!parsed.success)
45
35
  throw new Error('invalid_batch_request');
46
36
  const request = parsed.data;
47
37
  return serializeFreeze(async () => {
48
- const existing = await readSnapshot(state, request.batchId);
49
- if (existing)
50
- return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: existing });
51
- const source = await workspaceRoot(state, request.workspaceId, request.sourceDirectoryName);
52
- const percent = Math.min(100, Math.max(0, options?.usagePercent ?? await usagePercent(source)));
53
- if (workspaceDiskAdmission(percent) === 'paused')
54
- throw new Error('disk_admission_paused');
55
- await stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName);
56
- const cache = sourceCacheDir(state, request.batchId);
57
- await rm(cache, { recursive: true, force: true });
58
- const captured = await captureIndependentSource(source, cache);
59
- await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
60
- const binding = state.journal.getWorkspace(request.workspaceId);
61
- const frozenAt = new Date((await gitRead(cache, ['show', '-s', '--format=%cI', 'HEAD'])).trim()).toISOString();
62
- const snapshot = {
63
- batchId: request.batchId,
64
- commitHash: captured.commitHash,
65
- digest: captured.digest,
66
- sourceWorkspaceId: request.workspaceId,
67
- sourceDirectoryName: request.sourceDirectoryName,
68
- frozenAt,
69
- };
38
+ let candidate;
39
+ let source;
70
40
  try {
71
- if (request.persist === 'remote_ref') {
72
- if (!binding?.repositoryUrl)
73
- throw new Error('batch_source_push_failed');
74
- const ref = batchSourceRemoteRef(request.batchId);
75
- await pushHiddenRef(cache, source, binding.repositoryUrl, captured.commitHash, ref);
76
- snapshot.remoteRef = ref;
41
+ const pending = await readSourceCandidate(state, request.batchId);
42
+ const existing = await readSnapshot(state, request.batchId);
43
+ if (existing) {
44
+ assertSourceIdentity(state, request, existing, pending);
45
+ return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: existing });
46
+ }
47
+ if (!pending && 'resumeOnly' in request && request.resumeOnly === true) {
48
+ throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_candidate_lost', stage: 'capture', retryable: false, outcome: 'unknown' });
77
49
  }
50
+ if (pending)
51
+ assertSourceIdentity(state, request, pending.snapshot, pending);
52
+ source = await workspaceRoot(state, request.workspaceId, request.sourceDirectoryName);
53
+ if (pending)
54
+ candidate = pending;
78
55
  else {
79
- const key = `workspaces/${request.workspaceId}/batches/${request.batchId}/source/${captured.digest}.json.gz`;
80
- const store = options?.objectStore ?? filesystemObjectStore(state);
81
- try {
82
- await store.put(key, await archiveSource(cache, captured));
83
- }
84
- catch (error) {
85
- if (error instanceof Error && error.message === 'batch_object_key_invalid')
86
- throw error;
87
- throw new Error('batch_source_upload_failed');
88
- }
89
- snapshot.artifactKey = key;
56
+ const percent = Math.min(100, Math.max(0, options?.usagePercent ?? await usagePercent(source)));
57
+ if (workspaceDiskAdmission(percent) === 'paused')
58
+ throw new Error('disk_admission_paused');
59
+ await stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName);
60
+ const cache = sourceCacheDir(state, request.batchId);
61
+ await rm(cache, { recursive: true, force: true });
62
+ const captured = await captureIndependentSource(source, cache);
63
+ await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
64
+ const binding = state.journal.getWorkspace(request.workspaceId);
65
+ const snapshot = {
66
+ batchId: request.batchId, commitHash: captured.commitHash, digest: captured.digest,
67
+ sourceWorkspaceId: request.workspaceId, sourceDirectoryName: request.sourceDirectoryName,
68
+ frozenAt: new Date((await gitRead(cache, ['show', '-s', '--format=%cI', 'HEAD'])).trim()).toISOString(),
69
+ ...(request.persist === 'remote_ref' ? { remoteRef: batchSourceRemoteRef(request.batchId) }
70
+ : { artifactKey: `workspaces/${request.workspaceId}/batches/${request.batchId}/source/${captured.digest}.json.gz` }),
71
+ };
72
+ candidate = {
73
+ version: 1, persist: request.persist, repositoryUrl: binding?.repositoryUrl ?? undefined, projectId: binding?.projectId,
74
+ branchName: request.sourceDirectoryName === 'basic' ? binding?.branchName ?? undefined
75
+ : binding?.directories?.find(directory => directory.directoryName === request.sourceDirectoryName)?.branchName ?? undefined,
76
+ snapshot,
77
+ };
78
+ await writeSourceCandidate(state, candidate);
90
79
  }
91
80
  }
92
81
  catch (error) {
93
- await rm(cache, { recursive: true, force: true });
94
- await rm(`${cache}.digest`, { force: true });
95
- throw error;
82
+ if (error instanceof BatchSourceFreezeError)
83
+ throw error;
84
+ const known = new Set(['disk_admission_paused', 'disk_usage_unavailable', 'batch_source_changed', 'batch_source_path_invalid',
85
+ 'workspace_not_registered', 'batch_source_identity_mismatch', 'batch_source_snapshot_mismatch']);
86
+ const code = error instanceof Error && known.has(error.message) ? error.message : 'batch_source_capture_failed';
87
+ throw await sourceFreezeError(state, request.batchId, {
88
+ code, stage: 'capture', retryable: !['batch_source_path_invalid', 'batch_source_identity_mismatch', 'batch_source_snapshot_mismatch'].includes(code),
89
+ outcome: code === 'batch_source_identity_mismatch' || code === 'batch_source_snapshot_mismatch' ? 'unknown' : 'not_published',
90
+ }, error);
91
+ }
92
+ await publishSourceCandidate(state, candidate, source, options?.objectStore ?? filesystemObjectStore(state));
93
+ try {
94
+ await writeSnapshot(state, candidate.snapshot);
95
+ }
96
+ catch (error) {
97
+ throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
96
98
  }
97
- if (!await lstat(join(cache, '.git')).catch(() => undefined))
98
- throw new Error('batch_source_capture_failed');
99
- await writeSnapshot(state, snapshot);
100
- return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot });
99
+ return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: candidate.snapshot });
101
100
  });
102
101
  }
@@ -4,6 +4,7 @@ import { deliverBatchMember } from './deliver.js';
4
4
  import { freezeBatchSource } from './freeze.js';
5
5
  import { generateBatchMember } from './generate.js';
6
6
  import { prepareBatchMember } from './prepare.js';
7
+ import { BatchSourceFreezeError } from './source-failure.js';
7
8
  const CLIENT_ERRORS = new Set([
8
9
  'occupancy_required', 'invalid_batch_request', 'batch_branch_invalid', 'stale_owner', 'disk_admission_paused',
9
10
  'batch_source_push_failed', 'batch_source_upload_failed', 'batch_source_changed', 'batch_source_path_invalid',
@@ -49,6 +50,8 @@ export async function dispatchBatchRoute(state, request, url) {
49
50
  return undefined;
50
51
  }
51
52
  catch (error) {
53
+ if (error instanceof BatchSourceFreezeError)
54
+ return { status: 409, body: { error: error.message, failure: error.failure } };
52
55
  const code = error instanceof Error && CLIENT_ERRORS.has(error.message) ? error.message : undefined;
53
56
  if (!code)
54
57
  throw error;
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { link, mkdir, open, readFile, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { batchSourceSnapshotRefSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ export function sourceCandidateFile(state, batchId) {
6
+ return join(state.config.stateDir, 'batch-source-candidates', `${batchId}.json`);
7
+ }
8
+ export async function readSourceCandidate(state, batchId) {
9
+ let body;
10
+ try {
11
+ body = await readFile(sourceCandidateFile(state, batchId), 'utf8');
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT')
15
+ return undefined;
16
+ throw error;
17
+ }
18
+ let stored;
19
+ try {
20
+ stored = JSON.parse(body);
21
+ }
22
+ catch {
23
+ throw new Error('batch_source_snapshot_mismatch');
24
+ }
25
+ if (!stored || stored.version !== 1 || !['remote_ref', 'artifact'].includes(stored.persist)
26
+ || (stored.repositoryUrl !== undefined && typeof stored.repositoryUrl !== 'string')
27
+ || (stored.projectId !== undefined && stored.projectId !== null && typeof stored.projectId !== 'string')
28
+ || (stored.branchName !== undefined && typeof stored.branchName !== 'string'))
29
+ throw new Error('batch_source_snapshot_mismatch');
30
+ const parsed = batchSourceSnapshotRefSchema.safeParse(stored.snapshot);
31
+ if (!parsed.success)
32
+ throw new Error('batch_source_snapshot_mismatch');
33
+ const snapshot = parsed.data;
34
+ if (snapshot.batchId !== batchId)
35
+ throw new Error('batch_source_identity_mismatch');
36
+ return { ...stored, snapshot };
37
+ }
38
+ /** The immutable candidate is committed to disk before any request can publish its commit. */
39
+ export async function writeSourceCandidate(state, candidate) {
40
+ const path = sourceCandidateFile(state, candidate.snapshot.batchId);
41
+ const directory = join(path, '..');
42
+ await mkdir(directory, { recursive: true, mode: 0o700 });
43
+ const temporary = `${path}.${randomUUID()}.tmp`;
44
+ const handle = await open(temporary, 'wx', 0o600);
45
+ try {
46
+ await handle.writeFile(JSON.stringify(candidate));
47
+ await handle.sync();
48
+ await handle.close();
49
+ try {
50
+ await link(temporary, path);
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'EEXIST')
54
+ throw error;
55
+ const existing = await readSourceCandidate(state, candidate.snapshot.batchId);
56
+ if (JSON.stringify(existing) !== JSON.stringify(candidate))
57
+ throw new Error('batch_source_identity_mismatch');
58
+ }
59
+ const folder = await open(directory, 'r');
60
+ try {
61
+ await folder.sync();
62
+ }
63
+ finally {
64
+ await folder.close();
65
+ }
66
+ }
67
+ finally {
68
+ await handle.close();
69
+ await unlink(temporary).catch(() => undefined);
70
+ }
71
+ }
72
+ export function assertSourceIdentity(state, request, snapshot, candidate) {
73
+ const binding = state.journal.getWorkspace(request.workspaceId);
74
+ const branchName = request.sourceDirectoryName === 'basic' ? binding?.branchName
75
+ : binding?.directories?.find(directory => directory.directoryName === request.sourceDirectoryName)?.branchName;
76
+ if (snapshot.batchId !== request.batchId || snapshot.sourceWorkspaceId !== request.workspaceId
77
+ || snapshot.sourceDirectoryName !== request.sourceDirectoryName
78
+ || (request.persist === 'remote_ref' ? !snapshot.remoteRef || Boolean(snapshot.artifactKey) : !snapshot.artifactKey || Boolean(snapshot.remoteRef))
79
+ || (candidate && (candidate.persist !== request.persist || candidate.repositoryUrl !== (binding?.repositoryUrl ?? undefined)
80
+ || candidate.projectId !== binding?.projectId || candidate.branchName !== (branchName ?? undefined)
81
+ || candidate.snapshot.commitHash !== snapshot.commitHash || candidate.snapshot.digest !== snapshot.digest
82
+ || candidate.snapshot.frozenAt !== snapshot.frozenAt || candidate.snapshot.remoteRef !== snapshot.remoteRef
83
+ || candidate.snapshot.artifactKey !== snapshot.artifactKey))) {
84
+ throw new Error('batch_source_identity_mismatch');
85
+ }
86
+ }
@@ -0,0 +1,43 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ export class BatchSourceFreezeError extends Error {
5
+ failure;
6
+ constructor(failure) {
7
+ super(failure.code);
8
+ this.failure = failure;
9
+ this.name = 'BatchSourceFreezeError';
10
+ }
11
+ }
12
+ /** Diagnostics keep the upstream reason without command arguments, authentication or remote addresses. */
13
+ function redactedReason(error) {
14
+ const detail = error;
15
+ const text = typeof detail?.stderr === 'string' || typeof detail?.stdout === 'string'
16
+ ? `${typeof detail.stderr === 'string' ? detail.stderr : ''}\n${typeof detail.stdout === 'string' ? detail.stdout : ''}`
17
+ : typeof detail?.message === 'string' ? detail.message : 'unknown';
18
+ return text
19
+ .replace(/https?:\/\/[^\s<>"']+/gi, '<remote>')
20
+ .replace(/\b(Bearer|Basic)\s+[^\s,;"']+/gi, '$1 <redacted>')
21
+ .replace(/((?:password|token|secret|authorization|credential)\s*[:=]\s*)[^\s,;"']+/gi, '$1<redacted>')
22
+ .replace(/\b(?:gh[pousr]_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, '<redacted>')
23
+ .slice(0, 16_384);
24
+ }
25
+ export async function sourceFreezeError(state, batchId, failure, cause) {
26
+ if (cause === undefined)
27
+ return new BatchSourceFreezeError(failure);
28
+ const diagnosticId = randomUUID();
29
+ const directory = join(state.config.stateDir, 'batch-source-diagnostics');
30
+ const detail = cause;
31
+ try {
32
+ await mkdir(directory, { recursive: true, mode: 0o700 });
33
+ await writeFile(join(directory, `${diagnosticId}.json`), JSON.stringify({
34
+ diagnosticId, batchId, ...failure, recordedAt: new Date().toISOString(), reason: redactedReason(cause),
35
+ exitCode: typeof detail?.code === 'number' ? detail.code : undefined,
36
+ signal: typeof detail?.signal === 'string' && /^SIG[A-Z]+$/.test(detail.signal) ? detail.signal : undefined,
37
+ }), { mode: 0o600, flag: 'wx' });
38
+ return new BatchSourceFreezeError({ ...failure, diagnosticId });
39
+ }
40
+ catch {
41
+ return new BatchSourceFreezeError(failure);
42
+ }
43
+ }
@@ -0,0 +1,132 @@
1
+ import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
4
+ import { archiveSource, digestFiles, readSourceFiles, restoreArchive } from './source-capture.js';
5
+ import { sourceCacheDir } from './records.js';
6
+ import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
7
+ async function reject(state, candidate, code, stage, retryable, outcome, cause) {
8
+ throw await sourceFreezeError(state, candidate.snapshot.batchId, { code, stage, retryable, outcome }, cause);
9
+ }
10
+ async function verifyCache(cache, candidate) {
11
+ if ((await gitRead(cache, ['rev-parse', '--verify', 'HEAD']).catch(() => '')).trim() !== candidate.snapshot.commitHash)
12
+ return false;
13
+ return digestFiles(await readSourceFiles(cache)) === candidate.snapshot.digest;
14
+ }
15
+ function pushFailureCode(error) {
16
+ const detail = error;
17
+ return /not support[^\n]*(?:ref|push)|unsupported[^\n]*(?:ref|namespace)/i.test(`${detail?.stderr ?? ''}\n${detail?.stdout ?? ''}`)
18
+ ? 'batch_source_ref_unsupported' : 'batch_source_push_failed';
19
+ }
20
+ function definitelyRejected(error) {
21
+ const detail = error;
22
+ return /remote rejected|\[rejected\]|authentication failed|access denied|permission denied|not allowed|not support|hook declined|protected|returned error: (?:401|403)/i
23
+ .test(`${detail?.stderr ?? ''}\n${detail?.stdout ?? ''}`);
24
+ }
25
+ async function publishRemote(state, candidate, source) {
26
+ const { snapshot, repositoryUrl: remote } = candidate;
27
+ const cache = sourceCacheDir(state, snapshot.batchId);
28
+ if (!remote || !snapshot.remoteRef)
29
+ return reject(state, candidate, 'batch_source_push_failed', 'publish', false, 'not_published');
30
+ await mkdir(cache, { recursive: true, mode: 0o700 });
31
+ if (!await lstat(join(cache, '.git')).catch(() => undefined))
32
+ await gitWrite(cache, ['init', '--initial-branch', 'source']);
33
+ await copyCredentialHelpers(source, cache);
34
+ // All remote operations share the copied authentication, including read-only verification.
35
+ const remoteHash = async () => {
36
+ const output = await gitWrite(cache, ['ls-remote', '--refs', remote, snapshot.remoteRef]);
37
+ const rows = output.trim().split('\n').filter(Boolean).map(line => line.split(/\s+/));
38
+ if (rows.some(([hash, ref]) => !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(hash) || ref !== snapshot.remoteRef) || rows.length > 1) {
39
+ throw new Error('batch_source_remote_response_invalid');
40
+ }
41
+ return rows[0]?.[0];
42
+ };
43
+ let found;
44
+ try {
45
+ found = await remoteHash();
46
+ }
47
+ catch (error) {
48
+ return reject(state, candidate, 'batch_source_verification_failed', 'verify', true, 'unknown', error);
49
+ }
50
+ if (found && found !== snapshot.commitHash)
51
+ return reject(state, candidate, 'batch_source_ref_conflict', 'verify', false, 'unknown');
52
+ if (!found) {
53
+ if (!await verifyCache(cache, candidate))
54
+ return reject(state, candidate, 'batch_source_candidate_lost', 'capture', false, 'not_published');
55
+ let pushError;
56
+ try {
57
+ await gitWrite(cache, ['push', '--porcelain', remote, `${snapshot.commitHash}:${snapshot.remoteRef}`]);
58
+ }
59
+ catch (error) {
60
+ pushError = error;
61
+ }
62
+ try {
63
+ found = await remoteHash();
64
+ }
65
+ catch (error) {
66
+ const unsupported = pushError !== undefined && pushFailureCode(pushError) === 'batch_source_ref_unsupported';
67
+ return reject(state, candidate, pushError ? pushFailureCode(pushError) : 'batch_source_verification_failed', pushError ? 'publish' : 'verify', !unsupported, unsupported ? 'not_published' : 'unknown', pushError ?? error);
68
+ }
69
+ if (found && found !== snapshot.commitHash)
70
+ return reject(state, candidate, 'batch_source_ref_conflict', 'verify', false, 'unknown', pushError);
71
+ if (!found) {
72
+ const rejected = pushError !== undefined && definitelyRejected(pushError);
73
+ return reject(state, candidate, pushError ? pushFailureCode(pushError) : 'batch_source_verification_failed', pushError ? 'publish' : 'verify', !rejected, rejected ? 'not_published' : 'unknown', pushError);
74
+ }
75
+ }
76
+ if (!await verifyCache(cache, candidate)) {
77
+ try {
78
+ await gitWrite(cache, ['fetch', '--no-tags', remote, snapshot.remoteRef]);
79
+ if ((await gitRead(cache, ['rev-parse', 'FETCH_HEAD^{commit}'])).trim() !== snapshot.commitHash) {
80
+ return reject(state, candidate, 'batch_source_snapshot_mismatch', 'verify', false, 'unknown');
81
+ }
82
+ await gitWrite(cache, ['checkout', '--detach', snapshot.commitHash]);
83
+ }
84
+ catch (error) {
85
+ return reject(state, candidate, 'batch_source_verification_failed', 'verify', true, 'unknown', error);
86
+ }
87
+ if (!await verifyCache(cache, candidate))
88
+ return reject(state, candidate, 'batch_source_snapshot_mismatch', 'verify', false, 'unknown');
89
+ }
90
+ await writeFile(`${cache}.digest`, snapshot.digest, { mode: 0o600 });
91
+ }
92
+ async function publishArtifact(state, candidate, store) {
93
+ const { snapshot } = candidate;
94
+ const cache = sourceCacheDir(state, snapshot.batchId);
95
+ if (!snapshot.artifactKey)
96
+ return reject(state, candidate, 'batch_source_snapshot_mismatch', 'capture', false, 'not_published');
97
+ if (!await verifyCache(cache, candidate)) {
98
+ // A lost cache can only be restored from this candidate's already published archive.
99
+ try {
100
+ const archive = await store.get(snapshot.artifactKey);
101
+ await rm(cache, { recursive: true, force: true });
102
+ const restored = await restoreArchive(archive, cache, snapshot.digest);
103
+ if (restored.commitHash !== snapshot.commitHash)
104
+ return reject(state, candidate, 'batch_source_snapshot_mismatch', 'verify', false, 'unknown');
105
+ }
106
+ catch (error) {
107
+ return reject(state, candidate, 'batch_source_candidate_lost', 'capture', false, 'unknown', error);
108
+ }
109
+ }
110
+ else {
111
+ try {
112
+ await store.put(snapshot.artifactKey, await archiveSource(cache, snapshot));
113
+ }
114
+ catch (error) {
115
+ return reject(state, candidate, 'batch_source_upload_failed', 'publish', true, 'unknown', error);
116
+ }
117
+ }
118
+ await writeFile(`${cache}.digest`, snapshot.digest, { mode: 0o600 });
119
+ }
120
+ export async function publishSourceCandidate(state, candidate, source, store) {
121
+ try {
122
+ if (candidate.persist === 'remote_ref')
123
+ await publishRemote(state, candidate, source);
124
+ else
125
+ await publishArtifact(state, candidate, store);
126
+ }
127
+ catch (error) {
128
+ if (error instanceof BatchSourceFreezeError)
129
+ throw error;
130
+ throw await sourceFreezeError(state, candidate.snapshot.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
131
+ }
132
+ }
@@ -93,6 +93,7 @@ export async function handleRuntimeRequest(state, request, response) {
93
93
  permissionsVersion: 1,
94
94
  composerContextVersion: 1,
95
95
  remixReferencesVersion: 1,
96
+ batchSourceFreezeRevision: 2,
96
97
  ...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
97
98
  ...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
98
99
  ...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile, mkdtemp, rm } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { batchSourceRemoteRef, parseSandboxRuntimeConfig } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { createLoopbackNativeAdapter } from './codex/loopback.js';
10
+ import { startWorkspaceRuntime } from './serve.js';
11
+ async function dependencyVersion(name, entry) {
12
+ let directory = dirname(createRequire(import.meta.url).resolve(entry));
13
+ for (;;) {
14
+ const metadata = await readFile(join(directory, 'package.json'), 'utf8').then(JSON.parse).catch(() => undefined);
15
+ if (metadata?.name === name)
16
+ return metadata.version;
17
+ const parent = dirname(directory);
18
+ if (parent === directory)
19
+ throw new Error(`package_metadata_missing:${name}`);
20
+ directory = parent;
21
+ }
22
+ }
23
+ /** Starts only an isolated temporary runtime; never reads or changes an existing workspace. */
24
+ export async function verifyRuntimeArtifact(expectedVersion) {
25
+ assert.match(expectedVersion, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
26
+ const metadata = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
27
+ assert.equal(metadata.version, expectedVersion, 'command package version differs from the release target');
28
+ assert.equal(await dependencyVersion('@playcraft/common', '@playcraft/common/cloud-workspace/runtime'), expectedVersion);
29
+ assert.equal(await dependencyVersion('@playcraft/build', '@playcraft/build'), expectedVersion);
30
+ const batchId = randomUUID();
31
+ assert.equal(batchSourceRemoteRef(batchId), `refs/tags/playcraft/batch/${batchId}/source`, 'installed shared library lacks standard tag references for source snapshots');
32
+ const directory = await mkdtemp(join(tmpdir(), 'playcraft-artifact-verify-'));
33
+ let runtime;
34
+ try {
35
+ const config = parseSandboxRuntimeConfig({
36
+ protocolMajor: 1, protocolMinor: 0, environment: 'artifact-verification',
37
+ runtimeId: randomUUID(), userId: 'artifact-verification', generation: 1, runtimeEpoch: 0,
38
+ workspaceMode: 'multi', workspaceRoot: join(directory, 'workspaces'), stateDir: join(directory, 'state'),
39
+ publicBaseUrl: 'http://127.0.0.1:9', controlToken: randomUUID(), cliVersion: expectedVersion,
40
+ listen: { host: '127.0.0.1', port: 0 },
41
+ });
42
+ runtime = await startWorkspaceRuntime(config, { nativeAdapter: createLoopbackNativeAdapter() });
43
+ const response = await fetch(`${runtime.url}/runtime/identity`, {
44
+ headers: { authorization: `Bearer ${config.controlToken}` }, signal: AbortSignal.timeout(10_000),
45
+ });
46
+ assert.equal(response.status, 200, 'built runtime identity is unavailable');
47
+ const identity = await response.json();
48
+ assert.equal(identity.runtimeId, config.runtimeId);
49
+ assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
50
+ }
51
+ finally {
52
+ await runtime?.close();
53
+ await rm(directory, { recursive: true, force: true });
54
+ }
55
+ }
56
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
57
+ await verifyRuntimeArtifact(process.argv[2] ?? '');
58
+ console.log('Runtime artifact verified: batchSourceFreezeRevision=2');
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.46-beta",
3
+ "version": "0.1.47-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,8 +51,8 @@
51
51
  "@gltf-transform/core": "^4.3.0",
52
52
  "@gltf-transform/extensions": "^4.3.0",
53
53
  "@gltf-transform/functions": "^4.3.0",
54
- "@playcraft/build": "^0.1.46-beta",
55
- "@playcraft/common": "^0.1.46-beta",
54
+ "@playcraft/build": "0.1.47-beta",
55
+ "@playcraft/common": "0.1.47-beta",
56
56
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
57
57
  "chokidar": "^4.0.3",
58
58
  "commander": "^13.1.0",