@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.
Files changed (32) hide show
  1. package/dist/project-skills/builtin-import.js +2 -2
  2. package/dist/skill-registry/messages.js +2 -2
  3. package/dist/skill-registry/platform-package.js +2 -4
  4. package/dist/workspace-runtime/codex/app-server.js +24 -1
  5. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  6. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  7. package/dist/workspace-runtime/codex/launcher-client.js +25 -13
  8. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  9. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  10. package/dist/workspace-runtime/serve.js +7 -1
  11. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  12. package/dist/workspace-runtime/server/directory-access.js +10 -3
  13. package/dist/workspace-runtime/server/dispatch.js +39 -1
  14. package/dist/workspace-runtime/server/http.js +25 -3
  15. package/dist/workspace-runtime/server/operation-record.js +4 -1
  16. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  17. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  18. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  19. package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
  20. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  21. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  22. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  23. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  24. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  25. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  26. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  27. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  28. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  29. package/dist/workspace-runtime/workspaces/skills.js +15 -5
  30. package/package.json +3 -3
  31. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  32. 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
- await withDirectoryWrite(state, { workspaceId: parsed.data.workspaceId, directoryName: parsed.data.directoryName, mode: 'exclusive', wait: false }, () => restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName));
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
- await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
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, workspaceId, root);
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, workspaceId, root) {
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: `${WRITE_PREFIX}${workspaceId}`,
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 command = `{ ${input.command}; } </dev/null 2>&1`;
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', '-lc', command],
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', ['-lc', command], {
33
+ : spawn('bash', args, {
32
34
  cwd: input.cwd,
33
35
  detached: true,
34
- stdio: ['ignore', 'pipe', 'pipe'],
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
- let timedOut = false;
41
- let canceled = false;
42
- let killTimer;
43
- const abort = () => {
44
- if (canceled || timedOut)
45
- return;
46
- canceled = true;
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
- if (!local?.pid)
52
- return;
51
+ });
52
+ let stopping;
53
+ const stop = () => stopping ??= (async () => {
53
54
  try {
54
- process.kill(-local.pid, 'SIGTERM');
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
- /* already gone */
62
+ return false;
58
63
  }
59
- killTimer = setTimeout(() => {
60
- try {
61
- process.kill(-local.pid, 'SIGKILL');
62
- }
63
- catch {
64
- /* already gone */
65
- }
66
- }, 5_000);
67
- killTimer.unref?.();
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 code = await new Promise((resolve) => {
71
- const timer = setTimeout(() => {
72
- timedOut = true;
73
- if (launched)
74
- void launched.stop().catch(() => undefined);
75
- else if (local?.pid) {
76
- try {
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 (launched) {
101
- await launched.stop();
102
- stopped = true;
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
- else if (local?.pid)
105
- stopped = await stopBackgroundWriter(local.pid);
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 = ['playcraft-video-understanding', 'playcraft-replica-validation'];
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 repeats its latest export of the
88
- * branch, or uses the defaults without one. A standalone workspace first becomes a project.
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'));