@playcraft/cli 0.1.70 → 0.1.72
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/README.md +20 -1
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +3 -0
- package/dist/commands/skills.js +68 -4
- package/dist/commands/tools-batch.js +2 -0
- package/dist/project-skills/discovery.js +248 -0
- package/dist/project-skills/messages.js +80 -0
- package/dist/project-skills/remote-activation.js +500 -0
- package/dist/project-skills/remote-client.js +117 -0
- package/dist/project-skills/remote-commands.js +87 -0
- package/dist/project-skills/remote-prepare.js +223 -0
- package/dist/project-skills/search-command.js +47 -0
- package/dist/project-skills/search.js +96 -0
- package/dist/recording/project-digest.js +1 -1
- package/dist/references/messages.js +2 -0
- package/dist/references/protocol.js +39 -12
- package/dist/workspace-runtime/batch/freeze.js +94 -24
- package/dist/workspace-runtime/batch/records.js +12 -6
- package/dist/workspace-runtime/batch/routes.js +31 -11
- package/dist/workspace-runtime/batch/source-candidate.js +29 -16
- package/dist/workspace-runtime/batch/source-capture.js +251 -20
- package/dist/workspace-runtime/codex/app-server.js +30 -1
- package/dist/workspace-runtime/codex/model-broker.js +93 -6
- package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/directory-access.js +10 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -28
- package/dist/workspace-runtime/server/http.js +345 -117
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
- package/dist/workspace-runtime/workspaces/files.js +59 -23
- package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
- package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
- package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
- package/dist/workspace-runtime/workspaces/skills.js +13 -176
- package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
- package/package.json +4 -3
- package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-templates/empty/gitignore +1 -0
- package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { remoteSkillMatchRequestSchema, remoteSkillMatchResultSchema, remoteSkillSelectionRequestSchema, remoteSkillArtifactRequestSchema, remoteSkillActivationRequestSchema, parseRemoteSkillSelectionResult, } from '@playcraft/common/project-skills';
|
|
6
|
+
import { platformEndpoint, skillActivationRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
9
|
+
import { verifyLauncherOrigin } from './launcher-client.js';
|
|
10
|
+
import { registerSkillActivationRequest } from '../workspaces/skill-activation-requests.js';
|
|
11
|
+
const requestSchema = z.object({ cwd: z.string().min(1).max(4096), input: z.unknown() }).strict();
|
|
12
|
+
const MAX_METADATA_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
const SELECTION_TTL_MS = 15 * 60_000;
|
|
14
|
+
/** Resolves candidates with control identity; shell capability never authorizes changing the active lock. */
|
|
15
|
+
export class WorkspaceSkillDiscoveryBroker {
|
|
16
|
+
state;
|
|
17
|
+
selections = new Map();
|
|
18
|
+
constructor(state) {
|
|
19
|
+
this.state = state;
|
|
20
|
+
}
|
|
21
|
+
operation(context, valid) {
|
|
22
|
+
const state = this.state, session = state.journal.getSession(context.sessionId);
|
|
23
|
+
const operations = state.journal.listOperations().map(item => item.operation).filter(operation => operation.kind === 'session.turn' && operation.sessionId === context.sessionId && operation.clientRequestId === context.clientRequestId
|
|
24
|
+
&& operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch
|
|
25
|
+
&& ['dispatched', 'accepted'].includes(operation.status));
|
|
26
|
+
const operation = operations.length === 1 ? operations[0] : undefined;
|
|
27
|
+
if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation?.workspaceId || !operation.directoryName
|
|
28
|
+
|| operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
|
|
29
|
+
|| !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
|
|
30
|
+
|| session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
|
|
31
|
+
|| (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock
|
|
32
|
+
|| !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
33
|
+
throw new Error('skill_discovery_turn_unavailable');
|
|
34
|
+
return operation;
|
|
35
|
+
}
|
|
36
|
+
async current(context, valid) {
|
|
37
|
+
const operation = this.operation(context, valid);
|
|
38
|
+
const scope = await workspaceContextFromRoot(this.state, context.cwd);
|
|
39
|
+
if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
|
|
40
|
+
throw new Error('skill_discovery_scope_mismatch');
|
|
41
|
+
this.operation(context, valid);
|
|
42
|
+
return scope;
|
|
43
|
+
}
|
|
44
|
+
selectionKey(context, selectionId) {
|
|
45
|
+
return JSON.stringify([this.state.config.runtimeId, this.state.config.generation, this.state.runtimeEpoch,
|
|
46
|
+
context.cwd, context.sessionId, context.clientRequestId, selectionId]);
|
|
47
|
+
}
|
|
48
|
+
async forward(request, response, context, valid, action) {
|
|
49
|
+
const reply = (status, value) => {
|
|
50
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
51
|
+
response.end(JSON.stringify(value));
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
if (request.socket.remoteAddress !== '127.0.0.1')
|
|
55
|
+
throw new Error('skill_discovery_turn_unavailable');
|
|
56
|
+
let text = '';
|
|
57
|
+
for await (const chunk of request) {
|
|
58
|
+
text += chunk.toString();
|
|
59
|
+
if (Buffer.byteLength(text) > 32 * 1024)
|
|
60
|
+
throw new Error('skill_discovery_invalid_request');
|
|
61
|
+
}
|
|
62
|
+
const parsed = requestSchema.safeParse(JSON.parse(text));
|
|
63
|
+
if (!parsed.success)
|
|
64
|
+
throw new Error('skill_discovery_invalid_request');
|
|
65
|
+
const scope = await this.current(context, valid);
|
|
66
|
+
const callerCwd = await realpath(parsed.data.cwd);
|
|
67
|
+
const childPath = relative(context.cwd, callerCwd);
|
|
68
|
+
if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../'))
|
|
69
|
+
throw new Error('skill_discovery_scope_mismatch');
|
|
70
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
71
|
+
if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
|
|
72
|
+
|| !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
|
|
73
|
+
serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
|
|
74
|
+
throw new Error('skill_discovery_turn_unavailable');
|
|
75
|
+
await this.current(context, valid);
|
|
76
|
+
if (action === 'activate') {
|
|
77
|
+
const input = remoteSkillActivationRequestSchema.safeParse(parsed.data.input);
|
|
78
|
+
if (!input.success)
|
|
79
|
+
throw new Error('skill_discovery_invalid_request');
|
|
80
|
+
const operation = this.operation(context, valid);
|
|
81
|
+
if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only'
|
|
82
|
+
|| this.state.journal.getSession(context.sessionId)?.permissionMode === 'read-only')
|
|
83
|
+
throw new Error('skill_discovery_readonly');
|
|
84
|
+
const selected = this.selections.get(this.selectionKey(context, input.data.selectionId));
|
|
85
|
+
if (!selected || selected.expiresAt <= Date.now())
|
|
86
|
+
throw new Error('skill_discovery_selection_unavailable');
|
|
87
|
+
const result = await registerSkillActivationRequest(this.state, {
|
|
88
|
+
cwd: context.cwd, sessionId: context.sessionId, clientRequestId: context.clientRequestId, requestedSkillRefs: selected.requestedSkillRefs,
|
|
89
|
+
}, selected.selection);
|
|
90
|
+
await this.current(context, valid);
|
|
91
|
+
return reply(200, skillActivationRegistrationSchema.parse(result));
|
|
92
|
+
}
|
|
93
|
+
if (action === 'artifact') {
|
|
94
|
+
const input = remoteSkillArtifactRequestSchema.safeParse(parsed.data.input);
|
|
95
|
+
if (!input.success)
|
|
96
|
+
throw new Error('skill_discovery_invalid_request');
|
|
97
|
+
const selection = this.selections.get(this.selectionKey(context, input.data.selectionId));
|
|
98
|
+
const entry = selection && selection.expiresAt > Date.now() ? selection.entries.find(item => item.entryKey === input.data.entryKey) : undefined;
|
|
99
|
+
if (!entry)
|
|
100
|
+
throw new Error('skill_discovery_selection_unavailable');
|
|
101
|
+
const archive = await loadWorkspaceSkillArtifact(this.state, scope.workspaceId, entry, scope.directoryName);
|
|
102
|
+
await this.current(context, valid);
|
|
103
|
+
selection.expiresAt = Date.now() + SELECTION_TTL_MS;
|
|
104
|
+
return reply(200, { content: archive.toString('base64'), artifactHash: createHash('sha256').update(archive).digest('hex') });
|
|
105
|
+
}
|
|
106
|
+
const query = action === 'match' ? remoteSkillMatchRequestSchema.safeParse(parsed.data.input)
|
|
107
|
+
: remoteSkillSelectionRequestSchema.safeParse(parsed.data.input);
|
|
108
|
+
if (!query.success)
|
|
109
|
+
throw new Error('skill_discovery_invalid_request');
|
|
110
|
+
const raw = await this.upstream(scope, action, query.data);
|
|
111
|
+
await this.current(context, valid);
|
|
112
|
+
if (action === 'match')
|
|
113
|
+
return reply(200, remoteSkillMatchResultSchema.parse(raw));
|
|
114
|
+
const value = parseRemoteSkillSelectionResult(raw);
|
|
115
|
+
const selection = remoteSkillSelectionRequestSchema.parse(query.data);
|
|
116
|
+
for (const requested of selection.skills) {
|
|
117
|
+
if (!value.lock.entries.some(entry => entry.revision.kind === 'release' && entry.revision.skillRef === requested.skillRef
|
|
118
|
+
&& entry.revision.version === requested.version && entry.revision.artifactHash === requested.artifactHash)
|
|
119
|
+
|| !value.skills.some(item => item.skillVersionId === requested.skillVersionId && item.skillRef === requested.skillRef
|
|
120
|
+
&& item.version === requested.version && item.artifactHash === requested.artifactHash))
|
|
121
|
+
throw new Error('skill_discovery_invalid_response');
|
|
122
|
+
}
|
|
123
|
+
for (const [key, cached] of this.selections)
|
|
124
|
+
if (cached.expiresAt <= Date.now())
|
|
125
|
+
this.selections.delete(key);
|
|
126
|
+
if (this.selections.size >= 64)
|
|
127
|
+
this.selections.delete(this.selections.keys().next().value);
|
|
128
|
+
this.selections.set(this.selectionKey(context, value.selectionId), { expiresAt: Date.now() + SELECTION_TTL_MS, entries: value.lock.entries,
|
|
129
|
+
selection: value, requestedSkillRefs: selection.skills.map(skill => skill.skillRef) });
|
|
130
|
+
return reply(200, value);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (response.headersSent) {
|
|
134
|
+
response.destroy();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const code = error instanceof SyntaxError ? 'skill_discovery_invalid_request'
|
|
138
|
+
: error instanceof Error && /^(?:skill_discovery_|workspace_skills_)[a-z_]+$/.test(error.message)
|
|
139
|
+
? error.message : 'skill_discovery_unavailable';
|
|
140
|
+
reply(code === 'skill_discovery_invalid_request' ? 400 : code === 'skill_discovery_unavailable' ? 503 : 403, { error: { code } });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async upstream(scope, action, input) {
|
|
144
|
+
const config = this.state.config;
|
|
145
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, `/runtime/internal/skill-discovery/${action}`), {
|
|
146
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(45_000),
|
|
147
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
148
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
149
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName, input }),
|
|
150
|
+
});
|
|
151
|
+
if (!response.ok || !response.body) {
|
|
152
|
+
await response.body?.cancel().catch(() => undefined);
|
|
153
|
+
throw new Error('skill_discovery_unavailable');
|
|
154
|
+
}
|
|
155
|
+
const chunks = [];
|
|
156
|
+
let bytes = 0;
|
|
157
|
+
const reader = response.body.getReader();
|
|
158
|
+
try {
|
|
159
|
+
while (true) {
|
|
160
|
+
const chunk = await reader.read();
|
|
161
|
+
if (chunk.done)
|
|
162
|
+
break;
|
|
163
|
+
bytes += chunk.value.byteLength;
|
|
164
|
+
if (bytes > MAX_METADATA_BYTES)
|
|
165
|
+
throw new Error('skill_discovery_invalid_response');
|
|
166
|
+
chunks.push(Buffer.from(chunk.value));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await reader.cancel().catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -37,6 +37,8 @@ import { platformExportTarget, readPlatformExport, startPlatformExport } from '.
|
|
|
37
37
|
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
38
38
|
import { executionContextToolHandler } from './workspaces/execution-context.js';
|
|
39
39
|
import { registerLevelEditorRequest, startLevelEditorRequestQueue, stopLevelEditorRequestQueue } from './workspaces/level-editor-requests.js';
|
|
40
|
+
import { startSkillActivationRequestQueue, stopSkillActivationRequestQueue } from './workspaces/skill-activation-requests.js';
|
|
41
|
+
import { recoverInterruptedSkillActivations } from './workspaces/skill-activation-recovery.js';
|
|
40
42
|
import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
|
|
41
43
|
import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
|
|
42
44
|
import { recoverModelPreparationOperations, stopModelPreparations } from './workspaces/model-preparation.js';
|
|
@@ -83,6 +85,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
83
85
|
executionQueue: Promise.resolve(),
|
|
84
86
|
instanceToken: randomUUID(),
|
|
85
87
|
};
|
|
88
|
+
await recoverInterruptedSkillActivations(state);
|
|
86
89
|
const recoveredImports = new Map();
|
|
87
90
|
await recoverModelPreviewOperations(state, { identityMatches: previousIdentityMatches,
|
|
88
91
|
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
|
|
@@ -348,12 +351,14 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
348
351
|
});
|
|
349
352
|
const port = await listenOn(server, config.listen.host, config.listen.port);
|
|
350
353
|
startLevelEditorRequestQueue(state);
|
|
354
|
+
startSkillActivationRequestQueue(state);
|
|
351
355
|
return {
|
|
352
356
|
config: state.config,
|
|
353
357
|
state,
|
|
354
358
|
url: `http://${formatHost(config.listen.host)}:${port}`,
|
|
355
359
|
async close() {
|
|
356
360
|
stopLevelEditorRequestQueue(state);
|
|
361
|
+
await stopSkillActivationRequestQueue(state);
|
|
357
362
|
const serverClosed = closeServer(server);
|
|
358
363
|
await stopParameterVerifications(state);
|
|
359
364
|
await stopModelPreparations(state);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
4
5
|
import { listMembers, readSnapshot } from '../batch/records.js';
|
|
5
6
|
const context = new AsyncLocalStorage();
|
|
6
7
|
const gates = new WeakMap();
|
|
@@ -481,6 +482,15 @@ export function releaseBatchDirectory(state, member) {
|
|
|
481
482
|
if (!member.holding && member.writersStopped)
|
|
482
483
|
instances.get(state)?.release(`run:${member.runId}`);
|
|
483
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* A lease rejection names its cause so callers can tell a running batch member apart from ordinary
|
|
487
|
+
* directory contention: the former is a production lock, not a failure. A damaged member record
|
|
488
|
+
* must not replace the rejection with a failed response.
|
|
489
|
+
*/
|
|
490
|
+
export async function directoryBusyCode(state, workspaceId, directoryName = 'basic') {
|
|
491
|
+
const held = await batchDirectoryHeld(state, workspaceId, directoryName).catch(() => false);
|
|
492
|
+
return held ? 'batch_workspace_readonly' : 'directory_busy';
|
|
493
|
+
}
|
|
484
494
|
export async function withDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
485
495
|
const pending = freezing.get(state) ??
|
|
486
496
|
new Map();
|
|
@@ -9,7 +9,7 @@ import { remoteAttachPending } from '../workspaces/remote-attach-marker.js';
|
|
|
9
9
|
import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess, } from './directory-access.js';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, hashOperationPayload, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, levelEditorTaskRequestSchema, levelEditorVerifyPayloadSchema, levelEditorTransactionRequestSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, isSoundDocumentPath, } from '@playcraft/common/cloud-workspace/runtime';
|
|
12
|
+
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, hashOperationPayload, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, levelEditorTaskRequestSchema, skillActivationPayloadSchema, levelEditorVerifyPayloadSchema, levelEditorTransactionRequestSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, workspaceModelPreviewBuildSchema, workspaceModelPreviewCancelSchema, isSoundDocumentPath, } from '@playcraft/common/cloud-workspace/runtime';
|
|
13
13
|
import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
14
14
|
import { artifactFormat } from '../workspaces/artifact-files.js';
|
|
15
15
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
@@ -52,6 +52,7 @@ import { prepareLevelEditorTask, finishLevelEditorTask, verifyLevelEditorTask, }
|
|
|
52
52
|
import { readWorkspaceLevelEditor, transactWorkspaceLevelEditor, reconcileWorkspaceLevelEditorTransaction, } from '../workspaces/level-editor.js';
|
|
53
53
|
import { readLevelEditorHistory } from '../workspaces/level-editor-history-store.js';
|
|
54
54
|
import { wakeLevelEditorRequests, ensureLevelEditorTaskPipeline, retryLevelEditorRequest, assertLevelEditorRetryInput, } from '../workspaces/level-editor-requests.js';
|
|
55
|
+
import { activateSkillSelectionForTurn, cancelSkillActivationPreparation, skillActivationRuntimeClosing, waitForSkillActivationIdle } from '../workspaces/skill-activation-requests.js';
|
|
55
56
|
const CONVERSATION_KINDS = new Set([
|
|
56
57
|
'session.create',
|
|
57
58
|
'session.turn',
|
|
@@ -94,6 +95,7 @@ const LOCAL_KINDS = new Set([
|
|
|
94
95
|
]);
|
|
95
96
|
const parameterVerifications = new WeakMap();
|
|
96
97
|
const parameterVerificationClosing = new WeakSet();
|
|
98
|
+
const nativePreparations = new WeakMap();
|
|
97
99
|
export async function stopParameterVerifications(state) {
|
|
98
100
|
parameterVerificationClosing.add(state);
|
|
99
101
|
const tasks = parameterVerifications.get(state);
|
|
@@ -146,9 +148,30 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
|
|
|
146
148
|
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'model.preview', 'model.preview.cancel', 'model.prepare'].includes(operation.kind)) {
|
|
147
149
|
return run();
|
|
148
150
|
}
|
|
149
|
-
|
|
151
|
+
const execute = () => state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
|
|
150
152
|
? `session-execution:${operation.sessionId}`
|
|
151
153
|
: `workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
|
|
154
|
+
if (!['session.turn', 'session.review', 'session.compact'].includes(operation.kind))
|
|
155
|
+
return execute();
|
|
156
|
+
const pending = nativePreparations.get(state) ?? new Map();
|
|
157
|
+
nativePreparations.set(state, pending);
|
|
158
|
+
const entry = { scopeKey, operation, held: false, activation: operation.kind === 'session.turn'
|
|
159
|
+
&& skillActivationPayloadSchema.safeParse(asRecord(payload).skillActivation).success };
|
|
160
|
+
pending.set(operation.operationId, entry);
|
|
161
|
+
try {
|
|
162
|
+
return await state.journal.exclusive('native-preparation', async () => {
|
|
163
|
+
entry.held = true;
|
|
164
|
+
const live = state.journal.getOperation(scopeKey) ?? operation;
|
|
165
|
+
if (['completed', 'failed', 'interrupted'].includes(live.status))
|
|
166
|
+
return live;
|
|
167
|
+
if (skillActivationRuntimeClosing(state))
|
|
168
|
+
return persist(state, scopeKey, transitionOperation(live, 'interrupted', { error: 'runtime_stopping' }));
|
|
169
|
+
return execute();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
pending.delete(operation.operationId);
|
|
174
|
+
}
|
|
152
175
|
}
|
|
153
176
|
async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
|
|
154
177
|
let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
|
|
@@ -440,16 +463,39 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
440
463
|
const levelEditorTask = levelEditorTaskRequestSchema.optional().safeParse(body.levelEditorTask);
|
|
441
464
|
if (!levelEditorTask.success || levelEditorTask.data && (parameterTask.data || levelEditorTask.data.taskId !== operation.clientRequestId))
|
|
442
465
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_level_editor_task' }));
|
|
443
|
-
|
|
466
|
+
const skillActivation = skillActivationPayloadSchema.optional().safeParse(body.skillActivation);
|
|
467
|
+
if (!skillActivation.success || skillActivation.data && (parameterTask.data || levelEditorTask.data
|
|
468
|
+
|| skillActivation.data.requestId !== operation.clientRequestId || skillActivation.data.sourceSessionId !== sessionId))
|
|
469
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'skill_activation_request_invalid' }));
|
|
470
|
+
if (parameterTask.data || levelEditorTask.data || skillActivation.data) {
|
|
444
471
|
if (operation.directoryName === 'root')
|
|
445
472
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_directory' }));
|
|
446
473
|
if ((selection.data.workMode ?? 'execute') !== 'execute' || requestedMode === 'read-only')
|
|
447
|
-
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: levelEditorTask.data ? 'level_editor_task_requires_execute' : 'parameter_task_readonly' }));
|
|
474
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: skillActivation.data ? 'skill_activation_requires_execute' : levelEditorTask.data ? 'level_editor_task_requires_execute' : 'parameter_task_readonly' }));
|
|
448
475
|
if (workspaceSessions(state, operation.workspaceId, operation.directoryName).some(sessionTurnBusy))
|
|
449
476
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
450
477
|
if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
|
|
451
478
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'batch_workspace_readonly' }));
|
|
452
479
|
}
|
|
480
|
+
let activatedSkillSelection;
|
|
481
|
+
if (skillActivation.data) {
|
|
482
|
+
try {
|
|
483
|
+
if (!found || operation.directoryAccessMode !== 'exclusive' || selection.data.context?.skills.length)
|
|
484
|
+
throw new Error('skill_activation_request_invalid');
|
|
485
|
+
// Repair an interrupted activation pair before session recovery can read or freeze that pair.
|
|
486
|
+
await waitForSkillActivationIdle(state, operation, skillActivation.data);
|
|
487
|
+
activatedSkillSelection = await activateSkillSelectionForTurn(state, operation, skillActivation.data, await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName));
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
const live = state.journal.getOperation(scopeKey);
|
|
491
|
+
if (live && ['failed', 'interrupted'].includes(live.status))
|
|
492
|
+
return live;
|
|
493
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', {
|
|
494
|
+
error: error instanceof Error && /^(?:skill_activation_|skills_revision_conflict|workspace_skills_)/.test(error.message)
|
|
495
|
+
? error.message : 'skill_activation_failed',
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
453
499
|
const modelChanged = selected.data !== undefined && selected.data !== (found?.modelConfigId ?? recovery?.modelConfigId);
|
|
454
500
|
const requestedEffort = effort.data ?? (modelChanged ? undefined : recovery?.reasoningEffort ?? found?.reasoningEffort);
|
|
455
501
|
if (recovery && found?.recoverability !== 'native') {
|
|
@@ -476,11 +522,11 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
476
522
|
}
|
|
477
523
|
const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
478
524
|
try {
|
|
479
|
-
const policy = await preflightSkillExecution(state, { ...operation, payload });
|
|
525
|
+
const policy = skillActivation.data ? undefined : await preflightSkillExecution(state, { ...operation, payload });
|
|
480
526
|
if (policy?.directoryAccessMode === 'exclusive' && operation.directoryAccessMode !== 'exclusive')
|
|
481
527
|
throw new ComposerContextError('requires-exclusive-turn');
|
|
482
|
-
if (operation.skillExecutionPolicyVersion === 1 && (!policy ||
|
|
483
|
-
(parameterTask.data || levelEditorTask.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
|
|
528
|
+
if (!skillActivation.data && operation.skillExecutionPolicyVersion === 1 && (!policy ||
|
|
529
|
+
(parameterTask.data || levelEditorTask.data || skillActivation.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
|
|
484
530
|
throw new ComposerContextError('context_skills_changed');
|
|
485
531
|
}
|
|
486
532
|
catch (error) {
|
|
@@ -490,9 +536,14 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
490
536
|
}));
|
|
491
537
|
}
|
|
492
538
|
let composer;
|
|
539
|
+
let skillActivationInstructions = '';
|
|
493
540
|
let initializationSkill;
|
|
494
541
|
try {
|
|
495
542
|
let turnContext = selection.data.context;
|
|
543
|
+
if (activatedSkillSelection) {
|
|
544
|
+
turnContext = { files: turnContext?.files ?? [], skills: activatedSkillSelection.entries };
|
|
545
|
+
skillActivationInstructions = activatedSkillSelection.instructions;
|
|
546
|
+
}
|
|
496
547
|
if (parameterTask.data?.action === 'initialize' || levelEditorTask.data) {
|
|
497
548
|
const selectedSkill = await ensureParameterInitializationSkill(root, levelEditorTask.data ? 'playcraft-level-editor' : 'playcraft-runtime-parameters', levelEditorTask.data ? { preserveExisting: true } : {});
|
|
498
549
|
initializationSkill = { ...selectedSkill,
|
|
@@ -512,11 +563,16 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
512
563
|
});
|
|
513
564
|
}
|
|
514
565
|
catch (error) {
|
|
566
|
+
const live = state.journal.getOperation(scopeKey);
|
|
567
|
+
if (skillActivation.data && live && ['failed', 'interrupted'].includes(live.status))
|
|
568
|
+
return live;
|
|
515
569
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', {
|
|
516
|
-
error: error instanceof
|
|
517
|
-
? error
|
|
518
|
-
|
|
519
|
-
|
|
570
|
+
error: skillActivation.data ? error instanceof Error && /^(?:skill_activation_|skills_revision_conflict|workspace_skills_)/.test(error.message)
|
|
571
|
+
? error.message : 'skill_activation_failed'
|
|
572
|
+
: error instanceof ComposerContextError ? error.message : parameterTask.data?.action === 'initialize' || levelEditorTask.data
|
|
573
|
+
? error instanceof Error && /^(?:workspace_skills_|skills_revision_conflict$)/.test(error.message)
|
|
574
|
+
? error.message : levelEditorTask.data ? 'level_editor_skill_unavailable' : parameterAgentError(error)
|
|
575
|
+
: 'invalid_composer_context',
|
|
520
576
|
}));
|
|
521
577
|
}
|
|
522
578
|
let attachments;
|
|
@@ -567,6 +623,9 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
567
623
|
}
|
|
568
624
|
}
|
|
569
625
|
// Taken before the native turn exists, so nothing the agent writes can precede it.
|
|
626
|
+
const beforeStart = state.journal.getOperation(scopeKey);
|
|
627
|
+
if (skillActivation.data && beforeStart && ['failed', 'interrupted'].includes(beforeStart.status))
|
|
628
|
+
return beforeStart;
|
|
570
629
|
await beginTurnChanges(state, sessionId, {
|
|
571
630
|
root, workspaceId: operation.workspaceId, directoryName: binding.directoryName ?? 'basic',
|
|
572
631
|
});
|
|
@@ -575,7 +634,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
575
634
|
const startTurn = () => native(state).startTurn({
|
|
576
635
|
sessionId,
|
|
577
636
|
threadId: binding.threadId,
|
|
578
|
-
text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions].filter(Boolean).join('\n\n')),
|
|
637
|
+
text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions, skillActivationInstructions].filter(Boolean).join('\n\n')),
|
|
579
638
|
images: attachments.filter((file) => ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(file.mimeType)).map((file) => file.path),
|
|
580
639
|
clientRequestId: operation.clientRequestId,
|
|
581
640
|
modelConfigId: selected.data ?? binding.modelConfigId,
|
|
@@ -587,22 +646,30 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
587
646
|
scheduledTaskProposalBridge: typeof body.scheduledTaskProposalBridge === 'string' ? body.scheduledTaskProposalBridge : undefined,
|
|
588
647
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
589
648
|
persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort, permissionMode }) => {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
649
|
+
await state.journal.exclusive(`native-reservation:${operation.operationId}`, async () => {
|
|
650
|
+
const live = state.journal.getOperation(scopeKey) ?? operation;
|
|
651
|
+
if (live.status !== 'dispatched' || skillActivationRuntimeClosing(state) || state.journal.destroyed())
|
|
652
|
+
throw new NativePreparationError(skillActivation.data ? 'skill_activation_cancelled' : 'native_preparation_cancelled');
|
|
653
|
+
if ((live.leaseFence ?? 0) < state.acceptedLeaseFence || live.generation !== state.config.generation || live.runtimeEpoch !== state.runtimeEpoch)
|
|
654
|
+
throw new NativePreparationError('stale_lease');
|
|
655
|
+
const selectedEffort = reasoningEffort ?? requestedEffort;
|
|
656
|
+
const selectedMode = permissionMode ?? requestedMode;
|
|
657
|
+
await persist(state, scopeKey, {
|
|
658
|
+
...live,
|
|
659
|
+
nativeRequestId,
|
|
660
|
+
nativeThreadId: binding.threadId,
|
|
661
|
+
modelConfigId,
|
|
662
|
+
reasoningEffort: selectedEffort,
|
|
663
|
+
permissionMode: selectedMode,
|
|
664
|
+
workMode: selection.data.workMode ?? 'execute',
|
|
665
|
+
context: composer.context,
|
|
666
|
+
...(activatedSkillSelection ? { skillExecutionPolicyVersion: 1 } : {}),
|
|
667
|
+
userText: selection.data.workMode !== undefined || composer.context !== undefined || parameterTask.data || levelEditorTask.data
|
|
668
|
+
? String(body.userText ?? body.text ?? '') : undefined,
|
|
669
|
+
updatedAt: new Date().toISOString(),
|
|
670
|
+
});
|
|
671
|
+
await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
|
|
604
672
|
});
|
|
605
|
-
await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
|
|
606
673
|
},
|
|
607
674
|
});
|
|
608
675
|
result = await startTurn();
|
|
@@ -619,9 +686,12 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
619
686
|
}
|
|
620
687
|
}
|
|
621
688
|
catch (error) {
|
|
622
|
-
if (!parameterTask.data && !levelEditorTask.data)
|
|
689
|
+
if (!parameterTask.data && !levelEditorTask.data && !skillActivation.data)
|
|
623
690
|
throw error;
|
|
624
691
|
abandonTurnChanges(state, sessionId);
|
|
692
|
+
const live = state.journal.getOperation(scopeKey);
|
|
693
|
+
if (live && ['completed', 'failed', 'interrupted'].includes(live.status))
|
|
694
|
+
return live;
|
|
625
695
|
if (error instanceof NativePreparationError) {
|
|
626
696
|
await state.journal.putSession({ ...(state.journal.getSession(sessionId) ?? binding), turnStatus: undefined, turnBlock: undefined });
|
|
627
697
|
return persist(state, scopeKey, transitionOperation(state.journal.getOperation(scopeKey) ?? operation, 'failed', { error: error.message }));
|
|
@@ -630,6 +700,10 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
630
700
|
return persist(state, scopeKey, transitionOperation(state.journal.getOperation(scopeKey) ?? operation, 'uncertain', { error: 'native_receipt_lost' }));
|
|
631
701
|
}
|
|
632
702
|
const reserved = state.journal.getOperation(scopeKey) ?? operation;
|
|
703
|
+
if (['completed', 'failed', 'interrupted'].includes(reserved.status)) {
|
|
704
|
+
abandonTurnChanges(state, sessionId);
|
|
705
|
+
return reserved;
|
|
706
|
+
}
|
|
633
707
|
if (result.outcome === 'uncertain' || result.outcome === 'failed' || !result.turnId) {
|
|
634
708
|
abandonTurnChanges(state, sessionId);
|
|
635
709
|
const failed = result.outcome === 'failed';
|
|
@@ -952,6 +1026,25 @@ async function executeInterrupt(state, scopeKey, operation, payload) {
|
|
|
952
1026
|
// A directory-gated turn has no native turn to interrupt. Cancel only a proven pending
|
|
953
1027
|
// claim; an admitted/racing claim still goes through the native interruption contract.
|
|
954
1028
|
if (expectedTurnId === undefined && !binding.activeTurnId) {
|
|
1029
|
+
const preparations = [...(nativePreparations.get(state)?.values() ?? [])].filter(item => item.operation.sessionId === sessionId && item.operation.workspaceId === operation.workspaceId
|
|
1030
|
+
&& (item.operation.directoryName ?? 'basic') === (operation.directoryName ?? 'basic')
|
|
1031
|
+
&& (!item.held || item.activation)
|
|
1032
|
+
&& (cancelTarget === undefined || cancelTarget === item.operation.operationId || cancelTarget === item.operation.gatewayOperationId)
|
|
1033
|
+
&& !state.journal.getOperationById(item.operation.operationId)?.nativeRequestId);
|
|
1034
|
+
if (preparations.length === 1) {
|
|
1035
|
+
const preparation = preparations[0];
|
|
1036
|
+
const cancelled = await state.journal.exclusive(`native-reservation:${preparation.operation.operationId}`, async () => {
|
|
1037
|
+
const live = state.journal.getOperationById(preparation.operation.operationId) ?? preparation.operation;
|
|
1038
|
+
if (!['queued', 'dispatched'].includes(live.status) || live.nativeTurnId || live.nativeRequestId)
|
|
1039
|
+
return false;
|
|
1040
|
+
await persist(state, preparation.scopeKey, transitionOperation(live, 'interrupted', { error: 'cancelled' }));
|
|
1041
|
+
cancelSkillActivationPreparation(state, live.operationId);
|
|
1042
|
+
return true;
|
|
1043
|
+
});
|
|
1044
|
+
if (cancelled) {
|
|
1045
|
+
return persist(state, scopeKey, transitionOperation(transitionOperation(operation, 'accepted'), 'completed'));
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
955
1048
|
const candidates = state.journal.deferredDirectoryOperations().flatMap(pending => {
|
|
956
1049
|
const queued = state.journal.getOperationById(pending.operationId);
|
|
957
1050
|
return queued?.status === 'queued' && ['session.turn', 'session.review', 'session.compact'].includes(queued.kind)
|