@playcraft/cli 0.1.60-beta → 0.1.61-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/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/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/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +7 -1
- package/dist/workspace-runtime/server/accept-operation.js +10 -1
- package/dist/workspace-runtime/server/directory-access.js +10 -3
- package/dist/workspace-runtime/server/dispatch.js +39 -1
- package/dist/workspace-runtime/server/http.js +25 -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/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 +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +10 -0
|
@@ -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 = () => {
|
|
@@ -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,
|
|
@@ -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,7 @@ 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';
|
|
37
38
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
38
39
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
39
40
|
const negotiated = negotiateProtocol(config);
|
|
@@ -55,7 +56,11 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
55
56
|
const previousEpoch = journal.storedRuntimeEpoch();
|
|
56
57
|
const previousGroup = journal.executionGroup();
|
|
57
58
|
const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
|
|
58
|
-
await recoverInterruptedInstalls(journal
|
|
59
|
+
await recoverInterruptedInstalls(journal, {
|
|
60
|
+
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch, identityMatches: previousIdentityMatches,
|
|
61
|
+
...(config.workspaceMode === 'multi' && process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
62
|
+
? { launcherSocket: process.env.PLAYCRAFT_LAUNCHER_SOCKET } : {}),
|
|
63
|
+
});
|
|
59
64
|
await recoverInterruptedPreviews(journal);
|
|
60
65
|
await recoverInterruptedParameterVerifications(journal);
|
|
61
66
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
@@ -414,6 +419,7 @@ function defaultNativeAdapter(state) {
|
|
|
414
419
|
workspaceUi: true,
|
|
415
420
|
deliveryTools: deliveryToolHandlers(state),
|
|
416
421
|
scheduledTaskProposals: scheduledTaskProposalHandler(state),
|
|
422
|
+
productionSkill: proceduralSkillHandler(state),
|
|
417
423
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
418
424
|
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
419
425
|
const { workspaceId } = await workspaceContextFromRoot(state, cwd);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, takesWorkspaceDirectory, isTurnControl, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
2
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
3
3
|
import { buildOperationRecord } from './operation-record.js';
|
|
4
|
+
import { preflightSkillExecution } from '../workspaces/skill-execution-context.js';
|
|
4
5
|
/**
|
|
5
6
|
* Shared operation acceptance: lease fence, idempotency, capability, record creation and
|
|
6
7
|
* execution. HTTP supplies its transport/session checks through `validate`; in-sandbox Agent
|
|
@@ -62,7 +63,15 @@ export async function acceptOperationCore(state, parsed, options) {
|
|
|
62
63
|
if (validation)
|
|
63
64
|
return { rejected: true, refusal: validation };
|
|
64
65
|
const executable = canExecuteKind(state, parsed.kind);
|
|
65
|
-
|
|
66
|
+
let policy;
|
|
67
|
+
try {
|
|
68
|
+
policy = await preflightSkillExecution(state, parsed);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return rejected(409, { error: error instanceof Error && /^(?:context_|invalid_composer_context|untrusted_runtime_field)/.test(error.message)
|
|
72
|
+
? error.message : 'context_skill_unavailable' });
|
|
73
|
+
}
|
|
74
|
+
const operation = buildOperationRecord(state, parsed, payloadHash, options.leaseFence, executable, policy);
|
|
66
75
|
if (!executable) {
|
|
67
76
|
const saved = await state.journal.putOperation(scopeKey, operation);
|
|
68
77
|
return rejected(409, runtimeError('capability_unavailable', state.journal.destroyed() ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started', { operationId: saved.operationId }));
|
|
@@ -152,7 +152,7 @@ async function gateFor(state) {
|
|
|
152
152
|
gate.restore({ ...terminal, owner: `terminal:${terminal.terminalId}`, mode: 'shared' });
|
|
153
153
|
for (const writer of state.journal.listManagedWrites())
|
|
154
154
|
if (!writer.stoppedAt) {
|
|
155
|
-
gate.restore({ ...managedDirectoryScope(state, writer.workspaceId), owner: `managed:${writer.writeId}`, mode: 'shared' });
|
|
155
|
+
gate.restore({ ...managedDirectoryScope(state, writer.workspaceId), owner: `managed:${writer.writeId}`, mode: writer.directoryAccessMode ?? 'shared' });
|
|
156
156
|
}
|
|
157
157
|
const waiting = [];
|
|
158
158
|
for (const barrier of state.journal.directoryBarriers()) {
|
|
@@ -237,6 +237,11 @@ export async function withDirectoryOwner(state, scope, owner, action) {
|
|
|
237
237
|
throw new Error('directory_owner_not_held');
|
|
238
238
|
return context.run({ state, claim: { ...scope, owner, mode: 'exclusive' } }, action);
|
|
239
239
|
}
|
|
240
|
+
/** Verify the existing claim without acquiring or upgrading a lease from a tool callback. */
|
|
241
|
+
export async function assertOperationDirectoryAccess(state, operation, mode) {
|
|
242
|
+
if (!(await gateFor(state)).permits(`operation:${operation.operationId}`, operation, mode))
|
|
243
|
+
throw new Error('requires-exclusive-turn');
|
|
244
|
+
}
|
|
240
245
|
export async function withDirectoryWrite(state, scope, action) {
|
|
241
246
|
const inherited = context.getStore();
|
|
242
247
|
const gate = await gateFor(state);
|
|
@@ -314,9 +319,11 @@ export async function withOperationDirectoryAccess(state, operation, action, def
|
|
|
314
319
|
const current = state.journal.getOperationById(operation.operationId);
|
|
315
320
|
if (!current || TERMINAL.has(current.status))
|
|
316
321
|
return;
|
|
317
|
-
|
|
318
|
-
|
|
322
|
+
const cancelled = current.status === 'queued' && error instanceof Error && error.message === 'directory_access_cancelled';
|
|
323
|
+
await state.journal.putOperation(deferred.scopeKey, transitionOperation(current, cancelled ? 'interrupted' : 'uncertain', {
|
|
324
|
+
error: cancelled ? 'cancelled' : 'native_receipt_lost',
|
|
319
325
|
}));
|
|
326
|
+
await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
|
|
320
327
|
}).catch(() => undefined);
|
|
321
328
|
return state.journal.getOperationById(operation.operationId) ?? operation;
|
|
322
329
|
}
|