@playcraft/cli 0.1.60-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/project-skills/builtin-import.js +2 -2
- package/dist/skill-registry/messages.js +2 -2
- package/dist/skill-registry/platform-package.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +24 -1
- 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 +25 -13
- package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +7 -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 +39 -1
- package/dist/workspace-runtime/server/http.js +25 -3
- 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 +3 -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/skill-execution-context.js +69 -0
- package/dist/workspace-runtime/workspaces/skills.js +15 -5
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +10 -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 {
|
|
@@ -858,9 +873,32 @@ async function executeInterrupt(state, scopeKey, operation, payload) {
|
|
|
858
873
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
859
874
|
}
|
|
860
875
|
const expectedTurnId = asRecord(payload).expectedTurnId;
|
|
876
|
+
const cancelTarget = asRecord(payload).cancelQueuedOperationId;
|
|
861
877
|
if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
|
|
862
878
|
|| binding.activeTurnId !== expectedTurnId))
|
|
863
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' }));
|
|
864
902
|
const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
|
|
865
903
|
...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
|
|
866
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';
|
|
@@ -137,6 +137,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
137
137
|
...(state.native?.scheduledTaskProposalSource ? { scheduledTaskProposalVersion: 1 } : {}),
|
|
138
138
|
parameterAgentVersion: 1,
|
|
139
139
|
directoryConcurrencyVersion: 1,
|
|
140
|
+
skillExecutionPolicyVersion: 1,
|
|
140
141
|
parameterInitializationVersion: 1,
|
|
141
142
|
remixReferencesVersion: 1,
|
|
142
143
|
batchSourceFreezeRevision: 2,
|
|
@@ -707,6 +708,20 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
707
708
|
}
|
|
708
709
|
return;
|
|
709
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
|
+
}
|
|
710
725
|
if (request.method === 'GET' && url.pathname === '/runtime/skills') {
|
|
711
726
|
if (writeDestroyed(state, response))
|
|
712
727
|
return;
|
|
@@ -974,8 +989,15 @@ async function acceptOperation(state, request, response) {
|
|
|
974
989
|
writeJson(response, 400, { error: 'invalid_workspace_directory' });
|
|
975
990
|
return;
|
|
976
991
|
}
|
|
977
|
-
if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
|
|
978
|
-
|
|
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
|
+
}
|
|
979
1001
|
if (state.config.workspaceMode === 'multi')
|
|
980
1002
|
await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
981
1003
|
// Shared classification: a new conversation writes no files, so a batch hold must not refuse it.
|
|
@@ -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,6 +4,7 @@ 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';
|
|
9
10
|
import { WORKSPACE_REMIX_REFERENCES_SKILL } from './remix-reference-skill.js';
|
|
@@ -16,7 +17,8 @@ export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
|
|
|
16
17
|
WORKSPACE_REMIX_REFERENCES_SKILL,
|
|
17
18
|
'playcraft-build-optimizer',
|
|
18
19
|
];
|
|
19
|
-
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);
|
|
20
22
|
export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [...EMPTY_WORKSPACE_REPOSITORY_SKILLS, ...EMPTY_WORKSPACE_PLATFORM_SKILLS];
|
|
21
23
|
/** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
|
|
22
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const requestSchema = z
|
|
3
|
+
.object({
|
|
4
|
+
action: z.enum([
|
|
5
|
+
'describe',
|
|
6
|
+
'preflight',
|
|
7
|
+
'prepare',
|
|
8
|
+
'status',
|
|
9
|
+
'recover',
|
|
10
|
+
'next',
|
|
11
|
+
'run-step',
|
|
12
|
+
'request-correction',
|
|
13
|
+
'capture',
|
|
14
|
+
'review',
|
|
15
|
+
'adopt',
|
|
16
|
+
]),
|
|
17
|
+
runId: z.string().uuid().optional(),
|
|
18
|
+
expectedRevision: z.number().int().nonnegative().optional(),
|
|
19
|
+
input: z.record(z.unknown()).default({}),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
|
22
|
+
export const captureSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
pagePath: z.string(),
|
|
25
|
+
files: z.array(z.string()).min(2).max(256),
|
|
26
|
+
sourcePaths: z.array(z.string()).min(1).max(128),
|
|
27
|
+
views: z
|
|
28
|
+
.array(z.enum(['front', 'right', 'back', 'left', 'hero']))
|
|
29
|
+
.min(3)
|
|
30
|
+
.max(5)
|
|
31
|
+
.default(['front', 'right', 'hero']),
|
|
32
|
+
})
|
|
33
|
+
.strict()
|
|
34
|
+
.refine((input) => new Set(input.views).size === input.views.length && input.views.includes('hero'));
|