@playcraft/cli 0.1.70 → 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/source-capture.js +1 -1
- 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 +14 -0
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/workspaces/files.js +20 -14
- 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
|
@@ -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)
|
|
@@ -15,6 +15,7 @@ import { listParameterAgentTasks } from '../workspaces/parameter-agent.js';
|
|
|
15
15
|
import { readWorkspaceLevelEditor } from '../workspaces/level-editor.js';
|
|
16
16
|
import { listLevelEditorTasks } from '../workspaces/level-editor-agent.js';
|
|
17
17
|
import { listLevelEditorRequests } from '../workspaces/level-editor-requests.js';
|
|
18
|
+
import { listSkillActivationRequests } from '../workspaces/skill-activation-requests.js';
|
|
18
19
|
import { readDependencies } from '../workspaces/dependencies.js';
|
|
19
20
|
import { permissionDriftFromError } from '../workspaces/workspace-shares.js';
|
|
20
21
|
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
@@ -191,6 +192,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
191
192
|
parameterAgentVersion: 1,
|
|
192
193
|
directoryConcurrencyVersion: 1,
|
|
193
194
|
skillExecutionPolicyVersion: 1,
|
|
195
|
+
skillActivationVersion: 1,
|
|
194
196
|
parameterInitializationVersion: 1,
|
|
195
197
|
levelEditorVersion: 1,
|
|
196
198
|
levelEditorTaskVersion: 1,
|
|
@@ -881,6 +883,18 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
881
883
|
writeJson(response, 200, { tasks: await listLevelEditorTasks(state, workspaceId, directoryName, await workspaceRoot(state, workspaceId, directoryName)) });
|
|
882
884
|
return;
|
|
883
885
|
}
|
|
886
|
+
if (request.method === 'GET' && url.pathname === '/runtime/skill-activation-requests') {
|
|
887
|
+
if (writeDestroyed(state, response))
|
|
888
|
+
return;
|
|
889
|
+
if (directoryName === 'root') {
|
|
890
|
+
writeJson(response, 400, { error: 'invalid_workspace_directory' });
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
894
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
895
|
+
writeJson(response, 200, { requests: await listSkillActivationRequests(state, workspaceId, directoryName) });
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
884
898
|
if (request.method === 'GET' && url.pathname === '/runtime/level-editor/requests') {
|
|
885
899
|
if (writeDestroyed(state, response))
|
|
886
900
|
return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { workspaceComposerSelectionSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { skillActivationPayloadSchema, workspaceComposerSelectionSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
3
|
/** One construction point for operation records so HTTP and in-sandbox callers cannot drift. */
|
|
4
4
|
export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true, policy) {
|
|
5
5
|
const now = new Date().toISOString();
|
|
@@ -12,6 +12,8 @@ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, exe
|
|
|
12
12
|
...(policy ? { skillExecutionPolicyVersion: policy.skillExecutionPolicyVersion, directoryAccessMode: policy.directoryAccessMode,
|
|
13
13
|
...workspaceComposerSelectionSchema.parse({ context: payload.context, workMode: payload.workMode }) } : {}),
|
|
14
14
|
...(parsed.kind === 'session.turn' && (payload.parameterTask !== undefined || payload.levelEditorTask !== undefined) ? { directoryAccessMode: 'exclusive' } : {}),
|
|
15
|
+
...(parsed.kind === 'session.turn' && skillActivationPayloadSchema.safeParse(payload.skillActivation).success
|
|
16
|
+
? { directoryAccessMode: 'exclusive', skillExecutionPolicyVersion: 1 } : {}),
|
|
15
17
|
environment: state.config.environment,
|
|
16
18
|
userId: state.config.userId,
|
|
17
19
|
clientRequestId: parsed.clientRequestId,
|
|
@@ -22,7 +22,7 @@ export async function resolveWorkspaceFile(root, relativePath) {
|
|
|
22
22
|
const parsed = workspaceRelativePathSchema.safeParse(relativePath);
|
|
23
23
|
if (!parsed.success)
|
|
24
24
|
throw new InvalidWorkspacePathError(relativePath);
|
|
25
|
-
const rootReal = await
|
|
25
|
+
const rootReal = await realpath(root);
|
|
26
26
|
const target = resolve(rootReal, ...parsed.data.split('/'));
|
|
27
27
|
assertInsideRoot(rootReal, target);
|
|
28
28
|
try {
|
|
@@ -126,19 +126,25 @@ function isPermissionDenied(error) {
|
|
|
126
126
|
return ['EACCES', 'EPERM'].includes(String(error?.code));
|
|
127
127
|
}
|
|
128
128
|
async function assertParentInsideRoot(rootReal, parent) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
129
|
+
while (parent !== rootReal) {
|
|
130
|
+
try {
|
|
131
|
+
const parentReal = await realpath(parent);
|
|
132
|
+
assertInsideRoot(rootReal, parentReal);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error.code !== 'ENOENT')
|
|
137
|
+
throw error;
|
|
138
|
+
const entry = await lstat(parent).catch((error) => {
|
|
139
|
+
if (error.code === 'ENOENT')
|
|
140
|
+
return undefined;
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
if (entry?.isSymbolicLink())
|
|
144
|
+
throw new InvalidWorkspacePathError(parent);
|
|
145
|
+
// Resolve the nearest existing ancestor without creating anything during a read.
|
|
146
|
+
parent = dirname(parent);
|
|
147
|
+
}
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
150
|
function assertInsideRoot(rootReal, target) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import { skillActivationBindingHash, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { recoverPreparedSkillActivationTransactions } from '../../project-skills/remote-activation.js';
|
|
5
|
+
import { workspaceRoot } from './registry.js';
|
|
6
|
+
import { listSkillActivationRequests, reconcileSkillActivationRequestReceipts } from './skill-activation-requests.js';
|
|
7
|
+
/** Runs before native startup: restore owned pairs and settle only provably unsent continuations. */
|
|
8
|
+
export async function recoverInterruptedSkillActivations(state) {
|
|
9
|
+
if (state.config.workspaceMode !== 'multi' || !await lstat(join(state.config.stateDir, 'skill-activation-requests'))
|
|
10
|
+
.catch((error) => { if (error.code === 'ENOENT')
|
|
11
|
+
return undefined; throw error; }))
|
|
12
|
+
return;
|
|
13
|
+
const transactionDirectory = join(state.config.stateDir, 'skill-activation-requests/transactions');
|
|
14
|
+
const requests = await listSkillActivationRequests(state);
|
|
15
|
+
const unsentContinuations = (request) => {
|
|
16
|
+
const source = state.journal.getOperationById(request.sourceOperationId);
|
|
17
|
+
if (!source || source.kind !== 'session.turn' || source.sessionId !== request.sourceSessionId
|
|
18
|
+
|| source.environment !== state.config.environment || source.userId !== state.config.userId
|
|
19
|
+
|| source.workspaceId !== request.workspaceId || (source.directoryName ?? 'basic') !== request.directoryName
|
|
20
|
+
|| source.generation !== request.generation || source.runtimeEpoch !== request.runtimeEpoch
|
|
21
|
+
|| source.nativeThreadId !== request.sourceThreadId || source.nativeTurnId !== request.sourceTurnId)
|
|
22
|
+
return [];
|
|
23
|
+
return state.journal.listOperations().filter(({ operation }) => operation.kind === 'session.turn' && operation.clientRequestId === request.requestId
|
|
24
|
+
&& operation.environment === state.config.environment && operation.userId === state.config.userId
|
|
25
|
+
&& operation.sessionId === request.sourceSessionId && operation.workspaceId === request.workspaceId
|
|
26
|
+
&& (operation.directoryName ?? 'basic') === request.directoryName && operation.generation === request.generation
|
|
27
|
+
&& operation.runtimeEpoch === request.runtimeEpoch && operation.status === 'dispatched'
|
|
28
|
+
&& !operation.nativeRequestId && !operation.nativeTurnId);
|
|
29
|
+
};
|
|
30
|
+
for (const workspaceId of new Set(requests.map(request => request.workspaceId))) {
|
|
31
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
32
|
+
if (binding) {
|
|
33
|
+
for (const directoryName of new Set(['basic', ...(binding.directories ?? []).map(directory => directory.directoryName)])) {
|
|
34
|
+
const root = await workspaceRoot(state, binding.workspaceId, directoryName).catch(error => {
|
|
35
|
+
if (error instanceof Error && error.message === 'workspace_not_prepared')
|
|
36
|
+
return undefined;
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
if (root)
|
|
40
|
+
await recoverPreparedSkillActivationTransactions(root, { transactionDirectory,
|
|
41
|
+
orphanTransactionIds: requests.filter(request => request.workspaceId === workspaceId && request.directoryName === directoryName
|
|
42
|
+
&& request.registrationHash === skillActivationBindingHash(binding, directoryName) && unsentContinuations(request).length > 0)
|
|
43
|
+
.map(request => request.requestId) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const request of requests) {
|
|
48
|
+
for (const { scopeKey, operation } of unsentContinuations(request)) {
|
|
49
|
+
// Reservation is persisted before native send. Without it this interrupted preparation never sent a turn.
|
|
50
|
+
await state.journal.putOperation(scopeKey, transitionOperation(operation, 'failed', { error: 'skill_activation_interrupted' }));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await reconcileSkillActivationRequestReceipts(state);
|
|
54
|
+
}
|