@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.
- package/dist/workspace-runtime/batch/deliver.js +105 -87
- 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 +19 -1
- 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 +29 -8
- package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
- package/dist/workspace-runtime/serve.js +162 -4
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/asset-transfers.js +63 -0
- package/dist/workspace-runtime/server/dispatch.js +328 -44
- package/dist/workspace-runtime/server/http.js +193 -137
- 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/asset-transfer-dependencies.js +81 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -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/directory-mutations.js +238 -0
- package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
- package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
- package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
- package/dist/workspace-runtime/workspaces/parameters.js +77 -54
- 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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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) !==
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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
|
-
...(
|
|
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'
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|