@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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { requestSandboxTaskCreate } from '../sandbox/control/client.ts';
|
|
4
|
+
import { requestSandboxTaskCreate, SandboxControlClientError } from '../sandbox/control/client.ts';
|
|
5
5
|
import { SANDBOX_CONTROL_MAX_BYTES } from '../sandbox/control/protocol.ts';
|
|
6
6
|
import { validateTaskCreateCandidate } from '../task/create.ts';
|
|
7
7
|
import { createTask, type TaskCreateResult } from '../task/create-service.ts';
|
|
8
8
|
|
|
9
|
-
function failed(code: string, message: string): TaskCreateResult {
|
|
9
|
+
function failed(code: string, message: string, retryable = false): TaskCreateResult {
|
|
10
10
|
return {
|
|
11
|
-
status: 'failed', changed: false, task: { id: null, shortId: null }, issue: null,
|
|
12
|
-
operations: [], warnings: [], error: { code, message, retryable
|
|
11
|
+
status: retryable ? 'blocked' : 'failed', changed: false, task: { id: null, shortId: null }, issue: null,
|
|
12
|
+
operations: [], warnings: [], error: { code, message, retryable }
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -33,6 +33,14 @@ function taskCreate(args: string[]): void {
|
|
|
33
33
|
const candidate = validateTaskCreateCandidate(JSON.parse(fs.readFileSync(inputPath, 'utf8')));
|
|
34
34
|
if (process.env.AGENT_INFRA_CONTROL_TOKEN) {
|
|
35
35
|
const response = requestSandboxTaskCreate({ candidate });
|
|
36
|
+
if (response.phase === 'rejected') {
|
|
37
|
+
output(failed(
|
|
38
|
+
response.error?.code ?? 'SANDBOX_CONTROL_REJECTED',
|
|
39
|
+
response.error?.message ?? response.stderr,
|
|
40
|
+
response.error?.retryable ?? false
|
|
41
|
+
));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
36
44
|
process.stdout.write(response.stdout);
|
|
37
45
|
process.stderr.write(response.stderr);
|
|
38
46
|
process.exitCode = response.exitCode;
|
|
@@ -40,6 +48,10 @@ function taskCreate(args: string[]): void {
|
|
|
40
48
|
}
|
|
41
49
|
output(createTask(candidate, { repoRoot: process.cwd() }));
|
|
42
50
|
} catch (error) {
|
|
51
|
+
if (error instanceof SandboxControlClientError) {
|
|
52
|
+
output(failed(error.detail.code, error.detail.message, error.detail.retryable));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
43
55
|
const message = error instanceof Error ? error.message : String(error);
|
|
44
56
|
const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'TASK_CREATE_INPUT_INVALID';
|
|
45
57
|
output(failed(code, message));
|
|
@@ -4,13 +4,15 @@ import {
|
|
|
4
4
|
activateMatchingOrchestrationDelegation,
|
|
5
5
|
activateOrchestrationDelegation,
|
|
6
6
|
advanceOrchestration,
|
|
7
|
+
awaitOrchestrationDelegationActivation,
|
|
7
8
|
beginCommitIntent,
|
|
8
9
|
beginOrResumeOrchestration,
|
|
9
10
|
checkpointCommitIntent,
|
|
10
11
|
completeCommitIntent,
|
|
12
|
+
dispatchOrchestrationDelegation,
|
|
11
13
|
pauseOrchestration,
|
|
12
|
-
prepareOrchestrationDelegation,
|
|
13
14
|
recoverCommitIntent,
|
|
15
|
+
recoverPreparedOrchestrationDelegation,
|
|
14
16
|
routeOrchestration,
|
|
15
17
|
sealMatchingOrchestrationDelegation,
|
|
16
18
|
sealOrchestrationDelegation,
|
|
@@ -19,10 +21,11 @@ import {
|
|
|
19
21
|
statusOrchestration,
|
|
20
22
|
terminateCommitAttempt
|
|
21
23
|
} from '../task/orchestration.ts';
|
|
24
|
+
import { prepareCodexOrchestrationDelegation } from '../task/codex-orchestration.ts';
|
|
22
25
|
import { isAgentClientId } from '../agent-clients/types.ts';
|
|
23
26
|
import type { AgentClientId } from '../agent-clients/types.ts';
|
|
24
27
|
|
|
25
|
-
const USAGE = 'Usage: agent-infra-internal task-orchestration <task-ref|auto> <begin-or-resume|route|prepare|hook-start|hook-stop|advance|pause|status|commit-start|commit-begin|commit-checkpoint|commit-complete|commit-recover|commit-abort|commit-terminate|commit-status> [options]\n';
|
|
28
|
+
const USAGE = 'Usage: agent-infra-internal task-orchestration <task-ref|auto> <begin-or-resume|route|prepare|dispatch|await-activation|recover-prepared|hook-start|hook-stop|advance|pause|status|commit-start|commit-begin|commit-checkpoint|commit-complete|commit-recover|commit-abort|commit-terminate|commit-status> [options]\n';
|
|
26
29
|
|
|
27
30
|
function usageFailure(message: string): void {
|
|
28
31
|
process.stdout.write(`${JSON.stringify({
|
|
@@ -33,7 +36,7 @@ function usageFailure(message: string): void {
|
|
|
33
36
|
process.exitCode = 2;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
function taskOrchestration(args: string[] = []): void {
|
|
39
|
+
async function taskOrchestration(args: string[] = []): Promise<void> {
|
|
37
40
|
if (args[0] === '--help' || args[0] === '-h') {
|
|
38
41
|
process.stdout.write(USAGE);
|
|
39
42
|
return;
|
|
@@ -44,7 +47,8 @@ function taskOrchestration(args: string[] = []): void {
|
|
|
44
47
|
}
|
|
45
48
|
const [taskRef, intent] = args;
|
|
46
49
|
if (![
|
|
47
|
-
'begin-or-resume', 'route', 'prepare', '
|
|
50
|
+
'begin-or-resume', 'route', 'prepare', 'dispatch', 'await-activation', 'recover-prepared',
|
|
51
|
+
'hook-start', 'hook-stop', 'advance', 'pause', 'status',
|
|
48
52
|
'commit-start', 'commit-begin', 'commit-checkpoint', 'commit-complete', 'commit-recover', 'commit-abort', 'commit-terminate', 'commit-status'
|
|
49
53
|
].includes(intent!)) {
|
|
50
54
|
usageFailure(`unknown intent '${intent}'`);
|
|
@@ -64,7 +68,9 @@ function taskOrchestration(args: string[] = []): void {
|
|
|
64
68
|
if (![
|
|
65
69
|
'--max-steps', '--executor-model', '--executor-reasoning-effort',
|
|
66
70
|
'--reviewer-model', '--reviewer-reasoning-effort',
|
|
67
|
-
'--client', '--requested-model', '--requested-reasoning-effort', '--
|
|
71
|
+
'--client', '--requested-model', '--requested-reasoning-effort', '--capability-token',
|
|
72
|
+
'--parent-id', '--before-fingerprint',
|
|
73
|
+
'--stage', '--round', '--artifact', '--role',
|
|
68
74
|
'--native-agent', '--child-id', '--spawn-mode', '--actual-model', '--actual-reasoning-effort',
|
|
69
75
|
'--model-fallback-reason', '--reasoning-effort-fallback-reason',
|
|
70
76
|
'--exit-code', '--after-fingerprint', '--changed-paths', '--code', '--message', '--recoverable', '--agent',
|
|
@@ -212,11 +218,32 @@ function taskOrchestration(args: string[] = []): void {
|
|
|
212
218
|
} else if (intent === 'prepare') {
|
|
213
219
|
const missing = requireValues(['--client']);
|
|
214
220
|
if (missing) { usageFailure(`intent 'prepare' requires '${missing}'`); return; }
|
|
215
|
-
result =
|
|
221
|
+
result = await prepareCodexOrchestrationDelegation(taskRef!, {
|
|
216
222
|
client: values['--client'] as AgentClientId,
|
|
217
223
|
requestedModel: values['--requested-model'],
|
|
218
|
-
requestedReasoningEffort: values['--requested-reasoning-effort']
|
|
224
|
+
requestedReasoningEffort: values['--requested-reasoning-effort'],
|
|
225
|
+
capabilityToken: values['--capability-token']
|
|
226
|
+
}, { orchestrationOptions: coreOptions });
|
|
227
|
+
} else if (intent === 'dispatch') {
|
|
228
|
+
result = dispatchOrchestrationDelegation(taskRef!, coreOptions);
|
|
229
|
+
} else if (intent === 'await-activation') {
|
|
230
|
+
const missing = requireValues(['--stage', '--round', '--artifact', '--role']);
|
|
231
|
+
if (missing) { usageFailure(`intent 'await-activation' requires '${missing}'`); return; }
|
|
232
|
+
const round = Number(values['--round']);
|
|
233
|
+
if (!Number.isSafeInteger(round) || round < 1) {
|
|
234
|
+
usageFailure('--round must be a positive integer'); return;
|
|
235
|
+
}
|
|
236
|
+
if (!['executor', 'reviewer'].includes(values['--role']!)) {
|
|
237
|
+
usageFailure('--role must be executor or reviewer'); return;
|
|
238
|
+
}
|
|
239
|
+
result = await awaitOrchestrationDelegationActivation(taskRef!, {
|
|
240
|
+
stage: values['--stage'] as Parameters<typeof awaitOrchestrationDelegationActivation>[1]['stage'],
|
|
241
|
+
round,
|
|
242
|
+
artifact: values['--artifact']!,
|
|
243
|
+
role: values['--role'] as 'executor' | 'reviewer'
|
|
219
244
|
}, coreOptions);
|
|
245
|
+
} else if (intent === 'recover-prepared') {
|
|
246
|
+
result = recoverPreparedOrchestrationDelegation(taskRef!, coreOptions);
|
|
220
247
|
} else if (intent === 'hook-start') {
|
|
221
248
|
const missing = requireValues([
|
|
222
249
|
...(taskRef === 'auto' ? ['--client'] : []),
|
package/lib/platform/context.ts
CHANGED
|
@@ -20,6 +20,8 @@ type ContextOptions = {
|
|
|
20
20
|
platformType?: string;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const CURRENT_USER_QUERY = 'query { viewer { login } }';
|
|
24
|
+
|
|
23
25
|
function findRepoRoot(cwd: string): string {
|
|
24
26
|
try {
|
|
25
27
|
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
@@ -105,16 +107,17 @@ function resolveGitHubContext(options: ContextOptions & { cwd: string }): Platfo
|
|
|
105
107
|
error: { code: 'UPSTREAM_UNRESOLVED', message: 'Unable to resolve the upstream repository', retryable: false }
|
|
106
108
|
});
|
|
107
109
|
}
|
|
108
|
-
const user = client.json(['api', '
|
|
110
|
+
const user = client.json(['api', 'graphql', '-f', `query=${CURRENT_USER_QUERY}`], { cwd });
|
|
109
111
|
if (!user.ok) return failure(user.error, upstream);
|
|
110
|
-
const userValue = user.value as { login?: string } | null;
|
|
112
|
+
const userValue = user.value as { data?: { viewer?: { login?: string } } } | null;
|
|
113
|
+
const currentUser = userValue?.data?.viewer?.login || null;
|
|
111
114
|
const permissions = client.json(['api', `repos/${upstream}`], { cwd });
|
|
112
115
|
if (!permissions.ok) return failure(permissions.error, upstream);
|
|
113
116
|
const permissionValue = permissions.value as { permissions?: Record<string, boolean> } | null;
|
|
114
117
|
const values = permissionValue?.permissions || {};
|
|
115
118
|
const capabilities = {
|
|
116
|
-
authenticated: Boolean(
|
|
117
|
-
comment: Boolean(
|
|
119
|
+
authenticated: Boolean(currentUser),
|
|
120
|
+
comment: Boolean(currentUser),
|
|
118
121
|
triage: Boolean(values.triage || values.push || values.admin),
|
|
119
122
|
push: Boolean(values.push || values.admin),
|
|
120
123
|
admin: Boolean(values.admin)
|
|
@@ -122,7 +125,7 @@ function resolveGitHubContext(options: ContextOptions & { cwd: string }): Platfo
|
|
|
122
125
|
const status = Object.values(capabilities).every(Boolean) ? 'no-op' : 'degraded';
|
|
123
126
|
return platformResult(status, {
|
|
124
127
|
changed: false,
|
|
125
|
-
platform: { type: platform, repository: upstream, currentUser
|
|
128
|
+
platform: { type: platform, repository: upstream, currentUser },
|
|
126
129
|
resource: { kind: 'repository', number: null },
|
|
127
130
|
capabilities,
|
|
128
131
|
operations: [{ name: 'resolve', status: 'no-op', reasonCode: null }]
|
|
@@ -10,10 +10,18 @@ type RunOptions = { cwd?: string; input?: string };
|
|
|
10
10
|
type Runner = (args: string[], options: RunOptions) => RunResult;
|
|
11
11
|
type RequestOptions = RunOptions & { method?: 'GET' | 'PATCH' | 'POST' | 'DELETE' };
|
|
12
12
|
type ClientResult<T> = { ok: true; value: T } | { ok: false; error: PlatformError };
|
|
13
|
+
type ResponseMetadata = {
|
|
14
|
+
status: number;
|
|
15
|
+
requestUrl: string;
|
|
16
|
+
date?: string;
|
|
17
|
+
links: string[];
|
|
18
|
+
};
|
|
19
|
+
type JsonResponse<T> = { value: T; metadata: ResponseMetadata };
|
|
13
20
|
|
|
14
21
|
type GitHubClient = {
|
|
15
22
|
version(options?: RunOptions): ClientResult<string>;
|
|
16
23
|
json<T = unknown>(args: string[], options?: RequestOptions): ClientResult<T>;
|
|
24
|
+
jsonWithMetadata?<T = unknown>(args: string[], options?: RequestOptions): ClientResult<JsonResponse<T>>;
|
|
17
25
|
text(args: string[], options?: RequestOptions): ClientResult<string>;
|
|
18
26
|
};
|
|
19
27
|
|
|
@@ -23,7 +31,9 @@ type ClientOptions = {
|
|
|
23
31
|
sleep?: (delayMs: number) => void;
|
|
24
32
|
};
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
// closingIssuesReferences (introduced in gh 2.72.0) is the highest-versioned gh feature
|
|
35
|
+
// the platform layer depends on; see github-release-notes.ts:102.
|
|
36
|
+
const MINIMUM_GITHUB_CLI_VERSION = '2.72.0';
|
|
27
37
|
const GITHUB_CLI_MAX_BUFFER = 64 * 1024 * 1024;
|
|
28
38
|
const ERROR_DETAIL_LIMIT = 4096;
|
|
29
39
|
|
|
@@ -101,6 +111,39 @@ function classifyGitHubFailure(result: RunResult): PlatformError {
|
|
|
101
111
|
return { code: 'PLATFORM_REQUEST_FAILED', message: detail || 'GitHub request failed', retryable: false };
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
function responseUrl(args: string[]): string {
|
|
115
|
+
const endpoint = args.find((arg, index) => index > 0 && !arg.startsWith('-')) || '';
|
|
116
|
+
return endpoint.startsWith('http') ? endpoint : `https://api.github.com/${endpoint.replace(/^\//, '')}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseIncludedResponse<T>(stdout: string, args: string[]): JsonResponse<T> | null {
|
|
120
|
+
const statusBlocks = [...stdout.matchAll(/^HTTP\/[^\r\n]+/gim)];
|
|
121
|
+
const headerStart = statusBlocks.at(-1)?.index;
|
|
122
|
+
if (headerStart === undefined) return null;
|
|
123
|
+
const response = stdout.slice(headerStart);
|
|
124
|
+
const separator = response.match(/\r?\n\r?\n/);
|
|
125
|
+
if (!separator || separator.index === undefined) return null;
|
|
126
|
+
const header = response.slice(0, separator.index);
|
|
127
|
+
const body = response.slice(separator.index + separator[0].length);
|
|
128
|
+
const status = header.match(/^HTTP\/[^ ]+\s+(\d{3})/mi)?.[1];
|
|
129
|
+
if (!status) return null;
|
|
130
|
+
const date = header.match(/^Date:\s*(.+)$/mi)?.[1]?.trim();
|
|
131
|
+
const links = [...header.matchAll(/^Link:\s*(.+)$/gim)].flatMap((match) => match[1]!.split(',').map((value) => value.trim()));
|
|
132
|
+
try {
|
|
133
|
+
return {
|
|
134
|
+
value: JSON.parse(body || 'null') as T,
|
|
135
|
+
metadata: {
|
|
136
|
+
status: Number(status),
|
|
137
|
+
requestUrl: responseUrl(args),
|
|
138
|
+
...(date ? { date } : {}),
|
|
139
|
+
links
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
function createGitHubClient(options: ClientOptions = {}): GitHubClient {
|
|
105
148
|
const runner = options.runner || defaultRunner;
|
|
106
149
|
const delays = options.retryDelaysMs || retryDelaysFromEnvironment();
|
|
@@ -156,9 +199,24 @@ function createGitHubClient(options: ClientOptions = {}): GitHubClient {
|
|
|
156
199
|
error: { code: 'INVALID_PLATFORM_RESPONSE', message: 'GitHub returned invalid JSON', retryable: true }
|
|
157
200
|
};
|
|
158
201
|
}
|
|
202
|
+
},
|
|
203
|
+
jsonWithMetadata<T>(args: string[], request: RequestOptions = {}): ClientResult<JsonResponse<T>> {
|
|
204
|
+
const includeArgs = args[0] === 'api' && args[1] !== '--include'
|
|
205
|
+
? [args[0], '--include', ...args.slice(1)]
|
|
206
|
+
: args;
|
|
207
|
+
const result = run(includeArgs, request);
|
|
208
|
+
if (!result.ok) return result;
|
|
209
|
+
const parsed = parseIncludedResponse<T>(result.value, args);
|
|
210
|
+
if (!parsed) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: { code: 'INVALID_PLATFORM_RESPONSE', message: 'GitHub response metadata or JSON is invalid', retryable: true }
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, value: parsed };
|
|
159
217
|
}
|
|
160
218
|
};
|
|
161
219
|
}
|
|
162
220
|
|
|
163
|
-
export { classifyGitHubFailure, createGitHubClient, MINIMUM_GITHUB_CLI_VERSION };
|
|
164
|
-
export type { ClientResult, GitHubClient, RequestOptions, RunOptions, RunResult, Runner };
|
|
221
|
+
export { classifyGitHubFailure, createGitHubClient, MINIMUM_GITHUB_CLI_VERSION, parseIncludedResponse };
|
|
222
|
+
export type { ClientResult, GitHubClient, JsonResponse, RequestOptions, ResponseMetadata, RunOptions, RunResult, Runner };
|
|
@@ -39,6 +39,7 @@ const ARTIFACT_TITLES: Record<string, string> = {
|
|
|
39
39
|
code: '实现报告',
|
|
40
40
|
'review-code': '代码审查',
|
|
41
41
|
'manual-validation': '人工验证报告',
|
|
42
|
+
'validation-run': '验证运行证据',
|
|
42
43
|
'pr-review': 'PR 审查报告'
|
|
43
44
|
};
|
|
44
45
|
|
|
@@ -75,7 +76,7 @@ function renderTaskComment(content: string, taskId: string, agent: string): stri
|
|
|
75
76
|
|
|
76
77
|
function artifactIdentity(artifact: string): { stem: string; title: string } {
|
|
77
78
|
const stem = path.basename(artifact, '.md');
|
|
78
|
-
const match = stem.match(/^(analysis|review-analysis|plan|review-plan|code|review-code|manual-validation|pr-review)(?:-r(\d+))?$/);
|
|
79
|
+
const match = stem.match(/^(analysis|review-analysis|plan|review-plan|code|review-code|manual-validation|validation-run|pr-review)(?:-r(\d+))?$/);
|
|
79
80
|
if (!match) throw new Error(`unsupported artifact '${artifact}'`);
|
|
80
81
|
const base = ARTIFACT_TITLES[match[1]!]!;
|
|
81
82
|
const round = match[2] ? Number(match[2]) : 1;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { verifySnapshot } from './store.ts';
|
|
7
|
+
import type { GitHubCheckpoint, CheckpointOwner, ProcessResult, RecoveryReport } from './types.ts';
|
|
8
|
+
|
|
9
|
+
type CheckpointPaths = { directory: string; checkpoint: string; lock: string };
|
|
10
|
+
type CheckpointCandidate = {
|
|
11
|
+
schema: 'github-checkpoint/v1';
|
|
12
|
+
candidateSchema: 'github-checkpoint-candidate/v1';
|
|
13
|
+
ownerId: string;
|
|
14
|
+
expectedOld: { snapshotId: string | null; manifestSha256: string | null };
|
|
15
|
+
repository: string;
|
|
16
|
+
snapshotId: string;
|
|
17
|
+
watermark: string;
|
|
18
|
+
manifestSha256: string;
|
|
19
|
+
committedAt: string;
|
|
20
|
+
};
|
|
21
|
+
type CheckpointLease = {
|
|
22
|
+
paths: CheckpointPaths;
|
|
23
|
+
repository: string;
|
|
24
|
+
owner: CheckpointOwner;
|
|
25
|
+
recovery: RecoveryReport;
|
|
26
|
+
expectedOld: GitHubCheckpoint | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function repositoryKey(repository: string): string {
|
|
30
|
+
return crypto.createHash('sha256').update(repository).digest('hex').slice(0, 32);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function checkpointPaths(root: string, repository: string): CheckpointPaths {
|
|
34
|
+
const directory = path.join(root, 'checkpoints', 'github');
|
|
35
|
+
const base = path.join(directory, repositoryKey(repository));
|
|
36
|
+
return { directory, checkpoint: `${base}.json`, lock: `${base}.lock` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function processStartToken(pid: number): string | null {
|
|
40
|
+
if (process.platform === 'linux') {
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
43
|
+
const closing = stat.lastIndexOf(')');
|
|
44
|
+
const fields = stat.slice(closing + 2).split(' ');
|
|
45
|
+
return fields[19] ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return pid === process.pid ? `self:${process.pid}` : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fsyncDirectory(directory: string): void {
|
|
54
|
+
if (process.platform === 'win32') return;
|
|
55
|
+
const fd = fs.openSync(directory, 'r');
|
|
56
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensurePrivateDirectory(directory: string): void {
|
|
60
|
+
const missing: string[] = [];
|
|
61
|
+
let current = path.resolve(directory);
|
|
62
|
+
while (!fs.existsSync(current)) {
|
|
63
|
+
missing.push(current);
|
|
64
|
+
const parent = path.dirname(current);
|
|
65
|
+
if (parent === current) throw new Error(`cannot create directory tree: ${directory}`);
|
|
66
|
+
current = parent;
|
|
67
|
+
}
|
|
68
|
+
for (const target of missing.reverse()) {
|
|
69
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
70
|
+
if (process.platform !== 'win32') fs.chmodSync(target, 0o700);
|
|
71
|
+
fsyncDirectory(path.dirname(target));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeTemporary(filePath: string, value: unknown): string {
|
|
76
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
77
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
78
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
|
|
81
|
+
fs.fsyncSync(fd);
|
|
82
|
+
} finally {
|
|
83
|
+
fs.closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
return temporary;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validCheckpoint(value: unknown, repository: string): value is GitHubCheckpoint {
|
|
89
|
+
if (!value || typeof value !== 'object') return false;
|
|
90
|
+
const checkpoint = value as GitHubCheckpoint;
|
|
91
|
+
return checkpoint.schema === 'github-checkpoint/v1'
|
|
92
|
+
&& checkpoint.repository === repository
|
|
93
|
+
&& Boolean(checkpoint.snapshotId)
|
|
94
|
+
&& Boolean(checkpoint.watermark)
|
|
95
|
+
&& Boolean(checkpoint.committedAt)
|
|
96
|
+
&& /^[a-f0-9]{64}$/.test(checkpoint.manifestSha256);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkpointMatchesSnapshot(root: string, value: GitHubCheckpoint): boolean {
|
|
100
|
+
const verified = verifySnapshot(root, value.snapshotId);
|
|
101
|
+
return verified.ok
|
|
102
|
+
&& verified.manifest?.schema === 'raw-manifest/v2'
|
|
103
|
+
&& verified.manifest.snapshotId === value.snapshotId
|
|
104
|
+
&& verified.manifest.repository === value.repository
|
|
105
|
+
&& verified.manifest.manifestSha256 === value.manifestSha256
|
|
106
|
+
&& verified.manifest.watermark === value.watermark;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkpointState(value: GitHubCheckpoint | null): { snapshotId: string | null; manifestSha256: string | null } {
|
|
110
|
+
return { snapshotId: value?.snapshotId ?? null, manifestSha256: value?.manifestSha256 ?? null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readGitHubCheckpoint(root: string, repository: string): ProcessResult<GitHubCheckpoint | null> {
|
|
114
|
+
const target = checkpointPaths(root, repository).checkpoint;
|
|
115
|
+
if (!fs.existsSync(target)) return { ok: true, value: null };
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8')) as GitHubCheckpoint;
|
|
118
|
+
const value: GitHubCheckpoint = {
|
|
119
|
+
schema: parsed.schema,
|
|
120
|
+
repository: parsed.repository,
|
|
121
|
+
snapshotId: parsed.snapshotId,
|
|
122
|
+
watermark: parsed.watermark,
|
|
123
|
+
manifestSha256: parsed.manifestSha256,
|
|
124
|
+
committedAt: parsed.committedAt
|
|
125
|
+
};
|
|
126
|
+
if (!validCheckpoint(value, repository)) {
|
|
127
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'GitHub checkpoint schema or identity is invalid' } };
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, value };
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'GitHub checkpoint is not valid JSON' } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function quarantine(lockPath: string): string {
|
|
136
|
+
const target = `${lockPath}.orphaned-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
137
|
+
fs.renameSync(lockPath, target);
|
|
138
|
+
return target;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function acquireGitHubCheckpoint(root: string, repository: string): ProcessResult<CheckpointLease> {
|
|
142
|
+
const paths = checkpointPaths(root, repository);
|
|
143
|
+
ensurePrivateDirectory(paths.directory);
|
|
144
|
+
const recovery: RecoveryReport = { recovered: false, quarantined: [] };
|
|
145
|
+
const owner: CheckpointOwner = {
|
|
146
|
+
schema: 'checkpoint-owner/v1',
|
|
147
|
+
ownerId: crypto.randomUUID(),
|
|
148
|
+
pid: process.pid,
|
|
149
|
+
host: os.hostname(),
|
|
150
|
+
processStartToken: processStartToken(process.pid) ?? '',
|
|
151
|
+
createdAt: new Date().toISOString()
|
|
152
|
+
};
|
|
153
|
+
if (!owner.processStartToken) {
|
|
154
|
+
return { ok: false, error: { code: 'CHECKPOINT_TOPOLOGY_UNSUPPORTED', message: 'Process liveness cannot be verified on this host' } };
|
|
155
|
+
}
|
|
156
|
+
let recoveredOwnerId: string | null = null;
|
|
157
|
+
if (fs.existsSync(paths.lock)) {
|
|
158
|
+
let previous: CheckpointOwner;
|
|
159
|
+
try {
|
|
160
|
+
previous = JSON.parse(fs.readFileSync(paths.lock, 'utf8')) as CheckpointOwner;
|
|
161
|
+
} catch {
|
|
162
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint lock is unreadable' } };
|
|
163
|
+
}
|
|
164
|
+
if (previous.host !== owner.host || !previous.ownerId || !previous.processStartToken) {
|
|
165
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint lock owner topology is unknown' } };
|
|
166
|
+
}
|
|
167
|
+
const currentToken = processStartToken(previous.pid);
|
|
168
|
+
if (currentToken && currentToken === previous.processStartToken) {
|
|
169
|
+
return { ok: false, error: { code: 'CHECKPOINT_BUSY', message: 'Checkpoint is held by a live process' } };
|
|
170
|
+
}
|
|
171
|
+
if (process.platform !== 'linux') {
|
|
172
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint owner liveness cannot be proven on this host' } };
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
recoveredOwnerId = previous.ownerId;
|
|
176
|
+
recovery.quarantined.push(quarantine(paths.lock));
|
|
177
|
+
recovery.recovered = true;
|
|
178
|
+
} catch {
|
|
179
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Unable to quarantine an orphaned checkpoint lock' } };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const fd = fs.openSync(paths.lock, 'wx', 0o600);
|
|
184
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
185
|
+
fsyncDirectory(paths.directory);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
188
|
+
return { ok: false, error: { code: 'CHECKPOINT_BUSY', message: 'Checkpoint lock was acquired by another process' } };
|
|
189
|
+
}
|
|
190
|
+
return { ok: false, error: { code: 'CHECKPOINT_LOCK_FAILED', message: String(error) } };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const releaseOwnedLock = () => {
|
|
194
|
+
try {
|
|
195
|
+
fs.unlinkSync(paths.lock);
|
|
196
|
+
fsyncDirectory(paths.directory);
|
|
197
|
+
} catch {
|
|
198
|
+
// Preserve the original recovery error; a remaining lock will fail closed next time.
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const beforeResult = readGitHubCheckpoint(root, repository);
|
|
202
|
+
if (!beforeResult.ok) {
|
|
203
|
+
releaseOwnedLock();
|
|
204
|
+
return beforeResult;
|
|
205
|
+
}
|
|
206
|
+
let checkpointBefore = beforeResult.value;
|
|
207
|
+
const temporaryCandidates = fs.readdirSync(paths.directory)
|
|
208
|
+
.filter((entry) => entry.startsWith(path.basename(paths.checkpoint) + '.') && entry.endsWith('.tmp'))
|
|
209
|
+
.map((entry) => path.join(paths.directory, entry));
|
|
210
|
+
if (temporaryCandidates.length > 1) {
|
|
211
|
+
releaseOwnedLock();
|
|
212
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Multiple checkpoint recovery candidates exist' } };
|
|
213
|
+
}
|
|
214
|
+
if (temporaryCandidates.length === 1) {
|
|
215
|
+
const candidate = temporaryCandidates[0]!;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as CheckpointCandidate | GitHubCheckpoint;
|
|
218
|
+
const isCandidate = parsed && typeof parsed === 'object' && 'candidateSchema' in parsed
|
|
219
|
+
&& parsed.candidateSchema === 'github-checkpoint-candidate/v1';
|
|
220
|
+
if (isCandidate) {
|
|
221
|
+
const candidateValue = parsed as CheckpointCandidate;
|
|
222
|
+
const value: GitHubCheckpoint = candidateValue;
|
|
223
|
+
const expected = checkpointState(checkpointBefore);
|
|
224
|
+
const candidateExpected = candidateValue.expectedOld;
|
|
225
|
+
if (!recoveredOwnerId || candidateValue.ownerId !== recoveredOwnerId) {
|
|
226
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
227
|
+
releaseOwnedLock();
|
|
228
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate owner proof does not match the recovered lock owner' } };
|
|
229
|
+
}
|
|
230
|
+
if (!validCheckpoint(value, repository)
|
|
231
|
+
|| candidateExpected?.snapshotId !== expected.snapshotId
|
|
232
|
+
|| candidateExpected?.manifestSha256 !== expected.manifestSha256) {
|
|
233
|
+
releaseOwnedLock();
|
|
234
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate does not match the current head' } };
|
|
235
|
+
}
|
|
236
|
+
if (!checkpointMatchesSnapshot(root, value)) {
|
|
237
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
238
|
+
releaseOwnedLock();
|
|
239
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate snapshot failed full verification' } };
|
|
240
|
+
}
|
|
241
|
+
if (process.platform === 'win32' && checkpointBefore) {
|
|
242
|
+
releaseOwnedLock();
|
|
243
|
+
return { ok: false, error: { code: 'CHECKPOINT_TOPOLOGY_UNSUPPORTED', message: 'Checkpoint replacement durability cannot be proven on this host' } };
|
|
244
|
+
}
|
|
245
|
+
fs.renameSync(candidate, paths.checkpoint);
|
|
246
|
+
recovery.recovered = true;
|
|
247
|
+
fsyncDirectory(paths.directory);
|
|
248
|
+
const recovered = readGitHubCheckpoint(root, repository);
|
|
249
|
+
if (!recovered.ok) {
|
|
250
|
+
releaseOwnedLock();
|
|
251
|
+
return recovered;
|
|
252
|
+
}
|
|
253
|
+
checkpointBefore = recovered.value;
|
|
254
|
+
} else {
|
|
255
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
256
|
+
releaseOwnedLock();
|
|
257
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate lacks owner proof' } };
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { ok: true, value: { paths, repository, owner, recovery, expectedOld: checkpointBefore } };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function commitGitHubCheckpoint(lease: CheckpointLease, value: GitHubCheckpoint): ProcessResult<void> {
|
|
267
|
+
try {
|
|
268
|
+
const current = JSON.parse(fs.readFileSync(lease.paths.lock, 'utf8')) as CheckpointOwner;
|
|
269
|
+
if (current.ownerId !== lease.owner.ownerId) {
|
|
270
|
+
return { ok: false, error: { code: 'CHECKPOINT_OWNER_MISMATCH', message: 'Checkpoint lock ownership changed' } };
|
|
271
|
+
}
|
|
272
|
+
if (value.repository !== lease.repository || !validCheckpoint(value, lease.repository)) {
|
|
273
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'Checkpoint value is invalid' } };
|
|
274
|
+
}
|
|
275
|
+
const root = path.dirname(path.dirname(lease.paths.directory));
|
|
276
|
+
if (!checkpointMatchesSnapshot(root, value)) {
|
|
277
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'Checkpoint snapshot does not match a verified v2 manifest' } };
|
|
278
|
+
}
|
|
279
|
+
const currentCheckpoint = readGitHubCheckpoint(root, lease.repository);
|
|
280
|
+
if (!currentCheckpoint.ok) return currentCheckpoint;
|
|
281
|
+
if (checkpointState(currentCheckpoint.value).snapshotId !== checkpointState(lease.expectedOld).snapshotId
|
|
282
|
+
|| checkpointState(currentCheckpoint.value).manifestSha256 !== checkpointState(lease.expectedOld).manifestSha256) {
|
|
283
|
+
return { ok: false, error: { code: 'CHECKPOINT_CONFLICT', message: 'Checkpoint head changed before commit' } };
|
|
284
|
+
}
|
|
285
|
+
const candidate: CheckpointCandidate = {
|
|
286
|
+
...value,
|
|
287
|
+
candidateSchema: 'github-checkpoint-candidate/v1',
|
|
288
|
+
ownerId: lease.owner.ownerId,
|
|
289
|
+
expectedOld: checkpointState(lease.expectedOld)
|
|
290
|
+
};
|
|
291
|
+
const temporary = writeTemporary(lease.paths.checkpoint, candidate);
|
|
292
|
+
const rechecked = readGitHubCheckpoint(root, lease.repository);
|
|
293
|
+
if (!rechecked.ok) {
|
|
294
|
+
fs.unlinkSync(temporary);
|
|
295
|
+
return rechecked;
|
|
296
|
+
}
|
|
297
|
+
if (checkpointState(rechecked.value).snapshotId !== checkpointState(lease.expectedOld).snapshotId
|
|
298
|
+
|| checkpointState(rechecked.value).manifestSha256 !== checkpointState(lease.expectedOld).manifestSha256) {
|
|
299
|
+
fs.unlinkSync(temporary);
|
|
300
|
+
return { ok: false, error: { code: 'CHECKPOINT_CONFLICT', message: 'Checkpoint head changed during commit' } };
|
|
301
|
+
}
|
|
302
|
+
if (process.platform === 'win32' && fs.existsSync(lease.paths.checkpoint)) {
|
|
303
|
+
fs.unlinkSync(temporary);
|
|
304
|
+
return { ok: false, error: { code: 'CHECKPOINT_TOPOLOGY_UNSUPPORTED', message: 'Checkpoint replacement durability cannot be proven on this host' } };
|
|
305
|
+
}
|
|
306
|
+
fs.renameSync(temporary, lease.paths.checkpoint);
|
|
307
|
+
if (process.platform !== 'win32') fs.chmodSync(lease.paths.checkpoint, 0o600);
|
|
308
|
+
fsyncDirectory(lease.paths.directory);
|
|
309
|
+
return { ok: true, value: undefined };
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ok: false, error: { code: 'CHECKPOINT_COMMIT_FAILED', message: String(error) } };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function releaseGitHubCheckpoint(lease: CheckpointLease): void {
|
|
316
|
+
try {
|
|
317
|
+
const current = JSON.parse(fs.readFileSync(lease.paths.lock, 'utf8')) as CheckpointOwner;
|
|
318
|
+
if (current.ownerId === lease.owner.ownerId) {
|
|
319
|
+
fs.unlinkSync(lease.paths.lock);
|
|
320
|
+
fsyncDirectory(lease.paths.directory);
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
// The next invocation will fail closed and report recovery if the lock remains.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export {
|
|
328
|
+
acquireGitHubCheckpoint,
|
|
329
|
+
checkpointPaths,
|
|
330
|
+
commitGitHubCheckpoint,
|
|
331
|
+
processStartToken,
|
|
332
|
+
readGitHubCheckpoint,
|
|
333
|
+
releaseGitHubCheckpoint
|
|
334
|
+
};
|
|
335
|
+
export type { CheckpointLease, CheckpointPaths };
|