@playcraft/cli 0.1.48-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.
Files changed (39) hide show
  1. package/dist/workspace-runtime/batch/deliver.js +105 -87
  2. package/dist/workspace-runtime/batch/prepare.js +169 -56
  3. package/dist/workspace-runtime/batch/source-capture.js +7 -20
  4. package/dist/workspace-runtime/batch/source-publish.js +2 -4
  5. package/dist/workspace-runtime/codex/app-server.js +19 -1
  6. package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
  7. package/dist/workspace-runtime/codex/tool-response.js +10 -0
  8. package/dist/workspace-runtime/codex/ui-tools.js +22 -5
  9. package/dist/workspace-runtime/previews/diagnostics.js +44 -0
  10. package/dist/workspace-runtime/previews/publish.js +29 -8
  11. package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
  12. package/dist/workspace-runtime/serve.js +162 -4
  13. package/dist/workspace-runtime/server/accept-operation.js +61 -0
  14. package/dist/workspace-runtime/server/asset-transfers.js +63 -0
  15. package/dist/workspace-runtime/server/dispatch.js +328 -44
  16. package/dist/workspace-runtime/server/http.js +193 -137
  17. package/dist/workspace-runtime/server/operation-record.js +33 -0
  18. package/dist/workspace-runtime/server/submit-operation.js +41 -0
  19. package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
  20. package/dist/workspace-runtime/workspaces/asset-transfer-dependencies.js +81 -0
  21. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
  22. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -0
  23. package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
  24. package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
  25. package/dist/workspace-runtime/workspaces/delivery.js +64 -0
  26. package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
  27. package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
  28. package/dist/workspace-runtime/workspaces/directories.js +9 -4
  29. package/dist/workspace-runtime/workspaces/directory-mutations.js +238 -0
  30. package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
  31. package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
  32. package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
  33. package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
  34. package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
  35. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
  36. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
  37. package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
  38. package/dist/workspace-runtime/workspaces/parameters.js +77 -54
  39. package/package.json +3 -3
@@ -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
+ }
@@ -2,12 +2,14 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
- import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { createPlatformAdapter } from '@playcraft/build';
6
+ import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
6
7
  import { RuntimeJournal } from './persistence/journal.js';
7
8
  import { createCodexAppServerAdapter } from './codex/app-server.js';
8
9
  import { trustedMemberPolicy } from './batch/policy.js';
10
+ import { batchDirectoryHeld } from './batch/occupancy.js';
9
11
  import { createLoopbackNativeAdapter } from './codex/loopback.js';
10
- import { attachNativeCollector } from './server/dispatch.js';
12
+ import { attachNativeCollector, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
11
13
  import { handleRuntimeRequest } from './server/http.js';
12
14
  import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
13
15
  import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
@@ -18,14 +20,19 @@ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker
18
20
  import { WorkspaceModelBroker } from './codex/model-broker.js';
19
21
  import { recoverInterruptedPreviews } from './previews/publish.js';
20
22
  import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
23
+ import { DeliveryStore, recoverInterruptedDeliveries } from './workspaces/delivery-store.js';
21
24
  import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
22
25
  import { workspaceContextFromRoot, workspaceRoot } from './workspaces/registry.js';
23
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';
24
30
  import { observeWorkspaceBinaryFile } from './workspaces/binary-write.js';
25
31
  import { revisionScope } from './workspaces/registry.js';
26
32
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
27
33
  import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
28
34
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
35
+ import { submitTrustedOperation } from './server/submit-operation.js';
29
36
  export async function startWorkspaceRuntime(config, options = {}) {
30
37
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
31
38
  const negotiated = negotiateProtocol(config);
@@ -49,6 +56,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
49
56
  const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
50
57
  await recoverInterruptedInstalls(journal);
51
58
  await recoverInterruptedPreviews(journal);
59
+ await recoverInterruptedParameterVerifications(journal);
60
+ await recoverInterruptedDeliveries(config.stateDir);
52
61
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
53
62
  const state = runtimeState = {
54
63
  config: { ...config, protocolMinor: negotiated.protocolMinor },
@@ -63,6 +72,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
63
72
  };
64
73
  const recoveredImports = new Map();
65
74
  const recoveredChanges = [];
75
+ const recoveredWrites = [];
66
76
  const recoverLibraryImports = async (root) => {
67
77
  const result = await recoverInterruptedLibraryImports(root).catch(() => undefined);
68
78
  for (const published of result?.published ?? []) {
@@ -91,7 +101,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
91
101
  if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
92
102
  continue;
93
103
  const published = recoveredImports.get(operation.operationId);
94
- const result = operation.result ? { ...operation.result,
104
+ const result = operation.result && 'paths' in operation.result ? { ...operation.result,
95
105
  paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
96
106
  failures: operation.result.failures.filter(failure => !published?.assetIds.includes(failure.assetId)),
97
107
  } : undefined;
@@ -120,16 +130,107 @@ export async function startWorkspaceRuntime(config, options = {}) {
120
130
  recoveredChanges.push({ workspaceId: operation.workspaceId,
121
131
  directoryName: operation.directoryName ?? 'basic', paths: result.paths });
122
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
+ }
123
220
  const native = state.native ??= defaultNativeAdapter(state);
124
221
  await journal.bindRuntime({
125
222
  runtimeId: config.runtimeId,
126
223
  generation: config.generation,
127
224
  runtimeEpoch: state.runtimeEpoch,
128
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 });
129
230
  for (const change of recoveredChanges)
130
231
  await journal.allocateSourceEvent({ runtimeId: config.runtimeId,
131
232
  generation: config.generation, runtimeEpoch: state.runtimeEpoch, workspaceId: change.workspaceId,
132
- 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 },
133
234
  });
134
235
  startSourceEventUploader(state);
135
236
  if (config.workspaceMode === 'multi') {
@@ -194,6 +295,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
194
295
  url: `http://${formatHost(config.listen.host)}:${port}`,
195
296
  async close() {
196
297
  const serverClosed = closeServer(server);
298
+ await stopParameterVerifications(state);
197
299
  await closeCliCredentials(state);
198
300
  await gitCredentials?.close();
199
301
  try {
@@ -253,6 +355,61 @@ function closeServer(server) {
253
355
  function formatHost(host) {
254
356
  return host.includes(':') ? `[${host}]` : host;
255
357
  }
358
+ function deliveryToolHandlers(state) {
359
+ const contextFor = async (input) => {
360
+ if (input.workspaceId)
361
+ return { workspaceId: input.workspaceId, directoryName: input.directoryName ?? 'basic' };
362
+ if (state.config.workspaceMode !== 'multi' || !input.cwd)
363
+ throw new Error('feature_unavailable');
364
+ return workspaceContextFromRoot(state, input.cwd);
365
+ };
366
+ return {
367
+ async build(input) {
368
+ const { workspaceId, directoryName } = await contextFor(input);
369
+ if (directoryName === 'root')
370
+ throw new Error('invalid_workspace_directory');
371
+ const builds = [];
372
+ for (const channel of input.channels) {
373
+ const operation = await submitTrustedOperation(state, {
374
+ kind: 'delivery.build',
375
+ workspaceId,
376
+ directoryName,
377
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
378
+ clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
379
+ payload: {
380
+ channel,
381
+ format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
382
+ actor: 'agent',
383
+ },
384
+ });
385
+ let record;
386
+ for (let attempt = 0; attempt < 40 && !record; attempt++) {
387
+ record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
388
+ if (!record)
389
+ await new Promise(resolve => setTimeout(resolve, 25));
390
+ }
391
+ builds.push({
392
+ buildId: operation.operationId,
393
+ channel,
394
+ status: record?.status ?? operation.status,
395
+ ...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
396
+ });
397
+ }
398
+ return { builds };
399
+ },
400
+ async get(input) {
401
+ const { workspaceId, directoryName } = await contextFor(input);
402
+ const store = new DeliveryStore(state.config.stateDir, workspaceId);
403
+ const record = input.buildId
404
+ ? await store.read(input.buildId)
405
+ : (await store.list(directoryName, input.channel, 20))[0];
406
+ if (!record || record.directoryName !== directoryName)
407
+ throw new Error('delivery_build_not_found');
408
+ const log = await store.readLog(record.buildId, 0).catch(() => undefined);
409
+ return { build: record, ...(log ? { logTail: log.text.slice(-4_000) } : {}) };
410
+ },
411
+ };
412
+ }
256
413
  function defaultNativeAdapter(state) {
257
414
  const config = state.config;
258
415
  if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
@@ -266,6 +423,7 @@ function defaultNativeAdapter(state) {
266
423
  return createCodexAppServerAdapter({
267
424
  config,
268
425
  workspaceUi: true,
426
+ deliveryTools: deliveryToolHandlers(state),
269
427
  ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
270
428
  ...(multi ? { loadMcpConfig: async (cwd) => {
271
429
  const { workspaceId } = await workspaceContextFromRoot(state, cwd);
@@ -0,0 +1,61 @@
1
+ import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { canExecuteKind, executeOperation } from './dispatch.js';
3
+ import { buildOperationRecord } from './operation-record.js';
4
+ /**
5
+ * Shared operation acceptance: lease fence, idempotency, capability, record creation and
6
+ * execution. HTTP supplies its transport/session checks through `validate`; in-sandbox Agent
7
+ * tools pass none. The callback runs only when a new operation is about to be created.
8
+ */
9
+ export async function acceptOperationCore(state, parsed, options) {
10
+ const payloadHash = hashOperationPayload(parsed.payload);
11
+ const scopeKey = operationScopeKey({
12
+ environment: state.config.environment,
13
+ userId: state.config.userId,
14
+ kind: parsed.kind,
15
+ clientRequestId: parsed.clientRequestId,
16
+ workspaceId: parsed.workspaceId,
17
+ directoryName: parsed.directoryName,
18
+ sessionId: parsed.sessionId,
19
+ });
20
+ return state.journal.exclusive(scopeKey, async () => {
21
+ // Recheck after a concurrent dispatcher may have advanced the fence while this request waited.
22
+ if (assertLeaseFence(options.leaseFence, state.acceptedLeaseFence) !== 'ok') {
23
+ return rejected(409, runtimeError('stale_lease', 'leaseFence is behind the accepted dispatcher'));
24
+ }
25
+ const existing = state.journal.getOperation(scopeKey);
26
+ const decision = resolveIdempotentAccept(existing, payloadHash);
27
+ if (decision.action === 'conflict') {
28
+ return rejected(409, runtimeError('idempotency_conflict', 'clientRequestId reused with a different payload', {
29
+ operationId: decision.operation.operationId,
30
+ }));
31
+ }
32
+ const retryWithRecovery = decision.action === 'reuse' && parsed.kind === 'session.turn'
33
+ && Boolean(parsed.sessionRecovery) && decision.operation.status === 'failed'
34
+ && isLostThreadError(decision.operation.error);
35
+ if (decision.action === 'reuse' && !retryWithRecovery) {
36
+ if (decision.operation.status === 'uncertain') {
37
+ return rejected(409, runtimeError('uncertain_operation', decision.operation.error ?? 'native_receipt_lost', {
38
+ operationId: decision.operation.operationId,
39
+ }));
40
+ }
41
+ return { rejected: false, operation: decision.operation, executed: decision.operation, reused: true };
42
+ }
43
+ const validation = await options.validate?.(scopeKey);
44
+ if (validation)
45
+ return { rejected: true, refusal: validation };
46
+ const executable = canExecuteKind(state, parsed.kind);
47
+ const operation = buildOperationRecord(state, parsed, payloadHash, options.leaseFence, executable);
48
+ if (!executable) {
49
+ const saved = await state.journal.putOperation(scopeKey, operation);
50
+ return rejected(409, runtimeError('capability_unavailable', state.journal.destroyed() ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started', { operationId: saved.operationId }));
51
+ }
52
+ state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, options.leaseFence);
53
+ await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
54
+ const queued = await state.journal.putOperation(scopeKey, operation);
55
+ const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
56
+ return { rejected: false, operation: queued, executed, reused: false };
57
+ });
58
+ }
59
+ function rejected(status, body) {
60
+ return { rejected: true, refusal: { status, body } };
61
+ }
@@ -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
+ }