@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
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { dirname, isAbsolute, join, relative } from 'node:path';
5
5
  import { gzip, gunzip } from 'node:zlib';
6
6
  import { promisify } from 'node:util';
@@ -29,18 +29,10 @@ export function digestFiles(files) {
29
29
  hash.update(path).update('\0').update(String(file.mode)).update('\0').update(file.body).update('\0');
30
30
  return hash.digest('hex');
31
31
  }
32
- /** Working tree plus exact skill artifacts. Credentials, caches and node_modules are left out. */
32
+ /** Files Git will accept. Credentials, caches and node_modules are left out. */
33
33
  export async function readSourceFiles(root) {
34
34
  const source = await realpath(root);
35
35
  const paths = new Set((await gitRead(source, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean));
36
- const artifacts = await readdir(join(source, '.playcraft/skill-artifacts')).catch(error => {
37
- if (error.code === 'ENOENT')
38
- return [];
39
- throw error;
40
- });
41
- for (const name of artifacts)
42
- if (/^[a-f0-9]{64}\.tar\.gz$/.test(name))
43
- paths.add(`.playcraft/skill-artifacts/${name}`);
44
36
  const files = new Map();
45
37
  for (const path of [...paths].sort()) {
46
38
  if (!included(path))
@@ -57,12 +49,7 @@ export async function readSourceFiles(root) {
57
49
  throw new Error('batch_source_path_invalid');
58
50
  const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
59
51
  try {
60
- const body = await handle.readFile();
61
- if (/^\.playcraft\/skill-artifacts\/[a-f0-9]{64}\.tar\.gz$/.test(path)
62
- && createHash('sha256').update(body).digest('hex') !== path.slice(path.lastIndexOf('/') + 1, -7)) {
63
- throw new Error('batch_source_path_invalid');
64
- }
65
- files.set(path, { body, mode: snapshotMode(stat.mode) });
52
+ files.set(path, { body: await handle.readFile(), mode: snapshotMode(stat.mode) });
66
53
  }
67
54
  finally {
68
55
  await handle.close();
@@ -92,7 +79,7 @@ export async function captureIndependentSource(source, destination) {
92
79
  const headBefore = await gitRead(root, ['rev-parse', '--verify', 'HEAD']).catch(() => '');
93
80
  let files = await readSourceFiles(root);
94
81
  files = await filterUnstableFiles(root, files);
95
- const digest = digestFiles(files);
82
+ const contentDigest = digestFiles(files);
96
83
  const staging = join(dirname(destination), `.batch-source-${randomUUID()}`);
97
84
  try {
98
85
  await mkdir(staging, { recursive: true });
@@ -107,7 +94,7 @@ export async function captureIndependentSource(source, destination) {
107
94
  if (headBefore !== await gitRead(root, ['rev-parse', '--verify', 'HEAD']).catch(() => ''))
108
95
  throw new Error('batch_source_changed');
109
96
  const after = await filterUnstableFiles(root, await readSourceFiles(root));
110
- if (digestFiles(after) !== digest)
97
+ if (digestFiles(after) !== contentDigest)
111
98
  throw new Error('batch_source_changed');
112
99
  await excludeFromGit(staging);
113
100
  await excludeFromGit(staging, '/.env*');
@@ -117,7 +104,7 @@ export async function captureIndependentSource(source, destination) {
117
104
  await gitWrite(staging, ['commit', '--allow-empty', '-m', 'Freeze batch source']);
118
105
  const commitHash = (await gitWrite(staging, ['rev-parse', 'HEAD'])).trim();
119
106
  await rename(staging, destination);
120
- return { commitHash, digest };
107
+ return { commitHash, digest: commitHash };
121
108
  }
122
109
  finally {
123
110
  await rm(staging, { recursive: true, force: true });
@@ -145,7 +132,7 @@ export async function restoreArchive(body, destination, expectedDigest) {
145
132
  await writeFile(bundle, Buffer.from(archive.bundle, 'base64'), { mode: 0o600 });
146
133
  await gitWrite(temporary, ['clone', '--no-checkout', '--', bundle, destination]);
147
134
  await gitWrite(destination, ['checkout', '--detach', archive.commitHash]);
148
- if (digestFiles(await readSourceFiles(destination)) !== expectedDigest)
135
+ if ((await gitRead(destination, ['rev-parse', 'HEAD'])).trim() !== archive.commitHash)
149
136
  throw new Error('batch_snapshot_mismatch');
150
137
  return { commitHash: archive.commitHash };
151
138
  }
@@ -1,16 +1,14 @@
1
1
  import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
4
- import { archiveSource, digestFiles, readSourceFiles, restoreArchive } from './source-capture.js';
4
+ import { archiveSource, restoreArchive } from './source-capture.js';
5
5
  import { sourceCacheDir } from './records.js';
6
6
  import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
7
7
  async function reject(state, candidate, code, stage, retryable, outcome, cause) {
8
8
  throw await sourceFreezeError(state, candidate.snapshot.batchId, { code, stage, retryable, outcome }, cause);
9
9
  }
10
10
  async function verifyCache(cache, candidate) {
11
- if ((await gitRead(cache, ['rev-parse', '--verify', 'HEAD']).catch(() => '')).trim() !== candidate.snapshot.commitHash)
12
- return false;
13
- return digestFiles(await readSourceFiles(cache)) === candidate.snapshot.digest;
11
+ return (await gitRead(cache, ['rev-parse', '--verify', 'HEAD']).catch(() => '')).trim() === candidate.snapshot.commitHash;
14
12
  }
15
13
  function pushFailureCode(error) {
16
14
  const detail = error;
@@ -17,6 +17,7 @@ import { nativeMcpConfig } from './mcp-config.js';
17
17
  import { requestNativeCompaction } from './compact.js';
18
18
  import { readNativeThreadImage } from './thread-image.js';
19
19
  import { WORKSPACE_UI_TOOLS, WorkspaceUiTools } from './ui-tools.js';
20
+ import { WORKSPACE_DELIVERY_TOOLS, WorkspaceDeliveryTools, isWorkspaceDeliveryTool } from './delivery-tools.js';
20
21
  import { WORKSPACE_UI_INSTRUCTIONS } from './ui-instructions.js';
21
22
  function withModelShell(config, modelConfigId) {
22
23
  if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
@@ -103,6 +104,11 @@ export function createCodexAppServerAdapter(options) {
103
104
  const collected = new Set();
104
105
  const coordinatorDirectories = new Map();
105
106
  const uiTools = new WorkspaceUiTools(emitCollected, options.uiToolTimeoutMs);
107
+ const deliveryTools = options.deliveryTools ? new WorkspaceDeliveryTools(options.deliveryTools) : undefined;
108
+ const dynamicTools = [
109
+ ...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
110
+ ...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
111
+ ];
106
112
  function emitCollected(event) {
107
113
  for (const listener of collected)
108
114
  listener(event);
@@ -474,6 +480,16 @@ export function createCodexAppServerAdapter(options) {
474
480
  const payload = asRecord(params);
475
481
  if (method === 'item/tool/call') {
476
482
  const thread = typeof payload.threadId === 'string' ? threads.get(payload.threadId) : undefined;
483
+ if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
484
+ void deliveryTools.request(id, payload, nativeClient, {
485
+ sessionId: thread?.sessionId,
486
+ cwd: thread?.cwd,
487
+ workspaceId: thread?.uiSnapshot?.workspaceId,
488
+ directoryName: thread?.uiSnapshot?.directoryName,
489
+ readOnly: thread?.readOnlyConfirmed === true || thread?.permissionMode === 'read-only',
490
+ });
491
+ return;
492
+ }
477
493
  uiTools.request(id, payload, nativeClient, options.workspaceUi && thread?.turnActive
478
494
  && payload.turnId === thread.lastTurnId ? thread.uiSnapshot : undefined);
479
495
  return;
@@ -678,7 +694,7 @@ export function createCodexAppServerAdapter(options) {
678
694
  const result = await rpc.request('thread/start', {
679
695
  model,
680
696
  ...prepared.params,
681
- ...(options.workspaceUi ? { dynamicTools: WORKSPACE_UI_TOOLS } : {}),
697
+ ...(dynamicTools.length ? { dynamicTools } : {}),
682
698
  });
683
699
  const threadId = result.thread?.id;
684
700
  if (!threadId)
@@ -701,6 +717,8 @@ export function createCodexAppServerAdapter(options) {
701
717
  try {
702
718
  const resumed = await rpc.request('thread/resume', {
703
719
  threadId: input.threadId, ...prepared.params,
720
+ // Resume does not necessarily retain tools supplied to thread/start.
721
+ ...(dynamicTools.length ? { dynamicTools } : {}),
704
722
  }, 30_000);
705
723
  resumedId = resumed.thread?.id;
706
724
  readOnlyConfirmed = isReadOnlySnapshot(resumed);
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { DELIVERY_CHANNELS, deliveryFormatSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { rejectTool, toolContent } from './tool-response.js';
4
+ export const WORKSPACE_DELIVERY_TOOLS = [
5
+ {
6
+ type: 'function',
7
+ name: 'playcraft_build_delivery',
8
+ description: 'Start one formal channel delivery build. Returns build identifiers immediately; poll playcraft_get_delivery for status and validation results. This writes a delivery record under dist/delivery/.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ channels: {
13
+ type: 'array',
14
+ items: { type: 'string', enum: DELIVERY_CHANNELS },
15
+ minItems: 1,
16
+ maxItems: 8,
17
+ uniqueItems: true,
18
+ },
19
+ format: { type: 'string', enum: ['html', 'zip'] },
20
+ },
21
+ additionalProperties: false,
22
+ },
23
+ },
24
+ {
25
+ type: 'function',
26
+ name: 'playcraft_get_delivery',
27
+ description: 'Read the latest delivery build, or one build by id, including status, validation summary, error and a truncated build log.',
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {
31
+ buildId: { type: 'string' },
32
+ channel: { type: 'string', enum: DELIVERY_CHANNELS },
33
+ },
34
+ additionalProperties: false,
35
+ },
36
+ },
37
+ ];
38
+ const DELIVERY_TOOL_NAMES = new Set(WORKSPACE_DELIVERY_TOOLS.map(tool => tool.name));
39
+ export function isWorkspaceDeliveryTool(name) {
40
+ return typeof name === 'string' && DELIVERY_TOOL_NAMES.has(name);
41
+ }
42
+ export class WorkspaceDeliveryTools {
43
+ handlers;
44
+ constructor(handlers) {
45
+ this.handlers = handlers;
46
+ }
47
+ async request(requestId, payload, client, context) {
48
+ const tool = typeof payload.tool === 'string' ? payload.tool : '';
49
+ const args = payload.arguments;
50
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
51
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
52
+ return;
53
+ }
54
+ if (!context.sessionId || (!context.cwd && !context.workspaceId)) {
55
+ await this.reject(client, requestId, 'feature_unavailable');
56
+ return;
57
+ }
58
+ if (context.directoryName === 'root') {
59
+ await this.reject(client, requestId, 'invalid_workspace_directory');
60
+ return;
61
+ }
62
+ const input = args;
63
+ if (tool === 'playcraft_build_delivery')
64
+ return this.build(requestId, payload, input, client, context);
65
+ if (tool === 'playcraft_get_delivery')
66
+ return this.get(requestId, input, client, context);
67
+ await this.reject(client, requestId, 'unknown_delivery_tool');
68
+ }
69
+ async build(requestId, payload, input, client, context) {
70
+ if (context.readOnly) {
71
+ await this.reject(client, requestId, 'permission_denied');
72
+ return;
73
+ }
74
+ const unknown = Object.keys(input).filter(key => key !== 'channels' && key !== 'format');
75
+ if (unknown.length) {
76
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
77
+ return;
78
+ }
79
+ const channels = input.channels === undefined ? ['playcraft'] : input.channels;
80
+ if (!Array.isArray(channels) || channels.length < 1 || channels.length > 8
81
+ || channels.some(channel => !DELIVERY_CHANNELS.includes(channel))
82
+ || new Set(channels).size !== channels.length) {
83
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
84
+ return;
85
+ }
86
+ const format = input.format === undefined ? undefined : deliveryFormatSchema.safeParse(input.format);
87
+ if (format && !format.success) {
88
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
89
+ return;
90
+ }
91
+ try {
92
+ const result = await this.handlers.build({
93
+ ...context,
94
+ clientRequestId: typeof payload.callId === 'string' && payload.callId.length
95
+ ? payload.callId
96
+ : randomUUID(),
97
+ channels: channels,
98
+ ...(format && format.success ? { format: format.data } : {}),
99
+ });
100
+ await this.respond(client, requestId, result);
101
+ }
102
+ catch (cause) {
103
+ await this.reject(client, requestId, errorReason(cause));
104
+ }
105
+ }
106
+ async get(requestId, input, client, context) {
107
+ const unknown = Object.keys(input).filter(key => key !== 'buildId' && key !== 'channel');
108
+ if (unknown.length) {
109
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
110
+ return;
111
+ }
112
+ if (input.buildId !== undefined && (typeof input.buildId !== 'string'
113
+ || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(input.buildId))) {
114
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
115
+ return;
116
+ }
117
+ if (input.channel !== undefined && !DELIVERY_CHANNELS.includes(input.channel)) {
118
+ await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
119
+ return;
120
+ }
121
+ try {
122
+ const result = await this.handlers.get({
123
+ ...context,
124
+ ...(typeof input.buildId === 'string' ? { buildId: input.buildId } : {}),
125
+ ...(typeof input.channel === 'string' ? { channel: input.channel } : {}),
126
+ });
127
+ await this.respond(client, requestId, result);
128
+ }
129
+ catch (cause) {
130
+ await this.reject(client, requestId, errorReason(cause));
131
+ }
132
+ }
133
+ async respond(client, requestId, value) {
134
+ await client.respond(requestId, { success: true, contentItems: toolContent(value) }).catch(() => undefined);
135
+ }
136
+ async reject(client, requestId, reason) {
137
+ await rejectTool(client, requestId, reason);
138
+ }
139
+ }
140
+ function errorReason(cause) {
141
+ if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string')
142
+ return cause.code;
143
+ return 'delivery_tool_failed';
144
+ }
@@ -0,0 +1,10 @@
1
+ export function toolContent(value) {
2
+ return [{ type: 'inputText', text: JSON.stringify(value) }];
3
+ }
4
+ /** The App Server treats a failed tool call as content, not a JSON-RPC error. */
5
+ export async function rejectTool(client, requestId, reason) {
6
+ await client.respond(requestId, {
7
+ success: false,
8
+ contentItems: toolContent({ status: 'failed', reason }),
9
+ }).catch(() => undefined);
10
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { workspaceUiCommandSchema, workspaceUiResultSchema, WORKSPACE_UI_COMMAND_EVENT, WORKSPACE_UI_RESULT_EVENT, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { rejectTool } from './tool-response.js';
3
4
  export const WORKSPACE_UI_TOOLS = [
4
5
  { type: 'function', name: 'playcraft_get_ui_context',
5
6
  description: 'Read the PlayCraft browser view captured when the user sent this turn. This is a turn-start snapshot, not a live browser observation or visual verification. Only the visible panel target is returned by default; request open tabs only when needed.',
@@ -13,6 +14,13 @@ export const WORKSPACE_UI_TOOLS = [
13
14
  description: 'Open an existing preview in the current PlayCraft workspace browser view. Omit identifiers for the latest available preview. Opening does not start a build or verify the rendered appearance.',
14
15
  inputSchema: { type: 'object', properties: { artifactId: { type: 'string' }, versionId: { type: 'string' },
15
16
  previewId: { type: 'string' } }, additionalProperties: false } },
17
+ { type: 'function', name: 'playcraft_open_delivery',
18
+ description: 'Open the delivery panel in the current PlayCraft workspace browser view. Opening does not start or verify a build.',
19
+ inputSchema: { type: 'object', properties: { channel: { type: 'string' }, buildId: { type: 'string' } },
20
+ additionalProperties: false } },
21
+ { type: 'function', name: 'playcraft_open_task',
22
+ description: 'Open the task panel in the current PlayCraft workspace browser view. Opening does not change task state.',
23
+ inputSchema: { type: 'object', properties: { taskId: { type: 'string' } }, additionalProperties: false } },
16
24
  ];
17
25
  /** Read the saved view locally; browser commands have a separate receipt lifecycle. */
18
26
  export class WorkspaceUiTools {
@@ -48,8 +56,12 @@ export class WorkspaceUiTools {
48
56
  return;
49
57
  }
50
58
  const kind = params.tool === 'playcraft_open_file' ? 'open_file'
51
- : params.tool === 'playcraft_open_preview' ? 'open_preview' : undefined;
52
- const allowed = kind === 'open_file' ? ['path', 'range'] : ['artifactId', 'versionId', 'previewId'];
59
+ : params.tool === 'playcraft_open_preview' ? 'open_preview'
60
+ : params.tool === 'playcraft_open_delivery' ? 'open_delivery'
61
+ : params.tool === 'playcraft_open_task' ? 'open_task' : undefined;
62
+ const allowed = kind === 'open_file' ? ['path', 'range']
63
+ : kind === 'open_preview' ? ['artifactId', 'versionId', 'previewId']
64
+ : kind === 'open_delivery' ? ['channel', 'buildId'] : ['taskId'];
53
65
  if (!kind || Object.keys(args).some(key => !allowed.includes(key))) {
54
66
  void this.reject(client, requestId, 'invalid_ui_tool_arguments');
55
67
  return;
@@ -98,6 +110,8 @@ export class WorkspaceUiTools {
98
110
  if (snapshot && (snapshot.viewId !== command.viewId || snapshot.sessionId !== command.sessionId
99
111
  || snapshot.workspaceId !== command.workspaceId || snapshot.directoryName !== command.directoryName))
100
112
  return { outcome: 'failed', error: 'ui_result_scope_mismatch' };
113
+ const targetTab = command.kind === 'open_file' ? 'files'
114
+ : command.kind === 'open_preview' ? 'preview' : command.kind;
101
115
  if (result.status === 'opened' && (!snapshot?.panelOpen
102
116
  || (command.kind === 'open_file' && (!isAssetTab(snapshot.activeTab) || snapshot.activeFile?.path !== command.path
103
117
  || (command.range && (snapshot.activeFile.range?.startLine !== command.range.startLine
@@ -105,7 +119,11 @@ export class WorkspaceUiTools {
105
119
  || (command.kind === 'open_preview' && (snapshot.activeTab !== 'preview' || !snapshot.activePreview
106
120
  || (command.artifactId && snapshot.activePreview.artifactId !== command.artifactId)
107
121
  || (command.versionId && snapshot.activePreview.versionId !== command.versionId)
108
- || (command.previewId && snapshot.activePreview.previewId !== command.previewId)))))
122
+ || (command.previewId && snapshot.activePreview.previewId !== command.previewId)))
123
+ || (command.kind !== 'open_file' && command.kind !== 'open_preview' && snapshot.activeTab !== targetTab)
124
+ || (command.kind === 'open_delivery' && ((command.channel !== undefined && snapshot.activeDelivery?.channel !== command.channel)
125
+ || (command.buildId !== undefined && snapshot.activeDelivery?.buildId !== command.buildId)))
126
+ || (command.kind === 'open_task' && command.taskId !== undefined && snapshot.activeTask?.taskId !== command.taskId)))
109
127
  return { outcome: 'failed', error: 'ui_result_target_mismatch' };
110
128
  return this.finish(result.commandId, result, fingerprint);
111
129
  }
@@ -114,8 +132,7 @@ export class WorkspaceUiTools {
114
132
  .map(([commandId, pending]) => this.finish(commandId, { commandId, viewId: pending.command.viewId, status: 'failed', reason })));
115
133
  }
116
134
  async reject(client, requestId, reason) {
117
- await client.respond(requestId, { success: false, contentItems: [{ type: 'inputText', text: JSON.stringify({ status: 'failed', reason }) }] })
118
- .catch(() => undefined);
135
+ await rejectTool(client, requestId, reason);
119
136
  }
120
137
  async finish(commandId, result, fingerprint) {
121
138
  const pending = this.pending.get(commandId);
@@ -0,0 +1,44 @@
1
+ export const PREVIEW_DIAGNOSTICS_MARKER = 'data-playcraft-diagnostics';
2
+ /**
3
+ * Runs only inside a published preview. It reports browser-visible failures and a small
4
+ * performance sample to the workspace; delivery builds never include this script.
5
+ */
6
+ export const PREVIEW_DIAGNOSTICS_SCRIPT = `(function(){
7
+ if(window.__playcraftDiagnosticsV1)return;
8
+ window.__playcraftDiagnosticsV1=true;
9
+ var pageId=Math.random().toString(36).slice(2)+Date.now().toString(36);
10
+ var seq=0,buffer=[],seen={},stopped=false,start=Date.now();
11
+ function clean(value){try{return String(value).slice(0,2000)}catch(e){return ''}}
12
+ function emit(type,data){if(stopped||buffer.length>=100)return;var key=type+':'+(data.message||data.url||'');if(seen[key])return;seen[key]=1;buffer.push(Object.assign({type:type,at:Date.now()-start},data));}
13
+ function flush(){if(!buffer.length)return;var batch=buffer.splice(0,10);seq++;try{window.parent.postMessage({__playcraft:'diagnostics',v:1,pageId:pageId,seq:seq,events:batch},'*')}catch(e){}}
14
+ var timer=setInterval(flush,500);
15
+ window.addEventListener('error',function(event){
16
+ var target=event.target;
17
+ if(target&&target!==window&&target.tagName){emit('resource',{tag:String(target.tagName).toLowerCase(),url:clean(target.currentSrc||target.src||target.href||'')});return;}
18
+ emit('error',{message:clean(event.message),source:clean(event.filename),line:event.lineno,col:event.colno});
19
+ },true);
20
+ window.addEventListener('unhandledrejection',function(event){var reason=event.reason;emit('unhandledrejection',{message:clean(reason&&reason.message?reason.message:reason)});});
21
+ window.addEventListener('securitypolicyviolation',function(event){emit('securitypolicyviolation',{message:clean(event.violatedDirective),source:clean(event.blockedURI)});});
22
+ window.addEventListener('webglcontextlost',function(){emit('webgl-context-lost',{message:'webgl context lost'});},true);
23
+ function navigation(){
24
+ try{var entry=performance.getEntriesByType('navigation')[0];if(entry)emit('navigation',{duration:Math.round(entry.duration),domContentLoaded:Math.round(entry.domContentLoadedEventEnd),load:Math.round(entry.loadEventEnd)});}catch(e){}
25
+ try{var paint=performance.getEntriesByType('paint');for(var i=0;i<paint.length;i++){if(paint[i].name==='first-contentful-paint')emit('paint',{message:'first-contentful-paint',duration:Math.round(paint[i].startTime)});}}catch(e){}
26
+ }
27
+ if(document.readyState==='complete')navigation();else window.addEventListener('load',navigation,{once:true});
28
+ try{var observer=new PerformanceObserver(function(list){var entries=list.getEntries();for(var i=0;i<entries.length;i++){if(entries[i].name==='first-contentful-paint')emit('paint',{message:'first-contentful-paint',duration:Math.round(entries[i].startTime)});}});observer.observe({type:'paint',buffered:true});}catch(e){}
29
+ var frames=0,fpsStart=performance.now();
30
+ function sample(){frames++;var now=performance.now();if(now-fpsStart<5000){requestAnimationFrame(sample);}else{emit('fps',{fps:Math.round(frames/((now-fpsStart)/1000))});}}
31
+ requestAnimationFrame(sample);
32
+ window.setTimeout(function(){flush();stopped=true;clearInterval(timer);},60000);
33
+ })();`;
34
+ /** Injects only at preview publish time, after the builder has produced a single HTML file. */
35
+ export function injectPreviewDiagnostics(bytes) {
36
+ const html = bytes.toString('utf8');
37
+ if (html.includes(PREVIEW_DIAGNOSTICS_MARKER))
38
+ return bytes;
39
+ const script = `<script ${PREVIEW_DIAGNOSTICS_MARKER}>${PREVIEW_DIAGNOSTICS_SCRIPT}</script>`;
40
+ const head = /<head[^>]*>/i.exec(html);
41
+ const body = /<\/body\s*>/i.exec(html);
42
+ const at = head ? head.index + head[0].length : body ? body.index : html.length;
43
+ return Buffer.from(html.slice(0, at) + script + html.slice(at), 'utf8');
44
+ }
@@ -10,6 +10,9 @@ import { runDeveloperCommand } from '../workspaces/developer-command.js';
10
10
  import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
11
11
  import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
12
12
  import { saveWorkspacePlayableOutput } from './output.js';
13
+ import { injectPreviewDiagnostics } from './diagnostics.js';
14
+ import { OperationLogStore } from '../workspaces/operation-log.js';
15
+ import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
13
16
  const BUILD_TIMEOUT_MS = 10 * 60_000;
14
17
  /** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
15
18
  const OUTPUT_FOLDER = '.playcraft-preview-out';
@@ -68,19 +71,31 @@ export async function runPreviewPublish(state, operation, root) {
68
71
  : { sessionId: operation.sessionId, clientRequestId: operation.clientRequestId };
69
72
  const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
70
73
  const output = { log: '' };
74
+ const operationLog = new OperationLogStore(state.config.stateDir);
75
+ let logChain = Promise.resolve();
76
+ const appendLog = (text) => {
77
+ logChain = logChain.then(() => operationLog.append(operation.operationId, text)).catch(() => undefined);
78
+ };
79
+ await operationLog.append(operation.operationId, `$ preview build\n`).catch(() => undefined);
71
80
  try {
72
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/');
73
85
  await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
74
86
  await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
75
87
  await excludeFromGit(root, `/${WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY}/`);
76
- await buildPreview(state, root, outputDir, operation.operationId, output);
77
- const bytes = 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);
78
92
  if (bytes.byteLength > PREVIEW_HTML_MAX_BYTES)
79
93
  throw new PreviewFailure('preview_not_single_file');
80
94
  if (findExternalLocalReferences(bytes.toString('utf8')).length > 0) {
81
95
  throw new PreviewFailure('preview_not_single_file');
82
96
  }
83
- await saveWorkspacePlayableOutput(root, bytes);
97
+ if (!runtimePreview)
98
+ await saveWorkspacePlayableOutput(root, bytes);
84
99
  const contentDigest = createHash('sha256').update(bytes).digest('hex');
85
100
  const contentMd5 = createHash('md5').update(bytes).digest('base64');
86
101
  const source = await sourceRevision(root);
@@ -124,7 +139,9 @@ export async function runPreviewPublish(state, operation, root) {
124
139
  logTail: tail(output.log),
125
140
  }));
126
141
  if (published.sessionId !== operation.sessionId || published.previewId !== reserved.previewId ||
127
- published.artifactId !== reserved.artifactId) {
142
+ published.artifactId !== reserved.artifactId ||
143
+ published.contentDigest !== contentDigest ||
144
+ (runtimeBuild && JSON.stringify(published.runtimeParameters) !== JSON.stringify(runtimeBuild.profile))) {
128
145
  throw new PreviewFailure('preview_scope_mismatch');
129
146
  }
130
147
  if (published.status === 'conflict')
@@ -132,7 +149,8 @@ export async function runPreviewPublish(state, operation, root) {
132
149
  return published;
133
150
  }
134
151
  catch (error) {
135
- const code = error instanceof PreviewFailure ? error.code : 'preview_build_failed';
152
+ const code = error instanceof PreviewFailure || error instanceof RuntimePreviewBuildError ? error.code : 'preview_build_failed';
153
+ appendLog(`\n[${code}] ${error instanceof Error ? error.message : code}\n`);
136
154
  if (code !== 'publish_conflict') {
137
155
  await postControl(state, '/runtime/internal/preview-failures', {
138
156
  workspaceId,
@@ -146,6 +164,7 @@ export async function runPreviewPublish(state, operation, root) {
146
164
  throw new PreviewFailure(code);
147
165
  }
148
166
  finally {
167
+ await logChain;
149
168
  await rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
150
169
  }
151
170
  }
@@ -170,16 +189,18 @@ async function ensureDependencies(state, workspaceId, root, output) {
170
189
  throw new PreviewFailure('dependencies_not_ready');
171
190
  }
172
191
  }
173
- async function buildPreview(state, root, outputDir, operationId, output) {
192
+ async function buildPreview(state, root, outputDir, operationId, output, onLog, runtimeCommand) {
174
193
  // Tests replace the build; the real one needs a project and the published CLI.
175
194
  const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
176
195
  const outcome = await runDeveloperCommand(state, {
177
196
  processId: `preview-${operationId}`,
178
197
  cwd: root,
179
- command: `${planned} --output ${shellQuote(outputDir)} .`,
198
+ command: runtimeCommand ?? `${planned} --output ${shellQuote(outputDir)} .`,
180
199
  timeoutMs: BUILD_TIMEOUT_MS,
181
200
  onOutput: (chunk) => {
182
- output.log = (output.log + String(chunk)).slice(-64 * 1024);
201
+ const text = String(chunk);
202
+ output.log = (output.log + text).slice(-64 * 1024);
203
+ onLog?.(text);
183
204
  },
184
205
  });
185
206
  if (outcome.code !== 0)