forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -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,10 +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');
|
|
31
|
+
const { normalizeRecallHit } = require('../memory-recall');
|
|
32
|
+
const { getPackageRoot } = require('../package-root');
|
|
33
|
+
const { appendUsageEvidence, rebuildUsageProjection } = require('../../packages/memory');
|
|
20
34
|
|
|
21
35
|
const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
|
|
22
36
|
let probeCounter = 0;
|
|
@@ -132,6 +146,18 @@ function createDatabase(runtime, databasePath) {
|
|
|
132
146
|
throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
|
|
133
147
|
}
|
|
134
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
|
+
|
|
135
161
|
function execSql(_runtime, db, sql) {
|
|
136
162
|
db.exec(sql);
|
|
137
163
|
}
|
|
@@ -170,6 +196,35 @@ function runParams(runtime, db, sql, params = []) {
|
|
|
170
196
|
return db.prepare(sql).run(...params);
|
|
171
197
|
}
|
|
172
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
|
+
|
|
173
228
|
// A table may not exist on a partially-migrated DB; readiness inputs degrade to empty.
|
|
174
229
|
// ONLY a missing-table error is tolerated — a locked/corrupt DB or a real SQL
|
|
175
230
|
// regression must surface, not silently produce wrong readiness/stats/projection.
|
|
@@ -205,7 +260,7 @@ function parseLabels(raw) {
|
|
|
205
260
|
}
|
|
206
261
|
|
|
207
262
|
function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds = [], dependentIds = []) {
|
|
208
|
-
|
|
263
|
+
const summary = {
|
|
209
264
|
id: row.id,
|
|
210
265
|
title: row.title,
|
|
211
266
|
body: row.body ?? null,
|
|
@@ -249,6 +304,11 @@ function rowToIssueSummary(row, readinessEntry, claimedBy = null, dependencyIds
|
|
|
249
304
|
close_reason: row.close_reason ?? null,
|
|
250
305
|
metadata: row.metadata ?? null,
|
|
251
306
|
};
|
|
307
|
+
if (readinessEntry?.contract_applicable) {
|
|
308
|
+
summary.readiness_state = readinessEntry.state;
|
|
309
|
+
summary.readiness_reasons = readinessEntry.reasons;
|
|
310
|
+
}
|
|
311
|
+
return summary;
|
|
252
312
|
}
|
|
253
313
|
|
|
254
314
|
function okIssueResponse(command, data, nextCommands) {
|
|
@@ -264,30 +324,48 @@ function okIssueResponse(command, data, nextCommands) {
|
|
|
264
324
|
};
|
|
265
325
|
}
|
|
266
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
|
+
|
|
267
345
|
// Derive the whole-board readiness read model (D18) from the authority tables.
|
|
268
346
|
function loadBoardReadiness(runtime, db, context = {}) {
|
|
269
347
|
const issues = allParams(runtime, db, 'SELECT * FROM kernel_issues');
|
|
270
348
|
const dependencies = safeAll(runtime, db, 'SELECT * FROM kernel_dependencies');
|
|
271
349
|
const conflicts = safeAll(runtime, db, 'SELECT * FROM kernel_conflicts');
|
|
272
|
-
const
|
|
350
|
+
const now = context.now || new Date().toISOString();
|
|
351
|
+
const liveClaims = loadLiveKernelClaimRows(runtime, db, { ...context, now });
|
|
273
352
|
const index = buildReadinessIndex({
|
|
274
353
|
issues,
|
|
275
354
|
dependencies,
|
|
276
355
|
conflicts,
|
|
277
|
-
claims,
|
|
278
|
-
now
|
|
356
|
+
claims: liveClaims,
|
|
357
|
+
now,
|
|
279
358
|
actor: context.actor,
|
|
359
|
+
contractPolicy: context.contractPolicy,
|
|
360
|
+
isTrustedAdoption: context.isTrustedAdoption,
|
|
280
361
|
});
|
|
281
|
-
// Surface the
|
|
282
|
-
//
|
|
283
|
-
// 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.
|
|
284
364
|
// Null-prototype map: issue ids are unconstrained external strings, so a literal `{}`
|
|
285
365
|
// keyed by them would be a prototype-pollution vector (matches buildReadinessIndex).
|
|
286
366
|
const claimedById = Object.create(null);
|
|
287
|
-
for (const claim of
|
|
288
|
-
if (
|
|
289
|
-
claimedById[claim.issue_id] = claim.actor ?? null;
|
|
290
|
-
}
|
|
367
|
+
for (const claim of liveClaims) {
|
|
368
|
+
if (claim.issue_id) claimedById[claim.issue_id] = claim.actor ?? null;
|
|
291
369
|
}
|
|
292
370
|
// Per-issue declared dependency edges (the ids each issue depends on, i.e.
|
|
293
371
|
// blocks_issue_id where issue_id === the dependent). Distinct from readiness'
|
|
@@ -317,7 +395,7 @@ function loadBoardReadiness(runtime, db, context = {}) {
|
|
|
317
395
|
dependentsById[issueId] = [...new Set(dependentsById[issueId])]
|
|
318
396
|
.sort((a, b) => String(a).localeCompare(String(b)));
|
|
319
397
|
}
|
|
320
|
-
return { issues, index, claimedById, dependenciesById, dependentsById };
|
|
398
|
+
return { issues, index, liveClaims, claimedById, dependenciesById, dependentsById };
|
|
321
399
|
}
|
|
322
400
|
|
|
323
401
|
function firstPositional(args = []) {
|
|
@@ -434,6 +512,57 @@ function buildRollup(children) {
|
|
|
434
512
|
};
|
|
435
513
|
}
|
|
436
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
|
+
|
|
437
566
|
// Read-side of driver.issueOperation: ready/list/show/search/stats as parameterized
|
|
438
567
|
// SELECTs returning issue-command-contract shapes. Mutations are handled separately
|
|
439
568
|
// through the broker's guarded-event path (later wave).
|
|
@@ -505,55 +634,7 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
|
|
|
505
634
|
});
|
|
506
635
|
}
|
|
507
636
|
if (operation === 'owns') {
|
|
508
|
-
|
|
509
|
-
// not prove the caller won the lease: a duplicate replay also returns ok:true, so
|
|
510
|
-
// a worker must CONFIRM it holds the live lease before mutating a claimed issue.
|
|
511
|
-
// `owned` is true iff the resolving actor holds the SINGLE active claim AND that
|
|
512
|
-
// lease has not expired. read summaries derive `claimed_by` from state='active'
|
|
513
|
-
// only (never expiry — see loadActiveKernelClaimRow), so owns re-applies the
|
|
514
|
-
// expiry check here: an expired-but-not-yet-reclaimed lease is NOT ownership
|
|
515
|
-
// (planClaimAcquisition would supersede it). Actor resolution mirrors the mutation
|
|
516
|
-
// path's `context.actor || 'forge'` default so a bare CLI invocation matches its
|
|
517
|
-
// own claims; `now` falls back to the wall clock like the mutation route.
|
|
518
|
-
const id = firstPositional(args);
|
|
519
|
-
const rows = allParams(runtime, db, 'SELECT * FROM kernel_issues WHERE id = ?', [id]);
|
|
520
|
-
if (!rows[0]) {
|
|
521
|
-
return formatIssueCommandError({
|
|
522
|
-
command: 'issue.owns',
|
|
523
|
-
code: 'FORGE_ISSUE_NOT_FOUND',
|
|
524
|
-
message: `Issue ${id ?? '<missing id>'} not found`,
|
|
525
|
-
exitCode: ISSUE_COMMAND_EXIT_CODES.notFound,
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
const now = context.now || new Date().toISOString();
|
|
529
|
-
const actor = context.actor || 'forge';
|
|
530
|
-
const claim = loadActiveKernelClaimRow(runtime, db, id);
|
|
531
|
-
const claimedBy = claim ? (claim.actor ?? null) : null;
|
|
532
|
-
const expired = claim ? isLeaseExpired(claim, now) : false;
|
|
533
|
-
// Ownership is per-SESSION, not just per-actor (kernel d71a824b): two agents
|
|
534
|
-
// sharing one human actor but running as DIFFERENT sessions must not both read
|
|
535
|
-
// OWNED for a lease only one of them holds. When BOTH the caller and the live
|
|
536
|
-
// lease carry a session-id they must match; if either side is session-less (a
|
|
537
|
-
// no-env caller, or a pre-session claim) we fall back to actor-only ownership so
|
|
538
|
-
// historical behavior is preserved byte-for-byte. An empty/whitespace-only
|
|
539
|
-
// session-id counts as session-LESS on BOTH sides — the SAME trim-truthy test the
|
|
540
|
-
// claim-key write uses (buildClaimMutationEvent in lib/kernel/broker.js) — so ''
|
|
541
|
-
// can never count as "present" here and "absent" there.
|
|
542
|
-
const normalizeSession = value => (typeof value === 'string' && value.trim() !== '' ? value : null);
|
|
543
|
-
const contextSession = normalizeSession(context.sessionId);
|
|
544
|
-
const claimSession = claim ? normalizeSession(claim.session_id) : null;
|
|
545
|
-
const sessionMismatch = contextSession !== null
|
|
546
|
-
&& claimSession !== null
|
|
547
|
-
&& contextSession !== claimSession;
|
|
548
|
-
const owned = Boolean(claim) && !expired && claimedBy === actor && !sessionMismatch;
|
|
549
|
-
return okIssueResponse('issue.owns', {
|
|
550
|
-
id,
|
|
551
|
-
actor,
|
|
552
|
-
claimed_by: claimedBy,
|
|
553
|
-
owned,
|
|
554
|
-
expired,
|
|
555
|
-
expires_at: claim ? (claim.expires_at ?? null) : null,
|
|
556
|
-
});
|
|
637
|
+
return runIssueOwnsRead(runtime, db, args, context);
|
|
557
638
|
}
|
|
558
639
|
if (operation === 'search') {
|
|
559
640
|
const term = `%${firstPositional(args) || ''}%`;
|
|
@@ -567,20 +648,17 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
|
|
|
567
648
|
return okIssueResponse('issue.search', { issues: summaries, count: summaries.length });
|
|
568
649
|
}
|
|
569
650
|
if (operation === 'stats') {
|
|
570
|
-
const { index } = loadBoardReadiness(runtime, db, context);
|
|
651
|
+
const { index, liveClaims } = loadBoardReadiness(runtime, db, context);
|
|
571
652
|
const statusRows = allParams(runtime, db, 'SELECT status, COUNT(*) AS n FROM kernel_issues GROUP BY status');
|
|
572
653
|
const counts = {};
|
|
573
654
|
for (const row of statusRows) {
|
|
574
655
|
counts[row.status] = Number(row.n);
|
|
575
656
|
}
|
|
576
|
-
const activeClaims = Number(
|
|
577
|
-
safeAll(runtime, db, "SELECT COUNT(*) AS n FROM kernel_claims WHERE state = 'active'")[0]?.n || 0,
|
|
578
|
-
);
|
|
579
657
|
return okIssueResponse('issue.stats', {
|
|
580
658
|
counts,
|
|
581
659
|
ready_count: index.readyQueue.length,
|
|
582
660
|
blocked_count: index.blocked.length,
|
|
583
|
-
active_claims:
|
|
661
|
+
active_claims: liveClaims.length,
|
|
584
662
|
});
|
|
585
663
|
}
|
|
586
664
|
// KAP-7: derived read query — every issue whose readiness is blocked
|
|
@@ -695,24 +773,11 @@ function runIssueReadOperation(runtime, db, operation, args, context) {
|
|
|
695
773
|
count: children.length,
|
|
696
774
|
});
|
|
697
775
|
}
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
// session_id, worktree_id, expires_at, issue_id) — the CLI previously exposed
|
|
701
|
-
// only `claimed_by` (the actor). A lease is LIVE iff state='active' AND it has
|
|
702
|
-
// NOT expired at the read `now` (isLeaseExpired; a null expires_at never
|
|
703
|
-
// expires). An expired-but-not-yet-reclaimed lease is deliberately excluded:
|
|
704
|
-
// planClaimAcquisition would supersede it, so it is not a live presence signal
|
|
705
|
-
// (this mirrors the expiry check the `owns` verdict re-applies). Reads that
|
|
706
|
-
// derive claimed_by (loadBoardReadiness/loadActiveKernelClaimRow) filter on
|
|
707
|
-
// state only; THIS read additionally honors the lease TTL. Sorted by
|
|
708
|
-
// claimed_at (then issue_id) for deterministic output. The kernel_claims
|
|
709
|
-
// schema has NO `agent` column — the lease's who-dimension is actor +
|
|
710
|
-
// 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.
|
|
711
778
|
if (operation === 'claims') {
|
|
712
|
-
const
|
|
713
|
-
const
|
|
714
|
-
const claims = rows
|
|
715
|
-
.filter(row => !isLeaseExpired(row, nowIso))
|
|
779
|
+
const liveClaims = loadLiveKernelClaimRows(runtime, db, context);
|
|
780
|
+
const claims = liveClaims
|
|
716
781
|
.map(row => ({
|
|
717
782
|
id: row.id,
|
|
718
783
|
issue_id: row.issue_id,
|
|
@@ -748,11 +813,20 @@ const KERNEL_EVENT_COLUMNS = Object.freeze([
|
|
|
748
813
|
'created_at',
|
|
749
814
|
]);
|
|
750
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
|
+
|
|
751
824
|
// Persist one event. The id is supplied by the caller or minted here (event ids are
|
|
752
825
|
// TEXT, not autoincrement). The event's payload is stored as payload_json: a
|
|
753
826
|
// pre-serialized payload_json wins, else the payload object is JSON-stringified. The
|
|
754
827
|
// native UNIQUE(idempotency_key) error is intentionally NOT caught here.
|
|
755
828
|
function insertKernelEventRow(runtime, db, event) {
|
|
829
|
+
assertGenericEventNamespace(event);
|
|
756
830
|
const id = event.id || randomUUID();
|
|
757
831
|
const payloadJson = event.payload_json ?? JSON.stringify(event.payload ?? {});
|
|
758
832
|
const row = {
|
|
@@ -789,17 +863,40 @@ function loadKernelEntityRow(runtime, db, entityType, entityId) {
|
|
|
789
863
|
}
|
|
790
864
|
|
|
791
865
|
// Read the full event stream for one entity, oldest first (matches
|
|
792
|
-
// idx_kernel_events_entity_created
|
|
793
|
-
//
|
|
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.
|
|
794
869
|
function listKernelEventRows(runtime, db, entityType, entityId) {
|
|
795
870
|
return allParams(
|
|
796
871
|
runtime,
|
|
797
872
|
db,
|
|
798
|
-
'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',
|
|
799
874
|
[entityType, entityId],
|
|
800
875
|
);
|
|
801
876
|
}
|
|
802
877
|
|
|
878
|
+
// Bulk activity read (Slice C2, additive + read-only) across ALL entities, newest first,
|
|
879
|
+
// for `forge insights`. `since` is an optional ISO cutoff (created_at >= since); `limit`
|
|
880
|
+
// bounds the row count (default 1000). Imported beads interactions live here as
|
|
881
|
+
// `beads.interaction.<kind>` events, so insights derives interaction patterns from this
|
|
882
|
+
// instead of the retired legacy interactions log. Creates/migrates nothing.
|
|
883
|
+
function listRecentKernelEventRows(runtime, db, since, limit) {
|
|
884
|
+
const params = [];
|
|
885
|
+
let where = '';
|
|
886
|
+
if (since) {
|
|
887
|
+
where = 'WHERE created_at >= ?';
|
|
888
|
+
params.push(since);
|
|
889
|
+
}
|
|
890
|
+
const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 1000;
|
|
891
|
+
params.push(cap);
|
|
892
|
+
return allParams(
|
|
893
|
+
runtime,
|
|
894
|
+
db,
|
|
895
|
+
`SELECT * FROM kernel_events ${where} ORDER BY created_at DESC LIMIT ?`,
|
|
896
|
+
params,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
803
900
|
// Look up the committed event for an idempotency key (the duplicate-replay probe).
|
|
804
901
|
// The broker calls this unconditionally inside a Promise.all even for keyless
|
|
805
902
|
// events, so guard a falsy key up front rather than binding undefined.
|
|
@@ -1084,6 +1181,67 @@ function updateKernelClaimStateRow(runtime, db, claimId, state) {
|
|
|
1084
1181
|
return { id: claimId, state };
|
|
1085
1182
|
}
|
|
1086
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
|
+
|
|
1087
1245
|
// --- Worktree-linkage primitives (P0 kernel linkage backbone). The kernel_worktrees
|
|
1088
1246
|
// table is a plain authority registry (NOT event-sourced): `forge worktree create`
|
|
1089
1247
|
// writes a row here so the kernel records issue → worktree → work-folder, and
|
|
@@ -1193,6 +1351,194 @@ function listWorktreeRows(runtime, db, filter = {}) {
|
|
|
1193
1351
|
return safeAll(runtime, db, 'SELECT * FROM kernel_worktrees ORDER BY registered_at DESC');
|
|
1194
1352
|
}
|
|
1195
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
|
+
|
|
1196
1542
|
// --- Stage-run registry (f61601ab). kernel_stage_runs records the REAL workflow
|
|
1197
1543
|
// phase per issue so the dashboard/`show` read the phase instead of guessing it
|
|
1198
1544
|
// from status+claim (a claimed-open issue with a merged PR would otherwise still
|
|
@@ -1351,6 +1697,78 @@ function recordStageTransitionRow(runtime, db, input) {
|
|
|
1351
1697
|
}
|
|
1352
1698
|
}
|
|
1353
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
|
+
|
|
1354
1772
|
function listStageRunRows(runtime, db, issueId) {
|
|
1355
1773
|
if (!issueId) return [];
|
|
1356
1774
|
// Deterministic order: started_at first, then rowid (the implicit INSERT sequence)
|
|
@@ -1562,6 +1980,15 @@ function applyAcceptedIssueEvent(runtime, db, event, context = {}) {
|
|
|
1562
1980
|
// on this connection), so computeNewlyUnblocked sees the post-close readiness.
|
|
1563
1981
|
function finalizeIssueMutation(runtime, db, event, issueId, revision, context) {
|
|
1564
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
|
+
}
|
|
1565
1992
|
if (event.event_type === 'issue.close') {
|
|
1566
1993
|
summary.newly_unblocked = computeNewlyUnblocked(runtime, db, issueId, context);
|
|
1567
1994
|
}
|
|
@@ -1865,6 +2292,7 @@ function importIssueRecords(runtime, db, records = {}, options = {}) {
|
|
|
1865
2292
|
for (const event of activityEvents) {
|
|
1866
2293
|
if (!event || event.id == null) { summary.events.skipped += 1; continue; }
|
|
1867
2294
|
const row = buildImportEventRow(event, now);
|
|
2295
|
+
assertGenericEventNamespace(row);
|
|
1868
2296
|
const result = runParams(runtime, db, eventSql, IMPORT_EVENT_COLUMNS.map(column => row[column]));
|
|
1869
2297
|
summary.events[wasInserted(result) ? 'inserted' : 'skipped'] += 1;
|
|
1870
2298
|
}
|
|
@@ -2006,23 +2434,43 @@ function searchMemoryRows(runtime, db, query) {
|
|
|
2006
2434
|
).map(memoryRowToEntry);
|
|
2007
2435
|
}
|
|
2008
2436
|
|
|
2009
|
-
// Optional
|
|
2010
|
-
//
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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);
|
|
2015
2454
|
}
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
+
};
|
|
2018
2466
|
}
|
|
2019
2467
|
|
|
2020
2468
|
// The newest `limit` entries by logical (as-of) timestamp — the default read model for
|
|
2021
2469
|
// `recall` with no query. rowid breaks ties so same-timestamp rows are still deterministic.
|
|
2022
|
-
//
|
|
2023
|
-
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 = {}) {
|
|
2024
2472
|
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2025
|
-
const { clause, params } =
|
|
2473
|
+
const { clause, params } = memoryReadFilter(options);
|
|
2026
2474
|
return allParams(
|
|
2027
2475
|
runtime,
|
|
2028
2476
|
db,
|
|
@@ -2031,10 +2479,10 @@ function recentMemoryRows(runtime, db, limit, agents) {
|
|
|
2031
2479
|
).map(memoryRowToEntry);
|
|
2032
2480
|
}
|
|
2033
2481
|
|
|
2034
|
-
// 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
|
|
2035
2483
|
// recentMemoryRows so `recall` can report "showing N of TOTAL" instead of silently truncating.
|
|
2036
|
-
function countMemoryRows(runtime, db,
|
|
2037
|
-
const { clause, params } =
|
|
2484
|
+
function countMemoryRows(runtime, db, options = {}) {
|
|
2485
|
+
const { clause, params } = memoryReadFilter(options);
|
|
2038
2486
|
const rows = allParams(runtime, db, `SELECT count(*) AS count FROM kernel_memories${clause}`, params);
|
|
2039
2487
|
return Number((rows[0] || {}).count) || 0;
|
|
2040
2488
|
}
|
|
@@ -2049,24 +2497,40 @@ function buildMemoryFtsMatch(query) {
|
|
|
2049
2497
|
return tokens.map(token => `"${token}"`).join(' AND ');
|
|
2050
2498
|
}
|
|
2051
2499
|
|
|
2500
|
+
// Like buildMemoryFtsMatch but OR-joins the tokens: a natural-language prompt matches a note
|
|
2501
|
+
// containing ANY of its keywords, not EVERY one. Used ONLY by the relevance-only SCORED read
|
|
2502
|
+
// (the per-turn recall hook) — a raw prompt ("why is my forge push taking so long") token-ANDed
|
|
2503
|
+
// required every word in one note and matched nothing (0% recall). Each token is double-quoted
|
|
2504
|
+
// for FTS5 safety (tokens may be non-Latin unicode). Returns '' when the query has no tokens.
|
|
2505
|
+
function buildMemoryFtsMatchOr(query) {
|
|
2506
|
+
const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
|
|
2507
|
+
if (!tokens || tokens.length === 0) return '';
|
|
2508
|
+
return tokens.map(token => `"${token}"`).join(' OR ');
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2052
2511
|
// BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
|
|
2053
2512
|
// rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
|
|
2054
2513
|
// query falls back to recent entries so `recall` never returns a bare full dump.
|
|
2055
|
-
function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
2514
|
+
function searchMemoryRowsRanked(runtime, db, query, limit, options = {}) {
|
|
2056
2515
|
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2057
2516
|
const match = buildMemoryFtsMatch(query);
|
|
2058
2517
|
if (!match) {
|
|
2059
|
-
return recentMemoryRows(runtime, db, capped);
|
|
2518
|
+
return recentMemoryRows(runtime, db, capped, options);
|
|
2060
2519
|
}
|
|
2520
|
+
const { clause, params } = memoryReadFilter({
|
|
2521
|
+
kind: options.kind,
|
|
2522
|
+
includeSuperseded: options.includeSuperseded,
|
|
2523
|
+
}, 'm');
|
|
2524
|
+
const kindPredicate = clause ? clause.replace(/^ WHERE /, ' AND ') : '';
|
|
2061
2525
|
return allParams(
|
|
2062
2526
|
runtime,
|
|
2063
2527
|
db,
|
|
2064
2528
|
`SELECT m.* FROM kernel_memories m
|
|
2065
2529
|
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2066
|
-
WHERE kernel_memories_fts MATCH
|
|
2530
|
+
WHERE kernel_memories_fts MATCH ?${kindPredicate}
|
|
2067
2531
|
ORDER BY bm25(kernel_memories_fts)
|
|
2068
2532
|
LIMIT ?`,
|
|
2069
|
-
[match, capped],
|
|
2533
|
+
[match, ...params, capped],
|
|
2070
2534
|
).map(memoryRowToEntry);
|
|
2071
2535
|
}
|
|
2072
2536
|
|
|
@@ -2076,22 +2540,100 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
|
2076
2540
|
// Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
|
|
2077
2541
|
// fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
|
|
2078
2542
|
// returns more-negative for stronger matches, so rows come back best (lowest) first.
|
|
2079
|
-
function
|
|
2543
|
+
function confirmedMemorySql(alias) {
|
|
2544
|
+
return `(EXISTS (
|
|
2545
|
+
SELECT 1 FROM json_each(${alias}.tags_json)
|
|
2546
|
+
WHERE lower(json_each.value) = 'trust:confirmed'
|
|
2547
|
+
) OR (${alias}.source_agent = 'forge remember'
|
|
2548
|
+
AND json_type(${alias}.value_json) = 'text'
|
|
2549
|
+
AND NOT EXISTS (
|
|
2550
|
+
SELECT 1 FROM json_each(${alias}.tags_json)
|
|
2551
|
+
WHERE lower(json_each.value) LIKE 'trust:%'
|
|
2552
|
+
OR lower(json_each.value) = 'forge:auto-capture'
|
|
2553
|
+
)))`;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function projectMemoryScopeSql(alias) {
|
|
2557
|
+
return `(${alias}.scope IS NULL OR ${alias}.scope = 'project' OR ${alias}.scope = ?)`;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function suggestedFreshnessCutoff(now) {
|
|
2561
|
+
const timestamp = Date.parse(now || new Date().toISOString());
|
|
2562
|
+
return new Date(timestamp - (7 * 24 * 60 * 60 * 1000)).toISOString();
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
|
|
2080
2566
|
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2081
|
-
|
|
2567
|
+
// keyword-OR (NOT the token-AND of searchMemoryRowsRanked): this relevance-only read backs
|
|
2568
|
+
// the per-turn recall hook, where a natural-language prompt must match on ANY keyword.
|
|
2569
|
+
const match = buildMemoryFtsMatchOr(query);
|
|
2082
2570
|
if (!match) {
|
|
2083
2571
|
return [];
|
|
2084
2572
|
}
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2573
|
+
const projectId = options.projectId;
|
|
2574
|
+
if (typeof projectId !== 'string' || !projectId) return [];
|
|
2575
|
+
const cutoff = suggestedFreshnessCutoff(options.now);
|
|
2576
|
+
const excludeKeys = Array.isArray(options.excludeKeys)
|
|
2577
|
+
? [...new Set(options.excludeKeys.filter(key => typeof key === 'string'))].slice(0, 256)
|
|
2578
|
+
: [];
|
|
2579
|
+
const seenSql = excludeKeys.length > 0
|
|
2580
|
+
? `AND m.key NOT IN (${excludeKeys.map(() => '?').join(', ')})`
|
|
2581
|
+
: '';
|
|
2582
|
+
const confirmed = confirmedMemorySql('m');
|
|
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
|
+
)`;
|
|
2592
|
+
// Expand eligible supersession edges once. A correlated json_each scan repeated the
|
|
2593
|
+
// entire memory table for every FTS candidate and dominated the 1,000-row prompt path.
|
|
2594
|
+
// Recall temporarily waits less than the connection default so a real lock cannot outlive
|
|
2595
|
+
// the prompt hook; the finally block restores the caller's normal connection behavior.
|
|
2596
|
+
const previousBusyTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
|
|
2597
|
+
const requestedBusyTimeout = Number(options.busyTimeoutMs);
|
|
2598
|
+
const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
|
|
2599
|
+
? Math.floor(requestedBusyTimeout)
|
|
2600
|
+
: Math.min(previousBusyTimeout, 2_500);
|
|
2601
|
+
if (busyTimeout !== previousBusyTimeout) {
|
|
2602
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${busyTimeout};`);
|
|
2603
|
+
}
|
|
2604
|
+
try {
|
|
2605
|
+
return allParams(
|
|
2606
|
+
runtime,
|
|
2607
|
+
db,
|
|
2608
|
+
`WITH eligible_superseders AS MATERIALIZED (
|
|
2609
|
+
SELECT superseded.value AS memory_key,
|
|
2610
|
+
CASE WHEN ${supersederConfirmed} THEN 1 ELSE 0 END AS is_confirmed
|
|
2611
|
+
FROM kernel_memories s,
|
|
2612
|
+
json_each(COALESCE(s.supersedes_json, '[]')) superseded
|
|
2613
|
+
WHERE ${projectMemoryScopeSql('s')}
|
|
2614
|
+
AND (${supersederConfirmed} OR s.updated_at >= ?)
|
|
2615
|
+
)
|
|
2616
|
+
SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
|
|
2089
2617
|
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2090
2618
|
WHERE kernel_memories_fts MATCH ?
|
|
2091
|
-
|
|
2619
|
+
AND ${projectMemoryScopeSql('m')}
|
|
2620
|
+
AND (${confirmed} OR m.updated_at >= ?)
|
|
2621
|
+
${seenSql}
|
|
2622
|
+
${supersessionPredicate}
|
|
2623
|
+
ORDER BY bm25(kernel_memories_fts),
|
|
2624
|
+
CASE WHEN ${confirmed} THEN 0 ELSE 1 END,
|
|
2625
|
+
m.source_agent ASC, m.updated_at DESC, m.key ASC
|
|
2092
2626
|
LIMIT ?`,
|
|
2093
|
-
|
|
2094
|
-
|
|
2627
|
+
[projectId, cutoff, match, projectId, cutoff, ...excludeKeys, capped],
|
|
2628
|
+
).map(row => normalizeRecallHit(
|
|
2629
|
+
{ ...memoryRowToEntry(row), score: row.__score },
|
|
2630
|
+
projectId,
|
|
2631
|
+
));
|
|
2632
|
+
} finally {
|
|
2633
|
+
if (busyTimeout !== previousBusyTimeout) {
|
|
2634
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${previousBusyTimeout};`);
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2095
2637
|
}
|
|
2096
2638
|
|
|
2097
2639
|
function closeDatabase(db) {
|
|
@@ -2100,58 +2642,2187 @@ function closeDatabase(db) {
|
|
|
2100
2642
|
}
|
|
2101
2643
|
}
|
|
2102
2644
|
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
+
]);
|
|
2107
2669
|
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
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);
|
|
2718
|
+
}
|
|
2719
|
+
return { realPath, dev: stat.dev, ino: stat.ino };
|
|
2720
|
+
}
|
|
2721
|
+
|
|
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');
|
|
2117
2741
|
}
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
runtime,
|
|
2131
|
-
database,
|
|
2132
|
-
"SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
|
|
2133
|
-
).count) > 0;
|
|
2134
|
-
execSql(runtime, database, ftsDdl.create);
|
|
2135
|
-
for (const trigger of ftsDdl.triggers) {
|
|
2136
|
-
execSql(runtime, database, trigger);
|
|
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');
|
|
2137
2754
|
}
|
|
2138
|
-
|
|
2139
|
-
|
|
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 */ }
|
|
2140
2762
|
}
|
|
2141
|
-
memorySchemaEnsured = true;
|
|
2142
2763
|
}
|
|
2764
|
+
}
|
|
2143
2765
|
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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) {
|
|
2155
4826
|
throw new Error('Kernel SQLite driver requires a databasePath or broker config databasePath');
|
|
2156
4827
|
}
|
|
2157
4828
|
return databasePath;
|
|
@@ -2161,6 +4832,7 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2161
4832
|
const databasePath = resolveDatabasePath(config);
|
|
2162
4833
|
if (!db) {
|
|
2163
4834
|
db = createDatabase(runtime, databasePath);
|
|
4835
|
+
execSql(runtime, db, 'PRAGMA foreign_keys=ON;');
|
|
2164
4836
|
openedDatabasePath = databasePath;
|
|
2165
4837
|
} else if (openedDatabasePath !== databasePath) {
|
|
2166
4838
|
throw new Error(`Kernel SQLite driver is already open for ${openedDatabasePath}`);
|
|
@@ -2168,7 +4840,7 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2168
4840
|
return db;
|
|
2169
4841
|
}
|
|
2170
4842
|
|
|
2171
|
-
|
|
4843
|
+
const driver = {
|
|
2172
4844
|
runtime: {
|
|
2173
4845
|
id: runtime.id,
|
|
2174
4846
|
databaseClassName: runtime.databaseClassName,
|
|
@@ -2176,12 +4848,91 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2176
4848
|
experimental: runtime.experimental,
|
|
2177
4849
|
},
|
|
2178
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
|
+
},
|
|
2179
4861
|
async exec(statement, config) {
|
|
2180
4862
|
execSql(runtime, getDatabase(config), statement);
|
|
2181
4863
|
},
|
|
2182
4864
|
async queryAll(statement, config) {
|
|
2183
4865
|
return queryAll(runtime, getDatabase(config), statement);
|
|
2184
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
|
+
},
|
|
2185
4936
|
async issueOperation(operation, args = [], context = {}, config = {}) {
|
|
2186
4937
|
const database = getDatabase(config);
|
|
2187
4938
|
const READ_OPERATIONS = new Set(['ready', 'list', 'show', 'search', 'stats', 'blocked', 'stale', 'orphans', 'lint', 'children', 'owns', 'claims']);
|
|
@@ -2277,7 +5028,22 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2277
5028
|
registeredAt,
|
|
2278
5029
|
],
|
|
2279
5030
|
);
|
|
2280
|
-
|
|
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);
|
|
2281
5047
|
},
|
|
2282
5048
|
// The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
|
|
2283
5049
|
// PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
|
|
@@ -2335,9 +5101,19 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2335
5101
|
async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
|
|
2336
5102
|
return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
|
|
2337
5103
|
},
|
|
5104
|
+
// Additive bulk read for `forge insights` (Slice C2). Read-only; NOT part of the
|
|
5105
|
+
// GUARDED_DRIVER_METHODS write-path contract, so existing driver stubs stay valid.
|
|
5106
|
+
async listRecentKernelEvents({ since = null, limit = null } = {}, _context = {}, config = {}) {
|
|
5107
|
+
return listRecentKernelEventRows(runtime, getDatabase(config), since, limit);
|
|
5108
|
+
},
|
|
2338
5109
|
async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
|
|
2339
5110
|
return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
|
|
2340
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
|
+
},
|
|
2341
5117
|
async insertKernelConflict(conflict, _context = {}, config = {}) {
|
|
2342
5118
|
return insertKernelConflictRow(runtime, getDatabase(config), conflict);
|
|
2343
5119
|
},
|
|
@@ -2380,6 +5156,69 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2380
5156
|
async updateKernelClaimState(claimId, state, _context = {}, config = {}) {
|
|
2381
5157
|
return updateKernelClaimStateRow(runtime, getDatabase(config), claimId, state);
|
|
2382
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
|
+
},
|
|
2383
5222
|
// commitGuardedAccept invokes this (typeof-guarded) INSIDE its BEGIN IMMEDIATE
|
|
2384
5223
|
// transaction to apply an accepted issue event to the authority tables. The
|
|
2385
5224
|
// returned summary ({id, revision, comment_id?}) flows back through
|
|
@@ -2427,6 +5266,12 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2427
5266
|
recordStageTransition(input, config = {}) {
|
|
2428
5267
|
return recordStageTransitionRow(runtime, getDatabase(config), input);
|
|
2429
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
|
+
},
|
|
2430
5275
|
listStageRuns(filter = {}, config = {}) {
|
|
2431
5276
|
return listStageRunRows(runtime, getDatabase(config), filter.issue_id);
|
|
2432
5277
|
},
|
|
@@ -2453,42 +5298,111 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2453
5298
|
searchMemoriesRanked(query, limit, config = {}) {
|
|
2454
5299
|
const database = getDatabase(config);
|
|
2455
5300
|
ensureMemorySchema(database);
|
|
2456
|
-
return searchMemoryRowsRanked(runtime, database, query, limit);
|
|
5301
|
+
return searchMemoryRowsRanked(runtime, database, query, limit, config);
|
|
2457
5302
|
},
|
|
2458
5303
|
// Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
|
|
2459
5304
|
// caller can apply a relevance floor. A no-match/empty query returns [] (no recency
|
|
2460
5305
|
// fallback). Used by the per-turn memory-recall hook.
|
|
2461
5306
|
searchMemoriesRankedScored(query, limit, config = {}) {
|
|
2462
5307
|
const database = getDatabase(config);
|
|
2463
|
-
ensureMemorySchema(database);
|
|
2464
|
-
return searchMemoryRowsRankedScored(runtime, database, query, limit);
|
|
5308
|
+
ensureMemorySchema(database, config.busyTimeoutMs);
|
|
5309
|
+
return searchMemoryRowsRankedScored(runtime, database, query, limit, config);
|
|
2465
5310
|
},
|
|
2466
5311
|
// The newest `limit` entries (default recall with no query). `options.agents` scopes
|
|
2467
5312
|
// the read to a source_agent allow-list (e.g. human `remember` notes only).
|
|
2468
5313
|
recentMemories(limit, options = {}, config = {}) {
|
|
2469
5314
|
const database = getDatabase(config);
|
|
2470
5315
|
ensureMemorySchema(database);
|
|
2471
|
-
return recentMemoryRows(runtime, database, limit, options
|
|
5316
|
+
return recentMemoryRows(runtime, database, limit, options);
|
|
2472
5317
|
},
|
|
2473
|
-
// 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
|
|
2474
5319
|
// "showing N of TOTAL".
|
|
2475
5320
|
countMemories(options = {}, config = {}) {
|
|
2476
5321
|
const database = getDatabase(config);
|
|
2477
5322
|
ensureMemorySchema(database);
|
|
2478
|
-
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);
|
|
2479
5348
|
},
|
|
2480
5349
|
listMemories(config = {}) {
|
|
2481
5350
|
const database = getDatabase(config);
|
|
2482
5351
|
ensureMemorySchema(database);
|
|
2483
5352
|
return listMemoryRows(runtime, database);
|
|
2484
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
|
+
},
|
|
2485
5397
|
close() {
|
|
2486
5398
|
closeDatabase(db);
|
|
2487
5399
|
db = null;
|
|
2488
5400
|
openedDatabasePath = null;
|
|
2489
5401
|
memorySchemaEnsured = false;
|
|
5402
|
+
usageEvidenceSchemaEnsured = false;
|
|
2490
5403
|
},
|
|
2491
5404
|
};
|
|
5405
|
+
return driver;
|
|
2492
5406
|
}
|
|
2493
5407
|
|
|
2494
5408
|
function assertCapability(runtime, capability, detail) {
|
|
@@ -2595,13 +5509,354 @@ async function createBackup(runtime, db, backupPath) {
|
|
|
2595
5509
|
if (typeof db.serialize !== 'function') {
|
|
2596
5510
|
throw new Error('bun:sqlite Database.serialize() is unavailable');
|
|
2597
5511
|
}
|
|
2598
|
-
fs.writeFileSync(backupPath, db.serialize());
|
|
5512
|
+
fs.writeFileSync(backupPath, db.serialize(), { mode: 0o600 });
|
|
2599
5513
|
return;
|
|
2600
5514
|
}
|
|
2601
5515
|
|
|
2602
5516
|
throw new Error(`Unsupported builtin SQLite runtime: ${runtime.id}`);
|
|
2603
5517
|
}
|
|
2604
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
|
+
|
|
2605
5860
|
async function validateBackup(runtime, db, backupPath) {
|
|
2606
5861
|
const tableName = createProbeTableName('forge_backup_probe');
|
|
2607
5862
|
try {
|
|
@@ -2676,7 +5931,7 @@ async function validateBuiltinSQLiteRuntimeDriver(options = {}, deps = {}) {
|
|
|
2676
5931
|
|
|
2677
5932
|
function createBuiltinSQLiteDriver(options = {}, deps = {}) {
|
|
2678
5933
|
const runtime = options.runtime || selectBuiltinSQLiteRuntime(deps);
|
|
2679
|
-
return createDriver(runtime, options.databasePath);
|
|
5934
|
+
return createDriver(runtime, options.databasePath, options);
|
|
2680
5935
|
}
|
|
2681
5936
|
|
|
2682
5937
|
module.exports = {
|
|
@@ -2684,7 +5939,15 @@ module.exports = {
|
|
|
2684
5939
|
CONFLICT_SIGNAL,
|
|
2685
5940
|
classifyConflictSignal,
|
|
2686
5941
|
createBuiltinSQLiteDriver,
|
|
5942
|
+
hardenBackupPermissions,
|
|
5943
|
+
hardenBackupPermissionsBatch,
|
|
2687
5944
|
requireSqliteRuntimeModule,
|
|
5945
|
+
resolveWindowsCscriptPath,
|
|
5946
|
+
secureWindowsPathsAcl,
|
|
2688
5947
|
selectBuiltinSQLiteRuntime,
|
|
5948
|
+
syncClaimRepairRecoveryDirectory,
|
|
2689
5949
|
validateBuiltinSQLiteRuntimeDriver,
|
|
5950
|
+
WINDOWS_PRIVATE_ACL_SCRIPT_PATH,
|
|
5951
|
+
WINDOWS_PRIVATE_ACL_SCRIPT_SHA256,
|
|
5952
|
+
_runWindowsProcess: runWindowsProcess,
|
|
2690
5953
|
};
|