@playcraft/cli 0.1.44-beta → 0.1.46-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 (25) hide show
  1. package/dist/commands/tools-batch.js +1 -3
  2. package/dist/workspace-runtime/{production → batch}/content-changes.js +6 -12
  3. package/dist/workspace-runtime/batch/deliver.js +6 -1
  4. package/dist/workspace-runtime/batch/freeze.js +1 -1
  5. package/dist/workspace-runtime/batch/generate.js +24 -3
  6. package/dist/workspace-runtime/batch/occupancy.js +1 -1
  7. package/dist/workspace-runtime/batch/prepare.js +2 -0
  8. package/dist/workspace-runtime/server/dispatch.js +17 -8
  9. package/dist/workspace-runtime/server/http.js +9 -24
  10. package/dist/workspace-runtime/server/terminal-host.js +2 -2
  11. package/dist/workspace-runtime/workspaces/cli-credentials.js +6 -2
  12. package/dist/workspace-runtime/workspaces/directories.js +10 -20
  13. package/dist/workspace-runtime/workspaces/directory-maintenance.js +3 -3
  14. package/package.json +3 -3
  15. package/project-template-v2/.playcraft/skills.lock.json +2 -2
  16. package/dist/workspace-runtime/production/contracts.js +0 -19
  17. package/dist/workspace-runtime/production/controller.js +0 -228
  18. package/dist/workspace-runtime/production/dependencies.js +0 -60
  19. package/dist/workspace-runtime/production/native.js +0 -239
  20. package/dist/workspace-runtime/production/processes.js +0 -102
  21. package/dist/workspace-runtime/production/save-command.js +0 -35
  22. package/dist/workspace-runtime/production/source-archive.js +0 -137
  23. package/dist/workspace-runtime/production/source-copy.js +0 -92
  24. package/dist/workspace-runtime/production/stages.js +0 -354
  25. package/dist/workspace-runtime/production/storage.js +0 -75
@@ -15,7 +15,6 @@ const messages = {
15
15
  requestRequired: 'Prompt creation requires a stable --client-request-id; reuse it when retrying the same request.',
16
16
  help: '\nUse --prompt with --workspace-id and a stable --client-request-id. The platform plans and generates independent Remix directories while preserving the source. An omitted count defaults to 3; maximum 10. --input also accepts existing structured drafts.\n',
17
17
  confirmation: 'Generation is queued. Read the batch status to track its results.',
18
- review: 'This existing draft requires baseline approval in the browser.',
19
18
  sync: 'Apply the latest source snapshot to an existing batch',
20
19
  syncDone: 'Batch sync requested',
21
20
  },
@@ -31,7 +30,6 @@ const messages = {
31
30
  requestRequired: '使用 prompt 创建时必须提供稳定的 --client-request-id,重试同一请求时复用该标识。',
32
31
  help: '\n使用 --prompt、--workspace-id 和稳定的 --client-request-id。平台自动规划,在副本中生成独立 Remix,保持来源不变。未指定数量默认 3 个,最多 10 个。--input 也兼容现有结构化草案。\n',
33
32
  confirmation: '生成已排队,可查询批次状态追踪各项成果。',
34
- review: '此历史草案需要在浏览器确认基准。',
35
33
  sync: '将来源最新快照同步到已有批次',
36
34
  syncDone: '已请求同步到批次',
37
35
  },
@@ -69,7 +67,7 @@ export function registerBatchCommands(tools) {
69
67
  sourceWorkspaceId: opts.workspaceId, sourceDirectoryName: opts.sourceDirectory,
70
68
  ...(opts.teamId ? { teamId: opts.teamId } : {}),
71
69
  });
72
- console.log(opts.json ? JSON.stringify(result, null, 2) : `${t.created}: ${result.batchId}\n${result.manifest.autoStart ? t.confirmation : t.review}`);
70
+ console.log(opts.json ? JSON.stringify(result, null, 2) : `${t.created}: ${result.batchId}\n${t.confirmation}`);
73
71
  }
74
72
  catch (error) {
75
73
  handleError(error);
@@ -1,6 +1,4 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- const execute = promisify(execFile);
1
+ import { gitRead } from './git.js';
4
2
  const reference = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
5
3
  const excludedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'docs', 'test', 'tests', '__tests__']);
6
4
  const contentExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|s[ac]ss|less|json5?|ya?ml|xml|glsl|wgsl|vert|frag|lua|py|svg|png|jpe?g|webp|avif|gif|bmp|mp[34]|wav|ogg|m4a|webm|glb|gltf|fbx|obj|bin|atlas|woff2?|ttf|otf)$/i;
@@ -13,15 +11,11 @@ function contentPath(path) {
13
11
  return false;
14
12
  return contentExtension.test(name);
15
13
  }
16
- /** A completed native turn is useful only when code, runtime configuration, or assets changed. */
17
- export async function requireProductionContentChanges(root, before, after) {
14
+ /** A completed batch member must change code, runtime configuration, or assets. */
15
+ export async function requireBatchContentChanges(root, before, after) {
18
16
  if (!reference.test(before) || !reference.test(after))
19
- throw new Error('production_change_reference_invalid');
20
- const { stdout } = await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root,
21
- 'diff', '--raw', '--no-abbrev', '--no-renames', '-z', before, after, '--'], {
22
- timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
23
- env: { PATH: process.env.PATH, HOME: root, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
24
- });
17
+ throw new Error('batch_change_reference_invalid');
18
+ const stdout = await gitRead(root, ['diff', '--raw', '--no-abbrev', '--no-renames', '-z', before, after, '--']);
25
19
  const records = stdout.split('\0'), paths = [];
26
20
  for (let index = 0; index + 1 < records.length; index += 2) {
27
21
  const [oldMode, newMode, oldBlob, newBlob] = records[index].slice(1).split(' ');
@@ -31,6 +25,6 @@ export async function requireProductionContentChanges(root, before, after) {
31
25
  paths.push(path);
32
26
  }
33
27
  if (!paths.length)
34
- throw new Error('production_generation_no_content_change');
28
+ throw new Error('batch_generation_no_content_change');
35
29
  return paths;
36
30
  }
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { hashOperationPayload, sandboxBatchMemberDeliverSchema } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { findExternalLocalReferences } from '../previews/single-file.js';
6
6
  import { workspaceRoot } from '../workspaces/registry.js';
7
+ import { requireBatchContentChanges } from './content-changes.js';
7
8
  import { gitRead, gitWrite } from './git.js';
8
9
  import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
9
10
  import { filesystemObjectStore, memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
@@ -62,12 +63,15 @@ export async function deliverBatchMember(state, body, options) {
62
63
  directoryName: command.directoryName, phase: 'deliver', state: 'running', writersStopped: false, holding: true, stopRequested: false,
63
64
  processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId, commandHash: { ...existing?.commandHash, deliver: hash },
64
65
  snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, updatedAt: new Date().toISOString(),
66
+ sourceCommit: existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit,
67
+ beforeTree: existing?.beforeTree,
65
68
  });
66
69
  const buildRoot = join(state.config.stateDir, 'batch-builds', `${command.runId}-${randomUUID()}`);
67
70
  try {
68
71
  const savedCommit = command.fromCommit ?? await saveExact(root);
69
72
  if (command.fromCommit)
70
73
  await gitRead(root, ['cat-file', '-e', `${command.fromCommit}^{commit}`]);
74
+ await requireBatchContentChanges(root, record.sourceCommit ?? '', savedCommit);
71
75
  if (await readStopMarker(state, command.runId))
72
76
  return memberStatus(await markStopped(state, command, record));
73
77
  let pushedCommit;
@@ -79,7 +83,7 @@ export async function deliverBatchMember(state, body, options) {
79
83
  catch {
80
84
  throw new Error('batch_push_failed');
81
85
  }
82
- const remote = (await gitRead(root, ['ls-remote', 'origin', `refs/heads/${branch}`])).slice(0, savedCommit.length);
86
+ const remote = (await gitWrite(root, ['ls-remote', 'origin', `refs/heads/${branch}`])).slice(0, savedCommit.length);
83
87
  if (remote !== savedCommit)
84
88
  throw new Error('batch_push_failed');
85
89
  pushedCommit = savedCommit;
@@ -146,5 +150,6 @@ async function markStopped(state, command, existing) {
146
150
  holding: false, stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
147
151
  headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
148
152
  branchName: existing?.branchName, updatedAt: new Date().toISOString(),
153
+ sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
149
154
  });
150
155
  }
@@ -35,7 +35,7 @@ async function pushHiddenRef(cache, sourceRoot, remote, commit, ref) {
35
35
  catch {
36
36
  throw new Error('batch_source_push_failed');
37
37
  }
38
- const remoteHash = (await gitRead(cache, ['ls-remote', remote, ref]).catch(() => '')).slice(0, commit.length);
38
+ const remoteHash = (await gitWrite(cache, ['ls-remote', remote, ref]).catch(() => '')).slice(0, commit.length);
39
39
  if (remoteHash !== commit)
40
40
  throw new Error('batch_source_push_failed');
41
41
  }
@@ -5,6 +5,8 @@ import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { hashOperationPayload, operationScopeKey, sandboxBatchMemberGenerateSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { attachNativeCollector, executeOperation } from '../server/dispatch.js';
7
7
  import { workspaceRoot } from '../workspaces/registry.js';
8
+ import { snapshotWorkspaceTree } from '../workspaces/git.js';
9
+ import { requireBatchContentChanges } from './content-changes.js';
8
10
  import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
9
11
  import { groupExited, stopProcessGroup } from './processes.js';
10
12
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
@@ -83,12 +85,15 @@ export async function generateBatchMember(state, body) {
83
85
  const prior = state.journal.getOperation(turnKey.scopeKey);
84
86
  if (prior && prior.payloadHash !== turnKey.record.payloadHash)
85
87
  throw new Error('batch_command_conflict');
88
+ const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
89
+ const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
86
90
  let record = await writeMember(state, {
87
91
  runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
88
92
  directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
89
93
  writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
90
94
  operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
91
95
  snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
96
+ sourceCommit, beforeTree,
92
97
  updatedAt: new Date().toISOString(),
93
98
  });
94
99
  if (prior)
@@ -172,8 +177,7 @@ async function finishGenerate(state, record, started, root, early) {
172
177
  return writeMember(state, { ...record, state: 'stopping', writersStopped: false, holding: true, operationId: started.operationId });
173
178
  if (await readStopMarker(state, record.runId))
174
179
  return stoppedRecord(state, record, record);
175
- const head = await import('./git.js').then(git => git.gitRead(root, ['rev-parse', 'HEAD'])).then(value => value.trim()).catch(() => record.headCommit);
176
- return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, operationId: started.operationId, headCommit: head, error: undefined });
180
+ return completeGeneration(state, { ...record, operationId: started.operationId }, root);
177
181
  }
178
182
  const writersStopped = await proveStopped(record);
179
183
  return writeMember(state, {
@@ -187,11 +191,27 @@ async function reconcileGenerate(state, record) {
187
191
  if (record.state === 'unknown' && session?.turnStatus === 'completed') {
188
192
  const writersStopped = await proveStopped(record);
189
193
  if (writersStopped && !await readStopMarker(state, record.runId)) {
190
- return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, error: undefined });
194
+ const root = await workspaceRoot(state, record.workspaceId, record.directoryName);
195
+ return completeGeneration(state, record, root);
191
196
  }
192
197
  }
193
198
  return record;
194
199
  }
200
+ async function completeGeneration(state, record, root) {
201
+ try {
202
+ const afterTree = await snapshotWorkspaceTree(root);
203
+ await requireBatchContentChanges(root, record.beforeTree ?? '', afterTree);
204
+ await requireBatchContentChanges(root, record.sourceCommit ?? '', afterTree);
205
+ }
206
+ catch (error) {
207
+ const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'batch_generation_failed';
208
+ return writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: code });
209
+ }
210
+ if (await readStopMarker(state, record.runId))
211
+ return stoppedRecord(state, record, record);
212
+ const head = await import('./git.js').then(git => git.gitRead(root, ['rev-parse', 'HEAD'])).then(value => value.trim()).catch(() => record.headCommit);
213
+ return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, headCommit: head, error: undefined });
214
+ }
195
215
  async function stoppedRecord(state, command, existing) {
196
216
  if (existing)
197
217
  await proveStopped(existing);
@@ -201,5 +221,6 @@ async function stoppedRecord(state, command, existing) {
201
221
  stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
202
222
  headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
203
223
  branchName: existing?.branchName, updatedAt: new Date().toISOString(),
224
+ sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
204
225
  });
205
226
  }
@@ -1,6 +1,6 @@
1
1
  import { listMembers } from './records.js';
2
2
  /** True while a member Run still holds the directory. Other sandbox write entries must refuse it. */
3
- export async function batchDirectoryHeld(state, workspaceId, directoryName) {
3
+ export async function batchDirectoryHeld(state, workspaceId, directoryName = 'basic') {
4
4
  if (!workspaceId)
5
5
  return false;
6
6
  const members = await listMembers(state);
@@ -140,6 +140,7 @@ export async function prepareBatchMember(state, body, options) {
140
140
  workspaceId: command.workspaceId, directoryName: command.directoryName, phase: 'prepare', state: 'running',
141
141
  writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [],
142
142
  commandHash: { ...existing?.commandHash, prepare: hash }, snapshotDigest: snapshot.digest, branchName: command.branchName,
143
+ sourceCommit: snapshot.commitHash, beforeTree: existing?.beforeTree,
143
144
  updatedAt: new Date().toISOString(),
144
145
  });
145
146
  const parent = await workspaceContainerRoot(state, command.workspaceId);
@@ -197,6 +198,7 @@ async function sealStopped(state, command, existing) {
197
198
  state: 'stopped', writersStopped: true, holding: false, stopRequested: true, processes: existing?.processes ?? [],
198
199
  sessionId: existing?.sessionId, operationId: existing?.operationId, headCommit: existing?.headCommit, evidence: existing?.evidence,
199
200
  commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName,
201
+ sourceCommit: existing?.sourceCommit, beforeTree: existing?.beforeTree,
200
202
  updatedAt: new Date().toISOString(),
201
203
  });
202
204
  }
@@ -5,7 +5,7 @@ import { shareWorkspaceGit } from '../workspaces/shared-git.js';
5
5
  import { join } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
8
- import { closeCliCredentials } from '../workspaces/cli-credentials.js';
8
+ import { closeCliCredentials, ensureCliCredentials } from '../workspaces/cli-credentials.js';
9
9
  import { artifactFormat } from '../workspaces/artifact-files.js';
10
10
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
11
11
  import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
@@ -27,7 +27,7 @@ import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces
27
27
  import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
28
28
  import { spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
29
29
  import { wakeSourceEventUploader } from './source-uploader.js';
30
- import { productionDirectoryBusy } from '../production/controller.js';
30
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
31
31
  import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
32
32
  import { importWorkspaceLibraryAssets } from '../workspaces/asset-library-import.js';
33
33
  import { createWorkspaceDirectory, WorkspaceDirectoryCreateError } from '../workspaces/directory-create.js';
@@ -174,7 +174,7 @@ async function executeRemixReferenceImport(state, scopeKey, operation, payload)
174
174
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_remix_reference' }));
175
175
  if (workspaceSessions(state, operation.workspaceId, operation.directoryName).some(sessionTurnBusy))
176
176
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
177
- if (await productionDirectoryBusy(state, operation.workspaceId, operation.directoryName))
177
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
178
178
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'batch_workspace_readonly' }));
179
179
  try {
180
180
  const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
@@ -374,6 +374,15 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
374
374
  clientRequestId: operation.clientRequestId, sourceDirectoryName: binding.directoryName ?? 'basic',
375
375
  ...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}`
376
376
  : '';
377
+ if (state.config.workspaceMode === 'multi') {
378
+ try {
379
+ // The control service has adopted this epoch before dispatching the turn.
380
+ await ensureCliCredentials(state);
381
+ }
382
+ catch {
383
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'cli_credentials_unavailable' }));
384
+ }
385
+ }
377
386
  // Taken before the native turn exists, so nothing the agent writes can precede it.
378
387
  await beginTurnChanges(state, sessionId, {
379
388
  root, workspaceId: operation.workspaceId, directoryName: binding.directoryName ?? 'basic',
@@ -589,7 +598,7 @@ async function executeCompact(state, scopeKey, operation, payload) {
589
598
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'session_workspace_mismatch' }));
590
599
  if (sessionTurnBusy(binding))
591
600
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
592
- if (await productionDirectoryBusy(state, operation.workspaceId, operation.directoryName))
601
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
593
602
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'batch_workspace_readonly' }));
594
603
  const adapter = native(state);
595
604
  if (!adapter.startCompact)
@@ -644,7 +653,7 @@ async function executeSteer(state, scopeKey, operation, payload) {
644
653
  if (state.journal.listOperations().some(({ operation: other }) => other.kind === 'session.steer'
645
654
  && other.sessionId === sessionId && other.expectedTurnId === parsed.data.expectedTurnId && other.status === 'uncertain'))
646
655
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'steer_receipt_uncertain' }));
647
- if (await productionDirectoryBusy(state, operation.workspaceId, operation.directoryName))
656
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
648
657
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'batch_workspace_readonly' }));
649
658
  const adapter = native(state);
650
659
  if (!adapter.steer)
@@ -753,7 +762,7 @@ async function executeDirectoryCreate(state, scopeKey, operation, payload) {
753
762
  if (!parsed.success)
754
763
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_path' }));
755
764
  try {
756
- if (await productionDirectoryBusy(state, operation.workspaceId, operation.directoryName))
765
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
757
766
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'batch_workspace_readonly' }));
758
767
  const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
759
768
  const created = await createWorkspaceDirectory(root, parsed.data);
@@ -781,7 +790,7 @@ async function executeAssetLibraryImport(state, scopeKey, operation, payload) {
781
790
  const assertCurrent = async () => {
782
791
  if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed())
783
792
  throw new Error('stale_lease');
784
- if (await productionDirectoryBusy(state, operation.workspaceId, operation.directoryName))
793
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
785
794
  throw new Error('batch_workspace_readonly');
786
795
  };
787
796
  await assertCurrent();
@@ -824,7 +833,7 @@ async function executeFileBinaryWrite(state, scopeKey, operation, payload) {
824
833
  return state.journal.exclusive(`file:${scope ?? 'single'}:${input.path}`, async () => {
825
834
  try {
826
835
  // Recheck after waiting for the workspace/file locks.
827
- if (await productionDirectoryBusy(state, operation.workspaceId, operation.directoryName))
836
+ if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
828
837
  throw Object.assign(new Error('batch_workspace_readonly'), { code: 'batch_workspace_readonly' });
829
838
  const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
830
839
  const observed = await observeWorkspaceBinaryFile(root, input.path);
@@ -24,7 +24,7 @@ import { listWorkspaceRemixReferences, RemixReferenceError } from '../workspaces
24
24
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
25
25
  import { join } from 'node:path';
26
26
  import { terminalHost } from './terminal-host.js';
27
- import { productionCapability, productionDirectoryBusy, executeSandboxProduction, sandboxProductionReceipt } from '../production/controller.js';
27
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
28
28
  import { dispatchBatchRoute } from '../batch/routes.js';
29
29
  import { cleanupWorkspaceDirectories, syncWorkspaceDirectories } from '../workspaces/directory-maintenance.js';
30
30
  import { registerWorkspaceDirectory, restoreWorkspaceDirectory } from '../workspaces/directories.js';
@@ -56,21 +56,6 @@ export async function handleRuntimeRequest(state, request, response) {
56
56
  writeJson(response, batch.status, batch.body);
57
57
  return;
58
58
  }
59
- if (request.method === 'GET' && url.pathname === '/runtime/production/capability') {
60
- writeJson(response, 200, await productionCapability(state));
61
- return;
62
- }
63
- if (request.method === 'POST' && ['/runtime/production/stages', '/runtime/production/stop', '/runtime/production/reconcile'].includes(url.pathname)) {
64
- writeJson(response, 200, await executeSandboxProduction(state, await readJson(request)));
65
- return;
66
- }
67
- if (request.method === 'GET' && url.pathname.startsWith('/runtime/production/stages/')) {
68
- writeJson(response, 200, await sandboxProductionReceipt(state, decodeURIComponent(url.pathname.slice('/runtime/production/stages/'.length)), {
69
- workspaceId: url.searchParams.get('workspaceId') ?? '', directoryName, taskId: url.searchParams.get('taskId') ?? '',
70
- attemptId: url.searchParams.get('attemptId') ?? '', owner: url.searchParams.get('owner') ?? '', fence: Number(url.searchParams.get('fence')),
71
- }));
72
- return;
73
- }
74
59
  if (request.method === 'GET' && url.pathname === '/runtime/health') {
75
60
  writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
76
61
  return;
@@ -143,7 +128,7 @@ export async function handleRuntimeRequest(state, request, response) {
143
128
  }
144
129
  try {
145
130
  const restored = await state.journal.exclusive(`directory:${scope.data.workspaceId}:${directoryName}`, async () => {
146
- if (await productionDirectoryBusy(state, scope.data.workspaceId, directoryName))
131
+ if (await batchDirectoryHeld(state, scope.data.workspaceId, directoryName))
147
132
  throw new SessionRestoreError('batch_workspace_readonly');
148
133
  return state.journal.exclusive(`session:${scope.data.sessionId}`, () => state.journal.exclusive(`workspace:${revisionScope(state, scope.data.workspaceId, directoryName) ?? 'single'}`, () => restoreNativeSession(state, scope.data, parsed.data)));
149
134
  });
@@ -277,7 +262,7 @@ export async function handleRuntimeRequest(state, request, response) {
277
262
  importReceipts: await listWorkspaceLibraryImportReceipts(root, sessionId) });
278
263
  }
279
264
  else {
280
- if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
265
+ if (await batchDirectoryHeld(state, workspaceId, directoryName)) {
281
266
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
282
267
  return;
283
268
  }
@@ -303,7 +288,7 @@ export async function handleRuntimeRequest(state, request, response) {
303
288
  if (writeDestroyed(state, response))
304
289
  return;
305
290
  const workspaceId = url.searchParams.get('workspaceId') ?? '';
306
- if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
291
+ if (await batchDirectoryHeld(state, workspaceId, directoryName)) {
307
292
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
308
293
  return;
309
294
  }
@@ -337,7 +322,7 @@ export async function handleRuntimeRequest(state, request, response) {
337
322
  if (writeDestroyed(state, response))
338
323
  return;
339
324
  const workspaceId = url.searchParams.get('workspaceId') ?? '';
340
- if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
325
+ if (await batchDirectoryHeld(state, workspaceId, directoryName)) {
341
326
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
342
327
  return;
343
328
  }
@@ -354,7 +339,7 @@ export async function handleRuntimeRequest(state, request, response) {
354
339
  if (writeDestroyed(state, response))
355
340
  return;
356
341
  const workspaceId = url.searchParams.get('workspaceId') ?? '';
357
- if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
342
+ if (await batchDirectoryHeld(state, workspaceId, directoryName)) {
358
343
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
359
344
  return;
360
345
  }
@@ -767,7 +752,7 @@ async function acceptOperation(state, request, response) {
767
752
  await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
768
753
  const mutation = isWorkspaceMutation(parsed.data.kind);
769
754
  const occupiesDirectory = mutation || parsed.data.kind === 'session.compact';
770
- if (occupiesDirectory && await productionDirectoryBusy(state, parsed.data.workspaceId, parsed.data.directoryName)) {
755
+ if (occupiesDirectory && await batchDirectoryHeld(state, parsed.data.workspaceId, parsed.data.directoryName)) {
771
756
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
772
757
  return;
773
758
  }
@@ -909,7 +894,7 @@ async function acceptOperation(state, request, response) {
909
894
  writeJson(response, executed.status === 'failed' ? 409 : 202, executed);
910
895
  });
911
896
  const acceptBound = async () => {
912
- if (occupiesDirectory && await productionDirectoryBusy(state, parsed.data.workspaceId, parsed.data.directoryName)) {
897
+ if (occupiesDirectory && await batchDirectoryHeld(state, parsed.data.workspaceId, parsed.data.directoryName)) {
913
898
  writeJson(response, 409, { error: 'batch_workspace_readonly' });
914
899
  return;
915
900
  }
@@ -929,7 +914,7 @@ async function acceptOperation(state, request, response) {
929
914
  else
930
915
  await accept();
931
916
  };
932
- // Production admission and maintenance use this same lock before checking durable occupancy.
917
+ // Directory maintenance uses this same lock before checking durable occupancy.
933
918
  if (occupiesDirectory && state.config.workspaceMode === 'multi')
934
919
  await state.journal.exclusive(`directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
935
920
  else
@@ -1,4 +1,4 @@
1
- import { productionDirectoryBusy } from '../production/controller.js';
1
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
2
2
  import { createRequire } from 'node:module';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { lstat } from 'node:fs/promises';
@@ -67,7 +67,7 @@ export class TerminalHost {
67
67
  rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
68
68
  return;
69
69
  }
70
- if (await productionDirectoryBusy(this.state, terminal.workspaceId, terminal.directoryName)) {
70
+ if (await batchDirectoryHeld(this.state, terminal.workspaceId, terminal.directoryName)) {
71
71
  rejectUpgrade(socket, 409, { error: 'batch_workspace_readonly' });
72
72
  return;
73
73
  }
@@ -24,7 +24,7 @@ export async function ensureCliCredentials(state) {
24
24
  entry = { closed: false, expiresAt: 0, epoch: -1 };
25
25
  entries.set(state, entry);
26
26
  }
27
- if (entry.closed)
27
+ if (entry.closed || state.journal.destroyed())
28
28
  throw new Error('cli_credentials_unavailable');
29
29
  if (entry.epoch === state.runtimeEpoch && entry.expiresAt > Date.now() + 60_000)
30
30
  return;
@@ -33,6 +33,9 @@ export async function ensureCliCredentials(state) {
33
33
  entry.pending = undefined;
34
34
  });
35
35
  await entry.pending;
36
+ if (entry.closed || state.journal.destroyed() || entry.epoch !== state.runtimeEpoch
37
+ || entry.expiresAt <= Date.now() + 60_000)
38
+ throw new Error('cli_credentials_unavailable');
36
39
  }
37
40
  async function renew(state, entry) {
38
41
  const config = state.config;
@@ -92,7 +95,8 @@ async function renew(state, entry) {
92
95
  if (!entry.closed && !state.journal.destroyed()) {
93
96
  if (entry.timer)
94
97
  clearTimeout(entry.timer);
95
- const wait = entry.expiresAt > Date.now() + 60_000 ? entry.expiresAt - Date.now() - 59_000 : 30_000;
98
+ const wait = entry.epoch === state.runtimeEpoch && entry.expiresAt > Date.now() + 60_000
99
+ ? entry.expiresAt - Date.now() - 59_000 : 30_000;
96
100
  entry.timer = setTimeout(() => void ensureCliCredentials(state).catch(() => undefined), wait);
97
101
  entry.timer.unref();
98
102
  }
@@ -12,16 +12,13 @@ import { revisionScope } from './registry.js';
12
12
  import { ensureWorkspaceSkills } from './skills.js';
13
13
  import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
14
14
  import { ensureWorkspaceArtifactInstructions } from './instructions.js';
15
- import { checkoutProductionSourceArchive } from '../production/source-archive.js';
16
15
  import { shareWorkspaceRepository } from './git.js';
17
16
  const execute = promisify(execFile);
18
17
  /** Trusted Runtime registration restores immutable input; it never follows basic's current branch head. */
19
- export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName, sourceArchive) {
18
+ export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName) {
20
19
  const directory = workspaceDirectoryRegistrationSchema.parse(input);
21
20
  if (directory.directoryName === 'basic')
22
21
  throw new WorkspaceContextError('workspace_directory_binding_conflict');
23
- if (sourceArchive && (sourceArchive.snapshot.sourceWorkspaceId !== directory.workspaceId || sourceArchive.snapshot.commitHash !== directory.sourceCommit))
24
- throw new WorkspaceContextError('workspace_directory_binding_conflict');
25
22
  return state.journal.exclusive(`directory:${directory.workspaceId}:${directory.directoryName}`, async () => {
26
23
  if (state.journal.destroyed())
27
24
  throw new WorkspaceContextError('runtime_destroyed');
@@ -51,22 +48,15 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
51
48
  throw new WorkspaceContextError('workspace_directory_source_commit_required');
52
49
  const staging = join(parent, `.directory-${randomUUID()}`);
53
50
  try {
54
- if (sourceArchive) {
55
- if (!sourceArchive.body)
56
- throw new WorkspaceContextError('production_source_archive_required');
57
- await checkoutProductionSourceArchive({ snapshot: sourceArchive.snapshot, body: sourceArchive.body, destination: staging });
58
- }
59
- else {
60
- const source = await workspaceRoot(state, directory.workspaceId, sourceDirectoryName ?? 'basic');
61
- await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
62
- if (sourceDirectoryName !== undefined) {
63
- const frozen = await freezeBatchCommit({ source: { workspaceId: directory.workspaceId, root: source },
64
- commitHash: directory.sourceCommit, snapshotId: `directory-${directory.remixId}` });
65
- await checkoutBatchCommit({ source: { workspaceId: directory.workspaceId, root: source }, frozen, destination: staging });
66
- }
67
- else
68
- await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
51
+ const source = await workspaceRoot(state, directory.workspaceId, sourceDirectoryName ?? 'basic');
52
+ await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
53
+ if (sourceDirectoryName !== undefined) {
54
+ const frozen = await freezeBatchCommit({ source: { workspaceId: directory.workspaceId, root: source },
55
+ commitHash: directory.sourceCommit, snapshotId: `directory-${directory.remixId}` });
56
+ await checkoutBatchCommit({ source: { workspaceId: directory.workspaceId, root: source }, frozen, destination: staging });
69
57
  }
58
+ else
59
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
70
60
  await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
71
61
  await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
72
62
  await git(staging, 'config', 'user.name', 'PlayCraft');
@@ -90,7 +80,7 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
90
80
  const latest = state.journal.getWorkspace(directory.workspaceId) ?? binding;
91
81
  await state.journal.putWorkspace({ ...latest, directories: [...(latest.directories ?? []).filter(item => item.directoryName !== directory.directoryName), { ...directory, status: 'ready' }] });
92
82
  });
93
- if (state.gitCredentials && !sourceArchive)
83
+ if (state.gitCredentials)
94
84
  await state.gitCredentials.configure(state.journal.getWorkspace(directory.workspaceId) ?? binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
95
85
  return directory;
96
86
  });
@@ -4,14 +4,14 @@ import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { promisify } from 'node:util';
6
6
  import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
7
- import { productionDirectoryBusy } from '../production/controller.js';
7
+ import { batchDirectoryHeld } from '../batch/occupancy.js';
8
8
  import { workspaceRoot, workspaceSessions } from './registry.js';
9
9
  const execute = promisify(execFile);
10
10
  const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
11
11
  timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
12
12
  })).stdout.trim();
13
- export async function workspaceDirectoryOccupied(state, workspaceId, directoryName, excludeAttemptId, ignoreSourceCaptures = false) {
14
- if (await productionDirectoryBusy(state, workspaceId, directoryName, excludeAttemptId, ignoreSourceCaptures))
13
+ export async function workspaceDirectoryOccupied(state, workspaceId, directoryName) {
14
+ if (await batchDirectoryHeld(state, workspaceId, directoryName))
15
15
  return true;
16
16
  if (state.journal.listOperations().some(({ operation }) => operation.workspaceId === workspaceId
17
17
  && (operation.directoryName ?? 'basic') === directoryName && ['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.44-beta",
3
+ "version": "0.1.46-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,8 +51,8 @@
51
51
  "@gltf-transform/core": "^4.3.0",
52
52
  "@gltf-transform/extensions": "^4.3.0",
53
53
  "@gltf-transform/functions": "^4.3.0",
54
- "@playcraft/build": "^0.1.44-beta",
55
- "@playcraft/common": "^0.1.44-beta",
54
+ "@playcraft/build": "^0.1.46-beta",
55
+ "@playcraft/common": "^0.1.46-beta",
56
56
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
57
57
  "chokidar": "^4.0.3",
58
58
  "commander": "^13.1.0",
@@ -66,8 +66,8 @@
66
66
  },
67
67
  {
68
68
  "skillRef": "platform/playcraft-project-management",
69
- "version": "1.0.0",
70
- "artifactHash": "e7e4bffe80e0737362a5cac4234056a7d7fb048f0c8b5e41041e61ee914495e3",
69
+ "version": "1.1.0",
70
+ "artifactHash": "8653eefd40bc3b5a84728522f778515332c2334e64d90407ce2f8846a1f72e98",
71
71
  "surface": "portal-agent",
72
72
  "mountPath": ".playcraft/skills/platform__playcraft-project-management"
73
73
  },
@@ -1,19 +0,0 @@
1
- import { z } from 'zod';
2
- import { workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
3
- export const productionStageNames = ['capture-source', 'prepare', 'generate', 'freeze', 'save', 'build', 'preview'];
4
- export const productionExecutionRequestSchema = z.object({
5
- action: z.enum(['stage', 'stop', 'reconcile']),
6
- task: z.object({ taskId: z.string().uuid(), attemptId: z.string().uuid(), batchId: z.string().uuid(), workspaceId: z.string().uuid(),
7
- environment: z.string().min(1), userId: z.string().min(1), payload: z.record(z.unknown()), }).passthrough(),
8
- lease: z.object({ taskId: z.string().uuid(), attemptId: z.string().uuid(), owner: z.string().min(1), fence: z.number().int().positive() }).strict(),
9
- directoryName: z.string().regex(/^(?:basic|remix-[a-z0-9][a-z0-9-]{3,63})$/).default('basic'), generation: z.number().int().positive(), runtimeEpoch: z.number().int().nonnegative(),
10
- callbackUrl: z.string().url(), runnerToken: z.string().min(1), payload: z.record(z.unknown()),
11
- stage: z.enum(productionStageNames).optional(),
12
- stages: z.array(z.object({ stage: z.enum(productionStageNames), requestId: z.string(), status: z.string(),
13
- result: z.record(z.unknown()).optional(), payload: z.record(z.unknown()).optional() }).passthrough()).optional(),
14
- source: z.object({ workspaceId: z.string().uuid(), generation: z.number().int().positive(), runtimeId: z.string().min(1) }).optional(),
15
- model: z.object({ endpoint: z.string().url(), token: z.string().min(1), model: z.string().min(1) }).optional(),
16
- git: z.object({ repositoryUrl: z.string().min(1), branchName: z.string().min(1), username: z.string().optional(), password: z.string().optional() }).optional(),
17
- repairSnapshot: z.unknown().optional(), repairSource: z.unknown().optional(), repairCommitHash: z.string().optional(),
18
- repairPreparation: z.custom(value => workspacePreparationSchema.safeParse(value).success).optional(),
19
- }).strict();