@playcraft/cli 0.1.47-beta → 0.1.49-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 (28) hide show
  1. package/dist/commands/tools-batch.js +12 -2
  2. package/dist/workspace-runtime/batch/control.js +6 -0
  3. package/dist/workspace-runtime/batch/deliver.js +105 -87
  4. package/dist/workspace-runtime/batch/generate.js +26 -3
  5. package/dist/workspace-runtime/batch/prepare.js +169 -56
  6. package/dist/workspace-runtime/batch/source-capture.js +7 -20
  7. package/dist/workspace-runtime/batch/source-publish.js +2 -4
  8. package/dist/workspace-runtime/codex/app-server.js +29 -4
  9. package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
  10. package/dist/workspace-runtime/codex/tool-response.js +10 -0
  11. package/dist/workspace-runtime/codex/ui-tools.js +22 -5
  12. package/dist/workspace-runtime/previews/diagnostics.js +44 -0
  13. package/dist/workspace-runtime/previews/publish.js +16 -4
  14. package/dist/workspace-runtime/serve.js +60 -0
  15. package/dist/workspace-runtime/server/accept-operation.js +61 -0
  16. package/dist/workspace-runtime/server/dispatch.js +47 -2
  17. package/dist/workspace-runtime/server/http.js +163 -130
  18. package/dist/workspace-runtime/server/operation-record.js +33 -0
  19. package/dist/workspace-runtime/server/submit-operation.js +41 -0
  20. package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
  21. package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
  22. package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
  23. package/dist/workspace-runtime/workspaces/delivery.js +64 -0
  24. package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
  25. package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
  26. package/dist/workspace-runtime/workspaces/directories.js +9 -4
  27. package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
  28. package/package.json +3 -3
@@ -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,8 @@ 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';
13
15
  const BUILD_TIMEOUT_MS = 10 * 60_000;
14
16
  /** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
15
17
  const OUTPUT_FOLDER = '.playcraft-preview-out';
@@ -68,13 +70,19 @@ export async function runPreviewPublish(state, operation, root) {
68
70
  : { sessionId: operation.sessionId, clientRequestId: operation.clientRequestId };
69
71
  const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
70
72
  const output = { log: '' };
73
+ const operationLog = new OperationLogStore(state.config.stateDir);
74
+ let logChain = Promise.resolve();
75
+ const appendLog = (text) => {
76
+ logChain = logChain.then(() => operationLog.append(operation.operationId, text)).catch(() => undefined);
77
+ };
78
+ await operationLog.append(operation.operationId, `$ preview build\n`).catch(() => undefined);
71
79
  try {
72
80
  await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
73
81
  await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
74
82
  await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
75
83
  await excludeFromGit(root, `/${WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY}/`);
76
- await buildPreview(state, root, outputDir, operation.operationId, output);
77
- const bytes = await readSingleHtml(outputDir);
84
+ await buildPreview(state, root, outputDir, operation.operationId, output, appendLog);
85
+ const bytes = injectPreviewDiagnostics(await readSingleHtml(outputDir));
78
86
  if (bytes.byteLength > PREVIEW_HTML_MAX_BYTES)
79
87
  throw new PreviewFailure('preview_not_single_file');
80
88
  if (findExternalLocalReferences(bytes.toString('utf8')).length > 0) {
@@ -133,6 +141,7 @@ export async function runPreviewPublish(state, operation, root) {
133
141
  }
134
142
  catch (error) {
135
143
  const code = error instanceof PreviewFailure ? error.code : 'preview_build_failed';
144
+ appendLog(`\n[${code}] ${error instanceof Error ? error.message : code}\n`);
136
145
  if (code !== 'publish_conflict') {
137
146
  await postControl(state, '/runtime/internal/preview-failures', {
138
147
  workspaceId,
@@ -146,6 +155,7 @@ export async function runPreviewPublish(state, operation, root) {
146
155
  throw new PreviewFailure(code);
147
156
  }
148
157
  finally {
158
+ await logChain;
149
159
  await rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
150
160
  }
151
161
  }
@@ -170,7 +180,7 @@ async function ensureDependencies(state, workspaceId, root, output) {
170
180
  throw new PreviewFailure('dependencies_not_ready');
171
181
  }
172
182
  }
173
- async function buildPreview(state, root, outputDir, operationId, output) {
183
+ async function buildPreview(state, root, outputDir, operationId, output, onLog) {
174
184
  // Tests replace the build; the real one needs a project and the published CLI.
175
185
  const planned = process.env.PLAYCRAFT_PREVIEW_BUILD_COMMAND || BUILD_COMMAND;
176
186
  const outcome = await runDeveloperCommand(state, {
@@ -179,7 +189,9 @@ async function buildPreview(state, root, outputDir, operationId, output) {
179
189
  command: `${planned} --output ${shellQuote(outputDir)} .`,
180
190
  timeoutMs: BUILD_TIMEOUT_MS,
181
191
  onOutput: (chunk) => {
182
- output.log = (output.log + String(chunk)).slice(-64 * 1024);
192
+ const text = String(chunk);
193
+ output.log = (output.log + text).slice(-64 * 1024);
194
+ onLog?.(text);
183
195
  },
184
196
  });
185
197
  if (outcome.code !== 0)
@@ -2,6 +2,7 @@ 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 { createPlatformAdapter } from '@playcraft/build';
5
6
  import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
6
7
  import { RuntimeJournal } from './persistence/journal.js';
7
8
  import { createCodexAppServerAdapter } from './codex/app-server.js';
@@ -18,6 +19,7 @@ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker
18
19
  import { WorkspaceModelBroker } from './codex/model-broker.js';
19
20
  import { recoverInterruptedPreviews } from './previews/publish.js';
20
21
  import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
22
+ import { DeliveryStore, recoverInterruptedDeliveries } from './workspaces/delivery-store.js';
21
23
  import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
22
24
  import { workspaceContextFromRoot, workspaceRoot } from './workspaces/registry.js';
23
25
  import { recoverInterruptedLibraryImports } from './workspaces/asset-library-import.js';
@@ -26,6 +28,7 @@ import { revisionScope } from './workspaces/registry.js';
26
28
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
27
29
  import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
28
30
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
31
+ import { submitTrustedOperation } from './server/submit-operation.js';
29
32
  export async function startWorkspaceRuntime(config, options = {}) {
30
33
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
31
34
  const negotiated = negotiateProtocol(config);
@@ -49,6 +52,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
49
52
  const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
50
53
  await recoverInterruptedInstalls(journal);
51
54
  await recoverInterruptedPreviews(journal);
55
+ await recoverInterruptedDeliveries(config.stateDir);
52
56
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
53
57
  const state = runtimeState = {
54
58
  config: { ...config, protocolMinor: negotiated.protocolMinor },
@@ -253,6 +257,61 @@ function closeServer(server) {
253
257
  function formatHost(host) {
254
258
  return host.includes(':') ? `[${host}]` : host;
255
259
  }
260
+ function deliveryToolHandlers(state) {
261
+ const contextFor = async (input) => {
262
+ if (input.workspaceId)
263
+ return { workspaceId: input.workspaceId, directoryName: input.directoryName ?? 'basic' };
264
+ if (state.config.workspaceMode !== 'multi' || !input.cwd)
265
+ throw new Error('feature_unavailable');
266
+ return workspaceContextFromRoot(state, input.cwd);
267
+ };
268
+ return {
269
+ async build(input) {
270
+ const { workspaceId, directoryName } = await contextFor(input);
271
+ if (directoryName === 'root')
272
+ throw new Error('invalid_workspace_directory');
273
+ const builds = [];
274
+ for (const channel of input.channels) {
275
+ const operation = await submitTrustedOperation(state, {
276
+ kind: 'delivery.build',
277
+ workspaceId,
278
+ directoryName,
279
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
280
+ clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
281
+ payload: {
282
+ channel,
283
+ format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
284
+ actor: 'agent',
285
+ },
286
+ });
287
+ let record;
288
+ for (let attempt = 0; attempt < 40 && !record; attempt++) {
289
+ record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
290
+ if (!record)
291
+ await new Promise(resolve => setTimeout(resolve, 25));
292
+ }
293
+ builds.push({
294
+ buildId: operation.operationId,
295
+ channel,
296
+ status: record?.status ?? operation.status,
297
+ ...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
298
+ });
299
+ }
300
+ return { builds };
301
+ },
302
+ async get(input) {
303
+ const { workspaceId, directoryName } = await contextFor(input);
304
+ const store = new DeliveryStore(state.config.stateDir, workspaceId);
305
+ const record = input.buildId
306
+ ? await store.read(input.buildId)
307
+ : (await store.list(directoryName, input.channel, 20))[0];
308
+ if (!record || record.directoryName !== directoryName)
309
+ throw new Error('delivery_build_not_found');
310
+ const log = await store.readLog(record.buildId, 0).catch(() => undefined);
311
+ return { build: record, ...(log ? { logTail: log.text.slice(-4_000) } : {}) };
312
+ },
313
+ };
314
+ }
256
315
  function defaultNativeAdapter(state) {
257
316
  const config = state.config;
258
317
  if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
@@ -266,6 +325,7 @@ function defaultNativeAdapter(state) {
266
325
  return createCodexAppServerAdapter({
267
326
  config,
268
327
  workspaceUi: true,
328
+ deliveryTools: deliveryToolHandlers(state),
269
329
  ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
270
330
  ...(multi ? { loadMcpConfig: async (cwd) => {
271
331
  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
+ }
@@ -4,7 +4,7 @@ import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../work
4
4
  import { shareWorkspaceGit } from '../workspaces/shared-git.js';
5
5
  import { join } from 'node:path';
6
6
  import { z } from 'zod';
7
- import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
8
8
  import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
9
9
  import { artifactFormat } from '../workspaces/artifact-files.js';
10
10
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -31,6 +31,7 @@ import { batchDirectoryHeld } from '../batch/occupancy.js';
31
31
  import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
32
32
  import { importWorkspaceLibraryAssets } from '../workspaces/asset-library-import.js';
33
33
  import { createWorkspaceDirectory, WorkspaceDirectoryCreateError } from '../workspaces/directory-create.js';
34
+ import { runDeliveryBuild, cancelDeliveryBuild, trackDeliveryBuild, DeliveryBuildError } from '../workspaces/delivery-build.js';
34
35
  const CONVERSATION_KINDS = new Set([
35
36
  'session.create',
36
37
  'session.turn',
@@ -59,6 +60,8 @@ const LOCAL_KINDS = new Set([
59
60
  'terminal.create',
60
61
  'parameter.patch',
61
62
  'preview.publish',
63
+ 'delivery.build',
64
+ 'delivery.cancel',
62
65
  'skills.update',
63
66
  ]);
64
67
  export function canExecuteKind(state, kind) {
@@ -75,7 +78,7 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
75
78
  }
76
79
  return executeScopedOperation(state, scopeKey, operation, payload, recovery);
77
80
  };
78
- if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish'].includes(operation.kind)) {
81
+ if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
79
82
  return run();
80
83
  }
81
84
  return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
@@ -140,6 +143,12 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
140
143
  if (operation.kind === 'preview.publish') {
141
144
  return executePreviewPublish(state, scopeKey, current);
142
145
  }
146
+ if (operation.kind === 'delivery.build') {
147
+ return executeDeliveryBuild(state, scopeKey, current, payload);
148
+ }
149
+ if (operation.kind === 'delivery.cancel') {
150
+ return executeDeliveryCancel(state, scopeKey, current, payload);
151
+ }
143
152
  if (!state.native?.ready()) {
144
153
  return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'capability_unavailable' }));
145
154
  }
@@ -1165,6 +1174,42 @@ async function executePreviewPublish(state, scopeKey, operation) {
1165
1174
  }).catch(() => undefined));
1166
1175
  return accepted;
1167
1176
  }
1177
+ async function executeDeliveryBuild(state, scopeKey, operation, payload) {
1178
+ const parsed = deliveryBuildPayloadSchema.safeParse(payload);
1179
+ if (!parsed.success) {
1180
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_delivery_build' }));
1181
+ }
1182
+ if (!operation.workspaceId) {
1183
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace' }));
1184
+ }
1185
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
1186
+ const task = runDeliveryBuild(state, {
1187
+ buildId: operation.operationId,
1188
+ workspaceId: operation.workspaceId,
1189
+ directoryName: operation.directoryName ?? 'basic',
1190
+ payload: parsed.data,
1191
+ actor: parsed.data.actor ?? 'user',
1192
+ }).then(async (result) => {
1193
+ const error = result.record.error
1194
+ ?? (result.record.status === 'succeeded' ? undefined : 'delivery_build_failed');
1195
+ await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : 'failed', error ? { error } : {}));
1196
+ }, async (error) => {
1197
+ await persist(state, scopeKey, transitionOperation(accepted, 'failed', {
1198
+ error: error instanceof DeliveryBuildError ? error.code : 'delivery_build_failed',
1199
+ }));
1200
+ }).catch(() => undefined);
1201
+ trackDeliveryBuild(state, task);
1202
+ return accepted;
1203
+ }
1204
+ async function executeDeliveryCancel(state, scopeKey, operation, payload) {
1205
+ const parsed = deliveryCancelPayloadSchema.safeParse(payload);
1206
+ if (!parsed.success || !operation.workspaceId) {
1207
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_delivery_cancel' }));
1208
+ }
1209
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
1210
+ const canceled = await cancelDeliveryBuild(state, operation.workspaceId, operation.directoryName ?? 'basic', parsed.data.buildId);
1211
+ return persist(state, scopeKey, transitionOperation(accepted, canceled ? 'completed' : 'failed', canceled ? {} : { error: 'delivery_build_not_found' }));
1212
+ }
1168
1213
  async function executeTerminalCreate(state, scopeKey, operation, payload) {
1169
1214
  const parsed = terminalCreatePayloadSchema.safeParse(payload ?? {});
1170
1215
  if (!parsed.success) {