@fitlab-ai/agent-infra 0.9.5 → 0.9.7
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/bin/internal-cli.js +8 -3
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +171 -20
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +68 -8
- package/dist/lib/agent-clients/adapters/codex.js +1 -1
- package/dist/lib/defaults.json +10 -2
- package/dist/lib/git/workflow.js +30 -4
- package/dist/lib/internal/codex-lifecycle.js +169 -3
- package/dist/lib/internal/codex-sandbox-controller.js +106 -0
- package/dist/lib/internal/release-workflow.js +108 -11
- package/dist/lib/internal/sandbox-control.js +43 -6
- package/dist/lib/internal/task-create.js +12 -4
- package/dist/lib/internal/task-orchestration.js +40 -7
- package/dist/lib/platform/context.js +6 -4
- package/dist/lib/platform/github-client.js +55 -2
- package/dist/lib/platform/issue-comments.js +2 -1
- package/dist/lib/process-data/checkpoint.js +319 -0
- package/dist/lib/process-data/index.js +128 -60
- package/dist/lib/process-data/materialize.js +84 -0
- package/dist/lib/process-data/normalize.js +34 -1
- package/dist/lib/process-data/reconcile.js +55 -1
- package/dist/lib/process-data/sources.js +422 -1
- package/dist/lib/process-data/store.js +341 -11
- package/dist/lib/release/workflow.js +29 -21
- package/dist/lib/sandbox/commands/create.js +111 -60
- package/dist/lib/sandbox/commands/prune.js +40 -16
- package/dist/lib/sandbox/commands/rm.js +372 -94
- package/dist/lib/sandbox/commands/start.js +4 -3
- package/dist/lib/sandbox/control/client.js +138 -22
- package/dist/lib/sandbox/control/container-identity.js +64 -0
- package/dist/lib/sandbox/control/executor.js +142 -0
- package/dist/lib/sandbox/control/lifecycle.js +557 -0
- package/dist/lib/sandbox/control/protocol.js +69 -26
- package/dist/lib/sandbox/control/server.js +395 -99
- package/dist/lib/sandbox/control/state.js +191 -0
- package/dist/lib/sandbox/control/timing.js +8 -0
- package/dist/lib/sandbox/index.js +2 -2
- package/dist/lib/sandbox/managed-fs.js +14 -2
- package/dist/lib/sandbox/mounts.js +22 -19
- package/dist/lib/sandbox/recovery.js +284 -50
- package/dist/lib/sandbox/workspace-view.js +90 -10
- package/dist/lib/sandbox/worktree-safety.js +196 -0
- package/dist/lib/server/daemon.js +1 -1
- package/dist/lib/server/process-control.js +17 -1
- package/dist/lib/server/process-state.js +57 -20
- package/dist/lib/task/artifact-lifecycle.js +87 -15
- package/dist/lib/task/artifact-receipts.js +128 -0
- package/dist/lib/task/codex-orchestration.js +236 -0
- package/dist/lib/task/commands/status.js +3 -0
- package/dist/lib/task/commands/validate.js +289 -0
- package/dist/lib/task/commit-finalization.js +36 -9
- package/dist/lib/task/create.js +10 -2
- package/dist/lib/task/delegation-receipts.js +109 -2
- package/dist/lib/task/events.js +149 -6
- package/dist/lib/task/finalization-artifacts.js +2 -1
- package/dist/lib/task/implementation-inputs.js +1 -7
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/ledger.js +13 -1
- package/dist/lib/task/orchestration.js +365 -15
- package/dist/lib/task/sections.js +5 -1
- package/dist/lib/task/task-execution-lock.js +5 -4
- package/dist/lib/task/verification-engine.js +78 -14
- package/dist/lib/task/verification.js +1 -0
- package/dist/lifecycle-build-manifest.json +6 -0
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +202 -18
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
- package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +83 -6
- package/lib/agent-clients/adapters/codex.ts +1 -1
- package/lib/defaults.json +10 -2
- package/lib/git/workflow.ts +25 -3
- package/lib/internal/codex-lifecycle.ts +175 -4
- package/lib/internal/codex-sandbox-controller.ts +109 -0
- package/lib/internal/release-workflow.ts +107 -11
- package/lib/internal/sandbox-control.ts +38 -6
- package/lib/internal/task-create.ts +16 -4
- package/lib/internal/task-orchestration.ts +34 -7
- package/lib/platform/context.ts +8 -5
- package/lib/platform/github-client.ts +61 -3
- package/lib/platform/issue-comments.ts +2 -1
- package/lib/process-data/checkpoint.ts +335 -0
- package/lib/process-data/index.ts +112 -60
- package/lib/process-data/materialize.ts +83 -0
- package/lib/process-data/normalize.ts +29 -1
- package/lib/process-data/reconcile.ts +64 -2
- package/lib/process-data/sources.ts +449 -2
- package/lib/process-data/store.ts +304 -11
- package/lib/process-data/types.ts +124 -2
- package/lib/release/workflow.ts +69 -20
- package/lib/sandbox/commands/create.ts +70 -25
- package/lib/sandbox/commands/prune.ts +53 -15
- package/lib/sandbox/commands/rm.ts +423 -108
- package/lib/sandbox/commands/start.ts +4 -3
- package/lib/sandbox/control/client.ts +152 -28
- package/lib/sandbox/control/container-identity.ts +93 -0
- package/lib/sandbox/control/executor.ts +167 -0
- package/lib/sandbox/control/lifecycle.ts +573 -0
- package/lib/sandbox/control/protocol.ts +121 -68
- package/lib/sandbox/control/server.ts +398 -109
- package/lib/sandbox/control/state.ts +213 -0
- package/lib/sandbox/control/timing.ts +12 -0
- package/lib/sandbox/index.ts +2 -2
- package/lib/sandbox/managed-fs.ts +17 -2
- package/lib/sandbox/mounts.ts +16 -8
- package/lib/sandbox/recovery.ts +335 -65
- package/lib/sandbox/workspace-view.ts +102 -11
- package/lib/sandbox/worktree-safety.ts +234 -0
- package/lib/server/daemon.ts +1 -1
- package/lib/server/process-control.ts +24 -3
- package/lib/server/process-state.ts +82 -27
- package/lib/task/artifact-lifecycle.ts +78 -14
- package/lib/task/artifact-receipts.ts +161 -0
- package/lib/task/codex-orchestration.ts +319 -0
- package/lib/task/commands/status.ts +2 -0
- package/lib/task/commands/validate.ts +282 -0
- package/lib/task/commit-finalization.ts +52 -9
- package/lib/task/create.ts +11 -2
- package/lib/task/delegation-receipts.ts +208 -7
- package/lib/task/events.ts +131 -5
- package/lib/task/finalization-artifacts.ts +2 -1
- package/lib/task/implementation-inputs.ts +1 -5
- package/lib/task/index.ts +8 -0
- package/lib/task/ledger.ts +16 -1
- package/lib/task/orchestration.ts +552 -18
- package/lib/task/sections.ts +6 -2
- package/lib/task/task-execution-lock.ts +11 -9
- package/lib/task/verification-engine.ts +93 -24
- package/lib/task/verification.ts +2 -1
- package/package.json +1 -1
- package/templates/.agents/README.en.md +7 -0
- package/templates/.agents/README.zh-CN.md +5 -0
- package/templates/.agents/hooks/lifecycle-delegation.js +72 -12
- package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
- package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
- package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
- package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
- package/templates/.agents/rules/review-handshake.en.md +3 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +25 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +25 -0
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +69 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +69 -0
- package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -3
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -3
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
- package/templates/.claude/commands/run-manual-validation.en.md +9 -0
- package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
- package/templates/.codex/hooks.json +8 -8
- package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
- package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
- package/templates/.agents/workspace/README.en.md +0 -26
- package/templates/.agents/workspace/README.zh-CN.md +0 -26
|
@@ -9,6 +9,23 @@ export type SandboxWorkspaceView = Readonly<{
|
|
|
9
9
|
taskMountPath: string | null;
|
|
10
10
|
}>;
|
|
11
11
|
|
|
12
|
+
export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
|
|
13
|
+
'active',
|
|
14
|
+
'completed',
|
|
15
|
+
'blocked',
|
|
16
|
+
'archive'
|
|
17
|
+
] as const);
|
|
18
|
+
|
|
19
|
+
export function sandboxWorkspaceViewStatePaths(root: string): ReadonlyArray<Readonly<{
|
|
20
|
+
state: typeof SANDBOX_WORKSPACE_VIEW_STATES[number];
|
|
21
|
+
hostPath: string;
|
|
22
|
+
}>> {
|
|
23
|
+
return SANDBOX_WORKSPACE_VIEW_STATES.map((state) => ({
|
|
24
|
+
state,
|
|
25
|
+
hostPath: path.join(root, state)
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
export function sandboxWorkspaceViewPaths(params: Readonly<{
|
|
13
30
|
base: string;
|
|
14
31
|
project: string;
|
|
@@ -30,9 +47,12 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
|
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
export type SandboxControlSetup = Readonly<{
|
|
50
|
+
root: string;
|
|
33
51
|
channelDir: string;
|
|
52
|
+
statusDir: string;
|
|
34
53
|
manifestPath: string;
|
|
35
54
|
token: string;
|
|
55
|
+
generation: string;
|
|
36
56
|
}>;
|
|
37
57
|
|
|
38
58
|
export function sandboxControlPaths(params: Readonly<{
|
|
@@ -40,13 +60,19 @@ export function sandboxControlPaths(params: Readonly<{
|
|
|
40
60
|
project: string;
|
|
41
61
|
container: string;
|
|
42
62
|
identity: SandboxWorkspaceIdentity;
|
|
43
|
-
}>): Readonly<{ root: string; channelDir: string; manifestPath: string }> {
|
|
63
|
+
}>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; manifestPath: string }> {
|
|
44
64
|
const identityKey = params.identity.mode === 'task-bound'
|
|
45
65
|
? `task-bound:${params.identity.taskId}`
|
|
46
66
|
: 'branch-only';
|
|
47
67
|
const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
|
|
48
68
|
const root = path.resolve(params.base, params.project, params.container, digest);
|
|
49
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
root,
|
|
71
|
+
channelDir: path.join(root, 'channel'),
|
|
72
|
+
statusDir: path.join(root, 'public'),
|
|
73
|
+
processingDir: path.join(root, 'processing'),
|
|
74
|
+
manifestPath: path.join(root, 'manifest.json')
|
|
75
|
+
};
|
|
50
76
|
}
|
|
51
77
|
|
|
52
78
|
function assertSafeDirectory(directory: string, expectedBase: string): void {
|
|
@@ -74,6 +100,43 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
|
|
|
74
100
|
return canonical;
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
export function prepareSandboxWorkspaceMountTarget(worktreeRoot: string): string {
|
|
104
|
+
const worktree = path.resolve(worktreeRoot);
|
|
105
|
+
const canonicalWorktree = fs.realpathSync.native(worktree);
|
|
106
|
+
const agentsRoot = path.join(worktree, '.agents');
|
|
107
|
+
if (fs.existsSync(agentsRoot)) {
|
|
108
|
+
const agentsStat = fs.lstatSync(agentsRoot);
|
|
109
|
+
if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink()) {
|
|
110
|
+
throw new Error(`Sandbox workspace mount ancestor must be a real directory: ${agentsRoot}`);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
fs.mkdirSync(agentsRoot, { mode: 0o700 });
|
|
114
|
+
}
|
|
115
|
+
const canonicalAgents = fs.realpathSync.native(agentsRoot);
|
|
116
|
+
if (path.dirname(canonicalAgents) !== canonicalWorktree) {
|
|
117
|
+
throw new Error(`Sandbox workspace mount ancestor escapes the worktree: ${agentsRoot}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const target = path.join(agentsRoot, 'workspace');
|
|
121
|
+
if (fs.existsSync(target)) {
|
|
122
|
+
const stat = fs.lstatSync(target);
|
|
123
|
+
if (stat.isSymbolicLink()) {
|
|
124
|
+
throw new Error(`Sandbox workspace mount target must not be a symbolic link: ${target}`);
|
|
125
|
+
}
|
|
126
|
+
if (!stat.isDirectory()) {
|
|
127
|
+
throw new Error(`Sandbox workspace mount target must be a directory: ${target}`);
|
|
128
|
+
}
|
|
129
|
+
if (path.dirname(fs.realpathSync.native(target)) !== canonicalAgents) {
|
|
130
|
+
throw new Error(`Sandbox workspace mount target escapes the worktree: ${target}`);
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
134
|
+
}
|
|
135
|
+
fs.chmodSync(target, 0o700);
|
|
136
|
+
fs.accessSync(target, fs.constants.W_OK);
|
|
137
|
+
return target;
|
|
138
|
+
}
|
|
139
|
+
|
|
77
140
|
export function materializeSandboxWorkspaceView(params: Readonly<{
|
|
78
141
|
base: string;
|
|
79
142
|
project: string;
|
|
@@ -85,11 +148,10 @@ export function materializeSandboxWorkspaceView(params: Readonly<{
|
|
|
85
148
|
assertSafeDirectory(projectRoot, path.resolve(params.base));
|
|
86
149
|
assertSafeDirectory(root, projectRoot);
|
|
87
150
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
88
|
-
for (const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
fs.
|
|
92
|
-
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
for (const { hostPath } of sandboxWorkspaceViewStatePaths(root)) {
|
|
152
|
+
assertSafeDirectory(hostPath, root);
|
|
153
|
+
fs.rmSync(hostPath, { recursive: true, force: true });
|
|
154
|
+
fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
|
|
93
155
|
}
|
|
94
156
|
|
|
95
157
|
const active = path.join(root, 'active');
|
|
@@ -118,13 +180,18 @@ export function materializeSandboxControl(params: Readonly<{
|
|
|
118
180
|
container: string;
|
|
119
181
|
branch: string;
|
|
120
182
|
identity: SandboxWorkspaceIdentity;
|
|
183
|
+
engine?: string;
|
|
121
184
|
}>): SandboxControlSetup {
|
|
122
|
-
const { root, channelDir, manifestPath } = sandboxControlPaths(params);
|
|
185
|
+
const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
|
|
123
186
|
assertSafeDirectory(root, path.resolve(params.base));
|
|
124
187
|
const consumedDir = path.join(root, 'consumed');
|
|
125
188
|
assertSafeDirectory(consumedDir, root);
|
|
126
189
|
fs.rmSync(consumedDir, { recursive: true, force: true });
|
|
127
190
|
fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
|
|
191
|
+
for (const directory of [statusDir, processingDir]) {
|
|
192
|
+
assertSafeDirectory(directory, root);
|
|
193
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
194
|
+
}
|
|
128
195
|
for (const queue of ['requests', 'responses']) {
|
|
129
196
|
const directory = path.join(channelDir, queue);
|
|
130
197
|
assertSafeDirectory(directory, root);
|
|
@@ -132,19 +199,43 @@ export function materializeSandboxControl(params: Readonly<{
|
|
|
132
199
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
133
200
|
}
|
|
134
201
|
const token = randomBytes(32).toString('hex');
|
|
202
|
+
const generation = randomBytes(16).toString('hex');
|
|
135
203
|
const repoRoot = fs.realpathSync.native(params.repoRoot);
|
|
136
204
|
const manifest: SandboxControlManifest = {
|
|
137
|
-
version:
|
|
205
|
+
version: 4,
|
|
206
|
+
engine: params.engine ?? 'docker',
|
|
138
207
|
repoRoot,
|
|
139
208
|
worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
|
|
140
209
|
project: params.project,
|
|
141
210
|
container: params.container,
|
|
211
|
+
containerIdentity: { id: '', labels: {} },
|
|
142
212
|
branch: params.branch,
|
|
143
213
|
mode: params.identity.mode,
|
|
144
214
|
taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
|
|
145
215
|
token,
|
|
146
|
-
|
|
216
|
+
generation,
|
|
217
|
+
channelDir,
|
|
218
|
+
publicStatusDir: statusDir,
|
|
219
|
+
processingDir
|
|
147
220
|
};
|
|
148
221
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
|
149
|
-
return { channelDir, manifestPath, token };
|
|
222
|
+
return { root, channelDir, statusDir, manifestPath, token, generation };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function finalizeSandboxControlManifest(
|
|
226
|
+
setup: SandboxControlSetup,
|
|
227
|
+
identity: Readonly<{ engine: string; id: string; labels: Readonly<Record<string, string>> }>
|
|
228
|
+
): void {
|
|
229
|
+
if (!identity.engine || !identity.id) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
|
|
230
|
+
const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8')) as Record<string, unknown>;
|
|
231
|
+
manifest.version = 4;
|
|
232
|
+
manifest.engine = identity.engine;
|
|
233
|
+
manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
|
|
234
|
+
const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFileSync(temporary, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
|
|
237
|
+
fs.renameSync(temporary, setup.manifestPath);
|
|
238
|
+
} finally {
|
|
239
|
+
fs.rmSync(temporary, { force: true });
|
|
240
|
+
}
|
|
150
241
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { runProbe } from './shell.ts';
|
|
5
|
+
|
|
6
|
+
type WorktreeChange = {
|
|
7
|
+
indexStatus: string;
|
|
8
|
+
worktreeStatus: string;
|
|
9
|
+
path: string;
|
|
10
|
+
originalPath?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type WorktreeSnapshot = {
|
|
14
|
+
worktree: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
head: string;
|
|
17
|
+
changes: readonly WorktreeChange[];
|
|
18
|
+
identity: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type WorktreeInspection =
|
|
22
|
+
| { status: 'clean'; snapshot: WorktreeSnapshot }
|
|
23
|
+
| { status: 'dirty'; snapshot: WorktreeSnapshot }
|
|
24
|
+
| { status: 'failed'; worktree: string; message: string };
|
|
25
|
+
|
|
26
|
+
type WorktreeRemovalPermit = {
|
|
27
|
+
mode: 'clean' | 'discard';
|
|
28
|
+
snapshot: WorktreeSnapshot;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Probe = typeof runProbe;
|
|
32
|
+
|
|
33
|
+
function probeText(probe: Probe, cwd: string, args: string[]): string {
|
|
34
|
+
const result = probe('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
|
35
|
+
if (result.error) throw result.error;
|
|
36
|
+
if (result.status !== 0) {
|
|
37
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : result.stderr.toString('utf8').trim();
|
|
38
|
+
throw new Error(stderr || `git ${args[0] ?? 'command'} failed with exit code ${result.status ?? 'unknown'}`);
|
|
39
|
+
}
|
|
40
|
+
return typeof result.stdout === 'string' ? result.stdout : result.stdout.toString('utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseStatus(raw: string): WorktreeChange[] {
|
|
44
|
+
const records = raw.split('\0');
|
|
45
|
+
const changes: WorktreeChange[] = [];
|
|
46
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
47
|
+
const record = records[index] ?? '';
|
|
48
|
+
if (!record || record.startsWith('# ')) continue;
|
|
49
|
+
if (record.startsWith('? ')) {
|
|
50
|
+
changes.push({ indexStatus: '?', worktreeStatus: '?', path: record.slice(2) });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (record.startsWith('1 ')) {
|
|
54
|
+
const match = /^1 (..) \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/s.exec(record);
|
|
55
|
+
if (!match) throw new Error('Unable to parse git porcelain v2 ordinary record');
|
|
56
|
+
changes.push({ indexStatus: match[1]![0]!, worktreeStatus: match[1]![1]!, path: match[2]! });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (record.startsWith('2 ')) {
|
|
60
|
+
const match = /^2 (..) \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/s.exec(record);
|
|
61
|
+
const originalPath = records[++index];
|
|
62
|
+
if (!match || originalPath === undefined || originalPath === '') {
|
|
63
|
+
throw new Error('Unable to parse git porcelain v2 rename record');
|
|
64
|
+
}
|
|
65
|
+
changes.push({
|
|
66
|
+
indexStatus: match[1]![0]!,
|
|
67
|
+
worktreeStatus: match[1]![1]!,
|
|
68
|
+
path: match[2]!,
|
|
69
|
+
originalPath
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (record.startsWith('u ')) {
|
|
74
|
+
const match = /^u (..) \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/s.exec(record);
|
|
75
|
+
if (!match) throw new Error('Unable to parse git porcelain v2 unmerged record');
|
|
76
|
+
changes.push({ indexStatus: match[1]![0]!, worktreeStatus: match[1]![1]!, path: match[2]! });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Unsupported git porcelain v2 record '${record[0] ?? ''}'`);
|
|
80
|
+
}
|
|
81
|
+
return changes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function updatePart(hash: ReturnType<typeof createHash>, label: string, value: string | Buffer): void {
|
|
85
|
+
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8');
|
|
86
|
+
hash.update(`${label.length}:${label}:${data.length}:`, 'utf8');
|
|
87
|
+
hash.update(data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveSnapshotPath(worktree: string, relativePath: string): string {
|
|
91
|
+
const resolved = path.resolve(worktree, relativePath);
|
|
92
|
+
const relative = path.relative(worktree, resolved);
|
|
93
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
94
|
+
throw new Error(`Git reported a path outside the worktree: ${JSON.stringify(relativePath)}`);
|
|
95
|
+
}
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hashPath(hash: ReturnType<typeof createHash>, worktree: string, relativePath: string, probe: Probe): void {
|
|
100
|
+
updatePart(hash, 'path', relativePath);
|
|
101
|
+
const target = resolveSnapshotPath(worktree, relativePath);
|
|
102
|
+
let stat: fs.Stats;
|
|
103
|
+
try {
|
|
104
|
+
stat = fs.lstatSync(target);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
107
|
+
updatePart(hash, 'kind', 'absent');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
updatePart(hash, 'mode', String(stat.mode & 0o7777));
|
|
114
|
+
if (stat.isFile()) {
|
|
115
|
+
updatePart(hash, 'kind', 'file');
|
|
116
|
+
updatePart(hash, 'content', fs.readFileSync(target));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (stat.isSymbolicLink()) {
|
|
120
|
+
updatePart(hash, 'kind', 'symlink');
|
|
121
|
+
updatePart(hash, 'target', fs.readlinkSync(target));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (stat.isDirectory()) {
|
|
125
|
+
updatePart(hash, 'kind', 'git-directory');
|
|
126
|
+
const nestedHead = probeText(probe, target, ['rev-parse', '--verify', 'HEAD']).trim();
|
|
127
|
+
const nestedStatus = probeText(probe, target, [
|
|
128
|
+
'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all', '--ignore-submodules=none'
|
|
129
|
+
]);
|
|
130
|
+
const nestedIndex = probeText(probe, target, ['ls-files', '--stage', '-z']);
|
|
131
|
+
updatePart(hash, 'nested-head', nestedHead);
|
|
132
|
+
updatePart(hash, 'nested-status', nestedStatus);
|
|
133
|
+
updatePart(hash, 'nested-index', nestedIndex);
|
|
134
|
+
for (const change of parseStatus(nestedStatus)) {
|
|
135
|
+
hashPath(hash, target, change.path, probe);
|
|
136
|
+
if (change.originalPath) hashPath(hash, target, change.originalPath, probe);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Unsupported file type in dirty snapshot: ${JSON.stringify(relativePath)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function captureOnce(worktree: string, probe: Probe): WorktreeSnapshot {
|
|
144
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
145
|
+
const branch = probeText(probe, resolvedWorktree, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
146
|
+
const head = probeText(probe, resolvedWorktree, ['rev-parse', '--verify', 'HEAD']).trim();
|
|
147
|
+
const status = probeText(probe, resolvedWorktree, [
|
|
148
|
+
'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all', '--ignore-submodules=none'
|
|
149
|
+
]);
|
|
150
|
+
const index = probeText(probe, resolvedWorktree, ['ls-files', '--stage', '-z']);
|
|
151
|
+
if (!branch) throw new Error('Unable to determine worktree branch');
|
|
152
|
+
if (!head) throw new Error('Unable to determine worktree HEAD');
|
|
153
|
+
const changes = parseStatus(status);
|
|
154
|
+
const hash = createHash('sha256');
|
|
155
|
+
updatePart(hash, 'worktree', resolvedWorktree);
|
|
156
|
+
updatePart(hash, 'branch', branch);
|
|
157
|
+
updatePart(hash, 'head', head);
|
|
158
|
+
updatePart(hash, 'status', status);
|
|
159
|
+
updatePart(hash, 'index', index);
|
|
160
|
+
for (const change of changes) {
|
|
161
|
+
hashPath(hash, resolvedWorktree, change.path, probe);
|
|
162
|
+
if (change.originalPath) hashPath(hash, resolvedWorktree, change.originalPath, probe);
|
|
163
|
+
}
|
|
164
|
+
return { worktree: resolvedWorktree, branch, head, changes, identity: hash.digest('hex') };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function inspectWorktree(worktree: string, { probe = runProbe }: { probe?: Probe } = {}): WorktreeInspection {
|
|
168
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
169
|
+
try {
|
|
170
|
+
const first = captureOnce(resolvedWorktree, probe);
|
|
171
|
+
const second = captureOnce(resolvedWorktree, probe);
|
|
172
|
+
if (first.identity !== second.identity) {
|
|
173
|
+
return { status: 'failed', worktree: resolvedWorktree, message: 'Snapshot changed while inspecting worktree' };
|
|
174
|
+
}
|
|
175
|
+
return { status: second.changes.length === 0 ? 'clean' : 'dirty', snapshot: second };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return {
|
|
178
|
+
status: 'failed',
|
|
179
|
+
worktree: resolvedWorktree,
|
|
180
|
+
message: error instanceof Error ? error.message : String(error)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function inspectWorktrees(worktrees: readonly string[]): WorktreeInspection[] {
|
|
186
|
+
return [...new Set(worktrees.map((worktree) => path.resolve(worktree)))].sort()
|
|
187
|
+
.map((worktree) => inspectWorktree(worktree));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function createCleanPermit(snapshot: WorktreeSnapshot): WorktreeRemovalPermit {
|
|
191
|
+
if (snapshot.changes.length > 0) throw new Error('Cannot create a clean permit for a dirty worktree');
|
|
192
|
+
return { mode: 'clean', snapshot };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createDiscardPermit(snapshot: WorktreeSnapshot): WorktreeRemovalPermit {
|
|
196
|
+
if (snapshot.changes.length === 0) throw new Error('Cannot create a discard permit for a clean worktree');
|
|
197
|
+
return { mode: 'discard', snapshot };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function verifyWorktreePermit(
|
|
201
|
+
permit: WorktreeRemovalPermit,
|
|
202
|
+
options: { probe?: Probe } = {}
|
|
203
|
+
): WorktreeSnapshot {
|
|
204
|
+
const current = inspectWorktree(permit.snapshot.worktree, options);
|
|
205
|
+
if (current.status === 'failed') throw new Error(`Unable to verify worktree permit: ${current.message}`);
|
|
206
|
+
if (current.snapshot.identity !== permit.snapshot.identity || (permit.mode === 'clean' && current.status !== 'clean')) {
|
|
207
|
+
throw new Error(`Worktree changed after authorization: ${permit.snapshot.worktree}`);
|
|
208
|
+
}
|
|
209
|
+
return current.snapshot;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatWorktreeSnapshot(snapshot: WorktreeSnapshot): string {
|
|
213
|
+
const lines = snapshot.changes.map((change) => {
|
|
214
|
+
const rename = change.originalPath ? ` <- ${JSON.stringify(change.originalPath)}` : '';
|
|
215
|
+
return ` ${change.indexStatus}${change.worktreeStatus} ${JSON.stringify(change.path)}${rename}`;
|
|
216
|
+
});
|
|
217
|
+
return [
|
|
218
|
+
`Worktree: ${JSON.stringify(snapshot.worktree)}`,
|
|
219
|
+
`Branch: ${snapshot.branch}`,
|
|
220
|
+
`HEAD: ${snapshot.head}`,
|
|
221
|
+
...lines,
|
|
222
|
+
`Snapshot identity: ${snapshot.identity}`
|
|
223
|
+
].join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export {
|
|
227
|
+
createCleanPermit,
|
|
228
|
+
createDiscardPermit,
|
|
229
|
+
formatWorktreeSnapshot,
|
|
230
|
+
inspectWorktree,
|
|
231
|
+
inspectWorktrees,
|
|
232
|
+
verifyWorktreePermit
|
|
233
|
+
};
|
|
234
|
+
export type { WorktreeChange, WorktreeInspection, WorktreeRemovalPermit, WorktreeSnapshot };
|
package/lib/server/daemon.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
removePidFileIfMatches,
|
|
12
12
|
writePidRecord
|
|
13
13
|
} from './process-state.ts';
|
|
14
|
-
import type { PidRecord } from './process-state.ts';
|
|
14
|
+
import type { PidRecord, ProcessIdentity, ProcessState } from './process-state.ts';
|
|
15
15
|
|
|
16
16
|
export { isProcessAlive } from './process-state.ts';
|
|
17
17
|
|
|
@@ -24,6 +24,17 @@ export type StopCommand =
|
|
|
24
24
|
| { kind: 'signal'; signal: NodeJS.Signals }
|
|
25
25
|
| { kind: 'exec'; command: string; args: string[] };
|
|
26
26
|
|
|
27
|
+
export type ProcessTreeStopCommand =
|
|
28
|
+
| { kind: 'group-signal'; pid: number; signal: NodeJS.Signals }
|
|
29
|
+
| { kind: 'exec'; command: string; args: string[] };
|
|
30
|
+
|
|
31
|
+
export function buildProcessTreeStopCommand(pid: number, platform: NodeJS.Platform): ProcessTreeStopCommand {
|
|
32
|
+
if (platform === 'win32') {
|
|
33
|
+
return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
|
|
34
|
+
}
|
|
35
|
+
return { kind: 'group-signal', pid: -pid, signal: 'SIGTERM' };
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
export function buildStopCommand(pid: number, platform: NodeJS.Platform): StopCommand {
|
|
28
39
|
if (platform === 'win32') {
|
|
29
40
|
return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
|
|
@@ -40,13 +51,13 @@ async function waitForProcessIdentity(pid: number, timeoutMs = 2000): Promise<Pi
|
|
|
40
51
|
const deadline = Date.now() + timeoutMs;
|
|
41
52
|
while (Date.now() < deadline) {
|
|
42
53
|
const startTime = getProcessStartTime(pid);
|
|
43
|
-
if (startTime !== null) return { version:
|
|
54
|
+
if (startTime !== null) return { version: 2, pid, startTime };
|
|
44
55
|
await delay(25);
|
|
45
56
|
}
|
|
46
57
|
return null;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
function terminateIfMatching(record:
|
|
60
|
+
function terminateIfMatching(record: ProcessIdentity): void {
|
|
50
61
|
if (!processIdentityMatches(record)) return;
|
|
51
62
|
const command = buildStopCommand(record.pid, process.platform);
|
|
52
63
|
try {
|
|
@@ -72,6 +83,9 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
|
|
|
72
83
|
process.stdout.write(`server already running (pid ${existing.record.pid})\n`);
|
|
73
84
|
return;
|
|
74
85
|
}
|
|
86
|
+
if (existing.kind === 'legacy-json' || existing.kind === 'legacy-pid-only' || existing.kind === 'legacy-unknown') {
|
|
87
|
+
throw new Error('server: legacy pid record requires the matching previous version to stop it before starting');
|
|
88
|
+
}
|
|
75
89
|
if (existing.kind !== 'missing') {
|
|
76
90
|
removePidFileIfMatches(pidPath, existing.snapshot);
|
|
77
91
|
}
|
|
@@ -124,6 +138,9 @@ export async function stop(): Promise<void> {
|
|
|
124
138
|
return;
|
|
125
139
|
}
|
|
126
140
|
if (state.kind !== 'running') {
|
|
141
|
+
if (state.kind === 'legacy-json' || state.kind === 'legacy-pid-only' || state.kind === 'legacy-unknown') {
|
|
142
|
+
throw new Error('server: legacy pid record requires the matching previous version to stop it');
|
|
143
|
+
}
|
|
127
144
|
removePidFileIfMatches(config.pidFile, state.snapshot);
|
|
128
145
|
process.stdout.write('server is not running (removed stale pid file)\n');
|
|
129
146
|
return;
|
|
@@ -159,6 +176,10 @@ export function status(): void {
|
|
|
159
176
|
const state = readProcessState(config.pidFile);
|
|
160
177
|
|
|
161
178
|
if (state.kind !== 'running') {
|
|
179
|
+
if (state.kind === 'legacy-json' || state.kind === 'legacy-pid-only' || state.kind === 'legacy-unknown') {
|
|
180
|
+
process.stdout.write('server: legacy pid record requires the matching previous version to stop it\n');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
162
183
|
if (state.kind !== 'missing') removePidFileIfMatches(config.pidFile, state.snapshot);
|
|
163
184
|
process.stdout.write('server: stopped\n');
|
|
164
185
|
return;
|