forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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 +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- 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/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- 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 +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- 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-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- 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 +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- 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 +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- 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-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const journal = require('./journal');
|
|
4
|
+
const { processIdentityAlive } = require('./process-identity');
|
|
5
|
+
|
|
6
|
+
const VERSION = 1;
|
|
7
|
+
const MAX_REPO_BYTES = 256;
|
|
8
|
+
const MAX_GENERATION_BYTES = 128;
|
|
9
|
+
const MAX_RECEIPT_BYTES = 256;
|
|
10
|
+
const REPOSITORY = /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/;
|
|
11
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
12
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
13
|
+
const PHASES = new Set(['starting', 'running', 'stop_requested', 'terminal_pending', 'complete', 'blocked']);
|
|
14
|
+
const BLOCK_REASONS = new Set([
|
|
15
|
+
'legacy_live_pid',
|
|
16
|
+
'legacy_conflict',
|
|
17
|
+
'legacy_unreadable',
|
|
18
|
+
'legacy_lossy',
|
|
19
|
+
'legacy_receipt_unverified',
|
|
20
|
+
]);
|
|
21
|
+
const CONFLICT_CODES = new Set([
|
|
22
|
+
'legacy_identity_unmappable',
|
|
23
|
+
'legacy_snapshot_changed',
|
|
24
|
+
'legacy_owner_conflict',
|
|
25
|
+
]);
|
|
26
|
+
const GATE_STATES = new Set(['quarantined', 'conflict', 'complete']);
|
|
27
|
+
const MONOTONIC_OWNER_METHODS = new Set([
|
|
28
|
+
'watchOwnerReserveReopened', 'watchOwnerBindRunning', 'watchOwnerHeartbeat',
|
|
29
|
+
'watchOwnerRequestStop', 'watchOwnerRecordTerminal', 'watchOwnerCompleteTerminal',
|
|
30
|
+
'watchOwnerRecoverDeadStarting', 'watchOwnerRecoverDeadWatcher',
|
|
31
|
+
'watchOwnerMarkLegacyBlocked', 'watchOwnerRecheckLegacyBlocked',
|
|
32
|
+
'watchOwnerImportLegacyStarting', 'watchOwnerImportLegacyComplete',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const MUTATION_INPUT_FIELDS = [
|
|
36
|
+
'generation', 'controllerPid', 'pid', 'recoveryControllerPid',
|
|
37
|
+
'expectedReceiptId', 'providerEvidence', 'terminalReceiptId',
|
|
38
|
+
'blockReason', 'legacyEvidenceHash', 'snapshotHash', 'legacyPid',
|
|
39
|
+
'action', 'updatedAt', 'startedAt', 'conflictCode', 'pidReuseProven',
|
|
40
|
+
'expectedSnapshotHash', 'expectedConflictCode', 'replacementSnapshotHash',
|
|
41
|
+
];
|
|
42
|
+
const CAPTURED_INPUT = Symbol('capturedMutationInput');
|
|
43
|
+
const BOUND_OPTIONS = new WeakSet();
|
|
44
|
+
// Mirror of the builtin driver's enumeration caps (lib/kernel/sqlite-driver.js
|
|
45
|
+
// WATCH_OWNER_ENUMERATION_LIMIT/BYTES) so alternate adapters cannot allocate
|
|
46
|
+
// without bound through the public enumerateOwners API.
|
|
47
|
+
const OWNER_ENUMERATION_LIMIT = 4_096;
|
|
48
|
+
const OWNER_ENUMERATION_BYTES = 4 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
function utf8Length(value) {
|
|
51
|
+
return typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : -1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function positivePid(value) {
|
|
55
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function canonicalTimestamp(value) {
|
|
59
|
+
if (typeof value !== 'string' || utf8Length(value) !== 24 || !TIMESTAMP.test(value)) return false;
|
|
60
|
+
try {
|
|
61
|
+
return new Date(value).toISOString() === value;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function boundedString(value, maximum) {
|
|
68
|
+
const bytes = utf8Length(value);
|
|
69
|
+
return bytes > 0 && bytes <= maximum;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validHash(value) {
|
|
73
|
+
return typeof value === 'string' && SHA256.test(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeIdentity(ctx) {
|
|
77
|
+
try {
|
|
78
|
+
const repoValue = ctx?.repo;
|
|
79
|
+
const pr = ctx?.pr;
|
|
80
|
+
const repo = typeof repoValue === 'string' ? repoValue.toLowerCase() : '';
|
|
81
|
+
if (!REPOSITORY.test(repo) || utf8Length(repo) > MAX_REPO_BYTES
|
|
82
|
+
|| !Number.isSafeInteger(pr) || pr <= 0) return null;
|
|
83
|
+
return { repo, pr };
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Read the caller's identity exactly once per operation and freeze it: an
|
|
90
|
+
// accessor-backed (or clock-mutated) ctx must not present repository A to the
|
|
91
|
+
// mutation and repository B to the CAS evidence read.
|
|
92
|
+
function captureIdentity(ctx) {
|
|
93
|
+
const identity = normalizeIdentity(ctx);
|
|
94
|
+
return identity ? Object.freeze(identity) : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function nowIso(opts = {}, fallback) {
|
|
98
|
+
try {
|
|
99
|
+
const value = fallback !== undefined
|
|
100
|
+
? fallback
|
|
101
|
+
: (opts.now !== undefined
|
|
102
|
+
? (typeof opts.now === 'function' ? opts.now() : opts.now)
|
|
103
|
+
: new Date().toISOString());
|
|
104
|
+
return canonicalTimestamp(value) ? value : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fromSqliteOwnerRow(row) {
|
|
111
|
+
if (!row) return null;
|
|
112
|
+
return {
|
|
113
|
+
repo: row.repo,
|
|
114
|
+
pr: row.pr,
|
|
115
|
+
version: row.version,
|
|
116
|
+
generation: row.generation,
|
|
117
|
+
phase: row.phase,
|
|
118
|
+
controllerPid: row.controller_pid,
|
|
119
|
+
watcherPid: row.watcher_pid,
|
|
120
|
+
startedAt: row.started_at,
|
|
121
|
+
updatedAt: row.updated_at,
|
|
122
|
+
heartbeatAt: row.heartbeat_at,
|
|
123
|
+
terminalReceiptId: row.terminal_receipt_id,
|
|
124
|
+
blockReason: row.block_reason,
|
|
125
|
+
legacyEvidenceHash: row.legacy_evidence_hash,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function captureRecord(record) {
|
|
130
|
+
try {
|
|
131
|
+
return {
|
|
132
|
+
repo: record.repo,
|
|
133
|
+
pr: record.pr,
|
|
134
|
+
version: record.version,
|
|
135
|
+
generation: record.generation,
|
|
136
|
+
phase: record.phase,
|
|
137
|
+
controllerPid: record.controllerPid,
|
|
138
|
+
watcherPid: record.watcherPid,
|
|
139
|
+
startedAt: record.startedAt,
|
|
140
|
+
updatedAt: record.updatedAt,
|
|
141
|
+
heartbeatAt: record.heartbeatAt,
|
|
142
|
+
terminalReceiptId: record.terminalReceiptId,
|
|
143
|
+
blockReason: record.blockReason,
|
|
144
|
+
legacyEvidenceHash: record.legacyEvidenceHash,
|
|
145
|
+
};
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateRecord(record) {
|
|
152
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return 'invalid_identity';
|
|
153
|
+
const value = captureRecord(record);
|
|
154
|
+
if (!value) return 'invalid_record';
|
|
155
|
+
const identity = normalizeIdentity(value);
|
|
156
|
+
if (!identity || value.repo !== identity.repo || value.version !== VERSION) return 'invalid_identity';
|
|
157
|
+
if (!boundedString(value.generation, MAX_GENERATION_BYTES) || !PHASES.has(value.phase)) return 'invalid_generation_or_phase';
|
|
158
|
+
if (!canonicalTimestamp(value.startedAt) || !canonicalTimestamp(value.updatedAt)
|
|
159
|
+
|| value.updatedAt < value.startedAt) return 'invalid_timestamp';
|
|
160
|
+
if (value.heartbeatAt != null && !canonicalTimestamp(value.heartbeatAt)) return 'invalid_heartbeat';
|
|
161
|
+
if (['running', 'stop_requested', 'terminal_pending'].includes(value.phase)
|
|
162
|
+
&& value.heartbeatAt != null
|
|
163
|
+
&& (value.heartbeatAt < value.startedAt || value.heartbeatAt > value.updatedAt)) return 'invalid_heartbeat';
|
|
164
|
+
if (value.terminalReceiptId != null && !boundedString(value.terminalReceiptId, MAX_RECEIPT_BYTES)) return 'invalid_receipt';
|
|
165
|
+
if (value.legacyEvidenceHash != null && !validHash(value.legacyEvidenceHash)) return 'invalid_evidence';
|
|
166
|
+
if (value.controllerPid != null && !positivePid(value.controllerPid)) return 'invalid_controller_pid';
|
|
167
|
+
if (value.watcherPid != null && !positivePid(value.watcherPid)) return 'invalid_watcher_pid';
|
|
168
|
+
if (value.phase === 'starting') {
|
|
169
|
+
return positivePid(value.controllerPid) && value.watcherPid == null && value.heartbeatAt == null
|
|
170
|
+
&& value.terminalReceiptId == null && value.blockReason == null
|
|
171
|
+
? null : 'invalid_starting';
|
|
172
|
+
}
|
|
173
|
+
if (value.phase === 'running' || value.phase === 'stop_requested') {
|
|
174
|
+
return value.controllerPid == null && positivePid(value.watcherPid) && value.heartbeatAt != null
|
|
175
|
+
&& value.terminalReceiptId == null && value.blockReason == null
|
|
176
|
+
? null : 'invalid_active';
|
|
177
|
+
}
|
|
178
|
+
if (value.phase === 'terminal_pending') {
|
|
179
|
+
return value.controllerPid == null && positivePid(value.watcherPid) && value.heartbeatAt != null
|
|
180
|
+
&& value.terminalReceiptId != null && value.blockReason == null ? null : 'invalid_terminal_pending';
|
|
181
|
+
}
|
|
182
|
+
if (value.phase === 'complete') {
|
|
183
|
+
return value.controllerPid == null && value.watcherPid == null && value.heartbeatAt == null
|
|
184
|
+
&& value.terminalReceiptId != null && value.blockReason == null ? null : 'invalid_complete';
|
|
185
|
+
}
|
|
186
|
+
if (!BLOCK_REASONS.has(value.blockReason) || value.controllerPid != null || value.heartbeatAt != null
|
|
187
|
+
|| !validHash(value.legacyEvidenceHash)) return 'invalid_blocked';
|
|
188
|
+
return (value.blockReason === 'legacy_live_pid') === positivePid(value.watcherPid)
|
|
189
|
+
? null : 'invalid_blocked_pid';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function copyMigrationGate(gate) {
|
|
193
|
+
try {
|
|
194
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) return null;
|
|
195
|
+
const copy = {
|
|
196
|
+
singleton: gate.singleton,
|
|
197
|
+
state: gate.state,
|
|
198
|
+
snapshot_hash: gate.snapshot_hash,
|
|
199
|
+
conflict_code: gate.conflict_code,
|
|
200
|
+
updated_at: gate.updated_at,
|
|
201
|
+
};
|
|
202
|
+
if (copy.singleton !== 1 || !GATE_STATES.has(copy.state)
|
|
203
|
+
|| !canonicalTimestamp(copy.updated_at)) return null;
|
|
204
|
+
if (copy.state === 'quarantined') {
|
|
205
|
+
if (copy.snapshot_hash != null && !validHash(copy.snapshot_hash)) return null;
|
|
206
|
+
if (copy.conflict_code != null) return null;
|
|
207
|
+
} else {
|
|
208
|
+
if (!validHash(copy.snapshot_hash)) return null;
|
|
209
|
+
if (copy.state === 'complete'
|
|
210
|
+
? copy.conflict_code != null
|
|
211
|
+
: !CONFLICT_CODES.has(copy.conflict_code)) return null;
|
|
212
|
+
}
|
|
213
|
+
return copy;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sameMigrationGate(left, right) {
|
|
220
|
+
if (left == null || right == null) return left == null && right == null;
|
|
221
|
+
return ['singleton', 'state', 'snapshot_hash', 'conflict_code', 'updated_at']
|
|
222
|
+
.every(field => Object.is(left[field], right[field]));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function gateMatchesMutation(method, input, gate, result) {
|
|
226
|
+
// Every changed gate mutation stamps the durable checkpoint; only exact
|
|
227
|
+
// idempotent replays may keep the prior timestamp.
|
|
228
|
+
const advanced = result.changed === false
|
|
229
|
+
? gate.updated_at <= input.now
|
|
230
|
+
: gate.updated_at === input.now;
|
|
231
|
+
const prior = input.expectedGate;
|
|
232
|
+
if (result.reason === 'idempotent' && !sameMigrationGate(gate, prior)) return false;
|
|
233
|
+
switch (method) {
|
|
234
|
+
case 'watchGatePublishQuarantine':
|
|
235
|
+
return gate.state === 'quarantined' && gate.conflict_code == null && advanced
|
|
236
|
+
&& (result.reason === 'idempotent' ? prior?.state === 'quarantined' : prior == null);
|
|
237
|
+
case 'watchGateBindSnapshot':
|
|
238
|
+
return gate.state === 'quarantined' && gate.snapshot_hash === input.snapshotHash
|
|
239
|
+
&& gate.conflict_code == null && advanced && prior?.state === 'quarantined'
|
|
240
|
+
&& (result.reason === 'idempotent'
|
|
241
|
+
? prior.snapshot_hash === input.snapshotHash
|
|
242
|
+
: prior.snapshot_hash == null);
|
|
243
|
+
case 'watchGatePublishConflict':
|
|
244
|
+
return gate.state === 'conflict'
|
|
245
|
+
&& gate.snapshot_hash === input.snapshotHash
|
|
246
|
+
&& gate.conflict_code === input.conflictCode && advanced
|
|
247
|
+
&& (result.reason === 'idempotent'
|
|
248
|
+
? prior?.state === 'conflict'
|
|
249
|
+
: prior?.state === 'quarantined' && prior.conflict_code == null
|
|
250
|
+
&& (prior.snapshot_hash == null || prior.snapshot_hash === input.snapshotHash));
|
|
251
|
+
case 'watchGateRetryConflict':
|
|
252
|
+
return gate.state === 'quarantined'
|
|
253
|
+
&& gate.snapshot_hash === input.replacementSnapshotHash
|
|
254
|
+
&& gate.conflict_code == null && advanced
|
|
255
|
+
&& prior?.state === 'conflict'
|
|
256
|
+
&& prior.snapshot_hash === input.expectedSnapshotHash
|
|
257
|
+
&& prior.conflict_code === input.expectedConflictCode;
|
|
258
|
+
case 'watchGateCompleteMigration':
|
|
259
|
+
return gate.state === 'complete' && gate.snapshot_hash === input.snapshotHash && gate.conflict_code == null
|
|
260
|
+
&& advanced && (result.reason === 'idempotent'
|
|
261
|
+
? prior?.state === 'complete'
|
|
262
|
+
: prior?.state === 'quarantined' && prior.snapshot_hash === input.snapshotHash
|
|
263
|
+
&& prior.conflict_code == null);
|
|
264
|
+
default:
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function envelope(ok, changed, reason, record = null) {
|
|
270
|
+
return { ok, changed, reason, record };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function invalid(reason = 'invalid_input') {
|
|
274
|
+
return envelope(false, false, reason);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function invalidGate(reason = 'invalid_input') {
|
|
278
|
+
return { ok: false, changed: false, reason, gate: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function matchesIdentity(record, identity) {
|
|
282
|
+
return record?.repo === identity.repo && record?.pr === identity.pr;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function matchesOwnerRecord(record, fields, phases) {
|
|
286
|
+
if (!record || (phases && ![].concat(phases).includes(record.phase))) return false;
|
|
287
|
+
return Object.entries(fields).every(([field, expected]) => Object.is(record[field], expected));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function matchesOwnerSnapshot(record, snapshot) {
|
|
291
|
+
const expected = fromSqliteOwnerRow(snapshot);
|
|
292
|
+
return expected != null && matchesOwnerRecord(record, expected);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function validOwnerSuccess(result, record, reason, fields, phases, allowIdempotent = false) {
|
|
296
|
+
const idempotent = result.reason === 'idempotent';
|
|
297
|
+
if ((!idempotent && result.reason !== reason) || (idempotent && !allowIdempotent)
|
|
298
|
+
|| result.changed !== !idempotent) return false;
|
|
299
|
+
return matchesOwnerRecord(record, fields, phases);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Operations that rebuild the row mint a fresh generation; a success that kept
|
|
303
|
+
// the prior generation would be an un-recovered/un-reopened row, not a new one.
|
|
304
|
+
function mintedOwnerGeneration(input, record) {
|
|
305
|
+
const priorGeneration = input.expectedSnapshot?.generation;
|
|
306
|
+
if (priorGeneration == null) return false;
|
|
307
|
+
return record != null && !Object.is(record.generation, priorGeneration);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Import inserts mint a fresh generation while its idempotent replay keeps the
|
|
311
|
+
// original row, so the required relation to the prior snapshot flips by path.
|
|
312
|
+
// An idempotent replay without a captured prior row is impossible: replaying
|
|
313
|
+
// requires that a row was previously imported. expectedSnapshot fields are the
|
|
314
|
+
// raw snake_case driver row (deliberate).
|
|
315
|
+
function importedOwnerGeneration(result, input, record) {
|
|
316
|
+
const priorGeneration = input.expectedSnapshot?.generation;
|
|
317
|
+
if (!record) return false;
|
|
318
|
+
if (result.reason === 'idempotent') {
|
|
319
|
+
return priorGeneration != null && Object.is(record.generation, priorGeneration);
|
|
320
|
+
}
|
|
321
|
+
// A changed insert is only legal over an ABSENT owner; the builtin
|
|
322
|
+
// transaction answers any existing row with owner_conflict/idempotent.
|
|
323
|
+
return priorGeneration == null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A transitioned row must carry the submitted timestamp — an unchanged row
|
|
327
|
+
// returned as changed would fake durable-progress advancement. Idempotent
|
|
328
|
+
// replays legitimately keep their original timestamps.
|
|
329
|
+
function stampedOwnerTimestamp(result, input, record, field) {
|
|
330
|
+
if (result.reason === 'idempotent') return true;
|
|
331
|
+
return record != null && Object.is(record[field], input.now);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validOwnerOperationResult(method, input, result, record) {
|
|
335
|
+
if (method === 'watchOwnerRead') {
|
|
336
|
+
return result.changed === false
|
|
337
|
+
&& (result.reason === 'read') === (record != null)
|
|
338
|
+
&& (record != null || result.reason === 'absent');
|
|
339
|
+
}
|
|
340
|
+
if (result.reason === 'idempotent'
|
|
341
|
+
&& (!matchesOwnerSnapshot(record, input.expectedSnapshot)
|
|
342
|
+
|| [record.updatedAt, record.heartbeatAt]
|
|
343
|
+
.filter(Boolean)
|
|
344
|
+
.some(timestamp => timestamp > input.now))) return false;
|
|
345
|
+
switch (method) {
|
|
346
|
+
case 'watchOwnerReserveStarting':
|
|
347
|
+
return input.expectedSnapshot === null && validOwnerSuccess(result, record, 'acquired', {
|
|
348
|
+
controllerPid: input.controllerPid, legacyEvidenceHash: null,
|
|
349
|
+
}, 'starting')
|
|
350
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
351
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
352
|
+
case 'watchOwnerReserveReopened': {
|
|
353
|
+
const prior = input.expectedSnapshot;
|
|
354
|
+
if (!prior) return false;
|
|
355
|
+
return validOwnerSuccess(result, record, 'reopened', {
|
|
356
|
+
controllerPid: input.controllerPid,
|
|
357
|
+
legacyEvidenceHash: prior.legacy_evidence_hash,
|
|
358
|
+
}, 'starting') && mintedOwnerGeneration(input, record)
|
|
359
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
360
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
361
|
+
}
|
|
362
|
+
case 'watchOwnerBindRunning': {
|
|
363
|
+
// Imported provenance and the original started_at survive the bind.
|
|
364
|
+
const prior = input.expectedSnapshot;
|
|
365
|
+
if (!prior || prior.legacy_evidence_hash === undefined
|
|
366
|
+
|| prior.started_at === undefined) return false;
|
|
367
|
+
if ((prior.phase === 'running') !== (result.reason === 'idempotent')) return false;
|
|
368
|
+
return validOwnerSuccess(result, record, 'bound', {
|
|
369
|
+
generation: input.generation, watcherPid: input.watcherPid,
|
|
370
|
+
}, 'running', true)
|
|
371
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt')
|
|
372
|
+
&& stampedOwnerTimestamp(result, input, record, 'heartbeatAt')
|
|
373
|
+
&& Object.is(record.legacyEvidenceHash, prior.legacy_evidence_hash)
|
|
374
|
+
&& Object.is(record.startedAt, prior.started_at);
|
|
375
|
+
}
|
|
376
|
+
case 'watchOwnerHeartbeat': {
|
|
377
|
+
const prior = input.expectedSnapshot;
|
|
378
|
+
if (!prior || (prior.phase !== 'running' && prior.phase !== 'stop_requested')) return false;
|
|
379
|
+
// Imported provenance and the original started_at survive the beat.
|
|
380
|
+
if (prior.legacy_evidence_hash === undefined || prior.started_at === undefined) return false;
|
|
381
|
+
return validOwnerSuccess(result, record, 'heartbeat', {
|
|
382
|
+
generation: input.generation, watcherPid: input.watcherPid,
|
|
383
|
+
updatedAt: input.now, heartbeatAt: input.now,
|
|
384
|
+
legacyEvidenceHash: prior.legacy_evidence_hash,
|
|
385
|
+
startedAt: prior.started_at,
|
|
386
|
+
}, [prior.phase]);
|
|
387
|
+
}
|
|
388
|
+
case 'watchOwnerRequestStop': {
|
|
389
|
+
const prior = input.expectedSnapshot;
|
|
390
|
+
if (!prior || !['running', 'stop_requested'].includes(prior.phase)) return false;
|
|
391
|
+
if ((prior.phase === 'stop_requested') !== (result.reason === 'idempotent')) return false;
|
|
392
|
+
return validOwnerSuccess(result, record, 'stop_requested', {
|
|
393
|
+
generation: input.generation, watcherPid: input.watcherPid,
|
|
394
|
+
heartbeatAt: prior.heartbeat_at,
|
|
395
|
+
legacyEvidenceHash: prior.legacy_evidence_hash, startedAt: prior.started_at,
|
|
396
|
+
}, 'stop_requested', true) && stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
397
|
+
}
|
|
398
|
+
case 'watchOwnerRecordTerminal': {
|
|
399
|
+
const prior = input.expectedSnapshot;
|
|
400
|
+
if (!prior || !['running', 'stop_requested', 'terminal_pending'].includes(prior.phase)) return false;
|
|
401
|
+
if ((prior.phase === 'terminal_pending') !== (result.reason === 'idempotent')) return false;
|
|
402
|
+
return validOwnerSuccess(result, record, 'terminal_pending', {
|
|
403
|
+
generation: input.generation, watcherPid: input.watcherPid,
|
|
404
|
+
terminalReceiptId: input.terminalReceiptId,
|
|
405
|
+
heartbeatAt: prior.heartbeat_at,
|
|
406
|
+
legacyEvidenceHash: prior.legacy_evidence_hash, startedAt: prior.started_at,
|
|
407
|
+
}, 'terminal_pending', true) && stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
408
|
+
}
|
|
409
|
+
case 'watchOwnerCompleteTerminal': {
|
|
410
|
+
const prior = input.expectedSnapshot;
|
|
411
|
+
if (!prior || !['terminal_pending', 'complete'].includes(prior.phase)) return false;
|
|
412
|
+
if ((prior.phase === 'complete') !== (result.reason === 'idempotent')) return false;
|
|
413
|
+
return validOwnerSuccess(result, record, 'complete', {
|
|
414
|
+
generation: input.generation, watcherPid: null,
|
|
415
|
+
terminalReceiptId: input.terminalReceiptId,
|
|
416
|
+
legacyEvidenceHash: prior.legacy_evidence_hash, startedAt: prior.started_at,
|
|
417
|
+
}, 'complete', true) && stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
418
|
+
}
|
|
419
|
+
case 'watchOwnerAbortStarting': {
|
|
420
|
+
if (!input.expectedSnapshot) return false;
|
|
421
|
+
return result.reason === 'aborted' && result.changed === true && record === null;
|
|
422
|
+
}
|
|
423
|
+
case 'watchOwnerReleaseNonterminal': {
|
|
424
|
+
const prior = input.expectedSnapshot;
|
|
425
|
+
return prior?.phase === 'stop_requested'
|
|
426
|
+
&& prior.generation === input.generation
|
|
427
|
+
&& prior.watcher_pid === input.watcherPid
|
|
428
|
+
&& result.reason === 'released' && result.changed === true && record === null;
|
|
429
|
+
}
|
|
430
|
+
case 'watchOwnerRecoverDeadStarting': {
|
|
431
|
+
const prior = input.expectedSnapshot;
|
|
432
|
+
if (!prior) return false;
|
|
433
|
+
return validOwnerSuccess(result, record, 'recovered', {
|
|
434
|
+
controllerPid: input.controllerPid,
|
|
435
|
+
legacyEvidenceHash: prior.legacy_evidence_hash,
|
|
436
|
+
}, 'starting') && mintedOwnerGeneration(input, record)
|
|
437
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
438
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
439
|
+
}
|
|
440
|
+
case 'watchOwnerRecoverDeadWatcher': {
|
|
441
|
+
const prior = input.expectedSnapshot;
|
|
442
|
+
if (!prior || !['running', 'stop_requested'].includes(prior.phase)) return false;
|
|
443
|
+
return validOwnerSuccess(result, record, 'recovered', {
|
|
444
|
+
controllerPid: input.controllerPid,
|
|
445
|
+
legacyEvidenceHash: prior.legacy_evidence_hash,
|
|
446
|
+
}, 'starting') && mintedOwnerGeneration(input, record)
|
|
447
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
448
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
449
|
+
}
|
|
450
|
+
case 'watchOwnerMarkLegacyBlocked':
|
|
451
|
+
if (!quarantinedGateMatches(input) || (input.expectedSnapshot && result.reason !== 'idempotent')) return false;
|
|
452
|
+
return validOwnerSuccess(result, record, 'blocked', {
|
|
453
|
+
watcherPid: input.watcherPid, terminalReceiptId: input.terminalReceiptId ?? null,
|
|
454
|
+
blockReason: input.blockReason, legacyEvidenceHash: input.legacyEvidenceHash,
|
|
455
|
+
}, 'blocked', true)
|
|
456
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
457
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
458
|
+
case 'watchOwnerRecheckLegacyBlocked': {
|
|
459
|
+
if (input.action === 'release') {
|
|
460
|
+
if (input.expectedSnapshot?.block_reason !== 'legacy_live_pid') return false;
|
|
461
|
+
return result.reason === 'released' && result.changed === true && record === null;
|
|
462
|
+
}
|
|
463
|
+
const prior = input.expectedSnapshot;
|
|
464
|
+
if (!prior || prior.phase !== 'blocked') return false;
|
|
465
|
+
return validOwnerSuccess(result, record, 'complete', {
|
|
466
|
+
generation: input.generation, watcherPid: null,
|
|
467
|
+
terminalReceiptId: input.terminalReceiptId, blockReason: null,
|
|
468
|
+
legacyEvidenceHash: prior.legacy_evidence_hash, startedAt: prior.started_at,
|
|
469
|
+
}, 'complete') && stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
470
|
+
}
|
|
471
|
+
case 'watchOwnerImportLegacyStarting':
|
|
472
|
+
return quarantinedGateMatches(input) && validOwnerSuccess(result, record, 'imported', {
|
|
473
|
+
controllerPid: input.controllerPid, legacyEvidenceHash: input.legacyEvidenceHash,
|
|
474
|
+
}, 'starting', true) && importedOwnerGeneration(result, input, record)
|
|
475
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
476
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
477
|
+
case 'watchOwnerImportLegacyComplete':
|
|
478
|
+
return quarantinedGateMatches(input) && validOwnerSuccess(result, record, 'imported', {
|
|
479
|
+
watcherPid: null, terminalReceiptId: input.terminalReceiptId,
|
|
480
|
+
legacyEvidenceHash: input.legacyEvidenceHash,
|
|
481
|
+
}, 'complete', true) && importedOwnerGeneration(result, input, record)
|
|
482
|
+
&& stampedOwnerTimestamp(result, input, record, 'startedAt')
|
|
483
|
+
&& stampedOwnerTimestamp(result, input, record, 'updatedAt');
|
|
484
|
+
default:
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function quarantinedGateMatches(input) {
|
|
490
|
+
return input.expectedGate?.state === 'quarantined'
|
|
491
|
+
&& input.expectedGate.snapshot_hash === input.snapshotHash
|
|
492
|
+
&& input.expectedGate.conflict_code == null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function bindAuthorityOptions(opts = {}, methods = []) {
|
|
496
|
+
try {
|
|
497
|
+
if (BOUND_OPTIONS.has(opts)) return opts;
|
|
498
|
+
const driver = opts?.driver;
|
|
499
|
+
const databaseConfig = opts?.databaseConfig || {};
|
|
500
|
+
const stableDatabaseConfig = databaseConfig && typeof databaseConfig === 'object'
|
|
501
|
+
? Object.freeze({ ...databaseConfig })
|
|
502
|
+
: databaseConfig;
|
|
503
|
+
const authorityMethods = {};
|
|
504
|
+
for (const method of methods) {
|
|
505
|
+
const candidate = driver && typeof driver[method] === 'function' ? driver[method] : null;
|
|
506
|
+
if (candidate) authorityMethods[method] = candidate.bind(driver);
|
|
507
|
+
}
|
|
508
|
+
const bound = {
|
|
509
|
+
...opts,
|
|
510
|
+
driver,
|
|
511
|
+
databaseConfig: stableDatabaseConfig,
|
|
512
|
+
authorityMethods: Object.freeze(authorityMethods),
|
|
513
|
+
};
|
|
514
|
+
BOUND_OPTIONS.add(bound);
|
|
515
|
+
return Object.freeze(bound);
|
|
516
|
+
} catch {
|
|
517
|
+
return Object.freeze({ authorityMethods: Object.freeze({}) });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function captureMutationInput(input = {}) {
|
|
522
|
+
const captured = {};
|
|
523
|
+
try {
|
|
524
|
+
const hasUpdatedAt = input != null && Object.prototype.hasOwnProperty.call(input, 'updatedAt');
|
|
525
|
+
const hasStartedAt = input != null && Object.prototype.hasOwnProperty.call(input, 'startedAt');
|
|
526
|
+
for (const field of MUTATION_INPUT_FIELDS) captured[field] = input == null ? undefined : input[field];
|
|
527
|
+
Object.defineProperty(captured, CAPTURED_INPUT, {
|
|
528
|
+
value: { hasUpdatedAt, hasStartedAt },
|
|
529
|
+
});
|
|
530
|
+
return captured;
|
|
531
|
+
} catch {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function invoke(method, identity, input, opts) {
|
|
537
|
+
const operation = opts?.authorityMethods?.[method];
|
|
538
|
+
if (typeof operation !== 'function') return invalid('authority_unavailable');
|
|
539
|
+
if (ownerMutationRegresses(method, { ...identity, ...input })) return invalid('stale_evidence');
|
|
540
|
+
try {
|
|
541
|
+
const result = operation({ ...identity, ...input }, opts.databaseConfig || {});
|
|
542
|
+
if (result && typeof result.then === 'function') {
|
|
543
|
+
// Consume the orphaned rejection so a rejecting async adapter cannot crash the host.
|
|
544
|
+
Promise.resolve(result).then(() => {}, () => {});
|
|
545
|
+
return invalid('invalid_operation');
|
|
546
|
+
}
|
|
547
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return invalid('corrupt');
|
|
548
|
+
// Snapshot once: accessor-backed results must not flip between
|
|
549
|
+
// validation and the returned envelope.
|
|
550
|
+
const snap = {
|
|
551
|
+
ok: result.ok === true,
|
|
552
|
+
changed: result.changed === true,
|
|
553
|
+
reason: typeof result.reason === 'string' ? result.reason : '',
|
|
554
|
+
row: result.row,
|
|
555
|
+
};
|
|
556
|
+
const record = fromSqliteOwnerRow(snap.row);
|
|
557
|
+
if (snap.row != null && (validateRecord(record) || !matchesIdentity(record, identity))) return invalid('corrupt');
|
|
558
|
+
if (!snap.ok && snap.changed) return invalid('corrupt');
|
|
559
|
+
if (snap.ok && !validOwnerOperationResult(method, { ...identity, ...input }, snap, record)) return invalid('corrupt');
|
|
560
|
+
return envelope(snap.ok, snap.changed, snap.reason, record);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return invalid(error?.code === 'AUTHORITY_UNAVAILABLE' ? 'authority_unavailable' : 'store_error');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function ownerMutationRegresses(method, input) {
|
|
567
|
+
const prior = input.expectedSnapshot;
|
|
568
|
+
if (!prior || !MONOTONIC_OWNER_METHODS.has(method)
|
|
569
|
+
|| (method === 'watchOwnerRecheckLegacyBlocked' && input.action === 'release')) return false;
|
|
570
|
+
return [prior.updated_at, prior.heartbeat_at]
|
|
571
|
+
.filter(Boolean)
|
|
572
|
+
.some(timestamp => Date.parse(input.now) < Date.parse(timestamp));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function captureAuthoritySnapshot(identity, opts) {
|
|
576
|
+
if (!identity) return { ok: false, reason: 'invalid_input' };
|
|
577
|
+
const operation = opts?.authorityMethods?.watchOwnerRead;
|
|
578
|
+
if (typeof operation !== 'function') return { ok: false, reason: 'authority_unavailable' };
|
|
579
|
+
try {
|
|
580
|
+
const result = operation(identity, opts.databaseConfig || {});
|
|
581
|
+
if (result && typeof result.then === 'function') {
|
|
582
|
+
Promise.resolve(result).then(() => {}, () => {});
|
|
583
|
+
return { ok: false, reason: 'invalid_operation' };
|
|
584
|
+
}
|
|
585
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return { ok: false, reason: 'corrupt' };
|
|
586
|
+
// Snapshot once: accessor-backed results must not flip between the
|
|
587
|
+
// read-invariant probe and the evidence snapshot taken from them.
|
|
588
|
+
const snap = {
|
|
589
|
+
ok: result.ok === true,
|
|
590
|
+
changed: result.changed === true,
|
|
591
|
+
reason: typeof result.reason === 'string' ? result.reason : '',
|
|
592
|
+
row: result.row,
|
|
593
|
+
};
|
|
594
|
+
if (!snap.ok && snap.changed) return { ok: false, reason: 'corrupt' };
|
|
595
|
+
if (!snap.ok) return { ok: false, reason: snap.reason };
|
|
596
|
+
// Bind the evidence to one plain copy of the row: accessor-backed
|
|
597
|
+
// fields must not present different owners to the probe and the CAS
|
|
598
|
+
// snapshot.
|
|
599
|
+
let rawRow = null;
|
|
600
|
+
if (snap.row != null) {
|
|
601
|
+
if (typeof snap.row !== 'object' || Array.isArray(snap.row)) return { ok: false, reason: 'corrupt' };
|
|
602
|
+
rawRow = { ...snap.row };
|
|
603
|
+
}
|
|
604
|
+
const probe = rawRow && fromSqliteOwnerRow(rawRow);
|
|
605
|
+
if (snap.changed !== false
|
|
606
|
+
|| (snap.reason === 'read') !== (probe != null)
|
|
607
|
+
|| (probe == null && snap.reason !== 'absent')) {
|
|
608
|
+
return { ok: false, reason: 'corrupt' };
|
|
609
|
+
}
|
|
610
|
+
if (!rawRow) return { ok: true, identity, snapshot: null };
|
|
611
|
+
const record = fromSqliteOwnerRow(rawRow);
|
|
612
|
+
if (validateRecord(record) || !matchesIdentity(record, identity)) return { ok: false, reason: 'corrupt' };
|
|
613
|
+
return { ok: true, identity, snapshot: Object.freeze(rawRow) };
|
|
614
|
+
} catch (error) {
|
|
615
|
+
return { ok: false, reason: error?.code === 'AUTHORITY_UNAVAILABLE' ? 'authority_unavailable' : 'store_error' };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function evidenceVerified(identity, evidence, states, opts) {
|
|
620
|
+
if (typeof opts.verifyProviderEvidence !== 'function') return false;
|
|
621
|
+
try {
|
|
622
|
+
return await opts.verifyProviderEvidence(evidence, { ...identity, states }) === true;
|
|
623
|
+
} catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function receiptVerified(identity, receipt, opts) {
|
|
629
|
+
if (!boundedString(receipt, MAX_RECEIPT_BYTES) || typeof opts.verifyTerminalReceipt !== 'function') return false;
|
|
630
|
+
try {
|
|
631
|
+
return await opts.verifyTerminalReceipt(receipt, Object.freeze({ ...identity })) === true;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function pidState(pid, opts) {
|
|
638
|
+
const inspect = opts.isPidAlive || journal.pidAlive;
|
|
639
|
+
try {
|
|
640
|
+
return await inspect(pid);
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Recovery normally demands a dead PID. A caller that observed PID reuse may
|
|
648
|
+
* instead present `pidReuseProven`, but the flag alone is never enough: this
|
|
649
|
+
* authority re-derives the proof from the row's own marker (the timestamp the
|
|
650
|
+
* owner process itself wrote) and an observed process start time. No flag, no
|
|
651
|
+
* marker, or no start-time probe means no proof, and a live PID still blocks
|
|
652
|
+
* recovery — fail closed.
|
|
653
|
+
*
|
|
654
|
+
* @param {number} pid
|
|
655
|
+
* @param {string} marker owner-written timestamp the process must predate
|
|
656
|
+
* @param {object} input captured mutation input
|
|
657
|
+
* @param {object} opts bound authority options
|
|
658
|
+
* @returns {Promise<boolean>} true when recovery may proceed
|
|
659
|
+
*/
|
|
660
|
+
async function pidDeadOrProvenReused(pid, marker, input, opts) {
|
|
661
|
+
const state = await pidState(pid, opts);
|
|
662
|
+
if (state === false) return true;
|
|
663
|
+
if (input?.pidReuseProven !== true) return false;
|
|
664
|
+
try {
|
|
665
|
+
return await processIdentityAlive({
|
|
666
|
+
pid, startedAt: marker, isPidAlive: async () => state, pidStartedAt: opts.pidStartedAt,
|
|
667
|
+
}) === 'reused';
|
|
668
|
+
} catch {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function mutationInput(identity, input, opts, { generation = true, now = true } = {}) {
|
|
674
|
+
if (!identity) return null;
|
|
675
|
+
const value = { ...identity };
|
|
676
|
+
if (generation) {
|
|
677
|
+
if (!boundedString(input?.generation, MAX_GENERATION_BYTES)) return null;
|
|
678
|
+
value.generation = input.generation;
|
|
679
|
+
}
|
|
680
|
+
if (now) {
|
|
681
|
+
const captured = input?.[CAPTURED_INPUT];
|
|
682
|
+
const hasUpdatedAt = captured ? captured.hasUpdatedAt : input != null && Object.prototype.hasOwnProperty.call(input, 'updatedAt');
|
|
683
|
+
const hasStartedAt = captured ? captured.hasStartedAt : input != null && Object.prototype.hasOwnProperty.call(input, 'startedAt');
|
|
684
|
+
const timestamp = hasUpdatedAt ? input.updatedAt : (hasStartedAt ? input.startedAt : undefined);
|
|
685
|
+
value.now = hasUpdatedAt || hasStartedAt
|
|
686
|
+
? (canonicalTimestamp(timestamp) ? timestamp : null)
|
|
687
|
+
: nowIso(opts);
|
|
688
|
+
if (!value.now) return null;
|
|
689
|
+
}
|
|
690
|
+
return value;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function readOwner(ctx, opts = {}) {
|
|
694
|
+
const identity = captureIdentity(ctx);
|
|
695
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead']);
|
|
696
|
+
return identity ? invoke('watchOwnerRead', identity, {}, boundOpts) : invalid();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function enumerateOwners(_ctx, opts = {}) {
|
|
700
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerList']);
|
|
701
|
+
const operation = boundOpts.authorityMethods?.watchOwnerList;
|
|
702
|
+
if (typeof operation !== 'function') {
|
|
703
|
+
return { ok: false, changed: false, reason: 'authority_unavailable', records: [] };
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
const result = operation(boundOpts.databaseConfig || {});
|
|
707
|
+
if (result && typeof result.then === 'function') {
|
|
708
|
+
Promise.resolve(result).then(() => {}, () => {});
|
|
709
|
+
return { ok: false, changed: false, reason: 'invalid_operation', records: [] };
|
|
710
|
+
}
|
|
711
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
712
|
+
return { ok: false, changed: false, reason: 'corrupt', records: [] };
|
|
713
|
+
}
|
|
714
|
+
// Snapshot once into a bounded plain array: an accessor-backed rows
|
|
715
|
+
// property (or a retained Proxy array) must not report one length and
|
|
716
|
+
// yield different iteration contents afterwards.
|
|
717
|
+
const snap = {
|
|
718
|
+
ok: result.ok === true,
|
|
719
|
+
changed: result.changed === true,
|
|
720
|
+
reason: typeof result.reason === 'string' ? result.reason : '',
|
|
721
|
+
rows: result.rows,
|
|
722
|
+
};
|
|
723
|
+
if (!snap.ok && snap.changed) {
|
|
724
|
+
return { ok: false, changed: false, reason: 'corrupt', records: [] };
|
|
725
|
+
}
|
|
726
|
+
if (snap.ok && (snap.changed !== false || snap.reason !== 'read')) {
|
|
727
|
+
return { ok: false, changed: false, reason: 'corrupt', records: [] };
|
|
728
|
+
}
|
|
729
|
+
if (!Array.isArray(snap.rows) && typeof snap.rows?.[Symbol.iterator] !== 'function') {
|
|
730
|
+
return { ok: false, changed: false, reason: 'corrupt', records: [] };
|
|
731
|
+
}
|
|
732
|
+
if (!snap.ok) {
|
|
733
|
+
// Failed reads must never expose converted rows as if they were
|
|
734
|
+
// authoritative data.
|
|
735
|
+
return { ok: false, changed: false, reason: snap.reason, records: [] };
|
|
736
|
+
}
|
|
737
|
+
const rows = [];
|
|
738
|
+
for (const row of snap.rows) {
|
|
739
|
+
rows.push(row);
|
|
740
|
+
if (rows.length > OWNER_ENUMERATION_LIMIT) {
|
|
741
|
+
return { ok: false, changed: false, reason: 'enumeration_overflow', records: [] };
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
let bytes = 0;
|
|
745
|
+
let previous = null;
|
|
746
|
+
const records = [];
|
|
747
|
+
for (const row of rows) {
|
|
748
|
+
const record = fromSqliteOwnerRow(row);
|
|
749
|
+
if (validateRecord(record)) return { ok: false, changed: false, reason: 'corrupt', records: [] };
|
|
750
|
+
if (previous && !(record.repo > previous.repo
|
|
751
|
+
|| (record.repo === previous.repo && record.pr > previous.pr))) {
|
|
752
|
+
return { ok: false, changed: false, reason: 'corrupt', records: [] };
|
|
753
|
+
}
|
|
754
|
+
previous = record;
|
|
755
|
+
bytes += Buffer.byteLength(JSON.stringify(record), 'utf8');
|
|
756
|
+
if (bytes > OWNER_ENUMERATION_BYTES) {
|
|
757
|
+
return { ok: false, changed: false, reason: 'enumeration_overflow', records: [] };
|
|
758
|
+
}
|
|
759
|
+
records.push(record);
|
|
760
|
+
}
|
|
761
|
+
return { ok: snap.ok, changed: false, reason: snap.reason, records };
|
|
762
|
+
} catch (error) {
|
|
763
|
+
return { ok: false, changed: false,
|
|
764
|
+
reason: error?.code === 'AUTHORITY_UNAVAILABLE' ? 'authority_unavailable' : 'store_error', records: [] };
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function reserveStarting(ctx, input, opts = {}) {
|
|
769
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerReserveStarting']);
|
|
770
|
+
const capturedInput = captureMutationInput(input);
|
|
771
|
+
if (!capturedInput) return invalid();
|
|
772
|
+
input = capturedInput;
|
|
773
|
+
const identity = captureIdentity(ctx);
|
|
774
|
+
const value = mutationInput(identity, input, boundOpts, { generation: false });
|
|
775
|
+
if (!value || !positivePid(input?.controllerPid)) return invalid('invalid_reservation');
|
|
776
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
777
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
778
|
+
return invoke('watchOwnerReserveStarting', value, {
|
|
779
|
+
controllerPid: Number(input.controllerPid), expectedSnapshot: captured.snapshot,
|
|
780
|
+
}, boundOpts);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function reserveReopened(ctx, input, opts = {}) {
|
|
784
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerReserveReopened']);
|
|
785
|
+
const capturedInput = captureMutationInput(input);
|
|
786
|
+
if (!capturedInput) return invalid();
|
|
787
|
+
input = capturedInput;
|
|
788
|
+
const identity = captureIdentity(ctx);
|
|
789
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
790
|
+
const controllerPid = input?.controllerPid;
|
|
791
|
+
const expectedReceiptId = input?.expectedReceiptId;
|
|
792
|
+
const providerEvidence = input?.providerEvidence;
|
|
793
|
+
if (!value || !positivePid(controllerPid)
|
|
794
|
+
|| !boundedString(expectedReceiptId, MAX_RECEIPT_BYTES)) return invalid('invalid_reservation');
|
|
795
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
796
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
797
|
+
const prior = captured.snapshot;
|
|
798
|
+
if (!prior || prior.phase !== 'complete'
|
|
799
|
+
|| prior.generation !== value.generation
|
|
800
|
+
|| prior.terminal_receipt_id !== input.expectedReceiptId) {
|
|
801
|
+
return invalid('stale_evidence');
|
|
802
|
+
}
|
|
803
|
+
if (!await evidenceVerified(value, providerEvidence, ['open'], boundOpts)) return invalid('provider_evidence_invalid');
|
|
804
|
+
return invoke('watchOwnerReserveReopened', value, {
|
|
805
|
+
controllerPid: Number(controllerPid), expectedReceiptId,
|
|
806
|
+
expectedSnapshot: captured.snapshot,
|
|
807
|
+
}, boundOpts);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function bindRunning(ctx, input, opts = {}) {
|
|
811
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerBindRunning']);
|
|
812
|
+
const capturedInput = captureMutationInput(input);
|
|
813
|
+
if (!capturedInput) return invalid();
|
|
814
|
+
input = capturedInput;
|
|
815
|
+
const identity = captureIdentity(ctx);
|
|
816
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
817
|
+
if (!value || !positivePid(input?.controllerPid) || !positivePid(input?.pid)) return invalid('invalid_pid');
|
|
818
|
+
// Imported provenance (legacy evidence hash, started_at) must survive the
|
|
819
|
+
// bind, so capture the starting row first and bind the result to it.
|
|
820
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
821
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
822
|
+
if (!captured.snapshot || !['starting', 'running'].includes(captured.snapshot.phase)) {
|
|
823
|
+
return invalid('phase_mismatch');
|
|
824
|
+
}
|
|
825
|
+
if (captured.snapshot.generation !== value.generation) return invalid('generation_mismatch');
|
|
826
|
+
if (captured.snapshot.phase === 'starting'
|
|
827
|
+
&& captured.snapshot.controller_pid !== Number(input.controllerPid)) {
|
|
828
|
+
return invalid('controller_pid_mismatch');
|
|
829
|
+
}
|
|
830
|
+
if (captured.snapshot.phase === 'running'
|
|
831
|
+
&& captured.snapshot.watcher_pid !== Number(input.pid)) return invalid('pid_mismatch');
|
|
832
|
+
return invoke('watchOwnerBindRunning', value, {
|
|
833
|
+
controllerPid: Number(input.controllerPid), watcherPid: Number(input.pid),
|
|
834
|
+
expectedSnapshot: captured.snapshot,
|
|
835
|
+
}, boundOpts);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function heartbeat(ctx, input, opts = {}) {
|
|
839
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerHeartbeat']);
|
|
840
|
+
const capturedInput = captureMutationInput(input);
|
|
841
|
+
if (!capturedInput) return invalid();
|
|
842
|
+
input = capturedInput;
|
|
843
|
+
const identity = captureIdentity(ctx);
|
|
844
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
845
|
+
if (!value || !positivePid(input?.pid)) return invalid('invalid_pid');
|
|
846
|
+
// The heartbeated phase must be captured before the mutation so an
|
|
847
|
+
// alternate driver cannot flip running↔stop_requested in its result.
|
|
848
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
849
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
850
|
+
if (!captured.snapshot || !['running', 'stop_requested'].includes(captured.snapshot.phase)) {
|
|
851
|
+
return invalid('phase_mismatch');
|
|
852
|
+
}
|
|
853
|
+
if (captured.snapshot.generation !== value.generation) return invalid('generation_mismatch');
|
|
854
|
+
if (captured.snapshot.watcher_pid !== Number(input.pid)) return invalid('pid_mismatch');
|
|
855
|
+
return invoke('watchOwnerHeartbeat', value, {
|
|
856
|
+
watcherPid: Number(input.pid), expectedSnapshot: captured.snapshot,
|
|
857
|
+
}, boundOpts);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function requestStop(ctx, input, opts = {}) {
|
|
861
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerRequestStop']);
|
|
862
|
+
const capturedInput = captureMutationInput(input);
|
|
863
|
+
if (!capturedInput) return invalid();
|
|
864
|
+
input = capturedInput;
|
|
865
|
+
const identity = captureIdentity(ctx);
|
|
866
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
867
|
+
if (!value || !positivePid(input?.pid)) return invalid('invalid_pid');
|
|
868
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
869
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
870
|
+
const prior = captured.snapshot;
|
|
871
|
+
if (!prior || !['running', 'stop_requested'].includes(prior.phase)) return invalid('phase_mismatch');
|
|
872
|
+
if (prior.generation !== value.generation) return invalid('generation_mismatch');
|
|
873
|
+
if (prior.watcher_pid !== Number(input.pid)) return invalid('pid_mismatch');
|
|
874
|
+
return invoke('watchOwnerRequestStop', value, {
|
|
875
|
+
watcherPid: Number(input.pid), expectedSnapshot: prior,
|
|
876
|
+
}, boundOpts);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function recordTerminal(ctx, input, opts = {}) {
|
|
880
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerRecordTerminal']);
|
|
881
|
+
const capturedInput = captureMutationInput(input);
|
|
882
|
+
if (!capturedInput) return invalid();
|
|
883
|
+
input = capturedInput;
|
|
884
|
+
const identity = captureIdentity(ctx);
|
|
885
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
886
|
+
const watcherPid = input?.pid;
|
|
887
|
+
const terminalReceiptId = input?.terminalReceiptId;
|
|
888
|
+
if (!value || !positivePid(watcherPid)) return invalid('invalid_pid');
|
|
889
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
890
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
891
|
+
const prior = captured.snapshot;
|
|
892
|
+
const exactTerminalReplay = prior?.phase === 'terminal_pending'
|
|
893
|
+
&& prior.generation === value.generation
|
|
894
|
+
&& prior.watcher_pid === Number(watcherPid)
|
|
895
|
+
&& prior.terminal_receipt_id === terminalReceiptId;
|
|
896
|
+
const boundToSubmission = prior != null
|
|
897
|
+
&& ['running', 'stop_requested'].includes(prior.phase)
|
|
898
|
+
&& prior.generation === value.generation
|
|
899
|
+
&& prior.watcher_pid === Number(watcherPid);
|
|
900
|
+
if (!exactTerminalReplay && !boundToSubmission) return invalid('stale_evidence');
|
|
901
|
+
if (!exactTerminalReplay && !await receiptVerified(value, terminalReceiptId, boundOpts)) return invalid('receipt_unverified');
|
|
902
|
+
return invoke('watchOwnerRecordTerminal', value, {
|
|
903
|
+
watcherPid: Number(watcherPid), terminalReceiptId,
|
|
904
|
+
expectedSnapshot: captured.snapshot,
|
|
905
|
+
}, boundOpts);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function completeTerminal(ctx, input, opts = {}) {
|
|
909
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerCompleteTerminal']);
|
|
910
|
+
const capturedInput = captureMutationInput(input);
|
|
911
|
+
if (!capturedInput) return invalid();
|
|
912
|
+
input = capturedInput;
|
|
913
|
+
const identity = captureIdentity(ctx);
|
|
914
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
915
|
+
const watcherPid = input?.pid;
|
|
916
|
+
const terminalReceiptId = input?.terminalReceiptId;
|
|
917
|
+
if (!value || !positivePid(watcherPid) || !boundedString(terminalReceiptId, MAX_RECEIPT_BYTES)) return invalid();
|
|
918
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
919
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
920
|
+
const prior = captured.snapshot;
|
|
921
|
+
const exactCompletedReplay = prior?.phase === 'complete'
|
|
922
|
+
&& prior.generation === value.generation
|
|
923
|
+
&& prior.terminal_receipt_id === terminalReceiptId;
|
|
924
|
+
const boundToSubmission = prior != null
|
|
925
|
+
&& prior.phase === 'terminal_pending'
|
|
926
|
+
&& prior.generation === value.generation
|
|
927
|
+
&& prior.watcher_pid === Number(watcherPid)
|
|
928
|
+
&& prior.terminal_receipt_id === terminalReceiptId;
|
|
929
|
+
if (!exactCompletedReplay && !boundToSubmission) return invalid('stale_evidence');
|
|
930
|
+
// A bare "the PID exists" answer keeps a terminal_pending row active forever once
|
|
931
|
+
// the exited watcher's number is inherited by an unrelated process: the daemon can
|
|
932
|
+
// never retire and a reopened PR is never rewatched. Accept the caller's reuse
|
|
933
|
+
// proof under the SAME re-derivation every other recovery path uses — the row's
|
|
934
|
+
// own watcher-written heartbeat marker.
|
|
935
|
+
if (!exactCompletedReplay && !await pidDeadOrProvenReused(
|
|
936
|
+
Number(watcherPid), prior.heartbeat_at, input, boundOpts,
|
|
937
|
+
)) return invalid('pid_live');
|
|
938
|
+
return invoke('watchOwnerCompleteTerminal', value, {
|
|
939
|
+
watcherPid: Number(watcherPid), terminalReceiptId,
|
|
940
|
+
expectedSnapshot: captured.snapshot,
|
|
941
|
+
}, boundOpts);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function abortStarting(ctx, input, opts = {}) {
|
|
945
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerAbortStarting']);
|
|
946
|
+
const capturedInput = captureMutationInput(input);
|
|
947
|
+
if (!capturedInput) return invalid();
|
|
948
|
+
input = capturedInput;
|
|
949
|
+
const identity = captureIdentity(ctx);
|
|
950
|
+
const value = mutationInput(identity, input, boundOpts, { now: false });
|
|
951
|
+
const controllerPid = input?.controllerPid;
|
|
952
|
+
if (!value || !positivePid(controllerPid)) return invalid('invalid_pid');
|
|
953
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
954
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
955
|
+
const prior = captured.snapshot;
|
|
956
|
+
if (!prior || prior.phase !== 'starting'
|
|
957
|
+
|| prior.generation !== value.generation
|
|
958
|
+
|| prior.controller_pid !== Number(controllerPid)) {
|
|
959
|
+
return invalid('stale_evidence');
|
|
960
|
+
}
|
|
961
|
+
if (await pidState(Number(controllerPid), boundOpts) !== true) return invalid('controller_dead');
|
|
962
|
+
return invoke('watchOwnerAbortStarting', value, {
|
|
963
|
+
controllerPid: Number(controllerPid), expectedSnapshot: captured.snapshot,
|
|
964
|
+
}, boundOpts);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function releaseNonterminal(ctx, input, opts = {}) {
|
|
968
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerReleaseNonterminal']);
|
|
969
|
+
const capturedInput = captureMutationInput(input);
|
|
970
|
+
if (!capturedInput) return invalid();
|
|
971
|
+
input = capturedInput;
|
|
972
|
+
const identity = captureIdentity(ctx);
|
|
973
|
+
const value = mutationInput(identity, input, boundOpts, { now: false });
|
|
974
|
+
if (!value || !positivePid(input?.pid)) return invalid('invalid_pid');
|
|
975
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
976
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
977
|
+
const prior = captured.snapshot;
|
|
978
|
+
if (!prior || prior.phase !== 'stop_requested') return invalid('phase_mismatch');
|
|
979
|
+
if (prior.generation !== value.generation) return invalid('generation_mismatch');
|
|
980
|
+
if (prior.watcher_pid !== Number(input.pid)) return invalid('pid_mismatch');
|
|
981
|
+
return invoke('watchOwnerReleaseNonterminal', value, {
|
|
982
|
+
watcherPid: Number(input.pid), expectedSnapshot: prior,
|
|
983
|
+
}, boundOpts);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function recoverDeadStarting(ctx, input, opts = {}) {
|
|
987
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerRecoverDeadStarting']);
|
|
988
|
+
const capturedInput = captureMutationInput(input);
|
|
989
|
+
if (!capturedInput) return invalid();
|
|
990
|
+
input = capturedInput;
|
|
991
|
+
const identity = captureIdentity(ctx);
|
|
992
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
993
|
+
const controllerPid = input?.controllerPid;
|
|
994
|
+
const recoveryControllerPid = input?.recoveryControllerPid;
|
|
995
|
+
if (!value || !positivePid(controllerPid) || !positivePid(recoveryControllerPid)) return invalid('invalid_pid');
|
|
996
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
997
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
998
|
+
if (!captured.snapshot || captured.snapshot.phase !== 'starting'
|
|
999
|
+
|| captured.snapshot.generation !== value.generation
|
|
1000
|
+
|| captured.snapshot.controller_pid !== Number(controllerPid)) {
|
|
1001
|
+
return invalid('generation_mismatch');
|
|
1002
|
+
}
|
|
1003
|
+
if (!await pidDeadOrProvenReused(
|
|
1004
|
+
Number(controllerPid), captured.snapshot.updated_at, input, boundOpts,
|
|
1005
|
+
)) return invalid('pid_live');
|
|
1006
|
+
return invoke('watchOwnerRecoverDeadStarting', value, {
|
|
1007
|
+
expectedControllerPid: Number(controllerPid), controllerPid: Number(recoveryControllerPid),
|
|
1008
|
+
expectedSnapshot: captured.snapshot,
|
|
1009
|
+
}, boundOpts);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function recoverDeadWatcher(ctx, input, opts = {}) {
|
|
1013
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerRecoverDeadWatcher']);
|
|
1014
|
+
const capturedInput = captureMutationInput(input);
|
|
1015
|
+
if (!capturedInput) return invalid();
|
|
1016
|
+
input = capturedInput;
|
|
1017
|
+
const identity = captureIdentity(ctx);
|
|
1018
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
1019
|
+
const watcherPid = input?.pid;
|
|
1020
|
+
const recoveryControllerPid = input?.recoveryControllerPid;
|
|
1021
|
+
const providerEvidence = input?.providerEvidence;
|
|
1022
|
+
if (!value || !positivePid(watcherPid) || !positivePid(recoveryControllerPid)) return invalid('invalid_pid');
|
|
1023
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
1024
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
1025
|
+
// A watcher that exited after `requestStop()` but before `releaseNonterminal()`
|
|
1026
|
+
// landed leaves a dead `stop_requested` row. That row is as recoverable as a dead
|
|
1027
|
+
// `running` one — same generation, same watcher PID, same postconditions — so
|
|
1028
|
+
// refusing it would strand the PR unwatched forever.
|
|
1029
|
+
if (!captured.snapshot || !['running', 'stop_requested'].includes(captured.snapshot.phase)
|
|
1030
|
+
|| captured.snapshot.generation !== value.generation
|
|
1031
|
+
|| captured.snapshot.watcher_pid !== Number(watcherPid)) {
|
|
1032
|
+
return invalid('generation_mismatch');
|
|
1033
|
+
}
|
|
1034
|
+
if (!await pidDeadOrProvenReused(
|
|
1035
|
+
Number(watcherPid), captured.snapshot.heartbeat_at, input, boundOpts,
|
|
1036
|
+
)) return invalid('pid_live');
|
|
1037
|
+
if (!await evidenceVerified(value, providerEvidence, ['open', 'terminal'], boundOpts)) return invalid('provider_evidence_invalid');
|
|
1038
|
+
return invoke('watchOwnerRecoverDeadWatcher', value, {
|
|
1039
|
+
watcherPid: Number(watcherPid), controllerPid: Number(recoveryControllerPid),
|
|
1040
|
+
expectedSnapshot: captured.snapshot,
|
|
1041
|
+
}, boundOpts);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function markLegacyBlocked(ctx, input, opts = {}) {
|
|
1045
|
+
const boundOpts = bindAuthorityOptions(opts, [
|
|
1046
|
+
'watchOwnerRead', 'watchGateRead', 'watchOwnerMarkLegacyBlocked',
|
|
1047
|
+
]);
|
|
1048
|
+
const capturedInput = captureMutationInput(input);
|
|
1049
|
+
if (!capturedInput) return invalid();
|
|
1050
|
+
input = capturedInput;
|
|
1051
|
+
const identity = captureIdentity(ctx);
|
|
1052
|
+
const value = mutationInput(identity, input, boundOpts, { generation: false });
|
|
1053
|
+
const blockReason = input?.blockReason;
|
|
1054
|
+
const legacyEvidenceHash = input?.legacyEvidenceHash;
|
|
1055
|
+
const snapshotHash = input?.snapshotHash;
|
|
1056
|
+
const pid = input?.pid;
|
|
1057
|
+
const terminalReceiptId = input?.terminalReceiptId;
|
|
1058
|
+
if (!value || !BLOCK_REASONS.has(blockReason) || !validHash(legacyEvidenceHash)
|
|
1059
|
+
|| !validHash(snapshotHash)) {
|
|
1060
|
+
return invalid('invalid_legacy_evidence');
|
|
1061
|
+
}
|
|
1062
|
+
if (pid != null && !positivePid(pid)) return invalid('invalid_legacy_evidence');
|
|
1063
|
+
const watcherPid = pid == null ? null : pid;
|
|
1064
|
+
if ((blockReason === 'legacy_live_pid') !== positivePid(watcherPid)) return invalid('invalid_legacy_evidence');
|
|
1065
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
1066
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
1067
|
+
const gate = captureQuarantinedGate(snapshotHash, boundOpts);
|
|
1068
|
+
if (!gate.ok) return invalid(gate.reason);
|
|
1069
|
+
if (terminalReceiptId != null && !boundedString(terminalReceiptId, MAX_RECEIPT_BYTES)) return invalid('invalid_legacy_evidence');
|
|
1070
|
+
const exactBlockedReplay = captured.snapshot?.phase === 'blocked'
|
|
1071
|
+
&& captured.snapshot.block_reason === blockReason
|
|
1072
|
+
&& captured.snapshot.watcher_pid === watcherPid
|
|
1073
|
+
&& captured.snapshot.terminal_receipt_id === (terminalReceiptId || null)
|
|
1074
|
+
&& captured.snapshot.legacy_evidence_hash === legacyEvidenceHash;
|
|
1075
|
+
if (!exactBlockedReplay && watcherPid != null && await pidState(watcherPid, boundOpts) !== true) return invalid('pid_dead');
|
|
1076
|
+
return invoke('watchOwnerMarkLegacyBlocked', value, {
|
|
1077
|
+
watcherPid, blockReason, terminalReceiptId: terminalReceiptId || null,
|
|
1078
|
+
legacyEvidenceHash, snapshotHash,
|
|
1079
|
+
expectedSnapshot: captured.snapshot, expectedGate: gate.snapshot,
|
|
1080
|
+
}, boundOpts);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
async function recheckLegacyBlocked(ctx, input, opts = {}) {
|
|
1084
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchOwnerRead', 'watchOwnerRecheckLegacyBlocked']);
|
|
1085
|
+
const capturedInput = captureMutationInput(input);
|
|
1086
|
+
if (!capturedInput) return invalid();
|
|
1087
|
+
input = capturedInput;
|
|
1088
|
+
const identity = captureIdentity(ctx);
|
|
1089
|
+
const value = mutationInput(identity, input, boundOpts);
|
|
1090
|
+
const action = input?.action;
|
|
1091
|
+
const legacyEvidenceHash = input?.legacyEvidenceHash;
|
|
1092
|
+
const pid = input?.pid;
|
|
1093
|
+
const terminalReceiptId = input?.terminalReceiptId;
|
|
1094
|
+
if (!value || !validHash(legacyEvidenceHash) || !['release', 'complete'].includes(action)) {
|
|
1095
|
+
return invalid('invalid_legacy_evidence');
|
|
1096
|
+
}
|
|
1097
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
1098
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
1099
|
+
const prior = captured.snapshot;
|
|
1100
|
+
if (!prior || prior.phase !== 'blocked'
|
|
1101
|
+
|| prior.generation !== value.generation
|
|
1102
|
+
|| prior.legacy_evidence_hash !== input.legacyEvidenceHash) {
|
|
1103
|
+
return invalid('stale_evidence');
|
|
1104
|
+
}
|
|
1105
|
+
if (action === 'release' && prior.block_reason !== 'legacy_live_pid') {
|
|
1106
|
+
return invalid('invalid_transition');
|
|
1107
|
+
}
|
|
1108
|
+
const watcherPid = pid == null ? null : pid;
|
|
1109
|
+
if (prior.watcher_pid !== watcherPid) return invalid('pid_mismatch');
|
|
1110
|
+
// The legacy watcher never wrote a heartbeat into this row, so its identity marker
|
|
1111
|
+
// is the retained legacy `started_at` the import stamped. Same re-derived proof,
|
|
1112
|
+
// same fail-closed default: no marker or no start-time probe keeps a live PID
|
|
1113
|
+
// authoritative.
|
|
1114
|
+
if (watcherPid != null && !await pidDeadOrProvenReused(
|
|
1115
|
+
watcherPid, prior.started_at, input, boundOpts,
|
|
1116
|
+
)) return invalid('pid_live');
|
|
1117
|
+
if (action === 'complete' && !await receiptVerified(value, terminalReceiptId, boundOpts)) return invalid('receipt_unverified');
|
|
1118
|
+
return invoke('watchOwnerRecheckLegacyBlocked', value, {
|
|
1119
|
+
action, legacyEvidenceHash,
|
|
1120
|
+
terminalReceiptId: action === 'complete' ? terminalReceiptId : null,
|
|
1121
|
+
watcherPid, expectedSnapshot: captured.snapshot,
|
|
1122
|
+
}, boundOpts);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function importLegacyComplete(ctx, input, opts = {}) {
|
|
1126
|
+
const boundOpts = bindAuthorityOptions(opts, [
|
|
1127
|
+
'watchOwnerRead', 'watchGateRead', 'watchOwnerImportLegacyComplete',
|
|
1128
|
+
]);
|
|
1129
|
+
const capturedInput = captureMutationInput(input);
|
|
1130
|
+
if (!capturedInput) return invalid();
|
|
1131
|
+
input = capturedInput;
|
|
1132
|
+
const identity = captureIdentity(ctx);
|
|
1133
|
+
const value = mutationInput(identity, input, boundOpts, { generation: false });
|
|
1134
|
+
const snapshotHash = input?.snapshotHash;
|
|
1135
|
+
const legacyEvidenceHash = input?.legacyEvidenceHash;
|
|
1136
|
+
const legacyPid = input?.legacyPid;
|
|
1137
|
+
const terminalReceiptId = input?.terminalReceiptId;
|
|
1138
|
+
if (!value || !validHash(snapshotHash) || !validHash(legacyEvidenceHash)) {
|
|
1139
|
+
return invalid('invalid_legacy_evidence');
|
|
1140
|
+
}
|
|
1141
|
+
if (legacyPid != null && !positivePid(legacyPid)) return invalid('invalid_legacy_evidence');
|
|
1142
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
1143
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
1144
|
+
const gate = captureQuarantinedGate(snapshotHash, boundOpts);
|
|
1145
|
+
if (!gate.ok) return invalid(gate.reason);
|
|
1146
|
+
const exactCompleteReplay = captured.snapshot?.phase === 'complete'
|
|
1147
|
+
&& captured.snapshot.legacy_evidence_hash === legacyEvidenceHash
|
|
1148
|
+
&& captured.snapshot.terminal_receipt_id === terminalReceiptId;
|
|
1149
|
+
if (!exactCompleteReplay) {
|
|
1150
|
+
if (legacyPid != null && await pidState(Number(legacyPid), boundOpts) !== false) return invalid('pid_live');
|
|
1151
|
+
if (!await receiptVerified(value, terminalReceiptId, boundOpts)) return invalid('receipt_unverified');
|
|
1152
|
+
}
|
|
1153
|
+
return invoke('watchOwnerImportLegacyComplete', value, {
|
|
1154
|
+
snapshotHash, legacyEvidenceHash,
|
|
1155
|
+
terminalReceiptId, expectedSnapshot: captured.snapshot, expectedGate: gate.snapshot,
|
|
1156
|
+
}, boundOpts);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function importLegacyStarting(ctx, input, opts = {}) {
|
|
1160
|
+
const boundOpts = bindAuthorityOptions(opts, [
|
|
1161
|
+
'watchOwnerRead', 'watchGateRead', 'watchOwnerImportLegacyStarting',
|
|
1162
|
+
]);
|
|
1163
|
+
const capturedInput = captureMutationInput(input);
|
|
1164
|
+
if (!capturedInput) return invalid();
|
|
1165
|
+
input = capturedInput;
|
|
1166
|
+
const identity = captureIdentity(ctx);
|
|
1167
|
+
const value = mutationInput(identity, input, boundOpts, { generation: false });
|
|
1168
|
+
const snapshotHash = input?.snapshotHash;
|
|
1169
|
+
const legacyEvidenceHash = input?.legacyEvidenceHash;
|
|
1170
|
+
const legacyPid = input?.legacyPid;
|
|
1171
|
+
const controllerPid = input?.controllerPid;
|
|
1172
|
+
const providerEvidence = input?.providerEvidence;
|
|
1173
|
+
if (!value || !validHash(snapshotHash) || !validHash(legacyEvidenceHash)
|
|
1174
|
+
|| !positivePid(legacyPid) || !positivePid(controllerPid)) {
|
|
1175
|
+
return invalid('invalid_legacy_evidence');
|
|
1176
|
+
}
|
|
1177
|
+
const captured = captureAuthoritySnapshot(identity, boundOpts);
|
|
1178
|
+
if (!captured.ok) return invalid(captured.reason);
|
|
1179
|
+
const gate = captureQuarantinedGate(snapshotHash, boundOpts);
|
|
1180
|
+
if (!gate.ok) return invalid(gate.reason);
|
|
1181
|
+
const exactStartingReplay = captured.snapshot?.phase === 'starting'
|
|
1182
|
+
&& captured.snapshot.controller_pid === controllerPid
|
|
1183
|
+
&& captured.snapshot.legacy_evidence_hash === legacyEvidenceHash;
|
|
1184
|
+
if (!exactStartingReplay) {
|
|
1185
|
+
if (await pidState(legacyPid, boundOpts) !== false) return invalid('pid_live');
|
|
1186
|
+
if (!await evidenceVerified(value, providerEvidence, ['open'], boundOpts)) return invalid('provider_evidence_invalid');
|
|
1187
|
+
}
|
|
1188
|
+
return invoke('watchOwnerImportLegacyStarting', value, {
|
|
1189
|
+
snapshotHash, legacyEvidenceHash,
|
|
1190
|
+
controllerPid, expectedSnapshot: captured.snapshot, expectedGate: gate.snapshot,
|
|
1191
|
+
}, boundOpts);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function gateInput(input, opts) {
|
|
1195
|
+
const captured = input?.[CAPTURED_INPUT];
|
|
1196
|
+
const hasUpdatedAt = captured ? captured.hasUpdatedAt
|
|
1197
|
+
: input != null && Object.prototype.hasOwnProperty.call(input, 'updatedAt');
|
|
1198
|
+
const now = hasUpdatedAt
|
|
1199
|
+
? (canonicalTimestamp(input.updatedAt) ? input.updatedAt : null)
|
|
1200
|
+
: nowIso(opts);
|
|
1201
|
+
return now ? { now } : null;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Exact success envelopes the builtin gate driver can produce per method;
|
|
1205
|
+
// anything else is a mutating or malformed adapter response.
|
|
1206
|
+
const GATE_ENVELOPE_CONTRACTS = Object.freeze({
|
|
1207
|
+
watchGateRead: Object.freeze([Object.freeze({ changed: false, reason: 'read' })]),
|
|
1208
|
+
watchGatePublishQuarantine: Object.freeze([
|
|
1209
|
+
Object.freeze({ changed: true, reason: 'quarantined' }),
|
|
1210
|
+
Object.freeze({ changed: false, reason: 'idempotent' }),
|
|
1211
|
+
]),
|
|
1212
|
+
watchGateBindSnapshot: Object.freeze([
|
|
1213
|
+
Object.freeze({ changed: true, reason: 'bound' }),
|
|
1214
|
+
Object.freeze({ changed: false, reason: 'idempotent' }),
|
|
1215
|
+
]),
|
|
1216
|
+
watchGatePublishConflict: Object.freeze([
|
|
1217
|
+
Object.freeze({ changed: true, reason: 'conflict' }),
|
|
1218
|
+
Object.freeze({ changed: false, reason: 'idempotent' }),
|
|
1219
|
+
]),
|
|
1220
|
+
watchGateRetryConflict: Object.freeze([
|
|
1221
|
+
Object.freeze({ changed: true, reason: 'retry_bound' }),
|
|
1222
|
+
]),
|
|
1223
|
+
watchGateCompleteMigration: Object.freeze([
|
|
1224
|
+
Object.freeze({ changed: true, reason: 'complete' }),
|
|
1225
|
+
Object.freeze({ changed: false, reason: 'idempotent' }),
|
|
1226
|
+
]),
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
function invokeGate(method, input, opts) {
|
|
1230
|
+
const operation = opts?.authorityMethods?.[method];
|
|
1231
|
+
if (typeof operation !== 'function') return { ok: false, changed: false, reason: 'authority_unavailable', gate: null };
|
|
1232
|
+
if (input.expectedGate && Date.parse(input.now) < Date.parse(input.expectedGate.updated_at)) {
|
|
1233
|
+
return invalidGate('stale_evidence');
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
const expectedGate = input.expectedGate == null
|
|
1237
|
+
? input.expectedGate
|
|
1238
|
+
: Object.freeze({ ...input.expectedGate });
|
|
1239
|
+
const submitted = Object.freeze({ ...input, expectedGate });
|
|
1240
|
+
const result = operation({ ...submitted }, opts.databaseConfig || {});
|
|
1241
|
+
if (result && typeof result.then === 'function') {
|
|
1242
|
+
Promise.resolve(result).then(() => {}, () => {});
|
|
1243
|
+
return invalidGate('invalid_operation');
|
|
1244
|
+
}
|
|
1245
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return invalidGate('corrupt');
|
|
1246
|
+
// Snapshot once: accessor-backed results must not flip between
|
|
1247
|
+
// validation and the returned envelope.
|
|
1248
|
+
const snap = {
|
|
1249
|
+
ok: result.ok === true,
|
|
1250
|
+
changed: result.changed === true,
|
|
1251
|
+
reason: typeof result.reason === 'string' ? result.reason : '',
|
|
1252
|
+
gate: result.gate,
|
|
1253
|
+
};
|
|
1254
|
+
if (!snap.ok && snap.changed) return invalidGate('corrupt');
|
|
1255
|
+
if (snap.ok) {
|
|
1256
|
+
const contract = GATE_ENVELOPE_CONTRACTS[method] || [];
|
|
1257
|
+
if (!contract.some(entry => entry.changed === snap.changed && entry.reason === snap.reason)) {
|
|
1258
|
+
return invalidGate('corrupt');
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (method === 'watchGateRead' && snap.ok
|
|
1262
|
+
&& (snap.changed !== false || snap.reason !== 'read')) {
|
|
1263
|
+
return invalidGate('corrupt');
|
|
1264
|
+
}
|
|
1265
|
+
if (method === 'watchGateRead' && !snap.ok
|
|
1266
|
+
&& snap.reason === 'absent' && snap.gate != null) return invalidGate('corrupt');
|
|
1267
|
+
if (snap.ok && snap.gate == null) return invalidGate('corrupt');
|
|
1268
|
+
if (snap.gate == null) {
|
|
1269
|
+
return { ok: snap.ok, changed: snap.changed, reason: snap.reason, gate: null };
|
|
1270
|
+
}
|
|
1271
|
+
const gate = copyMigrationGate(snap.gate);
|
|
1272
|
+
if (!gate || (snap.ok && !gateMatchesMutation(method, submitted, gate, snap))) return invalidGate('corrupt');
|
|
1273
|
+
return { ok: snap.ok, changed: snap.changed, reason: snap.reason, gate };
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
return { ok: false, changed: false,
|
|
1276
|
+
reason: error?.code === 'AUTHORITY_UNAVAILABLE' ? 'authority_unavailable' : 'store_error', gate: null };
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function captureMigrationGate(opts) {
|
|
1281
|
+
const captured = invokeGate('watchGateRead', {}, opts);
|
|
1282
|
+
if (captured.ok) return { ok: true, snapshot: Object.freeze({ ...captured.gate }) };
|
|
1283
|
+
return captured.reason === 'absent'
|
|
1284
|
+
? { ok: true, snapshot: null }
|
|
1285
|
+
: { ok: false, reason: captured.reason };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function captureQuarantinedGate(snapshotHash, opts) {
|
|
1289
|
+
const captured = captureMigrationGate(opts);
|
|
1290
|
+
if (!captured.ok) {
|
|
1291
|
+
return { ok: false, reason: ['corrupt', 'authority_unavailable'].includes(captured.reason)
|
|
1292
|
+
? captured.reason : 'gate_mismatch' };
|
|
1293
|
+
}
|
|
1294
|
+
return captured.snapshot?.state === 'quarantined'
|
|
1295
|
+
&& captured.snapshot.snapshot_hash === snapshotHash
|
|
1296
|
+
&& captured.snapshot.conflict_code == null
|
|
1297
|
+
? captured
|
|
1298
|
+
: { ok: false, reason: 'gate_mismatch' };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
async function readMigrationGate(input = {}, opts = {}) {
|
|
1302
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchGateRead']);
|
|
1303
|
+
const capturedInput = captureMutationInput(input);
|
|
1304
|
+
return capturedInput ? invokeGate('watchGateRead', capturedInput, boundOpts) : invalidGate('invalid_input');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function publishMigrationQuarantine(input = {}, opts = {}) {
|
|
1308
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchGateRead', 'watchGatePublishQuarantine']);
|
|
1309
|
+
const capturedInput = captureMutationInput(input);
|
|
1310
|
+
if (!capturedInput) return invalidGate('invalid_input');
|
|
1311
|
+
const value = gateInput(capturedInput, boundOpts);
|
|
1312
|
+
if (!value) return invalidGate();
|
|
1313
|
+
const captured = captureMigrationGate(boundOpts);
|
|
1314
|
+
return captured.ok
|
|
1315
|
+
? invokeGate('watchGatePublishQuarantine', { ...value, expectedGate: captured.snapshot }, boundOpts)
|
|
1316
|
+
: invalidGate(captured.reason);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
async function bindMigrationSnapshot(input = {}, opts = {}) {
|
|
1320
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchGateRead', 'watchGateBindSnapshot']);
|
|
1321
|
+
const capturedInput = captureMutationInput(input);
|
|
1322
|
+
if (!capturedInput) return invalidGate('invalid_input');
|
|
1323
|
+
input = capturedInput;
|
|
1324
|
+
const value = gateInput(input, boundOpts);
|
|
1325
|
+
if (!value || !validHash(input.snapshotHash)) return invalidGate('invalid_snapshot');
|
|
1326
|
+
const captured = captureMigrationGate(boundOpts);
|
|
1327
|
+
return captured.ok
|
|
1328
|
+
? invokeGate('watchGateBindSnapshot', {
|
|
1329
|
+
...value, snapshotHash: input.snapshotHash, expectedGate: captured.snapshot,
|
|
1330
|
+
}, boundOpts)
|
|
1331
|
+
: invalidGate(captured.reason);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
async function publishMigrationConflict(input = {}, opts = {}) {
|
|
1335
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchGateRead', 'watchGatePublishConflict']);
|
|
1336
|
+
const capturedInput = captureMutationInput(input);
|
|
1337
|
+
if (!capturedInput) return invalidGate('invalid_input');
|
|
1338
|
+
input = capturedInput;
|
|
1339
|
+
const value = gateInput(input, boundOpts);
|
|
1340
|
+
if (!value || !validHash(input.snapshotHash) || !CONFLICT_CODES.has(input.conflictCode)) return invalidGate('invalid_conflict');
|
|
1341
|
+
const captured = captureMigrationGate(boundOpts);
|
|
1342
|
+
return captured.ok
|
|
1343
|
+
? invokeGate('watchGatePublishConflict', {
|
|
1344
|
+
...value, snapshotHash: input.snapshotHash, conflictCode: input.conflictCode,
|
|
1345
|
+
expectedGate: captured.snapshot,
|
|
1346
|
+
}, boundOpts)
|
|
1347
|
+
: invalidGate(captured.reason);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
async function retryMigrationConflict(input = {}, opts = {}) {
|
|
1351
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchGateRead', 'watchGateRetryConflict']);
|
|
1352
|
+
const capturedInput = captureMutationInput(input);
|
|
1353
|
+
if (!capturedInput) return invalidGate('invalid_input');
|
|
1354
|
+
input = capturedInput;
|
|
1355
|
+
const value = gateInput(input, boundOpts);
|
|
1356
|
+
if (!value || !validHash(input.expectedSnapshotHash)
|
|
1357
|
+
|| !CONFLICT_CODES.has(input.expectedConflictCode)
|
|
1358
|
+
|| !validHash(input.replacementSnapshotHash)
|
|
1359
|
+
|| input.replacementSnapshotHash === input.expectedSnapshotHash) return invalidGate('invalid_retry');
|
|
1360
|
+
const captured = captureMigrationGate(boundOpts);
|
|
1361
|
+
return captured.ok ? invokeGate('watchGateRetryConflict', {
|
|
1362
|
+
...value,
|
|
1363
|
+
expectedSnapshotHash: input.expectedSnapshotHash,
|
|
1364
|
+
expectedConflictCode: input.expectedConflictCode,
|
|
1365
|
+
replacementSnapshotHash: input.replacementSnapshotHash,
|
|
1366
|
+
expectedGate: captured.snapshot,
|
|
1367
|
+
}, boundOpts) : invalidGate(captured.reason);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async function completeMigrationGate(input = {}, opts = {}) {
|
|
1371
|
+
const boundOpts = bindAuthorityOptions(opts, ['watchGateRead', 'watchGateCompleteMigration']);
|
|
1372
|
+
const capturedInput = captureMutationInput(input);
|
|
1373
|
+
if (!capturedInput) return invalidGate('invalid_input');
|
|
1374
|
+
input = capturedInput;
|
|
1375
|
+
const value = gateInput(input, boundOpts);
|
|
1376
|
+
if (!value || !validHash(input.snapshotHash)) return invalidGate('invalid_snapshot');
|
|
1377
|
+
const captured = captureMigrationGate(boundOpts);
|
|
1378
|
+
return captured.ok
|
|
1379
|
+
? invokeGate('watchGateCompleteMigration', {
|
|
1380
|
+
...value, snapshotHash: input.snapshotHash, expectedGate: captured.snapshot,
|
|
1381
|
+
}, boundOpts)
|
|
1382
|
+
: invalidGate(captured.reason);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
module.exports = {
|
|
1386
|
+
VERSION,
|
|
1387
|
+
PHASES,
|
|
1388
|
+
BLOCK_REASONS,
|
|
1389
|
+
CONFLICT_CODES,
|
|
1390
|
+
validateRecord,
|
|
1391
|
+
readOwner,
|
|
1392
|
+
enumerateOwners,
|
|
1393
|
+
reserveStarting,
|
|
1394
|
+
reserveReopened,
|
|
1395
|
+
bindRunning,
|
|
1396
|
+
heartbeat,
|
|
1397
|
+
requestStop,
|
|
1398
|
+
recordTerminal,
|
|
1399
|
+
completeTerminal,
|
|
1400
|
+
abortStarting,
|
|
1401
|
+
releaseNonterminal,
|
|
1402
|
+
recoverDeadStarting,
|
|
1403
|
+
recoverDeadWatcher,
|
|
1404
|
+
markLegacyBlocked,
|
|
1405
|
+
recheckLegacyBlocked,
|
|
1406
|
+
importLegacyStarting,
|
|
1407
|
+
importLegacyComplete,
|
|
1408
|
+
readMigrationGate,
|
|
1409
|
+
publishMigrationQuarantine,
|
|
1410
|
+
bindMigrationSnapshot,
|
|
1411
|
+
publishMigrationConflict,
|
|
1412
|
+
retryMigrationConflict,
|
|
1413
|
+
completeMigrationGate,
|
|
1414
|
+
};
|