@playcraft/cli 0.1.45-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.
Files changed (29) hide show
  1. package/dist/commands/tools-batch.js +1 -3
  2. package/dist/workspace-runtime/{production → batch}/content-changes.js +6 -12
  3. package/dist/workspace-runtime/batch/deliver.js +5 -0
  4. package/dist/workspace-runtime/batch/freeze.js +61 -62
  5. package/dist/workspace-runtime/batch/generate.js +24 -3
  6. package/dist/workspace-runtime/batch/occupancy.js +1 -1
  7. package/dist/workspace-runtime/batch/prepare.js +2 -0
  8. package/dist/workspace-runtime/batch/routes.js +3 -0
  9. package/dist/workspace-runtime/batch/source-candidate.js +86 -0
  10. package/dist/workspace-runtime/batch/source-failure.js +43 -0
  11. package/dist/workspace-runtime/batch/source-publish.js +132 -0
  12. package/dist/workspace-runtime/server/dispatch.js +17 -8
  13. package/dist/workspace-runtime/server/http.js +10 -24
  14. package/dist/workspace-runtime/server/terminal-host.js +2 -2
  15. package/dist/workspace-runtime/verify-artifact.js +59 -0
  16. package/dist/workspace-runtime/workspaces/cli-credentials.js +6 -2
  17. package/dist/workspace-runtime/workspaces/directories.js +10 -20
  18. package/dist/workspace-runtime/workspaces/directory-maintenance.js +3 -3
  19. package/package.json +3 -3
  20. package/dist/workspace-runtime/production/contracts.js +0 -19
  21. package/dist/workspace-runtime/production/controller.js +0 -228
  22. package/dist/workspace-runtime/production/dependencies.js +0 -60
  23. package/dist/workspace-runtime/production/native.js +0 -239
  24. package/dist/workspace-runtime/production/processes.js +0 -102
  25. package/dist/workspace-runtime/production/save-command.js +0 -35
  26. package/dist/workspace-runtime/production/source-archive.js +0 -137
  27. package/dist/workspace-runtime/production/source-copy.js +0 -92
  28. package/dist/workspace-runtime/production/stages.js +0 -354
  29. package/dist/workspace-runtime/production/storage.js +0 -75
@@ -15,7 +15,6 @@ const messages = {
15
15
  requestRequired: 'Prompt creation requires a stable --client-request-id; reuse it when retrying the same request.',
16
16
  help: '\nUse --prompt with --workspace-id and a stable --client-request-id. The platform plans and generates independent Remix directories while preserving the source. An omitted count defaults to 3; maximum 10. --input also accepts existing structured drafts.\n',
17
17
  confirmation: 'Generation is queued. Read the batch status to track its results.',
18
- review: 'This existing draft requires baseline approval in the browser.',
19
18
  sync: 'Apply the latest source snapshot to an existing batch',
20
19
  syncDone: 'Batch sync requested',
21
20
  },
@@ -31,7 +30,6 @@ const messages = {
31
30
  requestRequired: '使用 prompt 创建时必须提供稳定的 --client-request-id,重试同一请求时复用该标识。',
32
31
  help: '\n使用 --prompt、--workspace-id 和稳定的 --client-request-id。平台自动规划,在副本中生成独立 Remix,保持来源不变。未指定数量默认 3 个,最多 10 个。--input 也兼容现有结构化草案。\n',
33
32
  confirmation: '生成已排队,可查询批次状态追踪各项成果。',
34
- review: '此历史草案需要在浏览器确认基准。',
35
33
  sync: '将来源最新快照同步到已有批次',
36
34
  syncDone: '已请求同步到批次',
37
35
  },
@@ -69,7 +67,7 @@ export function registerBatchCommands(tools) {
69
67
  sourceWorkspaceId: opts.workspaceId, sourceDirectoryName: opts.sourceDirectory,
70
68
  ...(opts.teamId ? { teamId: opts.teamId } : {}),
71
69
  });
72
- console.log(opts.json ? JSON.stringify(result, null, 2) : `${t.created}: ${result.batchId}\n${result.manifest.autoStart ? t.confirmation : t.review}`);
70
+ console.log(opts.json ? JSON.stringify(result, null, 2) : `${t.created}: ${result.batchId}\n${t.confirmation}`);
73
71
  }
74
72
  catch (error) {
75
73
  handleError(error);
@@ -1,6 +1,4 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- const execute = promisify(execFile);
1
+ import { gitRead } from './git.js';
4
2
  const reference = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
5
3
  const excludedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'docs', 'test', 'tests', '__tests__']);
6
4
  const contentExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|s[ac]ss|less|json5?|ya?ml|xml|glsl|wgsl|vert|frag|lua|py|svg|png|jpe?g|webp|avif|gif|bmp|mp[34]|wav|ogg|m4a|webm|glb|gltf|fbx|obj|bin|atlas|woff2?|ttf|otf)$/i;
@@ -13,15 +11,11 @@ function contentPath(path) {
13
11
  return false;
14
12
  return contentExtension.test(name);
15
13
  }
16
- /** A completed native turn is useful only when code, runtime configuration, or assets changed. */
17
- export async function requireProductionContentChanges(root, before, after) {
14
+ /** A completed batch member must change code, runtime configuration, or assets. */
15
+ export async function requireBatchContentChanges(root, before, after) {
18
16
  if (!reference.test(before) || !reference.test(after))
19
- throw new Error('production_change_reference_invalid');
20
- const { stdout } = await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root,
21
- 'diff', '--raw', '--no-abbrev', '--no-renames', '-z', before, after, '--'], {
22
- timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
23
- env: { PATH: process.env.PATH, HOME: root, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
24
- });
17
+ throw new Error('batch_change_reference_invalid');
18
+ const stdout = await gitRead(root, ['diff', '--raw', '--no-abbrev', '--no-renames', '-z', before, after, '--']);
25
19
  const records = stdout.split('\0'), paths = [];
26
20
  for (let index = 0; index + 1 < records.length; index += 2) {
27
21
  const [oldMode, newMode, oldBlob, newBlob] = records[index].slice(1).split(' ');
@@ -31,6 +25,6 @@ export async function requireProductionContentChanges(root, before, after) {
31
25
  paths.push(path);
32
26
  }
33
27
  if (!paths.length)
34
- throw new Error('production_generation_no_content_change');
28
+ throw new Error('batch_generation_no_content_change');
35
29
  return paths;
36
30
  }
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { hashOperationPayload, sandboxBatchMemberDeliverSchema } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { findExternalLocalReferences } from '../previews/single-file.js';
6
6
  import { workspaceRoot } from '../workspaces/registry.js';
7
+ import { requireBatchContentChanges } from './content-changes.js';
7
8
  import { gitRead, gitWrite } from './git.js';
8
9
  import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
9
10
  import { filesystemObjectStore, memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
@@ -62,12 +63,15 @@ export async function deliverBatchMember(state, body, options) {
62
63
  directoryName: command.directoryName, phase: 'deliver', state: 'running', writersStopped: false, holding: true, stopRequested: false,
63
64
  processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId, commandHash: { ...existing?.commandHash, deliver: hash },
64
65
  snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, updatedAt: new Date().toISOString(),
66
+ sourceCommit: existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit,
67
+ beforeTree: existing?.beforeTree,
65
68
  });
66
69
  const buildRoot = join(state.config.stateDir, 'batch-builds', `${command.runId}-${randomUUID()}`);
67
70
  try {
68
71
  const savedCommit = command.fromCommit ?? await saveExact(root);
69
72
  if (command.fromCommit)
70
73
  await gitRead(root, ['cat-file', '-e', `${command.fromCommit}^{commit}`]);
74
+ await requireBatchContentChanges(root, record.sourceCommit ?? '', savedCommit);
71
75
  if (await readStopMarker(state, command.runId))
72
76
  return memberStatus(await markStopped(state, command, record));
73
77
  let pushedCommit;
@@ -146,5 +150,6 @@ async function markStopped(state, command, existing) {
146
150
  holding: false, stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
147
151
  headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
148
152
  branchName: existing?.branchName, updatedAt: new Date().toISOString(),
153
+ sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
149
154
  });
150
155
  }
@@ -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
  }
@@ -5,6 +5,8 @@ import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { hashOperationPayload, operationScopeKey, sandboxBatchMemberGenerateSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { attachNativeCollector, executeOperation } from '../server/dispatch.js';
7
7
  import { workspaceRoot } from '../workspaces/registry.js';
8
+ import { snapshotWorkspaceTree } from '../workspaces/git.js';
9
+ import { requireBatchContentChanges } from './content-changes.js';
8
10
  import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
9
11
  import { groupExited, stopProcessGroup } from './processes.js';
10
12
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
@@ -83,12 +85,15 @@ export async function generateBatchMember(state, body) {
83
85
  const prior = state.journal.getOperation(turnKey.scopeKey);
84
86
  if (prior && prior.payloadHash !== turnKey.record.payloadHash)
85
87
  throw new Error('batch_command_conflict');
88
+ const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
89
+ const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
86
90
  let record = await writeMember(state, {
87
91
  runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
88
92
  directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
89
93
  writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
90
94
  operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
91
95
  snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
96
+ sourceCommit, beforeTree,
92
97
  updatedAt: new Date().toISOString(),
93
98
  });
94
99
  if (prior)
@@ -172,8 +177,7 @@ async function finishGenerate(state, record, started, root, early) {
172
177
  return writeMember(state, { ...record, state: 'stopping', writersStopped: false, holding: true, operationId: started.operationId });
173
178
  if (await readStopMarker(state, record.runId))
174
179
  return stoppedRecord(state, record, record);
175
- const head = await import('./git.js').then(git => git.gitRead(root, ['rev-parse', 'HEAD'])).then(value => value.trim()).catch(() => record.headCommit);
176
- return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, operationId: started.operationId, headCommit: head, error: undefined });
180
+ return completeGeneration(state, { ...record, operationId: started.operationId }, root);
177
181
  }
178
182
  const writersStopped = await proveStopped(record);
179
183
  return writeMember(state, {
@@ -187,11 +191,27 @@ async function reconcileGenerate(state, record) {
187
191
  if (record.state === 'unknown' && session?.turnStatus === 'completed') {
188
192
  const writersStopped = await proveStopped(record);
189
193
  if (writersStopped && !await readStopMarker(state, record.runId)) {
190
- return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, error: undefined });
194
+ const root = await workspaceRoot(state, record.workspaceId, record.directoryName);
195
+ return completeGeneration(state, record, root);
191
196
  }
192
197
  }
193
198
  return record;
194
199
  }
200
+ async function completeGeneration(state, record, root) {
201
+ try {
202
+ const afterTree = await snapshotWorkspaceTree(root);
203
+ await requireBatchContentChanges(root, record.beforeTree ?? '', afterTree);
204
+ await requireBatchContentChanges(root, record.sourceCommit ?? '', afterTree);
205
+ }
206
+ catch (error) {
207
+ const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'batch_generation_failed';
208
+ return writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: code });
209
+ }
210
+ if (await readStopMarker(state, record.runId))
211
+ return stoppedRecord(state, record, record);
212
+ const head = await import('./git.js').then(git => git.gitRead(root, ['rev-parse', 'HEAD'])).then(value => value.trim()).catch(() => record.headCommit);
213
+ return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, headCommit: head, error: undefined });
214
+ }
195
215
  async function stoppedRecord(state, command, existing) {
196
216
  if (existing)
197
217
  await proveStopped(existing);
@@ -201,5 +221,6 @@ async function stoppedRecord(state, command, existing) {
201
221
  stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
202
222
  headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
203
223
  branchName: existing?.branchName, updatedAt: new Date().toISOString(),
224
+ sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
204
225
  });
205
226
  }
@@ -1,6 +1,6 @@
1
1
  import { listMembers } from './records.js';
2
2
  /** True while a member Run still holds the directory. Other sandbox write entries must refuse it. */
3
- export async function batchDirectoryHeld(state, workspaceId, directoryName) {
3
+ export async function batchDirectoryHeld(state, workspaceId, directoryName = 'basic') {
4
4
  if (!workspaceId)
5
5
  return false;
6
6
  const members = await listMembers(state);
@@ -140,6 +140,7 @@ export async function prepareBatchMember(state, body, options) {
140
140
  workspaceId: command.workspaceId, directoryName: command.directoryName, phase: 'prepare', state: 'running',
141
141
  writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [],
142
142
  commandHash: { ...existing?.commandHash, prepare: hash }, snapshotDigest: snapshot.digest, branchName: command.branchName,
143
+ sourceCommit: snapshot.commitHash, beforeTree: existing?.beforeTree,
143
144
  updatedAt: new Date().toISOString(),
144
145
  });
145
146
  const parent = await workspaceContainerRoot(state, command.workspaceId);
@@ -197,6 +198,7 @@ async function sealStopped(state, command, existing) {
197
198
  state: 'stopped', writersStopped: true, holding: false, stopRequested: true, processes: existing?.processes ?? [],
198
199
  sessionId: existing?.sessionId, operationId: existing?.operationId, headCommit: existing?.headCommit, evidence: existing?.evidence,
199
200
  commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName,
201
+ sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
200
202
  updatedAt: new Date().toISOString(),
201
203
  });
202
204
  }
@@ -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
+ }