forge-workflow 0.1.0-beta.5 → 0.1.0-beta.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/AGENTS.md +4 -0
- package/CHANGELOG.md +48 -0
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/bin/forge.js +12 -1
- package/docs/guides/MIGRATION.md +3 -3
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +4 -4
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/shepherd.md +63 -13
- package/lib/adapters/pr-state-adapter.js +15 -2
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +48 -18
- package/lib/commands/clean.js +57 -1
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +54 -6
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +720 -73
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +2 -0
- package/lib/commands/preflight.js +10 -2
- package/lib/commands/push.js +120 -9
- package/lib/commands/recall.js +95 -61
- package/lib/commands/release.js +23 -2
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +132 -4
- package/lib/commands/shepherd.js +578 -72
- package/lib/commands/ship.js +15 -69
- package/lib/commands/skill.js +8 -0
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +163 -4
- package/lib/commands/validate.js +78 -22
- package/lib/commands/worktree.js +155 -19
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-issues.js +78 -0
- package/lib/gate-events.js +98 -10
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/hook-renderer.js +29 -1
- package/lib/issue-render.js +19 -0
- package/lib/kernel/broker.js +723 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3322 -183
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +94 -27
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +59 -0
- package/lib/merge-rules.js +135 -17
- package/lib/npm-publish-workflow.js +233 -40
- package/lib/package-root.js +2 -0
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +0 -37
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1101 -625
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +121 -24
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +28 -19
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +144 -38
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-shepherd.js +17 -3
- package/lib/project-memory.js +145 -3
- package/lib/protected-state-authority.js +799 -4
- package/lib/protected-state-surfaces.js +181 -3
- package/lib/release-readiness.js +2 -3
- package/lib/review-adapter.js +65 -0
- package/lib/skills-sync.js +65 -32
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/validation-receipt.js +190 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
- package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
- package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
- package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
- package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
- package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
- package/node_modules/@forge/contracts/index.js +32 -0
- package/node_modules/@forge/contracts/package.json +35 -0
- package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
- package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
- package/node_modules/@forge/contracts/src/baseline.js +23 -0
- package/node_modules/@forge/contracts/src/canonical.js +151 -0
- package/node_modules/@forge/contracts/src/definitions.js +176 -0
- package/node_modules/@forge/contracts/src/identity.js +42 -0
- package/node_modules/@forge/contracts/src/schema.js +72 -0
- package/node_modules/@forge/contracts/src/validate.js +305 -0
- package/node_modules/@forge/flow/index.js +114 -0
- package/node_modules/@forge/flow/package.json +34 -0
- package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
- package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
- package/node_modules/@forge/flow/src/executor.js +279 -0
- package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
- package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
- package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
- package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
- package/node_modules/@forge/memory/index.js +331 -0
- package/node_modules/@forge/memory/package.json +34 -0
- package/node_modules/@forge/memory/src/authority-provider.js +67 -0
- package/node_modules/@forge/memory/src/backend-registry.js +166 -0
- package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
- package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
- package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
- package/package.json +16 -4
- package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
- package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
- package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
- package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
- package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
- package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
- package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
- package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
- package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
- package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
- package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
- package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
- package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
- package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
- package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
- package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
- package/scripts/commitlint.js +13 -15
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runtime.js +3 -2
- package/scripts/process-tree.js +14 -2
- package/scripts/protected-state-check.js +440 -17
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/test-full-suite.js +704 -18
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +96 -15
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +2 -0
- package/skills/review/evals/scorecard.json +2 -2
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +19 -2
- package/skills/shepherd/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_MANIFEST_DIR,
|
|
7
|
+
defaultGetProcessIdentity,
|
|
8
|
+
isValidManifest,
|
|
9
|
+
readProcessManifest,
|
|
10
|
+
} = require('../../scripts/process-tree');
|
|
11
|
+
|
|
12
|
+
function nonEmptyString(value) {
|
|
13
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function positivePid(value) {
|
|
17
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verifiedManifestForClaim(claim, manifest) {
|
|
21
|
+
const sessionId = nonEmptyString(claim?.session_id);
|
|
22
|
+
const worktreeId = nonEmptyString(claim?.worktree_id);
|
|
23
|
+
if (!nonEmptyString(claim?.id) || !nonEmptyString(claim?.issue_id) || !sessionId || !worktreeId) return null;
|
|
24
|
+
if (claim.state != null && claim.state !== 'active') return null;
|
|
25
|
+
if (!isValidManifest(manifest) || manifest.token !== sessionId) return null;
|
|
26
|
+
|
|
27
|
+
const owner = manifest.owner;
|
|
28
|
+
const pid = positivePid(owner?.pid);
|
|
29
|
+
const identity = nonEmptyString(owner?.identity);
|
|
30
|
+
const startedAt = nonEmptyString(owner?.startedAt);
|
|
31
|
+
if (!pid || !identity || identity.startsWith('unverified:') || !startedAt || !Array.isArray(manifest.children)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { sessionId, worktreeId, pid, identity, startedAt };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultIsProcessAlive(pid) {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return error?.code === 'EPERM';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createManifestReader(options = {}) {
|
|
48
|
+
const fsApi = options.fsApi || fs;
|
|
49
|
+
const processApi = options.processApi || process;
|
|
50
|
+
const manifestDir = options.manifestDir || DEFAULT_MANIFEST_DIR;
|
|
51
|
+
return async (sessionId) => {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fsApi.readdirSync(manifestDir, { withFileTypes: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const matches = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const name = typeof entry === 'string' ? entry : entry?.name;
|
|
61
|
+
if (!name || !name.endsWith('.json')) continue;
|
|
62
|
+
if (typeof entry !== 'string' && entry.isFile && !entry.isFile()) continue;
|
|
63
|
+
const manifest = readProcessManifest(path.join(manifestDir, name), fsApi, processApi);
|
|
64
|
+
if (manifest?.token === sessionId) matches.push(manifest);
|
|
65
|
+
}
|
|
66
|
+
return matches.length === 1 ? matches[0] : null;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createWorktreeInspector(driver, fsApi = fs) {
|
|
71
|
+
return async (worktreeId, claim) => {
|
|
72
|
+
let rows;
|
|
73
|
+
try {
|
|
74
|
+
rows = await driver.listWorktrees({});
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const linked = (Array.isArray(rows) ? rows : []).filter(row => row
|
|
79
|
+
&& row.issue_id === claim.issue_id
|
|
80
|
+
&& row.state === 'active'
|
|
81
|
+
&& (row.id === worktreeId || path.basename(row.path || '') === worktreeId));
|
|
82
|
+
if (linked.length !== 1 || !nonEmptyString(linked[0].path)) return null;
|
|
83
|
+
try {
|
|
84
|
+
if (typeof fsApi.lstatSync === 'function') {
|
|
85
|
+
fsApi.lstatSync(linked[0].path);
|
|
86
|
+
return { exists: true, row: linked[0] };
|
|
87
|
+
}
|
|
88
|
+
return { exists: fsApi.existsSync(linked[0].path), row: linked[0] };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
91
|
+
return { exists: false, row: linked[0] };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createMissingPathVerifier(fsApi = fs) {
|
|
99
|
+
return (worktreePath) => {
|
|
100
|
+
try {
|
|
101
|
+
if (typeof fsApi.lstatSync === 'function') fsApi.lstatSync(worktreePath);
|
|
102
|
+
else if (fsApi.existsSync(worktreePath)) return false;
|
|
103
|
+
else return true;
|
|
104
|
+
return false;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function reconcileClaims(options = {}) {
|
|
112
|
+
const claims = Array.isArray(options.claims) ? options.claims : [];
|
|
113
|
+
const readManifest = options.readManifest;
|
|
114
|
+
const worktreeExists = options.worktreeExists;
|
|
115
|
+
const isProcessAlive = options.isProcessAlive;
|
|
116
|
+
const getProcessIdentity = options.getProcessIdentity;
|
|
117
|
+
const releaseClaim = options.releaseClaim;
|
|
118
|
+
const released = [];
|
|
119
|
+
const attempted = new Set();
|
|
120
|
+
|
|
121
|
+
if (![readManifest, worktreeExists, isProcessAlive, getProcessIdentity, releaseClaim]
|
|
122
|
+
.every(dependency => typeof dependency === 'function')) {
|
|
123
|
+
return { examined: claims.length, released };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const claim of claims) {
|
|
127
|
+
if (!nonEmptyString(claim?.id) || attempted.has(claim.id)) continue;
|
|
128
|
+
|
|
129
|
+
let manifest;
|
|
130
|
+
try {
|
|
131
|
+
manifest = await readManifest(claim.session_id, claim);
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const verified = verifiedManifestForClaim(claim, manifest);
|
|
136
|
+
if (!verified) continue;
|
|
137
|
+
|
|
138
|
+
let worktreeInspection;
|
|
139
|
+
try {
|
|
140
|
+
worktreeInspection = await worktreeExists(verified.worktreeId, claim);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const worktreeMissing = worktreeInspection === false
|
|
145
|
+
|| worktreeInspection?.exists === false;
|
|
146
|
+
if (!worktreeMissing) continue;
|
|
147
|
+
|
|
148
|
+
let ownerAlive;
|
|
149
|
+
try {
|
|
150
|
+
ownerAlive = await isProcessAlive(verified.pid);
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ownerAlive !== false) {
|
|
155
|
+
if (ownerAlive !== true) continue;
|
|
156
|
+
let currentIdentity;
|
|
157
|
+
try {
|
|
158
|
+
currentIdentity = await getProcessIdentity(verified.pid);
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// A matching owner is live. A mismatch is PID reuse or otherwise
|
|
163
|
+
// unverifiable; neither authorizes releasing its claim.
|
|
164
|
+
if (currentIdentity !== verified.identity) continue;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
attempted.add(claim.id);
|
|
169
|
+
const evidence = Object.freeze({
|
|
170
|
+
claim_id: claim.id,
|
|
171
|
+
issue_id: claim.issue_id,
|
|
172
|
+
session_id: verified.sessionId,
|
|
173
|
+
worktree_id: verified.worktreeId,
|
|
174
|
+
manifest_version: manifest.version,
|
|
175
|
+
manifest_token: manifest.token,
|
|
176
|
+
owner_pid: verified.pid,
|
|
177
|
+
owner_identity: verified.identity,
|
|
178
|
+
owner_started_at: verified.startedAt,
|
|
179
|
+
run_state: 'dead',
|
|
180
|
+
worktree_state: 'missing',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (await releaseClaim(claim, evidence, worktreeInspection)) released.push(claim.id);
|
|
185
|
+
} catch {
|
|
186
|
+
// A failed release is not evidence that authority changed.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { examined: claims.length, released };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function reconcileKernelClaims(options = {}) {
|
|
194
|
+
const driver = options.driver;
|
|
195
|
+
if (!driver || typeof driver.listWorktrees !== 'function'
|
|
196
|
+
|| typeof driver.releaseExactClaimIfWorktreeMissing !== 'function') {
|
|
197
|
+
return { examined: 0, released: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let claims;
|
|
201
|
+
try {
|
|
202
|
+
if (typeof driver.listActiveClaims === 'function') {
|
|
203
|
+
claims = await driver.listActiveClaims(options.context || {}, options.config || {});
|
|
204
|
+
} else {
|
|
205
|
+
const response = await driver.issueOperation('claims', [], options.context || {}, options.config || {});
|
|
206
|
+
claims = response?.data?.claims;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
return { examined: 0, released: [] };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return reconcileClaims({
|
|
213
|
+
claims,
|
|
214
|
+
readManifest: options.readManifest || createManifestReader(options),
|
|
215
|
+
worktreeExists: options.worktreeExists || createWorktreeInspector(driver, options.fsApi || fs),
|
|
216
|
+
isProcessAlive: options.isProcessAlive || defaultIsProcessAlive,
|
|
217
|
+
getProcessIdentity: options.getProcessIdentity || (pid => defaultGetProcessIdentity(pid, options)),
|
|
218
|
+
releaseClaim: async (claim, evidence, inspection) => {
|
|
219
|
+
const released = await driver.releaseExactClaimIfWorktreeMissing(
|
|
220
|
+
claim,
|
|
221
|
+
inspection?.row,
|
|
222
|
+
createMissingPathVerifier(options.fsApi || fs),
|
|
223
|
+
evidence,
|
|
224
|
+
options.config || {},
|
|
225
|
+
);
|
|
226
|
+
if (released && typeof options.onEvidence === 'function') options.onEvidence(evidence);
|
|
227
|
+
return released;
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
createManifestReader,
|
|
234
|
+
createWorktreeInspector,
|
|
235
|
+
reconcileClaims,
|
|
236
|
+
reconcileKernelClaims,
|
|
237
|
+
verifiedManifestForClaim,
|
|
238
|
+
};
|
|
@@ -78,12 +78,17 @@ function isValidExpiresAt(value) {
|
|
|
78
78
|
return new Date(timestamp).toISOString() === value;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// A lease with no expires_at never expires.
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// A lease with no expires_at never expires. Normalize both instants before comparing:
|
|
82
|
+
// callers supply `now`, so it may be non-canonical or unusable even though stored
|
|
83
|
+
// expires_at is canonical. Without a usable clock expiry is not proven; conservatively
|
|
84
|
+
// keep the lease live rather than allowing a false reclaim or ready projection.
|
|
84
85
|
function isLeaseExpired(claim, now) {
|
|
85
86
|
if (!claim?.expires_at) return false;
|
|
86
|
-
|
|
87
|
+
if (typeof now !== 'string') return false;
|
|
88
|
+
const expiresAt = Date.parse(claim.expires_at);
|
|
89
|
+
const clock = Date.parse(now);
|
|
90
|
+
if (!Number.isFinite(expiresAt) || !Number.isFinite(clock)) return false;
|
|
91
|
+
return expiresAt <= clock;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
function buildClaimRow(event, now) {
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { ISSUE_STATUSES } = require('./issue-command-contract');
|
|
9
|
+
const { CLAIM_STATES, isClaimableType, isTerminalStatus } = require('./taxonomy-validator');
|
|
10
|
+
const REQUIRED_CLAIM_COLUMNS = Object.freeze([
|
|
11
|
+
'id', 'issue_id', 'actor', 'state', 'session_id', 'worktree_id', 'claimed_at', 'expires_at',
|
|
12
|
+
]);
|
|
13
|
+
const REQUIRED_ISSUE_COLUMNS = Object.freeze(['id', 'status', 'type']);
|
|
14
|
+
const REQUIRED_INDEXES = Object.freeze({
|
|
15
|
+
idx_kernel_claims_active_lease: Object.freeze({ columns: ['issue_id'], unique: true, partial: true }),
|
|
16
|
+
idx_kernel_claims_actor_state: Object.freeze({ columns: ['actor', 'state'], unique: false, partial: false }),
|
|
17
|
+
idx_kernel_claims_issue_state: Object.freeze({ columns: ['issue_id', 'state'], unique: false, partial: false }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
class ClaimRepairError extends Error {
|
|
21
|
+
constructor(code, message, details = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ClaimRepairError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.details = details;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canonicalIso(value) {
|
|
30
|
+
if (typeof value !== 'string') return false;
|
|
31
|
+
const millis = Date.parse(value);
|
|
32
|
+
return Number.isFinite(millis) && new Date(millis).toISOString() === value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function compareCanonical(left, right) {
|
|
36
|
+
const leftValue = String(left);
|
|
37
|
+
const rightValue = String(right);
|
|
38
|
+
if (leftValue < rightValue) return -1;
|
|
39
|
+
if (leftValue > rightValue) return 1;
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stableValue(value) {
|
|
44
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
45
|
+
if (!value || typeof value !== 'object') return value;
|
|
46
|
+
const sorted = {};
|
|
47
|
+
for (const key of Object.keys(value).sort(compareCanonical)) {
|
|
48
|
+
sorted[key] = stableValue(value[key]);
|
|
49
|
+
}
|
|
50
|
+
return sorted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stableStringify(value) {
|
|
54
|
+
return JSON.stringify(stableValue(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sha256(value) {
|
|
58
|
+
return createHash('sha256').update(value).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeIndex(index = {}) {
|
|
62
|
+
return {
|
|
63
|
+
name: String(index.name || ''),
|
|
64
|
+
columns: Array.isArray(index.columns) ? index.columns.map(String) : [],
|
|
65
|
+
unique: Boolean(index.unique),
|
|
66
|
+
partial: Boolean(index.partial),
|
|
67
|
+
sql: String(index.sql || '').replace(/\s+/g, ' ').trim(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectSchemaErrors(snapshot) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
if (snapshot.integrity !== 'ok') errors.push('integrity_check');
|
|
74
|
+
if (snapshot.foreign_keys_enabled !== true) errors.push('foreign_keys_disabled');
|
|
75
|
+
if (Number(snapshot.foreign_key_faults || 0) !== 0) errors.push('foreign_key_fault');
|
|
76
|
+
if (Array.isArray(snapshot.read_errors) && snapshot.read_errors.length > 0) errors.push('schema_read_failure');
|
|
77
|
+
|
|
78
|
+
const claimColumns = new Set(snapshot.claim_columns || []);
|
|
79
|
+
const issueColumns = new Set(snapshot.issue_columns || []);
|
|
80
|
+
if (REQUIRED_CLAIM_COLUMNS.some(column => !claimColumns.has(column))) errors.push('claim_schema_columns');
|
|
81
|
+
if (REQUIRED_ISSUE_COLUMNS.some(column => !issueColumns.has(column))) errors.push('issue_schema_columns');
|
|
82
|
+
|
|
83
|
+
const foreignKeys = snapshot.claim_foreign_keys || [];
|
|
84
|
+
if (!foreignKeys.some(row => row.table === 'kernel_issues' && row.from === 'issue_id' && row.to === 'id')) {
|
|
85
|
+
errors.push('claim_issue_foreign_key');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const indexes = new Map((snapshot.claim_indexes || []).map(index => [index.name, normalizeIndex(index)]));
|
|
89
|
+
for (const [name, expected] of Object.entries(REQUIRED_INDEXES)) {
|
|
90
|
+
const actual = indexes.get(name);
|
|
91
|
+
if (!actual
|
|
92
|
+
|| stableStringify(actual.columns) !== stableStringify(expected.columns)
|
|
93
|
+
|| actual.unique !== expected.unique
|
|
94
|
+
|| actual.partial !== expected.partial) {
|
|
95
|
+
errors.push(`index:${name}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const activeIndex = indexes.get('idx_kernel_claims_active_lease');
|
|
99
|
+
if (activeIndex && !/\bon\s+kernel_claims\s*\(\s*issue_id\s*\)\s+where\s+state\s*=\s*'active'\s*;?$/i.test(activeIndex.sql)) {
|
|
100
|
+
errors.push('index:idx_kernel_claims_active_lease_sql');
|
|
101
|
+
}
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function exactClaimRow(row) {
|
|
106
|
+
return {
|
|
107
|
+
id: row.id,
|
|
108
|
+
issue_id: row.issue_id,
|
|
109
|
+
actor: row.actor,
|
|
110
|
+
state: row.state,
|
|
111
|
+
session_id: row.session_id ?? null,
|
|
112
|
+
worktree_id: row.worktree_id ?? null,
|
|
113
|
+
claimed_at: row.claimed_at,
|
|
114
|
+
expires_at: row.expires_at ?? null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function digestSnapshot(snapshot, observedAt, claims) {
|
|
119
|
+
const indexes = (snapshot.claim_indexes || []).map(normalizeIndex)
|
|
120
|
+
.sort((left, right) => compareCanonical(left.name, right.name));
|
|
121
|
+
const referencedIssueIds = new Set(claims.map(claim => claim.issue_id));
|
|
122
|
+
const issues = (snapshot.issues || [])
|
|
123
|
+
.filter(issue => referencedIssueIds.has(issue.id))
|
|
124
|
+
.map(issue => ({ id: issue.id, status: issue.status, type: issue.type }))
|
|
125
|
+
.sort((left, right) => compareCanonical(left.id, right.id));
|
|
126
|
+
const canonicalClaims = claims.map(exactClaimRow)
|
|
127
|
+
.sort((left, right) => compareCanonical(left.id, right.id));
|
|
128
|
+
const claimsById = new Map(canonicalClaims.map(claim => [claim.id, claim]));
|
|
129
|
+
const authoritySchema = [...(snapshot.authority_schema || [])]
|
|
130
|
+
.map(row => ({ type: row.type, name: row.name, table_name: row.tbl_name, sql: row.sql ?? null }))
|
|
131
|
+
.sort((left, right) => compareCanonical(stableStringify(left), stableStringify(right)));
|
|
132
|
+
const authorityTables = [...(snapshot.authority_tables || [])]
|
|
133
|
+
.map(table => {
|
|
134
|
+
const rows = (table.rows || []).map(row => {
|
|
135
|
+
if (table.name !== 'kernel_claims') return stableValue(row);
|
|
136
|
+
const claim = claimsById.get(row.id);
|
|
137
|
+
return stableValue(claim ? { ...row, state: claim.state } : row);
|
|
138
|
+
}).sort((left, right) => compareCanonical(stableStringify(left), stableStringify(right)));
|
|
139
|
+
return { name: table.name, rows };
|
|
140
|
+
})
|
|
141
|
+
.sort((left, right) => compareCanonical(left.name, right.name));
|
|
142
|
+
return sha256(stableStringify({
|
|
143
|
+
schema_version: 'forge.claim-repair.snapshot.v2',
|
|
144
|
+
observed_at: observedAt,
|
|
145
|
+
authority: { schema: authoritySchema, tables: authorityTables },
|
|
146
|
+
schema: {
|
|
147
|
+
claim_columns: [...(snapshot.claim_columns || [])]
|
|
148
|
+
.sort(compareCanonical),
|
|
149
|
+
issue_columns: [...(snapshot.issue_columns || [])]
|
|
150
|
+
.sort(compareCanonical),
|
|
151
|
+
claim_foreign_keys: [...(snapshot.claim_foreign_keys || [])]
|
|
152
|
+
.map(row => ({ table: row.table, from: row.from, to: row.to }))
|
|
153
|
+
.sort((left, right) => compareCanonical(stableStringify(left), stableStringify(right))),
|
|
154
|
+
claim_indexes: indexes,
|
|
155
|
+
},
|
|
156
|
+
issues,
|
|
157
|
+
claims: canonicalClaims,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validateIssueRows(issues, errors) {
|
|
162
|
+
const issuesById = new Map();
|
|
163
|
+
for (const issue of issues) {
|
|
164
|
+
if (typeof issue.id !== 'string' || issue.id.length === 0 || !ISSUE_STATUSES.includes(issue.status)) {
|
|
165
|
+
errors.push('invalid_issue_state');
|
|
166
|
+
}
|
|
167
|
+
if (issuesById.has(issue.id)) errors.push('duplicate_issue_id');
|
|
168
|
+
issuesById.set(issue.id, issue);
|
|
169
|
+
}
|
|
170
|
+
return issuesById;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function claimRowErrors(claim, claimIds, issuesById) {
|
|
174
|
+
const errors = [];
|
|
175
|
+
if (typeof claim.id !== 'string' || claim.id.length === 0 || claimIds.has(claim.id)) {
|
|
176
|
+
errors.push('duplicate_or_invalid_claim_id');
|
|
177
|
+
}
|
|
178
|
+
if (typeof claim.issue_id !== 'string' || !issuesById.has(claim.issue_id)) errors.push('orphan_claim');
|
|
179
|
+
if (typeof claim.actor !== 'string' || claim.actor.trim() === '') errors.push('invalid_claim_actor');
|
|
180
|
+
if (!CLAIM_STATES.includes(claim.state)) errors.push('invalid_claim_state');
|
|
181
|
+
if (!canonicalIso(claim.claimed_at)) errors.push('invalid_claimed_at');
|
|
182
|
+
if (claim.expires_at !== null && claim.expires_at !== undefined && !canonicalIso(claim.expires_at)) {
|
|
183
|
+
errors.push('invalid_expires_at');
|
|
184
|
+
}
|
|
185
|
+
return errors;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateClaimRows(claims, issuesById, errors) {
|
|
189
|
+
const claimIds = new Set();
|
|
190
|
+
const activeByIssue = new Map();
|
|
191
|
+
for (const claim of claims) {
|
|
192
|
+
errors.push(...claimRowErrors(claim, claimIds, issuesById));
|
|
193
|
+
claimIds.add(claim.id);
|
|
194
|
+
if (claim.state === 'active') {
|
|
195
|
+
activeByIssue.set(claim.issue_id, Number(activeByIssue.get(claim.issue_id) || 0) + 1);
|
|
196
|
+
const issue = issuesById.get(claim.issue_id);
|
|
197
|
+
if (issue && !isClaimableType(issue.type)) errors.push('unclaimable_active_claim');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if ([...activeByIssue.values()].some(count => count > 1)) errors.push('duplicate_active_claim');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function throwClaimRepairErrors(errors) {
|
|
204
|
+
if (errors.length === 0) return;
|
|
205
|
+
const counts = Object.create(null);
|
|
206
|
+
for (const error of errors) counts[error] = Number(counts[error] || 0) + 1;
|
|
207
|
+
const labels = Object.keys(counts).sort(compareCanonical);
|
|
208
|
+
throw new ClaimRepairError(
|
|
209
|
+
'CLAIM_REPAIR_PREFLIGHT_FAILED',
|
|
210
|
+
`Claim repair preflight failed closed (${labels.join(', ')})`,
|
|
211
|
+
{ errors: counts },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function classifyClaimActions(claims, issuesById, observedAt) {
|
|
216
|
+
const actions = [];
|
|
217
|
+
let preservedNullExpiry = 0;
|
|
218
|
+
let preservedUnexpired = 0;
|
|
219
|
+
for (const claim of claims) {
|
|
220
|
+
if (claim.state !== 'active') continue;
|
|
221
|
+
const issue = issuesById.get(claim.issue_id);
|
|
222
|
+
if (isTerminalStatus(issue.status)) {
|
|
223
|
+
actions.push({ claim: exactClaimRow(claim), issue_status: issue.status, to_state: 'released' });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (claim.expires_at !== null && claim.expires_at !== undefined
|
|
227
|
+
&& Date.parse(claim.expires_at) <= Date.parse(observedAt)) {
|
|
228
|
+
actions.push({ claim: exactClaimRow(claim), issue_status: issue.status, to_state: 'reclaimable' });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (claim.expires_at === null || claim.expires_at === undefined) preservedNullExpiry += 1;
|
|
232
|
+
else preservedUnexpired += 1;
|
|
233
|
+
}
|
|
234
|
+
return { actions, preservedNullExpiry, preservedUnexpired };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildClaimRepairCounts(issues, claims, classification) {
|
|
238
|
+
const { actions, preservedNullExpiry, preservedUnexpired } = classification;
|
|
239
|
+
const released = actions.filter(action => action.to_state === 'released').length;
|
|
240
|
+
const reclaimable = actions.filter(action => action.to_state === 'reclaimable').length;
|
|
241
|
+
return Object.freeze({
|
|
242
|
+
total_issues: issues.length,
|
|
243
|
+
total_claims: claims.length,
|
|
244
|
+
active_claims: claims.filter(claim => claim.state === 'active').length,
|
|
245
|
+
non_active_claims: claims.filter(claim => claim.state !== 'active').length,
|
|
246
|
+
terminal_active_to_release: released,
|
|
247
|
+
expired_nonterminal_to_reclaimable: reclaimable,
|
|
248
|
+
preserved_null_expiry_active: preservedNullExpiry,
|
|
249
|
+
preserved_unexpired_active: preservedUnexpired,
|
|
250
|
+
planned_mutations: actions.length,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildClaimRepairPlan(snapshot, options = {}) {
|
|
255
|
+
const observedAt = options.observedAt;
|
|
256
|
+
if (!canonicalIso(observedAt)) {
|
|
257
|
+
throw new ClaimRepairError(
|
|
258
|
+
'CLAIM_REPAIR_INVALID_OBSERVED_AT',
|
|
259
|
+
'Claim repair requires an exact canonical UTC timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)',
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const errors = collectSchemaErrors(snapshot);
|
|
264
|
+
const issues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
|
|
265
|
+
const claims = Array.isArray(snapshot.claims) ? snapshot.claims : [];
|
|
266
|
+
const issuesById = validateIssueRows(issues, errors);
|
|
267
|
+
validateClaimRows(claims, issuesById, errors);
|
|
268
|
+
throwClaimRepairErrors(errors);
|
|
269
|
+
const classification = classifyClaimActions(claims, issuesById, observedAt);
|
|
270
|
+
const { actions } = classification;
|
|
271
|
+
|
|
272
|
+
const digest = digestSnapshot(snapshot, observedAt, claims);
|
|
273
|
+
const actionById = new Map(actions.map(action => [action.claim.id, action]));
|
|
274
|
+
const afterClaims = claims.map(claim => {
|
|
275
|
+
const action = actionById.get(claim.id);
|
|
276
|
+
return action ? { ...claim, state: action.to_state } : claim;
|
|
277
|
+
});
|
|
278
|
+
const afterDigest = digestSnapshot(snapshot, observedAt, afterClaims);
|
|
279
|
+
return {
|
|
280
|
+
observedAt,
|
|
281
|
+
digest,
|
|
282
|
+
afterDigest,
|
|
283
|
+
counts: buildClaimRepairCounts(issues, claims, classification),
|
|
284
|
+
actions,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function publicClaimRepairPreflight(plan) {
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
schema_version: 'forge.claim-repair.preflight.v1',
|
|
291
|
+
mode: 'dry-run',
|
|
292
|
+
observed_at: plan.observedAt,
|
|
293
|
+
digest: plan.digest,
|
|
294
|
+
after_digest: plan.afterDigest,
|
|
295
|
+
counts: plan.counts,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function hashFile(filePath) {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
const hash = createHash('sha256');
|
|
302
|
+
const stream = fs.createReadStream(filePath);
|
|
303
|
+
stream.on('error', reject);
|
|
304
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
305
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function cleanupRestoreProofDirectory(directory, fsApi = fs) {
|
|
310
|
+
try {
|
|
311
|
+
fsApi.rmSync(directory, {
|
|
312
|
+
recursive: true,
|
|
313
|
+
force: true,
|
|
314
|
+
maxRetries: 5,
|
|
315
|
+
retryDelay: 100,
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
// A closed SQLite handle can remain briefly locked on Windows. The proof is
|
|
319
|
+
// already valid and the private OS temp directory can be reaped later.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function prepareRestoreProofSnapshot(backupPath, restoreDir, restorePath, options = {}) {
|
|
324
|
+
const fsApi = options.fsApi || fs;
|
|
325
|
+
const hardenPath = options.hardenPath;
|
|
326
|
+
const hardenPaths = options.hardenPaths;
|
|
327
|
+
if (typeof hardenPath !== 'function') {
|
|
328
|
+
throw new ClaimRepairError(
|
|
329
|
+
'CLAIM_REPAIR_BACKUP_PERMISSIONS',
|
|
330
|
+
'Restore proof requires a platform permission hardener',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (!options.restoreDirHardened) {
|
|
334
|
+
if (typeof hardenPaths === 'function') await hardenPaths([restoreDir]);
|
|
335
|
+
else await hardenPath(restoreDir);
|
|
336
|
+
}
|
|
337
|
+
fsApi.copyFileSync(backupPath, restorePath, fs.constants.COPYFILE_EXCL);
|
|
338
|
+
if (typeof hardenPaths === 'function') await hardenPaths([restorePath]);
|
|
339
|
+
else await hardenPath(restorePath);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function verifyClaimRepairBackup(options = {}) {
|
|
343
|
+
const { backupPath, observedAt, openDriver, hardenPath, hardenPaths } = options;
|
|
344
|
+
const batchHardenPath = hardenPaths || hardenPath?.batch;
|
|
345
|
+
if (!backupPath || typeof openDriver !== 'function' || !fs.existsSync(backupPath)) {
|
|
346
|
+
throw new ClaimRepairError('CLAIM_REPAIR_BACKUP_REQUIRED', 'A readable separate SQLite backup is required');
|
|
347
|
+
}
|
|
348
|
+
if (typeof hardenPath !== 'function') {
|
|
349
|
+
throw new ClaimRepairError(
|
|
350
|
+
'CLAIM_REPAIR_BACKUP_PERMISSIONS',
|
|
351
|
+
'Backup verification requires a platform permission hardener',
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const restoreDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-claim-restore-proof-'));
|
|
355
|
+
const restorePath = path.join(restoreDir, 'kernel.restored.sqlite');
|
|
356
|
+
let restoreDriver;
|
|
357
|
+
try {
|
|
358
|
+
if (typeof batchHardenPath === 'function') await batchHardenPath([backupPath, restoreDir]);
|
|
359
|
+
else {
|
|
360
|
+
await hardenPath(backupPath);
|
|
361
|
+
await hardenPath(restoreDir);
|
|
362
|
+
}
|
|
363
|
+
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
364
|
+
if (fs.existsSync(`${backupPath}${suffix}`)) {
|
|
365
|
+
throw new ClaimRepairError('CLAIM_REPAIR_BACKUP_DRIFT', 'Backup sidecars are not allowed during restore proof');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
await prepareRestoreProofSnapshot(backupPath, restoreDir, restorePath, {
|
|
369
|
+
hardenPath,
|
|
370
|
+
hardenPaths: batchHardenPath,
|
|
371
|
+
restoreDirHardened: typeof batchHardenPath === 'function',
|
|
372
|
+
});
|
|
373
|
+
restoreDriver = openDriver(restorePath);
|
|
374
|
+
const restored = await restoreDriver.preflightLegacyClaimRepair({ observedAt });
|
|
375
|
+
const restoredHash = await hashFile(restorePath);
|
|
376
|
+
const currentBackupHash = await hashFile(backupPath);
|
|
377
|
+
if (currentBackupHash !== restoredHash) {
|
|
378
|
+
throw new ClaimRepairError(
|
|
379
|
+
'CLAIM_REPAIR_BACKUP_DRIFT',
|
|
380
|
+
'Backup changed while its isolated restore proof was being verified',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return Object.freeze({
|
|
384
|
+
schema_version: 'forge.claim-repair.backup-proof.v1',
|
|
385
|
+
integrity: 'ok',
|
|
386
|
+
backup_sha256: restoredHash,
|
|
387
|
+
plan_digest: restored.digest,
|
|
388
|
+
restore_digest: restored.digest,
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
if (restoreDriver && typeof restoreDriver.close === 'function') restoreDriver.close();
|
|
392
|
+
cleanupRestoreProofDirectory(restoreDir);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function createVerifiedClaimRepairBackup(options = {}) {
|
|
397
|
+
const { sourceDriver, backupPath, observedAt, openDriver, hardenPath, hardenPaths } = options;
|
|
398
|
+
if (!sourceDriver || typeof sourceDriver.preflightLegacyClaimRepair !== 'function'
|
|
399
|
+
|| typeof sourceDriver.backup !== 'function') {
|
|
400
|
+
throw new ClaimRepairError('CLAIM_REPAIR_BACKUP_UNAVAILABLE', 'Source driver cannot create a verified SQLite backup');
|
|
401
|
+
}
|
|
402
|
+
if (!backupPath) {
|
|
403
|
+
throw new ClaimRepairError('CLAIM_REPAIR_BACKUP_REQUIRED', 'A separate SQLite backup path is required');
|
|
404
|
+
}
|
|
405
|
+
if (fs.existsSync(backupPath)) {
|
|
406
|
+
throw new ClaimRepairError(
|
|
407
|
+
'CLAIM_REPAIR_BACKUP_EXISTS',
|
|
408
|
+
'Refusing to overwrite an existing claim-repair backup; choose a new immutable path',
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const source = await sourceDriver.preflightLegacyClaimRepair({ observedAt });
|
|
412
|
+
try {
|
|
413
|
+
await sourceDriver.backup(backupPath, {}, { noReplace: true });
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error?.code === 'EEXIST') {
|
|
416
|
+
throw new ClaimRepairError(
|
|
417
|
+
'CLAIM_REPAIR_BACKUP_EXISTS',
|
|
418
|
+
'Refusing to overwrite a claim-repair backup path created during publication',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
const proof = await verifyClaimRepairBackup({ backupPath, observedAt, openDriver, hardenPath, hardenPaths });
|
|
424
|
+
if (proof.restore_digest !== source.digest) {
|
|
425
|
+
throw new ClaimRepairError(
|
|
426
|
+
'CLAIM_REPAIR_BACKUP_DRIFT',
|
|
427
|
+
'Backup does not restore to the exact preflight snapshot digest',
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return Object.freeze({ ...proof, plan_digest: source.digest });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
module.exports = {
|
|
434
|
+
CLAIM_STATES,
|
|
435
|
+
ClaimRepairError,
|
|
436
|
+
buildClaimRepairPlan,
|
|
437
|
+
cleanupRestoreProofDirectory,
|
|
438
|
+
createVerifiedClaimRepairBackup,
|
|
439
|
+
prepareRestoreProofSnapshot,
|
|
440
|
+
publicClaimRepairPreflight,
|
|
441
|
+
verifyClaimRepairBackup,
|
|
442
|
+
};
|