agent-dealer 0.2.0 → 1.0.0
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/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
- package/bundle/server/dist/adapters/agent-deck.js +38 -50
- package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
- package/bundle/server/dist/adapters/agent-health.js +230 -33
- package/bundle/server/dist/adapters/agent-health.test.js +237 -0
- package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
- package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
- package/bundle/server/dist/adapters/external.js +0 -1
- package/bundle/server/dist/adapters/git-worktree.js +514 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
- package/bundle/server/dist/adapters/github.js +170 -0
- package/bundle/server/dist/adapters/github.test.js +205 -0
- package/bundle/server/dist/adapters/linear-inbox.js +199 -30
- package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
- package/bundle/server/dist/adapters/linear-sync.js +3 -101
- package/bundle/server/dist/adapters/managed-repo.js +168 -0
- package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
- package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
- package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
- package/bundle/server/dist/adapters/reflect-authority.js +43 -0
- package/bundle/server/dist/cli-env.js +26 -0
- package/bundle/server/dist/cli-env.test.js +14 -1
- package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
- package/bundle/server/dist/coordinator/admission.js +330 -0
- package/bundle/server/dist/coordinator/admission.test.js +338 -0
- package/bundle/server/dist/coordinator/args.js +128 -0
- package/bundle/server/dist/coordinator/args.test.js +132 -0
- package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/auto-merge.js +286 -0
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
- package/bundle/server/dist/coordinator/branch-progress.js +81 -0
- package/bundle/server/dist/coordinator/clock-jump.js +98 -0
- package/bundle/server/dist/coordinator/commands.js +1088 -0
- package/bundle/server/dist/coordinator/commands.test.js +554 -0
- package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
- package/bundle/server/dist/coordinator/dependencies.js +242 -0
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
- package/bundle/server/dist/coordinator/developer-effect.js +959 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
- package/bundle/server/dist/coordinator/dispatcher.js +58 -0
- package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
- package/bundle/server/dist/coordinator/effect-registry.js +17 -0
- package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
- package/bundle/server/dist/coordinator/failure-reason.js +273 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
- package/bundle/server/dist/coordinator/guidance.js +33 -0
- package/bundle/server/dist/coordinator/guidance.test.js +110 -0
- package/bundle/server/dist/coordinator/human-resolution.js +88 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
- package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
- package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
- package/bundle/server/dist/coordinator/latest-failure.js +100 -0
- package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
- package/bundle/server/dist/coordinator/metrics.js +35 -0
- package/bundle/server/dist/coordinator/metrics.test.js +52 -0
- package/bundle/server/dist/coordinator/permissions.js +127 -0
- package/bundle/server/dist/coordinator/permissions.test.js +184 -0
- package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
- package/bundle/server/dist/coordinator/process-liveness.js +191 -0
- package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
- package/bundle/server/dist/coordinator/projection.js +147 -0
- package/bundle/server/dist/coordinator/projection.test.js +121 -0
- package/bundle/server/dist/coordinator/prompts.js +164 -0
- package/bundle/server/dist/coordinator/prompts.test.js +177 -0
- package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
- package/bundle/server/dist/coordinator/recovery.js +466 -0
- package/bundle/server/dist/coordinator/recovery.test.js +164 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
- package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
- package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
- package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
- package/bundle/server/dist/coordinator/routing.js +160 -0
- package/bundle/server/dist/coordinator/routing.test.js +336 -0
- package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
- package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
- package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
- package/bundle/server/dist/coordinator/session-progress.js +351 -0
- package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
- package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
- package/bundle/server/dist/coordinator/spawn.js +49 -0
- package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
- package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
- package/bundle/server/dist/coordinator/usage.js +40 -0
- package/bundle/server/dist/coordinator/usage.test.js +69 -0
- package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
- package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
- package/bundle/server/dist/coordinator/worker-loop.js +363 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
- package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
- package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
- package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
- package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
- package/bundle/server/dist/db/index.js +359 -7
- package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
- package/bundle/server/dist/db/migrate-to-issues.js +824 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
- package/bundle/server/dist/db/schema.sql +311 -1
- package/bundle/server/dist/db/schema.test.js +66 -0
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
- package/bundle/server/dist/index.js +58 -5
- package/bundle/server/dist/paths.js +40 -4
- package/bundle/server/dist/paths.test.js +42 -0
- package/bundle/server/dist/pid-marker-claim-child.js +18 -0
- package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
- package/bundle/server/dist/pid-marker.js +141 -0
- package/bundle/server/dist/pid-marker.test.js +208 -0
- package/bundle/server/dist/queue/approve-deliver.js +128 -28
- package/bundle/server/dist/queue/reflect-schedule.js +25 -0
- package/bundle/server/dist/queue/result-qa.js +1 -1
- package/bundle/server/dist/queue/send-gate.test.js +172 -36
- package/bundle/server/dist/repository/agents.js +55 -18
- package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
- package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
- package/bundle/server/dist/repository/artifacts.js +47 -0
- package/bundle/server/dist/repository/artifacts.test.js +48 -0
- package/bundle/server/dist/repository/authority-attempts.js +189 -0
- package/bundle/server/dist/repository/findings.js +70 -0
- package/bundle/server/dist/repository/findings.test.js +68 -0
- package/bundle/server/dist/repository/human-actions.js +142 -0
- package/bundle/server/dist/repository/human-actions.test.js +123 -0
- package/bundle/server/dist/repository/intake-settings.js +5 -59
- package/bundle/server/dist/repository/issues.js +305 -0
- package/bundle/server/dist/repository/issues.test.js +69 -0
- package/bundle/server/dist/repository/outbound-drafts.js +28 -0
- package/bundle/server/dist/repository/queue-entries.js +162 -0
- package/bundle/server/dist/repository/review-publications.js +153 -0
- package/bundle/server/dist/repository/review-publications.test.js +78 -0
- package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
- package/bundle/server/dist/repository/runs.js +4 -211
- package/bundle/server/dist/repository/runtime-availability.js +54 -0
- package/bundle/server/dist/repository/usage-events.js +60 -0
- package/bundle/server/dist/repository/usage-events.test.js +56 -0
- package/bundle/server/dist/repository/work-items.js +305 -0
- package/bundle/server/dist/repository/work-items.test.js +142 -0
- package/bundle/server/dist/repository/worker-sessions.js +219 -0
- package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
- package/bundle/server/dist/repository/workflow-events.js +193 -0
- package/bundle/server/dist/repository/workflow-events.test.js +89 -0
- package/bundle/server/dist/resolve-tsx-bin.js +19 -0
- package/bundle/server/dist/routes/human-actions.js +68 -0
- package/bundle/server/dist/routes/human-actions.test.js +284 -0
- package/bundle/server/dist/routes/index.js +29 -496
- package/bundle/server/dist/routes/issues.js +240 -0
- package/bundle/server/dist/routes/issues.test.js +451 -0
- package/bundle/server/dist/routes/queue.js +41 -0
- package/bundle/server/dist/routes/queue.test.js +78 -0
- package/bundle/server/dist/runners/claude-args.js +18 -30
- package/bundle/server/dist/runners/claude.js +19 -163
- package/bundle/server/dist/runners/claude.test.js +15 -70
- package/bundle/server/dist/runners/codex-args.js +59 -0
- package/bundle/server/dist/runners/codex-args.test.js +72 -0
- package/bundle/server/dist/runners/codex-jsonl.js +150 -0
- package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
- package/bundle/server/dist/runners/codex-resume.test.js +19 -0
- package/bundle/server/dist/runners/codex.js +46 -0
- package/bundle/server/dist/runners/models-codex.test.js +14 -0
- package/bundle/server/dist/runners/models.js +53 -1
- package/bundle/server/dist/runners/persist.js +4 -1
- package/bundle/server/dist/runners/process-registry.js +11 -0
- package/bundle/server/dist/runners/prompts.js +13 -280
- package/bundle/server/dist/runners/prompts.test.js +22 -153
- package/bundle/server/dist/runners/qa.js +17 -1
- package/bundle/server/dist/runners/reflect.js +4 -16
- package/bundle/server/dist/runners/spawn-cli.js +197 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
- package/bundle/server/dist/runners/stream-json.js +0 -141
- package/bundle/server/dist/runners/usage-cap.js +242 -0
- package/bundle/server/dist/runners/usage-cap.test.js +97 -0
- package/bundle/server/dist/server-liveness.js +37 -0
- package/bundle/server/dist/server-liveness.test.js +82 -0
- package/bundle/server/dist/static-ui.test.js +44 -0
- package/bundle/server/package.json +4 -2
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
- package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +262 -133
- package/bundle/shared/dist/agents.js +85 -19
- package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
- package/bundle/shared/dist/cursor-auth-health.js +36 -0
- package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
- package/bundle/shared/dist/findings.d.ts +46 -0
- package/bundle/shared/dist/findings.js +17 -0
- package/bundle/shared/dist/findings.test.d.ts +1 -0
- package/bundle/shared/dist/findings.test.js +20 -0
- package/bundle/shared/dist/github-repo.d.ts +28 -0
- package/bundle/shared/dist/github-repo.js +98 -0
- package/bundle/shared/dist/github-repo.test.d.ts +1 -0
- package/bundle/shared/dist/github-repo.test.js +32 -0
- package/bundle/shared/dist/human-actions.d.ts +66 -0
- package/bundle/shared/dist/human-actions.js +51 -0
- package/bundle/shared/dist/human-actions.test.d.ts +1 -0
- package/bundle/shared/dist/human-actions.test.js +57 -0
- package/bundle/shared/dist/index.d.ts +567 -757
- package/bundle/shared/dist/index.js +22 -36
- package/bundle/shared/dist/issues.d.ts +385 -0
- package/bundle/shared/dist/issues.js +169 -0
- package/bundle/shared/dist/issues.test.d.ts +1 -0
- package/bundle/shared/dist/issues.test.js +72 -0
- package/bundle/shared/dist/outbound-draft.d.ts +6 -0
- package/bundle/shared/dist/outbound-draft.js +4 -0
- package/bundle/shared/dist/plan-triage.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
- package/bundle/shared/dist/profile-snapshot.js +145 -0
- package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
- package/bundle/shared/dist/profile-snapshot.test.js +67 -0
- package/bundle/shared/dist/queue-entries.d.ts +89 -0
- package/bundle/shared/dist/queue-entries.js +19 -0
- package/bundle/shared/dist/result-qa.d.ts +2 -2
- package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
- package/bundle/shared/dist/runtime-auth-health.js +186 -0
- package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
- package/bundle/shared/dist/runtime-availability.d.ts +51 -0
- package/bundle/shared/dist/runtime-availability.js +13 -0
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +36 -0
- package/bundle/shared/dist/usage-events.js +13 -0
- package/bundle/shared/dist/worker-sessions.d.ts +140 -0
- package/bundle/shared/dist/worker-sessions.js +70 -0
- package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
- package/bundle/shared/dist/worker-sessions.test.js +39 -0
- package/bundle/shared/dist/workflow.d.ts +78 -0
- package/bundle/shared/dist/workflow.js +59 -0
- package/bundle/shared/dist/workflow.test.d.ts +1 -0
- package/bundle/shared/dist/workflow.test.js +61 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.d.ts +1 -0
- package/dist/action.js +50 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +70 -0
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +17 -0
- package/dist/agent.test.d.ts +1 -0
- package/dist/agent.test.js +19 -0
- package/dist/cli-check.d.ts +7 -0
- package/dist/cli-check.js +40 -0
- package/dist/doctor.js +59 -1
- package/dist/http.d.ts +5 -0
- package/dist/http.js +19 -0
- package/dist/index.js +28 -0
- package/dist/issue.d.ts +35 -0
- package/dist/issue.js +143 -0
- package/dist/issue.test.d.ts +1 -0
- package/dist/issue.test.js +240 -0
- package/dist/lifecycle.contract.test.d.ts +1 -0
- package/dist/lifecycle.contract.test.js +135 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +60 -0
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +46 -0
- package/dist/test-fetch-stub.d.ts +6 -0
- package/dist/test-fetch-stub.js +31 -0
- package/package.json +2 -1
- package/templates/prod.env.example +1 -1
- package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
- package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
// packages/server/src/db/migrate-to-issues.ts
|
|
3
|
+
//
|
|
4
|
+
// NOT-66: the one-time, explicit cutover from the legacy single-agent `runs` model to the
|
|
5
|
+
// issue-centric developer/reviewer model (design doc §"Migration and cutover"). This is
|
|
6
|
+
// NOT the additive, idempotent-at-every-startup `migrate()` in ./index.ts — it is an
|
|
7
|
+
// operator-invoked script that renames the legacy tables away, so it must refuse to run
|
|
8
|
+
// twice and must refuse to run against a live service.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// tsx src/db/migrate-to-issues.ts <path-to-dealer.db> # run the cutover
|
|
12
|
+
// tsx src/db/migrate-to-issues.ts <path-to-dealer.db> --rollback # undo it
|
|
13
|
+
//
|
|
14
|
+
// Rollback procedure (documented here, exercised by an automated test below):
|
|
15
|
+
// The cutover's first act is an exclusive copy of the database to
|
|
16
|
+
// `<dbPath>.pre-issue-migration-backup` *before* any table is touched. To undo a
|
|
17
|
+
// completed migration, stop the service and run this script again with `--rollback`:
|
|
18
|
+
// it restores that backup file over the live database, verbatim. Rollback itself
|
|
19
|
+
// refuses to run against a live service, for the same reason the forward migration does.
|
|
20
|
+
//
|
|
21
|
+
// Scope note: this script only migrates data and creates the `legacy_v0_*` audit tables.
|
|
22
|
+
// It does not touch any runtime route, the dispatcher, or the frontend nav — the ticket
|
|
23
|
+
// explicitly scopes "remove legacy writers" as a separate, later action. The legacy
|
|
24
|
+
// Operations/Inbox/Done UI and its routes keep working against the *renamed* legacy
|
|
25
|
+
// tables' data having already moved into the issue model; the legacy runtime writers
|
|
26
|
+
// themselves are untouched by this script and keep writing to a fresh, empty `runs` table
|
|
27
|
+
// (recreated by schema.sql on next startup) until that follow-up work removes them.
|
|
28
|
+
import Database from "better-sqlite3";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { v4 as uuid } from "uuid";
|
|
32
|
+
import { RunStatus } from "@agent-dealer/shared";
|
|
33
|
+
import { claimPidMarker, readPidMarkerOwner, releasePidMarker } from "../pid-marker.js";
|
|
34
|
+
import { responseOptionsFor } from "../coordinator/commands.js";
|
|
35
|
+
function emptyReport() {
|
|
36
|
+
return {
|
|
37
|
+
issuesCreated: 0,
|
|
38
|
+
legacySessionsCreated: 0,
|
|
39
|
+
artifactsRepointed: 0,
|
|
40
|
+
eventsRepointed: 0,
|
|
41
|
+
mismatches: [],
|
|
42
|
+
alreadyMigrated: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Groups every run by COALESCE(lineage_id, id) — one bucket per historical lineage. */
|
|
46
|
+
function groupByLineage(runs) {
|
|
47
|
+
const groups = new Map();
|
|
48
|
+
for (const run of runs) {
|
|
49
|
+
const key = run.lineage_id ?? run.id;
|
|
50
|
+
const bucket = groups.get(key) ?? [];
|
|
51
|
+
bucket.push(run);
|
|
52
|
+
groups.set(key, bucket);
|
|
53
|
+
}
|
|
54
|
+
return groups;
|
|
55
|
+
}
|
|
56
|
+
function latestRun(runs) {
|
|
57
|
+
return [...runs].sort((a, b) => a.created_at.localeCompare(b.created_at))[runs.length - 1];
|
|
58
|
+
}
|
|
59
|
+
/** Legacy worker_session.status follows the same bucket as the issue-status mapping below. */
|
|
60
|
+
function legacySessionStatus(runStatus) {
|
|
61
|
+
if (runStatus === "done" || runStatus === "review")
|
|
62
|
+
return "done";
|
|
63
|
+
if (runStatus === "failed")
|
|
64
|
+
return "failed";
|
|
65
|
+
return "cancelled"; // cancelled, queued, plan_pending, plan_approved, running
|
|
66
|
+
}
|
|
67
|
+
function issueStatusForLatest(runStatus) {
|
|
68
|
+
switch (runStatus) {
|
|
69
|
+
case "done":
|
|
70
|
+
return { status: "done", owner: "system" };
|
|
71
|
+
case "cancelled":
|
|
72
|
+
return { status: "closed", owner: "system" };
|
|
73
|
+
case "review":
|
|
74
|
+
return {
|
|
75
|
+
status: "final_review",
|
|
76
|
+
owner: "human",
|
|
77
|
+
humanAction: {
|
|
78
|
+
actionType: "final_review",
|
|
79
|
+
reason: "Migrated from a legacy run awaiting result review",
|
|
80
|
+
question: "Accept this legacy work?",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
case "failed":
|
|
84
|
+
return {
|
|
85
|
+
status: "needs_human",
|
|
86
|
+
owner: "human",
|
|
87
|
+
humanAction: {
|
|
88
|
+
actionType: "attempts_exhausted",
|
|
89
|
+
reason: "Migrated from a legacy failed run",
|
|
90
|
+
question: "This legacy run failed — how should it be resolved?",
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
default:
|
|
94
|
+
// queued, plan_pending, plan_approved, running
|
|
95
|
+
return { status: "ready", owner: "system" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The live app runs in WAL mode (getDb() in ./index.ts), which can leave recently
|
|
100
|
+
* committed data sitting only in the `<dbPath>-wal` sidecar file, not yet folded into the
|
|
101
|
+
* main database file. A plain `fs.copyFileSync(dbPath, ...)` of the main file alone would
|
|
102
|
+
* then silently miss that data (or, worse, produce a backup that is missing tables
|
|
103
|
+
* entirely if the schema itself was created since the last checkpoint). Checkpointing
|
|
104
|
+
* with TRUNCATE folds everything into the main file and empties the WAL, so a bare file
|
|
105
|
+
* copy of `dbPath` alone is always a complete, self-contained snapshot.
|
|
106
|
+
*/
|
|
107
|
+
function checkpointAndTruncate(dbPath) {
|
|
108
|
+
const db = new Database(dbPath);
|
|
109
|
+
try {
|
|
110
|
+
// No point waiting on SQLite's default busy retry loop — a conflicting reader isn't
|
|
111
|
+
// expected to release its snapshot imminently, and this function's job is to fail
|
|
112
|
+
// fast and clearly rather than block the whole migration on an indeterminate wait.
|
|
113
|
+
db.pragma("busy_timeout = 0");
|
|
114
|
+
// wal_checkpoint(TRUNCATE) reports failure via its result row, not by throwing: `busy`
|
|
115
|
+
// is nonzero when some other connection's open read transaction pinned WAL frames the
|
|
116
|
+
// checkpoint could not fold into the main file. Copying the main file in that state
|
|
117
|
+
// produces a backup silently missing whatever those unfoldable frames contained —
|
|
118
|
+
// exactly the rollback guarantee this function exists to protect. Refuse rather than
|
|
119
|
+
// guess; the caller can retry once nothing else holds the database open.
|
|
120
|
+
const [result] = db.pragma("wal_checkpoint(TRUNCATE)");
|
|
121
|
+
if (result.busy !== 0) {
|
|
122
|
+
throw new Error(`refusing to back up: WAL checkpoint could not complete (busy=${result.busy}, ` +
|
|
123
|
+
`log=${result.log} frames, checkpointed=${result.checkpointed}) — another connection ` +
|
|
124
|
+
`is holding a conflicting read lock on ${dbPath}. A backup taken now could silently ` +
|
|
125
|
+
`miss recent commits. Retry once nothing else is reading the database.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
db.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Removes a possibly-stale WAL/SHM sidecar so a later connection never replays frames
|
|
133
|
+
* left over from before a file at this path was overwritten (e.g. by rollback). */
|
|
134
|
+
function removeWalSidecars(dbPath) {
|
|
135
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(`${dbPath}${suffix}`);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* fine if absent */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function tableExists(db, name) {
|
|
145
|
+
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
|
|
146
|
+
}
|
|
147
|
+
function columnsOf(db, table) {
|
|
148
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
149
|
+
return new Set(rows.map((r) => r.name));
|
|
150
|
+
}
|
|
151
|
+
const REQUIRED_LEGACY_COLUMNS = {
|
|
152
|
+
runs: [
|
|
153
|
+
"id",
|
|
154
|
+
"source",
|
|
155
|
+
"external_id",
|
|
156
|
+
"external_label",
|
|
157
|
+
"title",
|
|
158
|
+
"description",
|
|
159
|
+
"repo",
|
|
160
|
+
"agent_id",
|
|
161
|
+
"status",
|
|
162
|
+
"lineage_id",
|
|
163
|
+
"acceptance_criteria",
|
|
164
|
+
"runtime",
|
|
165
|
+
"plan_model",
|
|
166
|
+
"execute_model",
|
|
167
|
+
"budget_json",
|
|
168
|
+
"created_at",
|
|
169
|
+
"updated_at",
|
|
170
|
+
],
|
|
171
|
+
artifacts: ["id", "run_id", "kind", "content_json", "blob_path", "author", "created_at", "issue_id", "worker_session_id"],
|
|
172
|
+
events: ["id", "run_id", "type", "payload_json", "ts"],
|
|
173
|
+
};
|
|
174
|
+
const REQUIRED_TARGET_TABLES = [
|
|
175
|
+
"issues",
|
|
176
|
+
"worker_sessions",
|
|
177
|
+
"workflow_instances",
|
|
178
|
+
"workflow_events",
|
|
179
|
+
"human_actions",
|
|
180
|
+
"agents",
|
|
181
|
+
];
|
|
182
|
+
/**
|
|
183
|
+
* Refuses to guess: validates the legacy `runs`/`artifacts`/`events` shape this script
|
|
184
|
+
* depends on, and that the issue-centric target tables already exist (created by the
|
|
185
|
+
* ordinary additive `migrate()` in ./index.ts, which every server startup already runs).
|
|
186
|
+
* Returns a description of the first problem found, or null when the schema is safe to
|
|
187
|
+
* migrate. Called *before* the backup is created — a bad schema should never even cost a
|
|
188
|
+
* backup copy.
|
|
189
|
+
*/
|
|
190
|
+
function validateLegacySchema(db) {
|
|
191
|
+
for (const table of ["runs", "artifacts", "events", "approval_gates"]) {
|
|
192
|
+
if (!tableExists(db, table)) {
|
|
193
|
+
return `expected legacy table "${table}" not found — this database may already be migrated or is not an agent-dealer database`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const table of REQUIRED_TARGET_TABLES) {
|
|
197
|
+
if (!tableExists(db, table)) {
|
|
198
|
+
return `expected issue-centric table "${table}" not found — run the ordinary server migration (npm run db:migrate) first`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const [table, cols] of Object.entries(REQUIRED_LEGACY_COLUMNS)) {
|
|
202
|
+
const present = columnsOf(db, table);
|
|
203
|
+
const missing = cols.filter((c) => !present.has(c));
|
|
204
|
+
if (missing.length > 0) {
|
|
205
|
+
return `table "${table}" is missing expected column(s): ${missing.join(", ")}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// issueStatusForLatest()/legacySessionStatus() have a `default:` branch that folds any
|
|
209
|
+
// unrecognized status into "ready"/"cancelled" — appropriate for the *known* pre-terminal
|
|
210
|
+
// statuses that fall into it (queued/plan_pending/plan_approved/running), but silently
|
|
211
|
+
// wrong for a genuinely unexpected value (a future/older/corrupt status this script has
|
|
212
|
+
// never seen), which would otherwise be converted into ordinary runnable work instead of
|
|
213
|
+
// being flagged. Validate every distinct value up front against the full closed set.
|
|
214
|
+
const distinctStatuses = db.prepare("SELECT DISTINCT status FROM runs").all().map((r) => r.status);
|
|
215
|
+
const unknown = distinctStatuses.filter((s) => !RunStatus.safeParse(s).success);
|
|
216
|
+
if (unknown.length > 0) {
|
|
217
|
+
const offendingIds = db
|
|
218
|
+
.prepare(`SELECT id FROM runs WHERE status IN (${unknown.map(() => "?").join(",")})`)
|
|
219
|
+
.all(...unknown).map((r) => r.id);
|
|
220
|
+
return `runs table contains unrecognized status value(s) ${unknown.map((s) => `"${s}"`).join(", ")} (run ids: ${offendingIds.join(", ")}) — refusing to guess how to migrate them`;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Resolves symlinks so every derived path (server.pid, run.json, the backup, WAL
|
|
226
|
+
* sidecars) is anchored to the database's *real* directory, not wherever a symlink to it
|
|
227
|
+
* happens to live. Without this, `tsx src/db/migrate-to-issues.ts /some/alias/dealer.db`
|
|
228
|
+
* (a symlink to the real, live dealer.db elsewhere) would derive server.pid next to the
|
|
229
|
+
* alias — a path the running server never wrote to — so both the live-service check and
|
|
230
|
+
* the exclusive claim would incorrectly see nothing there and proceed to mutate the very
|
|
231
|
+
* database the live server is using. Falls back to a plain absolute-path resolution when
|
|
232
|
+
* the target doesn't exist (rollbackMigration may be pointed at a path that hasn't been
|
|
233
|
+
* created yet in some caller), matching the old, unresolved-path behavior for that case
|
|
234
|
+
* rather than throwing.
|
|
235
|
+
*/
|
|
236
|
+
function canonicalDbPath(dbPath) {
|
|
237
|
+
try {
|
|
238
|
+
return fs.realpathSync(dbPath);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return path.resolve(dbPath);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function serverPidPathFor(dbPath) {
|
|
245
|
+
return path.join(path.dirname(dbPath), "server.pid");
|
|
246
|
+
}
|
|
247
|
+
function runStatePathFor(dbPath) {
|
|
248
|
+
return path.join(path.dirname(dbPath), "run.json");
|
|
249
|
+
}
|
|
250
|
+
/** run.json uses `serverPid`, not `pid` — packages/cli/src/runtime-state.ts's RunState
|
|
251
|
+
* shape, kept as its own reader since it is a different file this script never claims. */
|
|
252
|
+
function readRunStateOwner(dbPath) {
|
|
253
|
+
const owner = readPidMarkerOwner(runStatePathFor(dbPath));
|
|
254
|
+
if (owner)
|
|
255
|
+
return owner;
|
|
256
|
+
// readPidMarkerOwner() only understands the `pid` field; re-check run.json's own
|
|
257
|
+
// `serverPid` field directly rather than duplicating the alive-probe logic here.
|
|
258
|
+
const filePath = runStatePathFor(dbPath);
|
|
259
|
+
if (!fs.existsSync(filePath))
|
|
260
|
+
return null;
|
|
261
|
+
try {
|
|
262
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
263
|
+
const pid = raw.serverPid;
|
|
264
|
+
if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0)
|
|
265
|
+
return null;
|
|
266
|
+
try {
|
|
267
|
+
process.kill(pid, 0);
|
|
268
|
+
return { pid, alive: true };
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return { pid, alive: false };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Real liveness check, not a placeholder: the running service is tracked via
|
|
280
|
+
* `server.pid` (packages/server/src/server-liveness.ts — written directly by the server's
|
|
281
|
+
* own entrypoint on every launch mode: `npm run dev`, `npm run start`, and the CLI daemon,
|
|
282
|
+
* which spawns that exact same entrypoint as a child) and, as a fallback,
|
|
283
|
+
* `run.json` (packages/cli/src/runtime-state.ts's RunState, written only by the CLI
|
|
284
|
+
* daemon supervisor). Checking server.pid first is what makes this guard hold for a
|
|
285
|
+
* directly-launched dev/start server, which never writes run.json at all.
|
|
286
|
+
*
|
|
287
|
+
* This is a read-only inspection, useful on its own (e.g. reporting), but runMigration()
|
|
288
|
+
* below does not rely on it alone for its own safety: between this check returning "not
|
|
289
|
+
* running" and the migration actually acting, a server could start. runMigration()
|
|
290
|
+
* instead atomically *claims* server.pid via pid-marker.ts for the duration of the
|
|
291
|
+
* migration, which is what actually closes that race (a server whose own claim then fails
|
|
292
|
+
* is required — see index.ts — to refuse to start, rather than run unmonitored).
|
|
293
|
+
*/
|
|
294
|
+
export function isServiceRunning(rawDbPath) {
|
|
295
|
+
const dbPath = canonicalDbPath(rawDbPath);
|
|
296
|
+
const serverOwner = readPidMarkerOwner(serverPidPathFor(dbPath));
|
|
297
|
+
if (serverOwner?.alive) {
|
|
298
|
+
return { running: true, detail: `pid ${serverOwner.pid} (from ${serverPidPathFor(dbPath)})` };
|
|
299
|
+
}
|
|
300
|
+
const cliOwner = readRunStateOwner(dbPath);
|
|
301
|
+
if (cliOwner?.alive) {
|
|
302
|
+
return { running: true, detail: `serverPid ${cliOwner.pid} (from ${runStatePathFor(dbPath)})` };
|
|
303
|
+
}
|
|
304
|
+
return { running: false };
|
|
305
|
+
}
|
|
306
|
+
export function runMigration(rawDbPath, opts) {
|
|
307
|
+
// Canonicalize once, up front — every path derived below (server.pid, run.json, the
|
|
308
|
+
// backup, WAL sidecars) must be anchored to the database's real location. See
|
|
309
|
+
// canonicalDbPath's doc comment.
|
|
310
|
+
const dbPath = canonicalDbPath(rawDbPath);
|
|
311
|
+
// Phase 0: read-only probe — idempotent no-op check + schema validation, before anything
|
|
312
|
+
// else touches the filesystem or the database.
|
|
313
|
+
const probe = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
314
|
+
try {
|
|
315
|
+
if (tableExists(probe, "legacy_v0_runs")) {
|
|
316
|
+
return { ...emptyReport(), alreadyMigrated: true };
|
|
317
|
+
}
|
|
318
|
+
const schemaError = validateLegacySchema(probe);
|
|
319
|
+
if (schemaError) {
|
|
320
|
+
return { ...emptyReport(), mismatches: [schemaError] };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
probe.close();
|
|
325
|
+
}
|
|
326
|
+
// run.json (the CLI daemon's own marker) is a read-only signal this script never
|
|
327
|
+
// claims — a live entry there blocks unconditionally, the same as before.
|
|
328
|
+
if (!opts?.skipServiceCheck) {
|
|
329
|
+
const cliOwner = readRunStateOwner(dbPath);
|
|
330
|
+
if (cliOwner?.alive) {
|
|
331
|
+
return {
|
|
332
|
+
...emptyReport(),
|
|
333
|
+
mismatches: [
|
|
334
|
+
`refusing to migrate while the service is running (serverPid ${cliOwner.pid} from ${runStatePathFor(dbPath)}) — stop it first`,
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Atomically claim server.pid for the duration of this migration (see pid-marker.ts).
|
|
340
|
+
// This is what actually closes the TOCTOU window a plain read-then-act check could
|
|
341
|
+
// not: a server trying to start concurrently sees this migration's own live pid as the
|
|
342
|
+
// current owner and — index.ts treats a failed claim as fatal — refuses to start, while
|
|
343
|
+
// this claim itself fails if a server won the race first.
|
|
344
|
+
const serverPidPath = serverPidPathFor(dbPath);
|
|
345
|
+
let claimedLock = false;
|
|
346
|
+
if (!opts?.skipServiceCheck) {
|
|
347
|
+
claimedLock = claimPidMarker(serverPidPath, { role: "migration" });
|
|
348
|
+
if (!claimedLock) {
|
|
349
|
+
const owner = readPidMarkerOwner(serverPidPath);
|
|
350
|
+
return {
|
|
351
|
+
...emptyReport(),
|
|
352
|
+
mismatches: [
|
|
353
|
+
`refusing to migrate while the service is running (pid ${owner?.pid} from ${serverPidPath}) — stop it first`,
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
return runMigrationBody(dbPath, opts);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
if (claimedLock)
|
|
363
|
+
releasePidMarker(serverPidPath);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function runMigrationBody(dbPath, opts) {
|
|
367
|
+
if (opts?.injectFailureAt === "before-backup") {
|
|
368
|
+
throw new Error("injected failure: before-backup");
|
|
369
|
+
}
|
|
370
|
+
// Fold any WAL-resident commits into the main file before copying it — see
|
|
371
|
+
// checkpointAndTruncate's doc comment.
|
|
372
|
+
checkpointAndTruncate(dbPath);
|
|
373
|
+
// Exclusive, unique backup: COPYFILE_EXCL fails (EEXIST) rather than silently overwriting
|
|
374
|
+
// a backup from a prior attempt — a second invocation is a safe refusal, never a second
|
|
375
|
+
// (possibly different) backup replacing the first.
|
|
376
|
+
const backupPath = `${dbPath}.pre-issue-migration-backup`;
|
|
377
|
+
try {
|
|
378
|
+
fs.copyFileSync(dbPath, backupPath, fs.constants.COPYFILE_EXCL);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
if (err.code === "EEXIST") {
|
|
382
|
+
return {
|
|
383
|
+
...emptyReport(),
|
|
384
|
+
mismatches: [
|
|
385
|
+
`backup already exists at ${backupPath} — refusing to run again. If this is a stale backup ` +
|
|
386
|
+
`from a previous failed attempt, inspect and remove it manually before retrying.`,
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
if (opts?.injectFailureAt === "after-backup") {
|
|
393
|
+
throw new Error("injected failure: after-backup");
|
|
394
|
+
}
|
|
395
|
+
const db = new Database(dbPath);
|
|
396
|
+
const report = emptyReport();
|
|
397
|
+
const tx = db.transaction(() => {
|
|
398
|
+
const runs = db.prepare("SELECT * FROM runs").all();
|
|
399
|
+
const runIds = new Set(runs.map((r) => r.id));
|
|
400
|
+
// Refuse malformed input rather than guessing: a lineage_id that points nowhere.
|
|
401
|
+
for (const run of runs) {
|
|
402
|
+
if (run.lineage_id && !runIds.has(run.lineage_id) && run.lineage_id !== run.id) {
|
|
403
|
+
report.mismatches.push(`run ${run.id} has lineage_id ${run.lineage_id} which does not exist`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (report.mismatches.length > 0)
|
|
407
|
+
return;
|
|
408
|
+
const agentExists = (id) => !!id && !!db.prepare("SELECT 1 FROM agents WHERE id = ?").get(id);
|
|
409
|
+
const issuesBefore = db.prepare("SELECT COUNT(*) AS c FROM issues").get().c;
|
|
410
|
+
const groups = groupByLineage(runs);
|
|
411
|
+
for (const [, lineageRuns] of groups) {
|
|
412
|
+
const issueId = uuid();
|
|
413
|
+
const latest = latestRun(lineageRuns);
|
|
414
|
+
const mapped = issueStatusForLatest(latest.status);
|
|
415
|
+
const resolvedAgentId = agentExists(latest.agent_id) ? latest.agent_id : null;
|
|
416
|
+
db.prepare(`
|
|
417
|
+
INSERT INTO issues (
|
|
418
|
+
id, source, external_id, external_label, external_url, title, description,
|
|
419
|
+
acceptance_criteria, repo, base_branch, status, current_owner, current_intent,
|
|
420
|
+
developer_agent_id, reviewer_agent_id, max_review_rounds, current_round,
|
|
421
|
+
max_infra_attempts, infra_attempts,
|
|
422
|
+
branch, base_sha, head_sha, pr_number, pr_url, created_at, updated_at
|
|
423
|
+
) VALUES (
|
|
424
|
+
@id, @source, @external_id, @external_label, NULL, @title, @description,
|
|
425
|
+
@acceptance_criteria, @repo, 'main', @status, @current_owner, NULL,
|
|
426
|
+
@agent_id, @agent_id, 3, 1,
|
|
427
|
+
3, 0,
|
|
428
|
+
NULL, NULL, NULL, NULL, NULL, @created_at, @updated_at
|
|
429
|
+
)
|
|
430
|
+
`).run({
|
|
431
|
+
id: issueId,
|
|
432
|
+
source: latest.source,
|
|
433
|
+
external_id: latest.external_id,
|
|
434
|
+
external_label: latest.external_label,
|
|
435
|
+
title: latest.title,
|
|
436
|
+
description: latest.description,
|
|
437
|
+
acceptance_criteria: latest.acceptance_criteria,
|
|
438
|
+
repo: latest.repo ?? "unknown",
|
|
439
|
+
status: mapped.status,
|
|
440
|
+
current_owner: mapped.owner,
|
|
441
|
+
agent_id: resolvedAgentId,
|
|
442
|
+
created_at: latest.created_at,
|
|
443
|
+
updated_at: latest.updated_at,
|
|
444
|
+
});
|
|
445
|
+
report.issuesCreated += 1;
|
|
446
|
+
// One completed legacy_v0 workflow instance per lineage (audit only, never active).
|
|
447
|
+
// Inserted directly rather than through startWorkflowInstance(), which always creates
|
|
448
|
+
// an *active* (completed_at=null) instance — this one is born already complete.
|
|
449
|
+
const instanceId = uuid();
|
|
450
|
+
db.prepare(`
|
|
451
|
+
INSERT INTO workflow_instances (id, issue_id, workflow_version, started_at, completed_at, outcome)
|
|
452
|
+
VALUES (?, ?, 'legacy_v0', ?, ?, 'migrated')
|
|
453
|
+
`).run(instanceId, issueId, latest.created_at, latest.updated_at);
|
|
454
|
+
if (mapped.humanAction) {
|
|
455
|
+
// response_options_json populated from the exact same table the live coordinator
|
|
456
|
+
// uses (commands.ts's responseOptionsFor) — left NULL, the production UI parses it
|
|
457
|
+
// as no options and renders nothing to resolve the action with.
|
|
458
|
+
const responseOptionsJson = JSON.stringify(responseOptionsFor(mapped.humanAction.actionType));
|
|
459
|
+
db.prepare(`
|
|
460
|
+
INSERT INTO human_actions (
|
|
461
|
+
id, issue_id, workflow_instance_id, action_type, reason, question, evidence_json,
|
|
462
|
+
response_options_json, continuation_preview_json, status, resolution_json, resolved_by,
|
|
463
|
+
requested_at, resolved_at
|
|
464
|
+
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL, 'open', NULL, NULL, ?, NULL)
|
|
465
|
+
`).run(uuid(), issueId, instanceId, mapped.humanAction.actionType, mapped.humanAction.reason, mapped.humanAction.question, responseOptionsJson, latest.updated_at);
|
|
466
|
+
}
|
|
467
|
+
for (const run of lineageRuns) {
|
|
468
|
+
const sessionId = uuid();
|
|
469
|
+
const sessionStatus = legacySessionStatus(run.status);
|
|
470
|
+
const sessionAgentId = agentExists(run.agent_id) ? run.agent_id : null;
|
|
471
|
+
// Design §"worker_sessions": runtime/model/budget "moved from runs" — preserve them
|
|
472
|
+
// on the migrated session rather than dropping them, since legacy_v0_runs (where
|
|
473
|
+
// they'd otherwise still live) is only retained for one release. execute_model
|
|
474
|
+
// wins over plan_model when both are set, matching the same execute-first
|
|
475
|
+
// precedence migrate() already uses when backfilling agents.default_model.
|
|
476
|
+
const model = run.execute_model ?? run.plan_model;
|
|
477
|
+
db.prepare(`
|
|
478
|
+
INSERT INTO worker_sessions (
|
|
479
|
+
id, issue_id, role, round, agent_id, runtime, model, budget_json, worktree_path,
|
|
480
|
+
input_sha, status, session_ref, log_path, exit_code, error_json, metadata_json,
|
|
481
|
+
profile_snapshot_json, created_at, started_at, heartbeat_at, completed_at, updated_at
|
|
482
|
+
) VALUES (
|
|
483
|
+
?, ?, 'legacy', 1, ?, ?, ?, ?, NULL,
|
|
484
|
+
NULL, ?, NULL, NULL, NULL, NULL, ?,
|
|
485
|
+
NULL, ?, NULL, NULL, ?, ?
|
|
486
|
+
)
|
|
487
|
+
`).run(sessionId, issueId, sessionAgentId, run.runtime, model, run.budget_json, sessionStatus, JSON.stringify({
|
|
488
|
+
legacyRunId: run.id,
|
|
489
|
+
legacyStatus: run.status,
|
|
490
|
+
legacyLineageId: run.lineage_id,
|
|
491
|
+
legacyPlanModel: run.plan_model,
|
|
492
|
+
legacyExecuteModel: run.execute_model,
|
|
493
|
+
}), run.created_at, run.updated_at, run.updated_at);
|
|
494
|
+
report.legacySessionsCreated += 1;
|
|
495
|
+
// Repoint in place — artifacts is the SAME table the live coordinator writes to
|
|
496
|
+
// (issue_id/worker_session_id were added to it additively, see ./index.ts), never a
|
|
497
|
+
// renamed legacy table. Updating in place, rather than copying into a staging table
|
|
498
|
+
// and swapping the whole table, is what keeps any artifact rows a real (non-legacy)
|
|
499
|
+
// issue already wrote — this migration is not the first runtime activity against
|
|
500
|
+
// this database — completely untouched.
|
|
501
|
+
const repointed = db
|
|
502
|
+
.prepare("UPDATE artifacts SET issue_id = ?, worker_session_id = ? WHERE run_id = ?")
|
|
503
|
+
.run(issueId, sessionId, run.id);
|
|
504
|
+
report.artifactsRepointed += repointed.changes;
|
|
505
|
+
const events = db.prepare("SELECT * FROM events WHERE run_id = ?").all(run.id);
|
|
506
|
+
for (const e of events) {
|
|
507
|
+
let legacyPayload = null;
|
|
508
|
+
if (e.payload_json) {
|
|
509
|
+
try {
|
|
510
|
+
legacyPayload = JSON.parse(e.payload_json);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
legacyPayload = e.payload_json;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
db.prepare(`
|
|
517
|
+
INSERT INTO workflow_events (
|
|
518
|
+
id, issue_id, workflow_instance_id, worker_session_id, type, actor_type, actor_ref,
|
|
519
|
+
stage, round, payload_json, artifact_ref, idempotency_key, causation_event_id, ts
|
|
520
|
+
) VALUES (?, ?, ?, ?, 'legacy.imported', 'system', NULL, ?, 1, ?, NULL, NULL, NULL, ?)
|
|
521
|
+
`).run(uuid(), issueId, instanceId, sessionId, mapped.status, JSON.stringify({ legacyType: e.type, legacyPayload }), e.ts);
|
|
522
|
+
report.eventsRepointed += 1;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (opts?.injectFailureAt === "after-insert") {
|
|
527
|
+
throw new Error("injected failure: after-insert");
|
|
528
|
+
}
|
|
529
|
+
// Verification gate — any mismatch aborts the whole transaction (rollback).
|
|
530
|
+
const issuesAfter = db.prepare("SELECT COUNT(*) AS c FROM issues").get().c;
|
|
531
|
+
if (issuesAfter - issuesBefore !== groups.size) {
|
|
532
|
+
report.mismatches.push(`expected ${groups.size} new issues, found ${issuesAfter - issuesBefore} (database may already contain unrelated issues — compared as a delta)`);
|
|
533
|
+
}
|
|
534
|
+
const sessionCount = db.prepare("SELECT COUNT(*) AS c FROM worker_sessions WHERE role = 'legacy'").get().c;
|
|
535
|
+
if (sessionCount !== runs.length) {
|
|
536
|
+
report.mismatches.push(`expected ${runs.length} legacy sessions, found ${sessionCount}`);
|
|
537
|
+
}
|
|
538
|
+
// Scoped to the tables this migration writes to: a legacy `runs`/`events`/
|
|
539
|
+
// `approval_gates` row can already carry a dangling reference from before this script
|
|
540
|
+
// ever ran (e.g. an agent deleted outside deleteAgent()'s normal path) — that
|
|
541
|
+
// pre-existing legacy data quality issue is not this migration's to fix, and is kept
|
|
542
|
+
// as-is under legacy_v0_*. Only a violation in a table *this migration wrote to* means
|
|
543
|
+
// the migration itself produced a bad reference.
|
|
544
|
+
const NEW_TABLES = new Set(["issues", "worker_sessions", "workflow_instances", "workflow_events", "human_actions"]);
|
|
545
|
+
const fkViolations = db.prepare("PRAGMA foreign_key_check").all().filter((v) => NEW_TABLES.has(v.table));
|
|
546
|
+
if (fkViolations.length > 0) {
|
|
547
|
+
report.mismatches.push(`foreign key check failed: ${JSON.stringify(fkViolations)}`);
|
|
548
|
+
}
|
|
549
|
+
if (opts?.injectFailureAt === "after-verify") {
|
|
550
|
+
throw new Error("injected failure: after-verify");
|
|
551
|
+
}
|
|
552
|
+
if (report.mismatches.length > 0) {
|
|
553
|
+
throw new Error("migration verification failed — rolling back");
|
|
554
|
+
}
|
|
555
|
+
// Rename the legacy-only tables so the running app's fresh, empty versions (recreated
|
|
556
|
+
// by schema.sql on next startup) are the only ones the *new* coordinator ever writes —
|
|
557
|
+
// "artifacts" is deliberately excluded; see the repoint comment above.
|
|
558
|
+
for (const table of ["runs", "events", "approval_gates"]) {
|
|
559
|
+
db.exec(`ALTER TABLE ${table} RENAME TO legacy_v0_${table}`);
|
|
560
|
+
}
|
|
561
|
+
if (opts?.injectFailureAt === "after-rename") {
|
|
562
|
+
throw new Error("injected failure: after-rename");
|
|
563
|
+
}
|
|
564
|
+
// The rename above makes SQLite rewrite artifacts.run_id's FK target to
|
|
565
|
+
// "legacy_v0_runs" (a foreign key follows a RENAME of the table it references).
|
|
566
|
+
// schema.sql recreates a *fresh*, empty "runs" table on the very next startup — but
|
|
567
|
+
// the legacy `/api/runs` writers keep running per this ticket's scope, and the first
|
|
568
|
+
// fresh run's addArtifact() would then fail with "FOREIGN KEY constraint failed"
|
|
569
|
+
// because the live artifacts table's declared FK target no longer matches the table
|
|
570
|
+
// schema.sql just (re)created. Rebuild `artifacts` with run_id as a plain, unconstrained
|
|
571
|
+
// column instead — re-declaring `REFERENCES runs(id)` here does not work: SQLite
|
|
572
|
+
// requires the referenced table to exist by the time a row is *inserted* into the
|
|
573
|
+
// child (not merely at CREATE TABLE time), and this same transaction has to copy rows
|
|
574
|
+
// into the rebuilt table before schema.sql ever gets a chance to recreate "runs".
|
|
575
|
+
// Given "runs" itself now cycles between the renamed legacy table and a freshly
|
|
576
|
+
// recreated empty one across every cutover, a declared FK into it is unenforceable by
|
|
577
|
+
// construction; dropping the constraint (the column and its data are untouched) is the
|
|
578
|
+
// correct fix, not a workaround. Same rebuild-in-place technique migrate() already uses
|
|
579
|
+
// in ./index.ts for the analogous "SQLite can't ALTER a column's FK target" limitation.
|
|
580
|
+
db.exec(`
|
|
581
|
+
CREATE TABLE artifacts_new (
|
|
582
|
+
id TEXT PRIMARY KEY,
|
|
583
|
+
run_id TEXT,
|
|
584
|
+
kind TEXT NOT NULL,
|
|
585
|
+
content_json TEXT,
|
|
586
|
+
blob_path TEXT,
|
|
587
|
+
author TEXT NOT NULL,
|
|
588
|
+
created_at TEXT NOT NULL,
|
|
589
|
+
issue_id TEXT REFERENCES issues(id),
|
|
590
|
+
worker_session_id TEXT REFERENCES worker_sessions(id)
|
|
591
|
+
);
|
|
592
|
+
INSERT INTO artifacts_new (id, run_id, kind, content_json, blob_path, author, created_at, issue_id, worker_session_id)
|
|
593
|
+
SELECT id, run_id, kind, content_json, blob_path, author, created_at, issue_id, worker_session_id FROM artifacts;
|
|
594
|
+
DROP TABLE artifacts;
|
|
595
|
+
ALTER TABLE artifacts_new RENAME TO artifacts;
|
|
596
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_run ON artifacts(run_id);
|
|
597
|
+
`);
|
|
598
|
+
// The same rename-follows-reference behavior applies to each legacy table's own
|
|
599
|
+
// secondary indexes: SQLite keeps an index's original name attached through a table
|
|
600
|
+
// rename, so schema.sql's `CREATE INDEX IF NOT EXISTS idx_runs_status ...` (index
|
|
601
|
+
// names are unique database-wide, not per-table) finds that name already taken and
|
|
602
|
+
// silently skips creating it against the fresh "runs" table — leaving the live legacy
|
|
603
|
+
// write path with only its primary-key autoindex. Free the original names by giving
|
|
604
|
+
// the legacy copies new ones.
|
|
605
|
+
const LEGACY_INDEXES = [
|
|
606
|
+
{ name: "idx_runs_status", table: "runs", columns: "status" },
|
|
607
|
+
{ name: "idx_runs_external", table: "runs", columns: "source, external_id" },
|
|
608
|
+
{ name: "idx_events_run", table: "events", columns: "run_id" },
|
|
609
|
+
{ name: "idx_gates_run", table: "approval_gates", columns: "run_id" },
|
|
610
|
+
];
|
|
611
|
+
for (const idx of LEGACY_INDEXES) {
|
|
612
|
+
db.exec(`DROP INDEX IF EXISTS ${idx.name}`);
|
|
613
|
+
db.exec(`CREATE INDEX legacy_v0_${idx.name} ON legacy_v0_${idx.table}(${idx.columns})`);
|
|
614
|
+
}
|
|
615
|
+
// Post-rename assertion: query "artifacts" exactly the way the live API does (by
|
|
616
|
+
// issue_id) and confirm every repointed artifact is actually reachable there.
|
|
617
|
+
const reachableForRun = db
|
|
618
|
+
.prepare(`SELECT COUNT(*) AS c FROM artifacts WHERE issue_id IS NOT NULL AND run_id IN (SELECT id FROM legacy_v0_runs)`)
|
|
619
|
+
.get();
|
|
620
|
+
if (reachableForRun.c !== report.artifactsRepointed) {
|
|
621
|
+
report.mismatches.push(`expected ${report.artifactsRepointed} migrated artifacts reachable via the post-cutover "artifacts" table, found ${reachableForRun.c}`);
|
|
622
|
+
throw new Error("migration verification failed — rolling back");
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
try {
|
|
626
|
+
tx();
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
// better-sqlite3 already rolled back the transaction; report.mismatches carries the
|
|
630
|
+
// reason when it's one this script raised. An injected/unexpected error still needs a
|
|
631
|
+
// message so the caller can see *why* — mismatches is the one channel both use.
|
|
632
|
+
if (report.mismatches.length === 0) {
|
|
633
|
+
report.mismatches.push(err instanceof Error ? err.message : String(err));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
// Whether committed or rolled back, leave the file self-contained on disk — the same
|
|
638
|
+
// reason the backup is checkpointed before copy (see checkpointAndTruncate).
|
|
639
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
640
|
+
db.close();
|
|
641
|
+
}
|
|
642
|
+
return report;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Restores the pre-migration backup over the live database, undoing a completed cutover.
|
|
646
|
+
* Refuses while the service is running, for the same reason the forward migration does.
|
|
647
|
+
* Refuses when no backup exists rather than silently doing nothing that looks like success.
|
|
648
|
+
*/
|
|
649
|
+
/** Opens `path` read-only and runs an integrity check plus a minimal legacy-schema sanity
|
|
650
|
+
* check (it must look like a pre-migration snapshot, i.e. still have "runs" — a backup is
|
|
651
|
+
* only ever taken *before* the rename). Returns null when the file is a usable rollback
|
|
652
|
+
* source, or a description of the first problem found. */
|
|
653
|
+
function validateRollbackSource(path_) {
|
|
654
|
+
// A malformed file (not a SQLite database at all, or truncated/corrupt) does not
|
|
655
|
+
// necessarily fail at open time — better-sqlite3/SQLite reads the header lazily, so the
|
|
656
|
+
// actual SQLITE_NOTADB/SQLITE_CORRUPT throw can happen on the first real statement
|
|
657
|
+
// (integrity_check below) just as easily as on the constructor. Both must be caught the
|
|
658
|
+
// same way: this is a "not a usable rollback source" verdict either way, not a crash.
|
|
659
|
+
let db = null;
|
|
660
|
+
try {
|
|
661
|
+
db = new Database(path_, { readonly: true, fileMustExist: true });
|
|
662
|
+
const rows = db.pragma("integrity_check");
|
|
663
|
+
const problems = rows.filter((r) => r.integrity_check !== "ok").map((r) => r.integrity_check);
|
|
664
|
+
if (problems.length > 0) {
|
|
665
|
+
return `${path_} failed SQLite's integrity check: ${problems.join("; ")}`;
|
|
666
|
+
}
|
|
667
|
+
// Reuse the exact same validation that gates creating a backup in the first place — a
|
|
668
|
+
// genuine pre-cutover snapshot must satisfy every bit of it too (all legacy tables and
|
|
669
|
+
// columns, all issue-centric target tables, no unrecognized run status), not a
|
|
670
|
+
// narrower ad hoc subset that a post-restart or unrelated database could pass by
|
|
671
|
+
// accident (e.g. one with only "runs"/"artifacts"/"events" but no "approval_gates" or
|
|
672
|
+
// target tables — a near-miss the previous, narrower check here did not catch).
|
|
673
|
+
const schemaProblem = validateLegacySchema(db);
|
|
674
|
+
if (schemaProblem) {
|
|
675
|
+
return `${path_} does not look like a pre-migration backup — ${schemaProblem}`;
|
|
676
|
+
}
|
|
677
|
+
// validateLegacySchema() doesn't check this — it runs on a live, not-yet-migrated
|
|
678
|
+
// database in the forward direction, where legacy_v0_* tables couldn't exist yet
|
|
679
|
+
// anyway. A rollback source additionally must not carry them: a post-restart database
|
|
680
|
+
// (schema.sql recreates a fresh "runs" after a real cutover) would otherwise pass the
|
|
681
|
+
// shape check above and be silently accepted as if it were the original pre-cutover
|
|
682
|
+
// snapshot.
|
|
683
|
+
const cutoverTables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'legacy_v0_%'").all().map((r) => r.name);
|
|
684
|
+
if (cutoverTables.length > 0) {
|
|
685
|
+
return `${path_} does not look like a pre-migration backup — carries cutover-only table(s) ${cutoverTables.join(", ")}, meaning this looks like a post-migration database, not a pre-cutover snapshot`;
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
return `cannot open ${path_} as a SQLite database: ${err instanceof Error ? err.message : String(err)}`;
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
db?.close();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Restores the pre-migration backup over the live database, undoing a completed cutover.
|
|
698
|
+
* Refuses while the service is running, for the same reason the forward migration does.
|
|
699
|
+
* Refuses when no backup exists rather than silently doing nothing that looks like success.
|
|
700
|
+
*
|
|
701
|
+
* Never trusts the backup file blindly: a truncated, corrupted, or unrelated file at that
|
|
702
|
+
* path (a bad disk during the original backup, or manual tampering) is validated — opened
|
|
703
|
+
* read-only, integrity-checked, and schema-sanity-checked — *before* it ever touches the
|
|
704
|
+
* live database. The restore itself copies to a temporary file in the same directory,
|
|
705
|
+
* validates that copy too (protects against a corrupted copy, e.g. a disk failing
|
|
706
|
+
* mid-write), and only then atomically renames it over the live path — an interrupted
|
|
707
|
+
* copy leaves the original live database untouched rather than a half-written file in
|
|
708
|
+
* its place.
|
|
709
|
+
*/
|
|
710
|
+
export function rollbackMigration(rawDbPath, opts) {
|
|
711
|
+
const dbPath = canonicalDbPath(rawDbPath);
|
|
712
|
+
const backupPath = `${dbPath}.pre-issue-migration-backup`;
|
|
713
|
+
if (!fs.existsSync(backupPath)) {
|
|
714
|
+
return { rolledBack: false, detail: `no backup found at ${backupPath} — nothing to roll back to` };
|
|
715
|
+
}
|
|
716
|
+
// run.json (CLI daemon) is a read-only signal this script never claims — same as
|
|
717
|
+
// runMigration(), a live entry there blocks unconditionally.
|
|
718
|
+
if (!opts?.skipServiceCheck) {
|
|
719
|
+
const cliOwner = readRunStateOwner(dbPath);
|
|
720
|
+
if (cliOwner?.alive) {
|
|
721
|
+
return {
|
|
722
|
+
rolledBack: false,
|
|
723
|
+
detail: `refusing rollback while the service is running (serverPid ${cliOwner.pid} from ${runStatePathFor(dbPath)}) — stop it first`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Atomically claim server.pid for the duration of the restore, exactly as
|
|
728
|
+
// runMigration() does — a plain read-then-act liveness check leaves the same TOCTOU
|
|
729
|
+
// window a forward migration would: a server could start between the check and the
|
|
730
|
+
// rename below and have the file replaced out from under it mid-run.
|
|
731
|
+
const serverPidPath = serverPidPathFor(dbPath);
|
|
732
|
+
let claimedLock = false;
|
|
733
|
+
if (!opts?.skipServiceCheck) {
|
|
734
|
+
claimedLock = claimPidMarker(serverPidPath, { role: "rollback" });
|
|
735
|
+
if (!claimedLock) {
|
|
736
|
+
const owner = readPidMarkerOwner(serverPidPath);
|
|
737
|
+
return {
|
|
738
|
+
rolledBack: false,
|
|
739
|
+
detail: `refusing rollback while the service is running (pid ${owner?.pid} from ${serverPidPath}) — stop it first`,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
const sourceProblem = validateRollbackSource(backupPath);
|
|
745
|
+
// Opening the backup read-only for validation can itself create -wal/-shm sidecars
|
|
746
|
+
// next to it; tidy those up regardless of outcome — the backup file itself is untouched.
|
|
747
|
+
removeWalSidecars(backupPath);
|
|
748
|
+
if (sourceProblem) {
|
|
749
|
+
return { rolledBack: false, detail: `refusing rollback — ${sourceProblem}` };
|
|
750
|
+
}
|
|
751
|
+
const stagedPath = `${dbPath}.rollback-staging-${process.pid}-${Date.now()}`;
|
|
752
|
+
try {
|
|
753
|
+
fs.copyFileSync(backupPath, stagedPath);
|
|
754
|
+
const stagedProblem = validateRollbackSource(stagedPath);
|
|
755
|
+
if (stagedProblem) {
|
|
756
|
+
return { rolledBack: false, detail: `refusing rollback — staged copy is unusable: ${stagedProblem}` };
|
|
757
|
+
}
|
|
758
|
+
// Same-directory rename is atomic on the same filesystem — dbPath is either the
|
|
759
|
+
// fully restored file or (on any failure above) untouched; never a partial write.
|
|
760
|
+
fs.renameSync(stagedPath, dbPath);
|
|
761
|
+
}
|
|
762
|
+
finally {
|
|
763
|
+
// validateRollbackSource() opens stagedPath (even read-only), which can create its
|
|
764
|
+
// own -wal/-shm sidecars — clean those up alongside the main staged file too.
|
|
765
|
+
try {
|
|
766
|
+
fs.unlinkSync(stagedPath);
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// already renamed away on success, or never created on an early failure — fine
|
|
770
|
+
}
|
|
771
|
+
removeWalSidecars(stagedPath);
|
|
772
|
+
}
|
|
773
|
+
// The restored main file is a complete snapshot (the backup was made from a
|
|
774
|
+
// checkpointed, WAL-truncated database) — any WAL/SHM sidecar still sitting next to
|
|
775
|
+
// dbPath from the post-migration state describes pages that no longer match this
|
|
776
|
+
// now-older main file, so it must not be replayed against it.
|
|
777
|
+
removeWalSidecars(dbPath);
|
|
778
|
+
return { rolledBack: true, detail: `restored ${dbPath} from ${backupPath}` };
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
if (claimedLock)
|
|
782
|
+
releasePidMarker(serverPidPath);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function printUsage() {
|
|
786
|
+
console.error("Usage:");
|
|
787
|
+
console.error(" tsx src/db/migrate-to-issues.ts <path-to-dealer.db> # run the cutover");
|
|
788
|
+
console.error(" tsx src/db/migrate-to-issues.ts <path-to-dealer.db> --rollback # undo it");
|
|
789
|
+
}
|
|
790
|
+
async function main() {
|
|
791
|
+
const args = process.argv.slice(2);
|
|
792
|
+
const dbPath = args.find((a) => !a.startsWith("--"));
|
|
793
|
+
const rollback = args.includes("--rollback");
|
|
794
|
+
if (!dbPath) {
|
|
795
|
+
printUsage();
|
|
796
|
+
process.exit(1);
|
|
797
|
+
}
|
|
798
|
+
if (!fs.existsSync(dbPath)) {
|
|
799
|
+
console.error(`Database not found: ${dbPath}`);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
if (rollback) {
|
|
803
|
+
const result = rollbackMigration(dbPath);
|
|
804
|
+
console.log(JSON.stringify(result, null, 2));
|
|
805
|
+
process.exit(result.rolledBack ? 0 : 1);
|
|
806
|
+
}
|
|
807
|
+
const report = runMigration(dbPath);
|
|
808
|
+
console.log(JSON.stringify(report, null, 2));
|
|
809
|
+
if (report.alreadyMigrated) {
|
|
810
|
+
console.log("Already migrated — no-op.");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (report.mismatches.length > 0) {
|
|
814
|
+
console.error("Migration refused or rolled back — see mismatches above. Original data untouched.");
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
console.log(`Migration complete. Roll back with: tsx src/db/migrate-to-issues.ts ${dbPath} --rollback`);
|
|
818
|
+
}
|
|
819
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
820
|
+
main().catch((e) => {
|
|
821
|
+
console.error(e);
|
|
822
|
+
process.exit(1);
|
|
823
|
+
});
|
|
824
|
+
}
|