@playcraft/cli 0.1.71 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +3 -0
- package/dist/commands/tools-batch.js +2 -0
- package/dist/references/messages.js +2 -0
- package/dist/references/protocol.js +39 -12
- package/dist/workspace-runtime/batch/freeze.js +94 -24
- package/dist/workspace-runtime/batch/records.js +12 -6
- package/dist/workspace-runtime/batch/routes.js +31 -11
- package/dist/workspace-runtime/batch/source-candidate.js +29 -16
- package/dist/workspace-runtime/batch/source-capture.js +251 -20
- package/dist/workspace-runtime/codex/app-server.js +7 -2
- package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
- package/dist/workspace-runtime/persistence/journal.js +3 -0
- package/dist/workspace-runtime/serve.js +3 -1
- package/dist/workspace-runtime/server/directory-access.js +10 -0
- package/dist/workspace-runtime/server/http.js +335 -119
- package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
- package/dist/workspace-runtime/workspaces/files.js +39 -9
- package/dist/workspace-runtime/workspaces/platform-export.js +33 -9
- package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
- package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
- package/package.json +4 -3
package/dist/cli-root-help.js
CHANGED
|
@@ -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
|
|
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
|
|
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('
|
|
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(),
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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, {
|
|
46
|
-
|
|
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, {
|
|
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,
|
|
84
|
-
|
|
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'
|
|
87
|
-
|
|
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,
|
|
91
|
-
|
|
92
|
-
|
|
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([
|
|
102
|
-
'
|
|
103
|
-
|
|
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,
|
|
106
|
-
|
|
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, {
|
|
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({
|
|
177
|
+
return batchSourceFreezeResultSchema.parse({
|
|
178
|
+
status: 'frozen',
|
|
179
|
+
snapshot: candidate.snapshot,
|
|
180
|
+
});
|
|
119
181
|
}), () => stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName), {
|
|
120
|
-
signal: options?.signal,
|
|
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, {
|
|
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
|
|
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) {
|
|
115
|
-
|
|
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',
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
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 >
|
|
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
|
|
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 {
|
|
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' ||
|
|
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 ||
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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'
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
}
|