@playcraft/cli 0.1.59-beta → 0.1.61-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +55 -0
- package/dist/index.js +21 -3
- package/dist/project-skills/builtin-import.js +2 -2
- package/dist/references/client.js +69 -0
- package/dist/references/messages.js +57 -0
- package/dist/references/protocol.js +32 -0
- package/dist/skill-registry/messages.js +2 -2
- package/dist/skill-registry/platform-package.js +2 -4
- package/dist/workspace-runtime/batch/control.js +3 -3
- package/dist/workspace-runtime/batch/deliver.js +4 -3
- package/dist/workspace-runtime/batch/generate.js +3 -2
- package/dist/workspace-runtime/batch/install-log.js +49 -0
- package/dist/workspace-runtime/batch/prepare.js +29 -13
- package/dist/workspace-runtime/batch/processes.js +86 -14
- package/dist/workspace-runtime/batch/writer-proof.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +52 -2
- package/dist/workspace-runtime/codex/config-toml.js +9 -5
- package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
- package/dist/workspace-runtime/codex/launcher-client.js +46 -24
- package/dist/workspace-runtime/codex/model-broker.js +42 -5
- package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +9 -1
- package/dist/workspace-runtime/server/accept-operation.js +10 -1
- package/dist/workspace-runtime/server/directory-access.js +10 -3
- package/dist/workspace-runtime/server/dispatch.js +40 -1
- package/dist/workspace-runtime/server/http.js +39 -4
- package/dist/workspace-runtime/server/operation-record.js +4 -1
- package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
- package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
- package/dist/workspace-runtime/workspaces/directories.js +4 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +5 -1
- package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
- package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
- package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
- package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
- package/dist/workspace-runtime/workspaces/skills.js +18 -6
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
- package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template-v2/.playcraft/skills.lock.json +14 -0
- package/project-templates/empty/AGENTS.md +19 -0
- package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
|
+
import { preflightSkillExecution } from '../workspaces/skill-execution-context.js';
|
|
3
4
|
import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
|
|
4
5
|
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
5
|
-
import { holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
|
|
6
|
+
import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { z } from 'zod';
|
|
8
9
|
import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -411,6 +412,20 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
411
412
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
412
413
|
}
|
|
413
414
|
const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
415
|
+
try {
|
|
416
|
+
const policy = await preflightSkillExecution(state, { ...operation, payload });
|
|
417
|
+
if (policy?.directoryAccessMode === 'exclusive' && operation.directoryAccessMode !== 'exclusive')
|
|
418
|
+
throw new ComposerContextError('requires-exclusive-turn');
|
|
419
|
+
if (operation.skillExecutionPolicyVersion === 1 && (!policy ||
|
|
420
|
+
(parameterTask.data ? 'exclusive' : policy.directoryAccessMode) !== operation.directoryAccessMode))
|
|
421
|
+
throw new ComposerContextError('context_skills_changed');
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', {
|
|
425
|
+
error: error instanceof Error && /^(?:context_|requires-exclusive-turn)/.test(error.message)
|
|
426
|
+
? error.message : 'context_skill_unavailable',
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
414
429
|
let composer;
|
|
415
430
|
let initializationSkill;
|
|
416
431
|
try {
|
|
@@ -497,6 +512,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
497
512
|
workMode: selection.data.workMode ?? 'execute',
|
|
498
513
|
skillsLockHash: composer.skillsLockHash,
|
|
499
514
|
uiSnapshot: uiSnapshot.data,
|
|
515
|
+
scheduledTaskProposalBridge: typeof body.scheduledTaskProposalBridge === 'string' ? body.scheduledTaskProposalBridge : undefined,
|
|
500
516
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
501
517
|
persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort, permissionMode }) => {
|
|
502
518
|
const selectedEffort = reasoningEffort ?? requestedEffort;
|
|
@@ -857,9 +873,32 @@ async function executeInterrupt(state, scopeKey, operation, payload) {
|
|
|
857
873
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
858
874
|
}
|
|
859
875
|
const expectedTurnId = asRecord(payload).expectedTurnId;
|
|
876
|
+
const cancelTarget = asRecord(payload).cancelQueuedOperationId;
|
|
860
877
|
if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
|
|
861
878
|
|| binding.activeTurnId !== expectedTurnId))
|
|
862
879
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
|
|
880
|
+
// A directory-gated turn has no native turn to interrupt. Cancel only a proven pending
|
|
881
|
+
// claim; an admitted/racing claim still goes through the native interruption contract.
|
|
882
|
+
if (expectedTurnId === undefined && !binding.activeTurnId) {
|
|
883
|
+
const candidates = state.journal.deferredDirectoryOperations().flatMap(pending => {
|
|
884
|
+
const queued = state.journal.getOperationById(pending.operationId);
|
|
885
|
+
return queued?.status === 'queued' && ['session.turn', 'session.review', 'session.compact'].includes(queued.kind)
|
|
886
|
+
&& queued.sessionId === sessionId && queued.workspaceId === operation.workspaceId
|
|
887
|
+
&& (queued.directoryName ?? 'basic') === (operation.directoryName ?? 'basic')
|
|
888
|
+
&& (cancelTarget === undefined || cancelTarget === queued.operationId || cancelTarget === queued.gatewayOperationId)
|
|
889
|
+
&& !queued.nativeTurnId && !queued.nativeRequestId ? [{ pending, queued }] : [];
|
|
890
|
+
});
|
|
891
|
+
if (candidates.length === 1) {
|
|
892
|
+
const { pending, queued } = candidates[0];
|
|
893
|
+
if (await cancelPendingDirectoryAccess(state, `operation:${queued.operationId}`)) {
|
|
894
|
+
await persist(state, pending.scopeKey, transitionOperation(queued, 'interrupted', { error: 'cancelled' }));
|
|
895
|
+
await state.journal.deleteDeferredDirectoryOperation(queued.operationId);
|
|
896
|
+
return persist(state, scopeKey, transitionOperation(transitionOperation(operation, 'accepted'), 'completed'));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (cancelTarget !== undefined && expectedTurnId === undefined)
|
|
901
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
|
|
863
902
|
const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
|
|
864
903
|
...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
|
|
865
904
|
if (result.outcome === 'failed') {
|
|
@@ -22,7 +22,7 @@ import { validateDeliveryArtifact, DeliveryValidationError } from '../workspaces
|
|
|
22
22
|
import { DeliveryStore } from '../workspaces/delivery-store.js';
|
|
23
23
|
import { importWorkspaceLibraryAttachment, listWorkspaceLibraryAttachments, listWorkspaceLibraryImportReceipts, readLibraryAttachmentUpload } from '../workspaces/library-attachments.js';
|
|
24
24
|
import { beginWorkspaceDirectoryAttachment, uploadWorkspaceDirectoryFile, completeWorkspaceDirectoryAttachment, cancelWorkspaceDirectoryAttachment } from '../workspaces/directory-attachments.js';
|
|
25
|
-
import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
25
|
+
import { readWorkspaceSkills, readWorkspaceSkillSnapshot } from '../workspaces/skills.js';
|
|
26
26
|
import { listWorkspaceRemixReferences, RemixReferenceError } from '../workspaces/remix-references.js';
|
|
27
27
|
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
28
28
|
import { join } from 'node:path';
|
|
@@ -134,8 +134,10 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
134
134
|
composerContextVersion: 1,
|
|
135
135
|
scheduledTaskIdentityVersion: 1,
|
|
136
136
|
scheduledTaskCancellationVersion: 1,
|
|
137
|
+
...(state.native?.scheduledTaskProposalSource ? { scheduledTaskProposalVersion: 1 } : {}),
|
|
137
138
|
parameterAgentVersion: 1,
|
|
138
139
|
directoryConcurrencyVersion: 1,
|
|
140
|
+
skillExecutionPolicyVersion: 1,
|
|
139
141
|
parameterInitializationVersion: 1,
|
|
140
142
|
remixReferencesVersion: 1,
|
|
141
143
|
batchSourceFreezeRevision: 2,
|
|
@@ -706,6 +708,20 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
706
708
|
}
|
|
707
709
|
return;
|
|
708
710
|
}
|
|
711
|
+
if (request.method === 'GET' && url.pathname === '/runtime/skills/snapshot') {
|
|
712
|
+
if (writeDestroyed(state, response))
|
|
713
|
+
return;
|
|
714
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
715
|
+
try {
|
|
716
|
+
writeJson(response, 200, await readWorkspaceSkillSnapshot(await workspaceRoot(state, workspaceId, directoryName)));
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
if (error instanceof WorkspaceContextError)
|
|
720
|
+
throw error;
|
|
721
|
+
writeJson(response, 409, { error: 'context_skill_unavailable' });
|
|
722
|
+
}
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
709
725
|
if (request.method === 'GET' && url.pathname === '/runtime/skills') {
|
|
710
726
|
if (writeDestroyed(state, response))
|
|
711
727
|
return;
|
|
@@ -812,6 +828,16 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
812
828
|
writeJson(response, 200, operation);
|
|
813
829
|
return;
|
|
814
830
|
}
|
|
831
|
+
if (request.method === 'POST' && url.pathname === '/runtime/scheduled-task-proposals/source') {
|
|
832
|
+
const body = await readJson(request);
|
|
833
|
+
if (!body || Object.keys(body).some(key => !['bridge', 'threadId', 'turnId', 'toolCallId'].includes(key))
|
|
834
|
+
|| !['bridge', 'threadId', 'turnId', 'toolCallId'].every(key => typeof body[key] === 'string' && Boolean(body[key]))) {
|
|
835
|
+
writeJson(response, 400, { valid: false });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
writeJson(response, 200, { valid: state.native?.scheduledTaskProposalSource?.(body) === true });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
815
841
|
if (request.method === 'POST' && url.pathname === '/runtime/operations') {
|
|
816
842
|
// Track creation before accepting it, so a new unconnected terminal is known not to have spawned.
|
|
817
843
|
terminalHost(state);
|
|
@@ -963,8 +989,15 @@ async function acceptOperation(state, request, response) {
|
|
|
963
989
|
writeJson(response, 400, { error: 'invalid_workspace_directory' });
|
|
964
990
|
return;
|
|
965
991
|
}
|
|
966
|
-
if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
|
|
967
|
-
|
|
992
|
+
if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId && parsed.data.directoryName !== 'root') {
|
|
993
|
+
const workspaceId = parsed.data.workspaceId, directoryName = parsed.data.directoryName ?? 'basic';
|
|
994
|
+
const restoring = state.journal.getWorkspace(workspaceId)?.directories?.some(directory => directory.directoryName === directoryName && directory.status === 'reclaimed') ?? false;
|
|
995
|
+
// Opening a resident checkout only refreshes instructions and can share its background install.
|
|
996
|
+
// Rebuilding a reclaimed checkout must still exclude every existing writer.
|
|
997
|
+
await withDirectoryWrite(state, { workspaceId, directoryName, mode: restoring ? 'exclusive' : 'shared', wait: false }, async () => {
|
|
998
|
+
await restoreWorkspaceDirectory(state, workspaceId, directoryName, restoring);
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
968
1001
|
if (state.config.workspaceMode === 'multi')
|
|
969
1002
|
await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
970
1003
|
// Shared classification: a new conversation writes no files, so a batch hold must not refuse it.
|
|
@@ -977,7 +1010,9 @@ async function acceptOperation(state, request, response) {
|
|
|
977
1010
|
? { snapshot: parsed.data.remixReferenceSnapshot }
|
|
978
1011
|
: parsed.data.kind === 'asset.library.import'
|
|
979
1012
|
? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports }
|
|
980
|
-
: parsed.data.
|
|
1013
|
+
: parsed.data.kind === 'session.turn' && parsed.data.scheduledTaskProposalBridge
|
|
1014
|
+
? { ...asRecord(parsed.data.payload), scheduledTaskProposalBridge: parsed.data.scheduledTaskProposalBridge }
|
|
1015
|
+
: parsed.data.payload;
|
|
981
1016
|
const accept = async () => {
|
|
982
1017
|
const result = await acceptOperationCore(state, parsed.data, {
|
|
983
1018
|
leaseFence: incomingFence,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { workspaceComposerSelectionSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
3
|
/** One construction point for operation records so HTTP and in-sandbox callers cannot drift. */
|
|
3
|
-
export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true) {
|
|
4
|
+
export function buildOperationRecord(state, parsed, payloadHash, leaseFence, executable = true, policy) {
|
|
4
5
|
const now = new Date().toISOString();
|
|
5
6
|
const payload = parsed.payload && typeof parsed.payload === 'object' && !Array.isArray(parsed.payload)
|
|
6
7
|
? parsed.payload
|
|
@@ -8,6 +9,8 @@ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, exe
|
|
|
8
9
|
return {
|
|
9
10
|
operationId: randomUUID(),
|
|
10
11
|
...(parsed.gatewayOperationId ? { gatewayOperationId: parsed.gatewayOperationId } : {}),
|
|
12
|
+
...(policy ? { skillExecutionPolicyVersion: policy.skillExecutionPolicyVersion, directoryAccessMode: policy.directoryAccessMode,
|
|
13
|
+
...workspaceComposerSelectionSchema.parse({ context: payload.context, workMode: payload.workMode }) } : {}),
|
|
11
14
|
...(parsed.kind === 'session.turn' && payload.parameterTask !== undefined ? { directoryAccessMode: 'exclusive' } : {}),
|
|
12
15
|
environment: state.config.environment,
|
|
13
16
|
userId: state.config.userId,
|
|
@@ -5,6 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { launcherSocket, runDeveloperCommand } from './developer-command.js';
|
|
7
7
|
import { adoptDirectoryAccess, holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
|
|
8
|
+
import { stopLauncherExecution } from '../codex/launcher-client.js';
|
|
8
9
|
/** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
|
|
9
10
|
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
10
11
|
const LOG_TAIL_LINES = 50;
|
|
@@ -96,7 +97,7 @@ export async function ensureWorkspaceDependencies(state, workspaceId, root) {
|
|
|
96
97
|
* A lost controller makes the install result unknown. Keep its writer claim until a supervisor
|
|
97
98
|
* confirms that the process group stopped; a closed connection alone cannot prove that.
|
|
98
99
|
*/
|
|
99
|
-
export async function recoverInterruptedInstalls(journal) {
|
|
100
|
+
export async function recoverInterruptedInstalls(journal, recovery) {
|
|
100
101
|
const now = new Date().toISOString();
|
|
101
102
|
for (const record of journal.listWorkspaceDependencies()) {
|
|
102
103
|
if (record.status !== 'installing')
|
|
@@ -108,6 +109,26 @@ export async function recoverInterruptedInstalls(journal) {
|
|
|
108
109
|
finishedAt: now,
|
|
109
110
|
});
|
|
110
111
|
}
|
|
112
|
+
for (const writer of journal.listManagedWrites()) {
|
|
113
|
+
if (writer.stoppedAt || writer.source !== 'background' || writer.executionMode === 'local' || !writer.writeId.startsWith(WRITE_PREFIX))
|
|
114
|
+
continue;
|
|
115
|
+
// As with terminals, the trusted supervisor advances an epoch only after stop-all is confirmed.
|
|
116
|
+
const stoppedEpoch = recovery?.launcherSocket && recovery.identityMatches && recovery.previousRuntimeEpoch !== undefined &&
|
|
117
|
+
recovery.runtimeEpoch > recovery.previousRuntimeEpoch &&
|
|
118
|
+
(writer.runtimeEpoch ?? recovery.previousRuntimeEpoch) < recovery.runtimeEpoch;
|
|
119
|
+
if (!stoppedEpoch) {
|
|
120
|
+
if (!recovery?.identityMatches || !recovery.launcherSocket || !writer.launcher ||
|
|
121
|
+
writer.launcher.processId !== writer.writeId || writer.launcher.runtimeEpoch !== writer.runtimeEpoch)
|
|
122
|
+
continue;
|
|
123
|
+
try {
|
|
124
|
+
await stopLauncherExecution(recovery.launcherSocket, writer.launcher);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await journal.putManagedWrite({ ...writer, stoppedAt: now });
|
|
131
|
+
}
|
|
111
132
|
}
|
|
112
133
|
async function installOnce(state, workspaceId, root, writeId) {
|
|
113
134
|
const startedAt = new Date().toISOString();
|
|
@@ -150,7 +171,9 @@ async function installOnce(state, workspaceId, root, writeId) {
|
|
|
150
171
|
}
|
|
151
172
|
await excludeFromGit(root);
|
|
152
173
|
// A checkpoint waits for this write: node_modules is half written until the install ends.
|
|
153
|
-
|
|
174
|
+
const writer = { writeId, workspaceId, source: 'background', startedAt, runtimeEpoch: state.runtimeEpoch,
|
|
175
|
+
executionMode: launcherSocket(state) ? 'launcher' : 'local' };
|
|
176
|
+
await state.journal.putManagedWrite(writer);
|
|
154
177
|
await state.journal.putWorkspaceDependencies({
|
|
155
178
|
workspaceId,
|
|
156
179
|
status: 'installing',
|
|
@@ -163,7 +186,7 @@ async function installOnce(state, workspaceId, root, writeId) {
|
|
|
163
186
|
const shared = !launcherSocket(state) || (await waitUntilShared(root));
|
|
164
187
|
if (shared) {
|
|
165
188
|
processMayRun = true;
|
|
166
|
-
outcome = await runInstall(state,
|
|
189
|
+
outcome = await runInstall(state, root, writer);
|
|
167
190
|
processStopped = outcome.code !== undefined;
|
|
168
191
|
}
|
|
169
192
|
else
|
|
@@ -172,10 +195,7 @@ async function installOnce(state, workspaceId, root, writeId) {
|
|
|
172
195
|
finally {
|
|
173
196
|
if (!processMayRun || processStopped)
|
|
174
197
|
await state.journal.putManagedWrite({
|
|
175
|
-
writeId,
|
|
176
|
-
workspaceId,
|
|
177
|
-
source: 'background',
|
|
178
|
-
startedAt,
|
|
198
|
+
...(state.journal.getManagedWrite(writeId) ?? writer),
|
|
179
199
|
stoppedAt: new Date().toISOString(),
|
|
180
200
|
});
|
|
181
201
|
}
|
|
@@ -203,22 +223,27 @@ async function installOnce(state, workspaceId, root, writeId) {
|
|
|
203
223
|
* Runs as the development user through the launcher, so the packages belong to the same user as
|
|
204
224
|
* Codex and its own later installs. Without a launcher (local runs and tests) it runs directly.
|
|
205
225
|
*/
|
|
206
|
-
async function runInstall(state,
|
|
226
|
+
async function runInstall(state, root, writer) {
|
|
207
227
|
// Tests replace the install; a real registry is never reached from them.
|
|
208
228
|
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
|
|
209
229
|
const logDir = join(state.config.stateDir, 'dependency-logs');
|
|
210
230
|
// private
|
|
211
231
|
await mkdir(logDir, { recursive: true, mode: 0o700 });
|
|
212
|
-
const logPath = join(logDir, `${workspaceId}.log`);
|
|
232
|
+
const logPath = join(logDir, `${writer.workspaceId}.log`);
|
|
213
233
|
// private
|
|
214
234
|
await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
|
|
215
235
|
let log = '';
|
|
216
236
|
let pending = Promise.resolve();
|
|
217
237
|
const outcome = await runDeveloperCommand(state, {
|
|
218
|
-
processId:
|
|
238
|
+
processId: writer.writeId,
|
|
219
239
|
cwd: root,
|
|
220
240
|
command: planned,
|
|
221
241
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
242
|
+
onStarted: async (identity) => {
|
|
243
|
+
const current = state.journal.getManagedWrite(writer.writeId) ?? writer;
|
|
244
|
+
if (!current.stoppedAt)
|
|
245
|
+
await state.journal.putManagedWrite({ ...current, ...identity });
|
|
246
|
+
},
|
|
222
247
|
onOutput: (chunk) => {
|
|
223
248
|
const text = String(chunk);
|
|
224
249
|
log = (log + text).slice(-64 * 1024);
|
|
@@ -13,97 +13,98 @@ export function launcherSocket(state) {
|
|
|
13
13
|
export async function runDeveloperCommand(state, input) {
|
|
14
14
|
if (input.signal?.aborted)
|
|
15
15
|
return { code: null, timedOut: false, canceled: true };
|
|
16
|
-
const
|
|
16
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
17
|
+
const command = `{ umask 007; ${input.command}; } </dev/null 2>&1`;
|
|
18
|
+
const gate = input.onStarted ? 'IFS= read -r playcraft_start && [ "$playcraft_start" = start ] && ' : '';
|
|
19
|
+
const args = ['-c', `umask 007; ${gate}exec "$BASH" -lc "$1"`, 'playcraft-command', command];
|
|
17
20
|
const socketPath = launcherSocket(state);
|
|
18
21
|
const launched = socketPath
|
|
19
22
|
? await startLauncherProcess({
|
|
20
23
|
socketPath,
|
|
21
24
|
processId: input.processId,
|
|
22
25
|
runtimeEpoch: state.runtimeEpoch,
|
|
23
|
-
argv: ['/usr/bin/bash',
|
|
26
|
+
argv: ['/usr/bin/bash', ...args],
|
|
24
27
|
cwd: input.cwd,
|
|
25
28
|
env: {},
|
|
26
29
|
})
|
|
27
30
|
: undefined;
|
|
28
|
-
launched?.stdin?.end();
|
|
29
31
|
const local = launched
|
|
30
32
|
? undefined
|
|
31
|
-
: spawn('bash',
|
|
33
|
+
: spawn('bash', args, {
|
|
32
34
|
cwd: input.cwd,
|
|
33
35
|
detached: true,
|
|
34
|
-
stdio: ['
|
|
36
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
37
|
});
|
|
36
38
|
const stdout = launched?.stdout ?? local.stdout;
|
|
37
39
|
const stderr = launched?.stderr ?? local.stderr;
|
|
40
|
+
local?.stdin.on('error', () => undefined);
|
|
38
41
|
stdout.on('data', input.onOutput);
|
|
39
42
|
stderr.on('data', input.onOutput);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (launched) {
|
|
48
|
-
void launched.stop().catch(() => undefined);
|
|
49
|
-
return;
|
|
43
|
+
const exited = new Promise(resolve => {
|
|
44
|
+
const finish = (code) => resolve({ kind: 'exit', code });
|
|
45
|
+
if (launched)
|
|
46
|
+
launched.onExit(finish);
|
|
47
|
+
else {
|
|
48
|
+
local.once('error', () => finish(null));
|
|
49
|
+
local.once('close', finish);
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
});
|
|
52
|
+
let stopping;
|
|
53
|
+
const stop = () => stopping ??= (async () => {
|
|
53
54
|
try {
|
|
54
|
-
|
|
55
|
+
if (launched) {
|
|
56
|
+
await launched.stop();
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return !local?.pid || await stopBackgroundWriter(local.pid);
|
|
55
60
|
}
|
|
56
61
|
catch {
|
|
57
|
-
|
|
62
|
+
return false;
|
|
58
63
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
64
|
+
})();
|
|
65
|
+
let timedOut = false;
|
|
66
|
+
let canceled = false;
|
|
67
|
+
let interrupt;
|
|
68
|
+
const interrupted = new Promise(resolve => {
|
|
69
|
+
interrupt = () => { resolve({ kind: 'interrupted' }); void stop(); };
|
|
70
|
+
});
|
|
71
|
+
const abort = () => { canceled = true; interrupt(); };
|
|
72
|
+
const timeout = () => { timedOut = true; interrupt(); };
|
|
69
73
|
input.signal?.addEventListener('abort', abort, { once: true });
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
process.kill(-local.pid, 'SIGKILL');
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
/* already gone */
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}, input.timeoutMs);
|
|
84
|
-
const finish = (value) => {
|
|
85
|
-
clearTimeout(timer);
|
|
86
|
-
if (killTimer)
|
|
87
|
-
clearTimeout(killTimer);
|
|
88
|
-
input.signal?.removeEventListener('abort', abort);
|
|
89
|
-
resolve(value);
|
|
90
|
-
};
|
|
91
|
-
if (launched)
|
|
92
|
-
launched.onExit(finish);
|
|
93
|
-
else {
|
|
94
|
-
local.once('error', () => finish(null));
|
|
95
|
-
local.once('close', (value) => finish(value));
|
|
96
|
-
}
|
|
74
|
+
const timer = setTimeout(timeout, Math.max(0, deadline - Date.now()));
|
|
75
|
+
if (input.signal?.aborted)
|
|
76
|
+
abort();
|
|
77
|
+
if (Date.now() >= deadline)
|
|
78
|
+
timeout();
|
|
79
|
+
const outcome = async (code) => ({
|
|
80
|
+
code: await stop() ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}),
|
|
97
81
|
});
|
|
98
|
-
let stopped = !launched && !local?.pid;
|
|
99
82
|
try {
|
|
100
|
-
if (
|
|
101
|
-
await
|
|
102
|
-
|
|
83
|
+
if (canceled || timedOut)
|
|
84
|
+
return await outcome(null);
|
|
85
|
+
if (input.onStarted) {
|
|
86
|
+
if (launched && !launched.executionIdentity)
|
|
87
|
+
throw new Error('launcher_execution_identity_unavailable');
|
|
88
|
+
const persisted = Promise.resolve().then(() => input.onStarted({ pid: launched?.pid ?? local?.pid,
|
|
89
|
+
...(launched ? { launcher: launched.executionIdentity } : {}) })).then(() => ({ kind: 'persisted' }));
|
|
90
|
+
const ready = await Promise.race([persisted, interrupted, exited]);
|
|
91
|
+
if (ready.kind !== 'persisted')
|
|
92
|
+
return await outcome(ready.kind === 'exit' ? ready.code : null);
|
|
103
93
|
}
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
// A late persistence result must never reopen a canceled or timed-out command gate.
|
|
95
|
+
if (canceled || timedOut)
|
|
96
|
+
return await outcome(null);
|
|
97
|
+
(launched?.stdin ?? local.stdin)?.end(input.onStarted ? 'start\n' : undefined);
|
|
98
|
+
const result = await Promise.race([exited, interrupted]);
|
|
99
|
+
return await outcome(result.kind === 'exit' ? result.code : null);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// The gate never opened. Retain the journal claim if this stop cannot be confirmed.
|
|
103
|
+
await stop();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
input.signal?.removeEventListener('abort', abort);
|
|
106
109
|
}
|
|
107
|
-
catch { /* Transport loss cannot prove the process group has stopped. */ }
|
|
108
|
-
return { code: stopped ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}) };
|
|
109
110
|
}
|
|
@@ -94,7 +94,7 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
/** A reclaimed checkout recovers its existing remote branch; no new variant or moving basic head is used. */
|
|
97
|
-
export async function restoreWorkspaceDirectory(state, workspaceId, directoryName = 'basic') {
|
|
97
|
+
export async function restoreWorkspaceDirectory(state, workspaceId, directoryName = 'basic', allowReclaimedRestore = true) {
|
|
98
98
|
if (directoryName === 'root')
|
|
99
99
|
return;
|
|
100
100
|
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
@@ -106,6 +106,9 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
106
106
|
await ensureWorkspaceArtifactInstructions(await workspaceRoot(state, workspaceId, directoryName));
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
+
// Recheck under the journal lock: a resident-directory caller holds only shared access.
|
|
110
|
+
if (!allowReclaimedRestore)
|
|
111
|
+
throw new Error('directory_busy');
|
|
109
112
|
if (binding.targetKind === 'standalone' || !state.gitCredentials)
|
|
110
113
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
111
114
|
const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
|
|
@@ -4,17 +4,21 @@ import { resolveCliPackageRoot } from '../../scaffold/compose.js';
|
|
|
4
4
|
import { importBuiltinProjectSkills } from '../../project-skills/builtin-import.js';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { canonicalProjectSkillsJson } from '@playcraft/common/project-skills';
|
|
7
|
+
import { WORKSPACE_BUILTIN_SKILLS } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
8
|
import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
|
|
8
9
|
import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
|
|
10
|
+
import { WORKSPACE_REMIX_REFERENCES_SKILL } from './remix-reference-skill.js';
|
|
9
11
|
export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
|
|
10
12
|
'playcraft-skill-recommender',
|
|
11
13
|
'playcraft-image-generation',
|
|
12
14
|
'playcraft-audio-generation',
|
|
13
15
|
'playcraft-image-processing',
|
|
14
16
|
'playcraft-workspace-assets',
|
|
17
|
+
WORKSPACE_REMIX_REFERENCES_SKILL,
|
|
15
18
|
'playcraft-build-optimizer',
|
|
16
19
|
];
|
|
17
|
-
export const EMPTY_WORKSPACE_PLATFORM_SKILLS =
|
|
20
|
+
export const EMPTY_WORKSPACE_PLATFORM_SKILLS = WORKSPACE_BUILTIN_SKILLS
|
|
21
|
+
.filter(skill => skill.defaultOnInitialize).map(skill => skill.name);
|
|
18
22
|
export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [...EMPTY_WORKSPACE_REPOSITORY_SKILLS, ...EMPTY_WORKSPACE_PLATFORM_SKILLS];
|
|
19
23
|
/** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
|
|
20
24
|
export async function scaffoldEmptyWorkspace(root, skillCache, access) {
|
|
@@ -84,8 +84,9 @@ async function request(state, path, init) {
|
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
86
|
* Saves the directory (commit + push), then exports that commit for the channels through the
|
|
87
|
-
* platform, as the delivery panel's Export does. Each channel
|
|
88
|
-
*
|
|
87
|
+
* platform, as the delivery panel's Export does. Each channel uses that panel's fixed HTML or
|
|
88
|
+
* ZIP choice. Other options repeat the channel's latest export of the branch, or stay empty
|
|
89
|
+
* without one. A standalone workspace first becomes a project.
|
|
89
90
|
*/
|
|
90
91
|
export async function startPlatformExport(state, input) {
|
|
91
92
|
await promoteStandalone(state, input);
|
|
@@ -121,7 +122,6 @@ export async function startPlatformExport(state, input) {
|
|
|
121
122
|
branchName: status.branch,
|
|
122
123
|
commitHash: commit,
|
|
123
124
|
platforms: input.channels,
|
|
124
|
-
...(input.format ? { format: input.format } : {}),
|
|
125
125
|
clientRequestId: input.clientRequestId,
|
|
126
126
|
},
|
|
127
127
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { ensureSharedDirectory } from '../shared-write.js';
|
|
5
|
+
import { createHostReviewFiles } from './review-page.js';
|
|
6
|
+
import { REVIEW_DIRECTORY, REVIEW_PAGE, reviewProfileHash, validateRenderReceipt, } from './review-contracts.js';
|
|
7
|
+
import { checkedFile, copySnapshot, dependencyHash, digest, relativeFile, snapshotFiles, verifyFiles, } from './files.js';
|
|
8
|
+
export async function freezeCandidate(root, jobRoot, input, lockedHelpers) {
|
|
9
|
+
if ((!input.review && (!input.pagePath || !input.files.includes(input.pagePath))) ||
|
|
10
|
+
!input.sourcePaths.includes('src/createObjectModel.ts') ||
|
|
11
|
+
input.files.includes('object-sculpt-spec.json') ||
|
|
12
|
+
input.sourcePaths.some((path) => !input.files.includes(path)))
|
|
13
|
+
throw new Error('procedural_skill_missing_candidate_inputs');
|
|
14
|
+
const files = await snapshotFiles(join(jobRoot, 'workspace'), input.files);
|
|
15
|
+
if (input.review) {
|
|
16
|
+
if (!lockedHelpers ||
|
|
17
|
+
input.pagePath ||
|
|
18
|
+
!input.files.includes(input.review.factoryPath) ||
|
|
19
|
+
input.files.some((path) => path.toLowerCase().startsWith(`${REVIEW_DIRECTORY}/`)))
|
|
20
|
+
throw new Error('procedural_skill_invalid_review_input');
|
|
21
|
+
for (const file of createHostReviewFiles(input.review, lockedHelpers))
|
|
22
|
+
files.push({ ...file, hash: digest(file.bytes) });
|
|
23
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
24
|
+
if (files.length > 256 ||
|
|
25
|
+
files.reduce((size, file) => size + file.bytes.length, 0) > 64 * 1024 * 1024)
|
|
26
|
+
throw new Error('procedural_skill_size_limit');
|
|
27
|
+
}
|
|
28
|
+
const id = randomUUID();
|
|
29
|
+
await copySnapshot(root, join(jobRoot, 'candidates', id, 'source'), files);
|
|
30
|
+
const spec = await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json');
|
|
31
|
+
await copySnapshot(root, join(jobRoot, 'candidates', id), [{ path: 'spec.json', bytes: spec }]);
|
|
32
|
+
const hashes = files.map(({ path, hash }) => ({ path, hash }));
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
files: hashes,
|
|
36
|
+
sourcePaths: [...new Set(input.sourcePaths)].sort(),
|
|
37
|
+
sourceHash: digest(JSON.stringify(hashes)),
|
|
38
|
+
dependencyHash: await dependencyHash(root),
|
|
39
|
+
pagePath: relativeFile(input.review ? REVIEW_PAGE : input.pagePath),
|
|
40
|
+
views: input.views,
|
|
41
|
+
specHash: digest(spec),
|
|
42
|
+
expectedSpecHash: digest(spec),
|
|
43
|
+
passId: input.passId,
|
|
44
|
+
...(input.review
|
|
45
|
+
? {
|
|
46
|
+
hostReview: {
|
|
47
|
+
profile: input.review.profile,
|
|
48
|
+
profileHash: reviewProfileHash(input.review.profile),
|
|
49
|
+
helperHash: digest(lockedHelpers.scene),
|
|
50
|
+
profileHelperHash: digest(lockedHelpers.profile),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export const candidateWorkspaceFiles = (candidate) => candidate.hostReview
|
|
57
|
+
? candidate.files.filter((file) => !file.path.startsWith(`${REVIEW_DIRECTORY}/`))
|
|
58
|
+
: candidate.files;
|
|
59
|
+
export async function verifyCandidate(root, jobRoot, candidate) {
|
|
60
|
+
if ((await dependencyHash(root)) !== candidate.dependencyHash)
|
|
61
|
+
throw new Error('procedural_skill_dependencies_changed');
|
|
62
|
+
await verifyFiles(join(jobRoot, 'workspace'), candidateWorkspaceFiles(candidate));
|
|
63
|
+
await verifyFiles(join(jobRoot, 'candidates', candidate.id, 'source'), candidate.files);
|
|
64
|
+
if (digest(await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json')) !==
|
|
65
|
+
candidate.expectedSpecHash ||
|
|
66
|
+
digest(await checkedFile(join(jobRoot, 'candidates', candidate.id), 'spec.json')) !==
|
|
67
|
+
candidate.specHash)
|
|
68
|
+
throw new Error('procedural_skill_spec_changed');
|
|
69
|
+
if (!candidate.captures?.length)
|
|
70
|
+
throw new Error('procedural_skill_captures_missing');
|
|
71
|
+
await verifyFiles(jobRoot, candidate.captures);
|
|
72
|
+
if (candidate.hostReview) {
|
|
73
|
+
if (!candidate.renderReceipt)
|
|
74
|
+
throw new Error('procedural_skill_review_receipt_missing');
|
|
75
|
+
const bytes = await checkedFile(jobRoot, candidate.renderReceipt.path);
|
|
76
|
+
if (digest(bytes) !== candidate.renderReceipt.hash)
|
|
77
|
+
throw new Error('procedural_skill_review_receipt_changed');
|
|
78
|
+
validateRenderReceipt(JSON.parse(bytes.toString('utf8')), candidate.hostReview.profile, candidate.views);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Publish a new immutable source directory; never overwrite the agent's current factory. */
|
|
82
|
+
export async function adoptCandidate(root, jobRoot, candidate, assetId) {
|
|
83
|
+
await verifyCandidate(root, jobRoot, candidate);
|
|
84
|
+
if (candidate.review?.decision !== 'accept' ||
|
|
85
|
+
candidate.review.sourceHash !== candidate.sourceHash)
|
|
86
|
+
throw new Error('procedural_skill_review_required');
|
|
87
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(assetId))
|
|
88
|
+
throw new Error('procedural_skill_invalid_asset_id');
|
|
89
|
+
const relative = `src/generated/${assetId}/${candidate.id}`;
|
|
90
|
+
const target = join(root, relative);
|
|
91
|
+
const expected = candidate.files.filter((file) => candidate.sourcePaths.includes(file.path));
|
|
92
|
+
await ensureSharedDirectory(root, dirname(target));
|
|
93
|
+
const existing = await lstat(target).catch((error) => {
|
|
94
|
+
if (error.code === 'ENOENT')
|
|
95
|
+
return undefined;
|
|
96
|
+
throw error;
|
|
97
|
+
});
|
|
98
|
+
if (existing) {
|
|
99
|
+
if (!existing.isDirectory() || existing.isSymbolicLink())
|
|
100
|
+
throw new Error('procedural_skill_adoption_conflict');
|
|
101
|
+
const expectedPaths = new Set(expected.map((file) => file.path));
|
|
102
|
+
const checkTree = async (directory = '') => {
|
|
103
|
+
for (const item of await readdir(join(target, directory), { withFileTypes: true })) {
|
|
104
|
+
const path = directory ? `${directory}/${item.name}` : item.name;
|
|
105
|
+
if (item.isDirectory() && expected.some((file) => file.path.startsWith(`${path}/`)))
|
|
106
|
+
await checkTree(path);
|
|
107
|
+
else if (!item.isFile() || !expectedPaths.has(path))
|
|
108
|
+
throw new Error('procedural_skill_adoption_conflict');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
await checkTree();
|
|
112
|
+
await verifyFiles(target, expected);
|
|
113
|
+
return { path: relative, sourceHash: candidate.sourceHash };
|
|
114
|
+
}
|
|
115
|
+
const staging = `${target}.${randomUUID()}.pending`;
|
|
116
|
+
try {
|
|
117
|
+
const files = await Promise.all(expected.map(async (file) => ({
|
|
118
|
+
path: file.path,
|
|
119
|
+
bytes: await checkedFile(join(jobRoot, 'candidates', candidate.id, 'source'), file.path),
|
|
120
|
+
})));
|
|
121
|
+
await copySnapshot(root, staging, files);
|
|
122
|
+
await verifyFiles(staging, expected);
|
|
123
|
+
await rename(staging, target);
|
|
124
|
+
return { path: relative, sourceHash: candidate.sourceHash };
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await rm(staging, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|