@playcraft/cli 0.1.60-beta → 0.1.62-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/build.js +16 -7
- package/dist/project-skills/builtin-import.js +2 -2
- package/dist/skill-registry/messages.js +2 -2
- package/dist/skill-registry/platform-package.js +2 -4
- package/dist/utils/agent-api-client.js +22 -0
- package/dist/workspace-runtime/codex/app-server.js +24 -1
- package/dist/workspace-runtime/codex/config-toml.js +9 -5
- package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
- package/dist/workspace-runtime/codex/launcher-client.js +25 -13
- package/dist/workspace-runtime/codex/model-broker.js +13 -3
- package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +12 -1
- package/dist/workspace-runtime/server/accept-operation.js +10 -1
- package/dist/workspace-runtime/server/directory-access.js +11 -4
- package/dist/workspace-runtime/server/dispatch.js +70 -3
- package/dist/workspace-runtime/server/http.js +61 -3
- package/dist/workspace-runtime/server/operation-record.js +4 -1
- package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
- package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
- package/dist/workspace-runtime/workspaces/directories.js +4 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
- package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
- package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
- package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
- package/dist/workspace-runtime/workspaces/skills.js +15 -5
- package/package.json +4 -3
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +11 -1
package/dist/commands/build.js
CHANGED
|
@@ -9,7 +9,8 @@ import inquirer from 'inquirer';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { generatedWorkspace } from '../workspace-runtime/workspaces/artifact-receipts.js';
|
|
11
11
|
import { isolateWorkspaceForBuild } from '../workspace-runtime/workspaces/delivery-build.js';
|
|
12
|
-
import { finalizeWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
|
|
12
|
+
import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
|
|
13
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
13
14
|
/**
|
|
14
15
|
* 类型守卫:检查对象是否符合 PlayableScriptsConfig 类型
|
|
15
16
|
*/
|
|
@@ -268,13 +269,21 @@ export async function buildCommand(projectPath, options) {
|
|
|
268
269
|
if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith('.html'))
|
|
269
270
|
throw new Error('preview_not_single_file');
|
|
270
271
|
await finalizeWorkspacePlayable(workspace.root, await fs.readFile(path.join(output, entries[0].name)), 'playcraft build', { expectedSignature: signature });
|
|
272
|
+
console.log(path.join(workspace.root, 'dist/playable/index.html'));
|
|
273
|
+
try {
|
|
274
|
+
const { bytes } = await readWorkspacePlayable(workspace.root);
|
|
275
|
+
const { previewUrl } = await new AgentApiClient().publishWorkspacePlayable({ workspaceId: workspace.workspaceId, directoryName: path.basename(workspace.root) }, bytes);
|
|
276
|
+
console.log(`Public URL: ${previewUrl}`);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
console.warn(`Public URL unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
271
282
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
console.log(canonicalOutput ? path.join(workspace.root, 'dist/playable/index.html') : requestedOutput);
|
|
283
|
+
if (options.clean === true)
|
|
284
|
+
await fs.rm(requestedOutput, { recursive: true, force: true });
|
|
285
|
+
await fs.cp(output, requestedOutput, { recursive: true });
|
|
286
|
+
console.log(requestedOutput);
|
|
278
287
|
return;
|
|
279
288
|
}
|
|
280
289
|
finally {
|
|
@@ -2,9 +2,9 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { canonicalProjectSkillsJson, declarationDigest, dependencyDigest, extractBundleArchive, readProjectSkillSource, readProjectSkillsLockFile, validateProjectSkillsSnapshot, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, } from '@playcraft/common/project-skills';
|
|
5
|
-
import { LEGACY_VIDEO_SOURCE_HASHES } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { LEGACY_VIDEO_SOURCE_HASHES, WORKSPACE_BUILTIN_SKILL_NAMES } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { projectSkillsCache } from './local-store.js';
|
|
7
|
-
export const IMPORTABLE_BUILTIN_SKILLS =
|
|
7
|
+
export const IMPORTABLE_BUILTIN_SKILLS = WORKSPACE_BUILTIN_SKILL_NAMES;
|
|
8
8
|
export const platformSkillRef = (name) => `remix/${name}`;
|
|
9
9
|
const hash = (value) => createHash('sha256').update(canonicalProjectSkillsJson(value)).digest('hex');
|
|
10
10
|
async function safeDirectory(root, path) {
|
|
@@ -49,7 +49,7 @@ const messages = {
|
|
|
49
49
|
platform: 'Register native platform skill bundles',
|
|
50
50
|
platformPush: 'Register a native platform draft from a directory or archive without publishing',
|
|
51
51
|
evaluationTemplate: 'Optional evaluation template identifier for the draft',
|
|
52
|
-
invalidPlatformBundle: 'Expected
|
|
52
|
+
invalidPlatformBundle: 'Expected a registered native platform skill bundle with an aligned manifest and SKILL.md.',
|
|
53
53
|
evaluation: 'Configure and run real skill evaluations',
|
|
54
54
|
evaluationTemplates: 'List evaluation runtime templates',
|
|
55
55
|
createTemplate: 'Create an evaluation template draft from a file',
|
|
@@ -129,7 +129,7 @@ const messages = {
|
|
|
129
129
|
platform: '登记原生平台技能包',
|
|
130
130
|
platformPush: '从目录或压缩包登记原生平台草稿,不执行发布',
|
|
131
131
|
evaluationTemplate: '草稿可选使用的评估模板标识',
|
|
132
|
-
invalidPlatformBundle: '
|
|
132
|
+
invalidPlatformBundle: '需要已登记的原生平台技能包,且清单与 SKILL.md 信息一致;不自动改写清单。',
|
|
133
133
|
evaluation: '配置并执行真实技能评估',
|
|
134
134
|
evaluationTemplates: '列出评估运行模板',
|
|
135
135
|
createTemplate: '根据文件创建评估模板草稿',
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { constants, promises as fs } from 'node:fs';
|
|
2
2
|
import { extractBundleArchive } from '@playcraft/common/project-skills';
|
|
3
|
+
import { WORKSPACE_BUILTIN_SKILL_REFS } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
4
|
import yaml from 'js-yaml';
|
|
4
5
|
import { MAX_BUNDLE_BYTES, packSkillDirectory, parseSkillReference, sha256, } from './package.js';
|
|
5
6
|
import { message, RegistryError } from './messages.js';
|
|
6
|
-
const NATIVE_REFS = new Set(
|
|
7
|
-
'remix/playcraft-video-understanding',
|
|
8
|
-
'remix/playcraft-replica-validation',
|
|
9
|
-
]);
|
|
7
|
+
const NATIVE_REFS = new Set(WORKSPACE_BUILTIN_SKILL_REFS);
|
|
10
8
|
/** Preserve native manifest bytes; the server remains the authoritative bundle validator. */
|
|
11
9
|
export function inspectNativePlatformArchive(archive) {
|
|
12
10
|
const invalid = () => {
|
|
@@ -11,6 +11,7 @@ import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
|
11
11
|
import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
|
|
12
12
|
import { beginToolOperation, findToolOperation } from './tool-operation-journal.js';
|
|
13
13
|
import { businessEndpoint } from './environment-endpoints.js';
|
|
14
|
+
import { PLATFORM_ROUTES } from '@playcraft/common/cloud-workspace/runtime';
|
|
14
15
|
import { cloudRuntimeCredentialHint, isWorkspaceGenerationPath, withGenerationWorkspaceScope } from './generation-workspace-scope.js';
|
|
15
16
|
import { agentGenerationProvenanceHeaders } from './agent-generation-provenance.js';
|
|
16
17
|
import { redactDiagnosticText } from './error-diagnostics.js';
|
|
@@ -301,6 +302,27 @@ export class AgentApiClient {
|
|
|
301
302
|
}
|
|
302
303
|
await response.body?.cancel();
|
|
303
304
|
}
|
|
305
|
+
/** Publish a workspace's single-file playable through the runtime with the sandbox credential. */
|
|
306
|
+
async publishWorkspacePlayable(query, html, signal = AbortSignal.timeout(2 * 60_000)) {
|
|
307
|
+
const url = businessEndpoint(this.apiUrl, PLATFORM_ROUTES.cliPlayablePublications);
|
|
308
|
+
url.searchParams.set('workspaceId', query.workspaceId);
|
|
309
|
+
url.searchParams.set('directoryName', query.directoryName);
|
|
310
|
+
const response = await fetch(url, {
|
|
311
|
+
method: 'POST', redirect: 'error', signal,
|
|
312
|
+
headers: { ...this.authHeaders(), 'content-type': 'application/octet-stream' },
|
|
313
|
+
body: html,
|
|
314
|
+
});
|
|
315
|
+
const text = await response.text().catch(() => response.statusText);
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const reason = responseErrorReason(text) || redactDiagnosticText(response.statusText);
|
|
318
|
+
throw new Error(`playable_publication_failed:${response.status}${reason ? `: ${reason}` : ''}`);
|
|
319
|
+
}
|
|
320
|
+
const previewUrl = JSON.parse(text).previewUrl;
|
|
321
|
+
if (typeof previewUrl !== 'string' || !/^https?:$/.test(new URL(previewUrl).protocol)) {
|
|
322
|
+
throw new Error('playable_publication_failed: invalid_public_url');
|
|
323
|
+
}
|
|
324
|
+
return { previewUrl };
|
|
325
|
+
}
|
|
304
326
|
/**
|
|
305
327
|
* 按顺序搜索本地 .playcraft.json 文件(沙箱内或项目目录)。
|
|
306
328
|
* 与全局 login 配置(~/.playcraft/config.json)不同,前者专用于沙箱 token。
|
|
@@ -20,6 +20,7 @@ import { WORKSPACE_UI_TOOLS, WorkspaceUiTools } from './ui-tools.js';
|
|
|
20
20
|
import { WORKSPACE_DELIVERY_TOOLS, WorkspaceDeliveryTools, isWorkspaceDeliveryTool } from './delivery-tools.js';
|
|
21
21
|
import { WORKSPACE_UI_INSTRUCTIONS } from './ui-instructions.js';
|
|
22
22
|
import { WORKSPACE_SCHEDULED_TASK_TOOLS, WorkspaceScheduledTaskTools, isWorkspaceScheduledTaskTool } from './scheduled-task-tools.js';
|
|
23
|
+
import { PROCEDURAL_3D_TOOL, ProductionSkillTools } from './production-skill-tools.js';
|
|
23
24
|
function withModelShell(config, modelConfigId) {
|
|
24
25
|
if (!modelConfigId || !/^[0-9a-f-]{36}$/i.test(modelConfigId))
|
|
25
26
|
return config;
|
|
@@ -195,10 +196,12 @@ export function createCodexAppServerAdapter(options) {
|
|
|
195
196
|
const scheduledTaskTools = options.scheduledTaskProposals ? new WorkspaceScheduledTaskTools(options.scheduledTaskProposals, (proposalId, source) => emitCollected(collectedEvent('notification', 'scheduled-task.proposal.changed', {
|
|
196
197
|
proposalId, sessionId: threads.get(source.threadId)?.sessionId, threadId: source.threadId, turnId: source.turnId,
|
|
197
198
|
}))) : undefined;
|
|
199
|
+
const productionSkill = options.productionSkill ? new ProductionSkillTools(options.productionSkill) : undefined;
|
|
198
200
|
const dynamicTools = [
|
|
199
201
|
...(options.workspaceUi ? WORKSPACE_UI_TOOLS : []),
|
|
200
202
|
...(deliveryTools ? WORKSPACE_DELIVERY_TOOLS : []),
|
|
201
203
|
...(scheduledTaskTools ? WORKSPACE_SCHEDULED_TASK_TOOLS : []),
|
|
204
|
+
...(productionSkill ? [PROCEDURAL_3D_TOOL] : []),
|
|
202
205
|
];
|
|
203
206
|
function emitCollected(event) {
|
|
204
207
|
for (const listener of collected)
|
|
@@ -599,7 +602,13 @@ export function createCodexAppServerAdapter(options) {
|
|
|
599
602
|
approvals.delete(key);
|
|
600
603
|
}
|
|
601
604
|
}
|
|
602
|
-
|
|
605
|
+
if (method === 'turn/completed' && typeof payload.threadId === 'string'
|
|
606
|
+
&& productionSkill?.hasActive(payload.threadId, String(asRecord(payload.turn).id))) {
|
|
607
|
+
// Keep the operation's directory lease until the tool's writer has stopped.
|
|
608
|
+
void productionSkill.cancel(payload.threadId, String(asRecord(payload.turn).id)).then(() => emitCollected(collectedEvent('notification', method, payload)));
|
|
609
|
+
}
|
|
610
|
+
else
|
|
611
|
+
emitCollected(collectedEvent('notification', method, payload));
|
|
603
612
|
});
|
|
604
613
|
rpc.onServerRequest((id, method, params) => {
|
|
605
614
|
const payload = asRecord(params);
|
|
@@ -634,6 +643,16 @@ export function createCodexAppServerAdapter(options) {
|
|
|
634
643
|
}, 0);
|
|
635
644
|
return;
|
|
636
645
|
}
|
|
646
|
+
if (productionSkill && payload.tool === PROCEDURAL_3D_TOOL.name) {
|
|
647
|
+
const trusted = thread?.turnActive && thread.cwd && thread.activeMode === 'execute'
|
|
648
|
+
&& thread.permissionMode !== 'read-only' && thread.readOnlyConfirmed !== true
|
|
649
|
+
&& payload.turnId === thread.lastTurnId && typeof payload.threadId === 'string' && typeof payload.turnId === 'string';
|
|
650
|
+
void productionSkill.request(id, payload, nativeClient, trusted ? {
|
|
651
|
+
sessionId: thread.sessionId, root: thread.cwd,
|
|
652
|
+
nativeThreadId: payload.threadId, nativeTurnId: payload.turnId,
|
|
653
|
+
} : undefined);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
637
656
|
if (deliveryTools && isWorkspaceDeliveryTool(payload.tool)) {
|
|
638
657
|
void deliveryTools.request(id, payload, nativeClient, {
|
|
639
658
|
sessionId: thread?.sessionId,
|
|
@@ -678,6 +697,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
678
697
|
if (live === current) {
|
|
679
698
|
readyFlag = false;
|
|
680
699
|
void uiTools.cancel(undefined, 'ui_runtime_disconnected');
|
|
700
|
+
void productionSkill?.cancel();
|
|
681
701
|
approvals.clear();
|
|
682
702
|
// Retain the old handle until its entire execution group is confirmed stopped.
|
|
683
703
|
rpc = undefined;
|
|
@@ -781,6 +801,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
781
801
|
previous.superseded = true;
|
|
782
802
|
readyFlag = false;
|
|
783
803
|
await uiTools.cancel(undefined, 'ui_runtime_disconnected');
|
|
804
|
+
await productionSkill?.cancel();
|
|
784
805
|
startPromise = undefined;
|
|
785
806
|
threads.clear();
|
|
786
807
|
approvals.clear();
|
|
@@ -1267,6 +1288,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
1267
1288
|
return { outcome: 'failed', error: 'turn_changed' };
|
|
1268
1289
|
try {
|
|
1269
1290
|
await uiTools.cancel(input.threadId);
|
|
1291
|
+
await productionSkill?.cancel(input.threadId, turnId);
|
|
1270
1292
|
await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
|
|
1271
1293
|
const cwd = threads.get(input.threadId)?.cwd;
|
|
1272
1294
|
if (cwd && (input.expectedTurnId === undefined || threads.get(input.threadId)?.lastTurnId === turnId))
|
|
@@ -1327,6 +1349,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
1327
1349
|
closed = true;
|
|
1328
1350
|
readyFlag = false;
|
|
1329
1351
|
await uiTools.cancel(undefined, 'ui_runtime_disconnected');
|
|
1352
|
+
await productionSkill?.cancel();
|
|
1330
1353
|
approvals.clear();
|
|
1331
1354
|
if (live)
|
|
1332
1355
|
live.superseded = true;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
const exec = promisify(execFile);
|
|
@@ -77,13 +77,17 @@ export async function writePortableCodexCatalog(input) {
|
|
|
77
77
|
}
|
|
78
78
|
await mkdir(input.directory, { recursive: true });
|
|
79
79
|
const path = join(input.directory, 'portable-models.json');
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
await
|
|
80
|
+
// shared: public model metadata crosses from the control user to the development user.
|
|
81
|
+
// Explicit permissions also repair existing files and override a private process umask.
|
|
82
|
+
await writeFile(path, body, { encoding: 'utf8', mode: 0o644 }); // shared
|
|
83
|
+
await chmod(path, 0o644);
|
|
84
|
+
const versionPath = join(input.directory, 'portable-models.version.json');
|
|
85
|
+
await writeFile(// shared
|
|
86
|
+
versionPath, JSON.stringify({
|
|
84
87
|
version,
|
|
85
88
|
sha256: createHash('sha256').update(body).digest('hex'),
|
|
86
89
|
}), { encoding: 'utf8', mode: 0o644 });
|
|
90
|
+
await chmod(versionPath, 0o644);
|
|
87
91
|
return path;
|
|
88
92
|
}
|
|
89
93
|
export async function writePlaycraftCodexConfig(input) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { DELIVERY_CHANNELS,
|
|
2
|
+
import { DELIVERY_CHANNELS, } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
3
|
import { rejectTool, toolContent } from './tool-response.js';
|
|
4
4
|
export const WORKSPACE_DELIVERY_TOOLS = [
|
|
5
5
|
{
|
|
6
6
|
type: 'function',
|
|
7
7
|
name: 'playcraft_build_delivery',
|
|
8
|
-
description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit
|
|
8
|
+
description: 'Export channel packages through the PlayCraft platform. This first saves the workspace (commit and push), then exports that commit. Each channel uses the same fixed HTML or ZIP choice as the Export button for that channel and the project engine. Other options reuse the channel\'s latest export on this branch, or the defaults when it has none. A workspace without a project first gets one, with a private repository, as its first save would. If the result is pending at workspace.save, finish the current turn without polling so the save can run, then continue the export in a later turn. Once build ids are returned, poll playcraft_get_delivery for status, checks and failure details.',
|
|
9
9
|
inputSchema: {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
@@ -16,7 +16,6 @@ export const WORKSPACE_DELIVERY_TOOLS = [
|
|
|
16
16
|
maxItems: 8,
|
|
17
17
|
uniqueItems: true,
|
|
18
18
|
},
|
|
19
|
-
format: { type: 'string', enum: ['html', 'zip'] },
|
|
20
19
|
},
|
|
21
20
|
additionalProperties: false,
|
|
22
21
|
},
|
|
@@ -71,7 +70,7 @@ export class WorkspaceDeliveryTools {
|
|
|
71
70
|
await this.reject(client, requestId, 'permission_denied');
|
|
72
71
|
return;
|
|
73
72
|
}
|
|
74
|
-
const unknown = Object.keys(input).filter(key => key !== 'channels'
|
|
73
|
+
const unknown = Object.keys(input).filter(key => key !== 'channels');
|
|
75
74
|
if (unknown.length) {
|
|
76
75
|
await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
|
|
77
76
|
return;
|
|
@@ -83,11 +82,6 @@ export class WorkspaceDeliveryTools {
|
|
|
83
82
|
await this.reject(client, requestId, 'invalid_delivery_tool_arguments');
|
|
84
83
|
return;
|
|
85
84
|
}
|
|
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
85
|
try {
|
|
92
86
|
const result = await this.handlers.build({
|
|
93
87
|
...context,
|
|
@@ -95,7 +89,6 @@ export class WorkspaceDeliveryTools {
|
|
|
95
89
|
? payload.callId
|
|
96
90
|
: randomUUID(),
|
|
97
91
|
channels: channels,
|
|
98
|
-
...(format && format.success ? { format: format.data } : {}),
|
|
99
92
|
});
|
|
100
93
|
await this.respond(client, requestId, result);
|
|
101
94
|
}
|
|
@@ -38,18 +38,7 @@ export async function stopLauncherProcess(input) {
|
|
|
38
38
|
if (!input.instanceId)
|
|
39
39
|
throw new Error('launcher_start_unconfirmed');
|
|
40
40
|
const { socketPath, ...identity } = input;
|
|
41
|
-
|
|
42
|
-
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
43
|
-
await request(socketPath, { ...identity, action: 'stop', signal });
|
|
44
|
-
const deadline = Date.now() + 3000;
|
|
45
|
-
while (Date.now() < deadline) {
|
|
46
|
-
const status = await request(socketPath, { ...identity, action: 'status' });
|
|
47
|
-
if (status.running === false)
|
|
48
|
-
return;
|
|
49
|
-
await delay(50);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
throw new Error('codex_group_stop_unconfirmed');
|
|
41
|
+
await stopLauncherExecution(socketPath, identity);
|
|
53
42
|
}
|
|
54
43
|
/** The private launcher owns the boundary; development shells never receive its identity. */
|
|
55
44
|
export async function prepareLauncherOrigin(input) {
|
|
@@ -69,6 +58,24 @@ export async function verifyLauncherOrigin(input) {
|
|
|
69
58
|
return false;
|
|
70
59
|
}
|
|
71
60
|
}
|
|
61
|
+
/** Recovered writers need a receipt from the same launcher, not just an absent process id. */
|
|
62
|
+
export async function stopLauncherExecution(socketPath, identity) {
|
|
63
|
+
if (!identity || typeof identity.processId !== 'string' || !identity.processId ||
|
|
64
|
+
typeof identity.instanceId !== 'string' || !identity.instanceId ||
|
|
65
|
+
!Number.isSafeInteger(identity.runtimeEpoch) || identity.runtimeEpoch < 0)
|
|
66
|
+
throw new Error('launcher_execution_identity_invalid');
|
|
67
|
+
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
68
|
+
await request(socketPath, { ...identity, action: 'stop', signal });
|
|
69
|
+
const deadline = Date.now() + 3000;
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
const status = await request(socketPath, { ...identity, action: 'status' });
|
|
72
|
+
if (status.running === false)
|
|
73
|
+
return;
|
|
74
|
+
await delay(50);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error('codex_group_stop_unconfirmed');
|
|
78
|
+
}
|
|
72
79
|
export async function startLauncherProcess(input) {
|
|
73
80
|
const socket = createConnection(input.socketPath);
|
|
74
81
|
const stdout = new PassThrough();
|
|
@@ -167,8 +174,13 @@ export async function startLauncherProcess(input) {
|
|
|
167
174
|
});
|
|
168
175
|
});
|
|
169
176
|
return {
|
|
170
|
-
identity
|
|
177
|
+
identity,
|
|
171
178
|
pid,
|
|
179
|
+
executionIdentity: {
|
|
180
|
+
processId: identity.processId,
|
|
181
|
+
runtimeEpoch: identity.runtimeEpoch,
|
|
182
|
+
instanceId: identity.instanceId,
|
|
183
|
+
},
|
|
172
184
|
stdin,
|
|
173
185
|
stdout,
|
|
174
186
|
stderr,
|
|
@@ -17,6 +17,7 @@ const credentialSchema = z.object({
|
|
|
17
17
|
generation: z.number().int(), runtimeEpoch: z.number().int(),
|
|
18
18
|
}),
|
|
19
19
|
});
|
|
20
|
+
const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
|
|
20
21
|
/** Keeps renewable execution credentials in control memory, outside Codex and project files. */
|
|
21
22
|
export class WorkspaceModelBroker {
|
|
22
23
|
state;
|
|
@@ -213,6 +214,16 @@ export class WorkspaceModelBroker {
|
|
|
213
214
|
await this.references.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
|
|
214
215
|
return;
|
|
215
216
|
}
|
|
217
|
+
const rejectOversizedBody = () => {
|
|
218
|
+
response.writeHead(413, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
219
|
+
response.end(JSON.stringify({ error: { message: 'model_request_too_large',
|
|
220
|
+
type: 'invalid_request_error', code: 'model_request_too_large', maxBytes: MAX_MODEL_REQUEST_BYTES } }));
|
|
221
|
+
};
|
|
222
|
+
const declaredLength = request.headers['content-length'];
|
|
223
|
+
if (declaredLength !== undefined && /^\d+$/.test(declaredLength) && Number(declaredLength) > MAX_MODEL_REQUEST_BYTES) {
|
|
224
|
+
rejectOversizedBody();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
216
227
|
activeRoute = route;
|
|
217
228
|
const active = this.sessionRequests.get(route) ?? new Set();
|
|
218
229
|
active.add(abort);
|
|
@@ -222,9 +233,8 @@ export class WorkspaceModelBroker {
|
|
|
222
233
|
let size = 0;
|
|
223
234
|
for await (const chunk of request) {
|
|
224
235
|
size += chunk.length;
|
|
225
|
-
if (size >
|
|
226
|
-
|
|
227
|
-
response.end();
|
|
236
|
+
if (size > MAX_MODEL_REQUEST_BYTES) {
|
|
237
|
+
rejectOversizedBody();
|
|
228
238
|
return;
|
|
229
239
|
}
|
|
230
240
|
chunks.push(Buffer.from(chunk));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { rejectTool, toolContent } from './tool-response.js';
|
|
2
|
+
export const PROCEDURAL_3D_TOOL = {
|
|
3
|
+
type: 'function',
|
|
4
|
+
name: 'playcraft_procedural_3d',
|
|
5
|
+
description: 'Run the enabled, explicitly selected procedural 3D skill using its locked tools. Read that skill for action inputs. prepare freezes a local reference; adapter actions operate in the returned run directory; capture freezes and renders a candidate; review records observations against that candidate; adopt copies only the reviewed bytes. Calls require an exclusive execution turn. A requires_exclusive_turn result requires selecting the skill in a new turn.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
action: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
enum: [
|
|
12
|
+
'describe',
|
|
13
|
+
'preflight',
|
|
14
|
+
'prepare',
|
|
15
|
+
'status',
|
|
16
|
+
'recover',
|
|
17
|
+
'next',
|
|
18
|
+
'run-step',
|
|
19
|
+
'request-correction',
|
|
20
|
+
'capture',
|
|
21
|
+
'review',
|
|
22
|
+
'adopt',
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
runId: { type: 'string' },
|
|
26
|
+
expectedRevision: { type: 'integer', minimum: 0 },
|
|
27
|
+
input: { type: 'object' },
|
|
28
|
+
},
|
|
29
|
+
required: ['action'],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
/** Only the host may attach a validated workspace-relative diagnostic report. */
|
|
34
|
+
export class ProductionSkillFailure extends Error {
|
|
35
|
+
reportPath;
|
|
36
|
+
constructor(reason, reportPath) {
|
|
37
|
+
super(reason);
|
|
38
|
+
this.reportPath = reportPath;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Native turn identity is supplied by the app-server adapter, never by tool arguments. */
|
|
42
|
+
export class ProductionSkillTools {
|
|
43
|
+
handler;
|
|
44
|
+
active = new Map();
|
|
45
|
+
constructor(handler) {
|
|
46
|
+
this.handler = handler;
|
|
47
|
+
}
|
|
48
|
+
hasActive(threadId, turnId) {
|
|
49
|
+
return [...this.active.values()].some((item) => (!threadId || item.context.nativeThreadId === threadId) &&
|
|
50
|
+
(!turnId || item.context.nativeTurnId === turnId));
|
|
51
|
+
}
|
|
52
|
+
async request(id, payload, client, context) {
|
|
53
|
+
if (!context)
|
|
54
|
+
return rejectTool(client, id, 'procedural_skill_inactive_turn');
|
|
55
|
+
const key = `${context.nativeThreadId}:${context.nativeTurnId}`;
|
|
56
|
+
if (this.active.has(key))
|
|
57
|
+
return rejectTool(client, id, 'procedural_skill_busy');
|
|
58
|
+
const abort = new AbortController();
|
|
59
|
+
let finish;
|
|
60
|
+
const done = new Promise((resolve) => {
|
|
61
|
+
finish = resolve;
|
|
62
|
+
});
|
|
63
|
+
this.active.set(key, { context, abort, done });
|
|
64
|
+
try {
|
|
65
|
+
const result = await this.handler(payload.arguments, context, abort.signal);
|
|
66
|
+
await client.respond(id, { success: true, contentItems: toolContent(result) });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const reason = error instanceof Error && /^[a-z][a-z0-9_:\-]{0,160}$/.test(error.message)
|
|
70
|
+
? error.message
|
|
71
|
+
: 'procedural_skill_failed';
|
|
72
|
+
if (error instanceof ProductionSkillFailure) {
|
|
73
|
+
await client
|
|
74
|
+
.respond(id, {
|
|
75
|
+
success: false,
|
|
76
|
+
contentItems: toolContent({ status: 'failed', reason, reportPath: error.reportPath }),
|
|
77
|
+
})
|
|
78
|
+
.catch(() => undefined);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await rejectTool(client, id, reason);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.active.delete(key);
|
|
85
|
+
finish();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async cancel(threadId, turnId) {
|
|
89
|
+
const pending = [...this.active.values()].filter((item) => (!threadId || item.context.nativeThreadId === threadId) &&
|
|
90
|
+
(!turnId || item.context.nativeTurnId === turnId));
|
|
91
|
+
for (const item of pending)
|
|
92
|
+
item.abort.abort();
|
|
93
|
+
await Promise.allSettled(pending.map((item) => item.done));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
4
|
+
import { launcherSocket } from '../workspaces/developer-command.js';
|
|
5
|
+
import { holdDirectoryAccess } from '../server/directory-access.js';
|
|
6
|
+
import { stopBackgroundWriter } from './managed-writes.js';
|
|
7
|
+
/** Structured process invocation under the caller's exclusive directory owner. */
|
|
8
|
+
export async function runSkillCommand(state, input) {
|
|
9
|
+
if (input.signal.aborted)
|
|
10
|
+
throw new Error('procedural_skill_cancelled');
|
|
11
|
+
const writeId = `skill-${randomUUID()}`;
|
|
12
|
+
const release = await holdDirectoryAccess(state, input.scope, `managed:${writeId}`, 'exclusive');
|
|
13
|
+
const record = {
|
|
14
|
+
writeId,
|
|
15
|
+
workspaceId: state.config.workspaceMode === 'multi'
|
|
16
|
+
? `${input.scope.workspaceId}:${input.scope.directoryName ?? 'basic'}`
|
|
17
|
+
: 'basic',
|
|
18
|
+
source: 'agent',
|
|
19
|
+
directoryAccessMode: 'exclusive',
|
|
20
|
+
startedAt: new Date().toISOString(),
|
|
21
|
+
};
|
|
22
|
+
let child;
|
|
23
|
+
let stopped = true;
|
|
24
|
+
let timeout = false;
|
|
25
|
+
let oversized = false;
|
|
26
|
+
let timer;
|
|
27
|
+
const abort = () => {
|
|
28
|
+
void child?.stop().catch(() => undefined);
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
await state.journal.putManagedWrite(record);
|
|
32
|
+
if (input.signal.aborted)
|
|
33
|
+
throw new Error('procedural_skill_cancelled');
|
|
34
|
+
const socketPath = launcherSocket(state);
|
|
35
|
+
if (socketPath) {
|
|
36
|
+
stopped = false;
|
|
37
|
+
child = await startLauncherProcess({
|
|
38
|
+
socketPath,
|
|
39
|
+
processId: writeId,
|
|
40
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
41
|
+
// Development outputs must remain readable by the workspace control group even when
|
|
42
|
+
// the private launcher starts with umask 077. Keep arguments literal and this mask local.
|
|
43
|
+
argv: ['/usr/bin/bash', '-c', 'umask 007; exec "$@"', 'playcraft-skill', ...input.argv],
|
|
44
|
+
cwd: input.cwd,
|
|
45
|
+
env: { PYTHONDONTWRITEBYTECODE: '1' },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const local = spawn(input.argv[0], input.argv.slice(1), {
|
|
50
|
+
cwd: input.cwd,
|
|
51
|
+
detached: true,
|
|
52
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
53
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' },
|
|
54
|
+
});
|
|
55
|
+
stopped = !local.pid;
|
|
56
|
+
child = {
|
|
57
|
+
pid: local.pid,
|
|
58
|
+
stdin: local.stdin,
|
|
59
|
+
stdout: local.stdout,
|
|
60
|
+
stderr: local.stderr,
|
|
61
|
+
onExit(listener) {
|
|
62
|
+
local.once('error', () => listener(null));
|
|
63
|
+
local.once('close', listener);
|
|
64
|
+
},
|
|
65
|
+
async stop() {
|
|
66
|
+
if (local.pid && !(await stopBackgroundWriter(local.pid)))
|
|
67
|
+
throw new Error('procedural_skill_stop_unconfirmed');
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Attach exit listeners before asynchronous persistence: short commands can finish immediately.
|
|
72
|
+
const exit = new Promise((resolve) => child.onExit(resolve));
|
|
73
|
+
let stdout = '', stderr = '';
|
|
74
|
+
const collect = (stream, chunk) => {
|
|
75
|
+
if (stream === 'stdout')
|
|
76
|
+
stdout += chunk.toString();
|
|
77
|
+
else
|
|
78
|
+
stderr += chunk.toString();
|
|
79
|
+
if (stdout.length > 4 * 1024 * 1024 || stderr.length > 1024 * 1024) {
|
|
80
|
+
oversized = true;
|
|
81
|
+
abort();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
child.stdout?.on('data', (chunk) => collect('stdout', chunk));
|
|
85
|
+
child.stderr?.on('data', (chunk) => collect('stderr', chunk));
|
|
86
|
+
child.stdin?.on('error', () => undefined);
|
|
87
|
+
await state.journal.putManagedWrite({ ...record, pid: child.pid });
|
|
88
|
+
input.signal.addEventListener('abort', abort, { once: true });
|
|
89
|
+
timer = setTimeout(() => {
|
|
90
|
+
timeout = true;
|
|
91
|
+
abort();
|
|
92
|
+
}, input.timeoutMs ?? 120_000);
|
|
93
|
+
if (input.signal.aborted)
|
|
94
|
+
abort();
|
|
95
|
+
else
|
|
96
|
+
child.stdin?.end(JSON.stringify(input.input));
|
|
97
|
+
const code = await exit;
|
|
98
|
+
await child.stop();
|
|
99
|
+
stopped = true;
|
|
100
|
+
if (input.signal.aborted)
|
|
101
|
+
throw new Error('procedural_skill_cancelled');
|
|
102
|
+
if (timeout)
|
|
103
|
+
throw new Error('procedural_skill_timeout');
|
|
104
|
+
if (oversized)
|
|
105
|
+
throw new Error('procedural_skill_output_limit');
|
|
106
|
+
return { stdout, stderr, code };
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
if (timer)
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
input.signal.removeEventListener('abort', abort);
|
|
112
|
+
if (child && !stopped) {
|
|
113
|
+
try {
|
|
114
|
+
await child.stop();
|
|
115
|
+
stopped = true;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* retain the writer claim */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (stopped) {
|
|
122
|
+
await state.journal.putManagedWrite({
|
|
123
|
+
...record,
|
|
124
|
+
pid: child?.pid,
|
|
125
|
+
stoppedAt: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
release();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -34,6 +34,8 @@ import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
|
34
34
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
35
35
|
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
36
|
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
37
|
+
import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
|
|
38
|
+
import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
|
|
37
39
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
38
40
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
39
41
|
const negotiated = negotiateProtocol(config);
|
|
@@ -55,7 +57,11 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
55
57
|
const previousEpoch = journal.storedRuntimeEpoch();
|
|
56
58
|
const previousGroup = journal.executionGroup();
|
|
57
59
|
const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
|
|
58
|
-
await recoverInterruptedInstalls(journal
|
|
60
|
+
await recoverInterruptedInstalls(journal, {
|
|
61
|
+
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch, identityMatches: previousIdentityMatches,
|
|
62
|
+
...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
63
|
+
? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}),
|
|
64
|
+
});
|
|
59
65
|
await recoverInterruptedPreviews(journal);
|
|
60
66
|
await recoverInterruptedParameterVerifications(journal);
|
|
61
67
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
@@ -74,6 +80,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
74
80
|
instanceToken: randomUUID(),
|
|
75
81
|
};
|
|
76
82
|
const recoveredImports = new Map();
|
|
83
|
+
await recoverModelPreviewOperations(state, { identityMatches: previousIdentityMatches,
|
|
84
|
+
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
|
|
85
|
+
...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
86
|
+
? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}) });
|
|
77
87
|
const recoveredChanges = [];
|
|
78
88
|
const recoveredWrites = [];
|
|
79
89
|
const recoverLibraryImports = async (root) => {
|
|
@@ -414,6 +424,7 @@ function defaultNativeAdapter(state) {
|
|
|
414
424
|
workspaceUi: true,
|
|
415
425
|
deliveryTools: deliveryToolHandlers(state),
|
|
416
426
|
scheduledTaskProposals: scheduledTaskProposalHandler(state),
|
|
427
|
+
productionSkill: proceduralSkillHandler(state),
|
|
417
428
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
418
429
|
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
419
430
|
const { workspaceId } = await workspaceContextFromRoot(state, cwd);
|