@playcraft/cli 0.1.57-beta → 0.1.58-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/workspace.js +43 -0
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +11 -2
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { diagnoseWorkspaceShares, WORKSPACE_SHARED_PATHS } from '../workspace-runtime/workspaces/workspace-shares.js';
|
|
1
4
|
import { workspaceClone, workspacePush } from '../workspace/transfer-client.js';
|
|
2
5
|
import { workspaceMessage as message } from '../workspace/messages.js';
|
|
3
6
|
import { normalizeWorkspaceTransferError, WorkspaceCliTransferError } from '../workspace/transfer-errors.js';
|
|
@@ -22,7 +25,47 @@ export function registerWorkspaceCommands(program) {
|
|
|
22
25
|
process.exitCode = 1;
|
|
23
26
|
}
|
|
24
27
|
};
|
|
28
|
+
const doctor = async (options) => {
|
|
29
|
+
const root = resolve(options.dir ?? process.cwd());
|
|
30
|
+
const info = await stat(root).catch(() => undefined);
|
|
31
|
+
if (!info?.isDirectory()) {
|
|
32
|
+
if (options.json)
|
|
33
|
+
console.log(JSON.stringify({ ok: false, error: 'workspace_root_missing', root }));
|
|
34
|
+
else
|
|
35
|
+
console.error(message('doctorRootMissing'));
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const drift = await diagnoseWorkspaceShares(root);
|
|
40
|
+
if (options.json) {
|
|
41
|
+
console.log(JSON.stringify({
|
|
42
|
+
ok: drift.length === 0,
|
|
43
|
+
root,
|
|
44
|
+
shared: [...WORKSPACE_SHARED_PATHS],
|
|
45
|
+
drift: drift.map((entry) => ({
|
|
46
|
+
path: entry.path.slice(root.length + 1),
|
|
47
|
+
missing: entry.missing,
|
|
48
|
+
owner: entry.owner,
|
|
49
|
+
})),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
else if (drift.length === 0) {
|
|
53
|
+
console.log(message('doctorHealthy'));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
for (const entry of drift) {
|
|
57
|
+
console.log(`${entry.missing}\t${entry.owner}\t${entry.path.slice(root.length + 1)}`);
|
|
58
|
+
}
|
|
59
|
+
console.log(message('doctorSummary', { count: drift.length }));
|
|
60
|
+
console.log(message('doctorHint'));
|
|
61
|
+
}
|
|
62
|
+
process.exitCode = drift.length === 0 ? 0 : 1;
|
|
63
|
+
};
|
|
25
64
|
const workspace = program.command('workspace').description(message('description'));
|
|
65
|
+
workspace.command('doctor').description(message('doctor'))
|
|
66
|
+
.option('--dir <path>', message('doctorTarget'), process.cwd())
|
|
67
|
+
.option('--json', message('json'))
|
|
68
|
+
.action(doctor);
|
|
26
69
|
workspace.command('clone').description(message('clone'))
|
|
27
70
|
.requiredOption('--workspace-id <id>', message('workspaceId'))
|
|
28
71
|
.option('--directory <name>', message('directory'), 'basic')
|
|
@@ -31,6 +31,11 @@ const messages = {
|
|
|
31
31
|
preflight: 'Push preflight passed: {files} files, {bytes} of content. Preparing incremental transfer.',
|
|
32
32
|
upload: 'Uploading incremental transfer: {bytes}.',
|
|
33
33
|
login: 'Log in to the target environment using playcraft login --url',
|
|
34
|
+
doctor: 'List shared workspace paths that lost development-group access',
|
|
35
|
+
doctorTarget: 'Workspace checkout to inspect', doctorRootMissing: 'The target directory does not exist.',
|
|
36
|
+
doctorHealthy: 'All shared workspace paths are readable and writable by the development group.',
|
|
37
|
+
doctorSummary: '{count} shared path(s) need repair.',
|
|
38
|
+
doctorHint: 'Reconnect the workspace to repair entries owned by this user; entries owned by the other identity are repaired when that identity writes again.',
|
|
34
39
|
},
|
|
35
40
|
zh: {
|
|
36
41
|
description: '下载云工作区,并将本地提交推送回来', clone: '克隆当前工作区文件,包含尚未提交的修改',
|
|
@@ -59,6 +64,11 @@ const messages = {
|
|
|
59
64
|
preflight: '推送预检查通过:{files} 个文件,内容共 {bytes}。正在准备增量传输。',
|
|
60
65
|
upload: '正在上传增量传输包:{bytes}。',
|
|
61
66
|
login: '请使用 playcraft login --url 登录目标环境',
|
|
67
|
+
doctor: '列出失去开发组访问权限的共享工作区路径',
|
|
68
|
+
doctorTarget: '要检查的工作区目录', doctorRootMissing: '目标目录不存在。',
|
|
69
|
+
doctorHealthy: '所有共享工作区路径都能被开发组读写。',
|
|
70
|
+
doctorSummary: '有 {count} 个共享路径需要修复。',
|
|
71
|
+
doctorHint: '重新连接工作区可修复属于当前用户的条目;属于另一个身份的条目会在该身份下次写入时修复。',
|
|
62
72
|
},
|
|
63
73
|
};
|
|
64
74
|
export function workspaceMessage(key, values = {}) {
|
|
@@ -1,13 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { constants } from 'node:fs';
|
|
2
|
-
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
7
|
+
import { runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
5
8
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
6
9
|
import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
|
|
10
|
+
import { stopMemberWriters } from './writer-proof.js';
|
|
7
11
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
|
|
8
12
|
/** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
|
|
9
13
|
function buildCommand() {
|
|
10
|
-
return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
14
|
+
return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
|
|
15
|
+
?? 'umask 002 && CI=1 playcraft build --mode base --output dist .');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The batch build runs as the runtime identity while the agent wrote the tree as the development
|
|
19
|
+
* identity. Directories the agent created private (browser profiles, tool caches) are unreadable to
|
|
20
|
+
* the build user, and the build's pre-build scan fails hard on the first one — which is how a
|
|
21
|
+
* delivered-looking member used to end as `batch_build_failed`. Only the owning identity may chmod,
|
|
22
|
+
* so the repair has to run through the launcher rather than the runtime user.
|
|
23
|
+
*/
|
|
24
|
+
const PERMISSION_REPAIR_COMMAND = [
|
|
25
|
+
'find . \\( -path ./node_modules -o -path ./.git \\) -prune -o',
|
|
26
|
+
'\\( -type d \\( -not -perm -040 -o -not -perm -010 \\) -o -type f -not -perm -040 \\)',
|
|
27
|
+
'-exec chmod g+rX {} +',
|
|
28
|
+
].join(' ');
|
|
29
|
+
const BUILD_LOG_LIMIT = 64 * 1024;
|
|
30
|
+
/** Best effort: a failed repair is recorded in the log and never hides the build's own error. */
|
|
31
|
+
async function repairGroupAccess(state, root, runId) {
|
|
32
|
+
let output = '';
|
|
33
|
+
try {
|
|
34
|
+
const outcome = await runDeveloperCommand(state, {
|
|
35
|
+
processId: `batch-deliver-repair-${runId}-${randomUUID()}`,
|
|
36
|
+
cwd: root,
|
|
37
|
+
command: PERMISSION_REPAIR_COMMAND,
|
|
38
|
+
timeoutMs: 30_000,
|
|
39
|
+
onOutput: (chunk) => {
|
|
40
|
+
output = (output + String(chunk)).slice(-BUILD_LOG_LIMIT);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (outcome.code !== 0)
|
|
44
|
+
output += `\n[repair] exit ${outcome.code ?? 'unknown'}${outcome.timedOut ? ' (timeout)' : ''}`;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
output += `\n[repair] failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
48
|
+
}
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
51
|
+
/** Keeps the build's own stderr next to the run so a failure is diagnosable without a re-run. */
|
|
52
|
+
async function writeBuildLog(state, runId, text) {
|
|
53
|
+
const directory = join(state.config.stateDir, 'batch-temp', runId);
|
|
54
|
+
await mkdir(directory, { recursive: true });
|
|
55
|
+
await writeFile(join(directory, 'build.log'), text.slice(-BUILD_LOG_LIMIT));
|
|
56
|
+
}
|
|
57
|
+
/** A build command that writes nothing must not inherit an earlier run's index.html as success. */
|
|
58
|
+
async function clearBuiltHtml(root) {
|
|
59
|
+
await rm(join(root, 'dist', 'index.html'), { force: true }).catch(() => undefined);
|
|
11
60
|
}
|
|
12
61
|
async function readBuiltHtml(root) {
|
|
13
62
|
const htmlPath = join(root, 'dist', 'index.html');
|
|
@@ -67,86 +116,97 @@ export async function deliverBatchMember(state, body) {
|
|
|
67
116
|
existing.state === 'completed') {
|
|
68
117
|
throw new Error('batch_command_conflict');
|
|
69
118
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (await readStopMarker(state, command.runId))
|
|
98
|
-
return memberStatus(await markStopped(state, command, record));
|
|
99
|
-
const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
|
|
100
|
-
record = await writeMember(state, { ...record, processes: [...record.processes, process] });
|
|
101
|
-
});
|
|
102
|
-
record = await writeMember(state, {
|
|
103
|
-
...record,
|
|
104
|
-
processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
|
|
119
|
+
await claimBatchDirectory(state, command);
|
|
120
|
+
return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
|
|
121
|
+
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
122
|
+
let record = await writeMember(state, {
|
|
123
|
+
runId: command.runId,
|
|
124
|
+
ownerFence: command.ownerFence,
|
|
125
|
+
occupancyId: command.occupancyId,
|
|
126
|
+
workspaceId: command.workspaceId,
|
|
127
|
+
directoryName: command.directoryName,
|
|
128
|
+
phase: 'deliver',
|
|
129
|
+
state: 'running',
|
|
130
|
+
writersStopped: false,
|
|
131
|
+
holding: true,
|
|
132
|
+
stopRequested: false,
|
|
133
|
+
processes: existing?.processes ?? [],
|
|
134
|
+
sessionId: existing?.sessionId,
|
|
135
|
+
operationId: existing?.operationId,
|
|
136
|
+
commandHash: { ...existing?.commandHash, deliver: hash },
|
|
137
|
+
snapshotDigest: existing?.snapshotDigest,
|
|
138
|
+
branchName: existing?.branchName,
|
|
139
|
+
updatedAt: new Date().toISOString(),
|
|
140
|
+
sourceCommit: existing?.sourceCommit ??
|
|
141
|
+
state.journal
|
|
142
|
+
.getWorkspace(command.workspaceId)
|
|
143
|
+
?.directories?.find((directory) => directory.directoryName === command.directoryName)
|
|
144
|
+
?.sourceCommit,
|
|
145
|
+
beforeTree: existing?.beforeTree,
|
|
105
146
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
147
|
+
try {
|
|
148
|
+
if (await readStopMarker(state, command.runId))
|
|
149
|
+
return memberStatus(await markStopped(state, command, record));
|
|
150
|
+
const repairOutput = await repairGroupAccess(state, root, command.runId);
|
|
151
|
+
await clearBuiltHtml(root);
|
|
152
|
+
const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
|
|
153
|
+
record = await writeMember(state, { ...record, processes: [...record.processes, process] });
|
|
154
|
+
});
|
|
155
|
+
record = await writeMember(state, {
|
|
114
156
|
...record,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
|
|
158
|
+
});
|
|
159
|
+
if (await readStopMarker(state, command.runId))
|
|
160
|
+
return memberStatus(await markStopped(state, command, record));
|
|
161
|
+
const buildLog = [repairOutput, built.output].filter(Boolean).join('\n');
|
|
162
|
+
if (built.code !== 0) {
|
|
163
|
+
await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
|
|
164
|
+
const tail = built.output.trim().split('\n').slice(-3).join(' | ').slice(0, 400);
|
|
165
|
+
if (tail)
|
|
166
|
+
console.warn(`[workspace-runtime] batch build failed run=${command.runId} code=${built.code}: ${tail}`);
|
|
167
|
+
throw new Error('batch_build_failed');
|
|
168
|
+
}
|
|
169
|
+
await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
|
|
170
|
+
await readBuiltHtml(root);
|
|
171
|
+
const writersStopped = await prove(record);
|
|
172
|
+
if (!writersStopped)
|
|
173
|
+
return memberStatus(await writeMember(state, {
|
|
174
|
+
...record,
|
|
175
|
+
state: 'stopping',
|
|
176
|
+
writersStopped: false,
|
|
177
|
+
holding: true,
|
|
178
|
+
}));
|
|
179
|
+
if (await readStopMarker(state, command.runId))
|
|
180
|
+
return memberStatus(await markStopped(state, command, record));
|
|
181
|
+
record = await writeMember(state, {
|
|
182
|
+
...record,
|
|
183
|
+
state: 'completed',
|
|
184
|
+
writersStopped: true,
|
|
185
|
+
holding: false,
|
|
186
|
+
error: undefined,
|
|
187
|
+
evidence: { verified: true },
|
|
188
|
+
});
|
|
189
|
+
return memberStatus(record);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
|
|
193
|
+
? error.message
|
|
194
|
+
: 'batch_deliver_failed';
|
|
195
|
+
const writersStopped = await prove(record);
|
|
196
|
+
record = await writeMember(state, {
|
|
197
|
+
...record,
|
|
198
|
+
state: writersStopped ? 'failed' : 'stopping',
|
|
199
|
+
writersStopped,
|
|
200
|
+
holding: !writersStopped,
|
|
201
|
+
error: code,
|
|
202
|
+
});
|
|
203
|
+
return memberStatus(record);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
145
206
|
});
|
|
146
207
|
}
|
|
147
208
|
async function markStopped(state, command, existing) {
|
|
148
|
-
|
|
149
|
-
await prove(existing);
|
|
209
|
+
const writersStopped = await stopMemberWriters(state, existing);
|
|
150
210
|
return writeMember(state, {
|
|
151
211
|
runId: command.runId,
|
|
152
212
|
ownerFence: command.ownerFence,
|
|
@@ -154,9 +214,9 @@ async function markStopped(state, command, existing) {
|
|
|
154
214
|
workspaceId: command.workspaceId,
|
|
155
215
|
directoryName: command.directoryName,
|
|
156
216
|
phase: 'deliver',
|
|
157
|
-
state: 'stopped',
|
|
158
|
-
writersStopped
|
|
159
|
-
holding:
|
|
217
|
+
state: writersStopped ? 'stopped' : 'stopping',
|
|
218
|
+
writersStopped,
|
|
219
|
+
holding: !writersStopped,
|
|
160
220
|
stopRequested: true,
|
|
161
221
|
processes: existing?.processes ?? [],
|
|
162
222
|
sessionId: existing?.sessionId,
|
|
@@ -9,6 +9,7 @@ import { filesystemObjectStore, readSnapshot, serializeFreeze, sourceCacheDir, w
|
|
|
9
9
|
import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from './source-candidate.js';
|
|
10
10
|
import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
|
|
11
11
|
import { publishSourceCandidate } from './source-publish.js';
|
|
12
|
+
import { clearDirectoryFreeze, withDirectoryFreeze } from '../server/directory-access.js';
|
|
12
13
|
async function usagePercent(path) {
|
|
13
14
|
const disk = await statfs(path);
|
|
14
15
|
if (disk.blocks <= 0)
|
|
@@ -21,11 +22,8 @@ async function stopManagedWriters(state, workspaceId, directoryName) {
|
|
|
21
22
|
for (const write of state.journal.listManagedWrites()) {
|
|
22
23
|
if (write.stoppedAt || write.workspaceId !== scope)
|
|
23
24
|
continue;
|
|
24
|
-
if (write.pid
|
|
25
|
-
|
|
26
|
-
if (!stopped)
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
25
|
+
if (write.pid === undefined || !await stopBackgroundWriter(write.pid))
|
|
26
|
+
continue;
|
|
29
27
|
await state.journal.putManagedWrite({ ...write, stoppedAt: new Date().toISOString() });
|
|
30
28
|
}
|
|
31
29
|
}
|
|
@@ -34,7 +32,14 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
34
32
|
if (!parsed.success)
|
|
35
33
|
throw new Error('invalid_batch_request');
|
|
36
34
|
const request = parsed.data;
|
|
37
|
-
|
|
35
|
+
const frozen = await readSnapshot(state, request.batchId);
|
|
36
|
+
if (frozen) {
|
|
37
|
+
assertSourceIdentity(state, request, frozen, await readSourceCandidate(state, request.batchId));
|
|
38
|
+
await clearDirectoryFreeze(state, request.batchId);
|
|
39
|
+
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: frozen });
|
|
40
|
+
}
|
|
41
|
+
return withDirectoryFreeze(state, { workspaceId: request.workspaceId,
|
|
42
|
+
directoryName: request.sourceDirectoryName, batchId: request.batchId }, () => serializeFreeze(async () => {
|
|
38
43
|
let candidate;
|
|
39
44
|
let source;
|
|
40
45
|
try {
|
|
@@ -60,6 +65,7 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
60
65
|
const cache = sourceCacheDir(state, request.batchId);
|
|
61
66
|
await rm(cache, { recursive: true, force: true });
|
|
62
67
|
const captured = await captureIndependentSource(source, cache);
|
|
68
|
+
// private
|
|
63
69
|
await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
|
|
64
70
|
const binding = state.journal.getWorkspace(request.workspaceId);
|
|
65
71
|
const snapshot = {
|
|
@@ -97,5 +103,11 @@ export async function freezeBatchSource(state, body, options) {
|
|
|
97
103
|
throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
|
|
98
104
|
}
|
|
99
105
|
return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: candidate.snapshot });
|
|
106
|
+
}), () => stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName), {
|
|
107
|
+
signal: options?.signal, deadline: 'deadline' in request && typeof request.deadline === 'string' ? request.deadline : undefined,
|
|
108
|
+
}).catch(async (error) => {
|
|
109
|
+
if (error instanceof Error && error.message === 'directory_access_cancelled')
|
|
110
|
+
throw await sourceFreezeError(state, request.batchId, { code: 'source_freeze_timeout', stage: 'capture', retryable: false, outcome: 'not_published' });
|
|
111
|
+
throw error;
|
|
100
112
|
});
|
|
101
113
|
}
|
|
@@ -4,12 +4,14 @@ import { join } from 'node:path';
|
|
|
4
4
|
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
|
+
import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
|
|
7
8
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
8
9
|
import { snapshotWorkspaceTree } from '../workspaces/git.js';
|
|
9
10
|
import { requireBatchContentChanges } from './content-changes.js';
|
|
10
11
|
import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
|
|
11
12
|
import { groupExited, stopProcessGroup } from './processes.js';
|
|
12
13
|
import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
|
|
14
|
+
import { stopMemberWriters } from './writer-proof.js';
|
|
13
15
|
function hashCommand(value) {
|
|
14
16
|
return hashOperationPayload(value);
|
|
15
17
|
}
|
|
@@ -74,63 +76,69 @@ export async function generateBatchMember(state, body) {
|
|
|
74
76
|
}
|
|
75
77
|
if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.generate && existing.commandHash.generate !== hash)
|
|
76
78
|
throw new Error('batch_command_conflict');
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!threadId || !terminal)
|
|
111
|
-
return;
|
|
112
|
-
if (terminals.get(threadId) === 'needs_user')
|
|
113
|
-
return;
|
|
114
|
-
terminals.set(threadId, terminal);
|
|
115
|
-
});
|
|
116
|
-
try {
|
|
117
|
-
if (!state.journal.getSession(sessionId)) {
|
|
118
|
-
const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
|
|
119
|
-
if (!state.journal.getOperation(created.scopeKey))
|
|
120
|
-
await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
|
|
79
|
+
if (existing && !existing.holding && existing.writersStopped)
|
|
80
|
+
return memberStatus(existing);
|
|
81
|
+
await claimBatchDirectory(state, command);
|
|
82
|
+
return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
|
|
83
|
+
const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
|
|
84
|
+
const sessionId = command.sessionId ?? existing?.sessionId ?? `member-${command.runId}`;
|
|
85
|
+
const tempDir = join(state.config.stateDir, 'batch-temp', command.runId);
|
|
86
|
+
// private
|
|
87
|
+
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
88
|
+
const policy = memberExecutionPolicy(root, tempDir);
|
|
89
|
+
await bindMemberPolicy(state, sessionId, policy);
|
|
90
|
+
const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
|
|
91
|
+
const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
|
|
92
|
+
const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
|
|
93
|
+
const prior = state.journal.getOperation(turnKey.scopeKey);
|
|
94
|
+
if (prior && prior.payloadHash !== turnKey.record.payloadHash)
|
|
95
|
+
throw new Error('batch_command_conflict');
|
|
96
|
+
const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
|
|
97
|
+
const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
|
|
98
|
+
let record = await writeMember(state, {
|
|
99
|
+
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
100
|
+
directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
|
|
101
|
+
writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
|
|
102
|
+
operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
|
|
103
|
+
snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
|
|
104
|
+
sourceCommit, beforeTree,
|
|
105
|
+
updatedAt: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
if (prior)
|
|
108
|
+
return memberStatus(await finishGenerate(state, record, prior, root));
|
|
109
|
+
if (!state.native?.ready()) {
|
|
110
|
+
record = await writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: 'capability_unavailable' });
|
|
111
|
+
return memberStatus(record);
|
|
121
112
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
113
|
+
const terminals = new Map();
|
|
114
|
+
const unsubscribe = state.native.onCollected?.((event) => {
|
|
115
|
+
const threadId = threadIdOf(event);
|
|
116
|
+
const terminal = turnTerminal(event);
|
|
117
|
+
if (!threadId || !terminal)
|
|
118
|
+
return;
|
|
119
|
+
if (terminals.get(threadId) === 'needs_user')
|
|
120
|
+
return;
|
|
121
|
+
terminals.set(threadId, terminal);
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
if (!state.journal.getSession(sessionId)) {
|
|
125
|
+
const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
|
|
126
|
+
if (!state.journal.getOperation(created.scopeKey))
|
|
127
|
+
await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
|
|
128
|
+
}
|
|
129
|
+
if (await readStopMarker(state, command.runId))
|
|
130
|
+
return memberStatus(await stoppedRecord(state, command, record));
|
|
131
|
+
const started = await executeOperation(state, turnKey.scopeKey, turnKey.record, turnPayload);
|
|
132
|
+
record = await writeMember(state, { ...record, operationId: started.operationId, sessionId });
|
|
133
|
+
return memberStatus(await finishGenerate(state, record, started, root, () => {
|
|
134
|
+
const threadId = state.journal.getSession(sessionId)?.threadId;
|
|
135
|
+
return threadId ? terminals.get(threadId) : undefined;
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
unsubscribe?.();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
134
142
|
});
|
|
135
143
|
}
|
|
136
144
|
async function finishGenerate(state, record, started, root, early) {
|
|
@@ -142,7 +150,7 @@ async function finishGenerate(state, record, started, root, early) {
|
|
|
142
150
|
return writeMember(state, { ...record, state: 'unknown', writersStopped: false, holding: true, operationId: started.operationId, error: 'native_receipt_lost' });
|
|
143
151
|
}
|
|
144
152
|
if (!terminal && started.status === 'failed') {
|
|
145
|
-
const writersStopped = await
|
|
153
|
+
const writersStopped = await stopMemberWriters(state, record);
|
|
146
154
|
return writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping', writersStopped, holding: !writersStopped, operationId: started.operationId, error: started.error ?? 'batch_turn_failed' });
|
|
147
155
|
}
|
|
148
156
|
const deadline = Date.now() + 5_000;
|
|
@@ -161,10 +169,7 @@ async function finishGenerate(state, record, started, root, early) {
|
|
|
161
169
|
await delay(15);
|
|
162
170
|
}
|
|
163
171
|
if (terminal === 'needs_user') {
|
|
164
|
-
const
|
|
165
|
-
if (session?.threadId)
|
|
166
|
-
await state.native?.interrupt?.({ sessionId, threadId: session.threadId }).catch(() => undefined);
|
|
167
|
-
const writersStopped = await proveStopped(record);
|
|
172
|
+
const writersStopped = await stopMemberWriters(state, record);
|
|
168
173
|
if (await readStopMarker(state, record.runId))
|
|
169
174
|
return stoppedRecord(state, record, record);
|
|
170
175
|
return writeMember(state, {
|
|
@@ -236,11 +241,10 @@ async function completeGeneration(state, record, root) {
|
|
|
236
241
|
return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, headCommit: head, error: undefined });
|
|
237
242
|
}
|
|
238
243
|
async function stoppedRecord(state, command, existing) {
|
|
239
|
-
|
|
240
|
-
await proveStopped(existing);
|
|
244
|
+
const writersStopped = await stopMemberWriters(state, existing);
|
|
241
245
|
return writeMember(state, {
|
|
242
246
|
runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
|
|
243
|
-
directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: 'stopped'
|
|
247
|
+
directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: writersStopped ? 'stopped' : 'stopping', writersStopped, holding: !writersStopped,
|
|
244
248
|
stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
|
|
245
249
|
headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
|
|
246
250
|
branchName: existing?.branchName, updatedAt: new Date().toISOString(),
|