@playcraft/cli 0.1.49-beta → 0.1.50-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.
@@ -12,6 +12,7 @@ import { classifyPreviewOutput, findExternalLocalReferences } from './single-fil
12
12
  import { saveWorkspacePlayableOutput } from './output.js';
13
13
  import { injectPreviewDiagnostics } from './diagnostics.js';
14
14
  import { OperationLogStore } from '../workspaces/operation-log.js';
15
+ import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
15
16
  const BUILD_TIMEOUT_MS = 10 * 60_000;
16
17
  /** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
17
18
  const OUTPUT_FOLDER = '.playcraft-preview-out';
@@ -78,17 +79,23 @@ export async function runPreviewPublish(state, operation, root) {
78
79
  await operationLog.append(operation.operationId, `$ preview build\n`).catch(() => undefined);
79
80
  try {
80
81
  await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
82
+ const runtimePreview = await prepareRuntimePreview(state, operation, root);
83
+ if (runtimePreview)
84
+ await excludeFromGit(root, '/.playcraft-editor-preview/');
81
85
  await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
82
86
  await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
83
87
  await excludeFromGit(root, `/${WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY}/`);
84
- await buildPreview(state, root, outputDir, operation.operationId, output, appendLog);
85
- const bytes = injectPreviewDiagnostics(await readSingleHtml(outputDir));
88
+ await buildPreview(state, root, outputDir, operation.operationId, output, appendLog, runtimePreview?.command);
89
+ const runtimeBuild = runtimePreview ? await runtimePreview.finish() : undefined;
90
+ const built = runtimeBuild?.bytes ?? await readSingleHtml(outputDir);
91
+ const bytes = injectPreviewDiagnostics(built);
86
92
  if (bytes.byteLength > PREVIEW_HTML_MAX_BYTES)
87
93
  throw new PreviewFailure('preview_not_single_file');
88
94
  if (findExternalLocalReferences(bytes.toString('utf8')).length > 0) {
89
95
  throw new PreviewFailure('preview_not_single_file');
90
96
  }
91
- await saveWorkspacePlayableOutput(root, bytes);
97
+ if (!runtimePreview)
98
+ await saveWorkspacePlayableOutput(root, bytes);
92
99
  const contentDigest = createHash('sha256').update(bytes).digest('hex');
93
100
  const contentMd5 = createHash('md5').update(bytes).digest('base64');
94
101
  const source = await sourceRevision(root);
@@ -132,7 +139,9 @@ export async function runPreviewPublish(state, operation, root) {
132
139
  logTail: tail(output.log),
133
140
  }));
134
141
  if (published.sessionId !== operation.sessionId || published.previewId !== reserved.previewId ||
135
- published.artifactId !== reserved.artifactId) {
142
+ published.artifactId !== reserved.artifactId ||
143
+ published.contentDigest !== contentDigest ||
144
+ (runtimeBuild && JSON.stringify(published.runtimeParameters) !== JSON.stringify(runtimeBuild.profile))) {
136
145
  throw new PreviewFailure('preview_scope_mismatch');
137
146
  }
138
147
  if (published.status === 'conflict')
@@ -140,7 +149,7 @@ export async function runPreviewPublish(state, operation, root) {
140
149
  return published;
141
150
  }
142
151
  catch (error) {
143
- const code = error instanceof PreviewFailure ? error.code : 'preview_build_failed';
152
+ const code = error instanceof PreviewFailure || error instanceof RuntimePreviewBuildError ? error.code : 'preview_build_failed';
144
153
  appendLog(`\n[${code}] ${error instanceof Error ? error.message : code}\n`);
145
154
  if (code !== 'publish_conflict') {
146
155
  await postControl(state, '/runtime/internal/preview-failures', {
@@ -180,13 +189,13 @@ async function ensureDependencies(state, workspaceId, root, output) {
180
189
  throw new PreviewFailure('dependencies_not_ready');
181
190
  }
182
191
  }
183
- async function buildPreview(state, root, outputDir, operationId, output, onLog) {
192
+ async function buildPreview(state, root, outputDir, operationId, output, onLog, runtimeCommand) {
184
193
  // Tests replace the build; the real one needs a project and the published CLI.
185
194
  const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
186
195
  const outcome = await runDeveloperCommand(state, {
187
196
  processId: `preview-${operationId}`,
188
197
  cwd: root,
189
- command: `${planned} --output ${shellQuote(outputDir)} .`,
198
+ command: runtimeCommand ?? `${planned} --output ${shellQuote(outputDir)} .`,
190
199
  timeoutMs: BUILD_TIMEOUT_MS,
191
200
  onOutput: (chunk) => {
192
201
  const text = String(chunk);
@@ -0,0 +1,177 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, readFile, realpath, rm } from 'node:fs/promises';
3
+ import { relative, resolve, sep } from 'node:path';
4
+ import JSON5 from 'json5';
5
+ import { z } from 'zod';
6
+ import { embedWorkspaceRuntimeProfile, workspaceRelativePathSchema, workspaceRuntimePreviewProfileSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { readWorkspaceParameters } from '../workspaces/parameters.js';
8
+ import { revisionScope } from '../workspaces/registry.js';
9
+ const manifestPath = '.playcraft/workspace.json';
10
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
11
+ const relativePath = z
12
+ .string()
13
+ .refine((path) => workspaceRelativePathSchema.safeParse(path).success);
14
+ const configSchema = z
15
+ .object({
16
+ version: z.literal(1),
17
+ buildScript: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/),
18
+ outputPath: relativePath.refine((path) => path.startsWith('.playcraft-editor-preview/') && path.endsWith('.html')),
19
+ reportPath: relativePath.refine((path) => path.startsWith('.playcraft-editor-preview/') && path.endsWith('.json')),
20
+ configContractVersion: z.string().min(1).max(128),
21
+ gameFilePaths: z.array(relativePath).max(256).default([]),
22
+ })
23
+ .strict();
24
+ const reportSchema = z
25
+ .object({
26
+ target: z.literal('editor-static'),
27
+ buildId: z.string(),
28
+ htmlDigest: z.string().regex(/^[a-f0-9]{64}$/),
29
+ schemaDigest: z.string().regex(/^[a-f0-9]{64}$/),
30
+ sourceFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
31
+ dataHash: z.string().regex(/^[a-f0-9]{64}$/),
32
+ dataRevision: z.number().int().nonnegative(),
33
+ schemaRevision: z.number().int().nonnegative(),
34
+ moduleIds: z.array(z.string()).min(1).max(10000),
35
+ })
36
+ .strict();
37
+ export class RuntimePreviewBuildError extends Error {
38
+ code;
39
+ constructor(code) {
40
+ super(code);
41
+ this.code = code;
42
+ }
43
+ }
44
+ async function insideFile(root, path) {
45
+ const base = await realpath(root);
46
+ const file = await isolatedPath(root, path);
47
+ const real = await realpath(file);
48
+ const rel = relative(base, real);
49
+ const stat = await lstat(file);
50
+ if (!stat.isFile() ||
51
+ stat.isSymbolicLink() ||
52
+ rel === '..' ||
53
+ rel.startsWith(`..${sep}`) ||
54
+ stat.size > 50 * 1024 * 1024)
55
+ throw new RuntimePreviewBuildError('runtime_preview_path_invalid');
56
+ return readFile(file);
57
+ }
58
+ async function isolatedPath(root, path) {
59
+ const normalized = workspaceRelativePathSchema.parse(path);
60
+ const base = await realpath(root);
61
+ let parent = base;
62
+ for (const segment of normalized.split('/').slice(0, -1)) {
63
+ parent = resolve(parent, segment);
64
+ const stat = await lstat(parent).catch((error) => {
65
+ if (error.code === 'ENOENT')
66
+ return undefined;
67
+ throw error;
68
+ });
69
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
70
+ throw new RuntimePreviewBuildError('runtime_preview_path_invalid');
71
+ }
72
+ return resolve(base, normalized);
73
+ }
74
+ export async function prepareRuntimePreview(state, operation, root) {
75
+ let manifest;
76
+ try {
77
+ manifest = await insideFile(root, manifestPath);
78
+ }
79
+ catch (error) {
80
+ if (error.code === 'ENOENT')
81
+ return undefined;
82
+ throw error;
83
+ }
84
+ let declared;
85
+ try {
86
+ declared = JSON5.parse(manifest.toString()).previewRuntime;
87
+ }
88
+ catch {
89
+ throw new RuntimePreviewBuildError('runtime_preview_manifest_invalid');
90
+ }
91
+ if (declared === undefined)
92
+ return undefined;
93
+ const parsed = configSchema.safeParse(declared);
94
+ if (!parsed.success)
95
+ throw new RuntimePreviewBuildError('runtime_preview_manifest_invalid');
96
+ const config = parsed.data;
97
+ const packageData = JSON.parse((await insideFile(root, 'package.json')).toString());
98
+ if (typeof packageData.scripts?.[config.buildScript] !== 'string')
99
+ throw new RuntimePreviewBuildError('runtime_preview_script_missing');
100
+ const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
101
+ const read = () => readWorkspaceParameters(root, state.journal.fileRevisions(scope));
102
+ const before = await read();
103
+ if (before.status !== 'ready' ||
104
+ !before.schemaHash ||
105
+ !before.dataHash ||
106
+ !before.sourceFingerprint)
107
+ throw new RuntimePreviewBuildError('runtime_preview_parameters_missing');
108
+ const gameHashes = await Promise.all([...new Set(['package.json', ...config.gameFilePaths])].map(async (path) => [path, digest(await insideFile(root, path))]));
109
+ const buildId = randomUUID();
110
+ const profileBase = {
111
+ version: 1,
112
+ target: 'editor-static',
113
+ protocolVersion: 1,
114
+ binding: {
115
+ scope: {
116
+ projectId: operation.workspaceId,
117
+ branchName: operation.directoryName ?? 'basic',
118
+ themeId: before.themeId ?? 'default',
119
+ },
120
+ schemaVersion: before.schemaHash,
121
+ configContractVersion: config.configContractVersion,
122
+ locale: 'en',
123
+ },
124
+ sourceFingerprint: before.sourceFingerprint,
125
+ baseline: {
126
+ dataRevision: before.dataRevision ?? 0,
127
+ schemaRevision: before.schemaRevision ?? 0,
128
+ dataHash: before.dataHash,
129
+ },
130
+ };
131
+ const context = { buildId, ...profileBase };
132
+ // Remove stale outputs before the script runs; a stale report cannot satisfy this build's identity.
133
+ for (const path of [config.outputPath, config.reportPath]) {
134
+ await rm(await isolatedPath(root, path), { force: true });
135
+ }
136
+ return {
137
+ command: `PLAYCRAFT_RUNTIME_PREVIEW_CONTEXT=${shellQuote(JSON.stringify(context))} pnpm run ${shellQuote(config.buildScript)}`,
138
+ async finish() {
139
+ const after = await read();
140
+ if (after.status !== 'ready' ||
141
+ after.sourceFingerprint !== before.sourceFingerprint ||
142
+ after.dataHash !== before.dataHash ||
143
+ after.schemaHash !== before.schemaHash ||
144
+ after.dataRevision !== before.dataRevision ||
145
+ after.schemaRevision !== before.schemaRevision ||
146
+ digest(await insideFile(root, manifestPath)) !== digest(manifest))
147
+ throw new RuntimePreviewBuildError('runtime_preview_source_changed');
148
+ for (const [path, hash] of gameHashes) {
149
+ if (digest(await insideFile(root, path)) !== hash)
150
+ throw new RuntimePreviewBuildError('runtime_preview_source_changed');
151
+ }
152
+ const bytes = await insideFile(root, config.outputPath);
153
+ const report = reportSchema.safeParse(JSON.parse((await insideFile(root, config.reportPath)).toString()));
154
+ if (!report.success ||
155
+ report.data.buildId !== buildId ||
156
+ report.data.htmlDigest !== digest(bytes) ||
157
+ report.data.schemaDigest !== before.schemaHash ||
158
+ report.data.sourceFingerprint !== before.sourceFingerprint ||
159
+ report.data.dataHash !== before.dataHash ||
160
+ report.data.dataRevision !== before.dataRevision ||
161
+ report.data.schemaRevision !== before.schemaRevision ||
162
+ !report.data.moduleIds.some((id) => /(?:@playcraft\/preview-runtime|\/preview-runtime\/src\/(?:preview|runtime)\.ts)/.test(id)))
163
+ throw new RuntimePreviewBuildError('runtime_preview_report_invalid');
164
+ const profile = workspaceRuntimePreviewProfileSchema.parse({
165
+ ...profileBase,
166
+ buildDigest: report.data.htmlDigest,
167
+ });
168
+ return {
169
+ bytes: Buffer.from(embedWorkspaceRuntimeProfile(bytes.toString(), profile)),
170
+ profile,
171
+ };
172
+ },
173
+ };
174
+ }
175
+ function shellQuote(value) {
176
+ return `'${value.replace(/'/g, `'\\''`)}'`;
177
+ }
@@ -3,12 +3,13 @@ import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
5
  import { createPlatformAdapter } from '@playcraft/build';
6
- import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { RuntimeJournal } from './persistence/journal.js';
8
8
  import { createCodexAppServerAdapter } from './codex/app-server.js';
9
9
  import { trustedMemberPolicy } from './batch/policy.js';
10
+ import { batchDirectoryHeld } from './batch/occupancy.js';
10
11
  import { createLoopbackNativeAdapter } from './codex/loopback.js';
11
- import { attachNativeCollector } from './server/dispatch.js';
12
+ import { attachNativeCollector, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
12
13
  import { handleRuntimeRequest } from './server/http.js';
13
14
  import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
14
15
  import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
@@ -23,6 +24,9 @@ import { DeliveryStore, recoverInterruptedDeliveries } from './workspaces/delive
23
24
  import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
24
25
  import { workspaceContextFromRoot, workspaceRoot } from './workspaces/registry.js';
25
26
  import { recoverInterruptedLibraryImports } from './workspaces/asset-library-import.js';
27
+ import { recoverAssetTransfers } from './workspaces/asset-transfer-publish.js';
28
+ import { recoverWorkspaceDirectoryMutation } from './workspaces/directory-mutations.js';
29
+ import { observeWorkspaceDirectories } from './workspaces/directory-revisions.js';
26
30
  import { observeWorkspaceBinaryFile } from './workspaces/binary-write.js';
27
31
  import { revisionScope } from './workspaces/registry.js';
28
32
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
@@ -52,6 +56,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
52
56
  const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
53
57
  await recoverInterruptedInstalls(journal);
54
58
  await recoverInterruptedPreviews(journal);
59
+ await recoverInterruptedParameterVerifications(journal);
55
60
  await recoverInterruptedDeliveries(config.stateDir);
56
61
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
57
62
  const state = runtimeState = {
@@ -67,6 +72,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
67
72
  };
68
73
  const recoveredImports = new Map();
69
74
  const recoveredChanges = [];
75
+ const recoveredWrites = [];
70
76
  const recoverLibraryImports = async (root) => {
71
77
  const result = await recoverInterruptedLibraryImports(root).catch(() => undefined);
72
78
  for (const published of result?.published ?? []) {
@@ -95,7 +101,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
95
101
  if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
96
102
  continue;
97
103
  const published = recoveredImports.get(operation.operationId);
98
- const result = operation.result ? { ...operation.result,
104
+ const result = operation.result && 'paths' in operation.result ? { ...operation.result,
99
105
  paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
100
106
  failures: operation.result.failures.filter(failure => !published?.assetIds.includes(failure.assetId)),
101
107
  } : undefined;
@@ -124,16 +130,107 @@ export async function startWorkspaceRuntime(config, options = {}) {
124
130
  recoveredChanges.push({ workspaceId: operation.workspaceId,
125
131
  directoryName: operation.directoryName ?? 'basic', paths: result.paths });
126
132
  }
133
+ const recoveredBundles = new Map();
134
+ const transferScopes = new Set();
135
+ for (const { operation } of journal.listOperations()) {
136
+ if (operation.kind !== 'asset.bundle.save')
137
+ continue;
138
+ const directoryName = operation.directoryName ?? 'basic';
139
+ const key = `${operation.workspaceId ?? ''}:${directoryName}`;
140
+ if (transferScopes.has(key))
141
+ continue;
142
+ transferScopes.add(key);
143
+ const root = await workspaceRoot(state, operation.workspaceId, directoryName).catch(() => undefined);
144
+ if (!root)
145
+ continue;
146
+ const recovered = await recoverAssetTransfers(root, { environment: config.environment, userId: config.userId,
147
+ workspaceId: operation.workspaceId ?? '', directoryName }).catch(() => []);
148
+ for (const item of recovered)
149
+ recoveredBundles.set(item.operationId, item);
150
+ }
151
+ for (const { scopeKey, operation } of journal.listOperations()) {
152
+ if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
153
+ continue;
154
+ const recovered = recoveredBundles.get(operation.operationId), receipt = recovered?.receipt;
155
+ let next = operation;
156
+ if (receipt) {
157
+ const paths = [...receipt.files.map(file => file.path), ...(receipt.directories ?? [])];
158
+ const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
159
+ for (const file of receipt.files) {
160
+ const previous = journal.fileRevisions(scope)[file.path];
161
+ const revision = previous ? previous.revision + (previous.contentHash === file.contentHash ? 0 : 1) : 1;
162
+ await journal.putFileRevision(file.path, { revision,
163
+ contentHash: file.contentHash }, scope);
164
+ const metadata = recovered?.manifest.files.find(item => item.path === file.path);
165
+ recoveredWrites.push({ ...file, revision, writer: 'manual', workspaceId: operation.workspaceId,
166
+ directoryName: operation.directoryName ?? 'basic', operationId: operation.operationId, transferId: receipt.transferId,
167
+ ...(metadata?.processing ? { processing: metadata.processing } : {}), recovered: true });
168
+ }
169
+ if (next.status === 'queued')
170
+ next = transitionOperation(next, 'dispatched');
171
+ if (next.status === 'dispatched')
172
+ next = transitionOperation(next, 'accepted');
173
+ next = transitionOperation(next, 'completed', { result: { paths, skippedPaths: [], failures: [], bundleReceipt: receipt } });
174
+ if (operation.workspaceId)
175
+ recoveredChanges.push({ workspaceId: operation.workspaceId, directoryName: operation.directoryName ?? 'basic', paths, source: 'asset.bundle.save' });
176
+ }
177
+ else
178
+ next = transitionOperation(next, 'failed', { error: 'asset_bundle_save_interrupted' });
179
+ await journal.putOperation(scopeKey, next);
180
+ }
181
+ for (const { scopeKey, operation } of journal.listOperations()) {
182
+ if (!['directory.rename', 'directory.delete'].includes(operation.kind)
183
+ || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
184
+ continue;
185
+ const kind = operation.kind;
186
+ let next = operation;
187
+ try {
188
+ const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
189
+ const receipt = await recoverWorkspaceDirectoryMutation(root, kind, { operationId: operation.operationId,
190
+ operationPayloadHash: operation.payloadHash, scope: scopeKey, assertCurrent: async () => {
191
+ if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed())
192
+ throw new Error('stale_lease');
193
+ if (operation.sessionId && productionSessionAttemptId(operation.sessionId))
194
+ throw new Error('production_session_readonly');
195
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
196
+ throw new Error('batch_workspace_readonly');
197
+ } });
198
+ if (!receipt)
199
+ throw new Error('directory_mutation_interrupted');
200
+ await observeWorkspaceDirectories(root, journal, revisionScope(state, operation.workspaceId, operation.directoryName));
201
+ const paths = [receipt.path, ...(receipt.newPath ? [receipt.newPath] : [])];
202
+ if (next.status === 'queued')
203
+ next = transitionOperation(next, 'dispatched');
204
+ if (next.status === 'dispatched')
205
+ next = transitionOperation(next, 'accepted');
206
+ next = transitionOperation(next, 'completed', { result: { paths, skippedPaths: [], failures: [], directoryMutation: directoryMutationReceiptSchema.parse(receipt) } });
207
+ if (operation.workspaceId)
208
+ recoveredChanges.push({ workspaceId: operation.workspaceId,
209
+ directoryName: operation.directoryName ?? 'basic', paths, source: kind });
210
+ }
211
+ catch (error) {
212
+ const reason = String(error?.code ?? (error instanceof Error ? error.message : ''));
213
+ const code = ['directory_mutation_interrupted', 'stale_lease', 'production_session_readonly', 'batch_workspace_readonly',
214
+ 'directory_exists', 'directory_not_found', 'revision_conflict', 'directory_conflict', 'invalid_workspace_path', 'workspace_permission_denied']
215
+ .includes(reason) ? reason : 'directory_recovery_required';
216
+ next = transitionOperation(next, code === 'directory_mutation_interrupted' ? 'failed' : 'uncertain', { error: code });
217
+ }
218
+ await journal.putOperation(scopeKey, next);
219
+ }
127
220
  const native = state.native ??= defaultNativeAdapter(state);
128
221
  await journal.bindRuntime({
129
222
  runtimeId: config.runtimeId,
130
223
  generation: config.generation,
131
224
  runtimeEpoch: state.runtimeEpoch,
132
225
  });
226
+ for (const payload of recoveredWrites)
227
+ await journal.allocateSourceEvent({ runtimeId: config.runtimeId,
228
+ generation: config.generation, runtimeEpoch: state.runtimeEpoch, workspaceId: payload.workspaceId,
229
+ type: 'file.written', occurredAt: new Date().toISOString(), payload });
133
230
  for (const change of recoveredChanges)
134
231
  await journal.allocateSourceEvent({ runtimeId: config.runtimeId,
135
232
  generation: config.generation, runtimeEpoch: state.runtimeEpoch, workspaceId: change.workspaceId,
136
- type: 'files.changed', occurredAt: new Date().toISOString(), payload: { ...change, source: 'asset.library.import', recovered: true },
233
+ type: 'files.changed', occurredAt: new Date().toISOString(), payload: { ...change, source: change.source ?? 'asset.library.import', recovered: true },
137
234
  });
138
235
  startSourceEventUploader(state);
139
236
  if (config.workspaceMode === 'multi') {
@@ -198,6 +295,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
198
295
  url: `http://${formatHost(config.listen.host)}:${port}`,
199
296
  async close() {
200
297
  const serverClosed = closeServer(server);
298
+ await stopParameterVerifications(state);
201
299
  await closeCliCredentials(state);
202
300
  await gitCredentials?.close();
203
301
  try {
@@ -0,0 +1,63 @@
1
+ import { ASSET_TRANSFER_BODY_MAX_BYTES, ASSET_TRANSFER_CHUNK_BODY_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
3
+ import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
4
+ import { AssetTransferError, cancelAssetTransfer, createAssetTransfer, getAssetTransfer, putAssetTransferChunk } from '../workspaces/asset-transfer-storage.js';
5
+ export async function handleAssetTransferRequest(state, request, url) {
6
+ const route = /^\/runtime\/asset-transfers(?:\/([^/]+)(?:\/(chunks))?)?$/.exec(url.pathname);
7
+ if (!route)
8
+ return undefined;
9
+ const workspaceId = url.searchParams.get('workspaceId') ?? '';
10
+ const directoryName = url.searchParams.get('directoryName') ?? 'basic';
11
+ try {
12
+ if (state.journal.destroyed())
13
+ throw new AssetTransferError('runtime_destroyed');
14
+ if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
15
+ throw new AssetTransferError('invalid_workspace_directory', 400);
16
+ const [, id, action] = route;
17
+ const valid = !id && request.method === 'POST' || id && !action && ['GET', 'DELETE'].includes(request.method ?? '')
18
+ || id && action === 'chunks' && request.method === 'PUT';
19
+ if (!valid)
20
+ return { status: 405, body: { error: 'asset_transfer_method_not_allowed' } };
21
+ const scope = { workspaceId, directoryName, environment: state.config.environment, userId: state.config.userId };
22
+ const body = ['POST', 'PUT'].includes(request.method ?? '')
23
+ ? await readTransferJson(request, action ? ASSET_TRANSFER_CHUNK_BODY_MAX_BYTES : ASSET_TRANSFER_BODY_MAX_BYTES) : undefined;
24
+ return await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => state.journal.exclusive(`workspace:${revisionScope(state, workspaceId, directoryName) ?? 'single'}`, async () => {
25
+ if (state.journal.destroyed())
26
+ throw new AssetTransferError('runtime_destroyed');
27
+ if (request.method !== 'GET' && await batchDirectoryHeld(state, workspaceId, directoryName))
28
+ throw new AssetTransferError('batch_workspace_readonly');
29
+ const root = await workspaceRoot(state, workspaceId, directoryName);
30
+ const result = !id ? await createAssetTransfer(root, scope, body)
31
+ : action ? await putAssetTransferChunk(root, scope, id, body)
32
+ : request.method === 'GET' ? await getAssetTransfer(root, scope, id) : await cancelAssetTransfer(root, scope, id);
33
+ return { status: 200, body: result };
34
+ }));
35
+ }
36
+ catch (error) {
37
+ if (error instanceof AssetTransferError)
38
+ return { status: error.status, body: { error: error.code } };
39
+ if (error.name === 'ZodError')
40
+ return { status: 400, body: { error: 'invalid_asset_transfer' } };
41
+ throw error;
42
+ }
43
+ }
44
+ async function readTransferJson(request, limit) {
45
+ const length = request.headers['content-length'];
46
+ if (length && (!/^\d+$/.test(length) || Number(length) > limit))
47
+ throw new AssetTransferError('asset_transfer_too_large', 413);
48
+ const parts = [];
49
+ let size = 0;
50
+ for await (const data of request) {
51
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data);
52
+ size += bytes.length;
53
+ if (size > limit)
54
+ throw new AssetTransferError('asset_transfer_too_large', 413);
55
+ parts.push(bytes);
56
+ }
57
+ try {
58
+ return JSON.parse(Buffer.concat(parts).toString('utf8'));
59
+ }
60
+ catch {
61
+ throw new AssetTransferError('invalid_asset_transfer', 400);
62
+ }
63
+ }