@playcraft/cli 0.1.45-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.
- package/dist/commands/tools-batch.js +1 -3
- package/dist/workspace-runtime/{production → batch}/content-changes.js +6 -12
- package/dist/workspace-runtime/batch/deliver.js +5 -0
- package/dist/workspace-runtime/batch/generate.js +24 -3
- package/dist/workspace-runtime/batch/occupancy.js +1 -1
- package/dist/workspace-runtime/batch/prepare.js +2 -0
- package/dist/workspace-runtime/server/dispatch.js +17 -8
- package/dist/workspace-runtime/server/http.js +9 -24
- package/dist/workspace-runtime/server/terminal-host.js +2 -2
- package/dist/workspace-runtime/workspaces/cli-credentials.js +6 -2
- package/dist/workspace-runtime/workspaces/directories.js +10 -20
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +3 -3
- package/package.json +3 -3
- package/dist/workspace-runtime/production/contracts.js +0 -19
- package/dist/workspace-runtime/production/controller.js +0 -228
- package/dist/workspace-runtime/production/dependencies.js +0 -60
- package/dist/workspace-runtime/production/native.js +0 -239
- package/dist/workspace-runtime/production/processes.js +0 -102
- package/dist/workspace-runtime/production/save-command.js +0 -35
- package/dist/workspace-runtime/production/source-archive.js +0 -137
- package/dist/workspace-runtime/production/source-copy.js +0 -92
- package/dist/workspace-runtime/production/stages.js +0 -354
- 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${
|
|
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 {
|
|
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
|
|
17
|
-
export async function
|
|
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('
|
|
20
|
-
const
|
|
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('
|
|
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;
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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 {
|
|
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
|
|
14
|
-
if (await
|
|
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.
|
|
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.
|
|
55
|
-
"@playcraft/common": "^0.1.
|
|
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",
|
|
@@ -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();
|