@playcraft/cli 0.1.69 → 0.1.71
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/commands/skills.js +68 -4
- 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/workspace-runtime/batch/content-changes.js +3 -0
- package/dist/workspace-runtime/batch/generate.js +4 -1
- package/dist/workspace-runtime/batch/prepare.js +5 -1
- package/dist/workspace-runtime/batch/source-capture.js +43 -4
- package/dist/workspace-runtime/codex/app-server.js +25 -1
- package/dist/workspace-runtime/codex/model-broker.js +93 -6
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -28
- package/dist/workspace-runtime/server/http.js +15 -0
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/verify-artifact.js +2 -1
- package/dist/workspace-runtime/workspaces/files.js +20 -14
- package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
- 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/package.json +3 -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,512 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { platformEndpoint, productionSessionAttemptId, skillActivationPayloadSchema, skillActivationBindingHash, skillRequiresExclusiveTurn, skillActivationRegistrationSchema, skillActivationRequestRecordSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { canonicalProjectSkillsJson, parseRemoteSkillSelectionResult, projectSkillsDigest, } from '@playcraft/common/project-skills';
|
|
8
|
+
import { workspaceContextFromRoot } from './registry.js';
|
|
9
|
+
import { assertOperationDirectoryAccess } from '../server/directory-access.js';
|
|
10
|
+
import { readWorkspaceSkillSnapshot } from './skills.js';
|
|
11
|
+
import { readLockedProjectSkillCatalog } from '../../project-skills/discovery.js';
|
|
12
|
+
import { activatePreparedSkillSelection } from '../../project-skills/remote-activation.js';
|
|
13
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
14
|
+
import { trustedMemberPolicy } from '../batch/policy.js';
|
|
15
|
+
import { wakeSourceEventUploader } from '../server/source-uploader.js';
|
|
16
|
+
const privateFields = z.object({ selection: z.unknown(), leaseFence: z.number().int().nonnegative(),
|
|
17
|
+
requestedSkillRefs: z.array(z.string().min(1).max(257)).min(1).max(512) }).passthrough();
|
|
18
|
+
function parsePrivate(value) {
|
|
19
|
+
const proof = skillActivationRequestRecordSchema.strip().parse(value), extra = privateFields.parse(value);
|
|
20
|
+
return { ...proof, leaseFence: extra.leaseFence, requestedSkillRefs: extra.requestedSkillRefs,
|
|
21
|
+
selection: parseRemoteSkillSelectionResult(extra.selection) };
|
|
22
|
+
}
|
|
23
|
+
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
24
|
+
const loops = new WeakMap();
|
|
25
|
+
const closing = new WeakSet();
|
|
26
|
+
const waiters = new WeakMap();
|
|
27
|
+
const stableId = (value) => {
|
|
28
|
+
const hash = createHash('sha256').update(value).digest('hex');
|
|
29
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
30
|
+
};
|
|
31
|
+
async function directory(state) {
|
|
32
|
+
const target = resolve(state.config.stateDir, 'skill-activation-requests');
|
|
33
|
+
// private: controller-only activation request state, outside the shared workspace.
|
|
34
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
35
|
+
const stat = await lstat(target);
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
37
|
+
throw new Error('skill_activation_state_invalid');
|
|
38
|
+
const actual = await realpath(target), rel = relative(await realpath(state.config.workspaceRoot), actual);
|
|
39
|
+
if (!rel || rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
40
|
+
throw new Error('skill_activation_state_not_private');
|
|
41
|
+
return actual;
|
|
42
|
+
}
|
|
43
|
+
async function read(state, requestId) {
|
|
44
|
+
const path = join(await directory(state), `${requestId}.json`);
|
|
45
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
46
|
+
try {
|
|
47
|
+
const stat = await handle.stat();
|
|
48
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 16 * 1024 * 1024)
|
|
49
|
+
throw new Error('skill_activation_state_invalid');
|
|
50
|
+
return parsePrivate(JSON.parse(await handle.readFile('utf8')));
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function write(state, record) {
|
|
57
|
+
const folder = await directory(state), temporary = join(folder, `.${randomUUID()}.tmp`);
|
|
58
|
+
const bytes = canonicalProjectSkillsJson(parsePrivate(record));
|
|
59
|
+
if (Buffer.byteLength(bytes) > 16 * 1024 * 1024)
|
|
60
|
+
throw new Error('skill_activation_state_limit');
|
|
61
|
+
try {
|
|
62
|
+
// private: activation requests include trusted selections and controller lease fences.
|
|
63
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
64
|
+
await rename(temporary, join(folder, `${record.requestId}.json`));
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await rm(temporary, { force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function listPrivate(state) {
|
|
71
|
+
const files = (await readdir(await directory(state))).filter(name => /^[a-f0-9-]{36}\.json$/.test(name));
|
|
72
|
+
if (files.length > 256)
|
|
73
|
+
throw new Error('skill_activation_state_limit');
|
|
74
|
+
return Promise.all(files.sort().map(name => read(state, name.slice(0, -5))));
|
|
75
|
+
}
|
|
76
|
+
/** Control-only proof, stripped of the trusted selection body and all host paths. */
|
|
77
|
+
export async function listSkillActivationRequests(state, workspaceId, directoryName) {
|
|
78
|
+
return (await listPrivate(state)).filter(record => (!workspaceId || record.workspaceId === workspaceId)
|
|
79
|
+
&& (!directoryName || record.directoryName === directoryName)).map(record => {
|
|
80
|
+
const { selection: _selection, leaseFence: _lease, requestedSkillRefs: _roots, ...proof } = record;
|
|
81
|
+
return skillActivationRequestRecordSchema.parse(proof);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function change(state, requestId, patch) {
|
|
85
|
+
return state.journal.exclusive('skill-activation-request-store', async () => {
|
|
86
|
+
const current = await read(state, requestId);
|
|
87
|
+
if (Object.entries(patch).every(([key, value]) => current[key] === value))
|
|
88
|
+
return current;
|
|
89
|
+
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
90
|
+
await write(state, next);
|
|
91
|
+
return next;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function currentSource(state, context) {
|
|
95
|
+
const matches = state.journal.listOperations().map(item => item.operation).filter(operation => operation.kind === 'session.turn' && operation.sessionId === context.sessionId && operation.clientRequestId === context.clientRequestId
|
|
96
|
+
&& operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch
|
|
97
|
+
&& ['dispatched', 'accepted'].includes(operation.status));
|
|
98
|
+
const source = matches.length === 1 ? matches[0] : undefined;
|
|
99
|
+
const session = state.journal.getSession(context.sessionId);
|
|
100
|
+
if (!source?.workspaceId || !source.nativeThreadId || !source.nativeTurnId || source.directoryName === 'root'
|
|
101
|
+
|| source.userId !== state.config.userId || source.environment !== state.config.environment
|
|
102
|
+
|| source.workMode === 'plan' || source.permissionMode === 'read-only' || session?.permissionMode === 'read-only'
|
|
103
|
+
|| !session || session.turnBlock || !['running', 'waiting_for_user'].includes(session.turnStatus ?? '')
|
|
104
|
+
|| session.activeTurnId !== source.nativeTurnId || session.threadId !== source.nativeThreadId || session.cwd !== context.cwd
|
|
105
|
+
|| session.workspaceId !== source.workspaceId || (session.directoryName ?? 'basic') !== (source.directoryName ?? 'basic')
|
|
106
|
+
|| productionSessionAttemptId(context.sessionId) || /^member-[0-9a-f-]{36}$/i.test(context.sessionId) || trustedMemberPolicy(state, context.sessionId)
|
|
107
|
+
|| state.journal.destroyed() || (source.leaseFence ?? 0) < state.acceptedLeaseFence)
|
|
108
|
+
throw new Error('skill_activation_source_unavailable');
|
|
109
|
+
return source;
|
|
110
|
+
}
|
|
111
|
+
async function verifyPrepared(root, selection) {
|
|
112
|
+
const preparedRoot = join(root, '.playcraft/skill-selections', selection.selectionId);
|
|
113
|
+
for (const path of [join(root, '.playcraft'), join(root, '.playcraft/skill-selections'), preparedRoot]) {
|
|
114
|
+
const stat = await lstat(path);
|
|
115
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
116
|
+
throw new Error('skill_activation_selection_invalid');
|
|
117
|
+
}
|
|
118
|
+
const handle = await open(join(preparedRoot, 'selection.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
119
|
+
let saved;
|
|
120
|
+
try {
|
|
121
|
+
const stat = await handle.stat();
|
|
122
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 16 * 1024 * 1024)
|
|
123
|
+
throw new Error('skill_activation_selection_invalid');
|
|
124
|
+
saved = parseRemoteSkillSelectionResult(JSON.parse(await handle.readFile('utf8')));
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await handle.close();
|
|
128
|
+
}
|
|
129
|
+
if (canonicalProjectSkillsJson(saved) !== canonicalProjectSkillsJson(selection))
|
|
130
|
+
throw new Error('skill_activation_selection_changed');
|
|
131
|
+
const catalog = await readLockedProjectSkillCatalog(preparedRoot, join(preparedRoot, '.playcraft/skill-artifacts'));
|
|
132
|
+
if (catalog.lockHash !== selection.lockHash || catalog.skills.length !== selection.lock.entries.length)
|
|
133
|
+
throw new Error('skill_activation_selection_invalid');
|
|
134
|
+
}
|
|
135
|
+
function alreadyActive(snapshot, selection) {
|
|
136
|
+
return selection.lock.entries.every(selected => {
|
|
137
|
+
const matches = snapshot.lock.entries.filter(item => item.revision.skillRef === selected.revision.skillRef);
|
|
138
|
+
const locked = matches.length === 1 ? matches[0] : undefined;
|
|
139
|
+
const declared = snapshot.declaration.entries.find(item => item.entryKey === locked?.entryKey);
|
|
140
|
+
return locked?.revision.kind === 'release' && selected.revision.kind === 'release'
|
|
141
|
+
&& locked.revision.version === selected.revision.version && locked.revision.artifactHash === selected.revision.artifactHash
|
|
142
|
+
&& declared?.enabled && declared.surface === 'sandbox-agent';
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Registers immutable intent while the source turn runs; activation is deferred to a new trusted turn. */
|
|
146
|
+
export async function registerSkillActivationRequest(state, context, input) {
|
|
147
|
+
const selection = parseRemoteSkillSelectionResult(input), root = await realpath(context.cwd);
|
|
148
|
+
const requestedSkillRefs = [...new Set(context.requestedSkillRefs ?? selection.skills.map(skill => skill.skillRef))];
|
|
149
|
+
if (!requestedSkillRefs.length || requestedSkillRefs.some(ref => !selection.skills.some(skill => skill.skillRef === ref)))
|
|
150
|
+
throw new Error('skill_activation_selection_invalid');
|
|
151
|
+
if (root !== context.cwd)
|
|
152
|
+
context = { ...context, cwd: root };
|
|
153
|
+
const scope = await workspaceContextFromRoot(state, root), source = currentSource(state, context);
|
|
154
|
+
if (scope.workspaceId !== source.workspaceId || scope.directoryName !== (source.directoryName ?? 'basic')
|
|
155
|
+
|| await batchDirectoryHeld(state, scope.workspaceId, scope.directoryName))
|
|
156
|
+
throw new Error('skill_activation_scope_unavailable');
|
|
157
|
+
await verifyPrepared(root, selection);
|
|
158
|
+
const snapshot = await readWorkspaceSkillSnapshot(root), lockHash = projectSkillsDigest(snapshot.lock);
|
|
159
|
+
if (source.context?.skills.some(skill => skill.lockHash !== lockHash))
|
|
160
|
+
throw new Error('skill_activation_source_unavailable');
|
|
161
|
+
currentSource(state, context);
|
|
162
|
+
const exclusiveRefs = selection.skills.filter(skill => skillRequiresExclusiveTurn(skill.skillRef)).map(skill => skill.skillRef);
|
|
163
|
+
const executableHere = !exclusiveRefs.length || source.directoryAccessMode === 'exclusive' && source.skillExecutionPolicyVersion === 1
|
|
164
|
+
&& exclusiveRefs.every(ref => source.context?.skills.some(selected => selected.lockHash === lockHash
|
|
165
|
+
&& snapshot.lock.entries.some(entry => entry.entryKey === selected.entryKey && entry.revision.skillRef === ref)));
|
|
166
|
+
if (alreadyActive(snapshot, selection) && executableHere)
|
|
167
|
+
return skillActivationRegistrationSchema.parse({ status: 'already_active', selectionId: selection.selectionId,
|
|
168
|
+
lockHash, executionReady: true });
|
|
169
|
+
return state.journal.exclusive('skill-activation-request-store', async () => {
|
|
170
|
+
currentSource(state, context);
|
|
171
|
+
const records = await listPrivate(state);
|
|
172
|
+
const requestId = stableId(canonicalProjectSkillsJson([state.config.runtimeId, state.config.generation, state.runtimeEpoch,
|
|
173
|
+
scope.workspaceId, scope.directoryName, source.operationId, selection.selectionId]));
|
|
174
|
+
const previous = records.find(item => item.requestId === requestId);
|
|
175
|
+
if (previous) {
|
|
176
|
+
if (canonicalProjectSkillsJson(previous.selection) !== canonicalProjectSkillsJson(selection))
|
|
177
|
+
throw new Error('skill_activation_request_conflict');
|
|
178
|
+
return skillActivationRegistrationSchema.parse({ status: 'activation_requested', selectionId: selection.selectionId, requestId, executionReady: false });
|
|
179
|
+
}
|
|
180
|
+
if (records.some(item => item.sourceOperationId === source.operationId))
|
|
181
|
+
throw new Error('skill_activation_request_conflict');
|
|
182
|
+
if (records.length >= 256)
|
|
183
|
+
throw new Error('skill_activation_state_limit');
|
|
184
|
+
const now = new Date().toISOString();
|
|
185
|
+
const record = { version: 1, requestId, selectionId: selection.selectionId, selection,
|
|
186
|
+
requestedSkillRefs,
|
|
187
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName, sourceOperationId: source.operationId,
|
|
188
|
+
sourceSessionId: context.sessionId, sourceThreadId: source.nativeThreadId, sourceTurnId: source.nativeTurnId,
|
|
189
|
+
generation: state.config.generation, runtimeEpoch: state.runtimeEpoch, leaseFence: source.leaseFence ?? 0,
|
|
190
|
+
expectedLockHash: lockHash, expectedDeclarationHash: snapshot.lock.declarationHash,
|
|
191
|
+
registrationHash: skillActivationBindingHash(state.journal.getWorkspace(scope.workspaceId), scope.directoryName),
|
|
192
|
+
selectionReferences: selection.skills.map(({ skillRef, skillVersionId, version, artifactHash }) => ({ skillRef, skillVersionId, version, artifactHash })),
|
|
193
|
+
status: 'requested', createdAt: now, updatedAt: now };
|
|
194
|
+
await write(state, record);
|
|
195
|
+
await state.journal.allocateSourceEvent({ runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
196
|
+
type: 'skills.activation.requested', occurredAt: now, workspaceId: scope.workspaceId, sessionId: context.sessionId,
|
|
197
|
+
operationId: source.operationId, payload: { requestId, selectionId: selection.selectionId } });
|
|
198
|
+
wakeSourceEventUploader(state);
|
|
199
|
+
wakeSkillActivationRequests(state);
|
|
200
|
+
return skillActivationRegistrationSchema.parse({ status: 'activation_requested', selectionId: selection.selectionId, requestId, executionReady: false });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const busy = (state, exceptOperationId) => state.journal.listSessions().some(session => !!session.turnBlock || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
204
|
+
|| state.journal.listOperations().some(({ operation }) => operation.operationId !== exceptOperationId
|
|
205
|
+
&& ['session.turn', 'session.review', 'session.compact'].includes(operation.kind) && !TERMINAL.has(operation.status));
|
|
206
|
+
const nativeBusy = (state, exceptOperationId) => state.journal.listSessions().some(session => !!session.turnBlock || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
207
|
+
|| state.journal.listApprovals().some(approval => approval.status === 'pending')
|
|
208
|
+
|| state.journal.listOperations().some(({ operation }) => operation.operationId !== exceptOperationId
|
|
209
|
+
&& ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
|
|
210
|
+
&& ['dispatched', 'accepted', 'uncertain'].includes(operation.status));
|
|
211
|
+
function matchesSource(state, record, source) {
|
|
212
|
+
return !!source && source.kind === 'session.turn' && source.operationId === record.sourceOperationId
|
|
213
|
+
&& source.workspaceId === record.workspaceId && (source.directoryName ?? 'basic') === record.directoryName
|
|
214
|
+
&& source.sessionId === record.sourceSessionId && source.nativeThreadId === record.sourceThreadId && source.nativeTurnId === record.sourceTurnId
|
|
215
|
+
&& source.generation === record.generation && source.runtimeEpoch === record.runtimeEpoch
|
|
216
|
+
&& source.userId === state.config.userId && source.environment === state.config.environment;
|
|
217
|
+
}
|
|
218
|
+
function continuation(state, record) {
|
|
219
|
+
const matches = state.journal.listOperations().map(item => item.operation).filter(operation => operation.kind === 'session.turn'
|
|
220
|
+
&& operation.clientRequestId === record.requestId && operation.sessionId === record.sourceSessionId
|
|
221
|
+
&& operation.workspaceId === record.workspaceId && (operation.directoryName ?? 'basic') === record.directoryName
|
|
222
|
+
&& operation.generation === record.generation && operation.runtimeEpoch === record.runtimeEpoch);
|
|
223
|
+
if (matches.length > 1)
|
|
224
|
+
throw new Error('skill_activation_operation_ambiguous');
|
|
225
|
+
return matches[0];
|
|
226
|
+
}
|
|
227
|
+
async function applyContinuationReceipt(state, record, target) {
|
|
228
|
+
if (target.status === 'completed')
|
|
229
|
+
await change(state, record.requestId, { status: 'completed', error: undefined });
|
|
230
|
+
else if (target.status === 'failed' || target.status === 'interrupted')
|
|
231
|
+
await change(state, record.requestId, {
|
|
232
|
+
status: target.status === 'interrupted' ? 'cancelled' : 'failed', error: 'skill_activation_continuation_failed',
|
|
233
|
+
});
|
|
234
|
+
else if (target.status === 'uncertain')
|
|
235
|
+
await change(state, record.requestId, { status: 'uncertain', error: 'skill_activation_continuation_uncertain' });
|
|
236
|
+
else
|
|
237
|
+
await change(state, record.requestId, { status: 'dispatched', error: undefined });
|
|
238
|
+
}
|
|
239
|
+
/** Startup reconciliation reads existing receipts only; it never dispatches or restarts native work. */
|
|
240
|
+
export async function reconcileSkillActivationRequestReceipts(state) {
|
|
241
|
+
for (const record of await listPrivate(state)) {
|
|
242
|
+
if (['completed', 'failed', 'cancelled', 'stale'].includes(record.status))
|
|
243
|
+
continue;
|
|
244
|
+
try {
|
|
245
|
+
const target = continuation(state, record);
|
|
246
|
+
if (target)
|
|
247
|
+
await applyContinuationReceipt(state, record, target);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
await change(state, record.requestId, { status: 'uncertain', error: 'skill_activation_operation_ambiguous' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function superseded(state, record, currentOperationId) {
|
|
255
|
+
const source = state.journal.getOperationById(record.sourceOperationId);
|
|
256
|
+
return state.journal.listOperations().some(({ operation }) => operation.sessionId === record.sourceSessionId
|
|
257
|
+
&& ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
|
|
258
|
+
&& operation.operationId !== source?.operationId && operation.operationId !== currentOperationId
|
|
259
|
+
&& operation.clientRequestId !== record.requestId && operation.createdAt >= (source?.createdAt ?? record.createdAt));
|
|
260
|
+
}
|
|
261
|
+
function dispatchBody(state, record) {
|
|
262
|
+
return { ...skillActivationPayloadSchema.parse({ version: record.version, requestId: record.requestId, selectionId: record.selectionId,
|
|
263
|
+
sourceOperationId: record.sourceOperationId, sourceSessionId: record.sourceSessionId, sourceThreadId: record.sourceThreadId,
|
|
264
|
+
sourceTurnId: record.sourceTurnId, expectedLockHash: record.expectedLockHash, expectedDeclarationHash: record.expectedDeclarationHash,
|
|
265
|
+
registrationHash: record.registrationHash }), runtimeId: state.config.runtimeId, generation: record.generation,
|
|
266
|
+
runtimeEpoch: record.runtimeEpoch, workspaceId: record.workspaceId, directoryName: record.directoryName };
|
|
267
|
+
}
|
|
268
|
+
async function verifyActivationPermission(state, record) {
|
|
269
|
+
const response = await fetch(platformEndpoint(state.config.publicBaseUrl, '/runtime/internal/skill-activation-requests/verify'), {
|
|
270
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
271
|
+
headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
|
|
272
|
+
body: JSON.stringify(dispatchBody(state, record)),
|
|
273
|
+
});
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
await response.body?.cancel().catch(() => undefined);
|
|
276
|
+
throw new Error('skill_activation_authority_unavailable');
|
|
277
|
+
}
|
|
278
|
+
const result = await response.json().catch(() => undefined);
|
|
279
|
+
if (result?.verified !== true)
|
|
280
|
+
throw new Error('skill_activation_authority_unavailable');
|
|
281
|
+
}
|
|
282
|
+
/** The continuation owns an exclusive directory lease and is verified before changing any lock bytes. */
|
|
283
|
+
export async function activateSkillSelectionForTurn(state, operation, payload, root) {
|
|
284
|
+
const input = skillActivationPayloadSchema.parse(payload), record = await read(state, input.requestId);
|
|
285
|
+
for (const key of Object.keys(input))
|
|
286
|
+
if (record[key] !== input[key])
|
|
287
|
+
throw new Error('skill_activation_request_invalid');
|
|
288
|
+
const assertCurrent = () => {
|
|
289
|
+
const source = state.journal.getOperationById(record.sourceOperationId), session = state.journal.getSession(record.sourceSessionId);
|
|
290
|
+
const binding = state.journal.getWorkspace(record.workspaceId);
|
|
291
|
+
const live = state.journal.getOperationById(operation.operationId);
|
|
292
|
+
if (state.journal.destroyed() || record.generation !== state.config.generation || record.runtimeEpoch !== state.runtimeEpoch
|
|
293
|
+
|| operation.kind !== 'session.turn' || operation.clientRequestId !== record.requestId || operation.sessionId !== record.sourceSessionId
|
|
294
|
+
|| operation.userId !== state.config.userId || operation.environment !== state.config.environment
|
|
295
|
+
|| operation.generation !== record.generation || operation.runtimeEpoch !== record.runtimeEpoch
|
|
296
|
+
|| operation.workspaceId !== record.workspaceId || (operation.directoryName ?? 'basic') !== record.directoryName
|
|
297
|
+
|| operation.directoryAccessMode !== 'exclusive' || (operation.leaseFence ?? 0) < state.acceptedLeaseFence
|
|
298
|
+
|| operation.workMode === 'plan' || operation.permissionMode === 'read-only' || session?.permissionMode === 'read-only' || session?.cwd !== root
|
|
299
|
+
|| !live || live.status !== 'dispatched' || !binding || skillActivationBindingHash(binding, record.directoryName) !== record.registrationHash
|
|
300
|
+
|| !matchesSource(state, record, source) || source?.status !== 'completed'
|
|
301
|
+
|| ['failed', 'cancelled', 'stale', 'uncertain', 'completed'].includes(record.status)
|
|
302
|
+
|| superseded(state, record, operation.operationId) || nativeBusy(state, operation.operationId))
|
|
303
|
+
throw new Error('skill_activation_source_unavailable');
|
|
304
|
+
};
|
|
305
|
+
assertCurrent();
|
|
306
|
+
await assertOperationDirectoryAccess(state, operation, 'exclusive');
|
|
307
|
+
await verifyActivationPermission(state, record);
|
|
308
|
+
assertCurrent();
|
|
309
|
+
await verifyPrepared(root, record.selection);
|
|
310
|
+
const result = await activatePreparedSkillSelection(root, record.selection, { expectedLockHash: record.expectedLockHash,
|
|
311
|
+
expectedDeclarationHash: record.expectedDeclarationHash, transactionId: record.requestId,
|
|
312
|
+
transactionDirectory: join(await directory(state), 'transactions'), assertCurrent: async () => {
|
|
313
|
+
assertCurrent();
|
|
314
|
+
await assertOperationDirectoryAccess(state, operation, 'exclusive');
|
|
315
|
+
await verifyActivationPermission(state, record);
|
|
316
|
+
assertCurrent();
|
|
317
|
+
} });
|
|
318
|
+
await change(state, record.requestId, { status: 'dispatched', targetLockHash: result.lockHash });
|
|
319
|
+
await state.journal.allocateSourceEvent({ runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
320
|
+
type: 'skills.activation.applied', occurredAt: new Date().toISOString(), workspaceId: record.workspaceId,
|
|
321
|
+
sessionId: record.sourceSessionId, operationId: operation.operationId, payload: { requestId: record.requestId, selectionId: record.selectionId, lockHash: result.lockHash } });
|
|
322
|
+
wakeSourceEventUploader(state);
|
|
323
|
+
const catalog = await readLockedProjectSkillCatalog(root, join(state.config.stateDir, 'skill-artifacts'));
|
|
324
|
+
if (catalog.lockHash !== result.lockHash)
|
|
325
|
+
throw new Error('skill_activation_selection_changed');
|
|
326
|
+
const byRef = new Map(catalog.skills.map(skill => [skill.skillRef, skill]));
|
|
327
|
+
const skills = record.requestedSkillRefs.map(ref => {
|
|
328
|
+
const skill = byRef.get(ref);
|
|
329
|
+
if (!skill)
|
|
330
|
+
throw new Error('skill_activation_selection_invalid');
|
|
331
|
+
return { entryKey: skill.entryKey, skillRef: skill.skillRef, revision: skill.revision, path: skill.path };
|
|
332
|
+
});
|
|
333
|
+
const selected = [...catalog.skills.filter(skill => record.selection.skills.some(selected => selected.skillRef === skill.skillRef)
|
|
334
|
+
&& skillRequiresExclusiveTurn(skill.skillRef)), ...skills]
|
|
335
|
+
.filter((skill, index, items) => skill.entryKey.length <= 128 && items.findIndex(item => item.entryKey === skill.entryKey) === index).slice(0, 8)
|
|
336
|
+
.map(skill => ({ entryKey: skill.entryKey, lockHash: result.lockHash }));
|
|
337
|
+
return { ...result, entries: selected,
|
|
338
|
+
instructions: [
|
|
339
|
+
'The selected exact skills are now activated. Continue the user\'s original task from the current progress, preserve completed modifications, and read these newly locked SKILL.md files before using each skill. Do not request another continuation for this same selection.',
|
|
340
|
+
...skills.map(({ skillRef, revision, path }) => JSON.stringify({ skillRef, revision, path })),
|
|
341
|
+
].join('\n') };
|
|
342
|
+
}
|
|
343
|
+
/** Called while holding native-preparation: existing turns may finish, new starts cannot overtake activation. */
|
|
344
|
+
export async function waitForSkillActivationIdle(state, operation, input) {
|
|
345
|
+
const controller = new AbortController(), active = waiters.get(state) ?? new Map();
|
|
346
|
+
active.set(operation.operationId, { sessionId: operation.sessionId, controller });
|
|
347
|
+
waiters.set(state, active);
|
|
348
|
+
try {
|
|
349
|
+
await new Promise((resolveWait, reject) => {
|
|
350
|
+
let unsubscribe = () => { };
|
|
351
|
+
const finish = (error) => { unsubscribe(); controller.signal.removeEventListener('abort', abort); error ? reject(error) : resolveWait(); };
|
|
352
|
+
const abort = () => finish(new Error('skill_activation_cancelled'));
|
|
353
|
+
const check = () => {
|
|
354
|
+
const source = state.journal.getOperationById(input.sourceOperationId);
|
|
355
|
+
if (closing.has(state) || state.journal.destroyed() || operation.runtimeEpoch !== state.runtimeEpoch
|
|
356
|
+
|| operation.generation !== state.config.generation || (operation.leaseFence ?? 0) < state.acceptedLeaseFence
|
|
357
|
+
|| !source || source.status !== 'completed' || source.sessionId !== input.sourceSessionId
|
|
358
|
+
|| source.nativeThreadId !== input.sourceThreadId || source.nativeTurnId !== input.sourceTurnId) {
|
|
359
|
+
finish(new Error('skill_activation_source_unavailable'));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (state.journal.listOperations().some(({ operation: other }) => other.sessionId === input.sourceSessionId
|
|
363
|
+
&& ['session.turn', 'session.review', 'session.compact'].includes(other.kind)
|
|
364
|
+
&& other.operationId !== source.operationId && other.operationId !== operation.operationId
|
|
365
|
+
&& other.clientRequestId !== input.requestId && other.createdAt >= source.createdAt)) {
|
|
366
|
+
finish(new Error('skill_activation_source_superseded'));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const live = state.journal.getOperationById(operation.operationId);
|
|
370
|
+
if (live && ['interrupted', 'failed', 'completed'].includes(live.status)) {
|
|
371
|
+
finish(new Error('skill_activation_cancelled'));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!nativeBusy(state, operation.operationId))
|
|
375
|
+
finish();
|
|
376
|
+
};
|
|
377
|
+
unsubscribe = state.journal.onChange(check);
|
|
378
|
+
controller.signal.addEventListener('abort', abort, { once: true });
|
|
379
|
+
check();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
active.delete(operation.operationId);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
export function cancelSkillActivationPreparation(state, operationId) {
|
|
387
|
+
waiters.get(state)?.get(operationId)?.controller.abort();
|
|
388
|
+
}
|
|
389
|
+
export const skillActivationRuntimeClosing = (state) => closing.has(state);
|
|
390
|
+
/** Retries the same control identity after transport loss; local native receipts always take precedence. */
|
|
391
|
+
export async function drainSkillActivationRequests(state, signal) {
|
|
392
|
+
for (const record of await listPrivate(state)) {
|
|
393
|
+
if (state.journal.destroyed() || signal?.aborted)
|
|
394
|
+
return;
|
|
395
|
+
if (['completed', 'failed', 'cancelled', 'stale'].includes(record.status))
|
|
396
|
+
continue;
|
|
397
|
+
const update = (patch) => change(state, record.requestId, patch);
|
|
398
|
+
if (record.generation !== state.config.generation || record.runtimeEpoch !== state.runtimeEpoch) {
|
|
399
|
+
await update({ status: 'stale', error: 'skill_activation_runtime_changed' });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const binding = state.journal.getWorkspace(record.workspaceId);
|
|
403
|
+
if (!binding || skillActivationBindingHash(binding, record.directoryName) !== record.registrationHash) {
|
|
404
|
+
await update({ status: 'stale', error: 'skill_activation_binding_changed' });
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const source = state.journal.getOperationById(record.sourceOperationId);
|
|
408
|
+
if (!matchesSource(state, record, source)) {
|
|
409
|
+
await update({ status: 'stale', error: 'skill_activation_source_unavailable' });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
let target;
|
|
413
|
+
try {
|
|
414
|
+
target = continuation(state, record);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
await update({ status: 'uncertain', error: 'skill_activation_operation_ambiguous' });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (target) {
|
|
421
|
+
await applyContinuationReceipt(state, record, target);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (source.status === 'failed' || source.status === 'interrupted') {
|
|
425
|
+
await update({ status: 'cancelled', error: 'skill_activation_source_stopped' });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (source.status === 'uncertain') {
|
|
429
|
+
await update({ status: 'uncertain', error: 'skill_activation_source_uncertain' });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (source.status !== 'completed')
|
|
433
|
+
continue;
|
|
434
|
+
if (superseded(state, record)) {
|
|
435
|
+
await update({ status: 'stale', error: 'skill_activation_source_superseded' });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (record.status === 'uncertain' && record.error !== 'skill_activation_source_uncertain')
|
|
439
|
+
continue;
|
|
440
|
+
if (busy(state)) {
|
|
441
|
+
await update({ status: 'queued', error: undefined });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (await batchDirectoryHeld(state, record.workspaceId, record.directoryName)) {
|
|
445
|
+
await update({ status: 'queued', error: undefined });
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
await update({ status: 'queued' });
|
|
449
|
+
try {
|
|
450
|
+
const response = await fetch(platformEndpoint(state.config.publicBaseUrl, '/runtime/internal/skill-activation-requests/dispatch'), {
|
|
451
|
+
method: 'POST', redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
|
452
|
+
headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
|
|
453
|
+
body: JSON.stringify(dispatchBody(state, record)),
|
|
454
|
+
});
|
|
455
|
+
const result = await response.json().catch(() => ({}));
|
|
456
|
+
const reason = result.code ?? result.reason ?? result.message;
|
|
457
|
+
if (response.ok && typeof result.operationId === 'string') {
|
|
458
|
+
await update({ status: result.status === 'uncertain' ? 'uncertain' : result.status === 'failed' || result.status === 'interrupted' ? 'failed' : 'dispatched',
|
|
459
|
+
operationId: result.operationId, error: result.status === 'uncertain' ? 'skill_activation_continuation_uncertain' : undefined });
|
|
460
|
+
}
|
|
461
|
+
else if (reason === 'uncertain_operation' || reason === 'skill_activation_request_uncertain')
|
|
462
|
+
await update({ status: 'uncertain', error: 'skill_activation_continuation_uncertain' });
|
|
463
|
+
else if (reason === 'skill_activation_source_superseded')
|
|
464
|
+
await update({ status: 'stale', error: reason });
|
|
465
|
+
else if (response.status < 500 && response.status !== 409)
|
|
466
|
+
await update({ status: 'failed', error: 'skill_activation_dispatch_denied' });
|
|
467
|
+
else
|
|
468
|
+
await update({ status: 'queued', error: 'skill_activation_control_unavailable' });
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
if (!signal?.aborted)
|
|
472
|
+
await update({ status: 'queued', error: 'skill_activation_control_unavailable' });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
export function startSkillActivationRequestQueue(state) {
|
|
477
|
+
if (loops.has(state))
|
|
478
|
+
return;
|
|
479
|
+
closing.delete(state);
|
|
480
|
+
const loop = { running: false, closed: false, controller: new AbortController(),
|
|
481
|
+
timer: setInterval(() => wakeSkillActivationRequests(state), 2_000), unsubscribe: () => { } };
|
|
482
|
+
loop.timer.unref?.();
|
|
483
|
+
loops.set(state, loop);
|
|
484
|
+
loop.unsubscribe = state.journal.onChange(() => wakeSkillActivationRequests(state));
|
|
485
|
+
wakeSkillActivationRequests(state);
|
|
486
|
+
}
|
|
487
|
+
export async function stopSkillActivationRequestQueue(state) {
|
|
488
|
+
closing.add(state);
|
|
489
|
+
for (const waiter of waiters.get(state)?.values() ?? [])
|
|
490
|
+
waiter.controller.abort();
|
|
491
|
+
const loop = loops.get(state);
|
|
492
|
+
if (!loop)
|
|
493
|
+
return;
|
|
494
|
+
loop.closed = true;
|
|
495
|
+
loop.controller.abort();
|
|
496
|
+
clearInterval(loop.timer);
|
|
497
|
+
loop.unsubscribe();
|
|
498
|
+
loops.delete(state);
|
|
499
|
+
await loop.done;
|
|
500
|
+
}
|
|
501
|
+
export function wakeSkillActivationRequests(state) {
|
|
502
|
+
const loop = loops.get(state);
|
|
503
|
+
if (!loop || loop.closed || loop.running)
|
|
504
|
+
return;
|
|
505
|
+
loop.running = true;
|
|
506
|
+
loop.done = new Promise(done => {
|
|
507
|
+
setTimeout(() => {
|
|
508
|
+
void drainSkillActivationRequests(state, loop.controller.signal).catch(() => undefined)
|
|
509
|
+
.finally(() => { loop.running = false; done(); });
|
|
510
|
+
}, 0).unref?.();
|
|
511
|
+
});
|
|
512
|
+
}
|