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
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
-
const {
|
|
6
|
+
const { spawn: nodeSpawn } = require('node:child_process');
|
|
7
|
+
const { pathToFileURL } = require('node:url');
|
|
8
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
9
|
+
const { types: { isProxy } } = require('node:util');
|
|
7
10
|
|
|
8
11
|
const {
|
|
9
12
|
ISSUE_COMMAND_SCHEMA_VERSION,
|
|
@@ -13,11 +16,21 @@ const {
|
|
|
13
16
|
resolveNextCommands,
|
|
14
17
|
} = require('./issue-command-contract');
|
|
15
18
|
const { buildReadinessIndex } = require('./readiness-model');
|
|
16
|
-
const { buildMemoryProjectionMigration, memoryFtsDdl } = require('./migrations');
|
|
17
|
-
const {
|
|
19
|
+
const { buildMemoryProjectionMigration, buildUsageEvidenceMigration, memoryFtsDdl } = require('./migrations');
|
|
20
|
+
const { assertFilesystemSafeForKernel } = require('./fs-class');
|
|
21
|
+
const { isTerminalStatus, rankForPriorityLabel } = require('./taxonomy-validator');
|
|
18
22
|
const { isLeaseExpired } = require('./lease-enforcer');
|
|
23
|
+
const { isLiveClaim, projectLiveClaims } = require('./live-claim-projection');
|
|
24
|
+
const {
|
|
25
|
+
ClaimRepairError,
|
|
26
|
+
buildClaimRepairPlan,
|
|
27
|
+
publicClaimRepairPreflight,
|
|
28
|
+
verifyClaimRepairBackup,
|
|
29
|
+
} = require('./legacy-claim-repair');
|
|
19
30
|
const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
|
|
20
31
|
const { normalizeRecallHit } = require('../memory-recall');
|
|
32
|
+
const { getPackageRoot } = require('../package-root');
|
|
33
|
+
const { appendUsageEvidence, rebuildUsageProjection } = require('@forge/memory');
|
|
21
34
|
|
|
22
35
|
const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
|
|
23
36
|
let probeCounter = 0;
|
|
@@ -133,6 +146,18 @@ function createDatabase(runtime, databasePath) {
|
|
|
133
146
|
throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
|
|
134
147
|
}
|
|
135
148
|
|
|
149
|
+
function createExistingWatchOwnerDatabase(runtime, databasePath) {
|
|
150
|
+
if (runtime.id === 'bun:sqlite') {
|
|
151
|
+
return new runtime.module.Database(databasePath, { readwrite: true });
|
|
152
|
+
}
|
|
153
|
+
if (runtime.id === 'node:sqlite') {
|
|
154
|
+
const databaseUrl = pathToFileURL(databasePath);
|
|
155
|
+
databaseUrl.searchParams.set('mode', 'rw');
|
|
156
|
+
return new runtime.module.DatabaseSync(databaseUrl);
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
function execSql(_runtime, db, sql) {
|
|
137
162
|
db.exec(sql);
|
|
138
163
|
}
|
|
@@ -171,6 +196,35 @@ function runParams(runtime, db, sql, params = []) {
|
|
|
171
196
|
return db.prepare(sql).run(...params);
|
|
172
197
|
}
|
|
173
198
|
|
|
199
|
+
function createUsageEvidenceAdapter(runtime, db) {
|
|
200
|
+
return {
|
|
201
|
+
exec(sql) { execSql(runtime, db, sql); },
|
|
202
|
+
run(sql, params = []) { return runParams(runtime, db, sql, params); },
|
|
203
|
+
one(sql, params = []) { return allParams(runtime, db, sql, params)[0] || null; },
|
|
204
|
+
transaction(callback) {
|
|
205
|
+
let active = false;
|
|
206
|
+
try {
|
|
207
|
+
execSql(runtime, db, 'BEGIN IMMEDIATE;');
|
|
208
|
+
active = true;
|
|
209
|
+
const result = callback();
|
|
210
|
+
execSql(runtime, db, 'COMMIT;');
|
|
211
|
+
active = false;
|
|
212
|
+
return result;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (active) {
|
|
215
|
+
try { execSql(runtime, db, 'ROLLBACK;'); } catch { /* preserve the write failure */ }
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
assertUsageWriterEnabled() {
|
|
221
|
+
const row = allParams(runtime, db,
|
|
222
|
+
'SELECT enabled FROM memory_usage_writer_state WHERE singleton = 1')[0];
|
|
223
|
+
if (!row || Number(row.enabled) !== 1) throw new Error('memory usage evidence writer is disabled');
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
174
228
|
// A table may not exist on a partially-migrated DB; readiness inputs degrade to empty.
|
|
175
229
|
// ONLY a missing-table error is tolerated — a locked/corrupt DB or a real SQL
|
|
176
230
|
// regression must surface, not silently produce wrong readiness/stats/projection.
|
|
@@ -206,7 +260,7 @@ function parseLabels(raw) {
|
|
|
206
260
|
}
|
|
207
261
|
|
|
208
262
|
function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds = [], dependentIds = []) {
|
|
209
|
-
|
|
263
|
+
const summary = {
|
|
210
264
|
id: row.id,
|
|
211
265
|
title: row.title,
|
|
212
266
|
body: row.body ?? null,
|
|
@@ -250,6 +304,11 @@ function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds
|
|
|
250
304
|
close_reason: row.close_reason ?? null,
|
|
251
305
|
metadata: row.metadata ?? null,
|
|
252
306
|
};
|
|
307
|
+
if (readinessEntry?.contract_applicable) {
|
|
308
|
+
summary.readiness_state = readinessEntry.state;
|
|
309
|
+
summary.readiness_reasons = readinessEntry.reasons;
|
|
310
|
+
}
|
|
311
|
+
return summary;
|
|
253
312
|
}
|
|
254
313
|
|
|
255
314
|
function okIssueResponse(command, data, nextCommands) {
|
|
@@ -265,30 +324,48 @@ function okIssueResponse(command, data, nextCommands) {
|
|
|
265
324
|
};
|
|
266
325
|
}
|
|
267
326
|
|
|
327
|
+
function loadLiveKernelClaimRows(runtime, db, context = {}) {
|
|
328
|
+
const rows = safeAll(
|
|
329
|
+
runtime,
|
|
330
|
+
db,
|
|
331
|
+
`SELECT claim.*, issue.status AS issue_status
|
|
332
|
+
FROM kernel_claims AS claim
|
|
333
|
+
JOIN kernel_issues AS issue ON issue.id = claim.issue_id
|
|
334
|
+
WHERE claim.state = 'active'`,
|
|
335
|
+
);
|
|
336
|
+
const issues = rows.map(row => ({ id: row.issue_id, status: row.issue_status }));
|
|
337
|
+
return projectLiveClaims(rows, issues, context.now || new Date().toISOString())
|
|
338
|
+
.map(row => {
|
|
339
|
+
const claim = { ...row };
|
|
340
|
+
delete claim.issue_status;
|
|
341
|
+
return claim;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
268
345
|
// Derive the whole-board readiness read model (D18) from the authority tables.
|
|
269
346
|
function loadBoardReadiness(runtime, db, context = {}) {
|
|
270
347
|
const issues = allParams(runtime, db, 'SELECT * FROM kernel_issues');
|
|
271
348
|
const dependencies = safeAll(runtime, db, 'SELECT * FROM kernel_dependencies');
|
|
272
349
|
const conflicts = safeAll(runtime, db, 'SELECT * FROM kernel_conflicts');
|
|
273
|
-
const
|
|
350
|
+
const now = context.now || new Date().toISOString();
|
|
351
|
+
const liveClaims = loadLiveKernelClaimRows(runtime, db, { ...context, now });
|
|
274
352
|
const index = buildReadinessIndex({
|
|
275
353
|
issues,
|
|
276
354
|
dependencies,
|
|
277
355
|
conflicts,
|
|
278
|
-
claims,
|
|
279
|
-
now
|
|
356
|
+
claims: liveClaims,
|
|
357
|
+
now,
|
|
280
358
|
actor: context.actor,
|
|
359
|
+
contractPolicy: context.contractPolicy,
|
|
360
|
+
isTrustedAdoption: context.isTrustedAdoption,
|
|
281
361
|
});
|
|
282
|
-
// Surface the
|
|
283
|
-
//
|
|
284
|
-
// guarantees at most one active row per issue, so the map is unambiguous.
|
|
362
|
+
// Surface only the shared live-authority projection as claimed_by. The partial
|
|
363
|
+
// UNIQUE active-lease index guarantees at most one live row per issue.
|
|
285
364
|
// Null-prototype map: issue ids are unconstrained external strings, so a literal `{}`
|
|
286
365
|
// keyed by them would be a prototype-pollution vector (matches buildReadinessIndex).
|
|
287
366
|
const claimedById = Object.create(null);
|
|
288
|
-
for (const claim of
|
|
289
|
-
if (
|
|
290
|
-
claimedById[claim.issue_id] = claim.actor ?? null;
|
|
291
|
-
}
|
|
367
|
+
for (const claim of liveClaims) {
|
|
368
|
+
if (claim.issue_id) claimedById[claim.issue_id] = claim.actor ?? null;
|
|
292
369
|
}
|
|
293
370
|
// Per-issue declared dependency edges (the ids each issue depends on, i.e.
|
|
294
371
|
// blocks_issue_id where issue_id === the dependent). Distinct from readiness'
|
|
@@ -318,7 +395,7 @@ function loadBoardReadiness(runtime, db, context = {}) {
|
|
|
318
395
|
dependentsById[issueId] = [...new Set(dependentsById[issueId])]
|
|
319
396
|
.sort((a, b) => String(a).localeCompare(String(b)));
|
|
320
397
|
}
|
|
321
|
-
return { issues, index, claimedById, dependenciesById, dependentsById };
|
|
398
|
+
return { issues, index, liveClaims, claimedById, dependenciesById, dependentsById };
|
|
322
399
|
}
|
|
323
400
|
|
|
324
401
|
function firstPositional(args = []) {
|
|
@@ -435,6 +512,57 @@ function buildRollup(children) {
|
|
|
435
512
|
};
|
|
436
513
|
}
|
|
437
514
|
|
|
515
|
+
function runIssueOwnsRead(runtime, db, args, context) {
|
|
516
|
+
// Lease-ownership verification (kernel d71a824b). A claim returning ok:true does
|
|
517
|
+
// not prove the caller won the lease: a duplicate replay also returns ok:true, so
|
|
518
|
+
// a worker must CONFIRM it holds the live lease before mutating a claimed issue.
|
|
519
|
+
// `owned` is true iff the resolving actor holds the shared live-authority claim.
|
|
520
|
+
// loadActiveKernelClaimRow remains state-only for acquisition/reclaim planning;
|
|
521
|
+
// isLiveClaim applies expiry and issue-terminal fencing here. Actor resolution mirrors the mutation
|
|
522
|
+
// path's `context.actor || 'forge'` default so a bare CLI invocation matches its
|
|
523
|
+
// own claims; `now` falls back to the wall clock like the mutation route.
|
|
524
|
+
const id = firstPositional(args);
|
|
525
|
+
const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
|
|
526
|
+
if (!rows[0]) {
|
|
527
|
+
return formatIssueCommandError({
|
|
528
|
+
command: 'issue.owns',
|
|
529
|
+
code: 'FORGE_ISSUE_NOT_FOUND',
|
|
530
|
+
message: `Issue ${id ?? '<missing id>'} not found`,
|
|
531
|
+
exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const now = context.now || new Date().toISOString();
|
|
535
|
+
const actor = context.actor || 'forge';
|
|
536
|
+
const claim = loadActiveKernelClaimRow(runtime, db, id);
|
|
537
|
+
const liveClaim = isLiveClaim(claim, rows[0], now) ? claim : null;
|
|
538
|
+
const claimedBy = liveClaim ? (liveClaim.actor ?? null) : null;
|
|
539
|
+
const expired = claim ? isLeaseExpired(claim, now) : false;
|
|
540
|
+
// Ownership is per-SESSION, not just per-actor (kernel d71a824b): two agents
|
|
541
|
+
// sharing one human actor but running as DIFFERENT sessions must not both read
|
|
542
|
+
// OWNED for a lease only one of them holds. When BOTH the caller and the live
|
|
543
|
+
// lease carry a session-id they must match; if either side is session-less (a
|
|
544
|
+
// no-env caller, or a pre-session claim) we fall back to actor-only ownership so
|
|
545
|
+
// historical behavior is preserved byte-for-byte. An empty/whitespace-only
|
|
546
|
+
// session-id counts as session-LESS on BOTH sides — the SAME trim-truthy test the
|
|
547
|
+
// claim-key write uses (buildClaimMutationEvent in lib/kernel/broker.js) — so ''
|
|
548
|
+
// can never count as "present" here and "absent" there.
|
|
549
|
+
const normalizeSession = value => (typeof value === 'string' && value.trim() !== '' ? value : null);
|
|
550
|
+
const contextSession = normalizeSession(context.sessionId);
|
|
551
|
+
const claimSession = claim ? normalizeSession(claim.session_id) : null;
|
|
552
|
+
const sessionMismatch = contextSession !== null
|
|
553
|
+
&& claimSession !== null
|
|
554
|
+
&& contextSession !== claimSession;
|
|
555
|
+
const owned = Boolean(liveClaim) && claimedBy === actor && !sessionMismatch;
|
|
556
|
+
return okIssueResponse('issue.owns', {
|
|
557
|
+
id,
|
|
558
|
+
actor,
|
|
559
|
+
claimed_by: claimedBy,
|
|
560
|
+
owned,
|
|
561
|
+
expired,
|
|
562
|
+
expires_at: claim ? (claim.expires_at ?? null) : null,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
438
566
|
// Read-side of driver.issueOperation: ready/list/show/search/stats as parameterized
|
|
439
567
|
// SELECTs returning issue-command-contract shapes. Mutations are handled separately
|
|
440
568
|
// through the broker's guarded-event path (later wave).
|
|
@@ -506,55 +634,7 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
|
|
|
506
634
|
});
|
|
507
635
|
}
|
|
508
636
|
if (operation === 'owns') {
|
|
509
|
-
|
|
510
|
-
// not prove the caller won the lease: a duplicate replay also returns ok:true, so
|
|
511
|
-
// a worker must CONFIRM it holds the live lease before mutating a claimed issue.
|
|
512
|
-
// `owned` is true iff the resolving actor holds the SINGLE active claim AND that
|
|
513
|
-
// lease has not expired. read summaries derive `claimed_by` from state='active'
|
|
514
|
-
// only (never expiry — see loadActiveKernelClaimRow), so owns re-applies the
|
|
515
|
-
// expiry check here: an expired-but-not-yet-reclaimed lease is NOT ownership
|
|
516
|
-
// (planClaimAcquisition would supersede it). Actor resolution mirrors the mutation
|
|
517
|
-
// path's `context.actor || 'forge'` default so a bare CLI invocation matches its
|
|
518
|
-
// own claims; `now` falls back to the wall clock like the mutation route.
|
|
519
|
-
const id = firstPositional(args);
|
|
520
|
-
const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
|
|
521
|
-
if (!rows[0]) {
|
|
522
|
-
return formatIssueCommandError({
|
|
523
|
-
command: 'issue.owns',
|
|
524
|
-
code: 'FORGE_ISSUE_NOT_FOUND',
|
|
525
|
-
message: `Issue ${id ?? '<missing id>'} not found`,
|
|
526
|
-
exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
const now = context.now || new Date().toISOString();
|
|
530
|
-
const actor = context.actor || 'forge';
|
|
531
|
-
const claim = loadActiveKernelClaimRow(runtime, db, id);
|
|
532
|
-
const claimedBy = claim ? (claim.actor ?? null) : null;
|
|
533
|
-
const expired = claim ? isLeaseExpired(claim, now) : false;
|
|
534
|
-
// Ownership is per-SESSION, not just per-actor (kernel d71a824b): two agents
|
|
535
|
-
// sharing one human actor but running as DIFFERENT sessions must not both read
|
|
536
|
-
// OWNED for a lease only one of them holds. When BOTH the caller and the live
|
|
537
|
-
// lease carry a session-id they must match; if either side is session-less (a
|
|
538
|
-
// no-env caller, or a pre-session claim) we fall back to actor-only ownership so
|
|
539
|
-
// historical behavior is preserved byte-for-byte. An empty/whitespace-only
|
|
540
|
-
// session-id counts as session-LESS on BOTH sides — the SAME trim-truthy test the
|
|
541
|
-
// claim-key write uses (buildClaimMutationEvent in lib/kernel/broker.js) — so ''
|
|
542
|
-
// can never count as "present" here and "absent" there.
|
|
543
|
-
const normalizeSession = value => (typeof value === 'string' && value.trim() !== '' ? value : null);
|
|
544
|
-
const contextSession = normalizeSession(context.sessionId);
|
|
545
|
-
const claimSession = claim ? normalizeSession(claim.session_id) : null;
|
|
546
|
-
const sessionMismatch = contextSession !== null
|
|
547
|
-
&& claimSession !== null
|
|
548
|
-
&& contextSession !== claimSession;
|
|
549
|
-
const owned = Boolean(claim) && !expired && claimedBy === actor && !sessionMismatch;
|
|
550
|
-
return okIssueResponse('issue.owns', {
|
|
551
|
-
id,
|
|
552
|
-
actor,
|
|
553
|
-
claimed_by: claimedBy,
|
|
554
|
-
owned,
|
|
555
|
-
expired,
|
|
556
|
-
expires_at: claim ? (claim.expires_at ?? null) : null,
|
|
557
|
-
});
|
|
637
|
+
return runIssueOwnsRead(runtime, db, args, context);
|
|
558
638
|
}
|
|
559
639
|
if (operation === 'search') {
|
|
560
640
|
const term = `%${firstPositional(args) || ''}%`;
|
|
@@ -568,20 +648,17 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
|
|
|
568
648
|
return okIssueResponse('issue.search', { issues: summaries, count: summaries.length });
|
|
569
649
|
}
|
|
570
650
|
if (operation === 'stats') {
|
|
571
|
-
const { index } = loadBoardReadiness(runtime, db, context);
|
|
651
|
+
const { index, liveClaims } = loadBoardReadiness(runtime, db, context);
|
|
572
652
|
const statusRows = allParams(runtime, db, 'SELECT status, COUNT(*) AS n FROM kernel_issues GROUP BY status');
|
|
573
653
|
const counts = {};
|
|
574
654
|
for (const row of statusRows) {
|
|
575
655
|
counts[row.status] = Number(row.n);
|
|
576
656
|
}
|
|
577
|
-
const activeClaims = Number(
|
|
578
|
-
safeAll(runtime, db, "SELECT COUNT(*) AS n FROM kernel_claims WHERE state = 'active'")[0]?.n || 0,
|
|
579
|
-
);
|
|
580
657
|
return okIssueResponse('issue.stats', {
|
|
581
658
|
counts,
|
|
582
659
|
ready_count: index.readyQueue.length,
|
|
583
660
|
blocked_count: index.blocked.length,
|
|
584
|
-
active_claims:
|
|
661
|
+
active_claims: liveClaims.length,
|
|
585
662
|
});
|
|
586
663
|
}
|
|
587
664
|
// KAP-7: derived read query — every issue whose readiness is blocked
|
|
@@ -696,24 +773,11 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
|
|
|
696
773
|
count: children.length,
|
|
697
774
|
});
|
|
698
775
|
}
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
// session_id, worktree_id, expires_at, issue_id) — the CLI previously exposed
|
|
702
|
-
// only `claimed_by` (the actor). A lease is LIVE iff state='active' AND it has
|
|
703
|
-
// NOT expired at the read `now` (isLeaseExpired; a null expires_at never
|
|
704
|
-
// expires). An expired-but-not-yet-reclaimed lease is deliberately excluded:
|
|
705
|
-
// planClaimAcquisition would supersede it, so it is not a live presence signal
|
|
706
|
-
// (this mirrors the expiry check the `owns` verdict re-applies). Reads that
|
|
707
|
-
// derive claimed_by (loadBoardReadiness/loadActiveKernelClaimRow) filter on
|
|
708
|
-
// state only; THIS read additionally honors the lease TTL. Sorted by
|
|
709
|
-
// claimed_at (then issue_id) for deterministic output. The kernel_claims
|
|
710
|
-
// schema has NO `agent` column — the lease's who-dimension is actor +
|
|
711
|
-
// session_id + worktree_id — so no agent field is surfaced.
|
|
776
|
+
// Dashboard lease rows consume the same live-authority projection as stats,
|
|
777
|
+
// claimed_by, owns, and readiness. Sort by claimed_at then issue_id for stable output.
|
|
712
778
|
if (operation === 'claims') {
|
|
713
|
-
const
|
|
714
|
-
const
|
|
715
|
-
const claims = rows
|
|
716
|
-
.filter(row => !isLeaseExpired(row, nowIso))
|
|
779
|
+
const liveClaims = loadLiveKernelClaimRows(runtime, db, context);
|
|
780
|
+
const claims = liveClaims
|
|
717
781
|
.map(row => ({
|
|
718
782
|
id: row.id,
|
|
719
783
|
issue_id: row.issue_id,
|
|
@@ -749,11 +813,20 @@ const KERNEL_EVENT_COLUMNS = Object.freeze([
|
|
|
749
813
|
'created_at',
|
|
750
814
|
]);
|
|
751
815
|
|
|
816
|
+
function assertGenericEventNamespace(event) {
|
|
817
|
+
if (!String(event.idempotency_key || '').startsWith('claim.repair:')) return;
|
|
818
|
+
throw new ClaimRepairError(
|
|
819
|
+
'CLAIM_REPAIR_RECEIPT_RESERVED',
|
|
820
|
+
'Generic event insertion cannot write the reserved claim-repair receipt namespace',
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
752
824
|
// Persist one event. The id is supplied by the caller or minted here (event ids are
|
|
753
825
|
// TEXT, not autoincrement). The event's payload is stored as payload_json: a
|
|
754
826
|
// pre-serialized payload_json wins, else the payload object is JSON-stringified. The
|
|
755
827
|
// native UNIQUE(idempotency_key) error is intentionally NOT caught here.
|
|
756
828
|
function insertKernelEventRow(runtime, db, event) {
|
|
829
|
+
assertGenericEventNamespace(event);
|
|
757
830
|
const id = event.id || randomUUID();
|
|
758
831
|
const payloadJson = event.payload_json ?? JSON.stringify(event.payload ?? {});
|
|
759
832
|
const row = {
|
|
@@ -790,13 +863,14 @@ function loadKernelEntityRow(runtime, db, entityType, entityId) {
|
|
|
790
863
|
}
|
|
791
864
|
|
|
792
865
|
// Read the full event stream for one entity, oldest first (matches
|
|
793
|
-
// idx_kernel_events_entity_created
|
|
794
|
-
//
|
|
866
|
+
// idx_kernel_events_entity_created. SQLite rowid is the authoritative local
|
|
867
|
+
// insertion sequence when timestamps tie; event ids are random and cannot order
|
|
868
|
+
// same-clock approval/rejection decisions.
|
|
795
869
|
function listKernelEventRows(runtime, db, entityType, entityId) {
|
|
796
870
|
return allParams(
|
|
797
871
|
runtime,
|
|
798
872
|
db,
|
|
799
|
-
'SELECT * FROM kernel_events WHERE entity_type = ? AND entity_id = ? ORDER BY created_at ASC',
|
|
873
|
+
'SELECT * FROM kernel_events WHERE entity_type = ? AND entity_id = ? ORDER BY created_at ASC, rowid ASC',
|
|
800
874
|
[entityType, entityId],
|
|
801
875
|
);
|
|
802
876
|
}
|
|
@@ -1107,6 +1181,67 @@ function updateKernelClaimStateRow(runtime, db, claimId, state) {
|
|
|
1107
1181
|
return { id: claimId, state };
|
|
1108
1182
|
}
|
|
1109
1183
|
|
|
1184
|
+
function listActiveKernelClaimRows(runtime, db) {
|
|
1185
|
+
return safeAll(runtime, db, "SELECT * FROM kernel_claims WHERE state = 'active' ORDER BY claimed_at ASC, issue_id ASC");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Reconciliation must never release whichever lease happens to occupy an issue's
|
|
1189
|
+
// active slot after its evidence was gathered. Bind every ownership discriminator
|
|
1190
|
+
// from the observed row so a concurrent replacement makes this a zero-row no-op.
|
|
1191
|
+
function releaseExactKernelClaimRow(runtime, db, claim) {
|
|
1192
|
+
const result = runParams(
|
|
1193
|
+
runtime,
|
|
1194
|
+
db,
|
|
1195
|
+
`UPDATE kernel_claims SET state = 'released'
|
|
1196
|
+
WHERE id = ? AND issue_id = ? AND actor IS ? AND session_id IS ?
|
|
1197
|
+
AND worktree_id IS ? AND state = 'active'`,
|
|
1198
|
+
[claim.id, claim.issue_id, claim.actor ?? null, claim.session_id ?? null, claim.worktree_id ?? null],
|
|
1199
|
+
);
|
|
1200
|
+
return Number(result?.changes || 0) === 1;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function releaseExactKernelClaimIfWorktreeMissing(runtime, db, claim, expectedWorktree, isMissing) {
|
|
1204
|
+
if (!expectedWorktree || typeof isMissing !== 'function') return false;
|
|
1205
|
+
execSql(runtime, db, 'BEGIN IMMEDIATE;');
|
|
1206
|
+
try {
|
|
1207
|
+
const rows = safeAll(
|
|
1208
|
+
runtime,
|
|
1209
|
+
db,
|
|
1210
|
+
`SELECT * FROM kernel_worktrees
|
|
1211
|
+
WHERE id = ? AND git_common_dir IS ? AND path = ? AND branch IS ?
|
|
1212
|
+
AND actor IS ? AND issue_id IS ? AND work_folder IS ?
|
|
1213
|
+
AND registered_at IS ? AND state = 'active'`,
|
|
1214
|
+
[
|
|
1215
|
+
expectedWorktree.id,
|
|
1216
|
+
expectedWorktree.git_common_dir ?? null,
|
|
1217
|
+
expectedWorktree.path,
|
|
1218
|
+
expectedWorktree.branch ?? null,
|
|
1219
|
+
expectedWorktree.actor ?? null,
|
|
1220
|
+
expectedWorktree.issue_id ?? null,
|
|
1221
|
+
expectedWorktree.work_folder ?? null,
|
|
1222
|
+
expectedWorktree.registered_at ?? null,
|
|
1223
|
+
],
|
|
1224
|
+
);
|
|
1225
|
+
const exact = rows.length === 1 && rows[0];
|
|
1226
|
+
// This check deliberately runs under BEGIN IMMEDIATE to close the
|
|
1227
|
+
// worktree-state TOCTOU window. Keep callbacks synchronous, local, and fast.
|
|
1228
|
+
if (!exact || isMissing(exact.path, exact) !== true) {
|
|
1229
|
+
execSql(runtime, db, 'ROLLBACK;');
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
const released = releaseExactKernelClaimRow(runtime, db, claim);
|
|
1233
|
+
if (!released) {
|
|
1234
|
+
execSql(runtime, db, 'ROLLBACK;');
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
1237
|
+
execSql(runtime, db, 'COMMIT;');
|
|
1238
|
+
return true;
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
try { execSql(runtime, db, 'ROLLBACK;'); } catch { /* preserve original failure */ }
|
|
1241
|
+
throw error;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1110
1245
|
// --- Worktree-linkage primitives (P0 kernel linkage backbone). The kernel_worktrees
|
|
1111
1246
|
// table is a plain authority registry (NOT event-sourced): `forge worktree create`
|
|
1112
1247
|
// writes a row here so the kernel records issue → worktree → work-folder, and
|
|
@@ -1216,6 +1351,194 @@ function listWorktreeRows(runtime, db, filter = {}) {
|
|
|
1216
1351
|
return safeAll(runtime, db, 'SELECT * FROM kernel_worktrees ORDER BY registered_at DESC');
|
|
1217
1352
|
}
|
|
1218
1353
|
|
|
1354
|
+
const TRACE_MAX_PULL_REQUESTS = 128;
|
|
1355
|
+
const TRACE_MAX_ITERATIONS = 128;
|
|
1356
|
+
|
|
1357
|
+
function resolvePrLinkageRow(runtime, db, input = {}) {
|
|
1358
|
+
let worktree = null;
|
|
1359
|
+
if (input.worktree_id) {
|
|
1360
|
+
worktree = safeAll(
|
|
1361
|
+
runtime,
|
|
1362
|
+
db,
|
|
1363
|
+
"SELECT * FROM kernel_worktrees WHERE id = ? AND state = 'active' LIMIT 1",
|
|
1364
|
+
[input.worktree_id],
|
|
1365
|
+
)[0] || null;
|
|
1366
|
+
} else if (input.branch && input.git_common_dir) {
|
|
1367
|
+
worktree = safeAll(
|
|
1368
|
+
runtime,
|
|
1369
|
+
db,
|
|
1370
|
+
`SELECT * FROM kernel_worktrees
|
|
1371
|
+
WHERE git_common_dir = ? AND branch = ? AND state = 'active'
|
|
1372
|
+
ORDER BY registered_at DESC LIMIT 1`,
|
|
1373
|
+
[input.git_common_dir, input.branch],
|
|
1374
|
+
)[0] || null;
|
|
1375
|
+
}
|
|
1376
|
+
const inferredIssueId = worktree?.issue_id ?? null;
|
|
1377
|
+
const issueId = input.issue_id ?? inferredIssueId;
|
|
1378
|
+
const issue = issueId
|
|
1379
|
+
? safeAll(runtime, db, 'SELECT id, entity_revision FROM kernel_issues WHERE id = ? LIMIT 1', [issueId])[0] || null
|
|
1380
|
+
: null;
|
|
1381
|
+
return {
|
|
1382
|
+
issue_id: issueId,
|
|
1383
|
+
inferred_issue_id: inferredIssueId,
|
|
1384
|
+
issue_revision: issue?.entity_revision ?? null,
|
|
1385
|
+
worktree_id: worktree?.id ?? null,
|
|
1386
|
+
worktree_matches: Boolean(worktree
|
|
1387
|
+
&& worktree.git_common_dir === input.git_common_dir
|
|
1388
|
+
&& worktree.branch === input.branch
|
|
1389
|
+
&& worktree.issue_id === issueId),
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function parseTraceEvent(row) {
|
|
1394
|
+
let payload;
|
|
1395
|
+
try {
|
|
1396
|
+
payload = JSON.parse(row.payload_json || '{}');
|
|
1397
|
+
} catch {
|
|
1398
|
+
payload = {};
|
|
1399
|
+
}
|
|
1400
|
+
return {
|
|
1401
|
+
id: row.id,
|
|
1402
|
+
type: row.event_type,
|
|
1403
|
+
at: row.created_at,
|
|
1404
|
+
issue_id: payload.issue_id ?? null,
|
|
1405
|
+
worktree_id: payload.worktree_id ?? null,
|
|
1406
|
+
issue_revision: payload.issue_revision ?? null,
|
|
1407
|
+
head_sha: payload.head_sha ?? null,
|
|
1408
|
+
work_packet_hash: payload.work_packet_hash ?? null,
|
|
1409
|
+
work_packet_identity: payload.work_packet_identity ?? null,
|
|
1410
|
+
run_receipt_hash: payload.run_receipt_hash ?? null,
|
|
1411
|
+
run_id: payload.run_id ?? null,
|
|
1412
|
+
attempt_id: payload.attempt_id ?? null,
|
|
1413
|
+
risk_manifest_digest: payload.risk_manifest_digest ?? null,
|
|
1414
|
+
gate_receipts: payload.gate_receipts ?? null,
|
|
1415
|
+
url: payload.url ?? null,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function isCompleteTraceIteration(event) {
|
|
1420
|
+
if (!event.type?.startsWith('pr.')) return true;
|
|
1421
|
+
return typeof event.issue_id === 'string' && event.issue_id.length > 0
|
|
1422
|
+
&& typeof event.worktree_id === 'string' && event.worktree_id.length > 0
|
|
1423
|
+
&& Number.isInteger(event.issue_revision) && event.issue_revision >= 0
|
|
1424
|
+
&& /^[0-9a-f]{40}$/.test(event.head_sha)
|
|
1425
|
+
&& /^[0-9a-f]{64}$/.test(event.work_packet_hash)
|
|
1426
|
+
&& typeof event.work_packet_identity === 'string' && event.work_packet_identity.length > 0
|
|
1427
|
+
&& /^[0-9a-f]{64}$/.test(event.run_receipt_hash)
|
|
1428
|
+
&& /^[0-9a-f]{64}$/.test(event.risk_manifest_digest)
|
|
1429
|
+
&& Array.isArray(event.gate_receipts) && event.gate_receipts.length > 0
|
|
1430
|
+
&& event.gate_receipts.every(receipt => typeof receipt === 'string' && receipt.length > 0);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function loadPrTraceRow(runtime, db, row, gaps) {
|
|
1434
|
+
let iterations = safeAll(
|
|
1435
|
+
runtime,
|
|
1436
|
+
db,
|
|
1437
|
+
"SELECT * FROM kernel_events WHERE entity_type = 'pr' AND entity_id = ? ORDER BY created_at ASC, rowid ASC LIMIT ?",
|
|
1438
|
+
[row.id, TRACE_MAX_ITERATIONS + 1],
|
|
1439
|
+
);
|
|
1440
|
+
if (iterations.length > TRACE_MAX_ITERATIONS) {
|
|
1441
|
+
iterations = iterations.slice(0, TRACE_MAX_ITERATIONS);
|
|
1442
|
+
gaps.push(`iterations:${row.id}:overflow`);
|
|
1443
|
+
}
|
|
1444
|
+
const parsedIterations = iterations.map(parseTraceEvent);
|
|
1445
|
+
const prIterations = parsedIterations.filter(event => event.type?.startsWith('pr.'));
|
|
1446
|
+
if (prIterations.length === 0) {
|
|
1447
|
+
gaps.push(`iterations:${row.id}:missing`);
|
|
1448
|
+
} else if (prIterations.some(event => !isCompleteTraceIteration(event))) {
|
|
1449
|
+
gaps.push(`iterations:${row.id}:incomplete`);
|
|
1450
|
+
}
|
|
1451
|
+
return {
|
|
1452
|
+
...row,
|
|
1453
|
+
url: parsedIterations.find(event => event.url)?.url ?? null,
|
|
1454
|
+
iterations: parsedIterations,
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function loadSelectedTracePr(runtime, db, target) {
|
|
1459
|
+
if (target.pr_number === undefined || target.pr_number === null) return null;
|
|
1460
|
+
const clauses = ['number = ?'];
|
|
1461
|
+
const params = [Number(target.pr_number)];
|
|
1462
|
+
if (target.repo) {
|
|
1463
|
+
clauses.push('repo = ?');
|
|
1464
|
+
params.push(target.repo);
|
|
1465
|
+
}
|
|
1466
|
+
if (target.git_common_dir) {
|
|
1467
|
+
clauses.push('git_common_dir = ?');
|
|
1468
|
+
params.push(target.git_common_dir);
|
|
1469
|
+
}
|
|
1470
|
+
const matches = safeAll(
|
|
1471
|
+
runtime,
|
|
1472
|
+
db,
|
|
1473
|
+
`SELECT * FROM kernel_pr WHERE ${clauses.join(' AND ')} ORDER BY registered_at DESC LIMIT 2`,
|
|
1474
|
+
params,
|
|
1475
|
+
);
|
|
1476
|
+
if (matches.length > 1) throw new Error('Kernel trace PR target is ambiguous; supply repo and git_common_dir');
|
|
1477
|
+
return matches[0] || null;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function appendSelectedTracePr(pullRequests, selectedPr, issueId, gaps) {
|
|
1481
|
+
if (!selectedPr || pullRequests.some(row => row.id === selectedPr.id)) return pullRequests;
|
|
1482
|
+
if (issueId && selectedPr.issue_id !== issueId) {
|
|
1483
|
+
gaps.push(`pull_requests:${selectedPr.id}:unlinked_issue`);
|
|
1484
|
+
}
|
|
1485
|
+
const retained = pullRequests.length >= TRACE_MAX_PULL_REQUESTS
|
|
1486
|
+
? pullRequests.slice(0, TRACE_MAX_PULL_REQUESTS - 1)
|
|
1487
|
+
: pullRequests;
|
|
1488
|
+
if (retained.length < pullRequests.length && !gaps.includes('pull_requests:overflow')) {
|
|
1489
|
+
gaps.push('pull_requests:overflow');
|
|
1490
|
+
}
|
|
1491
|
+
return [...retained, selectedPr];
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function loadTraceRows(runtime, db, target = {}) {
|
|
1495
|
+
const gaps = [];
|
|
1496
|
+
const selectedPr = loadSelectedTracePr(runtime, db, target);
|
|
1497
|
+
|
|
1498
|
+
const issueId = target.issue_id ?? selectedPr?.issue_id ?? null;
|
|
1499
|
+
const issue = issueId
|
|
1500
|
+
? safeAll(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ? LIMIT 1', [issueId])[0] || null
|
|
1501
|
+
: null;
|
|
1502
|
+
let pullRequests = issue
|
|
1503
|
+
? safeAll(
|
|
1504
|
+
runtime,
|
|
1505
|
+
db,
|
|
1506
|
+
' SELECT * FROM kernel_pr WHERE issue_id = ? ORDER BY registered_at ASC, number ASC LIMIT ?',
|
|
1507
|
+
[issue.id, TRACE_MAX_PULL_REQUESTS + 1],
|
|
1508
|
+
)
|
|
1509
|
+
: [];
|
|
1510
|
+
if (pullRequests.length > TRACE_MAX_PULL_REQUESTS) {
|
|
1511
|
+
pullRequests = pullRequests.slice(0, TRACE_MAX_PULL_REQUESTS);
|
|
1512
|
+
gaps.push('pull_requests:overflow');
|
|
1513
|
+
}
|
|
1514
|
+
pullRequests = appendSelectedTracePr(pullRequests, selectedPr, issueId, gaps);
|
|
1515
|
+
|
|
1516
|
+
let worktree = null;
|
|
1517
|
+
const issueWorktreeId = pullRequests.find(row => row.issue_id === issueId && row.worktree_id)?.worktree_id;
|
|
1518
|
+
const selectedWorktreeId = !issueId || selectedPr?.issue_id === issueId ? selectedPr?.worktree_id : null;
|
|
1519
|
+
const preferredWorktreeId = selectedWorktreeId || issueWorktreeId;
|
|
1520
|
+
if (preferredWorktreeId) {
|
|
1521
|
+
worktree = safeAll(runtime, db, 'SELECT * FROM kernel_worktrees WHERE id = ? LIMIT 1', [preferredWorktreeId])[0] || null;
|
|
1522
|
+
}
|
|
1523
|
+
if (!worktree && issue) {
|
|
1524
|
+
worktree = safeAll(
|
|
1525
|
+
runtime,
|
|
1526
|
+
db,
|
|
1527
|
+
`SELECT * FROM kernel_worktrees WHERE issue_id = ?
|
|
1528
|
+
ORDER BY CASE state WHEN 'active' THEN 0 ELSE 1 END, registered_at DESC LIMIT 1`,
|
|
1529
|
+
[issue.id],
|
|
1530
|
+
)[0] || null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return {
|
|
1534
|
+
issue,
|
|
1535
|
+
worktree,
|
|
1536
|
+
work_folder: worktree?.work_folder ?? null,
|
|
1537
|
+
gaps,
|
|
1538
|
+
pull_requests: pullRequests.map(row => loadPrTraceRow(runtime, db, row, gaps)),
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1219
1542
|
// --- Stage-run registry (f61601ab). kernel_stage_runs records the REAL workflow
|
|
1220
1543
|
// phase per issue so the dashboard/`show` read the phase instead of guessing it
|
|
1221
1544
|
// from status+claim (a claimed-open issue with a merged PR would otherwise still
|
|
@@ -1374,6 +1697,78 @@ function recordStageTransitionRow(runtime, db, input) {
|
|
|
1374
1697
|
}
|
|
1375
1698
|
}
|
|
1376
1699
|
|
|
1700
|
+
const PLAN_SNAPSHOT_METADATA_KEY = 'forge.plan.v1';
|
|
1701
|
+
|
|
1702
|
+
function parseIssueMetadataObject(metadata) {
|
|
1703
|
+
if (metadata == null || metadata === '') return {};
|
|
1704
|
+
const parsed = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
|
1705
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1706
|
+
throw new Error('issue metadata must be a JSON object');
|
|
1707
|
+
}
|
|
1708
|
+
return parsed;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function loadPlanSnapshotRow(runtime, db, issueId) {
|
|
1712
|
+
const issue = loadKernelEntityRow(runtime, db, 'issue', issueId);
|
|
1713
|
+
if (!issue) throw new Error(`Issue ${issueId} not found in the kernel`);
|
|
1714
|
+
return parseIssueMetadataObject(issue.metadata)[PLAN_SNAPSHOT_METADATA_KEY] || null;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Persist the plan snapshot and the plan->dev transition as one authority write.
|
|
1718
|
+
// The issue metadata column already exists, so this adds no schema or migration.
|
|
1719
|
+
// A normal issue.update event is appended in the same transaction so revision/CAS
|
|
1720
|
+
// history remains truthful rather than mutating the issue projection out-of-band.
|
|
1721
|
+
function recordPlanSnapshotTransitionRow(runtime, db, input) {
|
|
1722
|
+
const issueId = input && input.issue_id;
|
|
1723
|
+
const snapshot = input && input.snapshot;
|
|
1724
|
+
if (!issueId || !snapshot) {
|
|
1725
|
+
throw new Error('recordPlanSnapshotTransition requires issue_id and snapshot');
|
|
1726
|
+
}
|
|
1727
|
+
const now = input.now || new Date().toISOString();
|
|
1728
|
+
execSql(runtime, db, 'BEGIN IMMEDIATE;');
|
|
1729
|
+
try {
|
|
1730
|
+
const issue = loadKernelEntityRow(runtime, db, 'issue', issueId);
|
|
1731
|
+
if (!issue) throw new Error(`Issue ${issueId} not found in the kernel`);
|
|
1732
|
+
const metadata = parseIssueMetadataObject(issue.metadata);
|
|
1733
|
+
const established = metadata[PLAN_SNAPSHOT_METADATA_KEY];
|
|
1734
|
+
if (established) {
|
|
1735
|
+
if (established.digest === snapshot.digest) {
|
|
1736
|
+
execSql(runtime, db, 'COMMIT;');
|
|
1737
|
+
return { idempotent: true };
|
|
1738
|
+
}
|
|
1739
|
+
throw new Error('Kernel plan snapshot is immutable; reconcile repository drift before retrying');
|
|
1740
|
+
}
|
|
1741
|
+
metadata[PLAN_SNAPSHOT_METADATA_KEY] = snapshot;
|
|
1742
|
+
const event = {
|
|
1743
|
+
id: randomUUID(),
|
|
1744
|
+
entity_type: 'issue',
|
|
1745
|
+
entity_id: issueId,
|
|
1746
|
+
event_type: 'issue.update',
|
|
1747
|
+
idempotency_key: `plan.snapshot:${issueId}:${snapshot.digest}`,
|
|
1748
|
+
expected_revision: Number(issue.entity_revision ?? 0),
|
|
1749
|
+
actor: input.actor || 'forge-plan',
|
|
1750
|
+
origin: 'local',
|
|
1751
|
+
payload: { metadata: JSON.stringify(metadata) },
|
|
1752
|
+
created_at: now,
|
|
1753
|
+
};
|
|
1754
|
+
insertKernelEventRow(runtime, db, event);
|
|
1755
|
+
applyAcceptedIssueEvent(runtime, db, event, {});
|
|
1756
|
+
const transition = {
|
|
1757
|
+
from: recordStageRunRow(runtime, db, { issue_id: issueId, stage: 'plan', action: 'complete', now }),
|
|
1758
|
+
to: recordStageRunRow(runtime, db, { issue_id: issueId, stage: 'dev', action: 'start', now }),
|
|
1759
|
+
};
|
|
1760
|
+
execSql(runtime, db, 'COMMIT;');
|
|
1761
|
+
return transition;
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
try {
|
|
1764
|
+
execSql(runtime, db, 'ROLLBACK;');
|
|
1765
|
+
} catch {
|
|
1766
|
+
// Preserve the original failure.
|
|
1767
|
+
}
|
|
1768
|
+
throw error;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1377
1772
|
function listStageRunRows(runtime, db, issueId) {
|
|
1378
1773
|
if (!issueId) return [];
|
|
1379
1774
|
// Deterministic order: started_at first, then rowid (the implicit INSERT sequence)
|
|
@@ -1585,6 +1980,15 @@ function applyAcceptedIssueEvent(runtime, db, event, context = {}) {
|
|
|
1585
1980
|
// on this connection), so computeNewlyUnblocked sees the post-close readiness.
|
|
1586
1981
|
function finalizeIssueMutation(runtime, db, event, issueId, revision, context) {
|
|
1587
1982
|
const summary = { id: issueId, revision };
|
|
1983
|
+
const issue = loadKernelEntityRow(runtime, db, 'issue', issueId);
|
|
1984
|
+
if (isTerminalStatus(issue?.status)) {
|
|
1985
|
+
const claim = loadActiveKernelClaimRow(runtime, db, issueId);
|
|
1986
|
+
if (claim && !releaseExactKernelClaimRow(runtime, db, claim)) {
|
|
1987
|
+
const error = new Error(`failed to release current claim for terminal issue ${issueId}`);
|
|
1988
|
+
error.code = 'FORGE_CLAIM_TERMINAL_RELEASE_CONFLICT';
|
|
1989
|
+
throw error;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1588
1992
|
if (event.event_type === 'issue.close') {
|
|
1589
1993
|
summary.newly_unblocked = computeNewlyUnblocked(runtime, db, issueId, context);
|
|
1590
1994
|
}
|
|
@@ -1888,6 +2292,7 @@ function importIssueRecords(runtime, db, records = {}, options = {}) {
|
|
|
1888
2292
|
for (const event of activityEvents) {
|
|
1889
2293
|
if (!event || event.id == null) { summary.events.skipped += 1; continue; }
|
|
1890
2294
|
const row = buildImportEventRow(event, now);
|
|
2295
|
+
assertGenericEventNamespace(row);
|
|
1891
2296
|
const result = runParams(runtime, db, eventSql, IMPORT_EVENT_COLUMNS.map(column => row[column]));
|
|
1892
2297
|
summary.events[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
|
|
1893
2298
|
}
|
|
@@ -2029,23 +2434,43 @@ function searchMemoryRows(runtime, db, query) {
|
|
|
2029
2434
|
).map(memoryRowToEntry);
|
|
2030
2435
|
}
|
|
2031
2436
|
|
|
2032
|
-
// Optional
|
|
2033
|
-
//
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2437
|
+
// Optional source-agent and kind filters become one parameterized WHERE clause. This keeps
|
|
2438
|
+
// typed recall complete even when its oldest match lies outside a broad recent-note window.
|
|
2439
|
+
function memoryReadFilter(options = {}, table = 'kernel_memories') {
|
|
2440
|
+
const predicates = [];
|
|
2441
|
+
const params = [];
|
|
2442
|
+
if (options.includeSuperseded !== true) {
|
|
2443
|
+
predicates.push(`(COALESCE(${table}.scope, ''), ${table}.key) NOT IN (
|
|
2444
|
+
SELECT COALESCE(superseder.scope, ''), superseded.value
|
|
2445
|
+
FROM kernel_memories superseder,
|
|
2446
|
+
json_each(COALESCE(superseder.supersedes_json, '[]')) superseded
|
|
2447
|
+
WHERE superseded.type = 'text'
|
|
2448
|
+
AND superseder.key != superseded.value
|
|
2449
|
+
)`);
|
|
2450
|
+
}
|
|
2451
|
+
if (Array.isArray(options.agents) && options.agents.length > 0) {
|
|
2452
|
+
predicates.push(`${table}.source_agent IN (${options.agents.map(() => '?').join(', ')})`);
|
|
2453
|
+
params.push(...options.agents);
|
|
2038
2454
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2455
|
+
if (typeof options.kind === 'string' && options.kind.trim()) {
|
|
2456
|
+
predicates.push(`EXISTS (
|
|
2457
|
+
SELECT 1 FROM json_each(${table}.tags_json)
|
|
2458
|
+
WHERE lower(json_each.value) = ?
|
|
2459
|
+
)`);
|
|
2460
|
+
params.push(`type:${options.kind.trim().toLowerCase()}`);
|
|
2461
|
+
}
|
|
2462
|
+
return {
|
|
2463
|
+
clause: predicates.length > 0 ? ` WHERE ${predicates.join(' AND ')}` : '',
|
|
2464
|
+
params,
|
|
2465
|
+
};
|
|
2041
2466
|
}
|
|
2042
2467
|
|
|
2043
2468
|
// The newest `limit` entries by logical (as-of) timestamp — the default read model for
|
|
2044
2469
|
// `recall` with no query. rowid breaks ties so same-timestamp rows are still deterministic.
|
|
2045
|
-
//
|
|
2046
|
-
function recentMemoryRows(runtime, db, limit,
|
|
2470
|
+
// Optional `agents` and `kind` filters scope the view before the limit is applied.
|
|
2471
|
+
function recentMemoryRows(runtime, db, limit, options = {}) {
|
|
2047
2472
|
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2048
|
-
const { clause, params } =
|
|
2473
|
+
const { clause, params } = memoryReadFilter(options);
|
|
2049
2474
|
return allParams(
|
|
2050
2475
|
runtime,
|
|
2051
2476
|
db,
|
|
@@ -2054,10 +2479,10 @@ function recentMemoryRows(runtime, db, limit, agents) {
|
|
|
2054
2479
|
).map(memoryRowToEntry);
|
|
2055
2480
|
}
|
|
2056
2481
|
|
|
2057
|
-
// Total number of stored memories (optionally scoped by `agents`) — paired with
|
|
2482
|
+
// Total number of stored memories (optionally scoped by `agents` and `kind`) — paired with
|
|
2058
2483
|
// recentMemoryRows so `recall` can report "showing N of TOTAL" instead of silently truncating.
|
|
2059
|
-
function countMemoryRows(runtime, db,
|
|
2060
|
-
const { clause, params } =
|
|
2484
|
+
function countMemoryRows(runtime, db, options = {}) {
|
|
2485
|
+
const { clause, params } = memoryReadFilter(options);
|
|
2061
2486
|
const rows = allParams(runtime, db, `SELECT count(*) AS count FROM kernel_memories${clause}`, params);
|
|
2062
2487
|
return Number((rows[0] || {}).count) || 0;
|
|
2063
2488
|
}
|
|
@@ -2086,21 +2511,26 @@ function buildMemoryFtsMatchOr(query) {
|
|
|
2086
2511
|
// BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
|
|
2087
2512
|
// rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
|
|
2088
2513
|
// query falls back to recent entries so `recall` never returns a bare full dump.
|
|
2089
|
-
function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
2514
|
+
function searchMemoryRowsRanked(runtime, db, query, limit, options = {}) {
|
|
2090
2515
|
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2091
2516
|
const match = buildMemoryFtsMatch(query);
|
|
2092
2517
|
if (!match) {
|
|
2093
|
-
return recentMemoryRows(runtime, db, capped);
|
|
2518
|
+
return recentMemoryRows(runtime, db, capped, options);
|
|
2094
2519
|
}
|
|
2520
|
+
const { clause, params } = memoryReadFilter({
|
|
2521
|
+
kind: options.kind,
|
|
2522
|
+
includeSuperseded: options.includeSuperseded,
|
|
2523
|
+
}, 'm');
|
|
2524
|
+
const kindPredicate = clause ? clause.replace(/^ WHERE /, ' AND ') : '';
|
|
2095
2525
|
return allParams(
|
|
2096
2526
|
runtime,
|
|
2097
2527
|
db,
|
|
2098
2528
|
`SELECT m.* FROM kernel_memories m
|
|
2099
2529
|
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2100
|
-
WHERE kernel_memories_fts MATCH
|
|
2530
|
+
WHERE kernel_memories_fts MATCH ?${kindPredicate}
|
|
2101
2531
|
ORDER BY bm25(kernel_memories_fts)
|
|
2102
2532
|
LIMIT ?`,
|
|
2103
|
-
[match, capped],
|
|
2533
|
+
[match, ...params, capped],
|
|
2104
2534
|
).map(memoryRowToEntry);
|
|
2105
2535
|
}
|
|
2106
2536
|
|
|
@@ -2151,6 +2581,14 @@ function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
|
|
|
2151
2581
|
: '';
|
|
2152
2582
|
const confirmed = confirmedMemorySql('m');
|
|
2153
2583
|
const supersederConfirmed = confirmedMemorySql('s');
|
|
2584
|
+
const supersessionPredicate = options.includeSuperseded === true
|
|
2585
|
+
? ''
|
|
2586
|
+
: `AND NOT EXISTS (
|
|
2587
|
+
SELECT 1
|
|
2588
|
+
FROM eligible_superseders superseder
|
|
2589
|
+
WHERE superseder.memory_key = m.key
|
|
2590
|
+
AND (superseder.is_confirmed = 1 OR NOT ${confirmed})
|
|
2591
|
+
)`;
|
|
2154
2592
|
// Expand eligible supersession edges once. A correlated json_each scan repeated the
|
|
2155
2593
|
// entire memory table for every FTS candidate and dominated the 1,000-row prompt path.
|
|
2156
2594
|
// Recall temporarily waits less than the connection default so a real lock cannot outlive
|
|
@@ -2181,12 +2619,7 @@ function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
|
|
|
2181
2619
|
AND ${projectMemoryScopeSql('m')}
|
|
2182
2620
|
AND (${confirmed} OR m.updated_at >= ?)
|
|
2183
2621
|
${seenSql}
|
|
2184
|
-
|
|
2185
|
-
SELECT 1
|
|
2186
|
-
FROM eligible_superseders superseder
|
|
2187
|
-
WHERE superseder.memory_key = m.key
|
|
2188
|
-
AND (superseder.is_confirmed = 1 OR NOT ${confirmed})
|
|
2189
|
-
)
|
|
2622
|
+
${supersessionPredicate}
|
|
2190
2623
|
ORDER BY bm25(kernel_memories_fts),
|
|
2191
2624
|
CASE WHEN ${confirmed} THEN 0 ELSE 1 END,
|
|
2192
2625
|
m.source_agent ASC, m.updated_at DESC, m.key ASC
|
|
@@ -2209,77 +2642,2197 @@ function closeDatabase(db) {
|
|
|
2209
2642
|
}
|
|
2210
2643
|
}
|
|
2211
2644
|
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2645
|
+
const WATCH_OWNER_TABLE = 'kernel_pr_watch_owners';
|
|
2646
|
+
const WATCH_GATE_TABLE = 'kernel_pr_watch_migration_gate';
|
|
2647
|
+
const WATCH_OWNER_SCHEMA_COLUMNS = Object.freeze([
|
|
2648
|
+
{ name: 'repo', type: 'TEXT', notnull: 1, pk: 1 },
|
|
2649
|
+
{ name: 'pr', type: 'INTEGER', notnull: 1, pk: 2 },
|
|
2650
|
+
{ name: 'version', type: 'INTEGER', notnull: 1, pk: 0 },
|
|
2651
|
+
{ name: 'generation', type: 'TEXT', notnull: 1, pk: 0 },
|
|
2652
|
+
{ name: 'phase', type: 'TEXT', notnull: 1, pk: 0 },
|
|
2653
|
+
{ name: 'controller_pid', type: 'INTEGER', notnull: 0, pk: 0 },
|
|
2654
|
+
{ name: 'watcher_pid', type: 'INTEGER', notnull: 0, pk: 0 },
|
|
2655
|
+
{ name: 'started_at', type: 'TEXT', notnull: 1, pk: 0 },
|
|
2656
|
+
{ name: 'updated_at', type: 'TEXT', notnull: 1, pk: 0 },
|
|
2657
|
+
{ name: 'heartbeat_at', type: 'TEXT', notnull: 0, pk: 0 },
|
|
2658
|
+
{ name: 'terminal_receipt_id', type: 'TEXT', notnull: 0, pk: 0 },
|
|
2659
|
+
{ name: 'block_reason', type: 'TEXT', notnull: 0, pk: 0 },
|
|
2660
|
+
{ name: 'legacy_evidence_hash', type: 'TEXT', notnull: 0, pk: 0 },
|
|
2661
|
+
]);
|
|
2662
|
+
const WATCH_GATE_SCHEMA_COLUMNS = Object.freeze([
|
|
2663
|
+
{ name: 'singleton', type: 'INTEGER', notnull: 1, pk: 1 },
|
|
2664
|
+
{ name: 'state', type: 'TEXT', notnull: 1, pk: 0 },
|
|
2665
|
+
{ name: 'snapshot_hash', type: 'TEXT', notnull: 0, pk: 0 },
|
|
2666
|
+
{ name: 'conflict_code', type: 'TEXT', notnull: 0, pk: 0 },
|
|
2667
|
+
{ name: 'updated_at', type: 'TEXT', notnull: 1, pk: 0 },
|
|
2668
|
+
]);
|
|
2216
2669
|
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2670
|
+
function watchOwnerStoreError(code, message, cause) {
|
|
2671
|
+
const error = new Error(message);
|
|
2672
|
+
error.code = code;
|
|
2673
|
+
if (cause) error.cause = cause;
|
|
2674
|
+
return error;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function assertWatchOwnerDatabasePath(databasePath, options = {}) {
|
|
2678
|
+
if (typeof databasePath !== 'string' || !databasePath
|
|
2679
|
+
|| databasePath === ':memory:' || databasePath.startsWith('file:')) {
|
|
2680
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority requires an existing file-backed Kernel database');
|
|
2681
|
+
}
|
|
2682
|
+
const resolved = path.resolve(databasePath);
|
|
2683
|
+
const filesystemDeps = options.watchOwnerFilesystemDeps || {};
|
|
2684
|
+
const authorityFilesystemDeps = {
|
|
2685
|
+
...filesystemDeps,
|
|
2686
|
+
env: { ...(filesystemDeps.env || process.env), FORGE_KERNEL_ALLOW_UNSAFE_FS: '' },
|
|
2687
|
+
};
|
|
2688
|
+
if (path.basename(resolved) !== 'kernel.sqlite' || path.basename(path.dirname(resolved)) !== 'forge') {
|
|
2689
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database path is outside the canonical forge/kernel.sqlite location');
|
|
2690
|
+
}
|
|
2691
|
+
try {
|
|
2692
|
+
assertFilesystemSafeForKernel(resolved, authorityFilesystemDeps);
|
|
2693
|
+
} catch (error) {
|
|
2694
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database is on a refused filesystem class', error);
|
|
2695
|
+
}
|
|
2696
|
+
let stat;
|
|
2697
|
+
try {
|
|
2698
|
+
stat = fs.statSync(resolved);
|
|
2699
|
+
} catch (error) {
|
|
2700
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database does not exist', error);
|
|
2701
|
+
}
|
|
2702
|
+
if (!stat.isFile()) {
|
|
2703
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database is not a regular file');
|
|
2704
|
+
}
|
|
2705
|
+
let realPath;
|
|
2706
|
+
try {
|
|
2707
|
+
realPath = fs.realpathSync(resolved);
|
|
2708
|
+
} catch (error) {
|
|
2709
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database path cannot be resolved', error);
|
|
2710
|
+
}
|
|
2711
|
+
if (path.basename(realPath) !== 'kernel.sqlite' || path.basename(path.dirname(realPath)) !== 'forge') {
|
|
2712
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database resolves outside the canonical forge/kernel.sqlite location');
|
|
2713
|
+
}
|
|
2714
|
+
try {
|
|
2715
|
+
assertFilesystemSafeForKernel(realPath, authorityFilesystemDeps);
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database resolves through a refused filesystem class', error);
|
|
2261
2718
|
}
|
|
2719
|
+
return { realPath, dev: stat.dev, ino: stat.ino };
|
|
2720
|
+
}
|
|
2262
2721
|
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2722
|
+
function sameWatchOwnerDatabaseIdentity(expected, actual) {
|
|
2723
|
+
return expected.realPath === actual.realPath
|
|
2724
|
+
&& expected.dev === actual.dev
|
|
2725
|
+
&& expected.ino === actual.ino;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function openWatchOwnerDatabase(runtime, validated) {
|
|
2729
|
+
let database;
|
|
2730
|
+
let guard;
|
|
2731
|
+
try {
|
|
2732
|
+
// SQLite's filename constructor may create a missing file. Hold a must-exist
|
|
2733
|
+
// descriptor across the open so a path swap after validation fails before any
|
|
2734
|
+
// runtime can create or rebind the authority database.
|
|
2735
|
+
guard = fs.openSync(validated.realPath, 'r+');
|
|
2736
|
+
const guardedStat = fs.fstatSync(guard);
|
|
2737
|
+
if (!sameWatchOwnerDatabaseIdentity(validated, {
|
|
2738
|
+
realPath: validated.realPath, dev: guardedStat.dev, ino: guardedStat.ino,
|
|
2739
|
+
})) {
|
|
2740
|
+
throw new Error('authority file identity changed before open');
|
|
2271
2741
|
}
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2742
|
+
database = createExistingWatchOwnerDatabase(runtime, validated.realPath);
|
|
2743
|
+
const main = queryAll(runtime, database, 'PRAGMA database_list;')
|
|
2744
|
+
.find(row => row.name === 'main');
|
|
2745
|
+
if (!main || typeof main.file !== 'string' || !main.file) {
|
|
2746
|
+
throw new Error('opened database did not expose a file-backed main database');
|
|
2747
|
+
}
|
|
2748
|
+
const openedPath = path.resolve(main.file);
|
|
2749
|
+
const openedRealPath = fs.realpathSync(openedPath);
|
|
2750
|
+
const openedStat = fs.statSync(openedRealPath);
|
|
2751
|
+
const actual = { realPath: openedRealPath, dev: openedStat.dev, ino: openedStat.ino };
|
|
2752
|
+
if (!sameWatchOwnerDatabaseIdentity(validated, actual)) {
|
|
2753
|
+
throw new Error('opened database identity differs from the validated authority file');
|
|
2754
|
+
}
|
|
2755
|
+
return database;
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
closeDatabase(database);
|
|
2758
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority database changed while opening', error);
|
|
2759
|
+
} finally {
|
|
2760
|
+
if (guard != null) {
|
|
2761
|
+
try { fs.closeSync(guard); } catch { /* preserve the open/identity result */ }
|
|
2275
2762
|
}
|
|
2276
|
-
return databasePath;
|
|
2277
2763
|
}
|
|
2764
|
+
}
|
|
2278
2765
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2766
|
+
function watchOwnerBusyTimeout(options = {}) {
|
|
2767
|
+
const requested = Number(options.watchOwnerBusyTimeoutMs);
|
|
2768
|
+
if (!Number.isFinite(requested)) return 1_000;
|
|
2769
|
+
return Math.max(0, Math.min(5_000, Math.floor(requested)));
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function hasExactWatchSchemaColumns(runtime, db, tableName, expectedColumns) {
|
|
2773
|
+
const columns = queryAll(runtime, db, `PRAGMA table_info('${tableName}')`);
|
|
2774
|
+
return columns.length === expectedColumns.length
|
|
2775
|
+
&& columns.every((column, index) => {
|
|
2776
|
+
const expected = expectedColumns[index];
|
|
2777
|
+
return column.name === expected.name
|
|
2778
|
+
&& String(column.type).toUpperCase() === expected.type
|
|
2779
|
+
&& Number(column.notnull) === expected.notnull
|
|
2780
|
+
&& Number(column.pk) === expected.pk;
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// Triggers owned by the authority tables are forbidden outright, and so are
|
|
2785
|
+
// triggers on any other table whose body even references an authority table:
|
|
2786
|
+
// quoted or schema-qualified identifiers would otherwise evade write-shape
|
|
2787
|
+
// matching, so a mere mention fails closed.
|
|
2788
|
+
function hasUnexpectedWatchAuthorityTriggers(runtime, db) {
|
|
2789
|
+
const triggers = allParams(
|
|
2790
|
+
runtime,
|
|
2791
|
+
db,
|
|
2792
|
+
"SELECT tbl_name AS tbl_name, sql FROM sqlite_master WHERE type = 'trigger'",
|
|
2793
|
+
);
|
|
2794
|
+
const authorityMention = new RegExp(
|
|
2795
|
+
`(?:${WATCH_OWNER_TABLE}|${WATCH_GATE_TABLE})`,
|
|
2796
|
+
'i',
|
|
2797
|
+
);
|
|
2798
|
+
return triggers.some(trigger => {
|
|
2799
|
+
const owned = [WATCH_OWNER_TABLE, WATCH_GATE_TABLE].includes(String(trigger.tbl_name).toLowerCase());
|
|
2800
|
+
return owned || (typeof trigger.sql === 'string' && authorityMention.test(trigger.sql));
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// Inbound ON DELETE CASCADE foreign keys would make abort/release delete rows
|
|
2805
|
+
// in unrelated tables (or vice versa), so any referencing table fails closed.
|
|
2806
|
+
// Outbound cascades declared on the authority tables themselves are equally
|
|
2807
|
+
// forbidden: deleting a referenced parent must never erase authority rows.
|
|
2808
|
+
function hasForbiddenWatchOwnerForeignKeys(runtime, db) {
|
|
2809
|
+
const tables = allParams(
|
|
2810
|
+
runtime,
|
|
2811
|
+
db,
|
|
2812
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN (?, ?)",
|
|
2813
|
+
[WATCH_OWNER_TABLE, WATCH_GATE_TABLE],
|
|
2814
|
+
);
|
|
2815
|
+
for (const { name } of tables) {
|
|
2816
|
+
const references = allParams(runtime, db, `PRAGMA foreign_key_list("${name.replace(/"/g, '""')}")`);
|
|
2817
|
+
if (references.some(reference => [WATCH_OWNER_TABLE, WATCH_GATE_TABLE]
|
|
2818
|
+
.includes(String(reference.table).toLowerCase()))) return true;
|
|
2819
|
+
}
|
|
2820
|
+
for (const table of [WATCH_OWNER_TABLE, WATCH_GATE_TABLE]) {
|
|
2821
|
+
if (allParams(runtime, db, `PRAGMA foreign_key_list("${table}")`).length > 0) return true;
|
|
2822
|
+
}
|
|
2823
|
+
return false;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// Extra uniqueness constraints (e.g. UNIQUE(repo) ON CONFLICT REPLACE) let a
|
|
2827
|
+
// later insert silently delete an earlier authority row, so anything beyond
|
|
2828
|
+
// the migration's primary-key indexes fails closed.
|
|
2829
|
+
function hasUnexpectedWatchAuthorityIndexes(runtime, db) {
|
|
2830
|
+
for (const table of [WATCH_OWNER_TABLE, WATCH_GATE_TABLE]) {
|
|
2831
|
+
const indexes = allParams(runtime, db, `PRAGMA index_list("${table}")`);
|
|
2832
|
+
if (indexes.some(index => index.origin !== 'pk')) return true;
|
|
2833
|
+
}
|
|
2834
|
+
return false;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function assertWatchOwnerSchema(runtime, db) {
|
|
2838
|
+
const rows = allParams(
|
|
2839
|
+
runtime,
|
|
2840
|
+
db,
|
|
2841
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?)",
|
|
2842
|
+
[WATCH_OWNER_TABLE, WATCH_GATE_TABLE],
|
|
2843
|
+
);
|
|
2844
|
+
const tables = new Set(rows.map(row => row.name));
|
|
2845
|
+
if (!tables.has(WATCH_OWNER_TABLE) || !tables.has(WATCH_GATE_TABLE)) {
|
|
2846
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority schema is not initialized; run Kernel migrations');
|
|
2847
|
+
}
|
|
2848
|
+
if (!hasExactWatchSchemaColumns(runtime, db, WATCH_OWNER_TABLE, WATCH_OWNER_SCHEMA_COLUMNS)
|
|
2849
|
+
|| !hasExactWatchSchemaColumns(runtime, db, WATCH_GATE_TABLE, WATCH_GATE_SCHEMA_COLUMNS)
|
|
2850
|
+
|| hasUnexpectedWatchAuthorityTriggers(runtime, db)
|
|
2851
|
+
|| hasUnexpectedWatchAuthorityIndexes(runtime, db)) {
|
|
2852
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority schema does not match the Kernel migration');
|
|
2853
|
+
}
|
|
2854
|
+
if (hasForbiddenWatchOwnerForeignKeys(runtime, db)) {
|
|
2855
|
+
throw watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority tables participate in a forbidden foreign key relationship');
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
const activeWatchOwnerConnections = new WeakSet();
|
|
2860
|
+
|
|
2861
|
+
function runWatchOwnerTransactionOnConnection(runtime, database, options, operation) {
|
|
2862
|
+
if (typeof operation !== 'function'
|
|
2863
|
+
|| operation.constructor?.name === 'AsyncFunction') {
|
|
2864
|
+
throw watchOwnerStoreError('INVALID_OPERATION', 'Watcher authority transactions require a synchronous operation');
|
|
2865
|
+
}
|
|
2866
|
+
if (activeWatchOwnerConnections.has(database)) {
|
|
2867
|
+
throw watchOwnerStoreError('INVALID_OPERATION', 'Watcher authority transaction re-entry on the same connection is forbidden');
|
|
2868
|
+
}
|
|
2869
|
+
activeWatchOwnerConnections.add(database);
|
|
2870
|
+
const timeout = watchOwnerBusyTimeout(options);
|
|
2871
|
+
let active = false;
|
|
2872
|
+
let committed = false;
|
|
2873
|
+
try {
|
|
2874
|
+
let previousTimeout;
|
|
2875
|
+
let timeoutInstalled = false;
|
|
2876
|
+
let result;
|
|
2877
|
+
let transactionError;
|
|
2878
|
+
let restorationError;
|
|
2879
|
+
try {
|
|
2880
|
+
previousTimeout = Number(queryOne(runtime, database, 'PRAGMA busy_timeout;').timeout) || 0;
|
|
2881
|
+
execSql(runtime, database, `PRAGMA busy_timeout=${timeout};`);
|
|
2882
|
+
timeoutInstalled = true;
|
|
2883
|
+
execSql(runtime, database, 'PRAGMA foreign_keys=ON;');
|
|
2884
|
+
execSql(runtime, database, 'BEGIN IMMEDIATE;');
|
|
2885
|
+
active = true;
|
|
2886
|
+
assertWatchOwnerSchema(runtime, database);
|
|
2887
|
+
result = operation(database, nestedOperation => runWatchOwnerTransactionOnConnection(
|
|
2888
|
+
runtime, database, options, nestedOperation,
|
|
2889
|
+
));
|
|
2890
|
+
if (result && typeof result.then === 'function') {
|
|
2891
|
+
throw watchOwnerStoreError('INVALID_OPERATION', 'Watcher authority transactions cannot return a thenable');
|
|
2892
|
+
}
|
|
2893
|
+
execSql(runtime, database, 'COMMIT;');
|
|
2894
|
+
active = false;
|
|
2895
|
+
committed = true;
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
if (active) rollbackTransaction(runtime, database);
|
|
2898
|
+
transactionError = /database is locked|SQLITE_BUSY/i.test(String(error?.message || ''))
|
|
2899
|
+
? watchOwnerStoreError('AUTHORITY_UNAVAILABLE', `Watcher authority remained busy after ${timeout}ms`, error)
|
|
2900
|
+
: error;
|
|
2901
|
+
} finally {
|
|
2902
|
+
if (timeoutInstalled) {
|
|
2903
|
+
try {
|
|
2904
|
+
execSql(runtime, database, `PRAGMA busy_timeout=${previousTimeout};`);
|
|
2905
|
+
} catch (error) {
|
|
2906
|
+
restorationError = watchOwnerStoreError('AUTHORITY_UNAVAILABLE', 'Watcher authority timeout restoration failed', error);
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
if (transactionError) throw transactionError;
|
|
2911
|
+
if (restorationError && !committed) throw restorationError;
|
|
2912
|
+
return result;
|
|
2913
|
+
} finally {
|
|
2914
|
+
activeWatchOwnerConnections.delete(database);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
function runWatchOwnerTransaction(runtime, databasePath, options, operation) {
|
|
2919
|
+
const validated = assertWatchOwnerDatabasePath(databasePath, options);
|
|
2920
|
+
const database = openWatchOwnerDatabase(runtime, validated);
|
|
2921
|
+
try {
|
|
2922
|
+
return runWatchOwnerTransactionOnConnection(runtime, database, options, operation);
|
|
2923
|
+
} finally {
|
|
2924
|
+
closeDatabase(database);
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
const WATCH_OWNER_PHASES = new Set(['starting', 'running', 'stop_requested', 'terminal_pending', 'complete', 'blocked']);
|
|
2929
|
+
const WATCH_OWNER_BLOCK_REASONS = new Set([
|
|
2930
|
+
'legacy_live_pid',
|
|
2931
|
+
'legacy_conflict',
|
|
2932
|
+
'legacy_unreadable',
|
|
2933
|
+
'legacy_lossy',
|
|
2934
|
+
'legacy_receipt_unverified',
|
|
2935
|
+
]);
|
|
2936
|
+
const WATCH_OWNER_GENERATION_OPERATIONS = new Set([
|
|
2937
|
+
'reserveReopened', 'bindRunning', 'heartbeat', 'requestStop', 'recordTerminal',
|
|
2938
|
+
'completeTerminal', 'abortStarting', 'releaseNonterminal', 'recoverDeadStarting',
|
|
2939
|
+
'recoverDeadWatcher', 'recheckLegacyBlocked',
|
|
2940
|
+
]);
|
|
2941
|
+
const WATCH_OWNER_MONOTONIC_OPERATIONS = new Set([
|
|
2942
|
+
'reserveReopened', 'bindRunning', 'heartbeat', 'requestStop', 'recordTerminal',
|
|
2943
|
+
'completeTerminal', 'recoverDeadStarting', 'recoverDeadWatcher', 'markLegacyBlocked',
|
|
2944
|
+
'recheckLegacyBlocked', 'importLegacyStarting', 'importLegacyComplete',
|
|
2945
|
+
]);
|
|
2946
|
+
const WATCH_OWNER_EVIDENCE_BOUND_OPERATIONS = new Set([
|
|
2947
|
+
'reserveReopened', 'recordTerminal', 'completeTerminal', 'abortStarting',
|
|
2948
|
+
'recoverDeadStarting', 'recoverDeadWatcher', 'markLegacyBlocked', 'recheckLegacyBlocked',
|
|
2949
|
+
'importLegacyStarting', 'importLegacyComplete',
|
|
2950
|
+
]);
|
|
2951
|
+
const WATCH_OWNER_SNAPSHOT_FIELDS = Object.freeze([
|
|
2952
|
+
'repo', 'pr', 'version', 'generation', 'phase', 'controller_pid', 'watcher_pid',
|
|
2953
|
+
'started_at', 'updated_at', 'heartbeat_at', 'terminal_receipt_id', 'block_reason',
|
|
2954
|
+
'legacy_evidence_hash',
|
|
2955
|
+
]);
|
|
2956
|
+
const WATCH_GATE_SNAPSHOT_FIELDS = Object.freeze([
|
|
2957
|
+
'singleton', 'state', 'snapshot_hash', 'conflict_code', 'updated_at',
|
|
2958
|
+
]);
|
|
2959
|
+
const WATCH_OWNER_MUTATION_FIELDS = Object.freeze([
|
|
2960
|
+
'repo', 'pr', 'controllerPid', 'watcherPid', 'expectedControllerPid', 'generation',
|
|
2961
|
+
'terminalReceiptId', 'expectedReceiptId', 'now', 'snapshotHash', 'legacyEvidenceHash',
|
|
2962
|
+
'blockReason', 'action', 'expectedSnapshot', 'expectedGate',
|
|
2963
|
+
]);
|
|
2964
|
+
const WATCH_GATE_STATES = new Set(['quarantined', 'conflict', 'complete']);
|
|
2965
|
+
const WATCH_GATE_CONFLICT_CODES = new Set([
|
|
2966
|
+
'legacy_identity_unmappable',
|
|
2967
|
+
'legacy_snapshot_changed',
|
|
2968
|
+
'legacy_owner_conflict',
|
|
2969
|
+
]);
|
|
2970
|
+
const WATCH_REPOSITORY = /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/;
|
|
2971
|
+
const WATCH_SHA256 = /^[0-9a-f]{64}$/;
|
|
2972
|
+
const WATCH_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
2973
|
+
const WATCH_OWNER_ENUMERATION_LIMIT = 4_096;
|
|
2974
|
+
const WATCH_OWNER_ENUMERATION_BYTES = 4 * 1024 * 1024;
|
|
2975
|
+
|
|
2976
|
+
function watchUtf8Bytes(value) {
|
|
2977
|
+
return typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : -1;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function watchTimestamp(value) {
|
|
2981
|
+
if (typeof value !== 'string' || watchUtf8Bytes(value) !== 24 || !WATCH_TIMESTAMP.test(value)) return false;
|
|
2982
|
+
try {
|
|
2983
|
+
return new Date(value).toISOString() === value;
|
|
2984
|
+
} catch {
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function positiveWatchPid(value) {
|
|
2990
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
function boundedWatchString(value, maxBytes) {
|
|
2994
|
+
const bytes = watchUtf8Bytes(value);
|
|
2995
|
+
return typeof value === 'string' && bytes > 0 && bytes <= maxBytes;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
function optionalWatchString(value, maxBytes) {
|
|
2999
|
+
return value == null || boundedWatchString(value, maxBytes);
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function validWatchRepository(value) {
|
|
3003
|
+
return boundedWatchString(value, 256) && WATCH_REPOSITORY.test(value);
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function validWatchHash(value) {
|
|
3007
|
+
return typeof value === 'string' && WATCH_SHA256.test(value);
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function validWatchOwnerRow(row) {
|
|
3011
|
+
if (!row || row.version !== 1 || !validWatchRepository(row.repo)
|
|
3012
|
+
|| !Number.isSafeInteger(row.pr) || row.pr <= 0
|
|
3013
|
+
|| !boundedWatchString(row.generation, 128)
|
|
3014
|
+
|| !WATCH_OWNER_PHASES.has(row.phase) || !watchTimestamp(row.started_at)
|
|
3015
|
+
|| !watchTimestamp(row.updated_at) || row.updated_at < row.started_at
|
|
3016
|
+
|| !optionalWatchString(row.terminal_receipt_id, 256)) return false;
|
|
3017
|
+
const controller = row.controller_pid;
|
|
3018
|
+
const watcher = row.watcher_pid;
|
|
3019
|
+
if ((controller != null && !positiveWatchPid(controller)) || (watcher != null && !positiveWatchPid(watcher))) return false;
|
|
3020
|
+
if (row.heartbeat_at != null && (!watchTimestamp(row.heartbeat_at)
|
|
3021
|
+
|| row.heartbeat_at < row.started_at || row.heartbeat_at > row.updated_at)) return false;
|
|
3022
|
+
if (row.legacy_evidence_hash != null && !validWatchHash(row.legacy_evidence_hash)) return false;
|
|
3023
|
+
if (row.phase === 'starting') {
|
|
3024
|
+
return controller != null && watcher == null && row.heartbeat_at == null
|
|
3025
|
+
&& row.terminal_receipt_id == null && row.block_reason == null;
|
|
3026
|
+
}
|
|
3027
|
+
if (row.phase === 'running' || row.phase === 'stop_requested') {
|
|
3028
|
+
return controller == null && watcher != null && row.heartbeat_at != null
|
|
3029
|
+
&& row.terminal_receipt_id == null && row.block_reason == null;
|
|
3030
|
+
}
|
|
3031
|
+
if (row.phase === 'terminal_pending') {
|
|
3032
|
+
return controller == null && watcher != null && row.heartbeat_at != null
|
|
3033
|
+
&& row.terminal_receipt_id != null && row.block_reason == null;
|
|
3034
|
+
}
|
|
3035
|
+
if (row.phase === 'complete') {
|
|
3036
|
+
return controller == null && watcher == null && row.heartbeat_at == null
|
|
3037
|
+
&& row.terminal_receipt_id != null && row.block_reason == null;
|
|
3038
|
+
}
|
|
3039
|
+
if (!WATCH_OWNER_BLOCK_REASONS.has(row.block_reason) || row.legacy_evidence_hash == null
|
|
3040
|
+
|| controller != null || row.heartbeat_at != null) return false;
|
|
3041
|
+
return row.block_reason === 'legacy_live_pid' ? watcher != null : watcher == null;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
function validWatchGateRow(row) {
|
|
3045
|
+
if (!row || row.singleton !== 1 || !WATCH_GATE_STATES.has(row.state)
|
|
3046
|
+
|| !watchTimestamp(row.updated_at)) return false;
|
|
3047
|
+
if (row.state === 'quarantined') {
|
|
3048
|
+
return (row.snapshot_hash == null || validWatchHash(row.snapshot_hash)) && row.conflict_code == null;
|
|
3049
|
+
}
|
|
3050
|
+
if (!validWatchHash(row.snapshot_hash)) return false;
|
|
3051
|
+
return row.state === 'complete'
|
|
3052
|
+
? row.conflict_code == null
|
|
3053
|
+
: WATCH_GATE_CONFLICT_CODES.has(row.conflict_code);
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
function copyWatchGateSnapshot(gate) {
|
|
3057
|
+
if (gate === null) return null;
|
|
3058
|
+
try {
|
|
3059
|
+
const copy = Object.fromEntries(WATCH_GATE_SNAPSHOT_FIELDS.map(field => [field, gate[field]]));
|
|
3060
|
+
return validWatchGateRow(copy) ? copy : undefined;
|
|
3061
|
+
} catch {
|
|
3062
|
+
return undefined;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function sameWatchGateSnapshot(current, expected) {
|
|
3067
|
+
if (current == null || expected == null) return current == null && expected == null;
|
|
3068
|
+
return WATCH_GATE_SNAPSHOT_FIELDS.every(field => Object.is(current[field], expected[field]));
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
function readWatchOwnerRow(runtime, database, input) {
|
|
3072
|
+
return allParams(
|
|
3073
|
+
runtime,
|
|
3074
|
+
database,
|
|
3075
|
+
`SELECT * FROM ${WATCH_OWNER_TABLE} WHERE repo = ? AND pr = ?`,
|
|
3076
|
+
[input.repo, input.pr],
|
|
3077
|
+
)[0] || null;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
function saveWatchOwnerRow(runtime, database, row, insert = false) {
|
|
3081
|
+
const columns = [
|
|
3082
|
+
row.repo, row.pr, row.version, row.generation, row.phase, row.controller_pid,
|
|
3083
|
+
row.watcher_pid, row.started_at, row.updated_at, row.heartbeat_at,
|
|
3084
|
+
row.terminal_receipt_id, row.block_reason, row.legacy_evidence_hash,
|
|
3085
|
+
];
|
|
3086
|
+
if (insert) {
|
|
3087
|
+
runParams(runtime, database, `INSERT INTO ${WATCH_OWNER_TABLE}
|
|
3088
|
+
(repo, pr, version, generation, phase, controller_pid, watcher_pid, started_at,
|
|
3089
|
+
updated_at, heartbeat_at, terminal_receipt_id, block_reason, legacy_evidence_hash)
|
|
3090
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, columns);
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
runParams(runtime, database, `UPDATE ${WATCH_OWNER_TABLE} SET
|
|
3094
|
+
version = ?, generation = ?, phase = ?, controller_pid = ?, watcher_pid = ?,
|
|
3095
|
+
started_at = ?, updated_at = ?, heartbeat_at = ?, terminal_receipt_id = ?,
|
|
3096
|
+
block_reason = ?, legacy_evidence_hash = ? WHERE repo = ? AND pr = ?`, [
|
|
3097
|
+
row.version, row.generation, row.phase, row.controller_pid, row.watcher_pid,
|
|
3098
|
+
row.started_at, row.updated_at, row.heartbeat_at, row.terminal_receipt_id,
|
|
3099
|
+
row.block_reason, row.legacy_evidence_hash, row.repo, row.pr,
|
|
3100
|
+
]);
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
function deleteWatchOwnerRow(runtime, database, input) {
|
|
3104
|
+
runParams(runtime, database, `DELETE FROM ${WATCH_OWNER_TABLE} WHERE repo = ? AND pr = ?`, [input.repo, input.pr]);
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
function watchOwnerResult(ok, changed, reason, row = null) {
|
|
3108
|
+
return { ok, changed, reason, row };
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function watchOwnerMismatch(current, input, phases) {
|
|
3112
|
+
if (!current) return 'absent';
|
|
3113
|
+
if (!validWatchOwnerRow(current)) return 'corrupt';
|
|
3114
|
+
if (input.generation != null && current.generation !== input.generation) return 'generation_mismatch';
|
|
3115
|
+
if (phases && ![].concat(phases).includes(current.phase)) return 'phase_mismatch';
|
|
3116
|
+
return null;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
function watchOwnerUpdatedAtRegresses(operation, current, input) {
|
|
3120
|
+
if (!current || !WATCH_OWNER_MONOTONIC_OPERATIONS.has(operation)) return false;
|
|
3121
|
+
if (operation === 'recheckLegacyBlocked' && input.action === 'release') return false;
|
|
3122
|
+
return [current.updated_at, current.heartbeat_at]
|
|
3123
|
+
.filter(Boolean)
|
|
3124
|
+
.some(timestamp => Date.parse(input.now) < Date.parse(timestamp));
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
function sameWatchOwnerSnapshot(current, expected) {
|
|
3128
|
+
if (current == null || expected == null) return current == null && expected == null;
|
|
3129
|
+
return WATCH_OWNER_SNAPSHOT_FIELDS.every(field => Object.is(current[field], expected[field]));
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function startingWatchOwnerRow(input, generation = randomUUID()) {
|
|
3133
|
+
return {
|
|
3134
|
+
repo: input.repo,
|
|
3135
|
+
pr: input.pr,
|
|
3136
|
+
version: 1,
|
|
3137
|
+
generation,
|
|
3138
|
+
phase: 'starting',
|
|
3139
|
+
controller_pid: input.controllerPid,
|
|
3140
|
+
watcher_pid: null,
|
|
3141
|
+
started_at: input.now,
|
|
3142
|
+
updated_at: input.now,
|
|
3143
|
+
heartbeat_at: null,
|
|
3144
|
+
terminal_receipt_id: null,
|
|
3145
|
+
block_reason: null,
|
|
3146
|
+
legacy_evidence_hash: null,
|
|
3147
|
+
};
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function validWatchOwnerIdentity(input) {
|
|
3151
|
+
return validWatchRepository(input?.repo) && Number.isSafeInteger(input.pr) && input.pr > 0;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
function captureWatchOwnerIdentity(input) {
|
|
3155
|
+
try {
|
|
3156
|
+
return { repo: input?.repo, pr: input?.pr };
|
|
3157
|
+
} catch {
|
|
3158
|
+
return null;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function validWatchOwnerMutationInput(operation, input) {
|
|
3163
|
+
if (!validWatchOwnerIdentity(input)) return false;
|
|
3164
|
+
if (WATCH_OWNER_EVIDENCE_BOUND_OPERATIONS.has(operation)
|
|
3165
|
+
&& (!Object.prototype.hasOwnProperty.call(input, 'expectedSnapshot')
|
|
3166
|
+
|| input.expectedSnapshot === undefined)) return false;
|
|
3167
|
+
if (!['abortStarting', 'releaseNonterminal'].includes(operation) && !watchTimestamp(input.now)) return false;
|
|
3168
|
+
if (!['controllerPid', 'watcherPid', 'expectedControllerPid'].every(field => (
|
|
3169
|
+
input[field] == null || positiveWatchPid(input[field])
|
|
3170
|
+
))) return false;
|
|
3171
|
+
if (!['generation', 'terminalReceiptId', 'expectedReceiptId'].every(field => (
|
|
3172
|
+
input[field] == null || boundedWatchString(input[field], field === 'generation' ? 128 : 256)
|
|
3173
|
+
))) return false;
|
|
3174
|
+
if (WATCH_OWNER_GENERATION_OPERATIONS.has(operation)
|
|
3175
|
+
&& !boundedWatchString(input.generation, 128)) return false;
|
|
3176
|
+
if (!['snapshotHash', 'legacyEvidenceHash'].every(field => (
|
|
3177
|
+
input[field] == null || validWatchHash(input[field])
|
|
3178
|
+
))) return false;
|
|
3179
|
+
if (input.blockReason != null && !WATCH_OWNER_BLOCK_REASONS.has(input.blockReason)) return false;
|
|
3180
|
+
if (operation === 'recheckLegacyBlocked') {
|
|
3181
|
+
return ['release', 'complete'].includes(input.action);
|
|
3182
|
+
}
|
|
3183
|
+
return input.action == null || ['release', 'complete'].includes(input.action);
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
function captureWatchOwnerMutationInput(input) {
|
|
3187
|
+
try {
|
|
3188
|
+
const ownFields = new Set(Object.getOwnPropertyNames(input));
|
|
3189
|
+
const prepared = {};
|
|
3190
|
+
for (const field of WATCH_OWNER_MUTATION_FIELDS) {
|
|
3191
|
+
if (ownFields.has(field)) prepared[field] = input[field];
|
|
3192
|
+
}
|
|
3193
|
+
return prepared;
|
|
3194
|
+
} catch {
|
|
3195
|
+
return null;
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
function copyWatchOwnerSnapshot(snapshot) {
|
|
3200
|
+
if (snapshot === null) return null;
|
|
3201
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return undefined;
|
|
3202
|
+
try {
|
|
3203
|
+
Object.keys(snapshot);
|
|
3204
|
+
const copy = {};
|
|
3205
|
+
for (const field of WATCH_OWNER_SNAPSHOT_FIELDS) {
|
|
3206
|
+
const value = snapshot[field];
|
|
3207
|
+
if (value !== null && !['number', 'string'].includes(typeof value)) return undefined;
|
|
3208
|
+
copy[field] = value;
|
|
3209
|
+
}
|
|
3210
|
+
return validWatchOwnerRow(copy) ? Object.freeze(copy) : undefined;
|
|
3211
|
+
} catch {
|
|
3212
|
+
return undefined;
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
function prepareWatchOwnerMutationInput(operation, input) {
|
|
3217
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
3218
|
+
const prepared = captureWatchOwnerMutationInput(input);
|
|
3219
|
+
if (!prepared) return null;
|
|
3220
|
+
if (!validWatchOwnerMutationInput(operation, prepared)) return null;
|
|
3221
|
+
if (Object.prototype.hasOwnProperty.call(prepared, 'expectedSnapshot')) {
|
|
3222
|
+
const expectedSnapshot = copyWatchOwnerSnapshot(prepared.expectedSnapshot);
|
|
3223
|
+
if (expectedSnapshot === undefined) return null;
|
|
3224
|
+
prepared.expectedSnapshot = expectedSnapshot;
|
|
3225
|
+
}
|
|
3226
|
+
if (Object.prototype.hasOwnProperty.call(prepared, 'expectedGate')) {
|
|
3227
|
+
const expectedGate = copyWatchGateSnapshot(prepared.expectedGate);
|
|
3228
|
+
if (expectedGate === undefined) return null;
|
|
3229
|
+
prepared.expectedGate = expectedGate;
|
|
3230
|
+
}
|
|
3231
|
+
return Object.freeze(prepared);
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
function applyWatchOwnerOperation(runtime, databasePath, options, operation, rawInput) {
|
|
3235
|
+
const input = prepareWatchOwnerMutationInput(operation, rawInput);
|
|
3236
|
+
if (!input) return watchOwnerResult(false, false, 'invalid_input');
|
|
3237
|
+
return runWatchOwnerTransaction(runtime, databasePath, options, database => {
|
|
3238
|
+
const current = readWatchOwnerRow(runtime, database, input);
|
|
3239
|
+
if (current && !validWatchOwnerRow(current)) return watchOwnerResult(false, false, 'corrupt', current);
|
|
3240
|
+
if (Object.prototype.hasOwnProperty.call(input, 'expectedSnapshot')
|
|
3241
|
+
&& !sameWatchOwnerSnapshot(current, input.expectedSnapshot)) {
|
|
3242
|
+
return watchOwnerResult(false, false, 'stale_evidence', current);
|
|
3243
|
+
}
|
|
3244
|
+
if (Object.prototype.hasOwnProperty.call(input, 'expectedGate')) {
|
|
3245
|
+
const currentGate = allParams(runtime, database,
|
|
3246
|
+
`SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
|
|
3247
|
+
if (currentGate && !validWatchGateRow(currentGate)) {
|
|
3248
|
+
return watchOwnerResult(false, false, 'corrupt', current);
|
|
3249
|
+
}
|
|
3250
|
+
if (!sameWatchGateSnapshot(currentGate, input.expectedGate)) {
|
|
3251
|
+
return watchOwnerResult(false, false, 'stale_evidence', current);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
if (watchOwnerUpdatedAtRegresses(operation, current, input)) {
|
|
3255
|
+
return watchOwnerResult(false, false, 'stale_evidence', current);
|
|
3256
|
+
}
|
|
3257
|
+
let row;
|
|
3258
|
+
let mismatch;
|
|
3259
|
+
switch (operation) {
|
|
3260
|
+
case 'reserveStarting':
|
|
3261
|
+
if (current) return watchOwnerResult(false, false, 'busy', current);
|
|
3262
|
+
row = startingWatchOwnerRow(input);
|
|
3263
|
+
if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
|
|
3264
|
+
saveWatchOwnerRow(runtime, database, row, true);
|
|
3265
|
+
return watchOwnerResult(true, true, 'acquired', row);
|
|
3266
|
+
case 'reserveReopened':
|
|
3267
|
+
mismatch = watchOwnerMismatch(current, input, 'complete');
|
|
3268
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3269
|
+
if (current.terminal_receipt_id !== input.expectedReceiptId) return watchOwnerResult(false, false, 'receipt_mismatch', current);
|
|
3270
|
+
row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: current.legacy_evidence_hash };
|
|
3271
|
+
if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition', current);
|
|
3272
|
+
saveWatchOwnerRow(runtime, database, row);
|
|
3273
|
+
return watchOwnerResult(true, true, 'reopened', row);
|
|
3274
|
+
case 'bindRunning':
|
|
3275
|
+
if (current?.phase === 'running' && current.generation === input.generation
|
|
3276
|
+
&& current.watcher_pid === input.watcherPid) return watchOwnerResult(true, false, 'idempotent', current);
|
|
3277
|
+
mismatch = watchOwnerMismatch(current, input, 'starting');
|
|
3278
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3279
|
+
if (current.controller_pid !== input.controllerPid) return watchOwnerResult(false, false, 'controller_pid_mismatch', current);
|
|
3280
|
+
row = { ...current, phase: 'running', controller_pid: null, watcher_pid: input.watcherPid,
|
|
3281
|
+
updated_at: input.now, heartbeat_at: input.now };
|
|
3282
|
+
break;
|
|
3283
|
+
case 'heartbeat':
|
|
3284
|
+
mismatch = watchOwnerMismatch(current, input, ['running', 'stop_requested']);
|
|
3285
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3286
|
+
if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3287
|
+
if (Date.parse(input.now) < Math.max(Date.parse(current.updated_at), Date.parse(current.heartbeat_at))) {
|
|
3288
|
+
return watchOwnerResult(false, false, 'stale_evidence', current);
|
|
3289
|
+
}
|
|
3290
|
+
row = { ...current, updated_at: input.now, heartbeat_at: input.now };
|
|
3291
|
+
break;
|
|
3292
|
+
case 'requestStop':
|
|
3293
|
+
if (current?.phase === 'stop_requested' && current.generation === input.generation
|
|
3294
|
+
&& current.watcher_pid === input.watcherPid) return watchOwnerResult(true, false, 'idempotent', current);
|
|
3295
|
+
mismatch = watchOwnerMismatch(current, input, 'running');
|
|
3296
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3297
|
+
if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3298
|
+
row = { ...current, phase: 'stop_requested', updated_at: input.now };
|
|
3299
|
+
break;
|
|
3300
|
+
case 'recordTerminal':
|
|
3301
|
+
if (current?.phase === 'terminal_pending' && current.generation === input.generation
|
|
3302
|
+
&& current.watcher_pid === input.watcherPid
|
|
3303
|
+
&& current.terminal_receipt_id === input.terminalReceiptId) return watchOwnerResult(true, false, 'idempotent', current);
|
|
3304
|
+
mismatch = watchOwnerMismatch(current, input, ['running', 'stop_requested']);
|
|
3305
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3306
|
+
if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3307
|
+
row = { ...current, phase: 'terminal_pending', terminal_receipt_id: input.terminalReceiptId, updated_at: input.now };
|
|
3308
|
+
break;
|
|
3309
|
+
case 'completeTerminal':
|
|
3310
|
+
if (current?.phase === 'complete' && current.generation === input.generation
|
|
3311
|
+
&& current.terminal_receipt_id === input.terminalReceiptId) return watchOwnerResult(true, false, 'idempotent', current);
|
|
3312
|
+
mismatch = watchOwnerMismatch(current, input, 'terminal_pending');
|
|
3313
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3314
|
+
if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3315
|
+
if (current.terminal_receipt_id !== input.terminalReceiptId) return watchOwnerResult(false, false, 'receipt_mismatch', current);
|
|
3316
|
+
row = { ...current, phase: 'complete', watcher_pid: null, heartbeat_at: null, updated_at: input.now };
|
|
3317
|
+
break;
|
|
3318
|
+
case 'abortStarting':
|
|
3319
|
+
mismatch = watchOwnerMismatch(current, input, 'starting');
|
|
3320
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3321
|
+
if (current.controller_pid !== input.controllerPid) return watchOwnerResult(false, false, 'controller_pid_mismatch', current);
|
|
3322
|
+
deleteWatchOwnerRow(runtime, database, input);
|
|
3323
|
+
return watchOwnerResult(true, true, 'aborted');
|
|
3324
|
+
case 'releaseNonterminal':
|
|
3325
|
+
mismatch = watchOwnerMismatch(current, input, 'stop_requested');
|
|
3326
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3327
|
+
if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3328
|
+
deleteWatchOwnerRow(runtime, database, input);
|
|
3329
|
+
return watchOwnerResult(true, true, 'released');
|
|
3330
|
+
case 'recoverDeadStarting':
|
|
3331
|
+
mismatch = watchOwnerMismatch(current, input, 'starting');
|
|
3332
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3333
|
+
if (current.controller_pid !== input.expectedControllerPid) return watchOwnerResult(false, false, 'controller_pid_mismatch', current);
|
|
3334
|
+
row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: current.legacy_evidence_hash };
|
|
3335
|
+
break;
|
|
3336
|
+
case 'recoverDeadWatcher':
|
|
3337
|
+
// `stop_requested` is a nonterminal watcher-owned phase exactly like
|
|
3338
|
+
// `running`; a dead watcher in either state recovers into a fresh
|
|
3339
|
+
// `starting` row.
|
|
3340
|
+
mismatch = watchOwnerMismatch(current, input, ['running', 'stop_requested']);
|
|
3341
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3342
|
+
if (current.watcher_pid !== input.watcherPid) return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3343
|
+
row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: current.legacy_evidence_hash };
|
|
3344
|
+
break;
|
|
3345
|
+
case 'importLegacyStarting': {
|
|
3346
|
+
const gate = allParams(runtime, database,
|
|
3347
|
+
`SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
|
|
3348
|
+
if (!validWatchGateRow(gate)) return watchOwnerResult(false, false, gate ? 'corrupt' : 'gate_mismatch', current);
|
|
3349
|
+
if (gate.state !== 'quarantined' || gate.snapshot_hash !== input.snapshotHash) {
|
|
3350
|
+
return watchOwnerResult(false, false, 'gate_mismatch', current);
|
|
3351
|
+
}
|
|
3352
|
+
if (current) {
|
|
3353
|
+
const same = validWatchOwnerRow(current) && current.phase === 'starting'
|
|
3354
|
+
&& current.controller_pid === input.controllerPid
|
|
3355
|
+
&& current.legacy_evidence_hash === input.legacyEvidenceHash;
|
|
3356
|
+
return watchOwnerResult(same, false, same ? 'idempotent' : 'owner_conflict', current);
|
|
3357
|
+
}
|
|
3358
|
+
row = { ...startingWatchOwnerRow(input), legacy_evidence_hash: input.legacyEvidenceHash };
|
|
3359
|
+
if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
|
|
3360
|
+
saveWatchOwnerRow(runtime, database, row, true);
|
|
3361
|
+
return watchOwnerResult(true, true, 'imported', row);
|
|
3362
|
+
}
|
|
3363
|
+
case 'markLegacyBlocked':
|
|
3364
|
+
{
|
|
3365
|
+
const gate = allParams(runtime, database,
|
|
3366
|
+
`SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
|
|
3367
|
+
if (!validWatchGateRow(gate)) return watchOwnerResult(false, false, gate ? 'corrupt' : 'gate_mismatch', current);
|
|
3368
|
+
if (gate.state !== 'quarantined' || gate.snapshot_hash !== input.snapshotHash) {
|
|
3369
|
+
return watchOwnerResult(false, false, 'gate_mismatch', current);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
if (current) {
|
|
3373
|
+
const same = validWatchOwnerRow(current) && current.phase === 'blocked'
|
|
3374
|
+
&& current.controller_pid == null && current.watcher_pid === input.watcherPid
|
|
3375
|
+
&& current.terminal_receipt_id === input.terminalReceiptId
|
|
3376
|
+
&& current.block_reason === input.blockReason
|
|
3377
|
+
&& current.legacy_evidence_hash === input.legacyEvidenceHash;
|
|
3378
|
+
return watchOwnerResult(same, false, same ? 'idempotent' : 'owner_conflict', current);
|
|
3379
|
+
}
|
|
3380
|
+
row = { ...startingWatchOwnerRow(input), phase: 'blocked', controller_pid: null,
|
|
3381
|
+
watcher_pid: input.watcherPid, terminal_receipt_id: input.terminalReceiptId,
|
|
3382
|
+
block_reason: input.blockReason, legacy_evidence_hash: input.legacyEvidenceHash };
|
|
3383
|
+
if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
|
|
3384
|
+
saveWatchOwnerRow(runtime, database, row, true);
|
|
3385
|
+
return watchOwnerResult(true, true, 'blocked', row);
|
|
3386
|
+
case 'recheckLegacyBlocked':
|
|
3387
|
+
mismatch = watchOwnerMismatch(current, input, 'blocked');
|
|
3388
|
+
if (mismatch) return watchOwnerResult(false, false, mismatch, current);
|
|
3389
|
+
if (current.legacy_evidence_hash !== input.legacyEvidenceHash) return watchOwnerResult(false, false, 'evidence_mismatch', current);
|
|
3390
|
+
if ((current.block_reason === 'legacy_live_pid' || input.watcherPid != null)
|
|
3391
|
+
&& current.watcher_pid !== input.watcherPid) {
|
|
3392
|
+
return watchOwnerResult(false, false, 'pid_mismatch', current);
|
|
3393
|
+
}
|
|
3394
|
+
if (input.action === 'release') {
|
|
3395
|
+
if (current.block_reason !== 'legacy_live_pid') {
|
|
3396
|
+
return watchOwnerResult(false, false, 'invalid_transition', current);
|
|
3397
|
+
}
|
|
3398
|
+
deleteWatchOwnerRow(runtime, database, input);
|
|
3399
|
+
return watchOwnerResult(true, true, 'released');
|
|
3400
|
+
}
|
|
3401
|
+
row = { ...current, phase: 'complete', watcher_pid: null, heartbeat_at: null,
|
|
3402
|
+
terminal_receipt_id: input.terminalReceiptId, block_reason: null, updated_at: input.now };
|
|
3403
|
+
break;
|
|
3404
|
+
case 'importLegacyComplete': {
|
|
3405
|
+
const gate = allParams(runtime, database,
|
|
3406
|
+
`SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
|
|
3407
|
+
if (!validWatchGateRow(gate)) return watchOwnerResult(false, false, gate ? 'corrupt' : 'gate_mismatch', current);
|
|
3408
|
+
if (gate.state !== 'quarantined' || gate.snapshot_hash !== input.snapshotHash) {
|
|
3409
|
+
return watchOwnerResult(false, false, 'gate_mismatch', current);
|
|
3410
|
+
}
|
|
3411
|
+
if (current) {
|
|
3412
|
+
const same = validWatchOwnerRow(current) && current.phase === 'complete'
|
|
3413
|
+
&& current.legacy_evidence_hash === input.legacyEvidenceHash
|
|
3414
|
+
&& current.terminal_receipt_id === input.terminalReceiptId;
|
|
3415
|
+
return watchOwnerResult(same, false, same ? 'idempotent' : 'owner_conflict', current);
|
|
3416
|
+
}
|
|
3417
|
+
row = { ...startingWatchOwnerRow(input), phase: 'complete', controller_pid: null,
|
|
3418
|
+
terminal_receipt_id: input.terminalReceiptId, legacy_evidence_hash: input.legacyEvidenceHash };
|
|
3419
|
+
if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition');
|
|
3420
|
+
saveWatchOwnerRow(runtime, database, row, true);
|
|
3421
|
+
return watchOwnerResult(true, true, 'imported', row);
|
|
3422
|
+
}
|
|
3423
|
+
default:
|
|
3424
|
+
throw watchOwnerStoreError('INVALID_OPERATION', 'Unknown watcher authority operation');
|
|
3425
|
+
}
|
|
3426
|
+
if (!validWatchOwnerRow(row)) return watchOwnerResult(false, false, 'invalid_transition', current);
|
|
3427
|
+
saveWatchOwnerRow(runtime, database, row);
|
|
3428
|
+
return watchOwnerResult(true, true, {
|
|
3429
|
+
bindRunning: 'bound',
|
|
3430
|
+
heartbeat: 'heartbeat',
|
|
3431
|
+
requestStop: 'stop_requested',
|
|
3432
|
+
recordTerminal: 'terminal_pending',
|
|
3433
|
+
completeTerminal: 'complete',
|
|
3434
|
+
recoverDeadStarting: 'recovered',
|
|
3435
|
+
recoverDeadWatcher: 'recovered',
|
|
3436
|
+
recheckLegacyBlocked: 'complete',
|
|
3437
|
+
}[operation], row);
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
function readWatchOwner(runtime, databasePath, options, input) {
|
|
3442
|
+
const identity = captureWatchOwnerIdentity(input);
|
|
3443
|
+
if (!identity || !validWatchOwnerIdentity(identity)) {
|
|
3444
|
+
return watchOwnerResult(false, false, 'invalid_input');
|
|
3445
|
+
}
|
|
3446
|
+
return runWatchOwnerTransaction(runtime, databasePath, options, database => {
|
|
3447
|
+
const row = readWatchOwnerRow(runtime, database, identity);
|
|
3448
|
+
if (!row) return watchOwnerResult(true, false, 'absent');
|
|
3449
|
+
return validWatchOwnerRow(row)
|
|
3450
|
+
? watchOwnerResult(true, false, 'read', row)
|
|
3451
|
+
: watchOwnerResult(false, false, 'corrupt', row);
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
function listWatchOwners(runtime, databasePath, options) {
|
|
3456
|
+
return runWatchOwnerTransaction(runtime, databasePath, options, database => {
|
|
3457
|
+
const rows = allParams(runtime, database,
|
|
3458
|
+
`SELECT * FROM ${WATCH_OWNER_TABLE} ORDER BY repo, pr LIMIT ?`,
|
|
3459
|
+
[WATCH_OWNER_ENUMERATION_LIMIT + 1]);
|
|
3460
|
+
if (rows.length > WATCH_OWNER_ENUMERATION_LIMIT) {
|
|
3461
|
+
return { ok: false, changed: false, reason: 'enumeration_overflow', rows: [] };
|
|
3462
|
+
}
|
|
3463
|
+
let bytes = 0;
|
|
3464
|
+
for (const row of rows) {
|
|
3465
|
+
if (!validWatchOwnerRow(row)) return { ok: false, changed: false, reason: 'corrupt', rows: [] };
|
|
3466
|
+
bytes += Buffer.byteLength(JSON.stringify(row), 'utf8');
|
|
3467
|
+
if (bytes > WATCH_OWNER_ENUMERATION_BYTES) {
|
|
3468
|
+
return { ok: false, changed: false, reason: 'enumeration_overflow', rows: [] };
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
return { ok: true, changed: false, reason: 'read', rows };
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
function readWatchGate(runtime, databasePath, options) {
|
|
3476
|
+
return runWatchOwnerTransaction(runtime, databasePath, options, database => {
|
|
3477
|
+
const gate = allParams(runtime, database,
|
|
3478
|
+
`SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
|
|
3479
|
+
if (!gate) return { ok: false, changed: false, reason: 'absent', gate: null };
|
|
3480
|
+
return validWatchGateRow(gate)
|
|
3481
|
+
? { ok: true, changed: false, reason: 'read', gate }
|
|
3482
|
+
: { ok: false, changed: false, reason: 'corrupt', gate };
|
|
3483
|
+
});
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
function validWatchGateMutationInput(operation, input) {
|
|
3487
|
+
if (!input || !watchTimestamp(input.now)) return false;
|
|
3488
|
+
if (operation === 'publishQuarantine') return true;
|
|
3489
|
+
if (operation === 'retryConflict') {
|
|
3490
|
+
return validWatchHash(input.expectedSnapshotHash)
|
|
3491
|
+
&& WATCH_GATE_CONFLICT_CODES.has(input.expectedConflictCode)
|
|
3492
|
+
&& validWatchHash(input.replacementSnapshotHash)
|
|
3493
|
+
&& input.replacementSnapshotHash !== input.expectedSnapshotHash;
|
|
3494
|
+
}
|
|
3495
|
+
if (!validWatchHash(input.snapshotHash)) return false;
|
|
3496
|
+
return operation !== 'publishConflict' || WATCH_GATE_CONFLICT_CODES.has(input.conflictCode);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
function captureWatchGateMutationInput(operation, input) {
|
|
3500
|
+
if (!input) return null;
|
|
3501
|
+
try {
|
|
3502
|
+
const captured = { now: input.now };
|
|
3503
|
+
if (Object.prototype.hasOwnProperty.call(input, 'expectedGate')) {
|
|
3504
|
+
const expectedGate = copyWatchGateSnapshot(input.expectedGate);
|
|
3505
|
+
if (expectedGate === undefined) return null;
|
|
3506
|
+
captured.expectedGate = expectedGate;
|
|
3507
|
+
}
|
|
3508
|
+
if (operation === 'retryConflict') {
|
|
3509
|
+
captured.expectedSnapshotHash = input.expectedSnapshotHash;
|
|
3510
|
+
captured.expectedConflictCode = input.expectedConflictCode;
|
|
3511
|
+
captured.replacementSnapshotHash = input.replacementSnapshotHash;
|
|
3512
|
+
} else {
|
|
3513
|
+
if (operation !== 'publishQuarantine') captured.snapshotHash = input.snapshotHash;
|
|
3514
|
+
if (operation === 'publishConflict') captured.conflictCode = input.conflictCode;
|
|
3515
|
+
}
|
|
3516
|
+
return Object.freeze(captured);
|
|
3517
|
+
} catch {
|
|
3518
|
+
return null;
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
function applyWatchGateOperation(runtime, databasePath, options, operation, input) {
|
|
3523
|
+
input = captureWatchGateMutationInput(operation, input);
|
|
3524
|
+
if (!validWatchGateMutationInput(operation, input)) {
|
|
3525
|
+
return { ok: false, changed: false, reason: 'invalid_input', gate: null };
|
|
3526
|
+
}
|
|
3527
|
+
return runWatchOwnerTransaction(runtime, databasePath, options, database => {
|
|
3528
|
+
const current = allParams(runtime, database,
|
|
3529
|
+
`SELECT * FROM ${WATCH_GATE_TABLE} WHERE singleton = 1`)[0] || null;
|
|
3530
|
+
if (current && !validWatchGateRow(current)) return { ok: false, changed: false, reason: 'corrupt', gate: current };
|
|
3531
|
+
if (Object.prototype.hasOwnProperty.call(input, 'expectedGate')
|
|
3532
|
+
&& !sameWatchGateSnapshot(current, input.expectedGate)) {
|
|
3533
|
+
return { ok: false, changed: false, reason: 'stale_evidence', gate: current };
|
|
3534
|
+
}
|
|
3535
|
+
if (current && input.now < current.updated_at) {
|
|
3536
|
+
return { ok: false, changed: false, reason: 'stale_evidence', gate: current };
|
|
3537
|
+
}
|
|
3538
|
+
if (operation === 'publishQuarantine') {
|
|
3539
|
+
if (current) return { ok: current.state === 'quarantined', changed: false,
|
|
3540
|
+
reason: current.state === 'quarantined' ? 'idempotent' : 'gate_conflict', gate: current };
|
|
3541
|
+
runParams(runtime, database, `INSERT INTO ${WATCH_GATE_TABLE}
|
|
3542
|
+
(singleton, state, snapshot_hash, conflict_code, updated_at) VALUES (1, 'quarantined', NULL, NULL, ?)`, [input.now]);
|
|
3543
|
+
return { ok: true, changed: true, reason: 'quarantined', gate: {
|
|
3544
|
+
singleton: 1, state: 'quarantined', snapshot_hash: null, conflict_code: null, updated_at: input.now,
|
|
3545
|
+
} };
|
|
3546
|
+
}
|
|
3547
|
+
if (!current) {
|
|
3548
|
+
return { ok: false, changed: false, reason: 'absent', gate: null };
|
|
3549
|
+
}
|
|
3550
|
+
if (operation === 'bindSnapshot') {
|
|
3551
|
+
if (current.state !== 'quarantined') return { ok: false, changed: false, reason: 'phase_mismatch', gate: current };
|
|
3552
|
+
if (current.snapshot_hash === input.snapshotHash) return { ok: true, changed: false, reason: 'idempotent', gate: current };
|
|
3553
|
+
if (current.snapshot_hash != null) return { ok: false, changed: false, reason: 'snapshot_mismatch', gate: current };
|
|
3554
|
+
runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE} SET snapshot_hash = ?, updated_at = ? WHERE singleton = 1`,
|
|
3555
|
+
[input.snapshotHash, input.now]);
|
|
3556
|
+
return { ok: true, changed: true, reason: 'bound', gate: { ...current, snapshot_hash: input.snapshotHash, updated_at: input.now } };
|
|
3557
|
+
}
|
|
3558
|
+
if (operation === 'publishConflict') {
|
|
3559
|
+
if (!WATCH_SHA256.test(input.snapshotHash) || !WATCH_GATE_CONFLICT_CODES.has(input.conflictCode)) {
|
|
3560
|
+
return { ok: false, changed: false, reason: 'invalid_conflict', gate: current };
|
|
3561
|
+
}
|
|
3562
|
+
if (current.state === 'conflict') {
|
|
3563
|
+
const idempotent = current.snapshot_hash === input.snapshotHash
|
|
3564
|
+
&& current.conflict_code === input.conflictCode;
|
|
3565
|
+
return { ok: idempotent, changed: false,
|
|
3566
|
+
reason: idempotent ? 'idempotent' : 'conflict_mismatch', gate: current };
|
|
3567
|
+
}
|
|
3568
|
+
if (current.state !== 'quarantined') return { ok: false, changed: false, reason: 'phase_mismatch', gate: current };
|
|
3569
|
+
if (current.snapshot_hash != null && current.snapshot_hash !== input.snapshotHash) {
|
|
3570
|
+
return { ok: false, changed: false, reason: 'snapshot_mismatch', gate: current };
|
|
3571
|
+
}
|
|
3572
|
+
runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE} SET state = 'conflict', snapshot_hash = ?, conflict_code = ?, updated_at = ? WHERE singleton = 1`,
|
|
3573
|
+
[input.snapshotHash, input.conflictCode, input.now]);
|
|
3574
|
+
return { ok: true, changed: true, reason: 'conflict', gate: { ...current, state: 'conflict', snapshot_hash: input.snapshotHash,
|
|
3575
|
+
conflict_code: input.conflictCode, updated_at: input.now } };
|
|
3576
|
+
}
|
|
3577
|
+
if (operation === 'retryConflict') {
|
|
3578
|
+
if (current.state !== 'conflict') return { ok: false, changed: false, reason: 'phase_mismatch', gate: current };
|
|
3579
|
+
if (current.snapshot_hash !== input.expectedSnapshotHash
|
|
3580
|
+
|| current.conflict_code !== input.expectedConflictCode) {
|
|
3581
|
+
return { ok: false, changed: false, reason: 'conflict_mismatch', gate: current };
|
|
3582
|
+
}
|
|
3583
|
+
runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE}
|
|
3584
|
+
SET state = 'quarantined', snapshot_hash = ?, conflict_code = NULL, updated_at = ? WHERE singleton = 1`,
|
|
3585
|
+
[input.replacementSnapshotHash, input.now]);
|
|
3586
|
+
return { ok: true, changed: true, reason: 'retry_bound', gate: {
|
|
3587
|
+
...current, state: 'quarantined', snapshot_hash: input.replacementSnapshotHash,
|
|
3588
|
+
conflict_code: null, updated_at: input.now,
|
|
3589
|
+
} };
|
|
3590
|
+
}
|
|
3591
|
+
if (operation === 'completeMigration') {
|
|
3592
|
+
if (current.state === 'complete' && current.snapshot_hash === input.snapshotHash) {
|
|
3593
|
+
return { ok: true, changed: false, reason: 'idempotent', gate: current };
|
|
3594
|
+
}
|
|
3595
|
+
if (current.state !== 'quarantined' || current.snapshot_hash !== input.snapshotHash) {
|
|
3596
|
+
return { ok: false, changed: false, reason: 'snapshot_mismatch', gate: current };
|
|
3597
|
+
}
|
|
3598
|
+
runParams(runtime, database, `UPDATE ${WATCH_GATE_TABLE} SET state = 'complete', updated_at = ? WHERE singleton = 1`, [input.now]);
|
|
3599
|
+
return { ok: true, changed: true, reason: 'complete', gate: { ...current, state: 'complete', updated_at: input.now } };
|
|
3600
|
+
}
|
|
3601
|
+
throw watchOwnerStoreError('INVALID_OPERATION', 'Unknown watcher migration-gate operation');
|
|
3602
|
+
});
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
function rollbackTransaction(runtime, db) {
|
|
3606
|
+
try {
|
|
3607
|
+
execSql(runtime, db, 'ROLLBACK;');
|
|
3608
|
+
} catch {
|
|
3609
|
+
// Preserve the original failure when SQLite has already closed the transaction.
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
const MONITOR_HASH = /^[0-9a-f]{64}$/;
|
|
3614
|
+
const MONITOR_TARGET = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
3615
|
+
const MONITOR_SECRET_PATTERNS = [
|
|
3616
|
+
/gh[pousr]_[a-z0-9]{20,}/i,
|
|
3617
|
+
/github_pat_[a-z0-9_]{20,}/i,
|
|
3618
|
+
/sk_(?:live|test)_[a-z0-9]{16,}/i,
|
|
3619
|
+
/sk-[a-z0-9]{16,}/i,
|
|
3620
|
+
/AKIA[0-9A-Z]{16}/i,
|
|
3621
|
+
/(?:api[_-]?key|token|secret|password)\s*[:=]\s*\S{8,}/i,
|
|
3622
|
+
];
|
|
3623
|
+
const MONITOR_MAX_TARGETS = 32;
|
|
3624
|
+
const MONITOR_MAX_TARGET_LENGTH = 128;
|
|
3625
|
+
const MONITOR_MAX_ENVELOPE_BYTES = 16_384;
|
|
3626
|
+
|
|
3627
|
+
const MONITOR_MAX_ENVELOPE_DEPTH = 8;
|
|
3628
|
+
const MONITOR_MAX_ENVELOPE_ITEMS = 128;
|
|
3629
|
+
const MONITOR_MAX_ENVELOPE_PROPERTIES = 64;
|
|
3630
|
+
const MONITOR_MAX_ENVELOPE_NODES = 1_024;
|
|
3631
|
+
const MONITOR_DEFAULT_READ_LIMIT = 128;
|
|
3632
|
+
const MONITOR_MAX_READ_LIMIT = 4_096;
|
|
3633
|
+
|
|
3634
|
+
function monitorStoreError(code, message, cause) {
|
|
3635
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
3636
|
+
error.code = code;
|
|
3637
|
+
return error;
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
function containsMonitorSecret(value) {
|
|
3641
|
+
return MONITOR_SECRET_PATTERNS.some(pattern => pattern.test(value));
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
const MONITOR_PRIVATE_PATH_ROOTS = ['users', 'home', 'root'];
|
|
3645
|
+
const MONITOR_MAX_PRIVATE_SCAN_LENGTH = MONITOR_MAX_ENVELOPE_BYTES;
|
|
3646
|
+
|
|
3647
|
+
function hasNonWhitespacePathSegment(segment) {
|
|
3648
|
+
return Boolean(segment && segment.trim());
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
function containsMonitorPrivatePath(value) {
|
|
3652
|
+
if (typeof value !== 'string') return false;
|
|
3653
|
+
if (value.length > MONITOR_MAX_PRIVATE_SCAN_LENGTH) return true;
|
|
3654
|
+
const normalized = value.replaceAll('\\', '/').toLowerCase();
|
|
3655
|
+
for (const root of MONITOR_PRIVATE_PATH_ROOTS) {
|
|
3656
|
+
const marker = `/${root}/`;
|
|
3657
|
+
let offset = 0;
|
|
3658
|
+
while (true) {
|
|
3659
|
+
const index = normalized.indexOf(marker, offset);
|
|
3660
|
+
if (index < 0) break;
|
|
3661
|
+
const segment = normalized.slice(index + marker.length).split('/')[0];
|
|
3662
|
+
if (hasNonWhitespacePathSegment(segment)) return true;
|
|
3663
|
+
offset = index + marker.length;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
for (let code = 97; code <= 122; code += 1) {
|
|
3667
|
+
const marker = `${String.fromCharCode(code)}:/users/`;
|
|
3668
|
+
const index = normalized.indexOf(marker);
|
|
3669
|
+
if (index >= 0 && hasNonWhitespacePathSegment(
|
|
3670
|
+
normalized.slice(index + marker.length).split('/')[0],
|
|
3671
|
+
)) return true;
|
|
3672
|
+
}
|
|
3673
|
+
return false;
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
function invalidMonitorPlainData() {
|
|
3677
|
+
throw new Error('monitor envelope must contain only bounded plain JSON data');
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
function cloneMonitorArray(value, descriptors, keys, state, depth) {
|
|
3681
|
+
const length = descriptors.length?.value;
|
|
3682
|
+
if (!Number.isInteger(length) || length < 0 || length > MONITOR_MAX_ENVELOPE_ITEMS
|
|
3683
|
+
|| keys.length !== length + 1) {
|
|
3684
|
+
invalidMonitorPlainData();
|
|
3685
|
+
}
|
|
3686
|
+
const clone = [];
|
|
3687
|
+
for (let index = 0; index < length; index += 1) {
|
|
3688
|
+
const descriptor = descriptors[String(index)];
|
|
3689
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) invalidMonitorPlainData();
|
|
3690
|
+
clone.push(cloneMonitorPlainData(descriptor.value, state, depth + 1));
|
|
3691
|
+
}
|
|
3692
|
+
return clone;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
function cloneMonitorObject(descriptors, keys, state, depth) {
|
|
3696
|
+
const clone = Object.create(null);
|
|
3697
|
+
for (const key of keys) {
|
|
3698
|
+
const descriptor = descriptors[key];
|
|
3699
|
+
if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalidMonitorPlainData();
|
|
3700
|
+
clone[key] = cloneMonitorPlainData(descriptor.value, state, depth + 1);
|
|
3701
|
+
}
|
|
3702
|
+
return clone;
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
function cloneMonitorPlainData(value, state, depth = 0) {
|
|
3706
|
+
const traversal = state || { ancestors: new WeakSet(), nodes: 0 };
|
|
3707
|
+
traversal.nodes += 1;
|
|
3708
|
+
if (traversal.nodes > MONITOR_MAX_ENVELOPE_NODES || depth > MONITOR_MAX_ENVELOPE_DEPTH) {
|
|
3709
|
+
invalidMonitorPlainData();
|
|
3710
|
+
}
|
|
3711
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
3712
|
+
if (typeof value === 'number') {
|
|
3713
|
+
if (!Number.isFinite(value)) invalidMonitorPlainData();
|
|
3714
|
+
return value;
|
|
3715
|
+
}
|
|
3716
|
+
if (typeof value !== 'object' || isProxy(value) || traversal.ancestors.has(value)) {
|
|
3717
|
+
invalidMonitorPlainData();
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
let prototype;
|
|
3721
|
+
let descriptors;
|
|
3722
|
+
try {
|
|
3723
|
+
prototype = Object.getPrototypeOf(value);
|
|
3724
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
3725
|
+
} catch {
|
|
3726
|
+
invalidMonitorPlainData();
|
|
3727
|
+
}
|
|
3728
|
+
const keys = Reflect.ownKeys(descriptors);
|
|
3729
|
+
if (keys.some(key => typeof key !== 'string' || key === 'toJSON')) invalidMonitorPlainData();
|
|
3730
|
+
|
|
3731
|
+
traversal.ancestors.add(value);
|
|
3732
|
+
try {
|
|
3733
|
+
if (Array.isArray(value)) {
|
|
3734
|
+
if (prototype !== Array.prototype) invalidMonitorPlainData();
|
|
3735
|
+
return cloneMonitorArray(value, descriptors, keys, traversal, depth);
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
if ((prototype !== Object.prototype && prototype !== null)
|
|
3739
|
+
|| keys.length > MONITOR_MAX_ENVELOPE_PROPERTIES) {
|
|
3740
|
+
invalidMonitorPlainData();
|
|
3741
|
+
}
|
|
3742
|
+
return cloneMonitorObject(descriptors, keys, traversal, depth);
|
|
3743
|
+
} finally {
|
|
3744
|
+
traversal.ancestors.delete(value);
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
function containsPrivateMonitorData(value) {
|
|
3749
|
+
if (typeof value === 'string') {
|
|
3750
|
+
return containsMonitorSecret(value) || containsMonitorPrivatePath(value);
|
|
3751
|
+
}
|
|
3752
|
+
if (!value || typeof value !== 'object') return false;
|
|
3753
|
+
return Object.entries(value).some(([key, nestedValue]) => (
|
|
3754
|
+
containsMonitorSecret(key)
|
|
3755
|
+
|| containsMonitorPrivatePath(key)
|
|
3756
|
+
|| containsPrivateMonitorData(nestedValue)
|
|
3757
|
+
));
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
function assertMonitorEnvelope(envelope, schemaId) {
|
|
3761
|
+
const plainEnvelope = cloneMonitorPlainData(envelope);
|
|
3762
|
+
const serialized = JSON.stringify(plainEnvelope);
|
|
3763
|
+
const safeEnvelope = JSON.parse(serialized);
|
|
3764
|
+
if (containsPrivateMonitorData(safeEnvelope)) {
|
|
3765
|
+
throw new Error(`private content rejected from ${schemaId}`);
|
|
3766
|
+
}
|
|
3767
|
+
const payload = safeEnvelope?.payload;
|
|
3768
|
+
const validCommon = safeEnvelope?.schema_id === schemaId
|
|
3769
|
+
&& MONITOR_HASH.test(safeEnvelope?.content_hash)
|
|
3770
|
+
&& typeof safeEnvelope?.created_at === 'string'
|
|
3771
|
+
&& payload && typeof payload === 'object' && !Array.isArray(payload);
|
|
3772
|
+
let validPayload = false;
|
|
3773
|
+
if (schemaId === 'forge.memory.monitor-event.v1') {
|
|
3774
|
+
validPayload = typeof payload?.monitor_id === 'string' && payload.monitor_id.length > 0
|
|
3775
|
+
&& typeof payload?.event_id === 'string' && payload.event_id.length > 0
|
|
3776
|
+
&& Number.isInteger(payload?.sequence) && payload.sequence >= 0;
|
|
3777
|
+
} else if (schemaId === 'forge.memory.delivery-receipt.v1') {
|
|
3778
|
+
validPayload = typeof payload?.event_id === 'string' && payload.event_id.length > 0
|
|
3779
|
+
&& Number.isInteger(payload?.attempt) && payload.attempt > 0;
|
|
3780
|
+
} else if (schemaId === 'forge.memory.monitor-receipt.v1') {
|
|
3781
|
+
validPayload = typeof payload?.monitor_id === 'string' && payload.monitor_id.length > 0
|
|
3782
|
+
&& Number.isInteger(payload?.last_sequence) && payload.last_sequence >= 0;
|
|
3783
|
+
}
|
|
3784
|
+
if (!validCommon || !validPayload || Buffer.byteLength(serialized, 'utf8') > MONITOR_MAX_ENVELOPE_BYTES) {
|
|
3785
|
+
throw new Error(`invalid ${schemaId.replace('forge.memory.', '').replace('.v1', '').replaceAll('-', ' ')} envelope`);
|
|
3786
|
+
}
|
|
3787
|
+
return safeEnvelope;
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
function assertMonitorTarget(target) {
|
|
3791
|
+
if (typeof target !== 'string' || target.length === 0 || target.length > MONITOR_MAX_TARGET_LENGTH
|
|
3792
|
+
|| !MONITOR_TARGET.test(target) || containsMonitorSecret(target) || containsMonitorPrivatePath(target)) {
|
|
3793
|
+
throw new Error('invalid or private monitor delivery target');
|
|
3794
|
+
}
|
|
3795
|
+
return target;
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
function normalizeMonitorTargets(targets) {
|
|
3799
|
+
if (!Array.isArray(targets) || targets.length === 0 || targets.length > MONITOR_MAX_TARGETS) {
|
|
3800
|
+
throw new Error(`monitor delivery targets must contain 1-${MONITOR_MAX_TARGETS} entries`);
|
|
3801
|
+
}
|
|
3802
|
+
return [...new Set(targets.map(assertMonitorTarget))];
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
function compareMonitorTargets(left, right) {
|
|
3806
|
+
if (left < right) return -1;
|
|
3807
|
+
if (left > right) return 1;
|
|
3808
|
+
return 0;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
function assertMonitorWriterEnabled(runtime, db, config = {}) {
|
|
3812
|
+
if (config.monitorDurabilityEnabled === false) {
|
|
3813
|
+
throw monitorStoreError('MONITOR_UNAVAILABLE', 'Monitor durability writers are disabled; retained evidence remains readable');
|
|
3814
|
+
}
|
|
3815
|
+
let row;
|
|
3816
|
+
try {
|
|
3817
|
+
row = allParams(runtime, db,
|
|
3818
|
+
'SELECT enabled FROM memory_monitor_writer_state WHERE singleton = 1')[0];
|
|
3819
|
+
} catch (error) {
|
|
3820
|
+
if (/no such table/i.test(String(error?.message || ''))) {
|
|
3821
|
+
throw monitorStoreError('MONITOR_UNAVAILABLE', 'Monitor durability schema is not initialized; run Kernel migrations', error);
|
|
3822
|
+
}
|
|
3823
|
+
throw error;
|
|
3824
|
+
}
|
|
3825
|
+
if (Number(row?.enabled) !== 1) {
|
|
3826
|
+
throw monitorStoreError('MONITOR_UNAVAILABLE', 'Monitor durability writers are disabled; retained evidence remains readable');
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
function monitorBusyTimeout(config = {}) {
|
|
3831
|
+
const requested = Number(config.monitorBusyTimeoutMs);
|
|
3832
|
+
if (!Number.isFinite(requested)) return 1_000;
|
|
3833
|
+
return Math.max(0, Math.min(5_000, Math.floor(requested)));
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
function runMonitorTransaction(runtime, db, config, operation) {
|
|
3837
|
+
const timeout = monitorBusyTimeout(config);
|
|
3838
|
+
const previousTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
|
|
3839
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${timeout};`);
|
|
3840
|
+
let active = false;
|
|
3841
|
+
try {
|
|
3842
|
+
execSql(runtime, db, 'BEGIN IMMEDIATE;');
|
|
3843
|
+
active = true;
|
|
3844
|
+
const result = operation();
|
|
3845
|
+
execSql(runtime, db, 'COMMIT;');
|
|
3846
|
+
active = false;
|
|
3847
|
+
return result;
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
if (active) rollbackTransaction(runtime, db);
|
|
3850
|
+
if (/database is locked|SQLITE_BUSY/i.test(String(error?.message || ''))) {
|
|
3851
|
+
throw monitorStoreError('MONITOR_UNAVAILABLE', `Monitor durability write remained busy after ${timeout}ms`, error);
|
|
3852
|
+
}
|
|
3853
|
+
throw error;
|
|
3854
|
+
} finally {
|
|
3855
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${previousTimeout};`);
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
function monitorEventRow(envelope) {
|
|
3860
|
+
const payload = envelope.payload || {};
|
|
3861
|
+
return {
|
|
3862
|
+
event_id: payload.event_id,
|
|
3863
|
+
monitor_id: payload.monitor_id,
|
|
3864
|
+
sequence: payload.sequence,
|
|
3865
|
+
content_hash: envelope.content_hash,
|
|
3866
|
+
envelope_json: JSON.stringify(envelope),
|
|
3867
|
+
artifact_digest: payload.artifact_digest ?? null,
|
|
3868
|
+
created_at: envelope.created_at,
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
function appendMonitorEventRow(runtime, db, envelope, targets, config) {
|
|
3873
|
+
const row = monitorEventRow(envelope);
|
|
3874
|
+
return runMonitorTransaction(runtime, db, config, () => {
|
|
3875
|
+
assertMonitorWriterEnabled(runtime, db, config);
|
|
3876
|
+
const existingById = allParams(runtime, db,
|
|
3877
|
+
'SELECT event_id, monitor_id, sequence, content_hash FROM memory_monitor_events WHERE event_id = ?', [row.event_id])[0];
|
|
3878
|
+
const existingBySequence = allParams(runtime, db,
|
|
3879
|
+
'SELECT event_id, monitor_id, sequence, content_hash FROM memory_monitor_events WHERE monitor_id = ? AND sequence = ?', [row.monitor_id, row.sequence])[0];
|
|
3880
|
+
const existing = existingById || existingBySequence;
|
|
3881
|
+
if (existing) {
|
|
3882
|
+
if (existing.content_hash !== row.content_hash) throw monitorStoreError('MONITOR_EVENT_CONFLICT', 'monitor event conflict: immutable identity has different content_hash');
|
|
3883
|
+
const storedTargets = allParams(runtime, db,
|
|
3884
|
+
'SELECT target FROM memory_monitor_outbox WHERE event_id = ? ORDER BY target ASC', [existing.event_id])
|
|
3885
|
+
.map(entry => entry.target);
|
|
3886
|
+
const replayTargets = [...targets].sort(compareMonitorTargets);
|
|
3887
|
+
if (storedTargets.length !== replayTargets.length
|
|
3888
|
+
|| storedTargets.some((target, index) => target !== replayTargets[index])) {
|
|
3889
|
+
throw monitorStoreError('MONITOR_TARGET_SET_CONFLICT', 'monitor event target set conflict: identical event replay changed delivery targets');
|
|
3890
|
+
}
|
|
3891
|
+
return { idempotent: true, event_id: existing.event_id, monitor_id: existing.monitor_id, sequence: existing.sequence };
|
|
3892
|
+
}
|
|
3893
|
+
const terminalReceipt = allParams(runtime, db,
|
|
3894
|
+
'SELECT monitor_id FROM memory_monitor_receipts WHERE monitor_id = ?', [row.monitor_id])[0];
|
|
3895
|
+
if (terminalReceipt) throw monitorStoreError('MONITOR_TERMINAL', 'monitor already has a terminal receipt');
|
|
3896
|
+
|
|
3897
|
+
runParams(runtime, db,
|
|
3898
|
+
`INSERT INTO memory_monitor_events
|
|
3899
|
+
(event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at)
|
|
3900
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
3901
|
+
[row.event_id, row.monitor_id, row.sequence, row.content_hash, row.envelope_json, row.artifact_digest, row.created_at]);
|
|
3902
|
+
for (const target of targets) {
|
|
3903
|
+
runParams(runtime, db,
|
|
3904
|
+
`INSERT INTO memory_monitor_outbox
|
|
3905
|
+
(id, event_id, target, status, attempts, next_attempt_at, created_at)
|
|
3906
|
+
VALUES (?, ?, ?, 'pending', 0, NULL, ?)`,
|
|
3907
|
+
[randomUUID(), row.event_id, target, row.created_at]);
|
|
3908
|
+
}
|
|
3909
|
+
return { idempotent: false, event_id: row.event_id, monitor_id: row.monitor_id, sequence: row.sequence };
|
|
3910
|
+
});
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
function recordMonitorDeliveryReceiptRow(runtime, db, envelope, config) {
|
|
3914
|
+
const payload = envelope.payload || {};
|
|
3915
|
+
const row = {
|
|
3916
|
+
event_id: payload.event_id,
|
|
3917
|
+
target: payload.target,
|
|
3918
|
+
attempt: payload.attempt,
|
|
3919
|
+
content_hash: envelope.content_hash,
|
|
3920
|
+
envelope_json: JSON.stringify(envelope),
|
|
3921
|
+
acknowledged: payload.acknowledged ? 1 : 0,
|
|
3922
|
+
delivered_at: payload.delivered_at,
|
|
3923
|
+
outcome: payload.outcome,
|
|
3924
|
+
};
|
|
3925
|
+
return runMonitorTransaction(runtime, db, config, () => {
|
|
3926
|
+
assertMonitorWriterEnabled(runtime, db, config);
|
|
3927
|
+
const existing = allParams(runtime, db,
|
|
3928
|
+
`SELECT content_hash FROM memory_monitor_delivery_receipts
|
|
3929
|
+
WHERE event_id = ? AND target = ? AND attempt = ?`, [row.event_id, row.target, row.attempt])[0];
|
|
3930
|
+
if (existing) {
|
|
3931
|
+
if (existing.content_hash !== row.content_hash) throw monitorStoreError('MONITOR_DELIVERY_CONFLICT', 'monitor delivery receipt conflict: attempt has different content_hash');
|
|
3932
|
+
return { idempotent: true, event_id: row.event_id, target: row.target, attempt: row.attempt };
|
|
3933
|
+
}
|
|
3934
|
+
const event = allParams(runtime, db,
|
|
3935
|
+
'SELECT monitor_id, sequence FROM memory_monitor_events WHERE event_id = ?', [row.event_id])[0];
|
|
3936
|
+
if (!event) throw monitorStoreError('MONITOR_DELIVERY_CONFLICT', 'monitor delivery receipt references an unknown event');
|
|
3937
|
+
const outbox = allParams(runtime, db,
|
|
3938
|
+
'SELECT id FROM memory_monitor_outbox WHERE event_id = ? AND target = ?', [row.event_id, row.target])[0];
|
|
3939
|
+
if (!outbox) throw monitorStoreError('MONITOR_DELIVERY_CONFLICT', 'monitor delivery receipt references an unplanned target');
|
|
3940
|
+
if (row.acknowledged) {
|
|
3941
|
+
const cursor = allParams(runtime, db,
|
|
3942
|
+
'SELECT sequence FROM memory_monitor_cursors WHERE monitor_id = ? AND target = ?',
|
|
3943
|
+
[event.monitor_id, row.target])[0];
|
|
3944
|
+
if (cursor && event.sequence < Number(cursor.sequence)) {
|
|
3945
|
+
throw monitorStoreError('MONITOR_STALE_CURSOR', 'stale monitor delivery cursor: acknowledged sequence is behind the durable cursor');
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
runParams(runtime, db,
|
|
3950
|
+
`INSERT INTO memory_monitor_delivery_receipts
|
|
3951
|
+
(event_id, target, attempt, content_hash, envelope_json, acknowledged, delivered_at, outcome)
|
|
3952
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3953
|
+
[row.event_id, row.target, row.attempt, row.content_hash, row.envelope_json, row.acknowledged, row.delivered_at, row.outcome]);
|
|
3954
|
+
if (row.acknowledged) {
|
|
3955
|
+
runParams(runtime, db,
|
|
3956
|
+
`INSERT INTO memory_monitor_cursors (monitor_id, target, sequence, updated_at)
|
|
3957
|
+
VALUES (?, ?, ?, ?)
|
|
3958
|
+
ON CONFLICT(monitor_id, target) DO UPDATE SET
|
|
3959
|
+
sequence = excluded.sequence,
|
|
3960
|
+
updated_at = excluded.updated_at
|
|
3961
|
+
WHERE excluded.sequence > memory_monitor_cursors.sequence`,
|
|
3962
|
+
[event.monitor_id, row.target, event.sequence, row.delivered_at]);
|
|
3963
|
+
runParams(runtime, db,
|
|
3964
|
+
"UPDATE memory_monitor_outbox SET status = 'acknowledged', attempts = attempts + 1, next_attempt_at = NULL WHERE id = ?",
|
|
3965
|
+
[outbox.id]);
|
|
3966
|
+
}
|
|
3967
|
+
return { idempotent: false, event_id: row.event_id, target: row.target, attempt: row.attempt, acknowledged: Boolean(row.acknowledged) };
|
|
3968
|
+
});
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function recordMonitorTerminalReceiptRow(runtime, db, envelope, config) {
|
|
3972
|
+
const payload = envelope.payload || {};
|
|
3973
|
+
const row = {
|
|
3974
|
+
monitor_id: payload.monitor_id,
|
|
3975
|
+
content_hash: envelope.content_hash,
|
|
3976
|
+
envelope_json: JSON.stringify(envelope),
|
|
3977
|
+
owner_run_id: payload.owner_run_id,
|
|
3978
|
+
terminal_state: payload.terminal_state,
|
|
3979
|
+
last_sequence: payload.last_sequence,
|
|
3980
|
+
evidence_digest: payload.evidence_digest,
|
|
3981
|
+
undelivered_cursor: payload.undelivered_cursor ?? null,
|
|
3982
|
+
created_at: envelope.created_at,
|
|
3983
|
+
};
|
|
3984
|
+
return runMonitorTransaction(runtime, db, config, () => {
|
|
3985
|
+
assertMonitorWriterEnabled(runtime, db, config);
|
|
3986
|
+
const existing = allParams(runtime, db,
|
|
3987
|
+
'SELECT content_hash FROM memory_monitor_receipts WHERE monitor_id = ?', [row.monitor_id])[0];
|
|
3988
|
+
if (existing) {
|
|
3989
|
+
if (existing.content_hash !== row.content_hash) throw monitorStoreError('MONITOR_RECEIPT_CONFLICT', 'monitor receipt conflict: terminal evidence has different content_hash');
|
|
3990
|
+
return { idempotent: true, monitor_id: row.monitor_id };
|
|
3991
|
+
}
|
|
3992
|
+
const eventState = allParams(runtime, db,
|
|
3993
|
+
'SELECT COUNT(*) AS event_count, MAX(sequence) AS max_sequence FROM memory_monitor_events WHERE monitor_id = ?',
|
|
3994
|
+
[row.monitor_id])[0];
|
|
3995
|
+
const expectedLastSequence = Number(eventState.event_count) === 0 ? 0 : Number(eventState.max_sequence);
|
|
3996
|
+
if (row.last_sequence !== expectedLastSequence) {
|
|
3997
|
+
throw monitorStoreError('MONITOR_STALE_TERMINAL', 'stale monitor terminal sequence: last_sequence does not match durable events');
|
|
3998
|
+
}
|
|
3999
|
+
runParams(runtime, db,
|
|
4000
|
+
`INSERT INTO memory_monitor_receipts
|
|
4001
|
+
(monitor_id, content_hash, envelope_json, owner_run_id, terminal_state, last_sequence, evidence_digest, undelivered_cursor, created_at)
|
|
4002
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4003
|
+
[row.monitor_id, row.content_hash, row.envelope_json, row.owner_run_id, row.terminal_state, row.last_sequence, row.evidence_digest, row.undelivered_cursor, row.created_at]);
|
|
4004
|
+
return { idempotent: false, monitor_id: row.monitor_id };
|
|
4005
|
+
});
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
function monitorReadLimit(options = {}, fallback = MONITOR_DEFAULT_READ_LIMIT) {
|
|
4009
|
+
const requested = options.limit ?? fallback;
|
|
4010
|
+
if (!Number.isSafeInteger(requested) || requested < 1 || requested > MONITOR_MAX_READ_LIMIT) {
|
|
4011
|
+
throw new TypeError(`monitor read limit must be an integer from 1 to ${MONITOR_MAX_READ_LIMIT}`);
|
|
4012
|
+
}
|
|
4013
|
+
return requested;
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
function getMonitorEventRow(runtime, db, eventId) {
|
|
4017
|
+
return allParams(
|
|
4018
|
+
runtime,
|
|
4019
|
+
db,
|
|
4020
|
+
`SELECT event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at
|
|
4021
|
+
FROM memory_monitor_events WHERE event_id = ? LIMIT 1`,
|
|
4022
|
+
[eventId],
|
|
4023
|
+
)[0] || null;
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
function listMonitorEventRows(runtime, db, monitorId) {
|
|
4027
|
+
return allParams(
|
|
4028
|
+
runtime,
|
|
4029
|
+
db,
|
|
4030
|
+
`SELECT event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at
|
|
4031
|
+
FROM memory_monitor_events WHERE monitor_id = ? ORDER BY sequence ASC, event_id ASC`,
|
|
4032
|
+
[monitorId],
|
|
4033
|
+
);
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
function readMonitorEventTailRows(runtime, db, monitorId, options = {}) {
|
|
4037
|
+
const limit = monitorReadLimit(options);
|
|
4038
|
+
const selected = allParams(
|
|
4039
|
+
runtime,
|
|
4040
|
+
db,
|
|
4041
|
+
`SELECT event_id, monitor_id, sequence, content_hash, envelope_json, artifact_digest, created_at
|
|
4042
|
+
FROM memory_monitor_events WHERE monitor_id = ?
|
|
4043
|
+
ORDER BY sequence DESC, event_id DESC LIMIT ?`,
|
|
4044
|
+
[monitorId, limit + 1],
|
|
4045
|
+
);
|
|
4046
|
+
const overflow = selected.length > limit;
|
|
4047
|
+
const events = selected.slice(0, limit).reverse();
|
|
4048
|
+
return {
|
|
4049
|
+
events,
|
|
4050
|
+
overflow,
|
|
4051
|
+
truncated_before_sequence: overflow && events.length > 0 ? Number(events[0].sequence) : null,
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
function readMonitorDeliveryStateRows(runtime, db, monitorId, options = {}) {
|
|
4056
|
+
const limit = monitorReadLimit(options);
|
|
4057
|
+
const cursorRows = allParams(
|
|
4058
|
+
runtime,
|
|
4059
|
+
db,
|
|
4060
|
+
`SELECT monitor_id, target, sequence, updated_at FROM memory_monitor_cursors
|
|
4061
|
+
WHERE monitor_id = ? ORDER BY target ASC LIMIT ?`,
|
|
4062
|
+
[monitorId, limit + 1],
|
|
4063
|
+
);
|
|
4064
|
+
const outboxRows = allParams(
|
|
4065
|
+
runtime,
|
|
4066
|
+
db,
|
|
4067
|
+
`SELECT o.id AS outbox_id, o.event_id, e.monitor_id, e.sequence, o.target,
|
|
4068
|
+
o.status, o.attempts, o.next_attempt_at, o.created_at
|
|
4069
|
+
FROM memory_monitor_outbox o
|
|
4070
|
+
JOIN memory_monitor_events e ON e.event_id = o.event_id
|
|
4071
|
+
WHERE e.monitor_id = ?
|
|
4072
|
+
ORDER BY e.sequence ASC, o.target ASC, o.id ASC LIMIT ?`,
|
|
4073
|
+
[monitorId, limit + 1],
|
|
4074
|
+
);
|
|
4075
|
+
const terminalReceipt = allParams(
|
|
4076
|
+
runtime,
|
|
4077
|
+
db,
|
|
4078
|
+
`SELECT monitor_id, content_hash, envelope_json, owner_run_id, terminal_state,
|
|
4079
|
+
last_sequence, evidence_digest, undelivered_cursor, created_at
|
|
4080
|
+
FROM memory_monitor_receipts WHERE monitor_id = ? LIMIT 1`,
|
|
4081
|
+
[monitorId],
|
|
4082
|
+
)[0] || null;
|
|
4083
|
+
return {
|
|
4084
|
+
cursors: cursorRows.slice(0, limit),
|
|
4085
|
+
outbox: outboxRows.slice(0, limit),
|
|
4086
|
+
terminal_receipt: terminalReceipt,
|
|
4087
|
+
overflow: {
|
|
4088
|
+
cursors: cursorRows.length > limit,
|
|
4089
|
+
outbox: outboxRows.length > limit,
|
|
4090
|
+
},
|
|
4091
|
+
};
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
const CLAIM_REPAIR_INDEX_NAMES = Object.freeze([
|
|
4095
|
+
'idx_kernel_claims_active_lease',
|
|
4096
|
+
'idx_kernel_claims_actor_state',
|
|
4097
|
+
'idx_kernel_claims_issue_state',
|
|
4098
|
+
]);
|
|
4099
|
+
const CLAIM_REPAIR_AUTHORITY_ROWID_TABLES = new Set([
|
|
4100
|
+
'kernel_events',
|
|
4101
|
+
'kernel_memories',
|
|
4102
|
+
'kernel_stage_runs',
|
|
4103
|
+
'kernel_worktrees',
|
|
4104
|
+
]);
|
|
4105
|
+
|
|
4106
|
+
function quoteSqlIdentifier(value) {
|
|
4107
|
+
return `"${String(value).replaceAll('"', '""')}"`;
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
function loadCompleteAuthoritySnapshot(runtime, db, guardedRead) {
|
|
4111
|
+
const schema = guardedRead(
|
|
4112
|
+
'authority_schema',
|
|
4113
|
+
() => allParams(
|
|
4114
|
+
runtime,
|
|
4115
|
+
db,
|
|
4116
|
+
`SELECT type, name, tbl_name, sql FROM sqlite_master
|
|
4117
|
+
WHERE name NOT LIKE 'sqlite_%' ORDER BY type ASC, name ASC`,
|
|
4118
|
+
),
|
|
4119
|
+
);
|
|
4120
|
+
const tables = schema
|
|
4121
|
+
.filter(row => row.type === 'table')
|
|
4122
|
+
.map(row => ({
|
|
4123
|
+
name: row.name,
|
|
4124
|
+
rows: guardedRead(
|
|
4125
|
+
`authority_table:${row.name}`,
|
|
4126
|
+
() => allParams(
|
|
4127
|
+
runtime,
|
|
4128
|
+
db,
|
|
4129
|
+
`SELECT ${CLAIM_REPAIR_AUTHORITY_ROWID_TABLES.has(row.name) ? 'rowid AS __forge_rowid, ' : ''}* FROM ${quoteSqlIdentifier(row.name)}`,
|
|
4130
|
+
),
|
|
4131
|
+
),
|
|
4132
|
+
}));
|
|
4133
|
+
return { schema, tables };
|
|
4134
|
+
}
|
|
4135
|
+
|
|
4136
|
+
function loadLegacyClaimRepairSnapshot(runtime, db) {
|
|
4137
|
+
const readErrors = [];
|
|
4138
|
+
const guardedRead = (label, read, fallback = []) => {
|
|
4139
|
+
try {
|
|
4140
|
+
return read();
|
|
4141
|
+
} catch {
|
|
4142
|
+
readErrors.push(label);
|
|
4143
|
+
return fallback;
|
|
4144
|
+
}
|
|
4145
|
+
};
|
|
4146
|
+
const integrityRows = guardedRead('integrity_check', () => queryAll(runtime, db, 'PRAGMA integrity_check;'));
|
|
4147
|
+
const foreignKeyState = guardedRead('foreign_keys', () => queryOne(runtime, db, 'PRAGMA foreign_keys;'), {});
|
|
4148
|
+
const foreignKeyFaults = guardedRead('foreign_key_check', () => queryAll(runtime, db, 'PRAGMA foreign_key_check;'));
|
|
4149
|
+
const claimColumns = guardedRead('claim_columns', () => queryAll(runtime, db, "PRAGMA table_info('kernel_claims');"));
|
|
4150
|
+
const issueColumns = guardedRead('issue_columns', () => queryAll(runtime, db, "PRAGMA table_info('kernel_issues');"));
|
|
4151
|
+
const claimForeignKeys = guardedRead('claim_foreign_keys', () => queryAll(runtime, db, "PRAGMA foreign_key_list('kernel_claims');"));
|
|
4152
|
+
const indexList = guardedRead('claim_indexes', () => queryAll(runtime, db, "PRAGMA index_list('kernel_claims');"));
|
|
4153
|
+
const indexSqlRows = guardedRead(
|
|
4154
|
+
'claim_index_sql',
|
|
4155
|
+
() => allParams(runtime, db, "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'kernel_claims'"),
|
|
4156
|
+
);
|
|
4157
|
+
const indexSql = new Map(indexSqlRows.map(row => [row.name, row.sql]));
|
|
4158
|
+
const indexByName = new Map(indexList.map(row => [row.name, row]));
|
|
4159
|
+
const claimIndexes = CLAIM_REPAIR_INDEX_NAMES.map(name => {
|
|
4160
|
+
const index = indexByName.get(name) || {};
|
|
4161
|
+
const columns = guardedRead(
|
|
4162
|
+
`index_columns:${name}`,
|
|
4163
|
+
() => queryAll(runtime, db, `PRAGMA index_info('${name}');`),
|
|
4164
|
+
);
|
|
4165
|
+
return {
|
|
4166
|
+
name,
|
|
4167
|
+
columns: columns.map(row => row.name),
|
|
4168
|
+
unique: Number(index.unique) === 1,
|
|
4169
|
+
partial: Number(index.partial) === 1,
|
|
4170
|
+
sql: indexSql.get(name) || '',
|
|
4171
|
+
};
|
|
4172
|
+
});
|
|
4173
|
+
const issues = guardedRead(
|
|
4174
|
+
'issues',
|
|
4175
|
+
() => allParams(runtime, db, 'SELECT id, status, type FROM kernel_issues ORDER BY id ASC'),
|
|
4176
|
+
);
|
|
4177
|
+
const claims = guardedRead(
|
|
4178
|
+
'claims',
|
|
4179
|
+
() => allParams(
|
|
4180
|
+
runtime,
|
|
4181
|
+
db,
|
|
4182
|
+
`SELECT id, issue_id, actor, state, session_id, worktree_id, claimed_at, expires_at
|
|
4183
|
+
FROM kernel_claims ORDER BY id ASC`,
|
|
4184
|
+
),
|
|
4185
|
+
);
|
|
4186
|
+
const authority = loadCompleteAuthoritySnapshot(runtime, db, guardedRead);
|
|
4187
|
+
return {
|
|
4188
|
+
integrity: integrityRows.length > 0
|
|
4189
|
+
&& integrityRows.every(row => String(row.integrity_check || '').toLowerCase() === 'ok')
|
|
4190
|
+
? 'ok'
|
|
4191
|
+
: 'failed',
|
|
4192
|
+
foreign_keys_enabled: Number(foreignKeyState.foreign_keys) === 1,
|
|
4193
|
+
foreign_key_faults: foreignKeyFaults.length,
|
|
4194
|
+
claim_columns: claimColumns.map(row => row.name),
|
|
4195
|
+
issue_columns: issueColumns.map(row => row.name),
|
|
4196
|
+
claim_foreign_keys: claimForeignKeys.map(row => ({ table: row.table, from: row.from, to: row.to })),
|
|
4197
|
+
claim_indexes: claimIndexes,
|
|
4198
|
+
issues,
|
|
4199
|
+
claims,
|
|
4200
|
+
authority_schema: authority.schema,
|
|
4201
|
+
authority_tables: authority.tables,
|
|
4202
|
+
read_errors: readErrors,
|
|
4203
|
+
};
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
function preflightLegacyClaimRepairRow(runtime, db, input = {}) {
|
|
4207
|
+
const snapshot = loadLegacyClaimRepairSnapshot(runtime, db);
|
|
4208
|
+
return publicClaimRepairPreflight(buildClaimRepairPlan(snapshot, input));
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
function assertClaimRepairApplyInput(input = {}) {
|
|
4212
|
+
const approvedDigest = input.approvedDigest;
|
|
4213
|
+
const proof = input.backupProof;
|
|
4214
|
+
if (!/^[0-9a-f]{64}$/.test(String(approvedDigest || ''))) {
|
|
4215
|
+
throw new ClaimRepairError('CLAIM_REPAIR_APPROVAL_REQUIRED', 'Apply requires the exact approved preflight digest');
|
|
4216
|
+
}
|
|
4217
|
+
if (proof?.schema_version !== 'forge.claim-repair.backup-proof.v1'
|
|
4218
|
+
|| proof.integrity !== 'ok'
|
|
4219
|
+
|| proof.plan_digest !== approvedDigest
|
|
4220
|
+
|| proof.restore_digest !== approvedDigest
|
|
4221
|
+
|| !/^[0-9a-f]{64}$/.test(String(proof.backup_sha256 || ''))) {
|
|
4222
|
+
throw new ClaimRepairError(
|
|
4223
|
+
'CLAIM_REPAIR_BACKUP_PROOF_REQUIRED',
|
|
4224
|
+
'Apply requires a verified separate backup restored from the exact approved snapshot',
|
|
4225
|
+
);
|
|
4226
|
+
}
|
|
4227
|
+
if (typeof input.actor !== 'string' || input.actor.trim() === '') {
|
|
4228
|
+
throw new ClaimRepairError('CLAIM_REPAIR_ACTOR_REQUIRED', 'Apply requires an explicit operator actor');
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
function parseStoredClaimRepairReceipt(row, approvedDigest, backupProof = null) {
|
|
4233
|
+
if (!row) return null;
|
|
4234
|
+
let receipt;
|
|
4235
|
+
try {
|
|
4236
|
+
receipt = JSON.parse(row.payload_json);
|
|
4237
|
+
} catch {
|
|
4238
|
+
throw new ClaimRepairError('CLAIM_REPAIR_RECEIPT_INVALID', 'Stored claim repair receipt is malformed');
|
|
4239
|
+
}
|
|
4240
|
+
if (receipt?.schema_version !== 'forge.claim-repair.receipt.v1'
|
|
4241
|
+
|| row.id !== receipt.receipt_id
|
|
4242
|
+
|| row.entity_type !== 'claim_repair'
|
|
4243
|
+
|| row.entity_id !== 'legacy_claims'
|
|
4244
|
+
|| row.event_type !== 'claim.repair'
|
|
4245
|
+
|| row.origin !== 'forge.claim-repair'
|
|
4246
|
+
|| receipt.approved_digest !== approvedDigest
|
|
4247
|
+
|| (backupProof && receipt.backup_sha256 !== backupProof.backup_sha256)
|
|
4248
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(receipt.recovery_ref || ''))
|
|
4249
|
+
|| !/^[0-9a-f]{64}$/.test(String(receipt.backup_sha256 || ''))
|
|
4250
|
+
|| !/^[0-9a-f]{64}$/.test(String(receipt.after_digest || ''))) {
|
|
4251
|
+
throw new ClaimRepairError('CLAIM_REPAIR_RECEIPT_INVALID', 'Stored claim repair receipt does not match the approved proof');
|
|
4252
|
+
}
|
|
4253
|
+
return receipt;
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
function claimRepairRecoveryPath(backupPath, recoveryReference) {
|
|
4257
|
+
return `${backupPath}.forge-recovery-${recoveryReference}`;
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
function attachClaimRepairRecoveryPath(receipt, backupPath) {
|
|
4261
|
+
if (!receipt || typeof backupPath !== 'string' || !path.isAbsolute(backupPath)) return receipt;
|
|
4262
|
+
return {
|
|
4263
|
+
...receipt,
|
|
4264
|
+
recovery_path: claimRepairRecoveryPath(backupPath, receipt.recovery_ref),
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
function replayStoredClaimRepairReceipt(runtime, db, approvedDigest, observedAt, backupPath) {
|
|
4269
|
+
if (!/^[0-9a-f]{64}$/.test(String(approvedDigest || ''))) return null;
|
|
4270
|
+
const existingRow = allParams(
|
|
4271
|
+
runtime,
|
|
4272
|
+
db,
|
|
4273
|
+
'SELECT * FROM kernel_events WHERE idempotency_key = ? LIMIT 1',
|
|
4274
|
+
[`claim.repair:${approvedDigest}`],
|
|
4275
|
+
)[0];
|
|
4276
|
+
const receipt = parseStoredClaimRepairReceipt(existingRow, approvedDigest);
|
|
4277
|
+
if (receipt && receipt.observed_at !== observedAt) {
|
|
4278
|
+
throw new ClaimRepairError('CLAIM_REPAIR_RECEIPT_INVALID', 'Stored claim repair receipt does not match the fixed observation time');
|
|
4279
|
+
}
|
|
4280
|
+
return receipt
|
|
4281
|
+
? attachClaimRepairRecoveryPath({ ...receipt, replayed: true }, backupPath)
|
|
4282
|
+
: null;
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
function claimRepairSourceIdentities(databasePath) {
|
|
4286
|
+
return [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`]
|
|
4287
|
+
.filter(sourcePath => fs.existsSync(sourcePath))
|
|
4288
|
+
.map(sourcePath => {
|
|
4289
|
+
const stat = fs.statSync(sourcePath, { bigint: true });
|
|
4290
|
+
return { device: stat.dev, inode: stat.ino };
|
|
4291
|
+
});
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
function assertNoClaimRepairBackupSidecars(backupPath) {
|
|
4295
|
+
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
4296
|
+
if (fs.existsSync(`${backupPath}${suffix}`)) throw new Error('backup sidecar exists');
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
function hashOpenFileDescriptor(fileDescriptor) {
|
|
4301
|
+
const hash = createHash('sha256');
|
|
4302
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
4303
|
+
let offset = 0;
|
|
4304
|
+
for (;;) {
|
|
4305
|
+
const bytesRead = fs.readSync(fileDescriptor, buffer, 0, buffer.length, offset);
|
|
4306
|
+
if (bytesRead === 0) break;
|
|
4307
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
4308
|
+
offset += bytesRead;
|
|
4309
|
+
}
|
|
4310
|
+
return hash.digest('hex');
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
function copyOpenFileDescriptor(sourceDescriptor, destinationDescriptor) {
|
|
4314
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
4315
|
+
let offset = 0;
|
|
4316
|
+
for (;;) {
|
|
4317
|
+
const bytesRead = fs.readSync(sourceDescriptor, buffer, 0, buffer.length, offset);
|
|
4318
|
+
if (bytesRead === 0) break;
|
|
4319
|
+
let written = 0;
|
|
4320
|
+
while (written < bytesRead) {
|
|
4321
|
+
const bytesWritten = fs.writeSync(
|
|
4322
|
+
destinationDescriptor,
|
|
4323
|
+
buffer,
|
|
4324
|
+
written,
|
|
4325
|
+
bytesRead - written,
|
|
4326
|
+
offset + written,
|
|
4327
|
+
);
|
|
4328
|
+
if (bytesWritten <= 0) throw new Error('recovery copy write made no progress');
|
|
4329
|
+
written += bytesWritten;
|
|
4330
|
+
}
|
|
4331
|
+
offset += bytesRead;
|
|
4332
|
+
}
|
|
4333
|
+
fs.fsyncSync(destinationDescriptor);
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
function syncClaimRepairRecoveryDirectory(recoveryPath, options = {}) {
|
|
4337
|
+
const platform = options.platform || process.platform;
|
|
4338
|
+
if (platform === 'win32') return;
|
|
4339
|
+
const fsApi = options.fsApi || fs;
|
|
4340
|
+
let directoryDescriptor;
|
|
4341
|
+
try {
|
|
4342
|
+
directoryDescriptor = fsApi.openSync(path.dirname(recoveryPath), 'r');
|
|
4343
|
+
fsApi.fsyncSync(directoryDescriptor);
|
|
4344
|
+
} finally {
|
|
4345
|
+
if (directoryDescriptor !== undefined) fsApi.closeSync(directoryDescriptor);
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
async function verifyClaimRepairRecovery(receipt, backupPath, databasePath, options = {}) {
|
|
4350
|
+
const recoveryPath = claimRepairRecoveryPath(backupPath, receipt.recovery_ref);
|
|
4351
|
+
let fileDescriptor;
|
|
4352
|
+
try {
|
|
4353
|
+
await (options.hardenPath || hardenBackupPermissions)(recoveryPath);
|
|
4354
|
+
const sourceIdentities = claimRepairSourceIdentities(databasePath);
|
|
4355
|
+
const backupIdentity = fs.existsSync(backupPath) ? fs.statSync(backupPath, { bigint: true }) : null;
|
|
4356
|
+
fileDescriptor = fs.openSync(recoveryPath, 'r');
|
|
4357
|
+
const before = fs.fstatSync(fileDescriptor, { bigint: true });
|
|
4358
|
+
const digest = hashOpenFileDescriptor(fileDescriptor);
|
|
4359
|
+
const after = fs.fstatSync(fileDescriptor, { bigint: true });
|
|
4360
|
+
const named = fs.statSync(recoveryPath, { bigint: true });
|
|
4361
|
+
if (digest !== receipt.backup_sha256
|
|
4362
|
+
|| before.size !== after.size
|
|
4363
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
4364
|
+
|| named.dev !== before.dev
|
|
4365
|
+
|| named.ino !== before.ino
|
|
4366
|
+
|| (backupIdentity && backupIdentity.dev === before.dev && backupIdentity.ino === before.ino)
|
|
4367
|
+
|| sourceIdentities.some(source => source.device === before.dev && source.inode === before.ino)) {
|
|
4368
|
+
throw new Error('recovery copy changed');
|
|
4369
|
+
}
|
|
4370
|
+
return recoveryPath;
|
|
4371
|
+
} catch {
|
|
4372
|
+
throw new ClaimRepairError(
|
|
4373
|
+
'CLAIM_REPAIR_RECOVERY_INVALID',
|
|
4374
|
+
'Retained claim-repair recovery copy is missing, unsafe, or changed',
|
|
4375
|
+
);
|
|
4376
|
+
} finally {
|
|
4377
|
+
if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
async function openClaimRepairBackupFence(databasePath, backupPath, backupProof, recoveryReference, options = {}) {
|
|
4382
|
+
let fileDescriptor;
|
|
4383
|
+
let recoveryFileDescriptor;
|
|
4384
|
+
let recoveryPath;
|
|
4385
|
+
try {
|
|
4386
|
+
const hardenPath = options.hardenPath || hardenBackupPermissions;
|
|
4387
|
+
const hardenPaths = options.hardenPaths;
|
|
4388
|
+
await hardenPath(backupPath);
|
|
4389
|
+
assertSafeBackupDestination(databasePath, backupPath);
|
|
4390
|
+
assertNoClaimRepairBackupSidecars(backupPath);
|
|
4391
|
+
const sourceIdentities = claimRepairSourceIdentities(databasePath);
|
|
4392
|
+
fileDescriptor = fs.openSync(backupPath, 'r');
|
|
4393
|
+
const before = fs.fstatSync(fileDescriptor, { bigint: true });
|
|
4394
|
+
if (sourceIdentities.some(source => source.device === before.dev && source.inode === before.ino)) {
|
|
4395
|
+
throw new Error('pinned backup aliases source');
|
|
4396
|
+
}
|
|
4397
|
+
const digest = hashOpenFileDescriptor(fileDescriptor);
|
|
4398
|
+
const after = fs.fstatSync(fileDescriptor, { bigint: true });
|
|
4399
|
+
const named = fs.statSync(backupPath, { bigint: true });
|
|
4400
|
+
if (digest !== backupProof.backup_sha256
|
|
4401
|
+
|| before.size !== after.size
|
|
4402
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
4403
|
+
|| before.dev !== named.dev
|
|
4404
|
+
|| before.ino !== named.ino) {
|
|
4405
|
+
throw new Error('backup identity or content changed');
|
|
4406
|
+
}
|
|
4407
|
+
recoveryPath = claimRepairRecoveryPath(backupPath, recoveryReference);
|
|
4408
|
+
recoveryFileDescriptor = fs.openSync(recoveryPath, 'wx+', 0o600);
|
|
4409
|
+
// The backup was hardened before opening its descriptor; harden the retained
|
|
4410
|
+
// copy in the same Windows PowerShell pass before copying any bytes.
|
|
4411
|
+
if (typeof hardenPaths === 'function') await hardenPaths([backupPath, recoveryPath]);
|
|
4412
|
+
else {
|
|
4413
|
+
await hardenPath(backupPath);
|
|
4414
|
+
await hardenPath(recoveryPath);
|
|
4415
|
+
}
|
|
4416
|
+
copyOpenFileDescriptor(fileDescriptor, recoveryFileDescriptor);
|
|
4417
|
+
syncClaimRepairRecoveryDirectory(recoveryPath);
|
|
4418
|
+
const sourceAfterCopy = fs.fstatSync(fileDescriptor, { bigint: true });
|
|
4419
|
+
const sourceDigestAfterCopy = hashOpenFileDescriptor(fileDescriptor);
|
|
4420
|
+
const namedAfterCopy = fs.statSync(backupPath, { bigint: true });
|
|
4421
|
+
const recoveryBefore = fs.fstatSync(recoveryFileDescriptor, { bigint: true });
|
|
4422
|
+
const recoveryDigest = hashOpenFileDescriptor(recoveryFileDescriptor);
|
|
4423
|
+
const recoveryAfter = fs.fstatSync(recoveryFileDescriptor, { bigint: true });
|
|
4424
|
+
const recoveryNamed = fs.statSync(recoveryPath, { bigint: true });
|
|
4425
|
+
if (sourceDigestAfterCopy !== backupProof.backup_sha256
|
|
4426
|
+
|| before.size !== sourceAfterCopy.size
|
|
4427
|
+
|| before.mtimeNs !== sourceAfterCopy.mtimeNs
|
|
4428
|
+
|| namedAfterCopy.dev !== before.dev
|
|
4429
|
+
|| namedAfterCopy.ino !== before.ino
|
|
4430
|
+
|| recoveryDigest !== backupProof.backup_sha256
|
|
4431
|
+
|| recoveryBefore.size !== recoveryAfter.size
|
|
4432
|
+
|| recoveryBefore.mtimeNs !== recoveryAfter.mtimeNs
|
|
4433
|
+
|| recoveryNamed.dev !== recoveryBefore.dev
|
|
4434
|
+
|| recoveryNamed.ino !== recoveryBefore.ino
|
|
4435
|
+
|| (recoveryBefore.dev === before.dev && recoveryBefore.ino === before.ino)) {
|
|
4436
|
+
throw new Error('recovery copy does not preserve independent verified bytes');
|
|
4437
|
+
}
|
|
4438
|
+
return {
|
|
4439
|
+
databasePath,
|
|
4440
|
+
backupPath,
|
|
4441
|
+
backupSha256: backupProof.backup_sha256,
|
|
4442
|
+
fileDescriptor,
|
|
4443
|
+
device: before.dev,
|
|
4444
|
+
inode: before.ino,
|
|
4445
|
+
recoveryFileDescriptor,
|
|
4446
|
+
recoveryDevice: recoveryBefore.dev,
|
|
4447
|
+
recoveryInode: recoveryBefore.ino,
|
|
4448
|
+
recoverySize: recoveryBefore.size,
|
|
4449
|
+
recoveryMtimeNs: recoveryBefore.mtimeNs,
|
|
4450
|
+
recoveryPath,
|
|
4451
|
+
};
|
|
4452
|
+
} catch {
|
|
4453
|
+
if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
|
|
4454
|
+
if (recoveryFileDescriptor !== undefined) fs.closeSync(recoveryFileDescriptor);
|
|
4455
|
+
if (recoveryPath) {
|
|
4456
|
+
try { fs.rmSync(recoveryPath); } catch { /* cleanup only */ }
|
|
4457
|
+
}
|
|
4458
|
+
throw new ClaimRepairError(
|
|
4459
|
+
'CLAIM_REPAIR_BACKUP_DRIFT',
|
|
4460
|
+
'Verified claim-repair backup changed before receipt commit',
|
|
4461
|
+
);
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
async function assertClaimRepairBackupFenceCurrent(fence, options = {}) {
|
|
4466
|
+
try {
|
|
4467
|
+
await (options.hardenPath || hardenBackupPermissions)(fence.backupPath);
|
|
4468
|
+
assertSafeBackupDestination(fence.databasePath, fence.backupPath);
|
|
4469
|
+
if (!options.sidecarsBlocked) assertNoClaimRepairBackupSidecars(fence.backupPath);
|
|
4470
|
+
const before = fs.fstatSync(fence.fileDescriptor, { bigint: true });
|
|
4471
|
+
const digest = hashOpenFileDescriptor(fence.fileDescriptor);
|
|
4472
|
+
const after = fs.fstatSync(fence.fileDescriptor, { bigint: true });
|
|
4473
|
+
const named = fs.statSync(fence.backupPath, { bigint: true });
|
|
4474
|
+
const recoveryBefore = fs.fstatSync(fence.recoveryFileDescriptor, { bigint: true });
|
|
4475
|
+
const recoveryDigest = hashOpenFileDescriptor(fence.recoveryFileDescriptor);
|
|
4476
|
+
const recoveryAfter = fs.fstatSync(fence.recoveryFileDescriptor, { bigint: true });
|
|
4477
|
+
const recoveryNamed = fs.statSync(fence.recoveryPath, { bigint: true });
|
|
4478
|
+
if (digest !== fence.backupSha256
|
|
4479
|
+
|| before.size !== after.size
|
|
4480
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
4481
|
+
|| before.dev !== fence.device
|
|
4482
|
+
|| before.ino !== fence.inode
|
|
4483
|
+
|| named.dev !== fence.device
|
|
4484
|
+
|| named.ino !== fence.inode
|
|
4485
|
+
|| recoveryDigest !== fence.backupSha256
|
|
4486
|
+
|| recoveryBefore.size !== recoveryAfter.size
|
|
4487
|
+
|| recoveryBefore.size !== fence.recoverySize
|
|
4488
|
+
|| recoveryBefore.mtimeNs !== recoveryAfter.mtimeNs
|
|
4489
|
+
|| recoveryBefore.mtimeNs !== fence.recoveryMtimeNs
|
|
4490
|
+
|| recoveryBefore.dev !== fence.recoveryDevice
|
|
4491
|
+
|| recoveryBefore.ino !== fence.recoveryInode
|
|
4492
|
+
|| recoveryNamed.dev !== fence.recoveryDevice
|
|
4493
|
+
|| recoveryNamed.ino !== fence.recoveryInode
|
|
4494
|
+
|| (recoveryNamed.dev === fence.device && recoveryNamed.ino === fence.inode)) {
|
|
4495
|
+
throw new Error('backup fence changed');
|
|
4496
|
+
}
|
|
4497
|
+
} catch {
|
|
4498
|
+
throw new ClaimRepairError(
|
|
4499
|
+
'CLAIM_REPAIR_BACKUP_DRIFT',
|
|
4500
|
+
'Verified claim-repair backup changed before receipt commit',
|
|
4501
|
+
);
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
function assertClaimRepairBackupSidecarsRemainAbsent(backupPath) {
|
|
4506
|
+
assertNoClaimRepairBackupSidecars(backupPath);
|
|
4507
|
+
const blockers = [];
|
|
4508
|
+
try {
|
|
4509
|
+
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
4510
|
+
const sidecarPath = `${backupPath}${suffix}`;
|
|
4511
|
+
fs.mkdirSync(sidecarPath);
|
|
4512
|
+
const stat = fs.statSync(sidecarPath, { bigint: true });
|
|
4513
|
+
blockers.push({ path: sidecarPath, device: stat.dev, inode: stat.ino });
|
|
4514
|
+
}
|
|
4515
|
+
return blockers;
|
|
4516
|
+
} catch (error) {
|
|
4517
|
+
for (const blocker of blockers) {
|
|
4518
|
+
try { fs.rmdirSync(blocker.path); } catch { /* cleanup only */ }
|
|
4519
|
+
}
|
|
4520
|
+
throw error;
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
function assertClaimRepairBackupSidecarBlockersCurrent(blockers = []) {
|
|
4525
|
+
for (const blocker of blockers) {
|
|
4526
|
+
const stat = fs.statSync(blocker.path, { bigint: true });
|
|
4527
|
+
if (!stat.isDirectory() || stat.dev !== blocker.device || stat.ino !== blocker.inode) {
|
|
4528
|
+
throw new Error('backup sidecar blocker changed');
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
function removeClaimRepairBackupSidecarBlockers(blockers = []) {
|
|
4534
|
+
for (const blocker of blockers) {
|
|
4535
|
+
try {
|
|
4536
|
+
const stat = fs.statSync(blocker.path, { bigint: true });
|
|
4537
|
+
if (stat.isDirectory() && stat.dev === blocker.device && stat.ino === blocker.inode) {
|
|
4538
|
+
fs.rmdirSync(blocker.path);
|
|
4539
|
+
}
|
|
4540
|
+
} catch { /* cleanup cannot change the committed receipt */ }
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
function closeClaimRepairBackupFence(fence, options = {}) {
|
|
4545
|
+
if (!fence) return;
|
|
4546
|
+
try { fs.closeSync(fence.fileDescriptor); } catch { /* descriptor cleanup cannot change a committed receipt */ }
|
|
4547
|
+
try { fs.closeSync(fence.recoveryFileDescriptor); } catch { /* descriptor cleanup cannot change a committed receipt */ }
|
|
4548
|
+
if (options.removeRecovery) {
|
|
4549
|
+
try {
|
|
4550
|
+
const recovery = fs.statSync(fence.recoveryPath, { bigint: true });
|
|
4551
|
+
if (recovery.dev === fence.recoveryDevice && recovery.ino === fence.recoveryInode) {
|
|
4552
|
+
fs.rmSync(fence.recoveryPath);
|
|
4553
|
+
}
|
|
4554
|
+
} catch { /* rolled-back cleanup cannot change authority */ }
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
function injectClaimRepairFault(driverOptions, phase) {
|
|
4559
|
+
if (typeof driverOptions.claimRepairFaultInjector === 'function') {
|
|
4560
|
+
driverOptions.claimRepairFaultInjector(phase);
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
function assertClaimRepairReplayInput(input = {}) {
|
|
4565
|
+
if (!/^[0-9a-f]{64}$/.test(String(input.approvedDigest || ''))) {
|
|
4566
|
+
throw new ClaimRepairError('CLAIM_REPAIR_APPROVAL_REQUIRED', 'Apply requires the exact approved preflight digest');
|
|
4567
|
+
}
|
|
4568
|
+
if (typeof input.actor !== 'string' || input.actor.trim() === '') {
|
|
4569
|
+
throw new ClaimRepairError('CLAIM_REPAIR_ACTOR_REQUIRED', 'Apply requires an explicit operator actor');
|
|
4570
|
+
}
|
|
4571
|
+
if (typeof input.observedAt !== 'string'
|
|
4572
|
+
|| !Number.isFinite(Date.parse(input.observedAt))
|
|
4573
|
+
|| new Date(Date.parse(input.observedAt)).toISOString() !== input.observedAt) {
|
|
4574
|
+
throw new ClaimRepairError('CLAIM_REPAIR_INVALID_TIME', 'Apply requires a fixed canonical UTC observation time');
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
|
|
4578
|
+
function updateExactClaimRepairAction(runtime, db, action) {
|
|
4579
|
+
const claim = action.claim;
|
|
4580
|
+
const result = runParams(
|
|
4581
|
+
runtime,
|
|
4582
|
+
db,
|
|
4583
|
+
`UPDATE kernel_claims SET state = ?
|
|
4584
|
+
WHERE id = ? AND issue_id = ? AND actor = ? AND state = 'active'
|
|
4585
|
+
AND session_id IS ? AND worktree_id IS ? AND claimed_at = ? AND expires_at IS ?
|
|
4586
|
+
AND EXISTS (
|
|
4587
|
+
SELECT 1 FROM kernel_issues WHERE id = ? AND status = ?
|
|
4588
|
+
)`,
|
|
4589
|
+
[
|
|
4590
|
+
action.to_state,
|
|
4591
|
+
claim.id,
|
|
4592
|
+
claim.issue_id,
|
|
4593
|
+
claim.actor,
|
|
4594
|
+
claim.session_id,
|
|
4595
|
+
claim.worktree_id,
|
|
4596
|
+
claim.claimed_at,
|
|
4597
|
+
claim.expires_at,
|
|
4598
|
+
claim.issue_id,
|
|
4599
|
+
action.issue_status,
|
|
4600
|
+
],
|
|
4601
|
+
);
|
|
4602
|
+
if (Number(result?.changes || 0) !== 1) {
|
|
4603
|
+
throw new ClaimRepairError(
|
|
4604
|
+
'CLAIM_REPAIR_CAS_CONFLICT',
|
|
4605
|
+
'Claim repair exact-row compare-and-swap rejected concurrent drift',
|
|
4606
|
+
);
|
|
4607
|
+
}
|
|
4608
|
+
}
|
|
4609
|
+
|
|
4610
|
+
const claimRepairApplyQueues = new Map();
|
|
4611
|
+
|
|
4612
|
+
function claimRepairApplyQueueKey(databasePath) {
|
|
4613
|
+
const canonicalPath = fs.realpathSync.native(databasePath);
|
|
4614
|
+
return process.platform === 'win32' ? canonicalPath.toLowerCase() : canonicalPath;
|
|
4615
|
+
}
|
|
4616
|
+
|
|
4617
|
+
async function withClaimRepairApplyQueue(databasePath, operation, queue = claimRepairApplyQueues) {
|
|
4618
|
+
const key = claimRepairApplyQueueKey(databasePath);
|
|
4619
|
+
const previous = queue.get(key) || Promise.resolve();
|
|
4620
|
+
let release;
|
|
4621
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
4622
|
+
const tail = previous.then(() => gate);
|
|
4623
|
+
queue.set(key, tail);
|
|
4624
|
+
await previous;
|
|
4625
|
+
try {
|
|
4626
|
+
return await operation();
|
|
4627
|
+
} finally {
|
|
4628
|
+
release();
|
|
4629
|
+
if (queue.get(key) === tail) queue.delete(key);
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
async function applyLegacyClaimRepairRow(runtime, db, input = {}, driverOptions = {}) {
|
|
4634
|
+
assertClaimRepairApplyInput(input);
|
|
4635
|
+
const idempotencyKey = `claim.repair:${input.approvedDigest}`;
|
|
4636
|
+
const hardenPath = driverOptions.hardenPath || hardenBackupPermissions;
|
|
4637
|
+
const hardenPaths = driverOptions.hardenPaths
|
|
4638
|
+
|| (driverOptions.hardenPath ? driverOptions.hardenPath.batch : hardenBackupPermissionsBatch);
|
|
4639
|
+
let backupFence;
|
|
4640
|
+
let sidecarBlockers = [];
|
|
4641
|
+
let committed = false;
|
|
4642
|
+
let rolledBack = false;
|
|
4643
|
+
execSql(runtime, db, 'BEGIN IMMEDIATE;');
|
|
4644
|
+
try {
|
|
4645
|
+
const existingRow = allParams(
|
|
4646
|
+
runtime,
|
|
4647
|
+
db,
|
|
4648
|
+
'SELECT * FROM kernel_events WHERE idempotency_key = ? LIMIT 1',
|
|
4649
|
+
[idempotencyKey],
|
|
4650
|
+
)[0];
|
|
4651
|
+
const existingReceipt = parseStoredClaimRepairReceipt(existingRow, input.approvedDigest, input.backupProof);
|
|
4652
|
+
if (existingReceipt) {
|
|
4653
|
+
await verifyClaimRepairRecovery(existingReceipt, input.backupPath, input.databasePath, { hardenPath });
|
|
4654
|
+
execSql(runtime, db, 'COMMIT;');
|
|
4655
|
+
return attachClaimRepairRecoveryPath({ ...existingReceipt, replayed: true }, input.backupPath);
|
|
4656
|
+
}
|
|
4657
|
+
const plan = buildClaimRepairPlan(loadLegacyClaimRepairSnapshot(runtime, db), {
|
|
4658
|
+
observedAt: input.observedAt,
|
|
4659
|
+
});
|
|
4660
|
+
if (plan.digest !== input.approvedDigest) {
|
|
4661
|
+
throw new ClaimRepairError(
|
|
4662
|
+
'CLAIM_REPAIR_DIGEST_DRIFT',
|
|
4663
|
+
'Live claim authority changed after approval; generate and approve a new fixed-time preflight',
|
|
4664
|
+
);
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
for (const action of plan.actions) updateExactClaimRepairAction(runtime, db, action);
|
|
4668
|
+
injectClaimRepairFault(driverOptions, 'after-mutations');
|
|
4669
|
+
const afterPlan = buildClaimRepairPlan(loadLegacyClaimRepairSnapshot(runtime, db), {
|
|
4670
|
+
observedAt: input.observedAt,
|
|
4671
|
+
});
|
|
4672
|
+
if (afterPlan.digest !== plan.afterDigest) {
|
|
4673
|
+
throw new ClaimRepairError('CLAIM_REPAIR_POSTCONDITION_FAILED', 'Claim repair postcondition digest did not match the approved plan');
|
|
4674
|
+
}
|
|
4675
|
+
injectClaimRepairFault(driverOptions, 'before-backup-commit-check');
|
|
4676
|
+
const recoveryReference = randomUUID();
|
|
4677
|
+
backupFence = await openClaimRepairBackupFence(
|
|
4678
|
+
input.databasePath,
|
|
4679
|
+
input.backupPath,
|
|
4680
|
+
input.backupProof,
|
|
4681
|
+
recoveryReference,
|
|
4682
|
+
{ hardenPath, hardenPaths },
|
|
4683
|
+
);
|
|
4684
|
+
injectClaimRepairFault(driverOptions, 'after-backup-fence');
|
|
4685
|
+
|
|
4686
|
+
const receiptId = randomUUID();
|
|
4687
|
+
const receipt = {
|
|
4688
|
+
schema_version: 'forge.claim-repair.receipt.v1',
|
|
4689
|
+
receipt_id: receiptId,
|
|
4690
|
+
observed_at: input.observedAt,
|
|
4691
|
+
approved_digest: input.approvedDigest,
|
|
4692
|
+
after_digest: afterPlan.digest,
|
|
4693
|
+
backup_sha256: input.backupProof.backup_sha256,
|
|
4694
|
+
recovery_ref: recoveryReference,
|
|
4695
|
+
mutations: {
|
|
4696
|
+
released: plan.actions.filter(action => action.to_state === 'released').length,
|
|
4697
|
+
reclaimable: plan.actions.filter(action => action.to_state === 'reclaimable').length,
|
|
4698
|
+
total: plan.actions.length,
|
|
4699
|
+
},
|
|
4700
|
+
replayed: false,
|
|
4701
|
+
};
|
|
4702
|
+
runParams(
|
|
4703
|
+
runtime,
|
|
4704
|
+
db,
|
|
4705
|
+
`INSERT INTO kernel_events (
|
|
4706
|
+
id, entity_type, entity_id, event_type, idempotency_key,
|
|
4707
|
+
expected_revision, actor, origin, payload_json, created_at
|
|
4708
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4709
|
+
[
|
|
4710
|
+
receiptId,
|
|
4711
|
+
'claim_repair',
|
|
4712
|
+
'legacy_claims',
|
|
4713
|
+
'claim.repair',
|
|
4714
|
+
idempotencyKey,
|
|
4715
|
+
0,
|
|
4716
|
+
input.actor,
|
|
4717
|
+
'forge.claim-repair',
|
|
4718
|
+
JSON.stringify(receipt),
|
|
4719
|
+
new Date().toISOString(),
|
|
4720
|
+
],
|
|
4721
|
+
);
|
|
4722
|
+
injectClaimRepairFault(driverOptions, 'after-receipt-before-commit');
|
|
4723
|
+
await assertClaimRepairBackupFenceCurrent(backupFence, { hardenPath });
|
|
4724
|
+
sidecarBlockers = assertClaimRepairBackupSidecarsRemainAbsent(input.backupPath);
|
|
4725
|
+
await assertClaimRepairBackupFenceCurrent(backupFence, { hardenPath, sidecarsBlocked: true });
|
|
4726
|
+
execSql(runtime, db, 'COMMIT;');
|
|
4727
|
+
committed = true;
|
|
4728
|
+
injectClaimRepairFault(driverOptions, 'after-commit-before-backup-check');
|
|
4729
|
+
try {
|
|
4730
|
+
await assertClaimRepairBackupFenceCurrent(backupFence, { hardenPath, sidecarsBlocked: true });
|
|
4731
|
+
assertClaimRepairBackupSidecarBlockersCurrent(sidecarBlockers);
|
|
4732
|
+
} catch {
|
|
4733
|
+
throw new ClaimRepairError(
|
|
4734
|
+
'CLAIM_REPAIR_BACKUP_POSTCOMMIT_DRIFT',
|
|
4735
|
+
'Claim repair committed, but the named backup changed; preserve the verified recovery path',
|
|
4736
|
+
{ recovery_path: backupFence.recoveryPath },
|
|
4737
|
+
);
|
|
4738
|
+
}
|
|
4739
|
+
injectClaimRepairFault(driverOptions, 'after-postcommit-backup-check');
|
|
4740
|
+
return attachClaimRepairRecoveryPath(receipt, input.backupPath);
|
|
4741
|
+
} catch (error) {
|
|
4742
|
+
if (!committed) {
|
|
4743
|
+
try {
|
|
4744
|
+
execSql(runtime, db, 'ROLLBACK;');
|
|
4745
|
+
rolledBack = true;
|
|
4746
|
+
} catch { /* preserve the original failure and recovery copy */ }
|
|
4747
|
+
}
|
|
4748
|
+
throw error;
|
|
4749
|
+
} finally {
|
|
4750
|
+
removeClaimRepairBackupSidecarBlockers(sidecarBlockers);
|
|
4751
|
+
closeClaimRepairBackupFence(backupFence, { removeRecovery: rolledBack });
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
function createDriver(runtime, configuredDatabasePath, driverOptions = {}) {
|
|
4756
|
+
let db;
|
|
4757
|
+
let openedDatabasePath;
|
|
4758
|
+
let memorySchemaEnsured = false;
|
|
4759
|
+
let usageEvidenceSchemaEnsured = false;
|
|
4760
|
+
|
|
4761
|
+
// kernel_memories is created by migration 005 through broker.initialize(), but the
|
|
4762
|
+
// synchronous project-memory facade writes WITHOUT first running migrations. Lazily
|
|
4763
|
+
// ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
|
|
4764
|
+
// plus a busy_timeout for the second connection the issue backend may hold open.
|
|
4765
|
+
function ensureMemorySchema(database, busyTimeoutMs) {
|
|
4766
|
+
if (memorySchemaEnsured) return;
|
|
4767
|
+
const requestedBusyTimeout = Number(busyTimeoutMs);
|
|
4768
|
+
const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
|
|
4769
|
+
? Math.floor(requestedBusyTimeout)
|
|
4770
|
+
: 5_000;
|
|
4771
|
+
execSql(runtime, database, `PRAGMA busy_timeout=${busyTimeout};`);
|
|
4772
|
+
try {
|
|
4773
|
+
for (const statement of buildMemoryProjectionMigration().apply) {
|
|
4774
|
+
execSql(runtime, database, statement);
|
|
4775
|
+
}
|
|
4776
|
+
// FTS5 recall index (migration 008): create the virtual table + sync triggers
|
|
4777
|
+
// idempotently so a synchronous memory write stays indexed without a prior
|
|
4778
|
+
// broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
|
|
4779
|
+
// rows written before it existed (a DB upgraded from before this feature, or rows the
|
|
4780
|
+
// insights engine wrote straight to kernel_memories) — the sync triggers keep it
|
|
4781
|
+
// current thereafter, so steady-state process starts skip the reindex.
|
|
4782
|
+
//
|
|
4783
|
+
// Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
|
|
4784
|
+
// external-content FTS5 table `count(*)` returns the CONTENT row count, not the
|
|
4785
|
+
// indexed-doc count, so it can never reveal an un-backfilled index.
|
|
4786
|
+
const ftsDdl = memoryFtsDdl();
|
|
4787
|
+
const ftsExisted = Number(queryOne(
|
|
4788
|
+
runtime,
|
|
4789
|
+
database,
|
|
4790
|
+
"SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
|
|
4791
|
+
).count) > 0;
|
|
4792
|
+
execSql(runtime, database, ftsDdl.create);
|
|
4793
|
+
for (const trigger of ftsDdl.triggers) {
|
|
4794
|
+
execSql(runtime, database, trigger);
|
|
4795
|
+
}
|
|
4796
|
+
if (!ftsExisted) {
|
|
4797
|
+
execSql(runtime, database, ftsDdl.rebuild);
|
|
4798
|
+
}
|
|
4799
|
+
memorySchemaEnsured = true;
|
|
4800
|
+
} finally {
|
|
4801
|
+
if (busyTimeout !== 5_000) {
|
|
4802
|
+
execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
function ensureUsageEvidenceSchema(database) {
|
|
4808
|
+
if (usageEvidenceSchemaEnsured) return;
|
|
4809
|
+
for (const statement of buildUsageEvidenceMigration().apply) {
|
|
4810
|
+
execSql(runtime, database, statement);
|
|
4811
|
+
}
|
|
4812
|
+
usageEvidenceSchemaEnsured = true;
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4815
|
+
function resolveDatabasePath(config) {
|
|
4816
|
+
const brokerDatabasePath = config && config.databasePath;
|
|
4817
|
+
if (configuredDatabasePath && brokerDatabasePath && configuredDatabasePath !== brokerDatabasePath) {
|
|
4818
|
+
throw new Error([
|
|
4819
|
+
'Kernel SQLite driver databasePath mismatch:',
|
|
4820
|
+
`driver is configured for ${configuredDatabasePath}`,
|
|
4821
|
+
`but broker config uses ${brokerDatabasePath}`,
|
|
4822
|
+
].join(' '));
|
|
4823
|
+
}
|
|
4824
|
+
const databasePath = brokerDatabasePath || configuredDatabasePath;
|
|
4825
|
+
if (!databasePath) {
|
|
4826
|
+
throw new Error('Kernel SQLite driver requires a databasePath or broker config databasePath');
|
|
4827
|
+
}
|
|
4828
|
+
return databasePath;
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4831
|
+
function getDatabase(config) {
|
|
4832
|
+
const databasePath = resolveDatabasePath(config);
|
|
2281
4833
|
if (!db) {
|
|
2282
4834
|
db = createDatabase(runtime, databasePath);
|
|
4835
|
+
execSql(runtime, db, 'PRAGMA foreign_keys=ON;');
|
|
2283
4836
|
openedDatabasePath = databasePath;
|
|
2284
4837
|
} else if (openedDatabasePath !== databasePath) {
|
|
2285
4838
|
throw new Error(`Kernel SQLite driver is already open for ${openedDatabasePath}`);
|
|
@@ -2287,7 +4840,7 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2287
4840
|
return db;
|
|
2288
4841
|
}
|
|
2289
4842
|
|
|
2290
|
-
|
|
4843
|
+
const driver = {
|
|
2291
4844
|
runtime: {
|
|
2292
4845
|
id: runtime.id,
|
|
2293
4846
|
databaseClassName: runtime.databaseClassName,
|
|
@@ -2295,12 +4848,91 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2295
4848
|
experimental: runtime.experimental,
|
|
2296
4849
|
},
|
|
2297
4850
|
databasePath: configuredDatabasePath,
|
|
4851
|
+
forkConnection(config = {}) {
|
|
4852
|
+
const databasePath = resolveDatabasePath(config);
|
|
4853
|
+
if (databasePath === ':memory:') {
|
|
4854
|
+
const fork = Object.create(driver);
|
|
4855
|
+
Object.defineProperty(fork, 'close', { value() {} });
|
|
4856
|
+
Object.defineProperty(fork, 'transactionQueueKey', { value: driver });
|
|
4857
|
+
return fork;
|
|
4858
|
+
}
|
|
4859
|
+
return createDriver(runtime, databasePath, driverOptions);
|
|
4860
|
+
},
|
|
2298
4861
|
async exec(statement, config) {
|
|
2299
4862
|
execSql(runtime, getDatabase(config), statement);
|
|
2300
4863
|
},
|
|
2301
4864
|
async queryAll(statement, config) {
|
|
2302
4865
|
return queryAll(runtime, getDatabase(config), statement);
|
|
2303
4866
|
},
|
|
4867
|
+
watchOwnerRead(input, config = {}) {
|
|
4868
|
+
return readWatchOwner(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, input);
|
|
4869
|
+
},
|
|
4870
|
+
watchOwnerList(config = {}) {
|
|
4871
|
+
return listWatchOwners(runtime, resolveDatabasePath(config), { ...driverOptions, ...config });
|
|
4872
|
+
},
|
|
4873
|
+
watchOwnerReserveStarting(input, config = {}) {
|
|
4874
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'reserveStarting', input);
|
|
4875
|
+
},
|
|
4876
|
+
watchOwnerReserveReopened(input, config = {}) {
|
|
4877
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'reserveReopened', input);
|
|
4878
|
+
},
|
|
4879
|
+
watchOwnerBindRunning(input, config = {}) {
|
|
4880
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'bindRunning', input);
|
|
4881
|
+
},
|
|
4882
|
+
watchOwnerHeartbeat(input, config = {}) {
|
|
4883
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'heartbeat', input);
|
|
4884
|
+
},
|
|
4885
|
+
watchOwnerRequestStop(input, config = {}) {
|
|
4886
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'requestStop', input);
|
|
4887
|
+
},
|
|
4888
|
+
watchOwnerRecordTerminal(input, config = {}) {
|
|
4889
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recordTerminal', input);
|
|
4890
|
+
},
|
|
4891
|
+
watchOwnerCompleteTerminal(input, config = {}) {
|
|
4892
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'completeTerminal', input);
|
|
4893
|
+
},
|
|
4894
|
+
watchOwnerAbortStarting(input, config = {}) {
|
|
4895
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'abortStarting', input);
|
|
4896
|
+
},
|
|
4897
|
+
watchOwnerReleaseNonterminal(input, config = {}) {
|
|
4898
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'releaseNonterminal', input);
|
|
4899
|
+
},
|
|
4900
|
+
watchOwnerRecoverDeadStarting(input, config = {}) {
|
|
4901
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recoverDeadStarting', input);
|
|
4902
|
+
},
|
|
4903
|
+
watchOwnerRecoverDeadWatcher(input, config = {}) {
|
|
4904
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recoverDeadWatcher', input);
|
|
4905
|
+
},
|
|
4906
|
+
watchOwnerMarkLegacyBlocked(input, config = {}) {
|
|
4907
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'markLegacyBlocked', input);
|
|
4908
|
+
},
|
|
4909
|
+
watchOwnerRecheckLegacyBlocked(input, config = {}) {
|
|
4910
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'recheckLegacyBlocked', input);
|
|
4911
|
+
},
|
|
4912
|
+
watchOwnerImportLegacyStarting(input, config = {}) {
|
|
4913
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'importLegacyStarting', input);
|
|
4914
|
+
},
|
|
4915
|
+
watchOwnerImportLegacyComplete(input, config = {}) {
|
|
4916
|
+
return applyWatchOwnerOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'importLegacyComplete', input);
|
|
4917
|
+
},
|
|
4918
|
+
watchGateRead(_input = {}, config = {}) {
|
|
4919
|
+
return readWatchGate(runtime, resolveDatabasePath(config), { ...driverOptions, ...config });
|
|
4920
|
+
},
|
|
4921
|
+
watchGatePublishQuarantine(input, config = {}) {
|
|
4922
|
+
return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'publishQuarantine', input);
|
|
4923
|
+
},
|
|
4924
|
+
watchGateBindSnapshot(input, config = {}) {
|
|
4925
|
+
return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'bindSnapshot', input);
|
|
4926
|
+
},
|
|
4927
|
+
watchGatePublishConflict(input, config = {}) {
|
|
4928
|
+
return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'publishConflict', input);
|
|
4929
|
+
},
|
|
4930
|
+
watchGateRetryConflict(input, config = {}) {
|
|
4931
|
+
return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'retryConflict', input);
|
|
4932
|
+
},
|
|
4933
|
+
watchGateCompleteMigration(input, config = {}) {
|
|
4934
|
+
return applyWatchGateOperation(runtime, resolveDatabasePath(config), { ...driverOptions, ...config }, 'completeMigration', input);
|
|
4935
|
+
},
|
|
2304
4936
|
async issueOperation(operation, args = [], context = {}, config = {}) {
|
|
2305
4937
|
const database = getDatabase(config);
|
|
2306
4938
|
const READ_OPERATIONS = new Set(['ready', 'list', 'show', 'search', 'stats', 'blocked', 'stale', 'orphans', 'lint', 'children', 'owns', 'claims']);
|
|
@@ -2396,7 +5028,22 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2396
5028
|
registeredAt,
|
|
2397
5029
|
],
|
|
2398
5030
|
);
|
|
2399
|
-
|
|
5031
|
+
const persisted = allParams(
|
|
5032
|
+
runtime,
|
|
5033
|
+
getDatabase(config),
|
|
5034
|
+
'SELECT * FROM kernel_pr WHERE git_common_dir = ? AND repo = ? AND number = ? LIMIT 1',
|
|
5035
|
+
[row.git_common_dir, row.repo, row.number],
|
|
5036
|
+
)[0];
|
|
5037
|
+
if (!persisted) {
|
|
5038
|
+
throw new Error(`Kernel SQLite driver upsertPr: no kernel_pr row after upsert for ${row.repo}#${row.number}`);
|
|
5039
|
+
}
|
|
5040
|
+
return { ok: true, ...persisted };
|
|
5041
|
+
},
|
|
5042
|
+
async resolvePrLinkage(input, _context = {}, config = {}) {
|
|
5043
|
+
return resolvePrLinkageRow(runtime, getDatabase(config), input);
|
|
5044
|
+
},
|
|
5045
|
+
async readTrace(target, _context = {}, config = {}) {
|
|
5046
|
+
return loadTraceRows(runtime, getDatabase(config), target);
|
|
2400
5047
|
},
|
|
2401
5048
|
// The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
|
|
2402
5049
|
// PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
|
|
@@ -2462,6 +5109,11 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2462
5109
|
async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
|
|
2463
5110
|
return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
|
|
2464
5111
|
},
|
|
5112
|
+
async loadPrEventsByRunId(runId, _context = {}, config = {}) {
|
|
5113
|
+
return allParams(runtime, getDatabase(config),
|
|
5114
|
+
"SELECT * FROM kernel_events WHERE entity_type = 'pr' AND json_extract(payload_json, '$.run_id') = ? ORDER BY created_at ASC, rowid ASC",
|
|
5115
|
+
[runId]);
|
|
5116
|
+
},
|
|
2465
5117
|
async insertKernelConflict(conflict, _context = {}, config = {}) {
|
|
2466
5118
|
return insertKernelConflictRow(runtime, getDatabase(config), conflict);
|
|
2467
5119
|
},
|
|
@@ -2504,6 +5156,69 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2504
5156
|
async updateKernelClaimState(claimId, state, _context = {}, config = {}) {
|
|
2505
5157
|
return updateKernelClaimStateRow(runtime, getDatabase(config), claimId, state);
|
|
2506
5158
|
},
|
|
5159
|
+
async listActiveClaims(_context = {}, config = {}) {
|
|
5160
|
+
return listActiveKernelClaimRows(runtime, getDatabase(config));
|
|
5161
|
+
},
|
|
5162
|
+
async releaseExactClaim(claim, _evidence = {}, config = {}) {
|
|
5163
|
+
return releaseExactKernelClaimRow(runtime, getDatabase(config), claim);
|
|
5164
|
+
},
|
|
5165
|
+
async releaseExactClaimIfWorktreeMissing(claim, worktree, isMissing, _evidence = {}, config = {}) {
|
|
5166
|
+
return releaseExactKernelClaimIfWorktreeMissing(
|
|
5167
|
+
runtime, getDatabase(config), claim, worktree, isMissing,
|
|
5168
|
+
);
|
|
5169
|
+
},
|
|
5170
|
+
// Explicit operator-only legacy data repair. This surface is never called by
|
|
5171
|
+
// broker initialization or normal claim acquisition: dry-run is immutable,
|
|
5172
|
+
// while apply requires a separately verified backup and approved exact digest.
|
|
5173
|
+
async preflightLegacyClaimRepair(input = {}, config = {}) {
|
|
5174
|
+
return preflightLegacyClaimRepairRow(runtime, getDatabase(config), input);
|
|
5175
|
+
},
|
|
5176
|
+
async applyLegacyClaimRepair(input = {}, config = {}) {
|
|
5177
|
+
assertClaimRepairReplayInput(input);
|
|
5178
|
+
const databasePath = resolveDatabasePath(config);
|
|
5179
|
+
return withClaimRepairApplyQueue(databasePath, async () => {
|
|
5180
|
+
const database = createDatabase(runtime, databasePath);
|
|
5181
|
+
const hardenPath = driverOptions.hardenPath || hardenBackupPermissions;
|
|
5182
|
+
const hardenPaths = driverOptions.hardenPaths
|
|
5183
|
+
|| (driverOptions.hardenPath ? driverOptions.hardenPath.batch : hardenBackupPermissionsBatch);
|
|
5184
|
+
try {
|
|
5185
|
+
execSql(runtime, database, 'PRAGMA foreign_keys=ON;');
|
|
5186
|
+
const replayed = replayStoredClaimRepairReceipt(
|
|
5187
|
+
runtime,
|
|
5188
|
+
database,
|
|
5189
|
+
input.approvedDigest,
|
|
5190
|
+
input.observedAt,
|
|
5191
|
+
input.backupPath,
|
|
5192
|
+
);
|
|
5193
|
+
if (replayed) {
|
|
5194
|
+
await verifyClaimRepairRecovery(replayed, input.backupPath, databasePath, { hardenPath });
|
|
5195
|
+
return replayed;
|
|
5196
|
+
}
|
|
5197
|
+
if (!input.backupPath) {
|
|
5198
|
+
throw new ClaimRepairError(
|
|
5199
|
+
'CLAIM_REPAIR_BACKUP_PROOF_REQUIRED',
|
|
5200
|
+
'Apply requires the path to a separately verified SQLite backup',
|
|
5201
|
+
);
|
|
5202
|
+
}
|
|
5203
|
+
assertSafeBackupDestination(databasePath, input.backupPath);
|
|
5204
|
+
const backupProof = await verifyClaimRepairBackup({
|
|
5205
|
+
backupPath: input.backupPath,
|
|
5206
|
+
observedAt: input.observedAt,
|
|
5207
|
+
openDriver: restorePath => createDriver(runtime, restorePath),
|
|
5208
|
+
hardenPath,
|
|
5209
|
+
hardenPaths,
|
|
5210
|
+
});
|
|
5211
|
+
return await applyLegacyClaimRepairRow(
|
|
5212
|
+
runtime,
|
|
5213
|
+
database,
|
|
5214
|
+
{ ...input, databasePath, backupProof },
|
|
5215
|
+
driverOptions,
|
|
5216
|
+
);
|
|
5217
|
+
} finally {
|
|
5218
|
+
closeDatabase(database);
|
|
5219
|
+
}
|
|
5220
|
+
}, driverOptions.claimRepairApplyQueue);
|
|
5221
|
+
},
|
|
2507
5222
|
// commitGuardedAccept invokes this (typeof-guarded) INSIDE its BEGIN IMMEDIATE
|
|
2508
5223
|
// transaction to apply an accepted issue event to the authority tables. The
|
|
2509
5224
|
// returned summary ({id, revision, comment_id?}) flows back through
|
|
@@ -2551,6 +5266,12 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2551
5266
|
recordStageTransition(input, config = {}) {
|
|
2552
5267
|
return recordStageTransitionRow(runtime, getDatabase(config), input);
|
|
2553
5268
|
},
|
|
5269
|
+
recordPlanSnapshotTransition(input, config = {}) {
|
|
5270
|
+
return recordPlanSnapshotTransitionRow(runtime, getDatabase(config), input);
|
|
5271
|
+
},
|
|
5272
|
+
loadPlanSnapshot(filter = {}, config = {}) {
|
|
5273
|
+
return loadPlanSnapshotRow(runtime, getDatabase(config), filter.issue_id);
|
|
5274
|
+
},
|
|
2554
5275
|
listStageRuns(filter = {}, config = {}) {
|
|
2555
5276
|
return listStageRunRows(runtime, getDatabase(config), filter.issue_id);
|
|
2556
5277
|
},
|
|
@@ -2577,7 +5298,7 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2577
5298
|
searchMemoriesRanked(query, limit, config = {}) {
|
|
2578
5299
|
const database = getDatabase(config);
|
|
2579
5300
|
ensureMemorySchema(database);
|
|
2580
|
-
return searchMemoryRowsRanked(runtime, database, query, limit);
|
|
5301
|
+
return searchMemoryRowsRanked(runtime, database, query, limit, config);
|
|
2581
5302
|
},
|
|
2582
5303
|
// Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
|
|
2583
5304
|
// caller can apply a relevance floor. A no-match/empty query returns [] (no recency
|
|
@@ -2592,27 +5313,96 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2592
5313
|
recentMemories(limit, options = {}, config = {}) {
|
|
2593
5314
|
const database = getDatabase(config);
|
|
2594
5315
|
ensureMemorySchema(database);
|
|
2595
|
-
return recentMemoryRows(runtime, database, limit, options
|
|
5316
|
+
return recentMemoryRows(runtime, database, limit, options);
|
|
2596
5317
|
},
|
|
2597
|
-
// Total stored memories (optionally scoped by `options.agents`) — lets recall report
|
|
5318
|
+
// Total stored memories (optionally scoped by `options.agents` and `options.kind`) — lets recall report
|
|
2598
5319
|
// "showing N of TOTAL".
|
|
2599
5320
|
countMemories(options = {}, config = {}) {
|
|
2600
5321
|
const database = getDatabase(config);
|
|
2601
5322
|
ensureMemorySchema(database);
|
|
2602
|
-
return countMemoryRows(runtime, database, options
|
|
5323
|
+
return countMemoryRows(runtime, database, options);
|
|
5324
|
+
},
|
|
5325
|
+
appendUsageEvidence(event, config = {}) {
|
|
5326
|
+
const database = getDatabase(config);
|
|
5327
|
+
ensureUsageEvidenceSchema(database);
|
|
5328
|
+
return appendUsageEvidence(createUsageEvidenceAdapter(runtime, database), event);
|
|
5329
|
+
},
|
|
5330
|
+
rebuildUsageProjection(config = {}) {
|
|
5331
|
+
const database = getDatabase(config);
|
|
5332
|
+
ensureUsageEvidenceSchema(database);
|
|
5333
|
+
return rebuildUsageProjection(createUsageEvidenceAdapter(runtime, database));
|
|
5334
|
+
},
|
|
5335
|
+
loadUsageProjection(memoryId, config = {}) {
|
|
5336
|
+
const database = getDatabase(config);
|
|
5337
|
+
ensureUsageEvidenceSchema(database);
|
|
5338
|
+
return allParams(runtime, database,
|
|
5339
|
+
'SELECT scope, memory_id, last_used_at, use_count FROM memory_usage_projection WHERE memory_id = ?', [memoryId])[0] || null;
|
|
5340
|
+
},
|
|
5341
|
+
loadUsageProjections(memoryIds = [], config = {}) {
|
|
5342
|
+
const database = getDatabase(config);
|
|
5343
|
+
ensureUsageEvidenceSchema(database);
|
|
5344
|
+
const ids = [...new Set(Array.isArray(memoryIds) ? memoryIds : [])];
|
|
5345
|
+
if (ids.length === 0) return [];
|
|
5346
|
+
return allParams(runtime, database,
|
|
5347
|
+
`SELECT scope, memory_id, last_used_at, use_count FROM memory_usage_projection WHERE memory_id IN (${ids.map(() => '?').join(', ')})`, ids);
|
|
2603
5348
|
},
|
|
2604
5349
|
listMemories(config = {}) {
|
|
2605
5350
|
const database = getDatabase(config);
|
|
2606
5351
|
ensureMemorySchema(database);
|
|
2607
5352
|
return listMemoryRows(runtime, database);
|
|
2608
5353
|
},
|
|
5354
|
+
// --- Memory-owned monitor durability. Public callers use @forge/memory, while
|
|
5355
|
+
// these primitives independently reject malformed/private data as defense in depth.
|
|
5356
|
+
async appendMonitorEvent(envelope, targets = [], config = {}) {
|
|
5357
|
+
const database = getDatabase(config);
|
|
5358
|
+
const safeEnvelope = assertMonitorEnvelope(envelope, 'forge.memory.monitor-event.v1');
|
|
5359
|
+
return appendMonitorEventRow(runtime, database, safeEnvelope, normalizeMonitorTargets(targets), config);
|
|
5360
|
+
},
|
|
5361
|
+
async recordMonitorDeliveryReceipt(envelope, config = {}) {
|
|
5362
|
+
const database = getDatabase(config);
|
|
5363
|
+
const safeEnvelope = assertMonitorEnvelope(envelope, 'forge.memory.delivery-receipt.v1');
|
|
5364
|
+
assertMonitorTarget(safeEnvelope.payload.target);
|
|
5365
|
+
return recordMonitorDeliveryReceiptRow(runtime, database, safeEnvelope, config);
|
|
5366
|
+
},
|
|
5367
|
+
async recordMonitorTerminalReceipt(envelope, config = {}) {
|
|
5368
|
+
const database = getDatabase(config);
|
|
5369
|
+
const safeEnvelope = assertMonitorEnvelope(envelope, 'forge.memory.monitor-receipt.v1');
|
|
5370
|
+
return recordMonitorTerminalReceiptRow(runtime, database, safeEnvelope, config);
|
|
5371
|
+
},
|
|
5372
|
+
async getMonitorEvent(eventId, config = {}) {
|
|
5373
|
+
if (typeof eventId !== 'string' || !eventId || eventId.length > 255) {
|
|
5374
|
+
throw new TypeError('eventId must be a bounded non-empty string');
|
|
5375
|
+
}
|
|
5376
|
+
return getMonitorEventRow(runtime, getDatabase(config), eventId);
|
|
5377
|
+
},
|
|
5378
|
+
async readMonitorEventTail(monitorId, options = {}, config = {}) {
|
|
5379
|
+
return readMonitorEventTailRows(runtime, getDatabase(config), monitorId, options);
|
|
5380
|
+
},
|
|
5381
|
+
async readMonitorDeliveryState(monitorId, options = {}, config = {}) {
|
|
5382
|
+
return readMonitorDeliveryStateRows(runtime, getDatabase(config), monitorId, options);
|
|
5383
|
+
},
|
|
5384
|
+
async listMonitorEvents(monitorId, config = {}) {
|
|
5385
|
+
return listMonitorEventRows(runtime, getDatabase(config), monitorId);
|
|
5386
|
+
},
|
|
5387
|
+
async backup(destinationPath, config = {}, backupOptions = {}) {
|
|
5388
|
+
const databasePath = resolveDatabasePath(config);
|
|
5389
|
+
return createSafeBackup(
|
|
5390
|
+
runtime,
|
|
5391
|
+
getDatabase(config),
|
|
5392
|
+
databasePath,
|
|
5393
|
+
destinationPath,
|
|
5394
|
+
{ ...driverOptions, ...backupOptions },
|
|
5395
|
+
);
|
|
5396
|
+
},
|
|
2609
5397
|
close() {
|
|
2610
5398
|
closeDatabase(db);
|
|
2611
5399
|
db = null;
|
|
2612
5400
|
openedDatabasePath = null;
|
|
2613
5401
|
memorySchemaEnsured = false;
|
|
5402
|
+
usageEvidenceSchemaEnsured = false;
|
|
2614
5403
|
},
|
|
2615
5404
|
};
|
|
5405
|
+
return driver;
|
|
2616
5406
|
}
|
|
2617
5407
|
|
|
2618
5408
|
function assertCapability(runtime, capability, detail) {
|
|
@@ -2719,13 +5509,354 @@ async function createBackup(runtime, db, backupPath) {
|
|
|
2719
5509
|
if (typeof db.serialize !== 'function') {
|
|
2720
5510
|
throw new Error('bun:sqlite Database.serialize() is unavailable');
|
|
2721
5511
|
}
|
|
2722
|
-
fs.writeFileSync(backupPath, db.serialize());
|
|
5512
|
+
fs.writeFileSync(backupPath, db.serialize(), { mode: 0o600 });
|
|
2723
5513
|
return;
|
|
2724
5514
|
}
|
|
2725
5515
|
|
|
2726
5516
|
throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
|
|
2727
5517
|
}
|
|
2728
5518
|
|
|
5519
|
+
function comparableFilePath(filePath) {
|
|
5520
|
+
const resolved = path.resolve(filePath);
|
|
5521
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5524
|
+
function assertSafeBackupDestination(databasePath, backupPath) {
|
|
5525
|
+
if (!backupPath || databasePath === ':memory:' || String(databasePath).startsWith('file:')) {
|
|
5526
|
+
throw new Error('SQLite backup requires distinct file-backed source and destination paths');
|
|
5527
|
+
}
|
|
5528
|
+
const source = comparableFilePath(databasePath);
|
|
5529
|
+
const destination = comparableFilePath(backupPath);
|
|
5530
|
+
if ([source, `${source}-wal`, `${source}-shm`, `${source}-journal`].includes(destination)) {
|
|
5531
|
+
throw new Error('Backup destination must not alias the live SQLite database or its sidecars');
|
|
5532
|
+
}
|
|
5533
|
+
if (fs.existsSync(backupPath)) {
|
|
5534
|
+
const destinationStat = fs.statSync(backupPath, { bigint: true });
|
|
5535
|
+
for (const sourcePath of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`, `${databasePath}-journal`]) {
|
|
5536
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
5537
|
+
const sourceStat = fs.statSync(sourcePath, { bigint: true });
|
|
5538
|
+
if (sourceStat.dev === destinationStat.dev && sourceStat.ino === destinationStat.ino) {
|
|
5539
|
+
throw new Error('Backup destination must not alias the live SQLite database or its sidecars');
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
}
|
|
5544
|
+
|
|
5545
|
+
function verifyBackupFile(runtime, backupPath) {
|
|
5546
|
+
const backupDb = createDatabase(runtime, backupPath);
|
|
5547
|
+
try {
|
|
5548
|
+
const row = queryOne(runtime, backupDb, 'PRAGMA integrity_check;');
|
|
5549
|
+
if (String(row.integrity_check || '').toLowerCase() !== 'ok') {
|
|
5550
|
+
throw new Error('SQLite backup integrity verification failed');
|
|
5551
|
+
}
|
|
5552
|
+
} finally {
|
|
5553
|
+
closeDatabase(backupDb);
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5557
|
+
async function hardenBackupPermissions(filePath, options = {}) {
|
|
5558
|
+
const platform = options.platform || process.platform;
|
|
5559
|
+
const fsApi = options.fsApi || fs;
|
|
5560
|
+
const effectiveUserId = options.effectiveUserId ?? (typeof process.geteuid === 'function' ? process.geteuid() : null);
|
|
5561
|
+
try {
|
|
5562
|
+
if (platform === 'win32') {
|
|
5563
|
+
const aclSecurer = options.aclSecurer || secureWindowsPathAcl;
|
|
5564
|
+
await aclSecurer(filePath);
|
|
5565
|
+
return;
|
|
5566
|
+
}
|
|
5567
|
+
const initial = fsApi.statSync(filePath);
|
|
5568
|
+
if (effectiveUserId !== null && Number(initial.uid) !== Number(effectiveUserId)) {
|
|
5569
|
+
throw new Error('path is not owned by the current effective user');
|
|
5570
|
+
}
|
|
5571
|
+
const ownerMode = typeof initial.isDirectory === 'function' && initial.isDirectory() ? 0o700 : 0o600;
|
|
5572
|
+
fsApi.chmodSync(filePath, ownerMode);
|
|
5573
|
+
if ((Number(fsApi.statSync(filePath).mode) & 0o077) !== 0) {
|
|
5574
|
+
throw new Error(`mode remains broader than ${ownerMode.toString(8)}`);
|
|
5575
|
+
}
|
|
5576
|
+
} catch (error) {
|
|
5577
|
+
throw new ClaimRepairError(
|
|
5578
|
+
'CLAIM_REPAIR_BACKUP_PERMISSIONS',
|
|
5579
|
+
'SQLite backup could not be secured with owner-only permissions',
|
|
5580
|
+
{ cause: error.message || String(error) },
|
|
5581
|
+
);
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5584
|
+
|
|
5585
|
+
async function hardenBackupPermissionsBatch(filePaths, options = {}) {
|
|
5586
|
+
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
|
|
5587
|
+
const paths = [...new Set(filePaths)];
|
|
5588
|
+
const platform = options.platform || process.platform;
|
|
5589
|
+
if (platform === 'win32' && !options.aclSecurer && !options.fsApi) {
|
|
5590
|
+
try {
|
|
5591
|
+
// One owner readback serves the whole batch after each DACL is secured.
|
|
5592
|
+
await secureWindowsPathsAcl(paths);
|
|
5593
|
+
return;
|
|
5594
|
+
} catch (error) {
|
|
5595
|
+
throw new ClaimRepairError(
|
|
5596
|
+
'CLAIM_REPAIR_BACKUP_PERMISSIONS',
|
|
5597
|
+
'SQLite backup could not be secured with owner-only permissions',
|
|
5598
|
+
{ cause: error.message || String(error) },
|
|
5599
|
+
);
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
for (const filePath of paths) await hardenBackupPermissions(filePath, options);
|
|
5603
|
+
}
|
|
5604
|
+
|
|
5605
|
+
const WINDOWS_PRIVATE_ACL_SCRIPT_PATH = path.join(__dirname, 'windows-private-acl.js');
|
|
5606
|
+
const WINDOWS_PRIVATE_ACL_SCRIPT_RELATIVE_PATH = path.join('lib', 'kernel', 'windows-private-acl.js');
|
|
5607
|
+
const WINDOWS_PRIVATE_ACL_SCRIPT_SHA256 = '97f44c740bb843fcca0ea158e21d201b4e34b81eb1636a6a9e9410803a31e349';
|
|
5608
|
+
|
|
5609
|
+
function resolveWindowsSystemExecutable(name, environment = process.env) {
|
|
5610
|
+
const systemRoot = environment.SystemRoot;
|
|
5611
|
+
if (typeof systemRoot !== 'string' || !path.win32.isAbsolute(systemRoot)) {
|
|
5612
|
+
throw new Error('Windows backup hardening requires an absolute SystemRoot');
|
|
5613
|
+
}
|
|
5614
|
+
return path.win32.join(systemRoot, 'System32', name);
|
|
5615
|
+
}
|
|
5616
|
+
|
|
5617
|
+
function resolveWindowsCscriptPath(environment = process.env) {
|
|
5618
|
+
return resolveWindowsSystemExecutable('cscript.exe', environment);
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
function windowsAclScriptPath(fsApi = fs, packageRoot = getPackageRoot()) {
|
|
5622
|
+
const scriptPath = path.join(packageRoot, WINDOWS_PRIVATE_ACL_SCRIPT_RELATIVE_PATH);
|
|
5623
|
+
const script = fsApi.readFileSync(scriptPath);
|
|
5624
|
+
const digest = createHash('sha256').update(script).digest('hex');
|
|
5625
|
+
if (digest !== WINDOWS_PRIVATE_ACL_SCRIPT_SHA256) {
|
|
5626
|
+
throw new Error('Windows ACL hardening script integrity check failed');
|
|
5627
|
+
}
|
|
5628
|
+
return scriptPath;
|
|
5629
|
+
}
|
|
5630
|
+
|
|
5631
|
+
async function runWindowsProcess(command, args, options = {}) {
|
|
5632
|
+
const runtime = options.runtime || (process.versions.bun ? 'bun' : 'node');
|
|
5633
|
+
const timeout = options.timeout;
|
|
5634
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('Windows ACL hardening subprocess timed out');
|
|
5635
|
+
const setTimer = options.setTimer || setTimeout;
|
|
5636
|
+
const clearTimer = options.clearTimer || clearTimeout;
|
|
5637
|
+
let timedOut = false;
|
|
5638
|
+
let exitCode;
|
|
5639
|
+
let stdout;
|
|
5640
|
+
|
|
5641
|
+
if (runtime === 'bun') {
|
|
5642
|
+
const spawn = options.bunSpawn || globalThis.Bun.spawn;
|
|
5643
|
+
const child = spawn([command, ...args], {
|
|
5644
|
+
cwd: options.cwd,
|
|
5645
|
+
env: options.env,
|
|
5646
|
+
stderr: 'ignore',
|
|
5647
|
+
stdout: options.captureStdout ? 'pipe' : 'ignore',
|
|
5648
|
+
windowsHide: false,
|
|
5649
|
+
});
|
|
5650
|
+
const timer = setTimer(() => {
|
|
5651
|
+
timedOut = true;
|
|
5652
|
+
child.kill('SIGKILL');
|
|
5653
|
+
}, timeout);
|
|
5654
|
+
try {
|
|
5655
|
+
const output = options.captureStdout ? new Response(child.stdout).text() : Promise.resolve('');
|
|
5656
|
+
[exitCode, stdout] = await Promise.all([child.exited, output]);
|
|
5657
|
+
} finally {
|
|
5658
|
+
clearTimer(timer);
|
|
5659
|
+
}
|
|
5660
|
+
} else {
|
|
5661
|
+
const spawn = options.nodeSpawn || nodeSpawn;
|
|
5662
|
+
const child = spawn(command, args, {
|
|
5663
|
+
cwd: options.cwd,
|
|
5664
|
+
env: options.env,
|
|
5665
|
+
stdio: ['ignore', options.captureStdout ? 'pipe' : 'ignore', 'ignore'],
|
|
5666
|
+
windowsHide: false,
|
|
5667
|
+
});
|
|
5668
|
+
const chunks = [];
|
|
5669
|
+
if (options.captureStdout) child.stdout.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
5670
|
+
const timer = setTimer(() => {
|
|
5671
|
+
timedOut = true;
|
|
5672
|
+
child.kill('SIGKILL');
|
|
5673
|
+
}, timeout);
|
|
5674
|
+
try {
|
|
5675
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
5676
|
+
child.once('error', reject);
|
|
5677
|
+
child.once('close', resolve);
|
|
5678
|
+
});
|
|
5679
|
+
stdout = options.captureStdout ? Buffer.concat(chunks).toString('utf8') : '';
|
|
5680
|
+
} finally {
|
|
5681
|
+
clearTimer(timer);
|
|
5682
|
+
}
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
if (timedOut) throw new Error('Windows ACL hardening subprocess timed out');
|
|
5686
|
+
if (exitCode !== 0) throw new Error(`Windows ACL hardening subprocess failed (exit ${exitCode})`);
|
|
5687
|
+
return stdout;
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
const WHOAMI_SID_CSV_ERROR = 'Windows ACL hardening could not resolve the current SID';
|
|
5691
|
+
|
|
5692
|
+
function readQuotedCsvCharacter(input, index, state) {
|
|
5693
|
+
const character = input[index];
|
|
5694
|
+
if (character === '\r' || character === '\n') {
|
|
5695
|
+
throw new Error(WHOAMI_SID_CSV_ERROR);
|
|
5696
|
+
}
|
|
5697
|
+
if (character !== '"') {
|
|
5698
|
+
state.field += character;
|
|
5699
|
+
return index;
|
|
5700
|
+
}
|
|
5701
|
+
if (input[index + 1] === '"') {
|
|
5702
|
+
state.field += '"';
|
|
5703
|
+
return index + 1;
|
|
5704
|
+
}
|
|
5705
|
+
state.inQuotes = false;
|
|
5706
|
+
state.closedQuote = true;
|
|
5707
|
+
return index;
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
function readUnquotedCsvCharacter(character, state) {
|
|
5711
|
+
if (character === '"') {
|
|
5712
|
+
if (state.field !== '') {
|
|
5713
|
+
throw new Error(WHOAMI_SID_CSV_ERROR);
|
|
5714
|
+
}
|
|
5715
|
+
state.inQuotes = true;
|
|
5716
|
+
return;
|
|
5717
|
+
}
|
|
5718
|
+
if (character === ',') {
|
|
5719
|
+
state.fields.push(state.field);
|
|
5720
|
+
state.field = '';
|
|
5721
|
+
return;
|
|
5722
|
+
}
|
|
5723
|
+
if (character === '\r' || character === '\n') {
|
|
5724
|
+
throw new Error(WHOAMI_SID_CSV_ERROR);
|
|
5725
|
+
}
|
|
5726
|
+
state.field += character;
|
|
5727
|
+
}
|
|
5728
|
+
|
|
5729
|
+
function parseWhoamiCsvRecord(stdout) {
|
|
5730
|
+
const input = String(stdout).replace(/\r?\n$/, '');
|
|
5731
|
+
const state = { fields: [], field: '', inQuotes: false, closedQuote: false };
|
|
5732
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
5733
|
+
const character = input[index];
|
|
5734
|
+
if (state.inQuotes) {
|
|
5735
|
+
index = readQuotedCsvCharacter(input, index, state);
|
|
5736
|
+
} else if (state.closedQuote) {
|
|
5737
|
+
if (character !== ',') {
|
|
5738
|
+
throw new Error(WHOAMI_SID_CSV_ERROR);
|
|
5739
|
+
}
|
|
5740
|
+
state.fields.push(state.field);
|
|
5741
|
+
state.field = '';
|
|
5742
|
+
state.closedQuote = false;
|
|
5743
|
+
} else {
|
|
5744
|
+
readUnquotedCsvCharacter(character, state);
|
|
5745
|
+
}
|
|
5746
|
+
}
|
|
5747
|
+
if (state.inQuotes) throw new Error(WHOAMI_SID_CSV_ERROR);
|
|
5748
|
+
state.fields.push(state.field);
|
|
5749
|
+
return state.fields;
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
function parseWhoamiSid(stdout) {
|
|
5753
|
+
const fields = parseWhoamiCsvRecord(stdout);
|
|
5754
|
+
const sid = fields[1];
|
|
5755
|
+
if (fields.length !== 2 || !/^S-1-[0-9]+(?:-[0-9]+)+$/.test(sid || '')) {
|
|
5756
|
+
throw new Error('Windows ACL hardening could not resolve the current SID');
|
|
5757
|
+
}
|
|
5758
|
+
return sid;
|
|
5759
|
+
}
|
|
5760
|
+
|
|
5761
|
+
async function secureWindowsPathsAcl(filePaths, options = {}) {
|
|
5762
|
+
if (!Array.isArray(filePaths) || filePaths.length === 0 || filePaths.length > 128) {
|
|
5763
|
+
throw new Error('Windows ACL hardening requires one to 128 targets');
|
|
5764
|
+
}
|
|
5765
|
+
for (const filePath of filePaths) {
|
|
5766
|
+
if (typeof filePath !== 'string' || !filePath || filePath.includes('\0')) {
|
|
5767
|
+
throw new Error('Windows ACL hardening requires valid target paths');
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
const targetPaths = [...new Set(filePaths.map(filePath => path.win32.resolve(filePath)))];
|
|
5771
|
+
const environment = options.environment || process.env;
|
|
5772
|
+
const cscriptPath = resolveWindowsCscriptPath(environment);
|
|
5773
|
+
const whoamiPath = resolveWindowsSystemExecutable('whoami.exe', environment);
|
|
5774
|
+
const systemDirectory = path.win32.dirname(cscriptPath);
|
|
5775
|
+
const scriptPath = windowsAclScriptPath(options.fsApi || fs, options.packageRoot || getPackageRoot());
|
|
5776
|
+
const now = options.now || Date.now;
|
|
5777
|
+
const deadline = now() + (options.timeout || 15_000);
|
|
5778
|
+
const remainingTimeout = () => {
|
|
5779
|
+
const remaining = deadline - now();
|
|
5780
|
+
if (remaining <= 0) throw new Error('Windows ACL hardening subprocess timed out');
|
|
5781
|
+
return remaining;
|
|
5782
|
+
};
|
|
5783
|
+
const processOptions = {
|
|
5784
|
+
bunSpawn: options.bunSpawn,
|
|
5785
|
+
clearTimer: options.clearTimer,
|
|
5786
|
+
cwd: systemDirectory,
|
|
5787
|
+
nodeSpawn: options.nodeSpawn,
|
|
5788
|
+
runtime: options.runtime,
|
|
5789
|
+
setTimer: options.setTimer,
|
|
5790
|
+
};
|
|
5791
|
+
const sid = parseWhoamiSid(await runWindowsProcess(whoamiPath, ['/user', '/fo', 'csv', '/nh'], {
|
|
5792
|
+
...processOptions,
|
|
5793
|
+
captureStdout: true,
|
|
5794
|
+
env: { SystemRoot: environment.SystemRoot },
|
|
5795
|
+
timeout: remainingTimeout(),
|
|
5796
|
+
}));
|
|
5797
|
+
const childEnvironment = {
|
|
5798
|
+
FORGE_PRIVATE_ACL_COUNT: String(targetPaths.length),
|
|
5799
|
+
FORGE_PRIVATE_ACL_SID: sid,
|
|
5800
|
+
SystemRoot: environment.SystemRoot,
|
|
5801
|
+
};
|
|
5802
|
+
targetPaths.forEach((filePath, index) => {
|
|
5803
|
+
childEnvironment[`FORGE_PRIVATE_ACL_TARGET_${index}`] = filePath;
|
|
5804
|
+
});
|
|
5805
|
+
await runWindowsProcess(cscriptPath, ['//B', '//Nologo', '//E:JScript', '//T:14', scriptPath], {
|
|
5806
|
+
...processOptions,
|
|
5807
|
+
captureStdout: false,
|
|
5808
|
+
env: childEnvironment,
|
|
5809
|
+
timeout: remainingTimeout(),
|
|
5810
|
+
});
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5813
|
+
async function secureWindowsPathAcl(filePath) {
|
|
5814
|
+
await secureWindowsPathsAcl([filePath]);
|
|
5815
|
+
}
|
|
5816
|
+
|
|
5817
|
+
async function createPrivateBackupDirectory(backupPath, options = {}) {
|
|
5818
|
+
const directory = fs.mkdtempSync(path.join(path.dirname(backupPath), '.forge-sqlite-backup-'));
|
|
5819
|
+
try {
|
|
5820
|
+
if (process.platform === 'win32') await (options.hardenPath || secureWindowsPathAcl)(directory);
|
|
5821
|
+
else {
|
|
5822
|
+
fs.chmodSync(directory, 0o700);
|
|
5823
|
+
if ((Number(fs.statSync(directory).mode) & 0o077) !== 0) throw new Error('mode remains broader than 0700');
|
|
5824
|
+
}
|
|
5825
|
+
} catch (error) {
|
|
5826
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
5827
|
+
throw new ClaimRepairError(
|
|
5828
|
+
'CLAIM_REPAIR_BACKUP_PERMISSIONS',
|
|
5829
|
+
'SQLite backup workspace could not be secured with owner-only permissions',
|
|
5830
|
+
{ cause: error.message || String(error) },
|
|
5831
|
+
);
|
|
5832
|
+
}
|
|
5833
|
+
return directory;
|
|
5834
|
+
}
|
|
5835
|
+
|
|
5836
|
+
async function createSafeBackup(runtime, db, databasePath, backupPath, options = {}) {
|
|
5837
|
+
assertSafeBackupDestination(databasePath, backupPath);
|
|
5838
|
+
ensureFileBackedDatabaseDirectory(backupPath);
|
|
5839
|
+
const hardenPath = options.hardenPath || hardenBackupPermissions;
|
|
5840
|
+
const tempDirectory = await createPrivateBackupDirectory(backupPath, { hardenPath });
|
|
5841
|
+
const tempPath = path.join(tempDirectory, `${path.basename(backupPath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
5842
|
+
const backupWriter = options.backupWriter || createBackup;
|
|
5843
|
+
const backupVerifier = options.backupVerifier || verifyBackupFile;
|
|
5844
|
+
const backupReplacer = options.backupReplacer || (options.noReplace
|
|
5845
|
+
? ((source, destination) => (process.platform === 'win32'
|
|
5846
|
+
? fs.linkSync(source, destination)
|
|
5847
|
+
: fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL)))
|
|
5848
|
+
: ((source, destination) => fs.renameSync(source, destination)));
|
|
5849
|
+
try {
|
|
5850
|
+
await backupWriter(runtime, db, tempPath);
|
|
5851
|
+
await hardenPath(tempPath);
|
|
5852
|
+
await backupVerifier(runtime, tempPath);
|
|
5853
|
+
await backupReplacer(tempPath, backupPath);
|
|
5854
|
+
await hardenPath(backupPath);
|
|
5855
|
+
} finally {
|
|
5856
|
+
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
5857
|
+
}
|
|
5858
|
+
}
|
|
5859
|
+
|
|
2729
5860
|
async function validateBackup(runtime, db, backupPath) {
|
|
2730
5861
|
const tableName = createProbeTableName('forge_backup_probe');
|
|
2731
5862
|
try {
|
|
@@ -2800,7 +5931,7 @@ async function validateBuiltinSQLiteRuntimeDriver(options = {}, deps = {}) {
|
|
|
2800
5931
|
|
|
2801
5932
|
function createBuiltinSQLiteDriver(options = {}, deps = {}) {
|
|
2802
5933
|
const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
|
|
2803
|
-
return createDriver(runtime, options.databasePath);
|
|
5934
|
+
return createDriver(runtime, options.databasePath, options);
|
|
2804
5935
|
}
|
|
2805
5936
|
|
|
2806
5937
|
module.exports = {
|
|
@@ -2808,7 +5939,15 @@ module.exports = {
|
|
|
2808
5939
|
CONFLICT_SIGNAL,
|
|
2809
5940
|
classifyConflictSignal,
|
|
2810
5941
|
createBuiltinSQLiteDriver,
|
|
5942
|
+
hardenBackupPermissions,
|
|
5943
|
+
hardenBackupPermissionsBatch,
|
|
2811
5944
|
requireSqliteRuntimeModule,
|
|
5945
|
+
resolveWindowsCscriptPath,
|
|
5946
|
+
secureWindowsPathsAcl,
|
|
2812
5947
|
selectBuiltinSQLiteRuntime,
|
|
5948
|
+
syncClaimRepairRecoveryDirectory,
|
|
2813
5949
|
validateBuiltinSQLiteRuntimeDriver,
|
|
5950
|
+
WINDOWS_PRIVATE_ACL_SCRIPT_PATH,
|
|
5951
|
+
WINDOWS_PRIVATE_ACL_SCRIPT_SHA256,
|
|
5952
|
+
_runWindowsProcess: runWindowsProcess,
|
|
2814
5953
|
};
|