@playcraft/cli 0.1.59-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/cli-root-help.js +1 -0
- package/dist/commands/references.js +55 -0
- package/dist/index.js +21 -3
- package/dist/project-skills/builtin-import.js +2 -2
- package/dist/references/client.js +69 -0
- package/dist/references/messages.js +57 -0
- package/dist/references/protocol.js +32 -0
- package/dist/skill-registry/messages.js +2 -2
- package/dist/skill-registry/platform-package.js +2 -4
- package/dist/workspace-runtime/batch/control.js +3 -3
- package/dist/workspace-runtime/batch/deliver.js +4 -3
- package/dist/workspace-runtime/batch/generate.js +3 -2
- package/dist/workspace-runtime/batch/install-log.js +49 -0
- package/dist/workspace-runtime/batch/prepare.js +29 -13
- package/dist/workspace-runtime/batch/processes.js +86 -14
- package/dist/workspace-runtime/batch/writer-proof.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +52 -2
- 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 +46 -24
- package/dist/workspace-runtime/codex/model-broker.js +42 -5
- package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +9 -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 +40 -1
- package/dist/workspace-runtime/server/http.js +39 -4
- 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 +5 -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/remix-reference-skill.js +126 -0
- package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
- package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
- package/dist/workspace-runtime/workspaces/skills.js +18 -6
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
- package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template-v2/.playcraft/skills.lock.json +14 -0
- package/project-templates/empty/AGENTS.md +19 -0
- package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
|
@@ -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,184 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { PLATFORM_ROUTES, platformEndpoint, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_REMIX_REFERENCES_MANIFEST, workspaceRemixReferenceCandidateSchema, workspaceRemixReferenceExportSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { withDirectoryWrite } from '../server/directory-access.js';
|
|
6
|
+
import { wakeSourceEventUploader } from '../server/source-uploader.js';
|
|
7
|
+
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { importWorkspaceRemixReference, listWorkspaceRemixReferences } from '../workspaces/remix-references.js';
|
|
9
|
+
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
10
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
11
|
+
import { verifyLauncherOrigin } from './launcher-client.js';
|
|
12
|
+
import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema } from '../../references/protocol.js';
|
|
13
|
+
const ACTIVE = new Set(['dispatched', 'accepted', 'uncertain']);
|
|
14
|
+
const candidatesSchema = z.object({ items: z.array(z.unknown()).max(50),
|
|
15
|
+
total: z.number().int().nonnegative(), page: z.number().int().positive(), pageSize: z.number().int().positive() }).strict();
|
|
16
|
+
const SOURCE_ERRORS = new Set(['remix_reference_unavailable', 'remix_reference_too_large', 'remix_reference_incomplete',
|
|
17
|
+
'remix_reference_unsupported', 'remix_reference_source_changed', 'workspace_access_denied', 'workspace_permission_denied']);
|
|
18
|
+
/** Executes source-reference operations only for the shell and native turn owning this route. */
|
|
19
|
+
export class WorkspaceRemixReferencesBroker {
|
|
20
|
+
state;
|
|
21
|
+
constructor(state) {
|
|
22
|
+
this.state = state;
|
|
23
|
+
}
|
|
24
|
+
operation(context, valid, write = false) {
|
|
25
|
+
const state = this.state;
|
|
26
|
+
const session = state.journal.getSession(context.sessionId);
|
|
27
|
+
const operations = state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
|
|
28
|
+
&& ACTIVE.has(operation.status) && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch);
|
|
29
|
+
const operation = operations.length === 1 ? operations[0] : undefined;
|
|
30
|
+
if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation || operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId
|
|
31
|
+
|| operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch
|
|
32
|
+
|| operation.clientRequestId !== context.clientRequestId || !operation.workspaceId || !operation.directoryName
|
|
33
|
+
|| operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
|
|
34
|
+
|| !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
|
|
35
|
+
|| session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
|
|
36
|
+
|| (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock === 'uncertain'
|
|
37
|
+
|| !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
38
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
39
|
+
if (write) {
|
|
40
|
+
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence)
|
|
41
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
42
|
+
if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only' || session.permissionMode === 'read-only')
|
|
43
|
+
throw new RemixReferenceToolError('remix_reference_readonly');
|
|
44
|
+
if (state.journal.listSessions().some(other => other.sessionId !== context.sessionId
|
|
45
|
+
&& other.workspaceId === operation.workspaceId
|
|
46
|
+
&& ((other.directoryName ?? 'basic') === operation.directoryName || other.directoryName === 'root')
|
|
47
|
+
&& (other.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(other.turnStatus ?? ''))))
|
|
48
|
+
throw new RemixReferenceToolError('turn_already_active');
|
|
49
|
+
}
|
|
50
|
+
return operation;
|
|
51
|
+
}
|
|
52
|
+
async current(context, valid, write = false) {
|
|
53
|
+
const operation = this.operation(context, valid, write);
|
|
54
|
+
const scope = await workspaceContextFromRoot(this.state, context.cwd);
|
|
55
|
+
if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
|
|
56
|
+
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
57
|
+
if (write && await batchDirectoryHeld(this.state, scope.workspaceId, scope.directoryName))
|
|
58
|
+
throw new RemixReferenceToolError('batch_workspace_readonly');
|
|
59
|
+
this.operation(context, valid, write);
|
|
60
|
+
return { ...scope, operation };
|
|
61
|
+
}
|
|
62
|
+
async forward(request, response, context, valid) {
|
|
63
|
+
const reply = (status, value) => {
|
|
64
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
65
|
+
response.end(JSON.stringify(value));
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
if (request.socket.remoteAddress !== '127.0.0.1')
|
|
69
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
70
|
+
let text = '';
|
|
71
|
+
for await (const chunk of request) {
|
|
72
|
+
text += chunk.toString();
|
|
73
|
+
if (Buffer.byteLength(text) > 16 * 1024)
|
|
74
|
+
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
75
|
+
}
|
|
76
|
+
const parsed = remixReferenceToolRequestSchema.safeParse(JSON.parse(text));
|
|
77
|
+
if (!parsed.success)
|
|
78
|
+
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
79
|
+
const input = parsed.data;
|
|
80
|
+
const write = input.action === 'import';
|
|
81
|
+
const scope = await this.current(context, valid, write);
|
|
82
|
+
const callerCwd = await realpath(input.cwd);
|
|
83
|
+
const childPath = relative(context.cwd, callerCwd);
|
|
84
|
+
if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../') || input.workspaceId !== undefined && input.workspaceId !== scope.workspaceId
|
|
85
|
+
|| input.directoryName !== undefined && input.directoryName !== scope.directoryName)
|
|
86
|
+
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
87
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
88
|
+
if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
|
|
89
|
+
|| !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
|
|
90
|
+
serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
|
|
91
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
92
|
+
await this.current(context, valid, write);
|
|
93
|
+
if (input.action === 'list') {
|
|
94
|
+
const items = await this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, () => listWorkspaceRemixReferences(context.cwd, scope.directoryName));
|
|
95
|
+
this.operation(context, valid);
|
|
96
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
97
|
+
items: items.map(reference => localRemixReference(context.cwd, reference)) });
|
|
98
|
+
}
|
|
99
|
+
if (input.action === 'search') {
|
|
100
|
+
const result = candidatesSchema.parse(await this.upstream(scope, { action: 'search', source: input.source,
|
|
101
|
+
...(input.tagCategory === undefined ? {} : { tagCategory: input.tagCategory, tagValues: input.tagValues }),
|
|
102
|
+
...(input.q === undefined ? {} : { q: input.q }), page: input.page, limit: input.limit }, 2 * 1024 * 1024));
|
|
103
|
+
await this.current(context, valid);
|
|
104
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...result,
|
|
105
|
+
items: result.items.map(item => workspaceRemixReferenceCandidateSchema.parse(item)) });
|
|
106
|
+
}
|
|
107
|
+
const result = await withDirectoryWrite(this.state, { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
108
|
+
sessionId: context.sessionId, wait: false }, async () => {
|
|
109
|
+
await this.current(context, valid, true);
|
|
110
|
+
const raw = await this.upstream(scope, { action: 'export', projectUuid: input.projectUuid, branchName: input.branchName }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
|
|
111
|
+
await this.current(context, valid, true);
|
|
112
|
+
const snapshot = workspaceRemixReferenceExportSchema.parse(raw);
|
|
113
|
+
if (snapshot.source.environment !== this.state.config.environment || snapshot.source.projectUuid !== input.projectUuid
|
|
114
|
+
|| snapshot.source.branchName !== input.branchName)
|
|
115
|
+
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
116
|
+
return this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, async () => {
|
|
117
|
+
await this.current(context, valid, true);
|
|
118
|
+
await shareWorkspaceGit(this.state, context.cwd);
|
|
119
|
+
await this.current(context, valid, true);
|
|
120
|
+
const reference = await importWorkspaceRemixReference(context.cwd, scope.directoryName, snapshot, () => {
|
|
121
|
+
this.operation(context, valid, true);
|
|
122
|
+
});
|
|
123
|
+
this.operation(context, valid, true);
|
|
124
|
+
await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
|
|
125
|
+
generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
126
|
+
type: 'workspace.references.imported', occurredAt: new Date().toISOString(),
|
|
127
|
+
sessionId: context.sessionId, workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
|
|
128
|
+
payload: { sessionId: context.sessionId, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
129
|
+
referenceId: reference.referenceId, path: reference.path, contentHash: reference.contentHash } });
|
|
130
|
+
await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
|
|
131
|
+
generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
132
|
+
type: 'files.changed', occurredAt: new Date().toISOString(), sessionId: context.sessionId,
|
|
133
|
+
workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
|
|
134
|
+
payload: { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
135
|
+
paths: [reference.path, WORKSPACE_REMIX_REFERENCES_MANIFEST], source: 'workspace.references.import',
|
|
136
|
+
operationId: scope.operation.operationId } });
|
|
137
|
+
wakeSourceEventUploader(this.state);
|
|
138
|
+
return reference;
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
142
|
+
reference: localRemixReference(context.cwd, result) });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (response.headersSent) {
|
|
146
|
+
response.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const code = error instanceof SyntaxError ? 'invalid_remix_reference_tool_request' : error instanceof RemixReferenceToolError ? error.code
|
|
150
|
+
: error instanceof Error && /^(?:remix_reference_[a-z_]+|directory_busy)$/.test(error.message)
|
|
151
|
+
? error.message : 'remix_reference_tool_failed';
|
|
152
|
+
reply(code === 'invalid_remix_reference_tool_request' ? 400
|
|
153
|
+
: ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code) ? 409 : 403, { ok: false, error: { code } });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async upstream(scope, input, limit) {
|
|
157
|
+
const config = this.state.config;
|
|
158
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.remixReferenceTools), {
|
|
159
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120_000),
|
|
160
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
161
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
162
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...input }),
|
|
163
|
+
});
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let bytes = 0;
|
|
166
|
+
if (!response.body)
|
|
167
|
+
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
168
|
+
for await (const chunk of response.body) {
|
|
169
|
+
bytes += chunk.byteLength;
|
|
170
|
+
if (bytes > (response.ok ? limit : 8192)) {
|
|
171
|
+
await response.body.cancel().catch(() => undefined);
|
|
172
|
+
throw new RemixReferenceToolError('remix_reference_too_large');
|
|
173
|
+
}
|
|
174
|
+
chunks.push(Buffer.from(chunk));
|
|
175
|
+
}
|
|
176
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const code = [value?.reason, value?.code, value?.message, value?.error?.reason, value?.error?.code]
|
|
179
|
+
.find(code => typeof code === 'string' && SOURCE_ERRORS.has(code));
|
|
180
|
+
throw new RemixReferenceToolError(code ?? 'remix_reference_unavailable');
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { scheduledTaskProposalDraftSchema, scheduledTaskProposalDraftJsonSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { rejectTool, toolContent } from './tool-response.js';
|
|
4
|
+
export const WORKSPACE_SCHEDULED_TASK_TOOLS = [
|
|
5
|
+
{ type: 'function', name: 'playcraft_get_scheduled_task_context',
|
|
6
|
+
description: 'Read server time, scheduling defaults, and tasks actually related to this conversation. The returned timeZone describes the server clock, not the user’s local timezone; clarify the intended timezone when it is unknown. When several tasks match, ask the user which task to change. This tool never changes a task.',
|
|
7
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
8
|
+
{ type: 'function', name: 'playcraft_propose_scheduled_task',
|
|
9
|
+
description: 'Prepare a scheduled-task creation or change for the user to review in a persistent confirmation card. Read scheduling context first. This does not create or change a task: only the user can confirm the card. A pending_confirmation result must never be reported as an applied task.',
|
|
10
|
+
inputSchema: { type: 'object', properties: {
|
|
11
|
+
draft: scheduledTaskProposalDraftJsonSchema, supersedesProposalId: { type: 'string', format: 'uuid' },
|
|
12
|
+
}, required: ['draft'], additionalProperties: false } },
|
|
13
|
+
];
|
|
14
|
+
export const SCHEDULED_TASK_TOOL_ERROR_CODES = new Set([
|
|
15
|
+
'unavailable', 'source_unavailable', 'source_pending', 'invalid_input', 'request_conflict', 'request_pending',
|
|
16
|
+
'feature_disabled', 'limit', 'version_conflict', 'schedule_expired', 'invalid_time_zone', 'interval_too_short',
|
|
17
|
+
'target_read_only', 'target_unavailable', 'target_access_denied', 'target_binding_changed', 'session_unavailable',
|
|
18
|
+
'account_unavailable', 'model_unavailable', 'reasoning_effort_unavailable', 'revoked',
|
|
19
|
+
].map(code => `scheduled_task_proposal_${code}`));
|
|
20
|
+
const prepareArguments = z.object({ draft: z.unknown(), supersedesProposalId: z.string().uuid().optional() }).strict();
|
|
21
|
+
const sourceKey = (source) => JSON.stringify([source.threadId, source.turnId, source.toolCallId]);
|
|
22
|
+
/** Only a live root-native call can attest the source of a prepare-only bridge request. */
|
|
23
|
+
export class WorkspaceScheduledTaskTools {
|
|
24
|
+
handler;
|
|
25
|
+
changed;
|
|
26
|
+
active = new Map();
|
|
27
|
+
settled = new Map();
|
|
28
|
+
constructor(handler, changed) {
|
|
29
|
+
this.handler = handler;
|
|
30
|
+
this.changed = changed;
|
|
31
|
+
}
|
|
32
|
+
isActive(source) {
|
|
33
|
+
const call = this.active.get(sourceKey(source));
|
|
34
|
+
return Boolean(call && call.source.bridge === source.bridge && call.current());
|
|
35
|
+
}
|
|
36
|
+
async request(requestId, payload, client, context) {
|
|
37
|
+
if (!context?.bridge || !context.current() || typeof payload.callId !== 'string' || !payload.callId.length
|
|
38
|
+
|| payload.namespace)
|
|
39
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_source_unavailable');
|
|
40
|
+
const action = payload.tool === 'playcraft_get_scheduled_task_context' ? 'context'
|
|
41
|
+
: payload.tool === 'playcraft_propose_scheduled_task' ? 'prepare' : undefined;
|
|
42
|
+
const parsed = action === 'context' ? z.object({}).strict().safeParse(payload.arguments)
|
|
43
|
+
: prepareArguments.safeParse(payload.arguments);
|
|
44
|
+
if (!action || !parsed.success || (action === 'prepare'
|
|
45
|
+
&& !scheduledTaskProposalDraftSchema.safeParse(parsed.data.draft).success))
|
|
46
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_invalid_input');
|
|
47
|
+
const source = { bridge: context.bridge, threadId: context.threadId, turnId: context.turnId, toolCallId: payload.callId };
|
|
48
|
+
const key = sourceKey(source), fingerprint = JSON.stringify([action, parsed.data]);
|
|
49
|
+
const prior = this.settled.get(key);
|
|
50
|
+
if (prior) {
|
|
51
|
+
if (prior.fingerprint !== fingerprint)
|
|
52
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_request_conflict');
|
|
53
|
+
await client.respond(requestId, { success: true, contentItems: toolContent(prior.result) });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (this.active.has(key))
|
|
57
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_request_pending');
|
|
58
|
+
this.active.set(key, { source, current: context.current });
|
|
59
|
+
try {
|
|
60
|
+
const result = await this.handler(action, source, parsed.data);
|
|
61
|
+
this.settled.set(key, { fingerprint, result });
|
|
62
|
+
if (this.settled.size > 256)
|
|
63
|
+
this.settled.delete(this.settled.keys().next().value);
|
|
64
|
+
if (action === 'prepare' && result && typeof result === 'object'
|
|
65
|
+
&& 'proposalId' in result && typeof result.proposalId === 'string')
|
|
66
|
+
this.changed(result.proposalId, source);
|
|
67
|
+
await client.respond(requestId, { success: true, contentItems: toolContent(result) });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
await rejectTool(client, requestId, error instanceof Error && SCHEDULED_TASK_TOOL_ERROR_CODES.has(error.message)
|
|
71
|
+
? error.message : 'scheduled_task_proposal_unavailable');
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
this.active.delete(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function isWorkspaceScheduledTaskTool(value) {
|
|
79
|
+
return WORKSPACE_SCHEDULED_TASK_TOOLS.some(tool => tool.name === value);
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -33,6 +33,8 @@ import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
|
33
33
|
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
|
+
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
37
|
+
import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
|
|
36
38
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
37
39
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
38
40
|
const negotiated = negotiateProtocol(config);
|
|
@@ -54,7 +56,11 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
54
56
|
const previousEpoch = journal.storedRuntimeEpoch();
|
|
55
57
|
const previousGroup = journal.executionGroup();
|
|
56
58
|
const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
|
|
57
|
-
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
|
+
});
|
|
58
64
|
await recoverInterruptedPreviews(journal);
|
|
59
65
|
await recoverInterruptedParameterVerifications(journal);
|
|
60
66
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
@@ -412,6 +418,8 @@ function defaultNativeAdapter(state) {
|
|
|
412
418
|
config,
|
|
413
419
|
workspaceUi: true,
|
|
414
420
|
deliveryTools: deliveryToolHandlers(state),
|
|
421
|
+
scheduledTaskProposals: scheduledTaskProposalHandler(state),
|
|
422
|
+
productionSkill: proceduralSkillHandler(state),
|
|
415
423
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
416
424
|
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
417
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
|
}
|