@playcraft/cli 0.1.48-beta → 0.1.50-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/workspace-runtime/batch/deliver.js +105 -87
- package/dist/workspace-runtime/batch/prepare.js +169 -56
- package/dist/workspace-runtime/batch/source-capture.js +7 -20
- package/dist/workspace-runtime/batch/source-publish.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +19 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
- package/dist/workspace-runtime/codex/tool-response.js +10 -0
- package/dist/workspace-runtime/codex/ui-tools.js +22 -5
- package/dist/workspace-runtime/previews/diagnostics.js +44 -0
- package/dist/workspace-runtime/previews/publish.js +29 -8
- package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
- package/dist/workspace-runtime/serve.js +162 -4
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/asset-transfers.js +63 -0
- package/dist/workspace-runtime/server/dispatch.js +328 -44
- package/dist/workspace-runtime/server/http.js +193 -137
- package/dist/workspace-runtime/server/operation-record.js +33 -0
- package/dist/workspace-runtime/server/submit-operation.js +41 -0
- package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-dependencies.js +81 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
- package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
- package/dist/workspace-runtime/workspaces/delivery.js +64 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
- package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
- package/dist/workspace-runtime/workspaces/directories.js +9 -4
- package/dist/workspace-runtime/workspaces/directory-mutations.js +238 -0
- package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
- package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
- package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
- package/dist/workspace-runtime/workspaces/parameters.js +77 -54
- package/package.json +3 -3
|
@@ -1,26 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { lstat,
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { hashOperationPayload, sandboxBatchMemberDeliverSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
-
import { findExternalLocalReferences } from '../previews/single-file.js';
|
|
4
|
+
import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
5
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
7
|
-
import { requireBatchContentChanges } from './content-changes.js';
|
|
8
|
-
import { gitRead, gitWrite } from './git.js';
|
|
9
6
|
import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
|
|
10
|
-
import {
|
|
7
|
+
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
|
|
8
|
+
/** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
|
|
11
9
|
function buildCommand() {
|
|
12
|
-
return process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
13
|
-
?? 'playcraft build --mode full --platform playcraft --format html --output dist/index.html .';
|
|
10
|
+
return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND ?? 'CI=1 playcraft build --mode base --output dist .');
|
|
14
11
|
}
|
|
15
|
-
async function
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
throw new Error('
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
async function readBuiltHtml(root) {
|
|
13
|
+
const htmlPath = join(root, 'dist', 'index.html');
|
|
14
|
+
const info = await lstat(htmlPath).catch(() => undefined);
|
|
15
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
16
|
+
throw new Error('batch_build_failed');
|
|
17
|
+
const handle = await open(htmlPath, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => {
|
|
18
|
+
throw new Error('batch_build_failed');
|
|
19
|
+
});
|
|
20
|
+
await handle.close();
|
|
24
21
|
}
|
|
25
22
|
async function prove(record) {
|
|
26
23
|
let stopped = true;
|
|
@@ -35,8 +32,11 @@ async function prove(record) {
|
|
|
35
32
|
}
|
|
36
33
|
return stopped;
|
|
37
34
|
}
|
|
38
|
-
export async function deliverBatchMember(state, body
|
|
39
|
-
if (!body ||
|
|
35
|
+
export async function deliverBatchMember(state, body) {
|
|
36
|
+
if (!body ||
|
|
37
|
+
typeof body !== 'object' ||
|
|
38
|
+
!('occupancyId' in body) ||
|
|
39
|
+
!body.occupancyId)
|
|
40
40
|
throw new Error('occupancy_required');
|
|
41
41
|
const parsed = sandboxBatchMemberDeliverSchema.safeParse(body);
|
|
42
42
|
if (!parsed.success)
|
|
@@ -47,109 +47,127 @@ export async function deliverBatchMember(state, body, options) {
|
|
|
47
47
|
const existing = await readMember(state, command.runId);
|
|
48
48
|
if (existing && command.ownerFence < existing.ownerFence)
|
|
49
49
|
throw new Error('stale_owner');
|
|
50
|
-
if (existing?.state === 'completed' &&
|
|
50
|
+
if (existing?.state === 'completed' &&
|
|
51
|
+
existing.phase === 'deliver' &&
|
|
52
|
+
existing.evidence &&
|
|
53
|
+
existing.commandHash?.deliver === hash &&
|
|
54
|
+
existing.ownerFence === command.ownerFence) {
|
|
51
55
|
return memberStatus(existing);
|
|
52
56
|
}
|
|
53
|
-
if (await readStopMarker(state, command.runId) &&
|
|
57
|
+
if ((await readStopMarker(state, command.runId)) &&
|
|
58
|
+
existing?.state === 'completed' &&
|
|
59
|
+
existing.evidence)
|
|
54
60
|
return memberStatus(existing);
|
|
55
61
|
if (await readStopMarker(state, command.runId))
|
|
56
62
|
return memberStatus(await markStopped(state, command, existing));
|
|
57
|
-
if (existing &&
|
|
63
|
+
if (existing &&
|
|
64
|
+
existing.ownerFence === command.ownerFence &&
|
|
65
|
+
existing.commandHash?.deliver &&
|
|
66
|
+
existing.commandHash.deliver !== hash &&
|
|
67
|
+
existing.state === 'completed') {
|
|
58
68
|
throw new Error('batch_command_conflict');
|
|
59
69
|
}
|
|
60
70
|
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
61
71
|
let record = await writeMember(state, {
|
|
62
|
-
runId: command.runId,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
runId: command.runId,
|
|
73
|
+
ownerFence: command.ownerFence,
|
|
74
|
+
occupancyId: command.occupancyId,
|
|
75
|
+
workspaceId: command.workspaceId,
|
|
76
|
+
directoryName: command.directoryName,
|
|
77
|
+
phase: 'deliver',
|
|
78
|
+
state: 'running',
|
|
79
|
+
writersStopped: false,
|
|
80
|
+
holding: true,
|
|
81
|
+
stopRequested: false,
|
|
82
|
+
processes: existing?.processes ?? [],
|
|
83
|
+
sessionId: existing?.sessionId,
|
|
84
|
+
operationId: existing?.operationId,
|
|
85
|
+
commandHash: { ...existing?.commandHash, deliver: hash },
|
|
86
|
+
snapshotDigest: existing?.snapshotDigest,
|
|
87
|
+
branchName: existing?.branchName,
|
|
88
|
+
updatedAt: new Date().toISOString(),
|
|
89
|
+
sourceCommit: existing?.sourceCommit ??
|
|
90
|
+
state.journal
|
|
91
|
+
.getWorkspace(command.workspaceId)
|
|
92
|
+
?.directories?.find((directory) => directory.directoryName === command.directoryName)
|
|
93
|
+
?.sourceCommit,
|
|
67
94
|
beforeTree: existing?.beforeTree,
|
|
68
95
|
});
|
|
69
|
-
const buildRoot = join(state.config.stateDir, 'batch-builds', `${command.runId}-${randomUUID()}`);
|
|
70
96
|
try {
|
|
71
|
-
const savedCommit = command.fromCommit ?? await saveExact(root);
|
|
72
|
-
if (command.fromCommit)
|
|
73
|
-
await gitRead(root, ['cat-file', '-e', `${command.fromCommit}^{commit}`]);
|
|
74
|
-
await requireBatchContentChanges(root, record.sourceCommit ?? '', savedCommit);
|
|
75
97
|
if (await readStopMarker(state, command.runId))
|
|
76
98
|
return memberStatus(await markStopped(state, command, record));
|
|
77
|
-
|
|
78
|
-
if (command.pushRemote) {
|
|
79
|
-
const branch = existing?.branchName ?? (await gitRead(root, ['symbolic-ref', '--short', 'HEAD'])).trim();
|
|
80
|
-
try {
|
|
81
|
-
await gitWrite(root, ['push', '--porcelain', 'origin', `${savedCommit}:refs/heads/${branch}`]);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
throw new Error('batch_push_failed');
|
|
85
|
-
}
|
|
86
|
-
const remote = (await gitWrite(root, ['ls-remote', 'origin', `refs/heads/${branch}`])).slice(0, savedCommit.length);
|
|
87
|
-
if (remote !== savedCommit)
|
|
88
|
-
throw new Error('batch_push_failed');
|
|
89
|
-
pushedCommit = savedCommit;
|
|
90
|
-
}
|
|
91
|
-
await mkdir(join(buildRoot, '..'), { recursive: true });
|
|
92
|
-
await gitWrite(join(buildRoot, '..'), ['clone', '--local', '--no-hardlinks', '--no-checkout', '--', root, buildRoot]);
|
|
93
|
-
await gitWrite(buildRoot, ['checkout', '--detach', savedCommit]);
|
|
94
|
-
const built = await runProcessGroup(buildRoot, buildCommand(), 'build', async (process) => {
|
|
99
|
+
const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
|
|
95
100
|
record = await writeMember(state, { ...record, processes: [...record.processes, process] });
|
|
96
101
|
});
|
|
97
|
-
record = await writeMember(state, {
|
|
102
|
+
record = await writeMember(state, {
|
|
103
|
+
...record,
|
|
104
|
+
processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
|
|
105
|
+
});
|
|
98
106
|
if (await readStopMarker(state, command.runId))
|
|
99
107
|
return memberStatus(await markStopped(state, command, record));
|
|
100
108
|
if (built.code !== 0)
|
|
101
109
|
throw new Error('batch_build_failed');
|
|
102
|
-
|
|
103
|
-
if (!(await lstat(htmlPath).catch(() => undefined))?.isFile())
|
|
104
|
-
throw new Error('batch_build_failed');
|
|
105
|
-
const entries = await readdir(join(buildRoot, 'dist'));
|
|
106
|
-
if (!entries.includes('index.html'))
|
|
107
|
-
throw new Error('batch_build_failed');
|
|
108
|
-
const bodyBytes = await readFile(htmlPath);
|
|
109
|
-
if (findExternalLocalReferences(bodyBytes.toString()).length)
|
|
110
|
-
throw new Error('batch_verify_failed');
|
|
111
|
-
const buildDigest = createHash('sha256').update(bodyBytes).digest('hex');
|
|
112
|
-
const previewRef = `workspaces/${command.workspaceId}/batches/members/${command.runId}/${buildDigest}.html`;
|
|
113
|
-
const store = options?.objectStore ?? filesystemObjectStore(state);
|
|
114
|
-
await store.put(previewRef, bodyBytes);
|
|
115
|
-
const published = await store.get(previewRef);
|
|
116
|
-
if (createHash('sha256').update(published).digest('hex') !== buildDigest)
|
|
117
|
-
throw new Error('batch_verify_failed');
|
|
118
|
-
await mkdir(join(root, 'dist'), { recursive: true });
|
|
119
|
-
await writeFile(join(root, 'dist', 'index.html'), published);
|
|
120
|
-
if (createHash('sha256').update(await readFile(join(root, 'dist', 'index.html'))).digest('hex') !== buildDigest)
|
|
121
|
-
throw new Error('batch_verify_failed');
|
|
110
|
+
await readBuiltHtml(root);
|
|
122
111
|
const writersStopped = await prove(record);
|
|
123
112
|
if (!writersStopped)
|
|
124
|
-
return memberStatus(await writeMember(state, {
|
|
113
|
+
return memberStatus(await writeMember(state, {
|
|
114
|
+
...record,
|
|
115
|
+
state: 'stopping',
|
|
116
|
+
writersStopped: false,
|
|
117
|
+
holding: true,
|
|
118
|
+
}));
|
|
125
119
|
if (await readStopMarker(state, command.runId))
|
|
126
|
-
return memberStatus(await markStopped(state, command,
|
|
120
|
+
return memberStatus(await markStopped(state, command, record));
|
|
127
121
|
record = await writeMember(state, {
|
|
128
|
-
...record,
|
|
129
|
-
|
|
122
|
+
...record,
|
|
123
|
+
state: 'completed',
|
|
124
|
+
writersStopped: true,
|
|
125
|
+
holding: false,
|
|
126
|
+
error: undefined,
|
|
127
|
+
evidence: { verified: true },
|
|
130
128
|
});
|
|
131
129
|
return memberStatus(record);
|
|
132
130
|
}
|
|
133
131
|
catch (error) {
|
|
134
|
-
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
132
|
+
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
133
|
+
? error.message
|
|
134
|
+
: 'batch_deliver_failed';
|
|
135
135
|
const writersStopped = await prove(record);
|
|
136
|
-
record = await writeMember(state, {
|
|
136
|
+
record = await writeMember(state, {
|
|
137
|
+
...record,
|
|
138
|
+
state: writersStopped ? 'failed' : 'stopping',
|
|
139
|
+
writersStopped,
|
|
140
|
+
holding: !writersStopped,
|
|
141
|
+
error: code,
|
|
142
|
+
});
|
|
137
143
|
return memberStatus(record);
|
|
138
144
|
}
|
|
139
|
-
finally {
|
|
140
|
-
await rm(buildRoot, { recursive: true, force: true });
|
|
141
|
-
}
|
|
142
145
|
});
|
|
143
146
|
}
|
|
144
147
|
async function markStopped(state, command, existing) {
|
|
145
148
|
if (existing)
|
|
146
149
|
await prove(existing);
|
|
147
150
|
return writeMember(state, {
|
|
148
|
-
runId: command.runId,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
runId: command.runId,
|
|
152
|
+
ownerFence: command.ownerFence,
|
|
153
|
+
occupancyId: existing?.occupancyId ?? command.occupancyId,
|
|
154
|
+
workspaceId: command.workspaceId,
|
|
155
|
+
directoryName: command.directoryName,
|
|
156
|
+
phase: 'deliver',
|
|
157
|
+
state: 'stopped',
|
|
158
|
+
writersStopped: true,
|
|
159
|
+
holding: false,
|
|
160
|
+
stopRequested: true,
|
|
161
|
+
processes: existing?.processes ?? [],
|
|
162
|
+
sessionId: existing?.sessionId,
|
|
163
|
+
operationId: existing?.operationId,
|
|
164
|
+
headCommit: existing?.headCommit,
|
|
165
|
+
evidence: existing?.evidence,
|
|
166
|
+
commandHash: existing?.commandHash,
|
|
167
|
+
snapshotDigest: existing?.snapshotDigest,
|
|
168
|
+
branchName: existing?.branchName,
|
|
169
|
+
updatedAt: new Date().toISOString(),
|
|
170
|
+
sourceCommit: existing?.sourceCommit,
|
|
171
|
+
beforeTree: existing?.beforeTree,
|
|
154
172
|
});
|
|
155
173
|
}
|
|
@@ -6,14 +6,21 @@ import { sandboxBatchMemberPrepareSchema, } from '@playcraft/common/cloud-worksp
|
|
|
6
6
|
import { registerWorkspaceDirectory } from '../workspaces/directories.js';
|
|
7
7
|
import { workspaceContainerRoot } from '../workspaces/registry.js';
|
|
8
8
|
import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
|
|
9
|
-
import {
|
|
9
|
+
import { restoreArchive } from './source-capture.js';
|
|
10
10
|
import { groupExited, runProcessGroup } from './processes.js';
|
|
11
11
|
import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
|
|
12
12
|
const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
|
|
13
|
-
const LOCKS = [
|
|
13
|
+
const LOCKS = [
|
|
14
|
+
'pnpm-lock.yaml',
|
|
15
|
+
'package-lock.json',
|
|
16
|
+
'npm-shrinkwrap.json',
|
|
17
|
+
'yarn.lock',
|
|
18
|
+
'bun.lock',
|
|
19
|
+
'bun.lockb',
|
|
20
|
+
];
|
|
14
21
|
export function frozenInstallCommand() {
|
|
15
|
-
return process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND
|
|
16
|
-
|
|
22
|
+
return (process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ??
|
|
23
|
+
'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
|
|
17
24
|
}
|
|
18
25
|
function hashCommand(value) {
|
|
19
26
|
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
@@ -21,10 +28,17 @@ function hashCommand(value) {
|
|
|
21
28
|
async function materialize(state, snapshot, destination, store, sourceRoot) {
|
|
22
29
|
const cache = sourceCacheDir(state, snapshot.batchId);
|
|
23
30
|
const cachedDigest = await readFile(`${cache}.digest`, 'utf8').catch(() => '');
|
|
24
|
-
if (cachedDigest.trim() === snapshot.digest &&
|
|
25
|
-
await
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
if (cachedDigest.trim() === snapshot.digest &&
|
|
32
|
+
(await lstat(join(cache, '.git')).catch(() => undefined))) {
|
|
33
|
+
await gitWrite(dirnameOf(destination), [
|
|
34
|
+
'clone',
|
|
35
|
+
'--local',
|
|
36
|
+
'--no-hardlinks',
|
|
37
|
+
'--',
|
|
38
|
+
cache,
|
|
39
|
+
destination,
|
|
40
|
+
]);
|
|
41
|
+
await assertFrozenCommit(destination, snapshot.commitHash);
|
|
28
42
|
return;
|
|
29
43
|
}
|
|
30
44
|
if (snapshot.remoteRef) {
|
|
@@ -41,34 +55,64 @@ async function materialize(state, snapshot, destination, store, sourceRoot) {
|
|
|
41
55
|
throw new Error('batch_snapshot_mismatch');
|
|
42
56
|
}
|
|
43
57
|
await gitWrite(destination, ['checkout', '--detach', 'FETCH_HEAD']);
|
|
44
|
-
|
|
45
|
-
throw new Error('batch_snapshot_mismatch');
|
|
46
|
-
if (digestFiles(await readSourceFiles(destination)) !== snapshot.digest)
|
|
47
|
-
throw new Error('batch_snapshot_mismatch');
|
|
58
|
+
await assertFrozenCommit(destination, snapshot.commitHash);
|
|
48
59
|
return;
|
|
49
60
|
}
|
|
50
61
|
if (!snapshot.artifactKey)
|
|
51
62
|
throw new Error('batch_snapshot_mismatch');
|
|
52
|
-
const body = await store.get(snapshot.artifactKey).catch(() => {
|
|
63
|
+
const body = await store.get(snapshot.artifactKey).catch(() => {
|
|
64
|
+
throw new Error('batch_snapshot_mismatch');
|
|
65
|
+
});
|
|
53
66
|
await restoreArchive(body, destination, snapshot.digest);
|
|
54
67
|
}
|
|
55
68
|
function dirnameOf(path) {
|
|
56
69
|
return join(path, '..');
|
|
57
70
|
}
|
|
71
|
+
async function assertFrozenCommit(root, commit) {
|
|
72
|
+
if ((await gitRead(root, ['rev-parse', 'HEAD'])).trim() !== commit)
|
|
73
|
+
throw new Error('batch_snapshot_mismatch');
|
|
74
|
+
}
|
|
75
|
+
/** A bound workspace keeps origin aligned. A local workspace only needs the branch. */
|
|
58
76
|
async function checkoutBranch(root, branchName, commit, repositoryUrl) {
|
|
59
77
|
await gitWrite(root, ['checkout', '-B', branchName, commit]);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
if (repositoryUrl) {
|
|
79
|
+
const origin = await gitRead(root, ['remote', 'get-url', 'origin']).catch(() => '');
|
|
80
|
+
if (!origin.trim())
|
|
81
|
+
await gitWrite(root, ['remote', 'add', 'origin', repositoryUrl]);
|
|
82
|
+
else if (origin.trim() !== repositoryUrl)
|
|
83
|
+
await gitWrite(root, ['remote', 'set-url', 'origin', repositoryUrl]);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
await gitWrite(root, ['remote', 'remove', 'origin']).catch(() => undefined);
|
|
87
|
+
}
|
|
65
88
|
await gitWrite(root, ['config', 'user.name', 'PlayCraft']);
|
|
66
89
|
await gitWrite(root, ['config', 'user.email', 'playcraft@localhost']);
|
|
67
90
|
}
|
|
68
91
|
async function resumeBranch(repositoryUrl, branchName, commit, destination, sourceRoot) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
if (repositoryUrl) {
|
|
93
|
+
await gitWrite(dirnameOf(destination), [
|
|
94
|
+
'clone',
|
|
95
|
+
'--branch',
|
|
96
|
+
branchName,
|
|
97
|
+
'--single-branch',
|
|
98
|
+
'--',
|
|
99
|
+
repositoryUrl,
|
|
100
|
+
destination,
|
|
101
|
+
]);
|
|
102
|
+
await copyCredentialHelpers(sourceRoot, destination);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
await gitWrite(dirnameOf(destination), [
|
|
106
|
+
'clone',
|
|
107
|
+
'--local',
|
|
108
|
+
'--no-hardlinks',
|
|
109
|
+
'--',
|
|
110
|
+
sourceRoot,
|
|
111
|
+
destination,
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
if ((await gitRead(destination, ['rev-parse', 'HEAD'])).trim() !== commit &&
|
|
115
|
+
!(await gitRead(destination, ['cat-file', '-e', `${commit}^{commit}`]).then(() => true, () => false))) {
|
|
72
116
|
throw new Error('batch_commit_mismatch');
|
|
73
117
|
}
|
|
74
118
|
await gitWrite(destination, ['checkout', '-B', branchName, commit]);
|
|
@@ -102,17 +146,25 @@ export async function assertMemberDependencies(root) {
|
|
|
102
146
|
if (present[0] !== 'pnpm-lock.yaml')
|
|
103
147
|
throw new Error('dependency_lock_mismatch');
|
|
104
148
|
const lock = await readFile(join(root, 'pnpm-lock.yaml'), 'utf8');
|
|
105
|
-
if (names.some(name => !lock.includes(`${name}:`) &&
|
|
149
|
+
if (names.some((name) => !lock.includes(`${name}:`) &&
|
|
150
|
+
!lock.includes(`/${name}@`) &&
|
|
151
|
+
!lock.includes(`'${name}'`) &&
|
|
152
|
+
!lock.includes(`"${name}"`))) {
|
|
106
153
|
throw new Error('dependency_lock_mismatch');
|
|
107
154
|
}
|
|
108
155
|
return 'install';
|
|
109
156
|
}
|
|
110
157
|
export async function prepareBatchMember(state, body, options) {
|
|
111
|
-
if (!body ||
|
|
158
|
+
if (!body ||
|
|
159
|
+
typeof body !== 'object' ||
|
|
160
|
+
!('occupancyId' in body) ||
|
|
161
|
+
!body.occupancyId)
|
|
112
162
|
throw new Error('occupancy_required');
|
|
113
163
|
const parsed = sandboxBatchMemberPrepareSchema.safeParse(body);
|
|
114
164
|
if (!parsed.success)
|
|
115
|
-
throw new Error(parsed.error.issues.some(issue => issue.path.includes('branchName'))
|
|
165
|
+
throw new Error(parsed.error.issues.some((issue) => issue.path.includes('branchName'))
|
|
166
|
+
? 'batch_branch_invalid'
|
|
167
|
+
: 'invalid_batch_request');
|
|
116
168
|
const command = parsed.data;
|
|
117
169
|
if (!BRANCH.test(command.branchName))
|
|
118
170
|
throw new Error('batch_branch_invalid');
|
|
@@ -124,81 +176,142 @@ export async function prepareBatchMember(state, body, options) {
|
|
|
124
176
|
throw new Error('stale_owner');
|
|
125
177
|
if (marker)
|
|
126
178
|
return memberStatus(await sealStopped(state, command, existing));
|
|
127
|
-
if (existing &&
|
|
179
|
+
if (existing &&
|
|
180
|
+
existing.ownerFence === command.ownerFence &&
|
|
181
|
+
existing.commandHash?.prepare === hash &&
|
|
182
|
+
existing.state !== 'running' &&
|
|
183
|
+
existing.state !== 'failed') {
|
|
128
184
|
return memberStatus(existing);
|
|
129
185
|
}
|
|
130
|
-
if (existing &&
|
|
186
|
+
if (existing &&
|
|
187
|
+
existing.ownerFence === command.ownerFence &&
|
|
188
|
+
existing.commandHash?.prepare &&
|
|
189
|
+
existing.commandHash.prepare !== hash)
|
|
131
190
|
throw new Error('batch_command_conflict');
|
|
132
|
-
if (existing?.processes.some(process => process.role === 'install' && !process.stopped && !groupExited(process.pid)))
|
|
191
|
+
if (existing?.processes.some((process) => process.role === 'install' && !process.stopped && !groupExited(process.pid)))
|
|
133
192
|
return memberStatus(existing);
|
|
134
193
|
const snapshot = command.snapshot;
|
|
135
194
|
if ((await readSnapshot(state, snapshot.batchId))?.digest === snapshot.digest) {
|
|
136
195
|
/* The local snapshot record is an accelerator; the command snapshot remains authoritative. */
|
|
137
196
|
}
|
|
138
197
|
let record = await writeMember(state, {
|
|
139
|
-
runId: command.runId,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
198
|
+
runId: command.runId,
|
|
199
|
+
ownerFence: command.ownerFence,
|
|
200
|
+
occupancyId: command.occupancyId,
|
|
201
|
+
workspaceId: command.workspaceId,
|
|
202
|
+
directoryName: command.directoryName,
|
|
203
|
+
phase: 'prepare',
|
|
204
|
+
state: 'running',
|
|
205
|
+
writersStopped: false,
|
|
206
|
+
holding: true,
|
|
207
|
+
stopRequested: false,
|
|
208
|
+
processes: existing?.processes ?? [],
|
|
209
|
+
commandHash: { ...existing?.commandHash, prepare: hash },
|
|
210
|
+
snapshotDigest: snapshot.digest,
|
|
211
|
+
branchName: command.branchName,
|
|
212
|
+
sourceCommit: snapshot.commitHash,
|
|
213
|
+
beforeTree: existing?.beforeTree,
|
|
144
214
|
updatedAt: new Date().toISOString(),
|
|
145
215
|
});
|
|
146
|
-
|
|
147
|
-
const root = join(parent, command.directoryName);
|
|
148
|
-
const staging = join(parent, `.batch-member-${randomUUID()}`);
|
|
149
|
-
const binding = state.journal.getWorkspace(command.workspaceId);
|
|
150
|
-
if (!binding?.repositoryUrl)
|
|
151
|
-
throw new Error('workspace_not_registered');
|
|
152
|
-
const sourceRoot = join(parent, snapshot.sourceDirectoryName);
|
|
216
|
+
let staging;
|
|
153
217
|
try {
|
|
154
|
-
|
|
218
|
+
const parent = await workspaceContainerRoot(state, command.workspaceId);
|
|
219
|
+
const root = join(parent, command.directoryName);
|
|
220
|
+
staging = join(parent, `.batch-member-${randomUUID()}`);
|
|
221
|
+
const binding = state.journal.getWorkspace(command.workspaceId);
|
|
222
|
+
if (!binding)
|
|
223
|
+
throw new Error('workspace_not_registered');
|
|
224
|
+
const repositoryUrl = binding.repositoryUrl ?? undefined;
|
|
225
|
+
const sourceRoot = join(parent, snapshot.sourceDirectoryName);
|
|
226
|
+
if (!(await lstat(root).catch(() => undefined))) {
|
|
155
227
|
await mkdir(parent, { recursive: true });
|
|
156
228
|
if (command.resumeFromCommit)
|
|
157
|
-
await resumeBranch(
|
|
229
|
+
await resumeBranch(repositoryUrl, command.branchName, command.resumeFromCommit, staging, sourceRoot);
|
|
158
230
|
else
|
|
159
231
|
await materialize(state, snapshot, staging, options?.objectStore ?? filesystemObjectStore(state), sourceRoot);
|
|
160
232
|
const commit = command.resumeFromCommit ?? snapshot.commitHash;
|
|
161
|
-
await checkoutBranch(staging, command.branchName, commit,
|
|
233
|
+
await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
|
|
162
234
|
const { rename } = await import('node:fs/promises');
|
|
163
235
|
await rename(staging, root);
|
|
164
236
|
}
|
|
165
237
|
await registerWorkspaceDirectory(state, {
|
|
166
|
-
workspaceId: command.workspaceId,
|
|
167
|
-
|
|
238
|
+
workspaceId: command.workspaceId,
|
|
239
|
+
directoryName: command.directoryName,
|
|
240
|
+
remixId: command.remixId,
|
|
241
|
+
branchName: command.branchName,
|
|
242
|
+
sourceCommit: command.resumeFromCommit ?? snapshot.commitHash,
|
|
168
243
|
}, state.acceptedLeaseFence);
|
|
169
244
|
const plan = await assertMemberDependencies(root);
|
|
170
245
|
if (plan === 'install') {
|
|
171
246
|
const installed = await runProcessGroup(root, frozenInstallCommand(), 'install', async (process) => {
|
|
172
|
-
record = await writeMember(state, {
|
|
247
|
+
record = await writeMember(state, {
|
|
248
|
+
...record,
|
|
249
|
+
processes: [...record.processes, process],
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
record = await writeMember(state, {
|
|
253
|
+
...record,
|
|
254
|
+
processes: record.processes.map((process) => process.pid === installed.process.pid
|
|
255
|
+
? { ...installed.process, role: 'install' }
|
|
256
|
+
: process),
|
|
173
257
|
});
|
|
174
|
-
record = await writeMember(state, { ...record, processes: record.processes.map(process => process.pid === installed.process.pid ? { ...installed.process, role: 'install' } : process) });
|
|
175
258
|
if (await readStopMarker(state, command.runId))
|
|
176
259
|
return memberStatus(await sealStopped(state, command, record));
|
|
177
260
|
if (installed.code !== 0)
|
|
178
261
|
throw new Error('batch_install_failed');
|
|
179
262
|
}
|
|
180
263
|
const head = (await gitRead(root, ['rev-parse', 'HEAD'])).trim();
|
|
181
|
-
record = await writeMember(state, {
|
|
264
|
+
record = await writeMember(state, {
|
|
265
|
+
...record,
|
|
266
|
+
state: 'completed',
|
|
267
|
+
writersStopped: true,
|
|
268
|
+
holding: true,
|
|
269
|
+
headCommit: head,
|
|
270
|
+
error: undefined,
|
|
271
|
+
});
|
|
182
272
|
return memberStatus(record);
|
|
183
273
|
}
|
|
184
274
|
catch (error) {
|
|
185
|
-
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
186
|
-
|
|
275
|
+
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
276
|
+
? error.message
|
|
277
|
+
: 'batch_prepare_failed';
|
|
278
|
+
record = await writeMember(state, {
|
|
279
|
+
...record,
|
|
280
|
+
state: 'failed',
|
|
281
|
+
writersStopped: true,
|
|
282
|
+
holding: false,
|
|
283
|
+
error: code,
|
|
284
|
+
});
|
|
187
285
|
return memberStatus(record);
|
|
188
286
|
}
|
|
189
287
|
finally {
|
|
190
|
-
|
|
288
|
+
if (staging)
|
|
289
|
+
await rm(staging, { recursive: true, force: true });
|
|
191
290
|
}
|
|
192
291
|
});
|
|
193
292
|
}
|
|
194
293
|
async function sealStopped(state, command, existing) {
|
|
195
294
|
return writeMember(state, {
|
|
196
|
-
runId: command.runId,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
295
|
+
runId: command.runId,
|
|
296
|
+
ownerFence: Math.max(command.ownerFence, existing?.ownerFence ?? 0),
|
|
297
|
+
occupancyId: command.occupancyId,
|
|
298
|
+
workspaceId: command.workspaceId,
|
|
299
|
+
directoryName: command.directoryName,
|
|
300
|
+
phase: existing?.phase ?? 'prepare',
|
|
301
|
+
state: 'stopped',
|
|
302
|
+
writersStopped: true,
|
|
303
|
+
holding: false,
|
|
304
|
+
stopRequested: true,
|
|
305
|
+
processes: existing?.processes ?? [],
|
|
306
|
+
sessionId: existing?.sessionId,
|
|
307
|
+
operationId: existing?.operationId,
|
|
308
|
+
headCommit: existing?.headCommit,
|
|
309
|
+
evidence: existing?.evidence,
|
|
310
|
+
commandHash: existing?.commandHash,
|
|
311
|
+
snapshotDigest: existing?.snapshotDigest,
|
|
312
|
+
branchName: existing?.branchName,
|
|
313
|
+
sourceCommit: existing?.sourceCommit,
|
|
314
|
+
beforeTree: existing?.beforeTree,
|
|
202
315
|
updatedAt: new Date().toISOString(),
|
|
203
316
|
});
|
|
204
317
|
}
|