@playcraft/cli 0.1.71 → 0.1.72

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.
@@ -8,6 +8,7 @@ export function getCliTopicsHelpText() {
8
8
  ' 素材库 library list/search/facets/get/files/download',
9
9
  ' 生成任务 assets estimate/generate/tasks/get/wait/cancel/download | assets animation',
10
10
  ' Remix 参考 references list/search/import(发现参考并读取当前工程中的固定快照)',
11
+ ' references prepare-starter-template(准备默认起始模板)',
11
12
  ' 视频理解 video models | video understand | video jobs get/cancel',
12
13
  ' 平台 build, build-base, build-playable, build-all, analyze, inspect, upgrade',
13
14
  ' tools 除 generate-* 外(Git / 沙箱构建 / 发布 / 列表 / Prefab 等,需后端 API)',
@@ -20,6 +20,8 @@ export function registerReferenceCommands(program) {
20
20
  const output = { schemaVersion: 'playcraft.references.v1', command: `references.${action}`, ...result };
21
21
  if (options.json)
22
22
  console.log(JSON.stringify(output));
23
+ else if (action === 'prepare-starter-template')
24
+ console.log(result.path);
23
25
  else if (action === 'import') {
24
26
  const reference = result.reference;
25
27
  console.log(`${reference.name}\n${reference.resolvedPath}`);
@@ -43,6 +45,7 @@ export function registerReferenceCommands(program) {
43
45
  const scope = (command) => command.option('--workspace <id>', message('workspace'))
44
46
  .option('--directory <name>', message('directory')).option('--json', message('json'));
45
47
  scope(references.command('list').description(message('list'))).action(options => run('list', options));
48
+ scope(references.command('prepare-starter-template').description(message('prepareStarterTemplate'))).action(options => run('prepare-starter-template', options));
46
49
  scope(references.command('search [query]').description(message('search'))
47
50
  .option('--source <source>', message('source'), 'recommended')
48
51
  .option('--tag <value>', message('tag'), (value, previous) => [...previous, value], [])
@@ -100,6 +100,8 @@ export function registerBatchCommands(tools) {
100
100
  tools.command('get-batch')
101
101
  .description(t.status)
102
102
  .requiredOption('--batch-id <id>', t.id)
103
+ // Output is always JSON; accept the flag so status reads match create-batch and sync-batch.
104
+ .option('--json', 'JSON (JavaScript Object Notation, 数据交换格式)')
103
105
  .action(async (opts) => {
104
106
  try {
105
107
  const client = new AgentApiClient();
@@ -3,6 +3,7 @@ const messages = {
3
3
  description: 'Discover and import Remix source references for the current cloud workspace',
4
4
  list: 'List verified local Remix references and their paths', search: 'Search accessible recommended or community Remixes',
5
5
  import: 'Import a fixed Remix source snapshot into the current engineering directory',
6
+ prepareStarterTemplate: 'Prepare a verified starter template in a temporary directory',
6
7
  query: 'Search names, branches, tags, creators or templates', source: 'recommended or community', project: 'Source project identifier', branch: 'Exact source branch',
7
8
  tag: 'Match a tag value; repeat to match any of the values', tagCategory: 'Tag category; defaults to Gameplay when --tag is used',
8
9
  workspace: 'Current cloud workspace identifier (must match this conversation)', directory: 'Current engineering directory (must match this conversation)',
@@ -22,6 +23,7 @@ const messages = {
22
23
  zh: {
23
24
  description: '为当前云工作区发现并导入 Remix 源码参考',
24
25
  list: '列出已验证的本地 Remix 参考及路径', search: '搜索可访问的推荐或社区 Remix', import: '把固定版本的 Remix 源码参考导入当前工程',
26
+ prepareStarterTemplate: '将经过校验的起始模板准备到临时目录',
25
27
  query: '搜索名称、分支、标签、创建者或模板', source: 'recommended(推荐)或 community(社区)', project: '来源项目标识', branch: '准确的来源分支名',
26
28
  tag: '匹配的标签值;可重复指定,匹配任意一个值', tagCategory: '标签分类;使用 --tag 时默认为 Gameplay',
27
29
  workspace: '当前云工作区标识(必须与本对话一致)', directory: '当前工程目录(必须与本对话一致)',
@@ -3,18 +3,38 @@ import { z } from 'zod';
3
3
  const scope = {
4
4
  cwd: z.string().min(1).max(4096),
5
5
  workspaceId: z.string().uuid().optional(),
6
- directoryName: z.string().regex(/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/).optional(),
6
+ directoryName: z
7
+ .string()
8
+ .regex(/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/)
9
+ .optional(),
7
10
  };
8
- export const remixReferenceToolRequestSchema = z.discriminatedUnion('action', [
11
+ export const remixReferenceToolRequestSchema = z
12
+ .discriminatedUnion('action', [
9
13
  z.object({ ...scope, action: z.literal('list') }).strict(),
10
- z.object({ ...scope, action: z.literal('search'), source: z.enum(['recommended', 'community']),
14
+ z.object({ ...scope, action: z.literal('prepare-starter-template') }).strict(),
15
+ z
16
+ .object({
17
+ ...scope,
18
+ action: z.literal('search'),
19
+ source: z.enum(['recommended', 'community']),
11
20
  tagCategory: z.string().trim().min(1).max(128).optional(),
12
21
  tagValues: z.array(z.string().trim().min(1).max(255)).min(1).max(20).optional(),
13
- q: z.string().max(200).optional(), page: z.number().int().min(1).max(10000).default(1),
14
- limit: z.number().int().min(1).max(50).default(12) }).strict(),
15
- z.object({ ...scope, action: z.literal('import'), projectUuid: z.string().uuid(),
16
- branchName: z.string().min(1).max(255) }).strict(),
17
- ]).refine(input => input.action !== 'search' || (input.tagCategory === undefined) === (input.tagValues === undefined));
22
+ q: z.string().max(200).optional(),
23
+ page: z.number().int().min(1).max(10000).default(1),
24
+ limit: z.number().int().min(1).max(50).default(12),
25
+ })
26
+ .strict(),
27
+ z
28
+ .object({
29
+ ...scope,
30
+ action: z.literal('import'),
31
+ projectUuid: z.string().uuid(),
32
+ branchName: z.string().min(1).max(255),
33
+ })
34
+ .strict(),
35
+ ])
36
+ .refine((input) => input.action !== 'search' ||
37
+ (input.tagCategory === undefined) === (input.tagValues === undefined));
18
38
  export class RemixReferenceToolError extends Error {
19
39
  code;
20
40
  constructor(code) {
@@ -25,8 +45,15 @@ export class RemixReferenceToolError extends Error {
25
45
  }
26
46
  export function localRemixReference(root, reference) {
27
47
  const { files, omittedFiles, ...metadata } = reference;
28
- return { ...metadata, source: { ...metadata.source,
29
- recommended: metadata.source.recommended ?? metadata.source.category === 'recommended' },
30
- resolvedPath: join(root, reference.path), fileCount: files.length,
31
- omittedFileCount: omittedFiles?.length ?? 0 };
48
+ return {
49
+ ...metadata,
50
+ source: {
51
+ ...metadata.source,
52
+ recommended: ('recommended' in metadata.source ? metadata.source.recommended : undefined) ??
53
+ metadata.source.category === 'recommended',
54
+ },
55
+ resolvedPath: join(root, reference.path),
56
+ fileCount: files.length,
57
+ omittedFileCount: omittedFiles?.length ?? 0,
58
+ };
32
59
  }
@@ -6,11 +6,11 @@ import { workspaceRoot } from '../workspaces/registry.js';
6
6
  import { gitRead } from './git.js';
7
7
  import { captureIndependentSource } from './source-capture.js';
8
8
  import { filesystemObjectStore, readSnapshot, serializeFreeze, sourceCacheDir, writeSnapshot, } from './records.js';
9
- import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from './source-candidate.js';
9
+ import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate, } from './source-candidate.js';
10
10
  import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
11
11
  import { publishSourceCandidate } from './source-publish.js';
12
12
  import { clearDirectoryFreeze, withDirectoryFreeze } from '../server/directory-access.js';
13
- import { adoptPublicationBinding, publicationBinding, publicationGitOptions } from './publication.js';
13
+ import { adoptPublicationBinding, publicationBinding, publicationGitOptions, } from './publication.js';
14
14
  async function usagePercent(path) {
15
15
  const disk = await statfs(path);
16
16
  if (disk.blocks <= 0)
@@ -23,7 +23,7 @@ async function stopManagedWriters(state, workspaceId, directoryName) {
23
23
  for (const write of state.journal.listManagedWrites()) {
24
24
  if (write.stoppedAt || write.workspaceId !== scope)
25
25
  continue;
26
- if (write.pid === undefined || !await stopBackgroundWriter(write.pid))
26
+ if (write.pid === undefined || !(await stopBackgroundWriter(write.pid)))
27
27
  continue;
28
28
  await state.journal.putManagedWrite({ ...write, stoppedAt: new Date().toISOString() });
29
29
  }
@@ -42,8 +42,11 @@ export async function freezeBatchSource(state, body, options) {
42
42
  await clearDirectoryFreeze(state, request.batchId);
43
43
  return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: frozen });
44
44
  }
45
- return withDirectoryFreeze(state, { workspaceId: request.workspaceId,
46
- directoryName: request.sourceDirectoryName, batchId: request.batchId }, () => serializeFreeze(async () => {
45
+ return withDirectoryFreeze(state, {
46
+ workspaceId: request.workspaceId,
47
+ directoryName: request.sourceDirectoryName,
48
+ batchId: request.batchId,
49
+ }, () => serializeFreeze(async () => {
47
50
  let candidate;
48
51
  let source;
49
52
  try {
@@ -56,7 +59,12 @@ export async function freezeBatchSource(state, body, options) {
56
59
  return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: existing });
57
60
  }
58
61
  if (!pending && 'resumeOnly' in request && request.resumeOnly === true) {
59
- throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_candidate_lost', stage: 'capture', retryable: false, outcome: 'unknown' });
62
+ throw await sourceFreezeError(state, request.batchId, {
63
+ code: 'batch_source_candidate_lost',
64
+ stage: 'capture',
65
+ retryable: false,
66
+ outcome: 'unknown',
67
+ });
60
68
  }
61
69
  if (pending)
62
70
  assertSourceIdentity(state, request, pending.snapshot, pending);
@@ -70,26 +78,40 @@ export async function freezeBatchSource(state, body, options) {
70
78
  if (pending)
71
79
  candidate = pending;
72
80
  else {
73
- const percent = Math.min(100, Math.max(0, options?.usagePercent ?? await usagePercent(source)));
81
+ const percent = Math.min(100, Math.max(0, options?.usagePercent ?? (await usagePercent(source))));
74
82
  if (workspaceDiskAdmission(percent) === 'paused')
75
83
  throw new Error('disk_admission_paused');
76
84
  await stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName);
77
85
  const cache = sourceCacheDir(state, request.batchId);
78
86
  await rm(cache, { recursive: true, force: true });
79
- const captured = await captureIndependentSource(source, cache);
87
+ const captured = await captureIndependentSource(source, cache, request.starterTemplate, {
88
+ batchId: request.batchId,
89
+ workspaceId: request.workspaceId,
90
+ directoryName: request.sourceDirectoryName,
91
+ });
80
92
  // private
81
93
  await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
82
94
  const snapshot = {
83
- batchId: request.batchId, commitHash: captured.commitHash, digest: captured.digest,
84
- sourceWorkspaceId: request.workspaceId, sourceDirectoryName: request.sourceDirectoryName,
95
+ batchId: request.batchId,
96
+ commitHash: captured.commitHash,
97
+ digest: captured.digest,
98
+ sourceWorkspaceId: request.workspaceId,
99
+ sourceDirectoryName: request.sourceDirectoryName,
85
100
  frozenAt: new Date((await gitRead(cache, ['show', '-s', '--format=%cI', 'HEAD'])).trim()).toISOString(),
86
- ...(request.persist === 'remote_ref' ? { remoteRef: batchSourceRemoteRef(request.batchId) }
87
- : { artifactKey: `workspaces/${request.workspaceId}/batches/${request.batchId}/source/${captured.digest}.json.gz` }),
101
+ ...(request.persist === 'remote_ref'
102
+ ? { remoteRef: batchSourceRemoteRef(request.batchId) }
103
+ : {
104
+ artifactKey: `workspaces/${request.workspaceId}/batches/${request.batchId}/source/${captured.digest}.json.gz`,
105
+ }),
88
106
  };
89
107
  candidate = {
90
- version: 1, persist: request.persist, repositoryUrl: binding?.repositoryUrl ?? undefined, projectId: binding?.projectId,
91
- branchName: request.sourceDirectoryName === 'basic' ? binding?.branchName ?? undefined
92
- : binding?.directories?.find(directory => directory.directoryName === request.sourceDirectoryName)?.branchName ?? undefined,
108
+ version: 1,
109
+ persist: request.persist,
110
+ repositoryUrl: binding?.repositoryUrl ?? undefined,
111
+ projectId: binding?.projectId,
112
+ branchName: request.sourceDirectoryName === 'basic'
113
+ ? (binding?.branchName ?? undefined)
114
+ : (binding?.directories?.find((directory) => directory.directoryName === request.sourceDirectoryName)?.branchName ?? undefined),
93
115
  snapshot,
94
116
  };
95
117
  await writeSourceCandidate(state, candidate);
@@ -98,12 +120,44 @@ export async function freezeBatchSource(state, body, options) {
98
120
  catch (error) {
99
121
  if (error instanceof BatchSourceFreezeError)
100
122
  throw error;
101
- const known = new Set(['disk_admission_paused', 'disk_usage_unavailable', 'batch_source_changed', 'batch_source_path_invalid',
102
- 'workspace_not_registered', 'batch_source_identity_mismatch', 'batch_source_snapshot_mismatch']);
103
- const code = error instanceof Error && known.has(error.message) ? error.message : 'batch_source_capture_failed';
123
+ const known = new Set([
124
+ 'disk_admission_paused',
125
+ 'disk_usage_unavailable',
126
+ 'batch_source_changed',
127
+ 'batch_source_path_invalid',
128
+ 'workspace_not_registered',
129
+ 'batch_source_identity_mismatch',
130
+ 'batch_source_snapshot_mismatch',
131
+ 'batch_source_candidate_lost',
132
+ 'starter_template_unavailable',
133
+ 'starter_template_invalid',
134
+ 'starter_template_path_conflict',
135
+ 'batch_source_ignored_files',
136
+ ]);
137
+ const code = error instanceof Error && known.has(error.message)
138
+ ? error.message
139
+ : 'batch_source_capture_failed';
104
140
  throw await sourceFreezeError(state, request.batchId, {
105
- code, stage: 'capture', retryable: !['batch_source_path_invalid', 'batch_source_identity_mismatch', 'batch_source_snapshot_mismatch'].includes(code),
106
- outcome: code === 'batch_source_identity_mismatch' || code === 'batch_source_snapshot_mismatch' ? 'unknown' : 'not_published',
141
+ code,
142
+ stage: 'capture',
143
+ retryable: !(code === 'batch_source_changed' &&
144
+ request.starterTemplate &&
145
+ 'snapshot' in request.starterTemplate) &&
146
+ ![
147
+ 'batch_source_path_invalid',
148
+ 'batch_source_identity_mismatch',
149
+ 'batch_source_snapshot_mismatch',
150
+ 'batch_source_candidate_lost',
151
+ 'starter_template_unavailable',
152
+ 'starter_template_invalid',
153
+ 'starter_template_path_conflict',
154
+ 'batch_source_ignored_files',
155
+ ].includes(code),
156
+ outcome: code === 'batch_source_identity_mismatch' ||
157
+ code === 'batch_source_snapshot_mismatch' ||
158
+ code === 'batch_source_candidate_lost'
159
+ ? 'unknown'
160
+ : 'not_published',
107
161
  }, error);
108
162
  }
109
163
  await publishSourceCandidate(state, candidate, source, options?.objectStore ?? filesystemObjectStore(state), request.publication);
@@ -113,14 +167,30 @@ export async function freezeBatchSource(state, body, options) {
113
167
  await writeSnapshot(state, candidate.snapshot);
114
168
  }
115
169
  catch (error) {
116
- throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
170
+ throw await sourceFreezeError(state, request.batchId, {
171
+ code: 'batch_source_verification_failed',
172
+ stage: 'verify',
173
+ retryable: true,
174
+ outcome: 'unknown',
175
+ }, error);
117
176
  }
118
- return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: candidate.snapshot });
177
+ return batchSourceFreezeResultSchema.parse({
178
+ status: 'frozen',
179
+ snapshot: candidate.snapshot,
180
+ });
119
181
  }), () => stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName), {
120
- signal: options?.signal, deadline: 'deadline' in request && typeof request.deadline === 'string' ? request.deadline : undefined,
182
+ signal: options?.signal,
183
+ deadline: 'deadline' in request && typeof request.deadline === 'string'
184
+ ? request.deadline
185
+ : undefined,
121
186
  }).catch(async (error) => {
122
187
  if (error instanceof Error && error.message === 'directory_access_cancelled')
123
- throw await sourceFreezeError(state, request.batchId, { code: 'source_freeze_timeout', stage: 'capture', retryable: false, outcome: 'not_published' });
188
+ throw await sourceFreezeError(state, request.batchId, {
189
+ code: 'source_freeze_timeout',
190
+ stage: 'capture',
191
+ retryable: false,
192
+ outcome: 'not_published',
193
+ });
124
194
  throw error;
125
195
  });
126
196
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { batchSourceSnapshotRefSchema, sandboxBatchMemberStatusSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { batchSourceSnapshotRefSchema, normalizeLegacyBatchSourceSnapshot, sandboxBatchMemberStatusSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { releaseBatchDirectory } from '../server/directory-access.js';
6
6
  const runs = new Map();
7
7
  let freezeChain = Promise.resolve();
@@ -29,7 +29,7 @@ export function sourceCacheDir(state, batchId) {
29
29
  return join(state.config.stateDir, 'batch-source-cache', batchId);
30
30
  }
31
31
  export function objectFile(state, key) {
32
- if (key.split('/').some(part => !part || part === '.' || part === '..'))
32
+ if (key.split('/').some((part) => !part || part === '.' || part === '..'))
33
33
  throw new Error('batch_object_key_invalid');
34
34
  return join(state.config.stateDir, 'batch-objects', key);
35
35
  }
@@ -53,7 +53,9 @@ async function readJson(path) {
53
53
  }
54
54
  export async function readSnapshot(state, batchId) {
55
55
  const value = await readJson(snapshotFile(state, batchId));
56
- return value ? batchSourceSnapshotRefSchema.parse(value) : undefined;
56
+ return value
57
+ ? batchSourceSnapshotRefSchema.parse(normalizeLegacyBatchSourceSnapshot(value))
58
+ : undefined;
57
59
  }
58
60
  export async function writeSnapshot(state, snapshot) {
59
61
  const parsed = batchSourceSnapshotRefSchema.parse(snapshot);
@@ -70,7 +72,7 @@ export async function writeMember(state, record) {
70
72
  }
71
73
  export async function listMembers(state) {
72
74
  const directory = join(state.config.stateDir, 'batch-members');
73
- const names = await readdir(directory).catch(error => {
75
+ const names = await readdir(directory).catch((error) => {
74
76
  if (error.code === 'ENOENT')
75
77
  return [];
76
78
  throw error;
@@ -111,7 +113,11 @@ export function memberStatus(record) {
111
113
  }
112
114
  export function filesystemObjectStore(state) {
113
115
  return {
114
- async put(key, body) { await writeAtomic(objectFile(state, key), body); },
115
- async get(key) { return readFile(objectFile(state, key)); },
116
+ async put(key, body) {
117
+ await writeAtomic(objectFile(state, key), body);
118
+ },
119
+ async get(key) {
120
+ return readFile(objectFile(state, key));
121
+ },
116
122
  };
117
123
  }
@@ -1,4 +1,4 @@
1
- import { batchSourceFreezeResultSchema } from '@playcraft/common/cloud-workspace/runtime';
1
+ import { batchSourceFreezeResultSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
2
2
  import { stopBatchMember, readBatchMemberStatus } from './control.js';
3
3
  import { deliverBatchMember } from './deliver.js';
4
4
  import { freezeBatchSource } from './freeze.js';
@@ -6,17 +6,28 @@ import { generateBatchMember } from './generate.js';
6
6
  import { prepareBatchMember } from './prepare.js';
7
7
  import { BatchSourceFreezeError } from './source-failure.js';
8
8
  const CLIENT_ERRORS = new Set([
9
- 'occupancy_required', 'invalid_batch_request', 'batch_branch_invalid', 'stale_owner', 'disk_admission_paused',
10
- 'batch_source_push_failed', 'batch_source_upload_failed', 'batch_source_changed', 'batch_source_path_invalid',
11
- 'batch_command_conflict', 'batch_snapshot_mismatch', 'batch_commit_mismatch', 'workspace_not_registered',
12
- 'disk_usage_unavailable', 'batch_object_key_invalid',
9
+ 'occupancy_required',
10
+ 'invalid_batch_request',
11
+ 'batch_branch_invalid',
12
+ 'stale_owner',
13
+ 'disk_admission_paused',
14
+ 'batch_source_push_failed',
15
+ 'batch_source_upload_failed',
16
+ 'batch_source_changed',
17
+ 'batch_source_path_invalid',
18
+ 'batch_command_conflict',
19
+ 'batch_snapshot_mismatch',
20
+ 'batch_commit_mismatch',
21
+ 'workspace_not_registered',
22
+ 'disk_usage_unavailable',
23
+ 'batch_object_key_invalid',
13
24
  ]);
14
- async function readBody(request) {
25
+ async function readBody(request, maxBytes = 1024 * 1024) {
15
26
  const chunks = [];
16
27
  let size = 0;
17
28
  for await (const chunk of request) {
18
29
  size += chunk.length;
19
- if (size > 1024 * 1024)
30
+ if (size > maxBytes)
20
31
  throw new Error('request_body_too_large');
21
32
  chunks.push(Buffer.from(chunk));
22
33
  }
@@ -30,7 +41,9 @@ export async function dispatchBatchRoute(state, request, url) {
30
41
  const member = /^\/runtime\/batch-members\/([^/]+)(?:\/(stop))?$/.exec(path);
31
42
  if (method === 'GET' && member && !member[2]) {
32
43
  const status = await readBatchMemberStatus(state, decodeURIComponent(member[1]));
33
- return status ? { status: 200, body: status } : { status: 404, body: { error: 'batch_member_not_found' } };
44
+ return status
45
+ ? { status: 200, body: status }
46
+ : { status: 404, body: { error: 'batch_member_not_found' } };
34
47
  }
35
48
  if (method !== 'POST')
36
49
  return undefined;
@@ -40,7 +53,7 @@ export async function dispatchBatchRoute(state, request, url) {
40
53
  const disconnected = () => controller.abort();
41
54
  request.socket.once('close', disconnected);
42
55
  try {
43
- const result = batchSourceFreezeResultSchema.parse(await freezeBatchSource(state, await readBody(request), { signal: controller.signal }));
56
+ const result = batchSourceFreezeResultSchema.parse(await freezeBatchSource(state, await readBody(request, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES), { signal: controller.signal }));
44
57
  return { status: 200, body: result };
45
58
  }
46
59
  finally {
@@ -54,7 +67,10 @@ export async function dispatchBatchRoute(state, request, url) {
54
67
  if (path === '/runtime/batch-members/deliver')
55
68
  return { status: 200, body: await deliverBatchMember(state, await readBody(request)) };
56
69
  if (member?.[2] === 'stop')
57
- return { status: 200, body: await stopBatchMember(state, decodeURIComponent(member[1]), await readBody(request)) };
70
+ return {
71
+ status: 200,
72
+ body: await stopBatchMember(state, decodeURIComponent(member[1]), await readBody(request)),
73
+ };
58
74
  return undefined;
59
75
  }
60
76
  catch (error) {
@@ -63,7 +79,11 @@ export async function dispatchBatchRoute(state, request, url) {
63
79
  const code = error instanceof Error && CLIENT_ERRORS.has(error.message) ? error.message : undefined;
64
80
  if (!code)
65
81
  throw error;
66
- const status = code === 'occupancy_required' || code === 'invalid_batch_request' || code === 'batch_branch_invalid' ? 400 : 409;
82
+ const status = code === 'occupancy_required' ||
83
+ code === 'invalid_batch_request' ||
84
+ code === 'batch_branch_invalid'
85
+ ? 400
86
+ : 409;
67
87
  return { status, body: { error: code } };
68
88
  }
69
89
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { link, mkdir, open, readFile, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { batchSourceSnapshotRefSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { batchSourceSnapshotRefSchema, normalizeLegacyBatchSourceSnapshot, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { publicationBinding } from './publication.js';
6
6
  export function sourceCandidateFile(state, batchId) {
7
7
  return join(state.config.stateDir, 'batch-source-candidates', `${batchId}.json`);
@@ -23,12 +23,16 @@ export async function readSourceCandidate(state, batchId) {
23
23
  catch {
24
24
  throw new Error('batch_source_snapshot_mismatch');
25
25
  }
26
- if (!stored || stored.version !== 1 || !['remote_ref', 'artifact'].includes(stored.persist)
27
- || (stored.repositoryUrl !== undefined && typeof stored.repositoryUrl !== 'string')
28
- || (stored.projectId !== undefined && stored.projectId !== null && typeof stored.projectId !== 'string')
29
- || (stored.branchName !== undefined && typeof stored.branchName !== 'string'))
26
+ if (!stored ||
27
+ stored.version !== 1 ||
28
+ !['remote_ref', 'artifact'].includes(stored.persist) ||
29
+ (stored.repositoryUrl !== undefined && typeof stored.repositoryUrl !== 'string') ||
30
+ (stored.projectId !== undefined &&
31
+ stored.projectId !== null &&
32
+ typeof stored.projectId !== 'string') ||
33
+ (stored.branchName !== undefined && typeof stored.branchName !== 'string'))
30
34
  throw new Error('batch_source_snapshot_mismatch');
31
- const parsed = batchSourceSnapshotRefSchema.safeParse(stored.snapshot);
35
+ const parsed = batchSourceSnapshotRefSchema.safeParse(normalizeLegacyBatchSourceSnapshot(stored.snapshot));
32
36
  if (!parsed.success)
33
37
  throw new Error('batch_source_snapshot_mismatch');
34
38
  const snapshot = parsed.data;
@@ -74,16 +78,25 @@ export async function writeSourceCandidate(state, candidate) {
74
78
  }
75
79
  export function assertSourceIdentity(state, request, snapshot, candidate) {
76
80
  const binding = publicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication);
77
- const branchName = request.sourceDirectoryName === 'basic' ? binding?.branchName
78
- : binding?.directories?.find(directory => directory.directoryName === request.sourceDirectoryName)?.branchName;
79
- if (snapshot.batchId !== request.batchId || snapshot.sourceWorkspaceId !== request.workspaceId
80
- || snapshot.sourceDirectoryName !== request.sourceDirectoryName
81
- || (request.persist === 'remote_ref' ? !snapshot.remoteRef || Boolean(snapshot.artifactKey) : !snapshot.artifactKey || Boolean(snapshot.remoteRef))
82
- || (candidate && (candidate.persist !== request.persist || candidate.repositoryUrl !== (binding?.repositoryUrl ?? undefined)
83
- || candidate.projectId !== binding?.projectId || candidate.branchName !== (branchName ?? undefined)
84
- || candidate.snapshot.commitHash !== snapshot.commitHash || candidate.snapshot.digest !== snapshot.digest
85
- || candidate.snapshot.frozenAt !== snapshot.frozenAt || candidate.snapshot.remoteRef !== snapshot.remoteRef
86
- || candidate.snapshot.artifactKey !== snapshot.artifactKey))) {
81
+ const branchName = request.sourceDirectoryName === 'basic'
82
+ ? binding?.branchName
83
+ : binding?.directories?.find((directory) => directory.directoryName === request.sourceDirectoryName)?.branchName;
84
+ if (snapshot.batchId !== request.batchId ||
85
+ snapshot.sourceWorkspaceId !== request.workspaceId ||
86
+ snapshot.sourceDirectoryName !== request.sourceDirectoryName ||
87
+ (request.persist === 'remote_ref'
88
+ ? !snapshot.remoteRef || Boolean(snapshot.artifactKey)
89
+ : !snapshot.artifactKey || Boolean(snapshot.remoteRef)) ||
90
+ (candidate &&
91
+ (candidate.persist !== request.persist ||
92
+ candidate.repositoryUrl !== (binding?.repositoryUrl ?? undefined) ||
93
+ candidate.projectId !== binding?.projectId ||
94
+ candidate.branchName !== (branchName ?? undefined) ||
95
+ candidate.snapshot.commitHash !== snapshot.commitHash ||
96
+ candidate.snapshot.digest !== snapshot.digest ||
97
+ candidate.snapshot.frozenAt !== snapshot.frozenAt ||
98
+ candidate.snapshot.remoteRef !== snapshot.remoteRef ||
99
+ candidate.snapshot.artifactKey !== snapshot.artifactKey))) {
87
100
  throw new Error('batch_source_identity_mismatch');
88
101
  }
89
102
  }