@playcraft/cli 0.1.43-beta → 0.1.45-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 +18 -0
- package/dist/workspace-runtime/batch/control.js +80 -0
- package/dist/workspace-runtime/batch/deliver.js +150 -0
- package/dist/workspace-runtime/batch/freeze.js +102 -0
- package/dist/workspace-runtime/batch/generate.js +205 -0
- package/dist/workspace-runtime/batch/git.js +40 -0
- package/dist/workspace-runtime/batch/occupancy.js +8 -0
- package/dist/workspace-runtime/batch/policy.js +70 -0
- package/dist/workspace-runtime/batch/prepare.js +202 -0
- package/dist/workspace-runtime/batch/processes.js +72 -0
- package/dist/workspace-runtime/batch/records.js +113 -0
- package/dist/workspace-runtime/batch/routes.js +58 -0
- package/dist/workspace-runtime/batch/source-capture.js +159 -0
- package/dist/workspace-runtime/codex/app-server.js +7 -7
- package/dist/workspace-runtime/codex/permissions.js +10 -1
- package/dist/workspace-runtime/production/controller.js +3 -0
- package/dist/workspace-runtime/serve.js +2 -0
- package/dist/workspace-runtime/server/connectivity.js +10 -2
- package/dist/workspace-runtime/server/dispatch.js +4 -0
- package/dist/workspace-runtime/server/http.js +6 -0
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +2 -2
|
@@ -16,6 +16,8 @@ const messages = {
|
|
|
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
18
|
review: 'This existing draft requires baseline approval in the browser.',
|
|
19
|
+
sync: 'Apply the latest source snapshot to an existing batch',
|
|
20
|
+
syncDone: 'Batch sync requested',
|
|
19
21
|
},
|
|
20
22
|
zh: {
|
|
21
23
|
create: '用一个 prompt 创建并自动生成独立 Remix 变体',
|
|
@@ -30,6 +32,8 @@ const messages = {
|
|
|
30
32
|
help: '\n使用 --prompt、--workspace-id 和稳定的 --client-request-id。平台自动规划,在副本中生成独立 Remix,保持来源不变。未指定数量默认 3 个,最多 10 个。--input 也兼容现有结构化草案。\n',
|
|
31
33
|
confirmation: '生成已排队,可查询批次状态追踪各项成果。',
|
|
32
34
|
review: '此历史草案需要在浏览器确认基准。',
|
|
35
|
+
sync: '将来源最新快照同步到已有批次',
|
|
36
|
+
syncDone: '已请求同步到批次',
|
|
33
37
|
},
|
|
34
38
|
};
|
|
35
39
|
const language = () => /^zh/i.test(process.env.PLAYCRAFT_LANG ?? process.env.LANG ?? '') ? messages.zh : messages.en;
|
|
@@ -71,6 +75,20 @@ export function registerBatchCommands(tools) {
|
|
|
71
75
|
handleError(error);
|
|
72
76
|
}
|
|
73
77
|
});
|
|
78
|
+
tools.command('sync-batch')
|
|
79
|
+
.description(t.sync)
|
|
80
|
+
.requiredOption('--batch-id <id>', t.id)
|
|
81
|
+
.option('--json', 'JSON (JavaScript Object Notation, 数据交换格式)')
|
|
82
|
+
.action(async (opts) => {
|
|
83
|
+
try {
|
|
84
|
+
const client = new AgentApiClient();
|
|
85
|
+
const result = await client.post('/sync-batch', { batchId: opts.batchId });
|
|
86
|
+
console.log(opts.json ? JSON.stringify(result, null, 2) : t.syncDone);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
handleError(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
74
92
|
tools.command('get-batch')
|
|
75
93
|
.description(t.status)
|
|
76
94
|
.requiredOption('--batch-id <id>', t.id)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { sandboxBatchMemberStopSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { groupExited, stopProcessGroup } from './processes.js';
|
|
4
|
+
import { memberStatus, readMember, serializeRun, writeMember, writeStopMarker } from './records.js';
|
|
5
|
+
async function prove(record) {
|
|
6
|
+
let stopped = true;
|
|
7
|
+
for (const process of record.processes) {
|
|
8
|
+
if (process.stopped || groupExited(process.pid))
|
|
9
|
+
process.stopped = true;
|
|
10
|
+
else {
|
|
11
|
+
process.stopped = await stopProcessGroup(process);
|
|
12
|
+
if (!process.stopped)
|
|
13
|
+
stopped = false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return stopped;
|
|
17
|
+
}
|
|
18
|
+
function sessionBusy(state, record) {
|
|
19
|
+
const session = record.sessionId ? state.journal.getSession(record.sessionId) : undefined;
|
|
20
|
+
if (!session)
|
|
21
|
+
return false;
|
|
22
|
+
return session.turnBlock === 'uncertain' || session.turnStatus === 'starting' || session.turnStatus === 'running';
|
|
23
|
+
}
|
|
24
|
+
/** Read-only besides reconciling process proof. Does not start work or advance a phase. */
|
|
25
|
+
export async function readBatchMemberStatus(state, runId) {
|
|
26
|
+
const record = await readMember(state, runId);
|
|
27
|
+
if (!record)
|
|
28
|
+
return undefined;
|
|
29
|
+
const processes = record.processes.map(process => ({ ...process, stopped: process.stopped || groupExited(process.pid) }));
|
|
30
|
+
const live = processes.some(process => !process.stopped);
|
|
31
|
+
let next = { ...record, processes };
|
|
32
|
+
if (live && (record.writersStopped || record.state === 'completed' || record.state === 'stopped' || record.state === 'needs_user' || record.state === 'failed')) {
|
|
33
|
+
next = { ...next, writersStopped: false, state: record.stopRequested ? 'stopping' : 'unknown', holding: true };
|
|
34
|
+
}
|
|
35
|
+
else if (!live && record.state === 'running') {
|
|
36
|
+
const turnLive = sessionBusy(state, record);
|
|
37
|
+
next = { ...next, writersStopped: !turnLive, state: record.stopRequested ? 'stopped' : turnLive ? 'running' : 'unknown', holding: turnLive || !record.stopRequested };
|
|
38
|
+
}
|
|
39
|
+
if (JSON.stringify(next) !== JSON.stringify(record))
|
|
40
|
+
next = await writeMember(state, next);
|
|
41
|
+
return memberStatus(next);
|
|
42
|
+
}
|
|
43
|
+
export async function stopBatchMember(state, runId, body) {
|
|
44
|
+
const parsed = sandboxBatchMemberStopSchema.safeParse({ ...(body && typeof body === 'object' ? body : {}), runId });
|
|
45
|
+
if (!parsed.success)
|
|
46
|
+
throw new Error('invalid_batch_request');
|
|
47
|
+
const command = parsed.data;
|
|
48
|
+
return serializeRun(runId, async () => {
|
|
49
|
+
const existing = await readMember(state, runId);
|
|
50
|
+
if (existing && command.ownerFence < existing.ownerFence)
|
|
51
|
+
throw new Error('stale_owner');
|
|
52
|
+
if (existing?.state === 'completed' && existing.phase === 'deliver' && existing.evidence && existing.writersStopped)
|
|
53
|
+
return memberStatus(existing);
|
|
54
|
+
await writeStopMarker(state, runId, command.ownerFence);
|
|
55
|
+
const session = existing?.sessionId ? state.journal.getSession(existing.sessionId) : undefined;
|
|
56
|
+
if (session?.threadId && existing?.sessionId) {
|
|
57
|
+
await state.native?.interrupt?.({ sessionId: existing.sessionId, threadId: session.threadId }).catch(() => undefined);
|
|
58
|
+
const deadline = Date.now() + 2_000;
|
|
59
|
+
while (sessionBusy(state, existing) && Date.now() < deadline)
|
|
60
|
+
await delay(15);
|
|
61
|
+
}
|
|
62
|
+
const record = existing ?? {
|
|
63
|
+
runId, ownerFence: command.ownerFence, occupancyId: '00000000-0000-0000-0000-000000000000', workspaceId: '00000000-0000-0000-0000-000000000000',
|
|
64
|
+
directoryName: 'basic', phase: 'prepare', state: 'stopping', writersStopped: false, holding: true, stopRequested: true, processes: [],
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
const writersStopped = await prove(record) && !sessionBusy(state, record);
|
|
68
|
+
const saved = await writeMember(state, {
|
|
69
|
+
...record, ownerFence: command.ownerFence, stopRequested: true, state: writersStopped ? 'stopped' : 'stopping',
|
|
70
|
+
writersStopped, holding: !writersStopped,
|
|
71
|
+
});
|
|
72
|
+
return memberStatus(saved);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function rememberProcess(state, runId, process) {
|
|
76
|
+
const record = await readMember(state, runId);
|
|
77
|
+
if (!record)
|
|
78
|
+
return;
|
|
79
|
+
await writeMember(state, { ...record, processes: [...record.processes.filter(item => item.pid !== process.pid), process] });
|
|
80
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { hashOperationPayload, sandboxBatchMemberDeliverSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { findExternalLocalReferences } from '../previews/single-file.js';
|
|
6
|
+
import { workspaceRoot } from '../workspaces/registry.js';
|
|
7
|
+
import { gitRead, gitWrite } from './git.js';
|
|
8
|
+
import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
|
|
9
|
+
import { filesystemObjectStore, memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
|
|
10
|
+
function buildCommand() {
|
|
11
|
+
return process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
12
|
+
?? 'playcraft build --mode full --platform playcraft --format html --output dist/index.html .';
|
|
13
|
+
}
|
|
14
|
+
async function saveExact(root) {
|
|
15
|
+
await gitWrite(root, ['add', '-A', '.']);
|
|
16
|
+
const privatePaths = await gitRead(root, ['ls-files', '--', '.playcraft.json', 'playcraft.config.json', ':(glob)**/.playcraft.json', ':(glob)**/playcraft.config.json']);
|
|
17
|
+
if (privatePaths.trim())
|
|
18
|
+
throw new Error('batch_private_path');
|
|
19
|
+
const clean = await gitWrite(root, ['diff', '--cached', '--quiet']).then(() => true, () => false);
|
|
20
|
+
if (!clean)
|
|
21
|
+
await gitWrite(root, ['commit', '-m', 'Save batch member']);
|
|
22
|
+
return (await gitRead(root, ['rev-parse', 'HEAD'])).trim();
|
|
23
|
+
}
|
|
24
|
+
async function prove(record) {
|
|
25
|
+
let stopped = true;
|
|
26
|
+
for (const process of record.processes) {
|
|
27
|
+
if (!(process.stopped || groupExited(process.pid))) {
|
|
28
|
+
process.stopped = await stopProcessGroup(process);
|
|
29
|
+
if (!process.stopped)
|
|
30
|
+
stopped = false;
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
process.stopped = true;
|
|
34
|
+
}
|
|
35
|
+
return stopped;
|
|
36
|
+
}
|
|
37
|
+
export async function deliverBatchMember(state, body, options) {
|
|
38
|
+
if (!body || typeof body !== 'object' || !('occupancyId' in body) || !body.occupancyId)
|
|
39
|
+
throw new Error('occupancy_required');
|
|
40
|
+
const parsed = sandboxBatchMemberDeliverSchema.safeParse(body);
|
|
41
|
+
if (!parsed.success)
|
|
42
|
+
throw new Error('invalid_batch_request');
|
|
43
|
+
const command = parsed.data;
|
|
44
|
+
const hash = hashOperationPayload(command);
|
|
45
|
+
return serializeRun(command.runId, async () => {
|
|
46
|
+
const existing = await readMember(state, command.runId);
|
|
47
|
+
if (existing && command.ownerFence < existing.ownerFence)
|
|
48
|
+
throw new Error('stale_owner');
|
|
49
|
+
if (existing?.state === 'completed' && existing.phase === 'deliver' && existing.evidence && existing.commandHash?.deliver === hash && existing.ownerFence === command.ownerFence) {
|
|
50
|
+
return memberStatus(existing);
|
|
51
|
+
}
|
|
52
|
+
if (await readStopMarker(state, command.runId) && existing?.state === 'completed' && existing.evidence)
|
|
53
|
+
return memberStatus(existing);
|
|
54
|
+
if (await readStopMarker(state, command.runId))
|
|
55
|
+
return memberStatus(await markStopped(state, command, existing));
|
|
56
|
+
if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.deliver && existing.commandHash.deliver !== hash && existing.state === 'completed') {
|
|
57
|
+
throw new Error('batch_command_conflict');
|
|
58
|
+
}
|
|
59
|
+
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
60
|
+
let record = await writeMember(state, {
|
|
61
|
+
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
62
|
+
directoryName: command.directoryName, phase: 'deliver', state: 'running', writersStopped: false, holding: true, stopRequested: false,
|
|
63
|
+
processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId, commandHash: { ...existing?.commandHash, deliver: hash },
|
|
64
|
+
snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, updatedAt: new Date().toISOString(),
|
|
65
|
+
});
|
|
66
|
+
const buildRoot = join(state.config.stateDir, 'batch-builds', `${command.runId}-${randomUUID()}`);
|
|
67
|
+
try {
|
|
68
|
+
const savedCommit = command.fromCommit ?? await saveExact(root);
|
|
69
|
+
if (command.fromCommit)
|
|
70
|
+
await gitRead(root, ['cat-file', '-e', `${command.fromCommit}^{commit}`]);
|
|
71
|
+
if (await readStopMarker(state, command.runId))
|
|
72
|
+
return memberStatus(await markStopped(state, command, record));
|
|
73
|
+
let pushedCommit;
|
|
74
|
+
if (command.pushRemote) {
|
|
75
|
+
const branch = existing?.branchName ?? (await gitRead(root, ['symbolic-ref', '--short', 'HEAD'])).trim();
|
|
76
|
+
try {
|
|
77
|
+
await gitWrite(root, ['push', '--porcelain', 'origin', `${savedCommit}:refs/heads/${branch}`]);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error('batch_push_failed');
|
|
81
|
+
}
|
|
82
|
+
const remote = (await gitWrite(root, ['ls-remote', 'origin', `refs/heads/${branch}`])).slice(0, savedCommit.length);
|
|
83
|
+
if (remote !== savedCommit)
|
|
84
|
+
throw new Error('batch_push_failed');
|
|
85
|
+
pushedCommit = savedCommit;
|
|
86
|
+
}
|
|
87
|
+
await mkdir(join(buildRoot, '..'), { recursive: true });
|
|
88
|
+
await gitWrite(join(buildRoot, '..'), ['clone', '--local', '--no-hardlinks', '--no-checkout', '--', root, buildRoot]);
|
|
89
|
+
await gitWrite(buildRoot, ['checkout', '--detach', savedCommit]);
|
|
90
|
+
const built = await runProcessGroup(buildRoot, buildCommand(), 'build', async (process) => {
|
|
91
|
+
record = await writeMember(state, { ...record, processes: [...record.processes, process] });
|
|
92
|
+
});
|
|
93
|
+
record = await writeMember(state, { ...record, processes: record.processes.map(process => process.pid === built.process.pid ? built.process : process) });
|
|
94
|
+
if (await readStopMarker(state, command.runId))
|
|
95
|
+
return memberStatus(await markStopped(state, command, record));
|
|
96
|
+
if (built.code !== 0)
|
|
97
|
+
throw new Error('batch_build_failed');
|
|
98
|
+
const htmlPath = join(buildRoot, 'dist', 'index.html');
|
|
99
|
+
if (!(await lstat(htmlPath).catch(() => undefined))?.isFile())
|
|
100
|
+
throw new Error('batch_build_failed');
|
|
101
|
+
const entries = await readdir(join(buildRoot, 'dist'));
|
|
102
|
+
if (!entries.includes('index.html'))
|
|
103
|
+
throw new Error('batch_build_failed');
|
|
104
|
+
const bodyBytes = await readFile(htmlPath);
|
|
105
|
+
if (findExternalLocalReferences(bodyBytes.toString()).length)
|
|
106
|
+
throw new Error('batch_verify_failed');
|
|
107
|
+
const buildDigest = createHash('sha256').update(bodyBytes).digest('hex');
|
|
108
|
+
const previewRef = `workspaces/${command.workspaceId}/batches/members/${command.runId}/${buildDigest}.html`;
|
|
109
|
+
const store = options?.objectStore ?? filesystemObjectStore(state);
|
|
110
|
+
await store.put(previewRef, bodyBytes);
|
|
111
|
+
const published = await store.get(previewRef);
|
|
112
|
+
if (createHash('sha256').update(published).digest('hex') !== buildDigest)
|
|
113
|
+
throw new Error('batch_verify_failed');
|
|
114
|
+
await mkdir(join(root, 'dist'), { recursive: true });
|
|
115
|
+
await writeFile(join(root, 'dist', 'index.html'), published);
|
|
116
|
+
if (createHash('sha256').update(await readFile(join(root, 'dist', 'index.html'))).digest('hex') !== buildDigest)
|
|
117
|
+
throw new Error('batch_verify_failed');
|
|
118
|
+
const writersStopped = await prove(record);
|
|
119
|
+
if (!writersStopped)
|
|
120
|
+
return memberStatus(await writeMember(state, { ...record, state: 'stopping', writersStopped: false, holding: true, headCommit: savedCommit }));
|
|
121
|
+
if (await readStopMarker(state, command.runId))
|
|
122
|
+
return memberStatus(await markStopped(state, command, { ...record, headCommit: savedCommit }));
|
|
123
|
+
record = await writeMember(state, {
|
|
124
|
+
...record, state: 'completed', writersStopped: true, holding: false, headCommit: savedCommit, error: undefined,
|
|
125
|
+
evidence: { savedCommit, ...(pushedCommit ? { pushedCommit } : {}), buildDigest, verified: true, previewRef },
|
|
126
|
+
});
|
|
127
|
+
return memberStatus(record);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'batch_deliver_failed';
|
|
131
|
+
const writersStopped = await prove(record);
|
|
132
|
+
record = await writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping', writersStopped, holding: !writersStopped, error: code });
|
|
133
|
+
return memberStatus(record);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await rm(buildRoot, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function markStopped(state, command, existing) {
|
|
141
|
+
if (existing)
|
|
142
|
+
await prove(existing);
|
|
143
|
+
return writeMember(state, {
|
|
144
|
+
runId: command.runId, ownerFence: command.ownerFence, occupancyId: existing?.occupancyId ?? command.occupancyId,
|
|
145
|
+
workspaceId: command.workspaceId, directoryName: command.directoryName, phase: 'deliver', state: 'stopped', writersStopped: true,
|
|
146
|
+
holding: false, stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
|
|
147
|
+
headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
|
|
148
|
+
branchName: existing?.branchName, updatedAt: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { lstat, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { statfs } from 'node:fs/promises';
|
|
4
|
+
import { batchSourceFreezeResultSchema, batchSourceRemoteRef, sandboxBatchSourceFreezeRequestSchema, workspaceDiskAdmission, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { stopBackgroundWriter } from '../processes/managed-writes.js';
|
|
6
|
+
import { workspaceRoot } from '../workspaces/registry.js';
|
|
7
|
+
import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
|
|
8
|
+
import { archiveSource, captureIndependentSource } from './source-capture.js';
|
|
9
|
+
import { filesystemObjectStore, readSnapshot, serializeFreeze, sourceCacheDir, writeSnapshot, } from './records.js';
|
|
10
|
+
async function usagePercent(path) {
|
|
11
|
+
const disk = await statfs(path);
|
|
12
|
+
if (disk.blocks <= 0)
|
|
13
|
+
throw new Error('disk_usage_unavailable');
|
|
14
|
+
return ((disk.blocks - disk.bfree) / disk.blocks) * 100;
|
|
15
|
+
}
|
|
16
|
+
/** Dependency installs are keyed `workspaceId:directoryName`. A bare workspace id is not this directory. */
|
|
17
|
+
async function stopManagedWriters(state, workspaceId, directoryName) {
|
|
18
|
+
const scope = `${workspaceId}:${directoryName}`;
|
|
19
|
+
for (const write of state.journal.listManagedWrites()) {
|
|
20
|
+
if (write.stoppedAt || write.workspaceId !== scope)
|
|
21
|
+
continue;
|
|
22
|
+
if (write.pid !== undefined) {
|
|
23
|
+
const stopped = await stopBackgroundWriter(write.pid);
|
|
24
|
+
if (!stopped)
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
await state.journal.putManagedWrite({ ...write, stoppedAt: new Date().toISOString() });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function pushHiddenRef(cache, sourceRoot, remote, commit, ref) {
|
|
31
|
+
await copyCredentialHelpers(sourceRoot, cache);
|
|
32
|
+
try {
|
|
33
|
+
await gitWrite(cache, ['push', remote, `${commit}:${ref}`]);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error('batch_source_push_failed');
|
|
37
|
+
}
|
|
38
|
+
const remoteHash = (await gitWrite(cache, ['ls-remote', remote, ref]).catch(() => '')).slice(0, commit.length);
|
|
39
|
+
if (remoteHash !== commit)
|
|
40
|
+
throw new Error('batch_source_push_failed');
|
|
41
|
+
}
|
|
42
|
+
export async function freezeBatchSource(state, body, options) {
|
|
43
|
+
const parsed = sandboxBatchSourceFreezeRequestSchema.safeParse(body);
|
|
44
|
+
if (!parsed.success)
|
|
45
|
+
throw new Error('invalid_batch_request');
|
|
46
|
+
const request = parsed.data;
|
|
47
|
+
return serializeFreeze(async () => {
|
|
48
|
+
const existing = await readSnapshot(state, request.batchId);
|
|
49
|
+
if (existing)
|
|
50
|
+
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: existing });
|
|
51
|
+
const source = await workspaceRoot(state, request.workspaceId, request.sourceDirectoryName);
|
|
52
|
+
const percent = Math.min(100, Math.max(0, options?.usagePercent ?? await usagePercent(source)));
|
|
53
|
+
if (workspaceDiskAdmission(percent) === 'paused')
|
|
54
|
+
throw new Error('disk_admission_paused');
|
|
55
|
+
await stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName);
|
|
56
|
+
const cache = sourceCacheDir(state, request.batchId);
|
|
57
|
+
await rm(cache, { recursive: true, force: true });
|
|
58
|
+
const captured = await captureIndependentSource(source, cache);
|
|
59
|
+
await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
|
|
60
|
+
const binding = state.journal.getWorkspace(request.workspaceId);
|
|
61
|
+
const frozenAt = new Date((await gitRead(cache, ['show', '-s', '--format=%cI', 'HEAD'])).trim()).toISOString();
|
|
62
|
+
const snapshot = {
|
|
63
|
+
batchId: request.batchId,
|
|
64
|
+
commitHash: captured.commitHash,
|
|
65
|
+
digest: captured.digest,
|
|
66
|
+
sourceWorkspaceId: request.workspaceId,
|
|
67
|
+
sourceDirectoryName: request.sourceDirectoryName,
|
|
68
|
+
frozenAt,
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
if (request.persist === 'remote_ref') {
|
|
72
|
+
if (!binding?.repositoryUrl)
|
|
73
|
+
throw new Error('batch_source_push_failed');
|
|
74
|
+
const ref = batchSourceRemoteRef(request.batchId);
|
|
75
|
+
await pushHiddenRef(cache, source, binding.repositoryUrl, captured.commitHash, ref);
|
|
76
|
+
snapshot.remoteRef = ref;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const key = `workspaces/${request.workspaceId}/batches/${request.batchId}/source/${captured.digest}.json.gz`;
|
|
80
|
+
const store = options?.objectStore ?? filesystemObjectStore(state);
|
|
81
|
+
try {
|
|
82
|
+
await store.put(key, await archiveSource(cache, captured));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof Error && error.message === 'batch_object_key_invalid')
|
|
86
|
+
throw error;
|
|
87
|
+
throw new Error('batch_source_upload_failed');
|
|
88
|
+
}
|
|
89
|
+
snapshot.artifactKey = key;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
await rm(cache, { recursive: true, force: true });
|
|
94
|
+
await rm(`${cache}.digest`, { force: true });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (!await lstat(join(cache, '.git')).catch(() => undefined))
|
|
98
|
+
throw new Error('batch_source_capture_failed');
|
|
99
|
+
await writeSnapshot(state, snapshot);
|
|
100
|
+
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { hashOperationPayload, operationScopeKey, sandboxBatchMemberGenerateSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { attachNativeCollector, executeOperation } from '../server/dispatch.js';
|
|
7
|
+
import { workspaceRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
|
|
9
|
+
import { groupExited, stopProcessGroup } from './processes.js';
|
|
10
|
+
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
|
|
11
|
+
function hashCommand(value) {
|
|
12
|
+
return hashOperationPayload(value);
|
|
13
|
+
}
|
|
14
|
+
function operation(state, input) {
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
const record = {
|
|
17
|
+
operationId: randomUUID(), environment: state.config.environment, userId: state.config.userId,
|
|
18
|
+
clientRequestId: input.clientRequestId, kind: input.kind, status: 'queued', payloadHash: hashOperationPayload(input.payload),
|
|
19
|
+
workspaceId: input.workspaceId, directoryName: input.directoryName, sessionId: input.sessionId,
|
|
20
|
+
generation: state.config.generation, runtimeEpoch: state.runtimeEpoch, leaseFence: state.acceptedLeaseFence,
|
|
21
|
+
createdAt: now, updatedAt: now,
|
|
22
|
+
};
|
|
23
|
+
return { scopeKey: operationScopeKey(record), record };
|
|
24
|
+
}
|
|
25
|
+
function turnTerminal(event) {
|
|
26
|
+
if (event.kind === 'approval_request')
|
|
27
|
+
return 'needs_user';
|
|
28
|
+
if (event.method !== 'turn/completed' && event.method !== 'turn.completed' && event.method !== 'turn.failed' && event.method !== 'turn.interrupted')
|
|
29
|
+
return undefined;
|
|
30
|
+
const turn = event.params && typeof event.params === 'object' ? event.params.turn : undefined;
|
|
31
|
+
if (event.method === 'turn.failed' || turn?.status === 'failed')
|
|
32
|
+
return 'failed';
|
|
33
|
+
if (event.method === 'turn.interrupted' || turn?.status === 'interrupted')
|
|
34
|
+
return 'interrupted';
|
|
35
|
+
return 'completed';
|
|
36
|
+
}
|
|
37
|
+
function threadIdOf(event) {
|
|
38
|
+
const params = event.params && typeof event.params === 'object' ? event.params : undefined;
|
|
39
|
+
return typeof params?.threadId === 'string' ? params.threadId : undefined;
|
|
40
|
+
}
|
|
41
|
+
async function proveStopped(record) {
|
|
42
|
+
let stopped = true;
|
|
43
|
+
for (const process of record.processes) {
|
|
44
|
+
if (process.stopped || groupExited(process.pid))
|
|
45
|
+
process.stopped = true;
|
|
46
|
+
else {
|
|
47
|
+
const did = await stopProcessGroup(process);
|
|
48
|
+
process.stopped = did;
|
|
49
|
+
if (!did)
|
|
50
|
+
stopped = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return stopped;
|
|
54
|
+
}
|
|
55
|
+
export async function generateBatchMember(state, body) {
|
|
56
|
+
if (!body || typeof body !== 'object' || !('occupancyId' in body) || !body.occupancyId)
|
|
57
|
+
throw new Error('occupancy_required');
|
|
58
|
+
const parsed = sandboxBatchMemberGenerateSchema.safeParse(body);
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
throw new Error('invalid_batch_request');
|
|
61
|
+
const command = parsed.data;
|
|
62
|
+
const hash = hashCommand(command);
|
|
63
|
+
return serializeRun(command.runId, async () => {
|
|
64
|
+
attachNativeCollector(state);
|
|
65
|
+
const existing = await readMember(state, command.runId);
|
|
66
|
+
if (existing && command.ownerFence < existing.ownerFence)
|
|
67
|
+
throw new Error('stale_owner');
|
|
68
|
+
if (await readStopMarker(state, command.runId))
|
|
69
|
+
return memberStatus(await stoppedRecord(state, command, existing));
|
|
70
|
+
if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.generate === hash && existing.phase === 'generate' && existing.state !== 'running') {
|
|
71
|
+
return memberStatus(await reconcileGenerate(state, existing));
|
|
72
|
+
}
|
|
73
|
+
if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.generate && existing.commandHash.generate !== hash)
|
|
74
|
+
throw new Error('batch_command_conflict');
|
|
75
|
+
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
76
|
+
const sessionId = command.sessionId ?? existing?.sessionId ?? `member-${command.runId}`;
|
|
77
|
+
const tempDir = join(state.config.stateDir, 'batch-temp', command.runId);
|
|
78
|
+
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
79
|
+
const policy = memberExecutionPolicy(root, tempDir);
|
|
80
|
+
await bindMemberPolicy(state, sessionId, policy);
|
|
81
|
+
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? { modelConfigId: command.model } : {}) };
|
|
82
|
+
const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
|
|
83
|
+
const prior = state.journal.getOperation(turnKey.scopeKey);
|
|
84
|
+
if (prior && prior.payloadHash !== turnKey.record.payloadHash)
|
|
85
|
+
throw new Error('batch_command_conflict');
|
|
86
|
+
let record = await writeMember(state, {
|
|
87
|
+
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
88
|
+
directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
|
|
89
|
+
writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
|
|
90
|
+
operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
|
|
91
|
+
snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
|
|
92
|
+
updatedAt: new Date().toISOString(),
|
|
93
|
+
});
|
|
94
|
+
if (prior)
|
|
95
|
+
return memberStatus(await finishGenerate(state, record, prior, root));
|
|
96
|
+
if (!state.native?.ready()) {
|
|
97
|
+
record = await writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: 'capability_unavailable' });
|
|
98
|
+
return memberStatus(record);
|
|
99
|
+
}
|
|
100
|
+
const terminals = new Map();
|
|
101
|
+
const unsubscribe = state.native.onCollected?.((event) => {
|
|
102
|
+
const threadId = threadIdOf(event);
|
|
103
|
+
const terminal = turnTerminal(event);
|
|
104
|
+
if (!threadId || !terminal)
|
|
105
|
+
return;
|
|
106
|
+
if (terminals.get(threadId) === 'needs_user')
|
|
107
|
+
return;
|
|
108
|
+
terminals.set(threadId, terminal);
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
if (!state.journal.getSession(sessionId)) {
|
|
112
|
+
const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto' } });
|
|
113
|
+
if (!state.journal.getOperation(created.scopeKey))
|
|
114
|
+
await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto' });
|
|
115
|
+
}
|
|
116
|
+
if (await readStopMarker(state, command.runId))
|
|
117
|
+
return memberStatus(await stoppedRecord(state, command, record));
|
|
118
|
+
const started = await executeOperation(state, turnKey.scopeKey, turnKey.record, turnPayload);
|
|
119
|
+
record = await writeMember(state, { ...record, operationId: started.operationId, sessionId });
|
|
120
|
+
return memberStatus(await finishGenerate(state, record, started, root, () => {
|
|
121
|
+
const threadId = state.journal.getSession(sessionId)?.threadId;
|
|
122
|
+
return threadId ? terminals.get(threadId) : undefined;
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
unsubscribe?.();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function finishGenerate(state, record, started, root, early) {
|
|
131
|
+
if (await readStopMarker(state, record.runId))
|
|
132
|
+
return stoppedRecord(state, { ...record, occupancyId: record.occupancyId }, record);
|
|
133
|
+
const sessionId = record.sessionId;
|
|
134
|
+
let terminal = early?.();
|
|
135
|
+
if (!terminal && (started.status === 'uncertain' || started.error === 'native_receipt_lost')) {
|
|
136
|
+
return writeMember(state, { ...record, state: 'unknown', writersStopped: false, holding: true, operationId: started.operationId, error: 'native_receipt_lost' });
|
|
137
|
+
}
|
|
138
|
+
if (!terminal && started.status === 'failed') {
|
|
139
|
+
const writersStopped = await proveStopped(record);
|
|
140
|
+
return writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping', writersStopped, holding: !writersStopped, operationId: started.operationId, error: started.error ?? 'batch_turn_failed' });
|
|
141
|
+
}
|
|
142
|
+
const deadline = Date.now() + 5_000;
|
|
143
|
+
while (!terminal && Date.now() < deadline) {
|
|
144
|
+
terminal = early?.();
|
|
145
|
+
const session = state.journal.getSession(sessionId);
|
|
146
|
+
if (session?.turnStatus === 'waiting_for_user')
|
|
147
|
+
terminal = 'needs_user';
|
|
148
|
+
else if (session?.turnStatus === 'completed')
|
|
149
|
+
terminal = 'completed';
|
|
150
|
+
else if (session?.turnStatus === 'failed')
|
|
151
|
+
terminal = 'failed';
|
|
152
|
+
else if (session?.turnStatus === 'interrupted')
|
|
153
|
+
terminal = 'interrupted';
|
|
154
|
+
if (!terminal)
|
|
155
|
+
await delay(15);
|
|
156
|
+
}
|
|
157
|
+
if (terminal === 'needs_user') {
|
|
158
|
+
const session = state.journal.getSession(sessionId);
|
|
159
|
+
if (session?.threadId)
|
|
160
|
+
await state.native?.interrupt?.({ sessionId, threadId: session.threadId }).catch(() => undefined);
|
|
161
|
+
const writersStopped = await proveStopped(record);
|
|
162
|
+
if (await readStopMarker(state, record.runId))
|
|
163
|
+
return stoppedRecord(state, record, record);
|
|
164
|
+
return writeMember(state, {
|
|
165
|
+
...record, state: writersStopped ? 'needs_user' : 'stopping', writersStopped, holding: writersStopped ? false : true,
|
|
166
|
+
operationId: started.operationId, error: writersStopped ? 'needs_user' : undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (terminal === 'completed') {
|
|
170
|
+
const writersStopped = await proveStopped(record);
|
|
171
|
+
if (!writersStopped)
|
|
172
|
+
return writeMember(state, { ...record, state: 'stopping', writersStopped: false, holding: true, operationId: started.operationId });
|
|
173
|
+
if (await readStopMarker(state, record.runId))
|
|
174
|
+
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 });
|
|
177
|
+
}
|
|
178
|
+
const writersStopped = await proveStopped(record);
|
|
179
|
+
return writeMember(state, {
|
|
180
|
+
...record, state: writersStopped ? (terminal === 'interrupted' ? 'stopped' : 'failed') : 'unknown',
|
|
181
|
+
writersStopped, holding: !writersStopped, operationId: started.operationId,
|
|
182
|
+
error: writersStopped ? (terminal === 'interrupted' ? undefined : 'batch_turn_failed') : 'native_receipt_lost',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async function reconcileGenerate(state, record) {
|
|
186
|
+
const session = record.sessionId ? state.journal.getSession(record.sessionId) : undefined;
|
|
187
|
+
if (record.state === 'unknown' && session?.turnStatus === 'completed') {
|
|
188
|
+
const writersStopped = await proveStopped(record);
|
|
189
|
+
if (writersStopped && !await readStopMarker(state, record.runId)) {
|
|
190
|
+
return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, error: undefined });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return record;
|
|
194
|
+
}
|
|
195
|
+
async function stoppedRecord(state, command, existing) {
|
|
196
|
+
if (existing)
|
|
197
|
+
await proveStopped(existing);
|
|
198
|
+
return writeMember(state, {
|
|
199
|
+
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
200
|
+
directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: 'stopped', writersStopped: true, holding: false,
|
|
201
|
+
stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
|
|
202
|
+
headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
|
|
203
|
+
branchName: existing?.branchName, updatedAt: new Date().toISOString(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
const execute = promisify(execFile);
|
|
5
|
+
/** Read-only git. An empty credential helper keeps the source checkout from authenticating or writing. */
|
|
6
|
+
export async function gitRead(root, args) {
|
|
7
|
+
return runGit(root, ['-c', 'credential.helper=', ...args]);
|
|
8
|
+
}
|
|
9
|
+
/** Push and fetch. Local credential helpers copied onto this repo stay in effect. */
|
|
10
|
+
export async function gitWrite(root, args) {
|
|
11
|
+
return runGit(root, args);
|
|
12
|
+
}
|
|
13
|
+
async function runGit(root, args) {
|
|
14
|
+
const { stdout } = await execute('git', [
|
|
15
|
+
'-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', '-c', 'core.fsmonitor=false', '-C', root, ...args,
|
|
16
|
+
], {
|
|
17
|
+
timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
|
|
18
|
+
env: {
|
|
19
|
+
PATH: process.env.PATH, HOME: dirname(root), GIT_OPTIONAL_LOCKS: '0',
|
|
20
|
+
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return stdout;
|
|
24
|
+
}
|
|
25
|
+
/** Copies URL credential helpers onto a snapshot repo without reading or writing the source config. */
|
|
26
|
+
export async function copyCredentialHelpers(sourceRoot, destinationRoot) {
|
|
27
|
+
const listed = await gitRead(sourceRoot, ['config', '--local', '--get-regexp', '^(credential\\.|http\\.|url\\.)']).catch(() => '');
|
|
28
|
+
for (const line of listed.split('\n')) {
|
|
29
|
+
if (!line)
|
|
30
|
+
continue;
|
|
31
|
+
const space = line.indexOf(' ');
|
|
32
|
+
if (space < 1)
|
|
33
|
+
continue;
|
|
34
|
+
const key = line.slice(0, space);
|
|
35
|
+
const value = line.slice(space + 1);
|
|
36
|
+
if (!/^(credential\.|http\.|url\.)/.test(key) || /[\r\n]/.test(value))
|
|
37
|
+
continue;
|
|
38
|
+
await gitWrite(destinationRoot, ['config', '--local', key, value]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { listMembers } from './records.js';
|
|
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) {
|
|
4
|
+
if (!workspaceId)
|
|
5
|
+
return false;
|
|
6
|
+
const members = await listMembers(state);
|
|
7
|
+
return members.some(record => record.holding && record.workspaceId === workspaceId && record.directoryName === directoryName);
|
|
8
|
+
}
|