@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
|
@@ -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,
|
|
@@ -201,6 +203,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
201
203
|
remixReferencesVersion: 1,
|
|
202
204
|
batchSourceFreezeRevision: 2,
|
|
203
205
|
batchSourcePublicationVersion: 1,
|
|
206
|
+
batchReferenceContextVersion: 1,
|
|
204
207
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
205
208
|
...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
|
|
206
209
|
...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
|
|
@@ -880,6 +883,18 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
880
883
|
writeJson(response, 200, { tasks: await listLevelEditorTasks(state, workspaceId, directoryName, await workspaceRoot(state, workspaceId, directoryName)) });
|
|
881
884
|
return;
|
|
882
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
|
+
}
|
|
883
898
|
if (request.method === 'GET' && url.pathname === '/runtime/level-editor/requests') {
|
|
884
899
|
if (writeDestroyed(state, response))
|
|
885
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,
|
|
@@ -48,6 +48,7 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
48
48
|
assert.equal(identity.runtimeId, config.runtimeId);
|
|
49
49
|
assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
|
|
50
50
|
assert.equal(identity.batchSourcePublicationVersion, 1, 'built runtime lacks trusted batch source publication');
|
|
51
|
+
assert.equal(identity.batchReferenceContextVersion, 1, 'built runtime lacks batch reference context support');
|
|
51
52
|
}
|
|
52
53
|
finally {
|
|
53
54
|
await runtime?.close();
|
|
@@ -56,5 +57,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
56
57
|
}
|
|
57
58
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
58
59
|
await verifyRuntimeArtifact(process.argv[2] ?? '');
|
|
59
|
-
console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1');
|
|
60
|
+
console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1, batchReferenceContextVersion=1');
|
|
60
61
|
}
|
|
@@ -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) {
|
|
@@ -115,12 +115,18 @@ function validateRecord(record, directoryName) {
|
|
|
115
115
|
originalFiles.reduce((sum, file) => sum + file.size, 0) > WORKSPACE_REMIX_REFERENCE_MAX_BYTES)
|
|
116
116
|
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
117
117
|
}
|
|
118
|
-
async function
|
|
118
|
+
async function verifyReferenceMetadata(root, record, directoryName, alternateDirectory) {
|
|
119
119
|
validateRecord(record, directoryName);
|
|
120
120
|
const target = await safeDirectory(root, record.path);
|
|
121
121
|
const receipt = JSON.parse((await readFileSafely(target, RECEIPT, MANIFEST_MAX_BYTES)).toString('utf8'));
|
|
122
|
-
|
|
122
|
+
const parsedReceipt = workspaceRemixReferenceSchema.parse(receipt);
|
|
123
|
+
if (JSON.stringify(parsedReceipt) !== JSON.stringify(record)
|
|
124
|
+
&& (!alternateDirectory || JSON.stringify(parsedReceipt) !== JSON.stringify({ ...record, directoryName: alternateDirectory })))
|
|
123
125
|
throw new RemixReferenceError('remix_reference_changed');
|
|
126
|
+
return target;
|
|
127
|
+
}
|
|
128
|
+
async function verifyReference(root, record, directoryName, alternateDirectory) {
|
|
129
|
+
const target = await verifyReferenceMetadata(root, record, directoryName, alternateDirectory);
|
|
124
130
|
if ((await readFileSafely(target, ATTRIBUTES, 256)).toString('utf8') !== SNAPSHOT_ATTRIBUTES)
|
|
125
131
|
throw new RemixReferenceError('remix_reference_changed');
|
|
126
132
|
const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map(file => file.path)]);
|
|
@@ -144,6 +150,117 @@ async function verifyReference(root, record, directoryName) {
|
|
|
144
150
|
throw new RemixReferenceError('remix_reference_changed');
|
|
145
151
|
}
|
|
146
152
|
}
|
|
153
|
+
/** Metadata-verified records identify files excluded by the batch source safety policy. */
|
|
154
|
+
export async function batchReferenceMetadata(root) {
|
|
155
|
+
let items;
|
|
156
|
+
try {
|
|
157
|
+
items = await readManifest(root);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
const verified = [];
|
|
163
|
+
for (const record of items) {
|
|
164
|
+
try {
|
|
165
|
+
await verifyReferenceMetadata(root, record, record.directoryName);
|
|
166
|
+
verified.push(record);
|
|
167
|
+
}
|
|
168
|
+
catch { /* An unrelated invalid reference cannot block source capture. */ }
|
|
169
|
+
}
|
|
170
|
+
return verified;
|
|
171
|
+
}
|
|
172
|
+
/** Only verified imported snapshots can retain credential-free .env examples in a batch freeze. */
|
|
173
|
+
export async function batchReferenceEnvExamples(root) {
|
|
174
|
+
const examples = new Set();
|
|
175
|
+
let items;
|
|
176
|
+
try {
|
|
177
|
+
items = await readManifest(root);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return examples;
|
|
181
|
+
}
|
|
182
|
+
for (const record of items) {
|
|
183
|
+
const files = record.files.filter(file => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '')
|
|
184
|
+
&& !isCredentialFile(file.sourcePath));
|
|
185
|
+
if (!files.length)
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
188
|
+
await verifyReference(root, record, record.directoryName);
|
|
189
|
+
for (const file of files)
|
|
190
|
+
examples.add(`${record.path}/${file.path}`);
|
|
191
|
+
}
|
|
192
|
+
catch { /* An unselected damaged reference must not stop freezing the project. */ }
|
|
193
|
+
}
|
|
194
|
+
return examples;
|
|
195
|
+
}
|
|
196
|
+
/** Rebind frozen source references inside a member checkout; content and source identities remain immutable. */
|
|
197
|
+
export async function rebindBatchRemixReferences(root, sourceDirectory, memberDirectory, selected) {
|
|
198
|
+
workspaceDirectoryNameSchema.parse(sourceDirectory);
|
|
199
|
+
workspaceDirectoryNameSchema.parse(memberDirectory);
|
|
200
|
+
const rootReal = await realpath(root);
|
|
201
|
+
let items;
|
|
202
|
+
try {
|
|
203
|
+
items = await readManifest(rootReal);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!selected.length)
|
|
207
|
+
return;
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
for (const reference of selected) {
|
|
211
|
+
const record = items.find(item => item.referenceId === reference.referenceId);
|
|
212
|
+
if (reference.directoryName !== sourceDirectory || !record || record.contentHash !== reference.snapshotHash)
|
|
213
|
+
throw new RemixReferenceError('context_remix_reference_changed');
|
|
214
|
+
}
|
|
215
|
+
if (selected.length) {
|
|
216
|
+
const manifest = JSON.parse((await readFileSafely(rootReal, WORKSPACE_REMIX_REFERENCES_MANIFEST, MANIFEST_MAX_BYTES)).toString('utf8'));
|
|
217
|
+
if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) && selected.some(reference => manifest.batchPolicyWithheldReferenceIds.includes(reference.referenceId)))
|
|
218
|
+
throw new RemixReferenceError('batch_reference_files_withheld');
|
|
219
|
+
}
|
|
220
|
+
// Selected references must be complete. Other records need only valid metadata to be rebound;
|
|
221
|
+
// their contents remain subject to the normal checks when the user later lists or selects them.
|
|
222
|
+
const rebound = [];
|
|
223
|
+
const writable = [];
|
|
224
|
+
for (const record of items) {
|
|
225
|
+
const isSelected = selected.some(reference => reference.referenceId === record.referenceId);
|
|
226
|
+
try {
|
|
227
|
+
if (record.directoryName !== sourceDirectory && record.directoryName !== memberDirectory)
|
|
228
|
+
throw new RemixReferenceError('remix_reference_manifest_invalid');
|
|
229
|
+
const alternate = record.directoryName === sourceDirectory ? memberDirectory : sourceDirectory;
|
|
230
|
+
if (isSelected)
|
|
231
|
+
await verifyReference(rootReal, record, record.directoryName, alternate);
|
|
232
|
+
else
|
|
233
|
+
await verifyReferenceMetadata(rootReal, record, record.directoryName, alternate);
|
|
234
|
+
const next = { ...record, directoryName: memberDirectory };
|
|
235
|
+
rebound.push(next);
|
|
236
|
+
writable.push(next);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (isSelected)
|
|
240
|
+
throw error;
|
|
241
|
+
rebound.push(record);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!items.length)
|
|
245
|
+
return;
|
|
246
|
+
const metadata = await safeDirectory(rootReal, '.playcraft');
|
|
247
|
+
const atomicWrite = async (destination, body) => {
|
|
248
|
+
const temporary = join(metadata, `.batch-reference-${randomUUID()}.tmp`);
|
|
249
|
+
try {
|
|
250
|
+
// shared
|
|
251
|
+
await writeFile(temporary, body, { flag: 'wx', mode: 0o644 });
|
|
252
|
+
await rename(temporary, destination);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await rm(temporary, { force: true });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
for (const record of writable) {
|
|
259
|
+
const target = await safeDirectory(rootReal, record.path);
|
|
260
|
+
await atomicWrite(join(target, RECEIPT), JSON.stringify(record));
|
|
261
|
+
}
|
|
262
|
+
await atomicWrite(join(metadata, 'remix-references.json'), JSON.stringify({ version: 1, items: rebound }));
|
|
263
|
+
}
|
|
147
264
|
/** Reads revalidate the snapshot, so changed/deleted files cannot silently remain attached to a turn. */
|
|
148
265
|
export async function listWorkspaceRemixReferences(root, directoryName = 'basic') {
|
|
149
266
|
workspaceDirectoryNameSchema.parse(directoryName);
|
|
@@ -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
|
+
}
|