@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.
- package/dist/commands/tools-batch.js +12 -2
- package/dist/workspace-runtime/batch/control.js +6 -0
- package/dist/workspace-runtime/batch/deliver.js +105 -87
- package/dist/workspace-runtime/batch/generate.js +26 -3
- package/dist/workspace-runtime/batch/prepare.js +169 -56
- package/dist/workspace-runtime/batch/source-capture.js +7 -20
- package/dist/workspace-runtime/batch/source-publish.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +29 -4
- package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
- package/dist/workspace-runtime/codex/tool-response.js +10 -0
- package/dist/workspace-runtime/codex/ui-tools.js +22 -5
- package/dist/workspace-runtime/previews/diagnostics.js +44 -0
- package/dist/workspace-runtime/previews/publish.js +16 -4
- package/dist/workspace-runtime/serve.js +60 -0
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/dispatch.js +47 -2
- package/dist/workspace-runtime/server/http.js +163 -130
- package/dist/workspace-runtime/server/operation-record.js +33 -0
- package/dist/workspace-runtime/server/submit-operation.js +41 -0
- package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
- package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
- package/dist/workspace-runtime/workspaces/delivery.js +64 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
- package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
- package/dist/workspace-runtime/workspaces/directories.js +9 -4
- package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
- 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'
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|