@yemi33/minions 0.1.2447 → 0.1.2449
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/bin/cli-api-client.js +1 -1
- package/bin/install-internal-minions.js +1382 -44
- package/bin/install-layout.js +150 -0
- package/bin/minions.js +460 -167
- package/dashboard/docs/typography.md +65 -12
- package/dashboard/js/command-center.js +66 -7
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +186 -45
- package/dashboard/js/refresh.js +148 -12
- package/dashboard/js/render-dispatch.js +3 -4
- package/dashboard/js/render-inbox.js +2 -2
- package/dashboard/js/render-other.js +3 -3
- package/dashboard/js/render-pipelines.js +14 -0
- package/dashboard/js/render-plans.js +57 -9
- package/dashboard/js/render-prd.js +132 -23
- package/dashboard/js/render-prs.js +195 -166
- package/dashboard/js/render-schedules.js +63 -3
- package/dashboard/js/render-utils.js +3 -3
- package/dashboard/js/render-watches.js +19 -3
- package/dashboard/js/render-work-items.js +238 -30
- package/dashboard/js/settings.js +205 -54
- package/dashboard/js/utils.js +51 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/qa.html +1 -16
- package/dashboard/pages/work.html +40 -0
- package/dashboard/shared/cc-limits.js +79 -0
- package/dashboard/shared/pr-filters.js +21 -38
- package/dashboard/shared/project-git-summary.js +1 -1
- package/dashboard/shared/record-filters.js +169 -0
- package/dashboard/shared/watches-source.js +1 -1
- package/dashboard/shared/welcome-popup.js +1 -1
- package/dashboard/shared/wi-filters.js +302 -0
- package/dashboard/slim/body.html +1 -0
- package/dashboard/slim/js/command-send.js +26 -0
- package/dashboard/slim/js/modals-tiles.js +380 -39
- package/dashboard/slim/js/status.js +13 -21
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/panel-bootstrap.js +6 -2
- package/dashboard/slim/styles.css +38 -0
- package/dashboard/styles.css +159 -55
- package/dashboard-build.js +52 -6
- package/dashboard.js +956 -423
- package/docs/README.md +11 -6
- package/docs/api-errors.md +2 -2
- package/docs/architecture-review-2026-07-09.md +1 -1
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +18 -9
- package/docs/branch-derivation.md +4 -4
- package/docs/capture-demos.js +39 -2
- package/docs/ci-runner-canary.md +123 -0
- package/docs/claude-md-propagation.md +2 -2
- package/docs/cloud-agent-dispatch.md +204 -0
- package/docs/command-center.md +7 -7
- package/docs/completion-reports.md +43 -20
- package/docs/constants.md +10 -3
- package/docs/constellation-bridge.md +134 -6
- package/docs/constellation-style-telemetry.md +4 -4
- package/docs/contracts/capability-protocol.v1.json +165 -0
- package/docs/cooldown-merge-semantics.md +12 -12
- package/docs/copilot-cli-schema.md +7 -7
- package/docs/cross-repo-plans.md +10 -10
- package/docs/dead-code-audit-retractions.md +5 -5
- package/docs/default-branch-ci.md +173 -0
- package/docs/deprecated.json +31 -31
- package/docs/design-inbox-entries-schema.md +3 -3
- package/docs/design-language.md +1051 -0
- package/docs/design-state-storage.md +11 -11
- package/docs/diagnostics-crash-reports.md +9 -9
- package/docs/diagnostics-memory.md +5 -5
- package/docs/documentation-audit-2026-07-09.md +7 -7
- package/docs/engine-restart.md +90 -5
- package/docs/harness-mode.md +1 -1
- package/docs/internal-install.md +338 -39
- package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
- package/docs/kb-pr3223-cascade-archiving.md +1 -1
- package/docs/kb-pr696-merge-conflict-docs.md +6 -6
- package/docs/kb-sweep.md +35 -35
- package/docs/keep-processes.md +1 -1
- package/docs/live-checkout-mode.md +30 -30
- package/docs/managed-spawn.md +18 -14
- package/docs/named-agents.md +7 -7
- package/docs/plan-lifecycle.md +69 -2
- package/docs/pr-author-identity.md +114 -0
- package/docs/pr-auto-fix-dispatch.md +19 -4
- package/docs/pr-comment-followup.md +6 -6
- package/docs/pr-review-fix-loop.md +59 -10
- package/docs/process-termination.md +40 -0
- package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
- package/docs/qa-runbook-lifecycle.md +367 -17
- package/docs/qa-runbooks.md +3 -3
- package/docs/rfc-completion-json.md +18 -18
- package/docs/runtime-adapters.md +26 -21
- package/docs/security.md +6 -6
- package/docs/self-improvement.md +4 -4
- package/docs/shared-lifecycle-module-map.md +473 -472
- package/docs/skills.md +52 -3
- package/docs/slim-ux/concepts.md +121 -116
- package/docs/specs/agent-configurability.md +18 -18
- package/docs/specs/agent-rename.md +18 -18
- package/docs/team-memory.md +38 -21
- package/docs/timeouts-and-liveness.md +118 -10
- package/docs/tutorials/01-install-and-connect.md +1 -1
- package/docs/watches.md +40 -39
- package/docs/workspace-manifests.md +4 -4
- package/docs/worktree-lifecycle.md +293 -14
- package/engine/README.md +46 -0
- package/engine/{ado-comment.js → ado/comment.js} +8 -8
- package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
- package/engine/{ado.js → ado/index.js} +417 -63
- package/engine/{ado-status.js → ado/status.js} +6 -8
- package/engine/{ado-token.js → ado/token.js} +1 -1
- package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
- package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
- package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
- package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
- package/engine/{harness-context.js → agents/harness-context.js} +5 -5
- package/engine/{harness.js → agents/harness.js} +3 -3
- package/engine/{llm.js → agents/llm.js} +18 -14
- package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
- package/engine/{playbook.js → agents/playbook.js} +155 -22
- package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
- package/engine/{preflight.js → agents/preflight.js} +29 -10
- package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
- package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
- package/engine/{steering.js → agents/steering.js} +5 -5
- package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
- package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
- package/engine/{api-validation.js → api/api-validation.js} +1 -1
- package/engine/api/bridge.js +787 -0
- package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
- package/engine/api/companion.js +560 -0
- package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
- package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
- package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
- package/engine/api-contracts/agent-content.js +4 -4
- package/engine/api-contracts/capability-manifest.js +236 -0
- package/engine/api-contracts/capability-protocol.js +333 -0
- package/engine/api-contracts/cc-ops.js +1 -1
- package/engine/api-contracts/config-runtime.js +5 -0
- package/engine/api-contracts/core.js +28 -1
- package/engine/api-contracts/index.js +100 -0
- package/engine/api-contracts/orchestration.js +18 -5
- package/engine/api-contracts/pull-requests.js +37 -6
- package/engine/api-contracts/qa-process.js +29 -6
- package/engine/api-contracts/work-plan-prd.js +21 -1
- package/engine/cloud/contract.js +212 -0
- package/engine/cloud/index.js +159 -0
- package/engine/{execution-model.js → core/execution-model.js} +1 -1
- package/engine/{features.js → core/features.js} +4 -4
- package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
- package/engine/{queries.js → core/queries.js} +201 -36
- package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
- package/engine/{shared.js → core/shared.js} +1637 -175
- package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
- package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
- package/engine/db/index.js +11 -2
- package/engine/db/migrations/002-dispatches.js +3 -3
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/db/migrations/006-metrics.js +1 -1
- package/engine/db/migrations/007-watches.js +2 -2
- package/engine/db/migrations/008-small-state.js +1 -1
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/010-pr-links.js +1 -1
- package/engine/db/migrations/011-remaining-state.js +1 -1
- package/engine/db/migrations/012-steering-deliveries.js +2 -2
- package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
- package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/db/migrations/018-sql-only-cutover.js +2 -2
- package/engine/db/migrations/021-archived-work-items.js +1 -1
- package/engine/db/migrations/022-global-cc-session.js +1 -1
- package/engine/db/migrations/023-engine-state.js +1 -1
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
- package/engine/db/migrations/029-repair-reused-versions.js +20 -0
- package/engine/db/migrations/031-pr-author-identity.js +137 -0
- package/engine/{consolidation.js → memory/consolidation.js} +6 -6
- package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
- package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
- package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
- package/engine/{memory-store.js → memory/memory-store.js} +3 -3
- package/engine/{promotion.js → memory/promotion.js} +3 -3
- package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
- package/engine/{review-learning.js → memory/review-learning.js} +10 -5
- package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
- package/engine/{logs-store.js → observability/logs-store.js} +5 -5
- package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
- package/engine/{check-status.js → operations/check-status.js} +3 -3
- package/engine/{cli.js → operations/cli.js} +271 -113
- package/engine/{distribution.js → operations/distribution.js} +5 -6
- package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
- package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
- package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
- package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
- package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
- package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
- package/engine/{meeting.js → orchestration/meeting.js} +6 -16
- package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
- package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
- package/engine/{routing.js → orchestration/routing.js} +3 -3
- package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
- package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
- package/engine/{timeout.js → orchestration/timeout.js} +158 -109
- package/engine/{db-events.js → persistence/db-events.js} +2 -2
- package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
- package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
- package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
- package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
- package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
- package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
- package/engine/persistence/state-operations.js +350 -0
- package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
- package/engine/{issues.js → planning/issues.js} +2 -2
- package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
- package/engine/planning/prd-result-sidecar.js +190 -0
- package/engine/{prd-store.js → planning/prd-store.js} +17 -17
- package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
- package/engine/{projects.js → planning/projects.js} +10 -10
- package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
- package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
- package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
- package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
- package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
- package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
- package/engine/{process-utils.js → processes/process-utils.js} +599 -55
- package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
- package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
- package/engine/{comment-format.js → providers/comment-format.js} +5 -5
- package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
- package/engine/{gh-token.js → providers/gh-token.js} +4 -4
- package/engine/{github.js → providers/github.js} +131 -54
- package/engine/{pr-action.js → providers/pr-action.js} +13 -12
- package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
- package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
- package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
- package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
- package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
- package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
- package/engine/{pr-track.js → providers/pr-track.js} +11 -13
- package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
- package/engine/qa/auto-prd-qa.js +313 -0
- package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
- package/engine/qa/prd-session.js +240 -0
- package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
- package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
- package/engine/{qa-runs.js → qa/runs.js} +286 -15
- package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
- package/engine/qa/visual-journey.js +654 -0
- package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
- package/engine/qa-runners/maestro.js +3 -3
- package/engine/qa-runners/playwright.js +2 -2
- package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
- package/engine/recovery/stop-stack.js +607 -0
- package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
- package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
- package/engine/runtimes/claude.js +14 -12
- package/engine/runtimes/codex.js +8 -6
- package/engine/runtimes/copilot.js +17 -16
- package/engine/{watch-actions.js → watches/actions.js} +13 -13
- package/engine/{watches.js → watches/index.js} +43 -32
- package/engine/{watches-store.js → watches/store.js} +4 -4
- package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
- package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
- package/engine/worktrees/inventory.js +671 -0
- package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
- package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
- package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
- package/engine/worktrees/quarantine-refs.js +173 -0
- package/engine.js +1137 -208
- package/minions.js +147 -77
- package/package.json +10 -6
- package/playbooks/_pr-description-audit.md +110 -78
- package/playbooks/build-fix-complex.md +2 -0
- package/playbooks/fix.md +16 -12
- package/playbooks/implement-shared.md +2 -0
- package/playbooks/implement.md +19 -20
- package/playbooks/plan-to-prd.md +18 -3
- package/playbooks/qa-session-draft.md +136 -1
- package/playbooks/qa-session-execute.md +80 -2
- package/playbooks/qa-session-setup.md +17 -1
- package/playbooks/qa-validate.md +1 -1
- package/playbooks/setup.md +2 -0
- package/playbooks/shared-rules.md +25 -32
- package/playbooks/templates/followup-dispatch.md +4 -3
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +19 -27
- package/watch-plugins/README.md +92 -0
- package/watch-plugins/ado-author-prs.js +336 -0
- package/watch-plugins/gh-author-prs.js +375 -0
- package/watch-plugins/http.js +474 -0
- package/watch-plugins/teams-channel.js +869 -0
- package/docs/dev-composite-workflow.md +0 -101
- package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
- package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
- package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/visual-evidence-ci.md +0 -103
- package/engine/bridge.js +0 -379
- package/engine/quarantine-refs.js +0 -103
- package/engine/state-operations.js +0 -178
- /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Explicit operational tooling for the SQL-only runtime state store.
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { getDb } = require('../db');
|
|
7
|
+
|
|
8
|
+
/** How many ids per entity are sampled into a state summary. */
|
|
9
|
+
const IDENTITY_SAMPLE_LIMIT = 50;
|
|
10
|
+
|
|
11
|
+
function _quoteSqlString(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Table names are interpolated into SQL, so they are validated, never trusted. */
|
|
16
|
+
function _tableNames(db) {
|
|
17
|
+
const names = db.prepare(`
|
|
18
|
+
SELECT name FROM sqlite_master
|
|
19
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
20
|
+
ORDER BY name
|
|
21
|
+
`).all().map(row => row.name);
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`unsafe table name: ${name}`);
|
|
24
|
+
}
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkState(db = getDb()) {
|
|
29
|
+
const schemaVersion = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_version').get().version) || 0;
|
|
30
|
+
const quickCheck = db.prepare('PRAGMA quick_check').all().map(row => Object.values(row)[0]);
|
|
31
|
+
const foreignKeyViolations = db.prepare('PRAGMA foreign_key_check').all();
|
|
32
|
+
const invariants = {
|
|
33
|
+
orphanPrdItems: Number(db.prepare('SELECT COUNT(*) AS n FROM prd_items pi LEFT JOIN prds p ON p.id=pi.prd_id WHERE p.id IS NULL').get().n) || 0,
|
|
34
|
+
orphanVerifyPrs: Number(db.prepare('SELECT COUNT(*) AS n FROM prd_verify_prs vp LEFT JOIN prds p ON p.id=vp.prd_id WHERE p.id IS NULL').get().n) || 0,
|
|
35
|
+
brokenWorkItemPrdLinks: Number(db.prepare('SELECT COUNT(*) AS n FROM work_items wi LEFT JOIN prd_items pi ON pi.id=wi.prd_item_id WHERE wi.prd_item_id IS NOT NULL AND pi.id IS NULL').get().n) || 0,
|
|
36
|
+
};
|
|
37
|
+
const ok = quickCheck.length === 1
|
|
38
|
+
&& quickCheck[0] === 'ok'
|
|
39
|
+
&& foreignKeyViolations.length === 0
|
|
40
|
+
&& Object.values(invariants).every(count => count === 0);
|
|
41
|
+
return { ok, schemaVersion, quickCheck, foreignKeyViolations, invariants };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Absolute path of the `main` database file behind an open connection. */
|
|
45
|
+
function _mainDbFile(db) {
|
|
46
|
+
try {
|
|
47
|
+
const row = db.prepare('PRAGMA database_list').all().find(entry => entry.name === 'main');
|
|
48
|
+
return row && row.file ? row.file : null;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _walBytes(db) {
|
|
53
|
+
const file = _mainDbFile(db);
|
|
54
|
+
if (!file) return 0;
|
|
55
|
+
try { return fs.statSync(`${file}-wal`).size; } catch { return 0; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Best-effort WAL quiescence. Deliberately PASSIVE: TRUNCATE/RESTART block
|
|
59
|
+
// until every reader is on the newest snapshot, so with the engine and
|
|
60
|
+
// dashboard connected they either burn the busy timeout and report `busy` (a
|
|
61
|
+
// RESULT ROW that `db.exec()` silently discards, which is how the old code
|
|
62
|
+
// claimed a checkpoint it never got) or raise outright and abort the whole
|
|
63
|
+
// backup. Quiescence is an optimisation here, not a correctness requirement,
|
|
64
|
+
// so every failure is captured and reported instead of thrown.
|
|
65
|
+
function _passiveCheckpoint(db) {
|
|
66
|
+
try {
|
|
67
|
+
const row = db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() || {};
|
|
68
|
+
const busy = Number(row.busy) || 0;
|
|
69
|
+
const log = Number(row.log);
|
|
70
|
+
const checkpointed = Number(row.checkpointed);
|
|
71
|
+
// `log` is the total frame count in the WAL and `checkpointed` the number
|
|
72
|
+
// already folded back into the database, so equality means fully quiesced.
|
|
73
|
+
// Both are -1 outside WAL mode, which is also quiesced.
|
|
74
|
+
const complete = busy === 0
|
|
75
|
+
&& Number.isFinite(log)
|
|
76
|
+
&& Number.isFinite(checkpointed)
|
|
77
|
+
&& checkpointed >= log;
|
|
78
|
+
return { checkpointed: complete, error: null };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { checkpointed: false, error: (e && e.message) ? e.message : String(e) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function backupState(targetPath) {
|
|
85
|
+
if (!targetPath) throw new Error('state backup requires an output path');
|
|
86
|
+
const resolved = path.resolve(targetPath);
|
|
87
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
88
|
+
if (fs.existsSync(resolved)) throw new Error(`backup target already exists: ${resolved}`);
|
|
89
|
+
const db = getDb();
|
|
90
|
+
const walBytesAtStart = _walBytes(db);
|
|
91
|
+
const checkpoint = _passiveCheckpoint(db);
|
|
92
|
+
if (checkpoint.error) {
|
|
93
|
+
console.warn(`[state] backup: WAL checkpoint skipped, continuing (non-fatal): ${checkpoint.error}`);
|
|
94
|
+
}
|
|
95
|
+
// Authoritative step. VACUUM INTO takes a read transaction whose snapshot
|
|
96
|
+
// already includes committed WAL frames and writes a standalone, internally
|
|
97
|
+
// consistent database, so it is correct with or without the checkpoint above
|
|
98
|
+
// and is the ONLY step allowed to fail the backup. Never byte-copy state.db:
|
|
99
|
+
// that drops committed-but-uncheckpointed transactions.
|
|
100
|
+
db.exec(`VACUUM INTO ${_quoteSqlString(resolved)}`);
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
path: resolved,
|
|
104
|
+
bytes: fs.statSync(resolved).size,
|
|
105
|
+
checkpointed: checkpoint.checkpointed,
|
|
106
|
+
walBytesAtStart,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _sampleIds(db, table, column = 'id', limit = IDENTITY_SAMPLE_LIMIT) {
|
|
111
|
+
try {
|
|
112
|
+
return db.prepare(`SELECT "${column}" AS id FROM "${table}" ORDER BY "${column}" LIMIT ${Number(limit) | 0}`)
|
|
113
|
+
.all().map(row => String(row.id));
|
|
114
|
+
} catch { return []; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Row-level census of the SQL state, used to prove state continuity across an
|
|
119
|
+
* install/migration/restore. The table list is derived from `sqlite_master` so
|
|
120
|
+
* tables added by future migrations are covered without touching this module.
|
|
121
|
+
*/
|
|
122
|
+
function summarizeState(db = getDb()) {
|
|
123
|
+
const schemaVersion = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_version').get().version) || 0;
|
|
124
|
+
const tables = {};
|
|
125
|
+
for (const table of _tableNames(db)) {
|
|
126
|
+
// A table that cannot be counted (an FTS shadow table on a read-only
|
|
127
|
+
// handle, say) records `null` — unknown, never a fabricated zero.
|
|
128
|
+
try { tables[table] = Number(db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get().n) || 0; }
|
|
129
|
+
catch { tables[table] = null; }
|
|
130
|
+
}
|
|
131
|
+
const identities = {
|
|
132
|
+
workItems: _sampleIds(db, 'work_items'),
|
|
133
|
+
pullRequests: _sampleIds(db, 'pull_requests'),
|
|
134
|
+
};
|
|
135
|
+
const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
|
|
136
|
+
schemaVersion,
|
|
137
|
+
tables: Object.keys(tables).sort().map(name => [name, tables[name]]),
|
|
138
|
+
})).digest('hex');
|
|
139
|
+
return { schemaVersion, tables, identities, fingerprint };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _assertSummary(summary, label) {
|
|
143
|
+
if (!summary || typeof summary !== 'object' || !summary.tables || typeof summary.tables !== 'object') {
|
|
144
|
+
throw new Error(`compareStateSummaries: ${label} summary is missing or malformed`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pure comparison of two `summarizeState()` results. A table that was non-empty
|
|
150
|
+
* before and is empty after is a hard regression, so a fresh/empty database can
|
|
151
|
+
* never be reported as a match for a populated baseline.
|
|
152
|
+
*
|
|
153
|
+
* `opts.ignoreTables` opts specific tables out; everything else is compared.
|
|
154
|
+
* This module deliberately holds no policy about WHICH tables are durable —
|
|
155
|
+
* that judgement belongs to the caller, because it depends on what the caller is
|
|
156
|
+
* gating. The install-time gate in `bin/install-internal-minions.js` derives its
|
|
157
|
+
* `ignoreTables` from an explicit durable-table ALLOWLIST
|
|
158
|
+
* (`CONTINUITY_DURABLE_TABLES`) rather than a denylist of volatile tables: most
|
|
159
|
+
* of the SQL state legitimately shrinks across a package cutover, so a denylist
|
|
160
|
+
* fails open on every table nobody remembered to enumerate.
|
|
161
|
+
*/
|
|
162
|
+
function compareStateSummaries(before, after, opts = {}) {
|
|
163
|
+
_assertSummary(before, 'before');
|
|
164
|
+
_assertSummary(after, 'after');
|
|
165
|
+
const ignore = new Set(Array.isArray(opts.ignoreTables) ? opts.ignoreTables : []);
|
|
166
|
+
const regressions = [];
|
|
167
|
+
const missingTables = [];
|
|
168
|
+
const emptyAfterNonEmptyBefore = [];
|
|
169
|
+
for (const table of Object.keys(before.tables).sort()) {
|
|
170
|
+
if (ignore.has(table)) continue;
|
|
171
|
+
if (!Object.prototype.hasOwnProperty.call(after.tables, table)) {
|
|
172
|
+
missingTables.push(table);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const beforeCount = before.tables[table];
|
|
176
|
+
const afterCount = after.tables[table];
|
|
177
|
+
// A null count means the table could not be counted on one side; treat it
|
|
178
|
+
// as unknown rather than inventing a regression.
|
|
179
|
+
if (!Number.isFinite(beforeCount) || !Number.isFinite(afterCount)) continue;
|
|
180
|
+
if (beforeCount > 0 && afterCount === 0) emptyAfterNonEmptyBefore.push(table);
|
|
181
|
+
if (afterCount < beforeCount) regressions.push({ table, before: beforeCount, after: afterCount });
|
|
182
|
+
}
|
|
183
|
+
const ok = regressions.length === 0 && missingTables.length === 0 && emptyAfterNonEmptyBefore.length === 0;
|
|
184
|
+
return { ok, regressions, missingTables, emptyAfterNonEmptyBefore };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Open a backup file read-only and run the same integrity + census checks against it. */
|
|
188
|
+
function verifyBackup(backupPath) {
|
|
189
|
+
if (!backupPath) throw new Error('state verify-backup requires a backup path');
|
|
190
|
+
const resolved = path.resolve(backupPath);
|
|
191
|
+
if (!fs.existsSync(resolved)) throw new Error(`backup not found: ${resolved}`);
|
|
192
|
+
const { installSqliteWarningFilter } = require('../db');
|
|
193
|
+
installSqliteWarningFilter();
|
|
194
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
195
|
+
// Read-only so verifying can never mutate, WAL, or lock the artifact it is
|
|
196
|
+
// supposed to be vouching for.
|
|
197
|
+
const db = new DatabaseSync(resolved, { readOnly: true });
|
|
198
|
+
try {
|
|
199
|
+
const integrity = checkState(db);
|
|
200
|
+
const summary = summarizeState(db);
|
|
201
|
+
return { ok: integrity.ok, path: resolved, integrity, summary };
|
|
202
|
+
} finally {
|
|
203
|
+
try { db.close(); } catch { /* already closed */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function exportState(targetPath) {
|
|
208
|
+
if (!targetPath) throw new Error('state export requires an output path');
|
|
209
|
+
const resolved = path.resolve(targetPath);
|
|
210
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
211
|
+
const db = getDb();
|
|
212
|
+
const tables = _tableNames(db);
|
|
213
|
+
const data = {
|
|
214
|
+
format: 'minions-sql-diagnostic-export-v1',
|
|
215
|
+
exportedAt: new Date().toISOString(),
|
|
216
|
+
integrity: checkState(),
|
|
217
|
+
tables: {},
|
|
218
|
+
};
|
|
219
|
+
for (const table of tables) {
|
|
220
|
+
data.tables[table] = db.prepare(`SELECT * FROM "${table}"`).all();
|
|
221
|
+
}
|
|
222
|
+
const tmp = `${resolved}.tmp.${process.pid}`;
|
|
223
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
224
|
+
fs.renameSync(tmp, resolved);
|
|
225
|
+
return { ok: true, path: resolved, tables: tables.length, bytes: fs.statSync(resolved).size };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _readJson(filePath, expected) {
|
|
229
|
+
if (!fs.existsSync(filePath)) return null;
|
|
230
|
+
const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
231
|
+
if (expected === 'array' && !Array.isArray(value)) throw new Error(`expected array in ${filePath}`);
|
|
232
|
+
if (expected === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) {
|
|
233
|
+
throw new Error(`expected object in ${filePath}`);
|
|
234
|
+
}
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function importLegacyJson(sourceRoot) {
|
|
239
|
+
if (!sourceRoot) throw new Error('state import-legacy-json requires a source directory');
|
|
240
|
+
const root = path.resolve(sourceRoot);
|
|
241
|
+
if (!fs.statSync(root).isDirectory()) throw new Error(`legacy source is not a directory: ${root}`);
|
|
242
|
+
const imported = {};
|
|
243
|
+
const replace = (modulePath, fn, value) => {
|
|
244
|
+
if (value == null) return;
|
|
245
|
+
require(modulePath)[fn](() => value);
|
|
246
|
+
imported[fn] = (imported[fn] || 0) + 1;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
replace(
|
|
250
|
+
'./small-state-store',
|
|
251
|
+
'applyEngineStateMutation',
|
|
252
|
+
_readJson(path.join(root, 'engine', 'state.json'), 'object'),
|
|
253
|
+
);
|
|
254
|
+
replace('../dispatch-store', 'applyDispatchMutation', _readJson(path.join(root, 'engine', 'dispatch.json'), 'object'));
|
|
255
|
+
replace('../observability/metrics-store', 'applyMetricsMutation', _readJson(path.join(root, 'engine', 'metrics.json'), 'object'));
|
|
256
|
+
replace('../watches/store', 'applyWatchesMutation', _readJson(path.join(root, 'engine', 'watches.json'), 'array'));
|
|
257
|
+
|
|
258
|
+
const small = [
|
|
259
|
+
['schedule-runs.json', 'applyScheduleRunsMutation', 'object'],
|
|
260
|
+
['pipeline-runs.json', 'applyPipelineRunsMutation', 'object'],
|
|
261
|
+
['managed-processes.json', 'applyManagedProcessesMutation', 'object'],
|
|
262
|
+
['worktree-pool.json', 'applyWorktreePoolMutation', 'object'],
|
|
263
|
+
['qa-runs.json', 'applyQaRunsMutation', 'array'],
|
|
264
|
+
['qa-sessions.json', 'applyQaSessionsMutation', 'array'],
|
|
265
|
+
['pr-links.json', 'applyPrLinksMutation', 'object'],
|
|
266
|
+
['cooldowns.json', 'applyCooldownsMutation', 'object'],
|
|
267
|
+
['pending-rebases.json', 'applyPendingRebasesMutation', 'array'],
|
|
268
|
+
['cc-sessions.json', 'applyCcSessionsMutation', 'array'],
|
|
269
|
+
['doc-sessions.json', 'applyDocSessionsMutation', 'object'],
|
|
270
|
+
];
|
|
271
|
+
for (const [filename, fn, shape] of small) {
|
|
272
|
+
replace('./small-state-store', fn, _readJson(path.join(root, 'engine', filename), shape));
|
|
273
|
+
}
|
|
274
|
+
replace(
|
|
275
|
+
'./small-state-store',
|
|
276
|
+
'applyCcGlobalSessionMutation',
|
|
277
|
+
_readJson(path.join(root, 'engine', 'cc-session.json'), 'object'),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const workItemsStore = require('../planning/work-items-store');
|
|
281
|
+
const pullRequestsStore = require('./pull-requests-store');
|
|
282
|
+
const importScope = (scope, dir) => {
|
|
283
|
+
const workItems = _readJson(path.join(dir, 'work-items.json'), 'array');
|
|
284
|
+
if (workItems) {
|
|
285
|
+
workItemsStore.applyWorkItemsMutation(scope, () => workItems);
|
|
286
|
+
imported.workItemScopes = (imported.workItemScopes || 0) + 1;
|
|
287
|
+
}
|
|
288
|
+
const archivedWorkItems = _readJson(path.join(dir, 'work-items-archive.json'), 'array');
|
|
289
|
+
if (archivedWorkItems) {
|
|
290
|
+
workItemsStore.replaceArchivedWorkItems(scope, archivedWorkItems);
|
|
291
|
+
imported.archivedWorkItemScopes = (imported.archivedWorkItemScopes || 0) + 1;
|
|
292
|
+
}
|
|
293
|
+
const pullRequests = _readJson(path.join(dir, 'pull-requests.json'), 'array');
|
|
294
|
+
if (pullRequests) {
|
|
295
|
+
pullRequestsStore.applyPullRequestsMutation(scope, () => pullRequests);
|
|
296
|
+
imported.pullRequestScopes = (imported.pullRequestScopes || 0) + 1;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
importScope('central', root);
|
|
300
|
+
try {
|
|
301
|
+
for (const entry of fs.readdirSync(path.join(root, 'projects'), { withFileTypes: true })) {
|
|
302
|
+
if (entry.isDirectory() && entry.name !== '.archived') {
|
|
303
|
+
importScope(entry.name, path.join(root, 'projects', entry.name));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch { /* projects are optional */ }
|
|
307
|
+
|
|
308
|
+
const prdStore = require('../planning/prd-store');
|
|
309
|
+
for (const [dir, archived] of [[path.join(root, 'prd'), false], [path.join(root, 'prd', 'archive'), true]]) {
|
|
310
|
+
let files = [];
|
|
311
|
+
try { files = fs.readdirSync(dir).filter(name => name.endsWith('.json')); } catch { continue; }
|
|
312
|
+
for (const filename of files) {
|
|
313
|
+
const prd = _readJson(path.join(dir, filename), 'object');
|
|
314
|
+
prdStore.writePrd(filename, prd, { archived });
|
|
315
|
+
imported.prds = (imported.prds || 0) + 1;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const logs = _readJson(path.join(root, 'engine', 'log.json'), 'array');
|
|
320
|
+
if (logs) {
|
|
321
|
+
const db = getDb();
|
|
322
|
+
const { withTransaction } = require('../db');
|
|
323
|
+
withTransaction(db, () => {
|
|
324
|
+
db.exec('DELETE FROM logs');
|
|
325
|
+
const insert = db.prepare('INSERT INTO logs (ts_ms, level, message, meta) VALUES (?, ?, ?, ?)');
|
|
326
|
+
for (const entry of logs) {
|
|
327
|
+
const timestamp = Date.parse(entry.timestamp);
|
|
328
|
+
const meta = { ...entry };
|
|
329
|
+
delete meta.timestamp;
|
|
330
|
+
delete meta.level;
|
|
331
|
+
delete meta.message;
|
|
332
|
+
const { level, message } = entry;
|
|
333
|
+
insert.run(Number.isFinite(timestamp) ? timestamp : Date.now(), level || 'info', String(message || ''), Object.keys(meta).length ? JSON.stringify(meta) : null);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
imported.logs = logs.length;
|
|
337
|
+
}
|
|
338
|
+
return { ok: true, source: root, imported, integrity: checkState() };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
checkState,
|
|
343
|
+
backupState,
|
|
344
|
+
summarizeState,
|
|
345
|
+
compareStateSummaries,
|
|
346
|
+
verifyBackup,
|
|
347
|
+
exportState,
|
|
348
|
+
importLegacyJson,
|
|
349
|
+
_passiveCheckpoint, // exported for testing
|
|
350
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// engine/steering-store.js — SQL-backed observable delivery state for
|
|
1
|
+
// engine/persistence/steering-store.js — SQL-backed observable delivery state for
|
|
2
2
|
// inbox steering messages.
|
|
3
3
|
//
|
|
4
4
|
// One row per steering message in the steering_deliveries table.
|
|
5
|
-
// Mirrors the shape of engine/dispatch-store.js / engine/small-state-store.js:
|
|
5
|
+
// Mirrors the shape of engine/persistence/dispatch-store.js / engine/persistence/small-state-store.js:
|
|
6
6
|
// - routes every read/write through getDb() (no JSON sidecar)
|
|
7
7
|
// - emits emitStateEvent('steering', {agentId, id, status}) on every
|
|
8
8
|
// status transition so the dashboard's MAX(events.id) cache check
|
|
@@ -80,7 +80,7 @@ function insert(rec) {
|
|
|
80
80
|
throw new Error(`steering-store.insert: invalid status '${status}'`);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const { getDb } = require('
|
|
83
|
+
const { getDb } = require('../db');
|
|
84
84
|
const db = getDb();
|
|
85
85
|
const existing = db.prepare('SELECT * FROM steering_deliveries WHERE id = ?').get(id);
|
|
86
86
|
if (existing) return _rowToRecord(existing);
|
|
@@ -120,7 +120,7 @@ function updateStatus(id, status, opts = {}) {
|
|
|
120
120
|
if (!VALID_STATUSES.has(status)) {
|
|
121
121
|
throw new Error(`steering-store.updateStatus: invalid status '${status}'`);
|
|
122
122
|
}
|
|
123
|
-
const { getDb } = require('
|
|
123
|
+
const { getDb } = require('../db');
|
|
124
124
|
const db = getDb();
|
|
125
125
|
const row = db.prepare('SELECT * FROM steering_deliveries WHERE id = ?').get(id);
|
|
126
126
|
if (!row) return null;
|
|
@@ -159,7 +159,7 @@ function listForAgent(agentId, opts = {}) {
|
|
|
159
159
|
throw new Error(`steering-store.listForAgent: invalid status '${status}'`);
|
|
160
160
|
}
|
|
161
161
|
let db;
|
|
162
|
-
try { const { getDb } = require('
|
|
162
|
+
try { const { getDb } = require('../db'); db = getDb(); }
|
|
163
163
|
catch { return []; }
|
|
164
164
|
const statusClause = status === null ? '' : ' AND status = ?';
|
|
165
165
|
const params = status === null
|
|
@@ -177,7 +177,7 @@ function listForAgent(agentId, opts = {}) {
|
|
|
177
177
|
function getById(id) {
|
|
178
178
|
if (!id) return null;
|
|
179
179
|
let db;
|
|
180
|
-
try { const { getDb } = require('
|
|
180
|
+
try { const { getDb } = require('../db'); db = getDb(); }
|
|
181
181
|
catch { return null; }
|
|
182
182
|
const row = db.prepare('SELECT * FROM steering_deliveries WHERE id = ?').get(String(id));
|
|
183
183
|
return _rowToRecord(row);
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execFileSync: _execFileSync } = require('child_process');
|
|
8
|
-
const shared = require('
|
|
9
|
-
const ghToken = require('
|
|
8
|
+
const shared = require('../core/shared');
|
|
9
|
+
const ghToken = require('../providers/gh-token');
|
|
10
10
|
|
|
11
11
|
const DEFAULT_REPO = 'opg-microsoft/minions';
|
|
12
12
|
const DEFAULT_LABELS = ['bug'];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const api = require('
|
|
5
|
-
const shared = require('
|
|
4
|
+
const api = require('../api/api-validation');
|
|
5
|
+
const shared = require('../core/shared');
|
|
6
6
|
|
|
7
7
|
const COMPLEXITY_VALUES = Object.freeze(['small', 'medium', 'large']);
|
|
8
8
|
const BRANCH_STRATEGY_VALUES = Object.freeze(['parallel', 'shared-branch']);
|
|
@@ -37,6 +37,12 @@ const PLAN_ACTION_STATUSES = Object.freeze({
|
|
|
37
37
|
shared.PLAN_STATUS.AWAITING_APPROVAL,
|
|
38
38
|
shared.PLAN_STATUS.APPROVED,
|
|
39
39
|
shared.PLAN_STATUS.PAUSED,
|
|
40
|
+
// W-msa0mrus00mh466f — a PRD already in `revision-requested` is gated by the
|
|
41
|
+
// handler's own revision state machine, not by this table: an ACTIVE
|
|
42
|
+
// revision is rejected with `revision-in-progress` (plus the existing work
|
|
43
|
+
// item id), while a FAILED/dangling one must stay re-requestable so the PRD
|
|
44
|
+
// is not stranded in `revision-requested` with no way out.
|
|
45
|
+
shared.PLAN_STATUS.REVISION_REQUESTED,
|
|
40
46
|
]),
|
|
41
47
|
discuss: new Set([
|
|
42
48
|
shared.PLAN_STATUS.AWAITING_APPROVAL,
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// engine/planning/prd-result-sidecar.js
|
|
2
|
+
//
|
|
3
|
+
// The one-shot plan-to-PRD result sidecar contract (W-ms9tcry701xz0389).
|
|
4
|
+
//
|
|
5
|
+
// A plan-to-prd agent writes exactly ONE envelope to
|
|
6
|
+
// `agents/<agent-id>/prd-result.json`:
|
|
7
|
+
//
|
|
8
|
+
// { "filename": "<expected>.json", "work_item": "W-…", "prd": { … } }
|
|
9
|
+
//
|
|
10
|
+
// The engine validates that envelope against the work item's PERSISTED
|
|
11
|
+
// expected identity (`item._prdFilename`, stable across retries) and imports
|
|
12
|
+
// it into the SQL PRD store. Both import sites — the pre-spawn recovery in
|
|
13
|
+
// `engine.js#discoverCentralWorkItems` and the post-completion import in
|
|
14
|
+
// `engine/orchestration/lifecycle.js` — go through this module so the
|
|
15
|
+
// validation and consume semantics can never drift apart.
|
|
16
|
+
//
|
|
17
|
+
// Two invariants this module exists to hold:
|
|
18
|
+
//
|
|
19
|
+
// 1. FAIL CLOSED. There is no permissive filename fallback: an envelope that
|
|
20
|
+
// does not DECLARE the expected filename is rejected rather than assumed
|
|
21
|
+
// to be ours. Combined with the source-plan check (and the optional
|
|
22
|
+
// `work_item` binding) a sidecar left behind by a different work item or
|
|
23
|
+
// a different source plan can never be imported.
|
|
24
|
+
// 2. EXACTLY ONCE. The sidecar is CLAIMED with an atomic rename before the
|
|
25
|
+
// PRD is written, and the claim is removed after a successful import. A
|
|
26
|
+
// concurrent or repeated consume finds no file, so one sidecar can never
|
|
27
|
+
// materialize two PRDs, and a consumed sidecar can never be inherited by
|
|
28
|
+
// a later unrelated dispatch from the same agent.
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const shared = require('../core/shared');
|
|
34
|
+
|
|
35
|
+
const PRD_RESULT_FILENAME = 'prd-result.json';
|
|
36
|
+
// Sibling of the sidecar, inside the same agent directory, so the claim rename
|
|
37
|
+
// is always an intra-directory (atomic) move on every supported platform.
|
|
38
|
+
const PRD_RESULT_CLAIM_FILENAME = 'prd-result.claimed.json';
|
|
39
|
+
|
|
40
|
+
// Stable outcome codes. Callers branch on these, never on the prose `reason`.
|
|
41
|
+
const PRD_RESULT_OUTCOME = Object.freeze({
|
|
42
|
+
IMPORTED: 'imported',
|
|
43
|
+
ABSENT: 'absent',
|
|
44
|
+
NO_AGENT: 'no-agent',
|
|
45
|
+
UNREADABLE: 'unreadable',
|
|
46
|
+
REJECTED: 'rejected',
|
|
47
|
+
ALREADY_CONSUMED: 'already-consumed',
|
|
48
|
+
CLAIM_FAILED: 'claim-failed',
|
|
49
|
+
IMPORT_FAILED: 'import-failed',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Absolute path of an agent's one-shot PRD result sidecar. */
|
|
53
|
+
function prdResultSidecarPath(agentsDir, agentId) {
|
|
54
|
+
if (!agentsDir || !agentId) return null;
|
|
55
|
+
return path.join(agentsDir, String(agentId), PRD_RESULT_FILENAME);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a parsed sidecar envelope against the dispatch's authoritative
|
|
60
|
+
* expectations. Returns `{ ok: true, filename, prd }` or
|
|
61
|
+
* `{ ok: false, reason }` — never throws for malformed agent input.
|
|
62
|
+
*
|
|
63
|
+
* `workItemId` is enforced only when the envelope declares `work_item`: the
|
|
64
|
+
* field is newer than the filename/source-plan binding, and an envelope that
|
|
65
|
+
* matches BOTH the expected filename and the source plan is by construction
|
|
66
|
+
* the PRD for this work item's plan (same-plan work items converge on one
|
|
67
|
+
* PRD filename by design).
|
|
68
|
+
*/
|
|
69
|
+
function validatePrdResultEnvelope(envelope, { expectedFilename, planFile, workItemId } = {}) {
|
|
70
|
+
if (!expectedFilename) return { ok: false, reason: 'no expected PRD filename is pinned on the work item' };
|
|
71
|
+
if (!planFile) return { ok: false, reason: 'work item has no planFile to validate the sidecar against' };
|
|
72
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
73
|
+
return { ok: false, reason: 'sidecar is not a JSON object' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const declared = envelope.filename;
|
|
77
|
+
if (typeof declared !== 'string' || !declared) {
|
|
78
|
+
return { ok: false, reason: `sidecar does not declare a "filename" (expected "${expectedFilename}")` };
|
|
79
|
+
}
|
|
80
|
+
if (declared !== expectedFilename) {
|
|
81
|
+
return { ok: false, reason: `sidecar filename "${declared}" does not match expected "${expectedFilename}"` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const declaredWi = envelope.work_item;
|
|
85
|
+
if (declaredWi != null && declaredWi !== '' && workItemId && String(declaredWi) !== String(workItemId)) {
|
|
86
|
+
return { ok: false, reason: `sidecar work_item "${declaredWi}" belongs to another work item (expected "${workItemId}")` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const prd = envelope.prd;
|
|
90
|
+
if (!prd || typeof prd !== 'object' || Array.isArray(prd)) {
|
|
91
|
+
return { ok: false, reason: 'sidecar has no "prd" object' };
|
|
92
|
+
}
|
|
93
|
+
if (!shared.prdMatchesSourcePlan(prd.source_plan, planFile)) {
|
|
94
|
+
return { ok: false, reason: `sidecar source_plan "${prd.source_plan ?? '(unknown)'}" does not match "${planFile}"` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
require('./prd-store').validatePrdFilename(declared);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { ok: false, reason: `sidecar filename is not importable: ${err.message}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { ok: true, filename: declared, prd };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read → validate → atomically claim → import → remove an agent's one-shot
|
|
108
|
+
* PRD result sidecar.
|
|
109
|
+
*
|
|
110
|
+
* Returns `{ imported, code, reason, filename }`. `code` is the stable,
|
|
111
|
+
* machine-readable outcome (`PRD_RESULT_OUTCOME.*`) — callers branch on it
|
|
112
|
+
* rather than on the human-readable `reason` prose. A rejected sidecar is left
|
|
113
|
+
* in place untouched (it may legitimately belong to a sibling work item that
|
|
114
|
+
* has not been retried yet); only a sidecar this call successfully imported is
|
|
115
|
+
* removed. Never throws.
|
|
116
|
+
*
|
|
117
|
+
* `revisionForPrd` is the work item's `_revisionForPrd` reverse stamp
|
|
118
|
+
* (W-msa0mrus00mh466f). When it names the PRD being imported, the envelope is
|
|
119
|
+
* normalized through `shared.applyPrdRevisionCompletion` as part of the SAME
|
|
120
|
+
* `writePrd` that imports it, so a completed revision is never observable
|
|
121
|
+
* alongside the stale recovery controls it just resolved. The rule lives here
|
|
122
|
+
* — beside the only write — for the same reason the validation does: both
|
|
123
|
+
* import sites must apply it identically or a recovered revision silently
|
|
124
|
+
* lands in a different state than a completed one.
|
|
125
|
+
*/
|
|
126
|
+
function consumePrdResultSidecar({
|
|
127
|
+
agentsDir, agentId, expectedFilename, planFile, workItemId, revisionForPrd, plansDir,
|
|
128
|
+
} = {}) {
|
|
129
|
+
const resultPath = prdResultSidecarPath(agentsDir, agentId);
|
|
130
|
+
if (!resultPath) {
|
|
131
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.NO_AGENT, reason: 'no agent to read a sidecar from', filename: null };
|
|
132
|
+
}
|
|
133
|
+
if (!fs.existsSync(resultPath)) {
|
|
134
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.ABSENT, reason: 'no sidecar on disk', filename: null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const envelope = shared.safeJsonNoRestore(resultPath);
|
|
138
|
+
if (envelope === null) {
|
|
139
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.UNREADABLE, reason: 'sidecar is not readable JSON', filename: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const verdict = validatePrdResultEnvelope(envelope, { expectedFilename, planFile, workItemId });
|
|
143
|
+
if (!verdict.ok) {
|
|
144
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.REJECTED, reason: verdict.reason, filename: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Atomic claim: whoever wins the rename owns the import. A loser (or a
|
|
148
|
+
// repeat call) sees ENOENT and reports "already consumed" instead of
|
|
149
|
+
// materializing the same PRD twice.
|
|
150
|
+
const claimPath = path.join(path.dirname(resultPath), PRD_RESULT_CLAIM_FILENAME);
|
|
151
|
+
try {
|
|
152
|
+
fs.renameSync(resultPath, claimPath);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err && err.code === 'ENOENT') {
|
|
155
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.ALREADY_CONSUMED, reason: 'sidecar already consumed', filename: null };
|
|
156
|
+
}
|
|
157
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.CLAIM_FAILED, reason: `could not claim sidecar: ${err.message}`, filename: null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const prdStore = require('./prd-store');
|
|
162
|
+
// A revision import is normalized into the SAME write that imports it.
|
|
163
|
+
// Gated on the structural `_revisionForPrd` ⇄ expected-filename relation
|
|
164
|
+
// (never title text), so a plain regeneration is written untouched.
|
|
165
|
+
const finalPrd = revisionForPrd && revisionForPrd === expectedFilename
|
|
166
|
+
? shared.applyPrdRevisionCompletion(verdict.prd, {
|
|
167
|
+
plansDir,
|
|
168
|
+
previous: prdStore.readPrd(expectedFilename),
|
|
169
|
+
})
|
|
170
|
+
: verdict.prd;
|
|
171
|
+
prdStore.writePrd(verdict.filename, finalPrd);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Import failed — put the sidecar back so the content is not lost and a
|
|
174
|
+
// later attempt (or the agent's own retry) can still recover it.
|
|
175
|
+
try { fs.renameSync(claimPath, resultPath); } catch { /* best effort */ }
|
|
176
|
+
return { imported: false, code: PRD_RESULT_OUTCOME.IMPORT_FAILED, reason: `PRD import failed: ${err.message}`, filename: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try { fs.unlinkSync(claimPath); } catch { /* claim file is inert once imported */ }
|
|
180
|
+
return { imported: true, code: PRD_RESULT_OUTCOME.IMPORTED, reason: null, filename: verdict.filename };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
PRD_RESULT_FILENAME,
|
|
185
|
+
PRD_RESULT_CLAIM_FILENAME,
|
|
186
|
+
PRD_RESULT_OUTCOME,
|
|
187
|
+
prdResultSidecarPath,
|
|
188
|
+
validatePrdResultEnvelope,
|
|
189
|
+
consumePrdResultSidecar,
|
|
190
|
+
};
|