@yemi33/minions 0.1.2448 → 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 +58 -4
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +179 -20
- 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 +13 -2
- 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
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// only. State work is delegated to the *installed* CLI (`minions state backup`,
|
|
15
15
|
// `minions init --force`, `minions restart`) rather than reimplemented here —
|
|
16
16
|
// those are the supported entry points and already own the consistency
|
|
17
|
-
// contracts (`engine/state-operations.js#backupState` does
|
|
18
|
-
// `PRAGMA wal_checkpoint(
|
|
17
|
+
// contracts (`engine/persistence/state-operations.js#backupState` does a
|
|
18
|
+
// best-effort `PRAGMA wal_checkpoint(PASSIVE)` + an authoritative
|
|
19
|
+
// `VACUUM INTO`, which is consistent even when the WAL cannot be quiesced).
|
|
19
20
|
//
|
|
20
21
|
// Token handling: the ADO access token is acquired per-run, written only into a
|
|
21
22
|
// 0600 temporary npm userconfig inside the OS temp dir, and that file is removed
|
|
@@ -29,7 +30,7 @@ const { spawnSync } = require('child_process');
|
|
|
29
30
|
|
|
30
31
|
// ─── Channel constants ──────────────────────────────────────────────────────
|
|
31
32
|
|
|
32
|
-
/** Azure DevOps resource id used for `az account get-access-token` (mirrors engine/ado
|
|
33
|
+
/** Azure DevOps resource id used for `az account get-access-token` (mirrors engine/ado/token.js). */
|
|
33
34
|
const ADO_TOKEN_RESOURCE = '499b84ac-1321-427f-aa17-267ca6975798';
|
|
34
35
|
|
|
35
36
|
/** Documented default feed: the ISS ProjectFeed-ISS Azure Artifacts npm registry. */
|
|
@@ -66,6 +67,9 @@ const USAGE = `
|
|
|
66
67
|
--package <name> Package to install (default: ${INTERNAL_PACKAGE})
|
|
67
68
|
--backup-dir <dir> Where to write the pre-migration state backup
|
|
68
69
|
(default: <runtime root>/backups/internal-install-<timestamp>)
|
|
70
|
+
--runtime-root <dir> Pin the runtime root (MINIONS_HOME) this run reads, backs up,
|
|
71
|
+
synchronizes, and restarts. Required to disambiguate when more
|
|
72
|
+
than one candidate root carries runtime state.
|
|
69
73
|
--keep-public Do not uninstall an existing ${PUBLIC_PACKAGE} global install
|
|
70
74
|
--no-restart Sync runtime files but skip the health-verified restart
|
|
71
75
|
--force Proceed even while agents are active (see docs/internal-install.md)
|
|
@@ -90,6 +94,7 @@ const VALUE_FLAGS = new Map([
|
|
|
90
94
|
['--registry', 'registry'],
|
|
91
95
|
['--package', 'packageName'],
|
|
92
96
|
['--backup-dir', 'backupDir'],
|
|
97
|
+
['--runtime-root', 'runtimeRoot'],
|
|
93
98
|
]);
|
|
94
99
|
|
|
95
100
|
/**
|
|
@@ -102,6 +107,7 @@ function parseArgs(argv) {
|
|
|
102
107
|
registry: DEFAULT_REGISTRY,
|
|
103
108
|
packageName: INTERNAL_PACKAGE,
|
|
104
109
|
backupDir: null,
|
|
110
|
+
runtimeRoot: null,
|
|
105
111
|
keepPublic: false,
|
|
106
112
|
restart: true,
|
|
107
113
|
force: false,
|
|
@@ -128,7 +134,17 @@ function parseArgs(argv) {
|
|
|
128
134
|
if (!opts.packageName.startsWith('@') || !opts.packageName.includes('/')) {
|
|
129
135
|
throw new Error(`--package must be a scoped package name, got: ${opts.packageName}`);
|
|
130
136
|
}
|
|
137
|
+
// The generic VALUE_FLAGS parse accepts an empty/whitespace string, which
|
|
138
|
+
// path.resolve() would turn into the cwd — silently installing into a
|
|
139
|
+
// different root than the caller named.
|
|
140
|
+
if (opts.runtimeRoot !== null && !String(opts.runtimeRoot).trim()) {
|
|
141
|
+
throw new Error('--runtime-root requires a value');
|
|
142
|
+
}
|
|
131
143
|
opts.registry = normalizeRegistry(opts.registry);
|
|
144
|
+
// An explicitly pinned root is resolved here so every downstream comparison —
|
|
145
|
+
// candidate dedupe, the MINIONS_HOME handed to children, the printed plan —
|
|
146
|
+
// sees the same absolute path.
|
|
147
|
+
if (opts.runtimeRoot) opts.runtimeRoot = path.resolve(opts.runtimeRoot);
|
|
132
148
|
return opts;
|
|
133
149
|
}
|
|
134
150
|
|
|
@@ -154,8 +170,54 @@ function packageScope(packageName) {
|
|
|
154
170
|
return scope;
|
|
155
171
|
}
|
|
156
172
|
|
|
173
|
+
// ─── Private-feed channels (W-ms9bhf9k001j29cb) ─────────────────────────────
|
|
174
|
+
//
|
|
175
|
+
// Which npm distributions are served by a private feed instead of the caller's
|
|
176
|
+
// ordinary registry. This is the table `minions update` consults to decide
|
|
177
|
+
// whether an update needs feed auth at all: a package that is not listed keeps
|
|
178
|
+
// the plain `npm view` / `npm install -g` behavior with no token, no temporary
|
|
179
|
+
// config, and no registry override.
|
|
180
|
+
const INTERNAL_FEED_CHANNELS = Object.freeze([
|
|
181
|
+
Object.freeze({ packageName: INTERNAL_PACKAGE, registry: DEFAULT_REGISTRY }),
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the private feed that owns `packageName`, or null when the package is
|
|
186
|
+
* served by whatever registry the caller's npm config already points at.
|
|
187
|
+
*
|
|
188
|
+
* Pure: no filesystem, no network, no environment. `registry` overrides the
|
|
189
|
+
* channel default (the `--registry` flag on both entry points) and is validated
|
|
190
|
+
* the same way, so an override can never silently downgrade to http.
|
|
191
|
+
*/
|
|
192
|
+
function resolveInternalFeed(packageName, { registry } = {}) {
|
|
193
|
+
const channel = INTERNAL_FEED_CHANNELS.find(c => c.packageName === packageName);
|
|
194
|
+
if (!channel) return null;
|
|
195
|
+
return {
|
|
196
|
+
packageName,
|
|
197
|
+
registry: normalizeRegistry(registry || channel.registry),
|
|
198
|
+
scope: packageScope(packageName),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
157
202
|
// ─── Command construction (pure) ────────────────────────────────────────────
|
|
158
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Pin a scoped package's registry at the ONE npm precedence layer ambient
|
|
206
|
+
* configuration cannot override.
|
|
207
|
+
*
|
|
208
|
+
* npm resolves a SCOPED package through `@scope:registry`, and `--registry`
|
|
209
|
+
* only sets the *default* registry — so a `@opg-microsoft:registry=<public
|
|
210
|
+
* proxy>` line in the caller's config wins over `--registry` and routes the
|
|
211
|
+
* request to the wrong feed (observed as a misleading E404 for a package that
|
|
212
|
+
* exists). npm's precedence is `cli > env > ./.npmrc > --userconfig > global`,
|
|
213
|
+
* which also means a project `.npmrc` in the caller's cwd beats the temporary
|
|
214
|
+
* userconfig this script stages. The command line is the only authoritative
|
|
215
|
+
* layer, and the registry URL is not a secret, so pinning it there is safe.
|
|
216
|
+
*/
|
|
217
|
+
function buildScopeRegistryArg({ packageName, registry }) {
|
|
218
|
+
return `--${packageScope(packageName)}:registry=${normalizeRegistry(registry)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
159
221
|
/**
|
|
160
222
|
* `az` arguments for a short-lived ADO access token. `-o tsv` keeps the raw
|
|
161
223
|
* token off the JSON path so it is never pretty-printed by az itself.
|
|
@@ -194,11 +256,13 @@ function buildNpmrc({ registry, scope, token, baseConfig = '' }) {
|
|
|
194
256
|
}
|
|
195
257
|
|
|
196
258
|
function buildNpmViewArgs({ packageName, versionSpec, registry, userconfig }) {
|
|
197
|
-
return ['view', `${packageName}@${versionSpec}`, 'version', '--registry', registry, '--userconfig', userconfig,
|
|
259
|
+
return ['view', `${packageName}@${versionSpec}`, 'version', '--registry', registry, '--userconfig', userconfig,
|
|
260
|
+
buildScopeRegistryArg({ packageName, registry }), '--json'];
|
|
198
261
|
}
|
|
199
262
|
|
|
200
263
|
function buildNpmInstallArgs({ packageName, version, registry, userconfig }) {
|
|
201
|
-
return ['install', '-g', `${packageName}@${version}`, '--registry', registry, '--userconfig', userconfig
|
|
264
|
+
return ['install', '-g', `${packageName}@${version}`, '--registry', registry, '--userconfig', userconfig,
|
|
265
|
+
buildScopeRegistryArg({ packageName, registry })];
|
|
202
266
|
}
|
|
203
267
|
|
|
204
268
|
/**
|
|
@@ -213,6 +277,7 @@ function buildNpmPackArgs({ packageName, version, registry, userconfig, destinat
|
|
|
213
277
|
'pack', `${packageName}@${version}`,
|
|
214
278
|
'--registry', registry,
|
|
215
279
|
'--userconfig', userconfig,
|
|
280
|
+
buildScopeRegistryArg({ packageName, registry }),
|
|
216
281
|
'--pack-destination', destination,
|
|
217
282
|
'--json',
|
|
218
283
|
];
|
|
@@ -630,8 +695,161 @@ function redact(text, secrets) {
|
|
|
630
695
|
|
|
631
696
|
// ─── Runtime-root inspection ────────────────────────────────────────────────
|
|
632
697
|
|
|
633
|
-
|
|
634
|
-
|
|
698
|
+
/**
|
|
699
|
+
* The engine's root-pointer file (`engine/core/shared.js` ROOT_POINTER_PATH, written
|
|
700
|
+
* by `minions init`). Duplicated rather than imported: this installer must run
|
|
701
|
+
* on a machine with no Minions installed, so it stays Node-built-ins-only.
|
|
702
|
+
*/
|
|
703
|
+
function runtimeRootPointerPath(homedir = os.homedir()) {
|
|
704
|
+
return path.join(homedir, '.minions-root');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function readRuntimeRootPointer(homedir = os.homedir()) {
|
|
708
|
+
try {
|
|
709
|
+
const pointed = fs.readFileSync(runtimeRootPointerPath(homedir), 'utf8').trim();
|
|
710
|
+
return pointed ? path.resolve(pointed) : null;
|
|
711
|
+
} catch { return null; }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Nearest ancestor of `cwd` (inclusive) that looks like a Minions source
|
|
716
|
+
* checkout — mirrors the `preferSourceCheckout` branch of the engine's
|
|
717
|
+
* `resolveMinionsHome()`, which returns a package root that contains `.git`.
|
|
718
|
+
* That branch is precisely why a repo checkout can become the engine's runtime
|
|
719
|
+
* root, so the installer has to see the same candidate the engine would.
|
|
720
|
+
*
|
|
721
|
+
* `engine.js` is required alongside `.git`: `.git` alone is every repository on
|
|
722
|
+
* the machine, and a directory the engine could never boot from is not a
|
|
723
|
+
* runtime root — listing it would manufacture ambiguity instead of exposing it.
|
|
724
|
+
*/
|
|
725
|
+
function findCheckoutRoot(cwd, exists) {
|
|
726
|
+
let dir = cwd ? path.resolve(String(cwd)) : null;
|
|
727
|
+
for (let depth = 0; dir && depth < 64; depth++) {
|
|
728
|
+
if (exists(path.join(dir, '.git')) && exists(path.join(dir, 'engine.js'))) return dir;
|
|
729
|
+
const parent = path.dirname(dir);
|
|
730
|
+
if (parent === dir) break;
|
|
731
|
+
dir = parent;
|
|
732
|
+
}
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function describeRuntimeRootSource(source, homedir = os.homedir()) {
|
|
737
|
+
switch (source) {
|
|
738
|
+
case 'flag': return '--runtime-root';
|
|
739
|
+
case 'env': return 'MINIONS_HOME';
|
|
740
|
+
case 'pointer': return `recorded in ${runtimeRootPointerPath(homedir)}`;
|
|
741
|
+
case 'default': return 'the default ~/.minions';
|
|
742
|
+
case 'checkout': return 'a source checkout at or above the current directory';
|
|
743
|
+
default: return String(source);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Decide, once and explicitly, which runtime root this run owns.
|
|
749
|
+
*
|
|
750
|
+
* The old resolution was `MINIONS_HOME || ~/.minions`: cwd-blind, and blind to
|
|
751
|
+
* the root-pointer file the ENGINE consults. The engine additionally prefers a
|
|
752
|
+
* package root containing `.git`. That divergence is how a repo checkout
|
|
753
|
+
* silently becomes the runtime root — the installer backs up and synchronizes
|
|
754
|
+
* one root while the engine boots out of another.
|
|
755
|
+
*
|
|
756
|
+
* Every candidate the engine could pick is enumerated here, deduplicated by
|
|
757
|
+
* resolved path (highest-priority source wins), and scored by whether it carries
|
|
758
|
+
* a NON-EMPTY `engine/state.db` — a zero-byte database is an aborted init, not a
|
|
759
|
+
* runtime worth protecting.
|
|
760
|
+
*
|
|
761
|
+
* Ranking:
|
|
762
|
+
* 1. `--runtime-root` explicit operator pin, always wins
|
|
763
|
+
* 2. two or more stateful roots REFUSE (`ambiguous`) — never guess
|
|
764
|
+
* 3. `MINIONS_HOME` explicit environment pin
|
|
765
|
+
* 4. the single stateful root the only way a checkout can be selected, and
|
|
766
|
+
* only when nothing else carries state
|
|
767
|
+
* 5. the root-pointer file what `minions init` last recorded
|
|
768
|
+
* 6. `~/.minions` the default (fresh install)
|
|
769
|
+
*
|
|
770
|
+
* Because a stateful candidate outranks every stateless one and two stateful
|
|
771
|
+
* candidates are a refusal, a repo checkout can never win over a root that
|
|
772
|
+
* already carries state.
|
|
773
|
+
*
|
|
774
|
+
* Pure apart from the injected `exists` / `size` / `readPointer` probes, so the
|
|
775
|
+
* contract is asserted directly rather than inferred from a live migration.
|
|
776
|
+
*/
|
|
777
|
+
function resolveAuthoritativeRuntimeRoot(options = {}) {
|
|
778
|
+
const {
|
|
779
|
+
flag = null,
|
|
780
|
+
env = process.env,
|
|
781
|
+
cwd = process.cwd(),
|
|
782
|
+
homedir = os.homedir(),
|
|
783
|
+
exists = p => fs.existsSync(p),
|
|
784
|
+
size = (p) => { try { return fs.statSync(p).size; } catch { return 0; } },
|
|
785
|
+
readPointer = null,
|
|
786
|
+
} = options;
|
|
787
|
+
|
|
788
|
+
const pointer = readPointer ? readPointer() : readRuntimeRootPointer(homedir);
|
|
789
|
+
const defaultRoot = path.resolve(path.join(homedir, '.minions'));
|
|
790
|
+
const ordered = [
|
|
791
|
+
['flag', flag],
|
|
792
|
+
['env', env && env.MINIONS_HOME],
|
|
793
|
+
['pointer', pointer],
|
|
794
|
+
['default', defaultRoot],
|
|
795
|
+
['checkout', findCheckoutRoot(cwd, exists)],
|
|
796
|
+
];
|
|
797
|
+
|
|
798
|
+
const candidates = [];
|
|
799
|
+
const seen = new Set();
|
|
800
|
+
for (const [source, value] of ordered) {
|
|
801
|
+
if (!value) continue;
|
|
802
|
+
const root = path.resolve(String(value));
|
|
803
|
+
if (seen.has(root)) continue;
|
|
804
|
+
seen.add(root);
|
|
805
|
+
const statePath = path.join(root, 'engine', 'state.db');
|
|
806
|
+
candidates.push({ source, root, hasState: exists(statePath) && size(statePath) > 0 });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const bySource = source => candidates.find(c => c.source === source) || null;
|
|
810
|
+
const pick = (candidate, reason) => ({
|
|
811
|
+
root: candidate.root,
|
|
812
|
+
source: candidate.source,
|
|
813
|
+
candidates,
|
|
814
|
+
ambiguous: false,
|
|
815
|
+
reason,
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const flagged = bySource('flag');
|
|
819
|
+
if (flagged) return pick(flagged, `pinned explicitly with --runtime-root ${flagged.root}`);
|
|
820
|
+
|
|
821
|
+
const stateful = candidates.filter(c => c.hasState);
|
|
822
|
+
if (stateful.length > 1) {
|
|
823
|
+
const describe = c => ` ${c.root} (${describeRuntimeRootSource(c.source, homedir)})`
|
|
824
|
+
+ `${c.hasState ? ' [carries state]' : ''}`;
|
|
825
|
+
return {
|
|
826
|
+
root: null,
|
|
827
|
+
source: null,
|
|
828
|
+
candidates,
|
|
829
|
+
ambiguous: true,
|
|
830
|
+
reason: `${stateful.length} candidate runtime roots carry a non-empty engine/state.db, `
|
|
831
|
+
+ 'and no --runtime-root was given.\n'
|
|
832
|
+
+ ' Candidates considered:\n'
|
|
833
|
+
+ candidates.map(describe).join('\n')
|
|
834
|
+
+ '\n Refusing to guess which one is authoritative. Re-run pinned to the one you mean:\n'
|
|
835
|
+
+ stateful.map(c => ` --runtime-root ${c.root}`).join('\n'),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const envRoot = bySource('env');
|
|
840
|
+
if (envRoot) return pick(envRoot, 'pinned by MINIONS_HOME');
|
|
841
|
+
|
|
842
|
+
if (stateful.length === 1) {
|
|
843
|
+
return pick(stateful[0], `the only candidate carrying runtime state (${describeRuntimeRootSource(stateful[0].source, homedir)})`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const pointerRoot = bySource('pointer');
|
|
847
|
+
if (pointerRoot && exists(pointerRoot.root)) {
|
|
848
|
+
return pick(pointerRoot, `recorded in ${runtimeRootPointerPath(homedir)}`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const fallback = candidates.find(c => c.root === defaultRoot);
|
|
852
|
+
return pick(fallback, `the default runtime root${fallback.hasState ? '' : ' (fresh install)'}`);
|
|
635
853
|
}
|
|
636
854
|
|
|
637
855
|
/**
|
|
@@ -679,8 +897,8 @@ function resolveBackupCli(state, exists = p => fs.existsSync(p)) {
|
|
|
679
897
|
* Decide how to back up the existing runtime's SQLite state, preferring the
|
|
680
898
|
* option that cannot go stale:
|
|
681
899
|
*
|
|
682
|
-
* 1. the runtime's OWN `engine/state-operations.js`. `minions init` copies
|
|
683
|
-
*
|
|
900
|
+
* 1. the runtime's OWN `engine/persistence/state-operations.js`. `minions init` copies
|
|
901
|
+
* packaged engine JavaScript into the runtime root, so it is present for every installed
|
|
684
902
|
* runtime and is by construction version-matched to that runtime's
|
|
685
903
|
* `engine/state.db`. Resolving a CLI is not sufficient on its own — the
|
|
686
904
|
* published public package predates the `minions state` command, so an
|
|
@@ -690,10 +908,16 @@ function resolveBackupCli(state, exists = p => fs.existsSync(p)) {
|
|
|
690
908
|
*/
|
|
691
909
|
function resolveBackupStrategy(state, exists = p => fs.existsSync(p)) {
|
|
692
910
|
const runtimeRoot = (state && state.runtimeRoot) || null;
|
|
693
|
-
const
|
|
911
|
+
const modulePaths = runtimeRoot
|
|
912
|
+
? [
|
|
913
|
+
path.join(runtimeRoot, 'engine', 'persistence', 'state-operations.js'),
|
|
914
|
+
path.join(runtimeRoot, 'engine', 'state-operations.js'),
|
|
915
|
+
]
|
|
916
|
+
: [];
|
|
917
|
+
const modulePath = modulePaths.find(exists) || null;
|
|
694
918
|
const cli = resolveBackupCli(state, exists);
|
|
695
|
-
const candidates = [
|
|
696
|
-
if (modulePath
|
|
919
|
+
const candidates = [...modulePaths, ...cli.candidates].filter(Boolean);
|
|
920
|
+
if (modulePath) return { kind: 'runtime-module', modulePath, candidates };
|
|
697
921
|
if (cli.path) return { kind: 'cli', cliPath: cli.path, candidates };
|
|
698
922
|
return { kind: 'none', candidates };
|
|
699
923
|
}
|
|
@@ -701,7 +925,16 @@ function resolveBackupStrategy(state, exists = p => fs.existsSync(p)) {
|
|
|
701
925
|
/** Run the runtime's own backupState() out-of-process against its own MINIONS_HOME. */
|
|
702
926
|
const BACKUP_MODULE_SCRIPT = 'require(process.argv[1]).backupState(process.argv[2]);';
|
|
703
927
|
|
|
704
|
-
/**
|
|
928
|
+
/**
|
|
929
|
+
* Runtime paths that MUST survive the migration.
|
|
930
|
+
*
|
|
931
|
+
* Presence-only by construction — an emptied `engine/state.db` still passes
|
|
932
|
+
* this list, which is why the `verify-continuity` step exists and compares a
|
|
933
|
+
* row-level `summarizeState()` census instead. Schedules, pipeline RUNS,
|
|
934
|
+
* watches, meetings, and QA sessions live in SQL and are covered by that
|
|
935
|
+
* census; `pipelines/` (definitions), `prompts/`, `playbooks/`, and `agents/`
|
|
936
|
+
* are on disk and are covered here.
|
|
937
|
+
*/
|
|
705
938
|
const PRESERVED_PATHS = [
|
|
706
939
|
'config.json',
|
|
707
940
|
'engine/state.db',
|
|
@@ -711,6 +944,10 @@ const PRESERVED_PATHS = [
|
|
|
711
944
|
'knowledge',
|
|
712
945
|
'projects',
|
|
713
946
|
'pinned.md',
|
|
947
|
+
'pipelines',
|
|
948
|
+
'prompts',
|
|
949
|
+
'playbooks',
|
|
950
|
+
'agents',
|
|
714
951
|
];
|
|
715
952
|
|
|
716
953
|
/**
|
|
@@ -738,7 +975,7 @@ function diffPreservedPaths(before, after) {
|
|
|
738
975
|
* operator- or agent-authored:
|
|
739
976
|
*
|
|
740
977
|
* - `routing.md` the operator's work-type → agent routing table
|
|
741
|
-
* - `knowledge/agents/` per-agent memory written by engine/consolidation.js
|
|
978
|
+
* - `knowledge/agents/` per-agent memory written by engine/memory/consolidation.js
|
|
742
979
|
*
|
|
743
980
|
* A package-channel cutover must not reseed either. Their bytes are captured
|
|
744
981
|
* before `sync-init` and put back after it, so the sync can still add newly
|
|
@@ -824,7 +1061,7 @@ function assessRollback({ publicUninstalled = false, internalUsable = false } =
|
|
|
824
1061
|
/**
|
|
825
1062
|
* Count live agent processes by walking the dispatch PID files the engine
|
|
826
1063
|
* writes under `<runtime root>/engine/tmp/` — both the per-dispatch directory
|
|
827
|
-
* layout and the legacy flat layout (see engine/shared.js#forEachPidFile).
|
|
1064
|
+
* layout and the legacy flat layout (see engine/core/shared.js#forEachPidFile).
|
|
828
1065
|
* Filesystem-based on purpose: it works with the dashboard down, which is
|
|
829
1066
|
* exactly when a migration is most likely to be attempted.
|
|
830
1067
|
*/
|
|
@@ -885,12 +1122,645 @@ function assessAgentActivity({ activeAgents, force }) {
|
|
|
885
1122
|
};
|
|
886
1123
|
}
|
|
887
1124
|
|
|
1125
|
+
// ─── Service quiescence ─────────────────────────────────────────────────────
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The three files the SQLite state actually consists of. `state.db` is the
|
|
1129
|
+
* database; `-wal` is the write-ahead log holding committed-but-uncheckpointed
|
|
1130
|
+
* transactions; `-shm` is the WAL shared-memory index.
|
|
1131
|
+
*
|
|
1132
|
+
* `-shm` is the liveness signal that matters here: SQLite creates it when the
|
|
1133
|
+
* FIRST connection opens the database in WAL mode and removes it when the LAST
|
|
1134
|
+
* one closes cleanly. A present, non-empty `-shm` therefore means a process
|
|
1135
|
+
* still holds the database open even when every PID we know about is gone.
|
|
1136
|
+
*/
|
|
1137
|
+
const STATE_DB_FILES = ['engine/state.db', 'engine/state.db-wal', 'engine/state.db-shm'];
|
|
1138
|
+
const STATE_SHM_REL = 'engine/state.db-shm';
|
|
1139
|
+
|
|
1140
|
+
/** Files copied verbatim beside the backup, and put back by a state rollback. */
|
|
1141
|
+
const BACKUP_COPIED_FILES = ['config.json', 'routing.md', 'pinned.md'];
|
|
1142
|
+
|
|
1143
|
+
/** Subdirectory of the backup holding the ORIGINAL, byte-for-byte state files. */
|
|
1144
|
+
const RETAINED_ORIGINAL_DIR = 'original';
|
|
1145
|
+
|
|
1146
|
+
const QUIESCE_TIMEOUT_MS = 60000;
|
|
1147
|
+
const QUIESCE_POLL_MS = 500;
|
|
1148
|
+
const CLI_STOP_TIMEOUT_MS = 120000;
|
|
1149
|
+
/** Budget for the read-only capability probe: `minions help` prints and exits. */
|
|
1150
|
+
const CLI_HELP_TIMEOUT_MS = 20000;
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The stop contract to prefer, as the EXACT argv it issues.
|
|
1154
|
+
*
|
|
1155
|
+
* A bare `minions stop` is delegated to `engine.js stop`, which writes stop
|
|
1156
|
+
* intent and RETURNS — it asks only the engine to stand down and never touches
|
|
1157
|
+
* the dashboard or the supervisor. Those hold `engine/state.db` independently,
|
|
1158
|
+
* so the `-shm` gate below could never go green after a "successful" stop. The
|
|
1159
|
+
* whole-stack verb tears the supervisor, dashboard, and engine down in order and
|
|
1160
|
+
* `--wait` blocks until the handles are actually released.
|
|
1161
|
+
*/
|
|
1162
|
+
const WHOLE_STACK_STOP_ARGS = ['stop', '--all', '--wait'];
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* The fallback for a CLI that predates the whole-stack verb. Mid-cutover the
|
|
1166
|
+
* resolved CLI is frequently the OLDER PUBLIC package (see `resolveBackupCli`),
|
|
1167
|
+
* which would reject or ignore `--all`, so it gets the verb it does know plus
|
|
1168
|
+
* the wait loop that has always backed it.
|
|
1169
|
+
*/
|
|
1170
|
+
const LEGACY_STOP_ARGS = ['stop'];
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The verb the capability probe runs.
|
|
1174
|
+
*
|
|
1175
|
+
* `help` is the FIRST branch of the CLI's command dispatch, prints usage, and
|
|
1176
|
+
* exits — no runtime root, no daemon, no state. `minions stop --help` would be
|
|
1177
|
+
* catastrophic here: `stop` is in the CLI's `engineCmds` delegation set, so an
|
|
1178
|
+
* older CLI would forward it to `engine.js stop` and actually stop the engine
|
|
1179
|
+
* as a side effect of asking whether it can.
|
|
1180
|
+
*/
|
|
1181
|
+
const CLI_CAPABILITY_PROBE_ARGS = ['help'];
|
|
1182
|
+
|
|
1183
|
+
/** Operator-facing names for the three ways this gate can try to stop a runtime. */
|
|
1184
|
+
const STOP_PATH_LABELS = {
|
|
1185
|
+
'whole-stack': 'whole-stack (`minions stop --all --wait`)',
|
|
1186
|
+
legacy: 'legacy fallback (`minions stop` + wait loop)',
|
|
1187
|
+
none: 'no stop issued (wait loop only)',
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Read a CLI's OWN help text and report which stop flags it advertises.
|
|
1192
|
+
*
|
|
1193
|
+
* Deliberately behavioral: the installed CLI is asked what it can do, and the
|
|
1194
|
+
* answer comes from the binary that will actually run. Comparing version strings
|
|
1195
|
+
* would be a guess across two independently-versioned publish channels — the
|
|
1196
|
+
* internal feed and the public registry share no ordering — and the failure mode
|
|
1197
|
+
* of a wrong guess is an unknown flag passed to a live teardown command.
|
|
1198
|
+
*
|
|
1199
|
+
* `--wait` is required as well as `--all` because both are in the argv we issue.
|
|
1200
|
+
* Matching is per line for `--all` so an unrelated mention elsewhere in the help
|
|
1201
|
+
* cannot vouch for the stop verb; `--no-wait` (from `minions update`) cannot
|
|
1202
|
+
* match `--wait`, which needs two consecutive dashes.
|
|
1203
|
+
*/
|
|
1204
|
+
function detectStopCapabilities(helpText) {
|
|
1205
|
+
const text = String(helpText || '');
|
|
1206
|
+
const wholeStack = text.split(/\r?\n/).some(line => /\bstop\b/.test(line) && /--all\b/.test(line));
|
|
1207
|
+
const wait = /--wait\b/.test(text);
|
|
1208
|
+
return { wholeStack, wait, supported: wholeStack && wait };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Ask the resolved CLI what it supports. FAILS CLOSED to the legacy verb: an
|
|
1213
|
+
* unreadable, empty, or unrecognized answer means we send the command every
|
|
1214
|
+
* published CLI has always understood, never one it might not.
|
|
1215
|
+
*/
|
|
1216
|
+
function probeStopCapability({ cliPath, run, env, timeoutMs = CLI_HELP_TIMEOUT_MS } = {}) {
|
|
1217
|
+
if (!cliPath) {
|
|
1218
|
+
return { supported: false, probed: false, reason: 'no installed CLI to probe' };
|
|
1219
|
+
}
|
|
1220
|
+
let result;
|
|
1221
|
+
try {
|
|
1222
|
+
result = run(process.execPath, [cliPath, ...CLI_CAPABILITY_PROBE_ARGS], {
|
|
1223
|
+
timeout: timeoutMs,
|
|
1224
|
+
capture: true,
|
|
1225
|
+
allowFailure: true,
|
|
1226
|
+
env,
|
|
1227
|
+
});
|
|
1228
|
+
} catch (e) {
|
|
1229
|
+
return {
|
|
1230
|
+
supported: false,
|
|
1231
|
+
probed: false,
|
|
1232
|
+
reason: `the capability probe could not run (${e && e.message ? e.message : e})`,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
const help = `${(result && result.stdout) || ''}\n${(result && result.stderr) || ''}`;
|
|
1236
|
+
const capabilities = detectStopCapabilities(help);
|
|
1237
|
+
return {
|
|
1238
|
+
supported: capabilities.supported,
|
|
1239
|
+
probed: true,
|
|
1240
|
+
capabilities,
|
|
1241
|
+
reason: capabilities.supported
|
|
1242
|
+
? 'the installed CLI advertises `minions stop --all [--wait]`'
|
|
1243
|
+
: 'the installed CLI does not advertise `minions stop --all [--wait]`',
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* The detail lines every quiescence refusal prints, in ONE place so the cutover
|
|
1249
|
+
* gate and the state-restoring rollback cannot drift.
|
|
1250
|
+
*
|
|
1251
|
+
* Reports which stop contract ran and the exact argv it issued — without that,
|
|
1252
|
+
* "the runtime did not release the database" is indistinguishable between a
|
|
1253
|
+
* whole-stack teardown that genuinely could not finish and a legacy fallback
|
|
1254
|
+
* that only ever asked the engine to stand down.
|
|
1255
|
+
*/
|
|
1256
|
+
function formatQuiescenceRefusal(quiescence = {}) {
|
|
1257
|
+
const stopPath = quiescence.stopPath || 'none';
|
|
1258
|
+
const issued = Array.isArray(quiescence.stopArgs) && quiescence.stopArgs.length
|
|
1259
|
+
? `; issued: ${quiescence.stopArgs.join(' ')}`
|
|
1260
|
+
: '';
|
|
1261
|
+
const exit = Number.isInteger(quiescence.stopExitCode) && quiescence.stopExitCode !== 0
|
|
1262
|
+
? ` (exit code ${quiescence.stopExitCode})`
|
|
1263
|
+
: '';
|
|
1264
|
+
const lines = [` Stop path: ${STOP_PATH_LABELS[stopPath] || STOP_PATH_LABELS.none}${issued}${exit}`];
|
|
1265
|
+
const holders = Array.isArray(quiescence.holders) ? quiescence.holders : [];
|
|
1266
|
+
if (holders.length) {
|
|
1267
|
+
lines.push(' Still holding the database:');
|
|
1268
|
+
for (const holder of holders) lines.push(` ${holder.name} PID ${holder.pid}`);
|
|
1269
|
+
} else {
|
|
1270
|
+
lines.push(` No recorded service PID is alive, but ${STATE_SHM_REL} is still non-empty —`);
|
|
1271
|
+
lines.push(' an unrecorded process (or a re-parented one) still has the database open.');
|
|
1272
|
+
}
|
|
1273
|
+
return lines;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The PIDs the runtime itself records for its long-lived services, read from
|
|
1278
|
+
* the files the running daemons write — so this works with the dashboard down,
|
|
1279
|
+
* which is exactly when a migration is most likely to be attempted:
|
|
1280
|
+
*
|
|
1281
|
+
* - `engine/control.json` `pid` the engine daemon
|
|
1282
|
+
* - `engine/dashboard-port.json` `pid` the dashboard
|
|
1283
|
+
* - `engine/supervisor.pid` the supervisor
|
|
1284
|
+
*
|
|
1285
|
+
* Agent processes are deliberately NOT included: they are covered by the
|
|
1286
|
+
* separate active-agent gate, and any database handle they hold is still
|
|
1287
|
+
* caught by the `-shm` evidence below.
|
|
1288
|
+
*/
|
|
1289
|
+
function readServicePids(runtimeRoot, deps = {}) {
|
|
1290
|
+
const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
|
|
1291
|
+
const sources = [
|
|
1292
|
+
['engine', ['engine', 'control.json'], 'json'],
|
|
1293
|
+
['dashboard', ['engine', 'dashboard-port.json'], 'json'],
|
|
1294
|
+
['supervisor', ['engine', 'supervisor.pid'], 'text'],
|
|
1295
|
+
];
|
|
1296
|
+
const found = [];
|
|
1297
|
+
for (const [name, segments, shape] of sources) {
|
|
1298
|
+
const file = path.join(runtimeRoot, ...segments);
|
|
1299
|
+
let raw;
|
|
1300
|
+
try { raw = String(readFile(file)); } catch { continue; }
|
|
1301
|
+
let pid = null;
|
|
1302
|
+
if (shape === 'json') {
|
|
1303
|
+
try { pid = Number(JSON.parse(raw).pid); } catch { pid = null; }
|
|
1304
|
+
} else {
|
|
1305
|
+
pid = Number(raw.trim());
|
|
1306
|
+
}
|
|
1307
|
+
if (Number.isInteger(pid) && pid > 0) found.push({ name, pid, file });
|
|
1308
|
+
}
|
|
1309
|
+
return found;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Recorded service PIDs that are still running. */
|
|
1313
|
+
function detectRunningServices(runtimeRoot, deps = {}) {
|
|
1314
|
+
const isAlive = deps.isAlive || defaultIsAlive;
|
|
1315
|
+
return readServicePids(runtimeRoot, deps)
|
|
1316
|
+
.filter(entry => entry.pid !== process.pid && isAlive(entry.pid));
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** `-shm` evidence for the pinned root: `{ present, bytes }`. */
|
|
1320
|
+
function readStateShm(runtimeRoot, deps = {}) {
|
|
1321
|
+
const stat = deps.statSync || (p => fs.statSync(p));
|
|
1322
|
+
try {
|
|
1323
|
+
const info = stat(path.join(runtimeRoot, ...STATE_SHM_REL.split('/')));
|
|
1324
|
+
return { present: true, bytes: Number(info && info.size) || 0 };
|
|
1325
|
+
} catch { return { present: false, bytes: 0 }; }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Pure verdict on whether the state database is free of live handles.
|
|
1330
|
+
*
|
|
1331
|
+
* BOTH conditions must hold: every recorded service PID is dead AND the WAL
|
|
1332
|
+
* shared-memory index is gone or zero-length. Either alone is a false green — a
|
|
1333
|
+
* PID file can be stale while a re-parented process still holds the database,
|
|
1334
|
+
* and `-shm` can lag a PID that has only just been signalled.
|
|
1335
|
+
*/
|
|
1336
|
+
function assessDatabaseQuiescence({ running = [], shm = { present: false, bytes: 0 } } = {}) {
|
|
1337
|
+
const holders = running.map(entry => ({ name: entry.name, pid: entry.pid }));
|
|
1338
|
+
const shmHeld = !!shm.present && Number(shm.bytes) > 0;
|
|
1339
|
+
if (holders.length) {
|
|
1340
|
+
return {
|
|
1341
|
+
quiesced: false,
|
|
1342
|
+
holders,
|
|
1343
|
+
shmHeld,
|
|
1344
|
+
reason: `still running: ${holders.map(h => `${h.name} (PID ${h.pid})`).join(', ')}`,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
if (shmHeld) {
|
|
1348
|
+
return {
|
|
1349
|
+
quiesced: false,
|
|
1350
|
+
holders,
|
|
1351
|
+
shmHeld,
|
|
1352
|
+
reason: `${STATE_SHM_REL} is still present and non-empty (${shm.bytes} bytes), `
|
|
1353
|
+
+ 'so a process still holds the database open',
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
return { quiesced: true, holders: [], shmHeld: false, reason: 'no service holds engine/state.db' };
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Block the calling thread. The installer is deliberately spawnSync-only. */
|
|
1360
|
+
function sleepSync(ms) {
|
|
1361
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(ms) || 0); }
|
|
1362
|
+
catch { const until = Date.now() + (Number(ms) || 0); while (Date.now() < until) { /* spin */ } }
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Stop the engine/dashboard running against the PINNED root and wait until the
|
|
1367
|
+
* database handles are actually released.
|
|
1368
|
+
*
|
|
1369
|
+
* Ordered before `backup-state` — and therefore before every uninstall and
|
|
1370
|
+
* package mutation — because a backup taken while the engine is writing is a
|
|
1371
|
+
* backup of a moving target, and rewriting the runtime `.js` files underneath a
|
|
1372
|
+
* live daemon is exactly the failure the restart contract does not cover.
|
|
1373
|
+
*
|
|
1374
|
+
* The stop verb is chosen by BEHAVIORAL probe of the resolved CLI, not by
|
|
1375
|
+
* version: `stop --all --wait` when that CLI advertises it, otherwise the legacy
|
|
1376
|
+
* `stop`. Both paths then run the same wait loop, because the CLI's own verdict
|
|
1377
|
+
* is never trusted — the `-shm` evidence below is.
|
|
1378
|
+
*
|
|
1379
|
+
* FAILS CLOSED: when the handles are not released inside the timeout the caller
|
|
1380
|
+
* gets `ok: false`, the path that was taken, and the exact PIDs still holding
|
|
1381
|
+
* the database.
|
|
1382
|
+
*/
|
|
1383
|
+
function quiesceServices({
|
|
1384
|
+
runtimeRoot,
|
|
1385
|
+
cliPath = null,
|
|
1386
|
+
run,
|
|
1387
|
+
env,
|
|
1388
|
+
log = console.log,
|
|
1389
|
+
timeoutMs = QUIESCE_TIMEOUT_MS,
|
|
1390
|
+
pollMs = QUIESCE_POLL_MS,
|
|
1391
|
+
probeTimeoutMs = CLI_HELP_TIMEOUT_MS,
|
|
1392
|
+
deps = {},
|
|
1393
|
+
sleep = sleepSync,
|
|
1394
|
+
now = () => Date.now(),
|
|
1395
|
+
} = {}) {
|
|
1396
|
+
const probeState = () => assessDatabaseQuiescence({
|
|
1397
|
+
running: detectRunningServices(runtimeRoot, deps),
|
|
1398
|
+
shm: readStateShm(runtimeRoot, deps),
|
|
1399
|
+
});
|
|
1400
|
+
const startedAt = now();
|
|
1401
|
+
const initial = probeState();
|
|
1402
|
+
if (initial.quiesced) {
|
|
1403
|
+
return {
|
|
1404
|
+
ok: true,
|
|
1405
|
+
stopped: false,
|
|
1406
|
+
stopPath: 'none',
|
|
1407
|
+
stopArgs: null,
|
|
1408
|
+
stopExitCode: null,
|
|
1409
|
+
capabilityProbe: null,
|
|
1410
|
+
holders: [],
|
|
1411
|
+
waitedMs: 0,
|
|
1412
|
+
reason: initial.reason,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
log(` ${initial.reason}`);
|
|
1416
|
+
let stopped = false;
|
|
1417
|
+
let stopPath = 'none';
|
|
1418
|
+
let stopArgs = null;
|
|
1419
|
+
let stopExitCode = null;
|
|
1420
|
+
let capabilityProbe = null;
|
|
1421
|
+
if (cliPath) {
|
|
1422
|
+
capabilityProbe = probeStopCapability({ cliPath, run, env, timeoutMs: probeTimeoutMs });
|
|
1423
|
+
stopPath = capabilityProbe.supported ? 'whole-stack' : 'legacy';
|
|
1424
|
+
stopArgs = [cliPath, ...(capabilityProbe.supported ? WHOLE_STACK_STOP_ARGS : LEGACY_STOP_ARGS)];
|
|
1425
|
+
log(` ${capabilityProbe.reason}.`);
|
|
1426
|
+
log(` Stopping the runtime — ${STOP_PATH_LABELS[stopPath]} ...`);
|
|
1427
|
+
try {
|
|
1428
|
+
const result = run(process.execPath, stopArgs, {
|
|
1429
|
+
timeout: CLI_STOP_TIMEOUT_MS,
|
|
1430
|
+
capture: true,
|
|
1431
|
+
allowFailure: true,
|
|
1432
|
+
env,
|
|
1433
|
+
});
|
|
1434
|
+
stopExitCode = result && Number.isInteger(result.status) ? result.status : null;
|
|
1435
|
+
stopped = true;
|
|
1436
|
+
} catch (e) {
|
|
1437
|
+
log(` WARNING: \`minions ${stopArgs.slice(1).join(' ')}\` failed (${e && e.message ? e.message : e}); waiting for the handles anyway.`);
|
|
1438
|
+
}
|
|
1439
|
+
} else {
|
|
1440
|
+
log(' WARNING: no installed CLI was found to run `minions stop`; waiting for the handles to be released.');
|
|
1441
|
+
}
|
|
1442
|
+
const deadline = now() + timeoutMs;
|
|
1443
|
+
let verdict = probeState();
|
|
1444
|
+
while (!verdict.quiesced && now() < deadline) {
|
|
1445
|
+
sleep(pollMs);
|
|
1446
|
+
verdict = probeState();
|
|
1447
|
+
}
|
|
1448
|
+
return {
|
|
1449
|
+
ok: verdict.quiesced,
|
|
1450
|
+
stopped,
|
|
1451
|
+
stopPath,
|
|
1452
|
+
stopArgs,
|
|
1453
|
+
stopExitCode,
|
|
1454
|
+
capabilityProbe,
|
|
1455
|
+
holders: verdict.holders,
|
|
1456
|
+
waitedMs: now() - startedAt,
|
|
1457
|
+
reason: verdict.reason,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ─── Original-state retention ───────────────────────────────────────────────
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Copy the ORIGINAL `state.db`, `-wal`, and `-shm` byte-for-byte into
|
|
1465
|
+
* `<backup>/original/`, beside the `VACUUM INTO` snapshot.
|
|
1466
|
+
*
|
|
1467
|
+
* The snapshot is the *consistent* artifact and is what a human should restore
|
|
1468
|
+
* by hand; these are the *literal* pre-migration bytes, which is what an
|
|
1469
|
+
* automatic state-restoring rollback needs in order to put the runtime back
|
|
1470
|
+
* exactly as it was — WAL included. Neither is ever deleted by this run under
|
|
1471
|
+
* any outcome: `cleanup` only removes the temporary npm config and the temp
|
|
1472
|
+
* artifact directory.
|
|
1473
|
+
*/
|
|
1474
|
+
function retainOriginalStateFiles(runtimeRoot, backupDir, deps = {}) {
|
|
1475
|
+
const exists = deps.existsSync || (p => fs.existsSync(p));
|
|
1476
|
+
const mkdir = deps.mkdirSync || ((p, options) => fs.mkdirSync(p, options));
|
|
1477
|
+
const copy = deps.copyFileSync || ((src, dest) => fs.copyFileSync(src, dest));
|
|
1478
|
+
const dir = path.join(backupDir, RETAINED_ORIGINAL_DIR);
|
|
1479
|
+
const retained = [];
|
|
1480
|
+
const missing = [];
|
|
1481
|
+
const failed = [];
|
|
1482
|
+
for (const rel of STATE_DB_FILES) {
|
|
1483
|
+
const src = path.join(runtimeRoot, ...rel.split('/'));
|
|
1484
|
+
const base = path.basename(rel);
|
|
1485
|
+
if (!exists(src)) { missing.push(rel); continue; }
|
|
1486
|
+
try {
|
|
1487
|
+
mkdir(dir, { recursive: true });
|
|
1488
|
+
copy(src, path.join(dir, base));
|
|
1489
|
+
retained.push(`${RETAINED_ORIGINAL_DIR}/${base}`);
|
|
1490
|
+
} catch (e) {
|
|
1491
|
+
failed.push({ rel, error: e && e.message ? e.message : String(e) });
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return { dir, retained, missing, failed };
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Put the retained original state files back. Idempotent: copying the same
|
|
1499
|
+
* bytes twice is a no-op, and a sidecar the original run did NOT have is
|
|
1500
|
+
* removed rather than left to pair a restored database with a foreign WAL.
|
|
1501
|
+
*
|
|
1502
|
+
* `engine/state.db` itself is never removed — only its sidecars — so a backup
|
|
1503
|
+
* that somehow lacks the database can never delete the live one.
|
|
1504
|
+
*/
|
|
1505
|
+
function restoreOriginalStateFiles(runtimeRoot, backupDir, deps = {}) {
|
|
1506
|
+
const exists = deps.existsSync || (p => fs.existsSync(p));
|
|
1507
|
+
const mkdir = deps.mkdirSync || ((p, options) => fs.mkdirSync(p, options));
|
|
1508
|
+
const copy = deps.copyFileSync || ((src, dest) => fs.copyFileSync(src, dest));
|
|
1509
|
+
const remove = deps.rmSync || ((p, options) => fs.rmSync(p, options));
|
|
1510
|
+
const dir = path.join(backupDir, RETAINED_ORIGINAL_DIR);
|
|
1511
|
+
const restored = [];
|
|
1512
|
+
const removed = [];
|
|
1513
|
+
const failed = [];
|
|
1514
|
+
for (const rel of STATE_DB_FILES) {
|
|
1515
|
+
const src = path.join(dir, path.basename(rel));
|
|
1516
|
+
const dest = path.join(runtimeRoot, ...rel.split('/'));
|
|
1517
|
+
try {
|
|
1518
|
+
if (exists(src)) {
|
|
1519
|
+
mkdir(path.dirname(dest), { recursive: true });
|
|
1520
|
+
copy(src, dest);
|
|
1521
|
+
restored.push(rel);
|
|
1522
|
+
} else if (rel !== 'engine/state.db' && exists(dest)) {
|
|
1523
|
+
remove(dest, { force: true });
|
|
1524
|
+
removed.push(rel);
|
|
1525
|
+
}
|
|
1526
|
+
} catch (e) {
|
|
1527
|
+
failed.push({ rel, error: e && e.message ? e.message : String(e) });
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return { dir, restored, removed, failed };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** Put back the config/routing/pinned copies parked beside the backup. */
|
|
1534
|
+
function restoreBackupCopiedFiles(runtimeRoot, backupDir, deps = {}) {
|
|
1535
|
+
const exists = deps.existsSync || (p => fs.existsSync(p));
|
|
1536
|
+
const copy = deps.copyFileSync || ((src, dest) => fs.copyFileSync(src, dest));
|
|
1537
|
+
const restored = [];
|
|
1538
|
+
const failed = [];
|
|
1539
|
+
for (const rel of BACKUP_COPIED_FILES) {
|
|
1540
|
+
const src = path.join(backupDir, rel);
|
|
1541
|
+
if (!exists(src)) continue;
|
|
1542
|
+
try {
|
|
1543
|
+
copy(src, path.join(runtimeRoot, rel));
|
|
1544
|
+
restored.push(rel);
|
|
1545
|
+
} catch (e) {
|
|
1546
|
+
failed.push({ rel, error: e && e.message ? e.message : String(e) });
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return { restored, failed };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// ─── State continuity (pre/post invariants) ─────────────────────────────────
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* The continuity gate is an explicit ALLOWLIST of durable tables, not a denylist
|
|
1556
|
+
* of volatile ones. A denylist fails open: every table nobody remembered to
|
|
1557
|
+
* enumerate is silently gated, and most of the SQL state legitimately shrinks
|
|
1558
|
+
* across a package cutover —
|
|
1559
|
+
*
|
|
1560
|
+
* - `cc_sessions` / `doc_sessions` are DELETEd when a session is invalidated
|
|
1561
|
+
* (engine/persistence/small-state-store.js), and a changed
|
|
1562
|
+
* `prompts/cc-system.md` hash invalidates CC sessions — which `sync-init`
|
|
1563
|
+
* ships by definition,
|
|
1564
|
+
* - `pending_rebases` is deleted and rewritten wholesale every tick,
|
|
1565
|
+
* - `worktree_pool`, `managed_processes`, `schedule_runs`, `pipeline_runs`,
|
|
1566
|
+
* `metrics`, `cc_queued_messages`, `memory_retrieval_runs`, `steering_deliveries`
|
|
1567
|
+
* are all runtime bookkeeping that shrinks by design,
|
|
1568
|
+
* - `pr_mirror_hashes` is DROPPED outright by migration 018, which surfaces as
|
|
1569
|
+
* a `missingTables` entry, and
|
|
1570
|
+
* - `logs` / `events` / `memory_lifecycle_events` / `dispatches` / `cooldowns`
|
|
1571
|
+
* are capped, consumed, retention-pruned, or expire.
|
|
1572
|
+
*
|
|
1573
|
+
* So only the tables listed here are gated; everything else is passed to
|
|
1574
|
+
* `compareStateSummaries` as `ignoreTables`. These hold irreplaceable
|
|
1575
|
+
* operator- and agent-authored records that no install step may reduce.
|
|
1576
|
+
*
|
|
1577
|
+
* `repairedByMigration` names a shipped REPAIR migration that deletes rows from
|
|
1578
|
+
* that table by design. The gate is lifted for that table only on a run that
|
|
1579
|
+
* actually crosses that schema version — so a normal upgrade still fails on a
|
|
1580
|
+
* PRD drop, while the one migration entitled to delete PRD rows is not reported
|
|
1581
|
+
* as data loss.
|
|
1582
|
+
*/
|
|
1583
|
+
const CONTINUITY_DURABLE_TABLES = [
|
|
1584
|
+
{ table: 'work_items' },
|
|
1585
|
+
{ table: 'pull_requests' },
|
|
1586
|
+
{ table: 'plans' },
|
|
1587
|
+
// Migration 024 deletes duplicate live PRD echoes of archived imports.
|
|
1588
|
+
{ table: 'prds', repairedByMigration: 24 },
|
|
1589
|
+
{ table: 'prd_items', repairedByMigration: 24 },
|
|
1590
|
+
{ table: 'prd_verify_prs', repairedByMigration: 24 },
|
|
1591
|
+
{ table: 'memory_records' },
|
|
1592
|
+
{ table: 'inbox_entries' },
|
|
1593
|
+
{ table: 'watches' },
|
|
1594
|
+
{ table: 'qa_runs' },
|
|
1595
|
+
{ table: 'qa_sessions' },
|
|
1596
|
+
];
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Turn the durable allowlist into the `ignoreTables` set `compareStateSummaries`
|
|
1600
|
+
* expects: every table present on either side of the census that is NOT gated.
|
|
1601
|
+
* Pure — the schema versions come from the two summaries themselves.
|
|
1602
|
+
*/
|
|
1603
|
+
function resolveContinuityIgnoreTables(before, after, durable = CONTINUITY_DURABLE_TABLES) {
|
|
1604
|
+
const beforeVersion = Number(before && before.schemaVersion) || 0;
|
|
1605
|
+
const afterVersion = Number(after && after.schemaVersion) || 0;
|
|
1606
|
+
const gated = new Set();
|
|
1607
|
+
for (const entry of durable) {
|
|
1608
|
+
const { table, repairedByMigration = null } = typeof entry === 'string' ? { table: entry } : (entry || {});
|
|
1609
|
+
if (!table) continue;
|
|
1610
|
+
// A run that crosses the repair migration is entitled to the deletions it makes.
|
|
1611
|
+
if (repairedByMigration && beforeVersion < repairedByMigration && afterVersion >= repairedByMigration) continue;
|
|
1612
|
+
gated.add(table);
|
|
1613
|
+
}
|
|
1614
|
+
const seen = new Set([
|
|
1615
|
+
...Object.keys((before && before.tables) || {}),
|
|
1616
|
+
...Object.keys((after && after.tables) || {}),
|
|
1617
|
+
]);
|
|
1618
|
+
return [...seen].filter(table => !gated.has(table)).sort();
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** Markers so a summary can be recovered from stdout that also carries warnings. */
|
|
1622
|
+
const SUMMARY_BEGIN = '<<MINIONS-STATE-SUMMARY>>';
|
|
1623
|
+
const SUMMARY_END = '<</MINIONS-STATE-SUMMARY>>';
|
|
1624
|
+
|
|
1625
|
+
const EMIT = (expr) => `process.stdout.write(${JSON.stringify(SUMMARY_BEGIN)}+JSON.stringify(${expr})+${JSON.stringify(SUMMARY_END)});`;
|
|
1626
|
+
|
|
1627
|
+
/** Census the LIVE state of a runtime root, through that runtime's own module. */
|
|
1628
|
+
const SUMMARIZE_STATE_SCRIPT = EMIT('require(process.argv[1]).summarizeState()');
|
|
1629
|
+
|
|
1630
|
+
/** Census a backup file read-only, through the runtime's own `verifyBackup`. */
|
|
1631
|
+
const SUMMARIZE_BACKUP_SCRIPT = EMIT('require(process.argv[1]).verifyBackup(process.argv[2]).summary');
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Compare two summaries with the runtime's OWN `compareStateSummaries` rather
|
|
1635
|
+
* than a second implementation here. The summaries travel through files because
|
|
1636
|
+
* a full table census is far too large for argv on Windows.
|
|
1637
|
+
*/
|
|
1638
|
+
const COMPARE_SUMMARIES_SCRIPT = 'const f=require("fs");const m=require(process.argv[1]);'
|
|
1639
|
+
+ 'const r=(p)=>JSON.parse(f.readFileSync(p,"utf8"));'
|
|
1640
|
+
+ EMIT('m.compareStateSummaries(r(process.argv[2]),r(process.argv[3]),{ignoreTables:r(process.argv[4])})');
|
|
1641
|
+
|
|
1642
|
+
/** Recover the emitted JSON payload from a child's stdout. Null when absent. */
|
|
1643
|
+
function extractEmittedJson(stdout) {
|
|
1644
|
+
const text = String(stdout == null ? '' : stdout);
|
|
1645
|
+
const start = text.indexOf(SUMMARY_BEGIN);
|
|
1646
|
+
if (start < 0) return null;
|
|
1647
|
+
const end = text.indexOf(SUMMARY_END, start + SUMMARY_BEGIN.length);
|
|
1648
|
+
if (end < 0) return null;
|
|
1649
|
+
try { return JSON.parse(text.slice(start + SUMMARY_BEGIN.length, end)); } catch { return null; }
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Locate the state-operations module a runtime root carries. `minions init`
|
|
1654
|
+
* copies packaged engine JavaScript into the runtime root, so it is present for
|
|
1655
|
+
* every installed runtime and is by construction version-matched to that
|
|
1656
|
+
* runtime's `engine/state.db`.
|
|
1657
|
+
*/
|
|
1658
|
+
function resolveStateOperationsModule(runtimeRoot, exists = p => fs.existsSync(p)) {
|
|
1659
|
+
if (!runtimeRoot) return null;
|
|
1660
|
+
return [
|
|
1661
|
+
path.join(runtimeRoot, 'engine', 'persistence', 'state-operations.js'),
|
|
1662
|
+
path.join(runtimeRoot, 'engine', 'state-operations.js'),
|
|
1663
|
+
].find(exists) || null;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Run a `summarizeState()` census out-of-process, against either a backup file
|
|
1668
|
+
* (read-only, preferred for the pre-migration side) or the live pinned root.
|
|
1669
|
+
* Never throws — an unavailable census is reported, not fatal, so the caller
|
|
1670
|
+
* can degrade to the presence-only check instead of aborting a healthy install.
|
|
1671
|
+
*/
|
|
1672
|
+
function readStateSummary({ run, modulePath, backupPath = null, env, timeout = CLI_INIT_TIMEOUT_MS } = {}) {
|
|
1673
|
+
if (!modulePath) {
|
|
1674
|
+
return { ok: false, summary: null, error: 'no engine/persistence/state-operations.js was found under the runtime root' };
|
|
1675
|
+
}
|
|
1676
|
+
const args = backupPath
|
|
1677
|
+
? ['-e', SUMMARIZE_BACKUP_SCRIPT, modulePath, backupPath]
|
|
1678
|
+
: ['-e', SUMMARIZE_STATE_SCRIPT, modulePath];
|
|
1679
|
+
try {
|
|
1680
|
+
const result = run(process.execPath, args, { timeout, capture: true, env });
|
|
1681
|
+
const summary = extractEmittedJson(result.stdout);
|
|
1682
|
+
if (!summary || !summary.tables) throw new Error('the census returned no table counts');
|
|
1683
|
+
return { ok: true, summary, error: null };
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
return { ok: false, summary: null, error: e && e.message ? e.message : String(e) };
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Compare a pre- and post-migration census through the runtime's own
|
|
1691
|
+
* `compareStateSummaries`. Returns `{ ok, comparison, error }`; an
|
|
1692
|
+
* unavailable comparison is `ok: false` with `comparison: null`, which the
|
|
1693
|
+
* caller must treat as "could not verify", not as "verified".
|
|
1694
|
+
*/
|
|
1695
|
+
function compareStateContinuity({
|
|
1696
|
+
run, modulePath, before, after, env,
|
|
1697
|
+
ignoreTables = null,
|
|
1698
|
+
timeout = CLI_INIT_TIMEOUT_MS,
|
|
1699
|
+
tmpDirFactory = () => fs.mkdtempSync(path.join(os.tmpdir(), 'minions-continuity-')),
|
|
1700
|
+
} = {}) {
|
|
1701
|
+
if (!modulePath) return { ok: false, comparison: null, error: 'no state-operations module to compare with' };
|
|
1702
|
+
if (!before || !after) return { ok: false, comparison: null, error: 'a state census is missing' };
|
|
1703
|
+
const ignore = ignoreTables || resolveContinuityIgnoreTables(before, after);
|
|
1704
|
+
let dir = null;
|
|
1705
|
+
try {
|
|
1706
|
+
dir = tmpDirFactory();
|
|
1707
|
+
const beforePath = path.join(dir, 'before.json');
|
|
1708
|
+
const afterPath = path.join(dir, 'after.json');
|
|
1709
|
+
const ignorePath = path.join(dir, 'ignore.json');
|
|
1710
|
+
fs.writeFileSync(beforePath, JSON.stringify(before));
|
|
1711
|
+
fs.writeFileSync(afterPath, JSON.stringify(after));
|
|
1712
|
+
fs.writeFileSync(ignorePath, JSON.stringify(ignore));
|
|
1713
|
+
const result = run(process.execPath, ['-e', COMPARE_SUMMARIES_SCRIPT, modulePath, beforePath, afterPath, ignorePath], {
|
|
1714
|
+
timeout, capture: true, env,
|
|
1715
|
+
});
|
|
1716
|
+
const comparison = extractEmittedJson(result.stdout);
|
|
1717
|
+
if (!comparison || typeof comparison.ok !== 'boolean') throw new Error('the comparison returned no verdict');
|
|
1718
|
+
return { ok: true, comparison, error: null };
|
|
1719
|
+
} catch (e) {
|
|
1720
|
+
return { ok: false, comparison: null, error: e && e.message ? e.message : String(e) };
|
|
1721
|
+
} finally {
|
|
1722
|
+
if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** Render a failed comparison as the operator-facing explanation. */
|
|
1727
|
+
function describeContinuityFailure(comparison) {
|
|
1728
|
+
const parts = [];
|
|
1729
|
+
if (comparison.missingTables && comparison.missingTables.length) {
|
|
1730
|
+
parts.push(`tables missing after migration: ${comparison.missingTables.join(', ')}`);
|
|
1731
|
+
}
|
|
1732
|
+
if (comparison.emptyAfterNonEmptyBefore && comparison.emptyAfterNonEmptyBefore.length) {
|
|
1733
|
+
parts.push(`tables emptied: ${comparison.emptyAfterNonEmptyBefore.join(', ')}`);
|
|
1734
|
+
}
|
|
1735
|
+
if (comparison.regressions && comparison.regressions.length) {
|
|
1736
|
+
parts.push(`row counts fell: ${comparison.regressions.map(r => `${r.table} ${r.before} → ${r.after}`).join(', ')}`);
|
|
1737
|
+
}
|
|
1738
|
+
return parts.length ? parts.join('; ') : 'the state census regressed';
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* Decide whether a state-restoring rollback may run. Package rollback is always
|
|
1743
|
+
* available; STATE rollback additionally requires the retained original bytes,
|
|
1744
|
+
* because restoring a `VACUUM INTO` snapshot over a live root would silently
|
|
1745
|
+
* discard the WAL the original run left behind.
|
|
1746
|
+
*/
|
|
1747
|
+
function assessStateRollback({ backupDir = null, retainedOriginals = [] } = {}) {
|
|
1748
|
+
if (!backupDir) {
|
|
1749
|
+
return { allowed: false, reason: 'this run took no backup, so there is nothing to restore from' };
|
|
1750
|
+
}
|
|
1751
|
+
if (!retainedOriginals.includes('original/state.db')) {
|
|
1752
|
+
return { allowed: false, reason: `the original engine/state.db was not retained under ${RETAINED_ORIGINAL_DIR}/` };
|
|
1753
|
+
}
|
|
1754
|
+
return { allowed: true, reason: `restoring the original state files retained in ${backupDir}` };
|
|
1755
|
+
}
|
|
1756
|
+
|
|
888
1757
|
// ─── Migration plan (pure) ──────────────────────────────────────────────────
|
|
889
1758
|
|
|
890
1759
|
const STEP_ORDER = [
|
|
891
1760
|
'acquire-token',
|
|
892
1761
|
'validate-feed',
|
|
893
1762
|
'download-artifact',
|
|
1763
|
+
'quiesce-services',
|
|
894
1764
|
'backup-state',
|
|
895
1765
|
'uninstall-public',
|
|
896
1766
|
'repair-shims',
|
|
@@ -898,6 +1768,7 @@ const STEP_ORDER = [
|
|
|
898
1768
|
'verify-install',
|
|
899
1769
|
'verify-shim',
|
|
900
1770
|
'sync-init',
|
|
1771
|
+
'verify-continuity',
|
|
901
1772
|
'restart',
|
|
902
1773
|
'cleanup',
|
|
903
1774
|
];
|
|
@@ -911,6 +1782,11 @@ const STEP_ORDER = [
|
|
|
911
1782
|
* - `download-artifact` precedes `uninstall-public`, so the public package is
|
|
912
1783
|
* only removed once the internal one is resolved AND on local disk — a feed
|
|
913
1784
|
* failure during the install is then recoverable offline.
|
|
1785
|
+
* - `quiesce-services` precedes `backup-state`, and therefore precedes every
|
|
1786
|
+
* uninstall and package mutation: a backup taken while the engine is
|
|
1787
|
+
* writing is a backup of a moving target, and rewriting the runtime `.js`
|
|
1788
|
+
* files underneath a live daemon is exactly what the restart contract does
|
|
1789
|
+
* not cover. It fails CLOSED when the handles are not released.
|
|
914
1790
|
* - `backup-state` precedes every mutation of the runtime root, and also
|
|
915
1791
|
* precedes `uninstall-public`, so the cutover never starts without a
|
|
916
1792
|
* restore point.
|
|
@@ -923,6 +1799,24 @@ const STEP_ORDER = [
|
|
|
923
1799
|
* before a restore point exists.
|
|
924
1800
|
* - `verify-shim` precedes `sync-init`, so the runtime is only synchronized
|
|
925
1801
|
* once `minions` actually resolves to the internal package.
|
|
1802
|
+
* - `verify-continuity` runs while the services are still quiesced, BEFORE
|
|
1803
|
+
* `restart`. Two reasons, and both are load-bearing:
|
|
1804
|
+
* 1. A census taken against a live engine races it — rows move under the
|
|
1805
|
+
* count, so the verdict is not reproducible. Under quiescence the only
|
|
1806
|
+
* writer is the census itself, and `getDb()` applies pending migrations
|
|
1807
|
+
* on open (`engine/db/index.js`), so a pre-restart census still
|
|
1808
|
+
* exercises the migrations deterministically.
|
|
1809
|
+
* 2. Its failure action, `rollbackStateAndPackage()`, overwrites
|
|
1810
|
+
* `engine/state.db{,-wal,-shm}` with the retained pre-migration bytes.
|
|
1811
|
+
* After a restart that would destroy every write the restarted engine
|
|
1812
|
+
* and dashboard had already made — including the migrations that had
|
|
1813
|
+
* just succeeded. Gating before the restart means the rollback can only
|
|
1814
|
+
* ever discard bytes that nothing has written to since the backup.
|
|
1815
|
+
* It stays the last gate before `cleanup`: the preserved-path check is
|
|
1816
|
+
* presence-only and an emptied `engine/state.db` passes it.
|
|
1817
|
+
* - `restart` is therefore the final action of a run that has already proven
|
|
1818
|
+
* its state survived, so the machine is only brought back up on state that
|
|
1819
|
+
* was verified.
|
|
926
1820
|
* - `cleanup` is always last and always present — it runs in a `finally`.
|
|
927
1821
|
*
|
|
928
1822
|
* `install-internal` is also the only step that (re-)links the `minions` bin
|
|
@@ -949,7 +1843,13 @@ function planSteps(state) {
|
|
|
949
1843
|
&& !willUninstallPublic
|
|
950
1844
|
&& shimOwned;
|
|
951
1845
|
const skip = new Set();
|
|
952
|
-
if (!hasExistingRuntime)
|
|
1846
|
+
if (!hasExistingRuntime) {
|
|
1847
|
+
// Nothing is running against a root that has no runtime, nothing to back
|
|
1848
|
+
// up, and no pre-migration census to compare a post-migration one against.
|
|
1849
|
+
skip.add('quiesce-services');
|
|
1850
|
+
skip.add('backup-state');
|
|
1851
|
+
skip.add('verify-continuity');
|
|
1852
|
+
}
|
|
953
1853
|
if (!willUninstallPublic) skip.add('uninstall-public');
|
|
954
1854
|
if (alreadyAtTarget) {
|
|
955
1855
|
skip.add('install-internal');
|
|
@@ -1020,7 +1920,10 @@ function makeRunner(secrets, { log = console.log, error = console.error } = {})
|
|
|
1020
1920
|
err.stderr = stderr;
|
|
1021
1921
|
throw err;
|
|
1022
1922
|
}
|
|
1023
|
-
|
|
1923
|
+
// A tolerated failure is still a failure: its stderr is the only recorded
|
|
1924
|
+
// cause for the fallback, so it belongs on the diagnostic stream with every
|
|
1925
|
+
// other diagnostic — never interleaved into this script's stdout.
|
|
1926
|
+
if (capture && !ok && stderr.trim()) error(stderr.trimEnd());
|
|
1024
1927
|
return { ok, stdout, stderr, status: result.status };
|
|
1025
1928
|
};
|
|
1026
1929
|
}
|
|
@@ -1063,6 +1966,156 @@ function resolveNpmGlobalPath(run, kind, config) {
|
|
|
1063
1966
|
} catch { return null; }
|
|
1064
1967
|
}
|
|
1065
1968
|
|
|
1969
|
+
// ─── Authenticated feed session (W-ms9bhf9k001j29cb) ────────────────────────
|
|
1970
|
+
//
|
|
1971
|
+
// The seam `minions update` uses to update an internal install from its own
|
|
1972
|
+
// feed. It exists so the in-place updater reuses THIS file's auth/config
|
|
1973
|
+
// handling — short-lived `az` token, 0600 temporary userconfig, redacted
|
|
1974
|
+
// output, guaranteed cleanup — instead of growing a second, divergent copy of
|
|
1975
|
+
// security-sensitive logic in bin/minions.js.
|
|
1976
|
+
|
|
1977
|
+
/** Read a version out of `npm view … version --json` (array, JSON string, or bare). */
|
|
1978
|
+
function parseViewedVersion(stdout) {
|
|
1979
|
+
const raw = String(stdout == null ? '' : stdout).trim();
|
|
1980
|
+
if (!raw) return '';
|
|
1981
|
+
try {
|
|
1982
|
+
const parsed = JSON.parse(raw);
|
|
1983
|
+
const value = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
|
|
1984
|
+
return value == null ? '' : String(value).trim();
|
|
1985
|
+
} catch {
|
|
1986
|
+
return raw.replace(/^"|"$/g, '');
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Operator-facing explanation for a failed feed operation, as printable lines.
|
|
1992
|
+
*
|
|
1993
|
+
* The failure this replaces is the misleading one: with no registry of its own,
|
|
1994
|
+
* an update of the internal package was routed by ambient npm config to a
|
|
1995
|
+
* public proxy, which answered E404 — reading as "this package does not exist"
|
|
1996
|
+
* for a package that does exist, in a feed that was never contacted. Every
|
|
1997
|
+
* message below therefore names the feed that WAS queried and the two real
|
|
1998
|
+
* causes (auth, or feed read permission / missing version).
|
|
1999
|
+
*/
|
|
2000
|
+
function describeInternalFeedFailure({ kind, error, feed, versionSpec = DEFAULT_VERSION_SPEC } = {}) {
|
|
2001
|
+
const detail = error && error.message ? String(error.message) : '';
|
|
2002
|
+
const pkg = feed ? feed.packageName : INTERNAL_PACKAGE;
|
|
2003
|
+
const registry = feed ? feed.registry : DEFAULT_REGISTRY;
|
|
2004
|
+
if (kind === 'auth') {
|
|
2005
|
+
return [
|
|
2006
|
+
`Could not acquire an Azure DevOps token for the internal feed (${registry}).`,
|
|
2007
|
+
detail ? ` ${detail}` : '',
|
|
2008
|
+
' Sign in with the Azure CLI, then retry:',
|
|
2009
|
+
' az login',
|
|
2010
|
+
' az account set --subscription <id> (only if you have more than one)',
|
|
2011
|
+
].filter(Boolean);
|
|
2012
|
+
}
|
|
2013
|
+
const action = kind === 'install' ? `install ${pkg}@${versionSpec} from` : `read ${pkg}@${versionSpec} from`;
|
|
2014
|
+
return [
|
|
2015
|
+
`Could not ${action} the internal feed.`,
|
|
2016
|
+
` Feed: ${registry}`,
|
|
2017
|
+
detail ? ` ${detail}` : '',
|
|
2018
|
+
' This is the ISS ProjectFeed-ISS Azure Artifacts feed, not a public registry — a 404 here',
|
|
2019
|
+
' means the version is not published to the feed or your account has no Azure Artifacts read',
|
|
2020
|
+
' access to it, never that the package is unpublished.',
|
|
2021
|
+
' Check: `az login` succeeds, you have read permission on ProjectFeed-ISS, and the version exists.',
|
|
2022
|
+
].filter(Boolean);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function feedError({ kind, error, feed, versionSpec }) {
|
|
2026
|
+
const lines = describeInternalFeedFailure({ kind, error, feed, versionSpec });
|
|
2027
|
+
const err = new Error(lines.join('\n'));
|
|
2028
|
+
err.feedFailure = kind;
|
|
2029
|
+
err.lines = lines;
|
|
2030
|
+
return err;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* Open an authenticated npm session against a private feed.
|
|
2035
|
+
*
|
|
2036
|
+
* Acquires a short-lived ADO access token through the caller's existing Azure
|
|
2037
|
+
* CLI login and stages it in a 0600 temporary userconfig — the token never
|
|
2038
|
+
* reaches argv, the environment, stdout, or any persistent file. Every npm
|
|
2039
|
+
* command issued through the session carries that userconfig plus the
|
|
2040
|
+
* command-line scope pin, so neither the caller's npm config nor the ambient
|
|
2041
|
+
* default registry can redirect it.
|
|
2042
|
+
*
|
|
2043
|
+
* `close()` removes the staged credential and is idempotent. It is also wired
|
|
2044
|
+
* to a `process.on('exit')` hook, because the caller (`minions update`) exits
|
|
2045
|
+
* non-zero on npm failures and `process.exit()` skips `finally` blocks.
|
|
2046
|
+
*/
|
|
2047
|
+
function openInternalFeedSession(feed, deps = {}) {
|
|
2048
|
+
if (!feed || !feed.registry || !feed.packageName) throw new Error('an internal feed is required');
|
|
2049
|
+
const log = deps.log || console.log;
|
|
2050
|
+
const error = deps.error || console.error;
|
|
2051
|
+
const secrets = [];
|
|
2052
|
+
const run = deps.run || makeRunner(secrets, { log, error });
|
|
2053
|
+
|
|
2054
|
+
let token;
|
|
2055
|
+
try {
|
|
2056
|
+
const acquired = deps.acquireToken
|
|
2057
|
+
? deps.acquireToken()
|
|
2058
|
+
: run('az', buildTokenArgs(), { timeout: AZ_TOKEN_TIMEOUT_MS, capture: true }).stdout;
|
|
2059
|
+
token = String(acquired == null ? '' : acquired).trim();
|
|
2060
|
+
} catch (e) {
|
|
2061
|
+
throw feedError({ kind: 'auth', error: e, feed });
|
|
2062
|
+
}
|
|
2063
|
+
if (!token) throw feedError({ kind: 'auth', error: new Error('the Azure CLI returned an empty access token'), feed });
|
|
2064
|
+
secrets.push(token);
|
|
2065
|
+
|
|
2066
|
+
const temp = (deps.writeTempNpmrc || writeTempNpmrc)({
|
|
2067
|
+
registry: feed.registry,
|
|
2068
|
+
scope: feed.scope,
|
|
2069
|
+
token,
|
|
2070
|
+
baseConfig: (deps.readUserNpmrc || readUserNpmrc)(),
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
let closed = false;
|
|
2074
|
+
const cleanup = () => {
|
|
2075
|
+
if (closed) return false;
|
|
2076
|
+
closed = true;
|
|
2077
|
+
return (deps.cleanupTempNpmrc || cleanupTempNpmrc)(temp.dir);
|
|
2078
|
+
};
|
|
2079
|
+
const onExit = () => { cleanup(); };
|
|
2080
|
+
process.on('exit', onExit);
|
|
2081
|
+
|
|
2082
|
+
return {
|
|
2083
|
+
userconfig: temp.file,
|
|
2084
|
+
registry: feed.registry,
|
|
2085
|
+
packageName: feed.packageName,
|
|
2086
|
+
secrets,
|
|
2087
|
+
/** Resolve a version spec (dist-tag or exact) to a concrete version from the feed. */
|
|
2088
|
+
resolveVersion(versionSpec = DEFAULT_VERSION_SPEC) {
|
|
2089
|
+
const args = buildNpmViewArgs({
|
|
2090
|
+
packageName: feed.packageName, versionSpec, registry: feed.registry, userconfig: temp.file,
|
|
2091
|
+
});
|
|
2092
|
+
let result;
|
|
2093
|
+
try {
|
|
2094
|
+
result = run('npm', args, { timeout: NPM_VIEW_TIMEOUT_MS, capture: true });
|
|
2095
|
+
} catch (e) {
|
|
2096
|
+
throw feedError({ kind: 'view', error: e, feed, versionSpec });
|
|
2097
|
+
}
|
|
2098
|
+
return parseViewedVersion(result.stdout);
|
|
2099
|
+
},
|
|
2100
|
+
/** Install an exact version globally from the feed. */
|
|
2101
|
+
install(version) {
|
|
2102
|
+
const args = buildNpmInstallArgs({
|
|
2103
|
+
packageName: feed.packageName, version, registry: feed.registry, userconfig: temp.file,
|
|
2104
|
+
});
|
|
2105
|
+
try {
|
|
2106
|
+
return run('npm', args, { timeout: NPM_INSTALL_TIMEOUT_MS });
|
|
2107
|
+
} catch (e) {
|
|
2108
|
+
throw feedError({ kind: 'install', error: e, feed, versionSpec: version });
|
|
2109
|
+
}
|
|
2110
|
+
},
|
|
2111
|
+
close() {
|
|
2112
|
+
const removed = cleanup();
|
|
2113
|
+
process.removeListener('exit', onExit);
|
|
2114
|
+
return removed;
|
|
2115
|
+
},
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
|
|
1066
2119
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
1067
2120
|
|
|
1068
2121
|
function main(argv) {
|
|
@@ -1078,7 +2131,6 @@ function main(argv) {
|
|
|
1078
2131
|
console.log(USAGE);
|
|
1079
2132
|
return EXIT.OK;
|
|
1080
2133
|
}
|
|
1081
|
-
|
|
1082
2134
|
const secrets = [];
|
|
1083
2135
|
const log = (...args) => console.log(...args);
|
|
1084
2136
|
const run = makeRunner(secrets, { log });
|
|
@@ -1087,15 +2139,26 @@ function main(argv) {
|
|
|
1087
2139
|
// pinned onto every child process below (backup, init, restart) rather than
|
|
1088
2140
|
// left to whatever MINIONS_HOME each child happens to inherit. A cutover that
|
|
1089
2141
|
// backed up one root and synchronized another would look successful and lose
|
|
1090
|
-
// the operator's state
|
|
1091
|
-
|
|
2142
|
+
// the operator's state — and a machine where more than one candidate carries
|
|
2143
|
+
// runtime state is refused here rather than resolved by guessing.
|
|
2144
|
+
const rootDecision = resolveAuthoritativeRuntimeRoot({ flag: opts.runtimeRoot });
|
|
2145
|
+
if (rootDecision.ambiguous) {
|
|
2146
|
+
console.error(`\n REFUSED: ${rootDecision.reason}`);
|
|
2147
|
+
console.error('\n Nothing was downloaded, uninstalled, or modified.\n');
|
|
2148
|
+
return EXIT.REFUSED;
|
|
2149
|
+
}
|
|
2150
|
+
const runtimeRoot = rootDecision.root;
|
|
1092
2151
|
const runtimeEnv = { ...process.env, MINIONS_HOME: runtimeRoot };
|
|
2152
|
+
// MINIONS_TEST_DIR outranks MINIONS_HOME in the engine's resolveMinionsHome(),
|
|
2153
|
+
// so an inherited one would silently un-pin every child we just pinned.
|
|
2154
|
+
delete runtimeEnv.MINIONS_TEST_DIR;
|
|
1093
2155
|
const hasExistingRuntime = fs.existsSync(path.join(runtimeRoot, 'engine.js'));
|
|
1094
2156
|
|
|
1095
2157
|
log(`\n Internal Minions install`);
|
|
1096
2158
|
log(` package: ${opts.packageName}@${opts.versionSpec}`);
|
|
1097
2159
|
log(` registry: ${opts.registry}`);
|
|
1098
2160
|
log(` runtime root: ${runtimeRoot}${hasExistingRuntime ? '' : ' (fresh install)'}`);
|
|
2161
|
+
log(` pinned via: ${rootDecision.source} — ${rootDecision.reason}`);
|
|
1099
2162
|
|
|
1100
2163
|
// npm-usability probe only. The authoritative global root and prefix are
|
|
1101
2164
|
// re-resolved below under the same `--userconfig` the install uses, because a
|
|
@@ -1196,12 +2259,7 @@ function main(argv) {
|
|
|
1196
2259
|
console.error(' Nothing was uninstalled or modified.\n');
|
|
1197
2260
|
return EXIT.FAILED;
|
|
1198
2261
|
}
|
|
1199
|
-
|
|
1200
|
-
const parsed = JSON.parse(viewResult.stdout.trim());
|
|
1201
|
-
targetVersion = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
|
|
1202
|
-
} catch {
|
|
1203
|
-
targetVersion = viewResult.stdout.trim().replace(/^"|"$/g, '');
|
|
1204
|
-
}
|
|
2262
|
+
targetVersion = parseViewedVersion(viewResult.stdout);
|
|
1205
2263
|
if (!targetVersion) {
|
|
1206
2264
|
console.error('\n ERROR: the feed returned no version for that spec. Nothing was modified.\n');
|
|
1207
2265
|
return EXIT.FAILED;
|
|
@@ -1254,7 +2312,15 @@ function main(argv) {
|
|
|
1254
2312
|
if (steps.includes('download-artifact')) {
|
|
1255
2313
|
log(` npm ${redact(buildNpmPackArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file, destination: '<temp>' }).join(' '), secrets)}`);
|
|
1256
2314
|
}
|
|
1257
|
-
if (steps.includes('
|
|
2315
|
+
if (steps.includes('quiesce-services')) {
|
|
2316
|
+
log(` minions ${CLI_CAPABILITY_PROBE_ARGS.join(' ')} (capability probe: does this CLI know \`stop --all\`?)`);
|
|
2317
|
+
log(` minions ${WHOLE_STACK_STOP_ARGS.join(' ')} (or \`minions ${LEGACY_STOP_ARGS.join(' ')}\` when the probe says it does not)`);
|
|
2318
|
+
log(` (then wait for engine/state.db-shm to be released)`);
|
|
2319
|
+
}
|
|
2320
|
+
if (steps.includes('backup-state')) {
|
|
2321
|
+
log(` minions state backup <backup-dir>/state.db`);
|
|
2322
|
+
log(` (retain) copy engine/state.db{,-wal,-shm} → <backup-dir>/${RETAINED_ORIGINAL_DIR}/`);
|
|
2323
|
+
}
|
|
1258
2324
|
if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE, npmConfig).join(' ')}`);
|
|
1259
2325
|
if (steps.includes('repair-shims')) {
|
|
1260
2326
|
log(orphanShims.needsRepair
|
|
@@ -1266,7 +2332,9 @@ function main(argv) {
|
|
|
1266
2332
|
log(` (on failure) npm ${buildNpmInstallTarballArgs('<backup-dir>/<package>.tgz').join(' ')}`);
|
|
1267
2333
|
}
|
|
1268
2334
|
log(` minions init --force --skip-start`);
|
|
2335
|
+
if (steps.includes('verify-continuity')) log(` (verify) summarizeState() before vs after — any regression rolls state back`);
|
|
1269
2336
|
if (steps.includes('restart')) log(` minions restart`);
|
|
2337
|
+
log(` MINIONS_HOME=${runtimeRoot} (pinned onto every command above; source: ${rootDecision.source})`);
|
|
1270
2338
|
log('\n [dry-run] No changes made.\n');
|
|
1271
2339
|
return EXIT.OK;
|
|
1272
2340
|
}
|
|
@@ -1305,9 +2373,46 @@ function main(argv) {
|
|
|
1305
2373
|
log(` Artifact downloaded: ${path.basename(artifactPath)}`);
|
|
1306
2374
|
}
|
|
1307
2375
|
|
|
1308
|
-
// 4.
|
|
2376
|
+
// 4. quiesce-services ──────────────────────────────────────────────────
|
|
2377
|
+
// Everything below this line mutates the runtime root or the installed
|
|
2378
|
+
// package, so the engine and dashboard running against the PINNED root are
|
|
2379
|
+
// stopped first and their database handles are proven released. Fails
|
|
2380
|
+
// CLOSED: nothing is backed up, uninstalled, or installed otherwise.
|
|
2381
|
+
const backupCli = resolveBackupCli({
|
|
2382
|
+
npmRootG,
|
|
2383
|
+
runtimeRoot,
|
|
2384
|
+
packageName: opts.packageName,
|
|
2385
|
+
installedInternalVersion,
|
|
2386
|
+
installedPublic,
|
|
2387
|
+
});
|
|
2388
|
+
let servicesStopped = false;
|
|
2389
|
+
if (steps.includes('quiesce-services')) {
|
|
2390
|
+
log(`\n Quiescing services running against ${runtimeRoot} ...`);
|
|
2391
|
+
const quiescence = quiesceServices({
|
|
2392
|
+
runtimeRoot,
|
|
2393
|
+
cliPath: backupCli.path,
|
|
2394
|
+
run,
|
|
2395
|
+
env: runtimeEnv,
|
|
2396
|
+
log,
|
|
2397
|
+
});
|
|
2398
|
+
if (!quiescence.ok) {
|
|
2399
|
+
console.error(`\n REFUSED: the runtime did not release engine/state.db — ${quiescence.reason}.`);
|
|
2400
|
+
for (const line of formatQuiescenceRefusal(quiescence)) console.error(line);
|
|
2401
|
+
console.error(' Backing up or replacing the package while the database is open would capture a moving');
|
|
2402
|
+
console.error(' target and rewrite engine code a live daemon is executing.');
|
|
2403
|
+
console.error(' Stop those process(es) (`minions stop --all --wait`, or end the PIDs above) and re-run.');
|
|
2404
|
+
console.error(' Nothing was backed up, uninstalled, or modified.\n');
|
|
2405
|
+
return EXIT.REFUSED;
|
|
2406
|
+
}
|
|
2407
|
+
servicesStopped = quiescence.stopped;
|
|
2408
|
+
log(` ${quiescence.stopped ? `Services stopped via the ${STOP_PATH_LABELS[quiescence.stopPath]}; ` : ''}${quiescence.reason}.`);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// 5. backup-state ──────────────────────────────────────────────────────
|
|
1309
2412
|
let backupDir = null;
|
|
1310
2413
|
let backupStatePath = null;
|
|
2414
|
+
let retainedOriginals = { retained: [], missing: [], failed: [] };
|
|
2415
|
+
let preMigrationSummary = null;
|
|
1311
2416
|
if (steps.includes('backup-state')) {
|
|
1312
2417
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1313
2418
|
backupDir = opts.backupDir
|
|
@@ -1339,17 +2444,52 @@ function main(argv) {
|
|
|
1339
2444
|
env: runtimeEnv,
|
|
1340
2445
|
});
|
|
1341
2446
|
if (!fs.existsSync(backupStatePath)) throw new Error('the backup command reported success but wrote no file');
|
|
1342
|
-
log(` SQLite state
|
|
2447
|
+
log(` SQLite state backed up consistently (via ${backup.kind === 'runtime-module' ? 'the runtime\'s own engine/persistence/state-operations.js' : backup.cliPath}).`);
|
|
1343
2448
|
} catch (e) {
|
|
1344
2449
|
backupStatePath = null;
|
|
1345
2450
|
console.error(`\n ERROR: state backup failed (${e.message}).`);
|
|
1346
2451
|
console.error(' Refusing to migrate without a consistent backup. Nothing was uninstalled.\n');
|
|
1347
2452
|
return EXIT.FAILED;
|
|
1348
2453
|
}
|
|
1349
|
-
for (const rel of
|
|
2454
|
+
for (const rel of BACKUP_COPIED_FILES) {
|
|
1350
2455
|
const src = path.join(runtimeRoot, rel);
|
|
1351
2456
|
try { if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backupDir, rel)); } catch { /* best-effort */ }
|
|
1352
2457
|
}
|
|
2458
|
+
// Retain the ORIGINAL bytes beside the VACUUM snapshot. The snapshot is
|
|
2459
|
+
// the consistent artifact a human restores by hand; these are what an
|
|
2460
|
+
// automatic state-restoring rollback needs to put the runtime back
|
|
2461
|
+
// exactly as it was, WAL included. Neither is ever deleted by this run.
|
|
2462
|
+
retainedOriginals = retainOriginalStateFiles(runtimeRoot, backupDir);
|
|
2463
|
+
if (!retainedOriginals.retained.includes(`${RETAINED_ORIGINAL_DIR}/state.db`)) {
|
|
2464
|
+
const why = retainedOriginals.failed.length
|
|
2465
|
+
? retainedOriginals.failed.map(f => `${f.rel} (${f.error})`).join('; ')
|
|
2466
|
+
: `engine/state.db was not found under ${runtimeRoot}`;
|
|
2467
|
+
console.error(`\n ERROR: could not retain the original state files — ${why}.`);
|
|
2468
|
+
console.error(' A state-restoring rollback would be impossible. Nothing was uninstalled.\n');
|
|
2469
|
+
return EXIT.FAILED;
|
|
2470
|
+
}
|
|
2471
|
+
log(` Original state retained byte-for-byte: ${retainedOriginals.retained.join(', ')}`);
|
|
2472
|
+
// Pre-migration census, read from the BACKUP rather than the live root:
|
|
2473
|
+
// `verifyBackup` opens it read-only, so measuring the baseline can never
|
|
2474
|
+
// mutate, WAL, or lock the state it is vouching for.
|
|
2475
|
+
const censusModule = backup.kind === 'runtime-module'
|
|
2476
|
+
? backup.modulePath
|
|
2477
|
+
: resolveStateOperationsModule(runtimeRoot);
|
|
2478
|
+
const census = readStateSummary({
|
|
2479
|
+
run,
|
|
2480
|
+
modulePath: censusModule,
|
|
2481
|
+
backupPath: backupStatePath,
|
|
2482
|
+
env: runtimeEnv,
|
|
2483
|
+
});
|
|
2484
|
+
if (census.ok) {
|
|
2485
|
+
preMigrationSummary = census.summary;
|
|
2486
|
+
const rows = Object.values(census.summary.tables).reduce((sum, n) => sum + (Number(n) || 0), 0);
|
|
2487
|
+
log(` Pre-migration census: schema v${census.summary.schemaVersion}, `
|
|
2488
|
+
+ `${Object.keys(census.summary.tables).length} tables, ${rows} rows.`);
|
|
2489
|
+
} else {
|
|
2490
|
+
log(` WARNING: no pre-migration state census could be taken (${census.error}).`);
|
|
2491
|
+
log(' Continuity verification will be skipped; the preserved-path check still applies.');
|
|
2492
|
+
}
|
|
1353
2493
|
// Park the artifact beside the backup so the recovery source outlives the
|
|
1354
2494
|
// temp directory and stays discoverable after the process exits.
|
|
1355
2495
|
if (artifactPath) {
|
|
@@ -1363,25 +2503,41 @@ function main(argv) {
|
|
|
1363
2503
|
fs.writeFileSync(path.join(backupDir, 'manifest.json'), `${JSON.stringify({
|
|
1364
2504
|
createdAt: new Date().toISOString(),
|
|
1365
2505
|
runtimeRoot,
|
|
2506
|
+
runtimeRootSource: rootDecision.source,
|
|
2507
|
+
runtimeRootReason: rootDecision.reason,
|
|
1366
2508
|
fromPackage: installedPublic ? PUBLIC_PACKAGE : (installedInternalVersion ? opts.packageName : null),
|
|
1367
2509
|
fromVersion: installedInternalVersion,
|
|
1368
2510
|
toPackage: opts.packageName,
|
|
1369
2511
|
toVersion: targetVersion,
|
|
1370
2512
|
artifact: artifactPath ? path.basename(artifactPath) : null,
|
|
1371
2513
|
preservedPaths: preservedBefore,
|
|
2514
|
+
// Everything below is retained, not transient: the artifacts listed here
|
|
2515
|
+
// are never removed by this run under any outcome.
|
|
2516
|
+
retainedArtifacts: [
|
|
2517
|
+
'state.db',
|
|
2518
|
+
...BACKUP_COPIED_FILES.filter(rel => fs.existsSync(path.join(backupDir, rel))),
|
|
2519
|
+
...retainedOriginals.retained,
|
|
2520
|
+
],
|
|
2521
|
+
servicesStopped,
|
|
2522
|
+
preMigrationSummary,
|
|
1372
2523
|
}, null, 2)}\n`);
|
|
1373
2524
|
log(` config/routing/pinned copied; manifest written.`);
|
|
1374
2525
|
if (artifactPath && backupArtifactParked) log(` Recovery artifact parked at ${artifactPath}`);
|
|
1375
2526
|
}
|
|
1376
2527
|
|
|
1377
|
-
//
|
|
2528
|
+
// 6. uninstall-public ──────────────────────────────────────────────────
|
|
1378
2529
|
let publicUninstalled = false;
|
|
2530
|
+
// Captured before `sync-init` and reused by the state rollback below, so
|
|
2531
|
+
// the restore path uses the SAME copier as the normal reseed repair.
|
|
2532
|
+
let reseedable = [];
|
|
1379
2533
|
|
|
1380
2534
|
/**
|
|
1381
2535
|
* Put the previous package back when a failed cutover would otherwise leave
|
|
1382
|
-
* the machine with no `minions` command. Package-scoped ONLY:
|
|
1383
|
-
* backup is never restored
|
|
1384
|
-
*
|
|
2536
|
+
* the machine with no `minions` command. Package-scoped ONLY: on this path
|
|
2537
|
+
* the state backup is never restored, so state written before or during the
|
|
2538
|
+
* cutover is neither rolled backward nor deleted. The state-restoring
|
|
2539
|
+
* rollback below is a separate, narrower path used only when continuity
|
|
2540
|
+
* verification proves state was actually lost.
|
|
1385
2541
|
*/
|
|
1386
2542
|
const rollbackPackageIfNeeded = () => {
|
|
1387
2543
|
const decision = assessRollback({
|
|
@@ -1418,7 +2574,7 @@ function main(argv) {
|
|
|
1418
2574
|
publicUninstalled = true;
|
|
1419
2575
|
}
|
|
1420
2576
|
|
|
1421
|
-
//
|
|
2577
|
+
// 7. repair-shims ──────────────────────────────────────────────────────
|
|
1422
2578
|
// npm has now removed whatever shims it still owned. Anything left under the
|
|
1423
2579
|
// prefix that names a Minions package with no usable install behind it is an
|
|
1424
2580
|
// ORPHAN — npm will not overwrite it and aborts the global install with
|
|
@@ -1450,7 +2606,7 @@ function main(argv) {
|
|
|
1450
2606
|
}
|
|
1451
2607
|
}
|
|
1452
2608
|
|
|
1453
|
-
//
|
|
2609
|
+
// 8. install-internal ──────────────────────────────────────────────────
|
|
1454
2610
|
if (steps.includes('install-internal')) {
|
|
1455
2611
|
log(`\n Installing ${opts.packageName}@${targetVersion} ...`);
|
|
1456
2612
|
let installed = false;
|
|
@@ -1492,7 +2648,7 @@ function main(argv) {
|
|
|
1492
2648
|
}
|
|
1493
2649
|
}
|
|
1494
2650
|
|
|
1495
|
-
//
|
|
2651
|
+
// 9. verify-install ────────────────────────────────────────────────────
|
|
1496
2652
|
const installedNow = readGlobalPackageVersion(npmRootG, opts.packageName);
|
|
1497
2653
|
if (installedNow !== targetVersion) {
|
|
1498
2654
|
console.error(`\n ERROR: expected ${opts.packageName}@${targetVersion} on disk, found ${installedNow || 'nothing'}.`);
|
|
@@ -1503,7 +2659,7 @@ function main(argv) {
|
|
|
1503
2659
|
}
|
|
1504
2660
|
log(` Verified ${opts.packageName}@${installedNow} on disk.`);
|
|
1505
2661
|
|
|
1506
|
-
//
|
|
2662
|
+
// 10. verify-shim ───────────────────────────────────────────────────────
|
|
1507
2663
|
// Owning the package directory is not the same as owning the `minions`
|
|
1508
2664
|
// command: a half-finished cutover can leave the shim wired to the public
|
|
1509
2665
|
// package while the internal one sits installed and unused.
|
|
@@ -1528,13 +2684,13 @@ function main(argv) {
|
|
|
1528
2684
|
|
|
1529
2685
|
const installedCliPath = path.join(resolveGlobalPackageRoot(npmRootG, opts.packageName), 'bin', 'minions.js');
|
|
1530
2686
|
|
|
1531
|
-
//
|
|
2687
|
+
// 11. sync-init ────────────────────────────────────────────────────────
|
|
1532
2688
|
// `minions init --force` overwrites every file the package ships, which
|
|
1533
2689
|
// includes `routing.md` and `knowledge/agents/*.md`. Those are operator- and
|
|
1534
2690
|
// agent-authored, so their bytes are captured here and put back below: this
|
|
1535
2691
|
// is a package-channel cutover, and the sync may only ADD newly required
|
|
1536
2692
|
// files and run the runtime's own idempotent startup migrations.
|
|
1537
|
-
|
|
2693
|
+
reseedable = snapshotReseedableFiles(runtimeRoot);
|
|
1538
2694
|
log('\n Synchronizing the runtime root (minions init --force)...');
|
|
1539
2695
|
try {
|
|
1540
2696
|
run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], {
|
|
@@ -1563,7 +2719,148 @@ function main(argv) {
|
|
|
1563
2719
|
}
|
|
1564
2720
|
if (preservedBefore.length) log(` Preserved: ${preservedBefore.join(', ')}`);
|
|
1565
2721
|
|
|
1566
|
-
//
|
|
2722
|
+
// 12. verify-continuity ────────────────────────────────────────────────
|
|
2723
|
+
// The preserved-path check above is presence-only: an emptied
|
|
2724
|
+
// `engine/state.db` passes it. This step re-runs the runtime's OWN
|
|
2725
|
+
// `summarizeState()` against the pinned root, out-of-process, and feeds
|
|
2726
|
+
// both censuses to its `compareStateSummaries`. Any regression, missing
|
|
2727
|
+
// table, or non-empty → empty transition is a hard failure that triggers a
|
|
2728
|
+
// state-restoring rollback.
|
|
2729
|
+
|
|
2730
|
+
/**
|
|
2731
|
+
* Restore the runtime to its pre-migration state and put the previous
|
|
2732
|
+
* package back. Unlike `rollbackPackageIfNeeded`, this IS state-scoped —
|
|
2733
|
+
* it is only reached when continuity verification proved state was lost, so
|
|
2734
|
+
* the retained original bytes are strictly better than what is on disk.
|
|
2735
|
+
*
|
|
2736
|
+
* Idempotent: every step re-copies the same retained bytes, so a re-run
|
|
2737
|
+
* converges on the same result rather than compounding.
|
|
2738
|
+
*/
|
|
2739
|
+
const rollbackStateAndPackage = () => {
|
|
2740
|
+
const report = { quiesced: null, state: null, copied: null, reseeded: [], reverified: null, restarted: false };
|
|
2741
|
+
const gate = assessStateRollback({ backupDir, retainedOriginals: retainedOriginals.retained });
|
|
2742
|
+
if (!gate.allowed) {
|
|
2743
|
+
console.error(` State rollback is not possible: ${gate.reason}.`);
|
|
2744
|
+
rollbackPackageIfNeeded();
|
|
2745
|
+
return report;
|
|
2746
|
+
}
|
|
2747
|
+
console.error(` ${gate.reason}.`);
|
|
2748
|
+
|
|
2749
|
+
// Nothing may hold the database we are about to overwrite.
|
|
2750
|
+
report.quiesced = quiesceServices({
|
|
2751
|
+
runtimeRoot,
|
|
2752
|
+
cliPath: resolveBackupCli({
|
|
2753
|
+
npmRootG,
|
|
2754
|
+
runtimeRoot,
|
|
2755
|
+
packageName: opts.packageName,
|
|
2756
|
+
installedInternalVersion: readGlobalPackageVersion(npmRootG, opts.packageName),
|
|
2757
|
+
installedPublic: !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE),
|
|
2758
|
+
}).path,
|
|
2759
|
+
run,
|
|
2760
|
+
env: runtimeEnv,
|
|
2761
|
+
log: console.error,
|
|
2762
|
+
});
|
|
2763
|
+
if (!report.quiesced.ok) {
|
|
2764
|
+
console.error(` ABORTED: ${report.quiesced.reason}. Nothing was restored — your backup is intact at ${backupDir}.`);
|
|
2765
|
+
for (const line of formatQuiescenceRefusal(report.quiesced)) console.error(line);
|
|
2766
|
+
return report;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
report.state = restoreOriginalStateFiles(runtimeRoot, backupDir);
|
|
2770
|
+
for (const rel of report.state.restored) console.error(` restored ${rel}`);
|
|
2771
|
+
for (const rel of report.state.removed) console.error(` removed ${rel} (the original run had none)`);
|
|
2772
|
+
for (const failure of report.state.failed) console.error(` FAILED ${failure.rel}: ${failure.error}`);
|
|
2773
|
+
|
|
2774
|
+
report.copied = restoreBackupCopiedFiles(runtimeRoot, backupDir);
|
|
2775
|
+
for (const rel of report.copied.restored) console.error(` restored ${rel}`);
|
|
2776
|
+
for (const failure of report.copied.failed) console.error(` FAILED ${failure.rel}: ${failure.error}`);
|
|
2777
|
+
|
|
2778
|
+
// Same copier as the normal reseed repair — one implementation, not two.
|
|
2779
|
+
report.reseeded = restoreReseededFiles(runtimeRoot, reseedable);
|
|
2780
|
+
for (const rel of report.reseeded) console.error(` restored ${rel}`);
|
|
2781
|
+
|
|
2782
|
+
rollbackPackageIfNeeded();
|
|
2783
|
+
|
|
2784
|
+
const restartCli = resolveBackupCli({
|
|
2785
|
+
npmRootG,
|
|
2786
|
+
runtimeRoot,
|
|
2787
|
+
packageName: opts.packageName,
|
|
2788
|
+
installedInternalVersion: readGlobalPackageVersion(npmRootG, opts.packageName),
|
|
2789
|
+
installedPublic: !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE),
|
|
2790
|
+
}).path;
|
|
2791
|
+
if (restartCli && opts.restart) {
|
|
2792
|
+
try {
|
|
2793
|
+
run(process.execPath, [restartCli, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS, env: runtimeEnv });
|
|
2794
|
+
report.restarted = true;
|
|
2795
|
+
console.error(' engine and dashboard restarted');
|
|
2796
|
+
} catch (e) {
|
|
2797
|
+
console.error(` could not restart automatically (${e.message}); run \`minions restart\` yourself`);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
// Re-verify: the restore is only credible if the census comes back.
|
|
2802
|
+
const after = readStateSummary({
|
|
2803
|
+
run,
|
|
2804
|
+
modulePath: resolveStateOperationsModule(runtimeRoot),
|
|
2805
|
+
env: runtimeEnv,
|
|
2806
|
+
});
|
|
2807
|
+
report.reverified = after.ok
|
|
2808
|
+
? compareStateContinuity({
|
|
2809
|
+
run,
|
|
2810
|
+
modulePath: resolveStateOperationsModule(runtimeRoot),
|
|
2811
|
+
before: preMigrationSummary,
|
|
2812
|
+
after: after.summary,
|
|
2813
|
+
env: runtimeEnv,
|
|
2814
|
+
})
|
|
2815
|
+
: { ok: false, comparison: null, error: after.error };
|
|
2816
|
+
if (report.reverified.ok && report.reverified.comparison.ok) {
|
|
2817
|
+
console.error(' Rollback verified: the restored state matches the pre-migration census.');
|
|
2818
|
+
} else {
|
|
2819
|
+
console.error(` Rollback could NOT be verified (${report.reverified.comparison
|
|
2820
|
+
? describeContinuityFailure(report.reverified.comparison)
|
|
2821
|
+
: report.reverified.error}).`);
|
|
2822
|
+
console.error(` Restore by hand from ${backupDir}.`);
|
|
2823
|
+
}
|
|
2824
|
+
return report;
|
|
2825
|
+
};
|
|
2826
|
+
|
|
2827
|
+
if (steps.includes('verify-continuity') && preMigrationSummary) {
|
|
2828
|
+
log('\n Verifying state continuity (row-level census, before vs after)...');
|
|
2829
|
+
const postModule = resolveStateOperationsModule(runtimeRoot);
|
|
2830
|
+
const after = readStateSummary({ run, modulePath: postModule, env: runtimeEnv });
|
|
2831
|
+
const continuity = after.ok
|
|
2832
|
+
? compareStateContinuity({
|
|
2833
|
+
run,
|
|
2834
|
+
modulePath: postModule,
|
|
2835
|
+
before: preMigrationSummary,
|
|
2836
|
+
after: after.summary,
|
|
2837
|
+
env: runtimeEnv,
|
|
2838
|
+
})
|
|
2839
|
+
: { ok: false, comparison: null, error: after.error };
|
|
2840
|
+
if (!continuity.ok) {
|
|
2841
|
+
// Could not verify is NOT verified — but it is also not proof of loss,
|
|
2842
|
+
// so it warns loudly and leaves the retained originals in place rather
|
|
2843
|
+
// than rolling a healthy runtime backward on missing evidence.
|
|
2844
|
+
log(` WARNING: state continuity could not be verified (${continuity.error}).`);
|
|
2845
|
+
log(` The pre-migration state is retained at ${path.join(backupDir, RETAINED_ORIGINAL_DIR)}.`);
|
|
2846
|
+
} else if (!continuity.comparison.ok) {
|
|
2847
|
+
console.error(`\n ERROR: state was lost during the migration — ${describeContinuityFailure(continuity.comparison)}.`);
|
|
2848
|
+
rollbackStateAndPackage();
|
|
2849
|
+
console.error(`\n Your pre-migration state remains at ${backupDir} (nothing there is ever deleted).\n`);
|
|
2850
|
+
return EXIT.FAILED;
|
|
2851
|
+
} else {
|
|
2852
|
+
const rows = Object.values(after.summary.tables).reduce((sum, n) => sum + (Number(n) || 0), 0);
|
|
2853
|
+
log(` Continuity verified: schema v${after.summary.schemaVersion}, `
|
|
2854
|
+
+ `${Object.keys(after.summary.tables).length} tables, ${rows} rows; no table regressed.`);
|
|
2855
|
+
}
|
|
2856
|
+
} else if (steps.includes('verify-continuity')) {
|
|
2857
|
+
log('\n Continuity verification skipped: no pre-migration census was available.');
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// 13. restart ──────────────────────────────────────────────────────────
|
|
2861
|
+
// Last, and only on state that verify-continuity already proved survived:
|
|
2862
|
+
// the machine is never brought back up on state this run cannot vouch for,
|
|
2863
|
+
// and no post-restart write can be destroyed by a state-restoring rollback.
|
|
1567
2864
|
if (steps.includes('restart')) {
|
|
1568
2865
|
log('\n Restarting engine and dashboard (health-verified)...');
|
|
1569
2866
|
try {
|
|
@@ -1578,14 +2875,19 @@ function main(argv) {
|
|
|
1578
2875
|
}
|
|
1579
2876
|
|
|
1580
2877
|
log(`\n Done. ${opts.packageName}@${installedNow} is installed at ${runtimeRoot}.`);
|
|
1581
|
-
if (backupDir)
|
|
2878
|
+
if (backupDir) {
|
|
2879
|
+
log(` Pre-migration backup: ${backupDir}`);
|
|
2880
|
+
if (retainedOriginals.retained.length) {
|
|
2881
|
+
log(` Original state retained: ${retainedOriginals.retained.join(', ')} (never removed by this installer)`);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
1582
2884
|
log('');
|
|
1583
2885
|
return EXIT.OK;
|
|
1584
2886
|
} catch (e) {
|
|
1585
2887
|
console.error(`\n ERROR: ${redact(e && e.message ? e.message : String(e), secrets)}\n`);
|
|
1586
2888
|
return EXIT.FAILED;
|
|
1587
2889
|
} finally {
|
|
1588
|
-
//
|
|
2890
|
+
// 14. cleanup — runs on success and on every failure path, including the
|
|
1589
2891
|
// early `return`s above, so the token file never outlives the process. The
|
|
1590
2892
|
// temp artifact copy goes too; when there was a runtime to back up, the
|
|
1591
2893
|
// recovery tarball was already parked in the backup directory.
|
|
@@ -1609,12 +2911,20 @@ module.exports = {
|
|
|
1609
2911
|
EXIT,
|
|
1610
2912
|
PRESERVED_PATHS,
|
|
1611
2913
|
INIT_RESEEDABLE_PATHS,
|
|
2914
|
+
STATE_DB_FILES,
|
|
2915
|
+
BACKUP_COPIED_FILES,
|
|
2916
|
+
RETAINED_ORIGINAL_DIR,
|
|
2917
|
+
CONTINUITY_DURABLE_TABLES,
|
|
2918
|
+
resolveContinuityIgnoreTables,
|
|
1612
2919
|
SHIM_OWNER_PACKAGES,
|
|
1613
2920
|
STEP_ORDER,
|
|
1614
2921
|
parseArgs,
|
|
1615
2922
|
normalizeRegistry,
|
|
1616
2923
|
registryAuthKey,
|
|
1617
2924
|
packageScope,
|
|
2925
|
+
INTERNAL_FEED_CHANNELS,
|
|
2926
|
+
resolveInternalFeed,
|
|
2927
|
+
buildScopeRegistryArg,
|
|
1618
2928
|
buildTokenArgs,
|
|
1619
2929
|
buildNpmrc,
|
|
1620
2930
|
buildNpmViewArgs,
|
|
@@ -1636,7 +2946,9 @@ module.exports = {
|
|
|
1636
2946
|
quoteWindowsArg,
|
|
1637
2947
|
resolveSpawnTarget,
|
|
1638
2948
|
redact,
|
|
1639
|
-
|
|
2949
|
+
resolveAuthoritativeRuntimeRoot,
|
|
2950
|
+
runtimeRootPointerPath,
|
|
2951
|
+
findCheckoutRoot,
|
|
1640
2952
|
resolveBackupCliCandidates,
|
|
1641
2953
|
resolveBackupCli,
|
|
1642
2954
|
resolveBackupStrategy,
|
|
@@ -1644,6 +2956,27 @@ module.exports = {
|
|
|
1644
2956
|
diffPreservedPaths,
|
|
1645
2957
|
snapshotReseedableFiles,
|
|
1646
2958
|
restoreReseededFiles,
|
|
2959
|
+
readServicePids,
|
|
2960
|
+
detectRunningServices,
|
|
2961
|
+
readStateShm,
|
|
2962
|
+
assessDatabaseQuiescence,
|
|
2963
|
+
WHOLE_STACK_STOP_ARGS,
|
|
2964
|
+
LEGACY_STOP_ARGS,
|
|
2965
|
+
CLI_CAPABILITY_PROBE_ARGS,
|
|
2966
|
+
STOP_PATH_LABELS,
|
|
2967
|
+
detectStopCapabilities,
|
|
2968
|
+
probeStopCapability,
|
|
2969
|
+
formatQuiescenceRefusal,
|
|
2970
|
+
quiesceServices,
|
|
2971
|
+
retainOriginalStateFiles,
|
|
2972
|
+
restoreOriginalStateFiles,
|
|
2973
|
+
restoreBackupCopiedFiles,
|
|
2974
|
+
resolveStateOperationsModule,
|
|
2975
|
+
extractEmittedJson,
|
|
2976
|
+
readStateSummary,
|
|
2977
|
+
compareStateContinuity,
|
|
2978
|
+
describeContinuityFailure,
|
|
2979
|
+
assessStateRollback,
|
|
1647
2980
|
assessRollback,
|
|
1648
2981
|
countActiveAgents,
|
|
1649
2982
|
assessAgentActivity,
|
|
@@ -1651,6 +2984,11 @@ module.exports = {
|
|
|
1651
2984
|
planSteps,
|
|
1652
2985
|
writeTempNpmrc,
|
|
1653
2986
|
cleanupTempNpmrc,
|
|
2987
|
+
makeRunner,
|
|
2988
|
+
readUserNpmrc,
|
|
2989
|
+
parseViewedVersion,
|
|
2990
|
+
describeInternalFeedFailure,
|
|
2991
|
+
openInternalFeedSession,
|
|
1654
2992
|
main,
|
|
1655
2993
|
};
|
|
1656
2994
|
|