@worca/app 1.0.0 → 1.1.1

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.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
package/src/core/db.mjs CHANGED
@@ -17,10 +17,11 @@
17
17
  // would make getDb() async and break the synchronous data layer.
18
18
 
19
19
  import { createRequire } from 'node:module';
20
- import { mkdirSync } from 'node:fs';
20
+ import { mkdirSync, existsSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { worcaHome } from './projects.mjs';
23
23
  import { maybeMigrateFromFs } from './migrate-fs-to-db.mjs';
24
+ import { SEED_TEMPLATES, NODE_ID_MAP, FB_WIRE_MAP } from './graph/seed-templates.mjs';
24
25
 
25
26
  const _require = createRequire(import.meta.url);
26
27
  let _DatabaseSync; // cached node:sqlite DatabaseSync ctor (lazy-loaded once)
@@ -50,8 +51,10 @@ const BUSY_TIMEOUT_MS = 5000;
50
51
  const OPEN_RETRY_LIMIT = 100;
51
52
  const OPEN_BACKOFF_MS = 15;
52
53
 
53
- /** Latest schema version. Bump + append a new migration step when the DDL grows. */
54
- const SCHEMA_VERSION = 17;
54
+ /** Latest schema version. Bump + append a new migration step when the DDL grows.
55
+ * Exported so migration tests assert "reached the module's current version"
56
+ * instead of hardcoding the number — a schema bump then touches no test file. */
57
+ export const SCHEMA_VERSION = 25;
55
58
 
56
59
  /** Absolute path to the database file: <worcaHome>/worca-cc.db. */
57
60
  export function dbPath() {
@@ -70,6 +73,7 @@ export function getDb() {
70
73
  mkdirSync(home, { recursive: true }); // chicken/egg: ensure the dir before open
71
74
  const db = _openConfiguredMigrated(); // open + pragmas + migrate, retried on BUSY
72
75
  maybeMigrateFromFs(db); // one-shot fs→db import (other phase; self-guarded)
76
+ reconcileAfterFsImport(db); // V24: archive v1 templates that import just created
73
77
  _db = db; // publish only after the DB is fully ready
74
78
  return _db;
75
79
  }
@@ -196,14 +200,14 @@ CREATE TABLE workflows (
196
200
  -- preserving unknown top-level keys (e.g. webUiTesting).
197
201
  CREATE TABLE project_config (
198
202
  project_key TEXT PRIMARY KEY,
199
- steps TEXT NOT NULL DEFAULT '{}', -- JSON: { role: {model?,effort?,fanOut?} }
203
+ steps TEXT NOT NULL DEFAULT '{}', -- JSON: { role: {model?,effort?,subagentModel?,fanOut?} }
200
204
  custom_models TEXT NOT NULL DEFAULT '[]', -- JSON: [ {id,label} ]
201
205
  active_workflow_id TEXT,
202
206
  extra TEXT NOT NULL DEFAULT '{}' -- JSON: unknown top-level keys
203
207
  );
204
208
 
205
209
  -- config_workflow_nodes: normalized per-node overrides (was config.json
206
- -- workflows[wf].nodes[nodeId] = {model?,effort?,fanOut?}). One row per node.
210
+ -- workflows[wf].nodes[nodeId] = {model?,effort?,subagentModel?,fanOut?}). One row per node.
207
211
  CREATE TABLE config_workflow_nodes (
208
212
  project_key TEXT NOT NULL,
209
213
  workflow_id TEXT NOT NULL,
@@ -513,6 +517,29 @@ CREATE TABLE IF NOT EXISTS step_questions (
513
517
  );
514
518
  `;
515
519
 
520
+ /**
521
+ * v18 (task-source profiles): which PROFILE of a plugin task source a project or
522
+ * workspace uses. One row per (scope, plugin, source) — a project that pulls from
523
+ * two different sources binds each independently.
524
+ *
525
+ * scope_type is 'project' | 'workspace'; scope_key is projects.key or
526
+ * workspaces.id respectively. Deliberately NOT a foreign key: a binding must
527
+ * survive a project being removed and re-added at the same path (the key is a
528
+ * path hash, so it comes back identical), and source-bindings.mjs already treats
529
+ * a row whose plugin/profile no longer exists as "no binding".
530
+ */
531
+ const SOURCE_BINDINGS_DDL = `
532
+ CREATE TABLE IF NOT EXISTS source_bindings (
533
+ scope_type TEXT NOT NULL, -- 'project' | 'workspace'
534
+ scope_key TEXT NOT NULL, -- projects.key | workspaces.id
535
+ plugin TEXT NOT NULL,
536
+ source_id TEXT NOT NULL,
537
+ profile TEXT NOT NULL,
538
+ updated_at TEXT NOT NULL,
539
+ PRIMARY KEY (scope_type, scope_key, plugin, source_id)
540
+ );
541
+ `;
542
+
516
543
  const GUARDRAIL_SETS_DDL = `
517
544
  CREATE TABLE IF NOT EXISTS guardrail_sets (
518
545
  id TEXT PRIMARY KEY,
@@ -549,6 +576,146 @@ CREATE TABLE IF NOT EXISTS model_cost_flags (
549
576
  );
550
577
  `;
551
578
 
579
+ /** v19: Ask Worca — assistant chat threads, messages, attachments and run links
580
+ * (ask-worca-design.md §7.1). ALL `IF NOT EXISTS`, because this DDL runs from TWO
581
+ * places: the `< 19` ladder step AND the schemaGaps() self-heal — a live DB stamped
582
+ * 19 by a divergent ladder (another branch) would otherwise never get the tables.
583
+ * Nothing here is ALTERed later; a future ask_* column goes into INCREMENTAL_COLUMNS. */
584
+ const ASK_DDL = `
585
+ CREATE TABLE IF NOT EXISTS ask_threads (
586
+ id TEXT PRIMARY KEY, -- 'ask_' + 8 hex
587
+ title TEXT,
588
+ created_at TEXT NOT NULL,
589
+ updated_at TEXT NOT NULL,
590
+ model TEXT, -- last model / effort used
591
+ effort TEXT,
592
+ session_id TEXT, -- claude session for --resume; NULL = fresh
593
+ context TEXT, -- JSON: last page context
594
+ totals TEXT NOT NULL DEFAULT '{}' -- JSON {costUsd,input,output,cacheRead,cacheCreation,turns,agents}
595
+ );
596
+ CREATE TABLE IF NOT EXISTS ask_messages (
597
+ id TEXT PRIMARY KEY,
598
+ thread_id TEXT NOT NULL REFERENCES ask_threads(id) ON DELETE CASCADE,
599
+ seq INTEGER NOT NULL, -- MAX(seq)+1 allocated inside tx()
600
+ role TEXT NOT NULL, -- user | assistant | system
601
+ text TEXT NOT NULL DEFAULT '',
602
+ blocks TEXT, -- JSON array (ask-worca-design.md §7.1 block schema)
603
+ status TEXT, -- assistant: streaming | done | stopped | error
604
+ reason TEXT, -- stopped: user | max_turns | max_budget
605
+ model TEXT,
606
+ effort TEXT,
607
+ usage TEXT, -- JSON {input,output,cacheRead,cacheCreation}
608
+ cost_usd REAL, -- NULL when the turn ended before a \`result\`
609
+ duration_ms INTEGER,
610
+ created_at TEXT NOT NULL,
611
+ UNIQUE (thread_id, seq)
612
+ );
613
+ CREATE INDEX IF NOT EXISTS idx_ask_messages_thread ON ask_messages (thread_id, seq);
614
+ CREATE TABLE IF NOT EXISTS ask_attachments (
615
+ id TEXT PRIMARY KEY,
616
+ thread_id TEXT NOT NULL REFERENCES ask_threads(id) ON DELETE CASCADE,
617
+ message_id TEXT,
618
+ name TEXT NOT NULL, -- sanitized basename (display only)
619
+ bytes INTEGER NOT NULL,
620
+ created_at TEXT NOT NULL
621
+ );
622
+ CREATE INDEX IF NOT EXISTS idx_ask_attachments_thread ON ask_attachments (thread_id);
623
+ CREATE TABLE IF NOT EXISTS ask_run_links (
624
+ thread_id TEXT NOT NULL REFERENCES ask_threads(id) ON DELETE CASCADE,
625
+ run_id TEXT NOT NULL, -- runs-Map UUID from POST /api/run
626
+ pipeline_id TEXT, -- short id, from the first \`state\` event
627
+ card_id TEXT,
628
+ status TEXT, -- last seen status
629
+ phase TEXT,
630
+ created_at TEXT NOT NULL,
631
+ PRIMARY KEY (thread_id, run_id)
632
+ );
633
+ `;
634
+
635
+ /** v20: append-only Ask Worca spend ledger (ask-cost-statistics-design.md §6).
636
+ * NO foreign key on thread_id: spend is a permanent financial fact and must
637
+ * survive thread deletion (cost_ledger precedent). One row per completed turn
638
+ * with a finite cost > 0; tokens = input+output+cacheRead+cacheCreation. */
639
+ const ASK_COST_LEDGER_DDL = `
640
+ CREATE TABLE IF NOT EXISTS ask_cost_ledger (
641
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
642
+ thread_id TEXT NOT NULL,
643
+ message_id TEXT,
644
+ amount_usd REAL NOT NULL,
645
+ tokens INTEGER,
646
+ model TEXT,
647
+ ts INTEGER NOT NULL
648
+ );
649
+ CREATE INDEX IF NOT EXISTS idx_ask_cost_ledger_ts ON ask_cost_ledger (ts);
650
+ `;
651
+
652
+ /** v21: Ask Worca worktrees — per-thread detached git checkouts
653
+ * (ask-worca-worktrees-design.md §4). IF NOT EXISTS + an INCREMENTAL_TABLES entry (the
654
+ * ask_cost_ledger precedent): reconcile-safe on divergent-stamp DBs. The git
655
+ * state lives on disk under <worcaHome>/ask/<threadId>/wt/<id>; these rows are
656
+ * the registry the cascade, the sweep and the UI read. */
657
+ const ASK_WORKTREES_DDL = `
658
+ CREATE TABLE IF NOT EXISTS ask_worktrees (
659
+ id TEXT PRIMARY KEY, -- 'wt_' + 8 hex
660
+ thread_id TEXT NOT NULL REFERENCES ask_threads(id) ON DELETE CASCADE,
661
+ project_key TEXT NOT NULL,
662
+ project_dir TEXT NOT NULL, -- source repo at creation time
663
+ ref TEXT NOT NULL, -- last ref the model checked out (label)
664
+ resolved_commit TEXT NOT NULL, -- HEAD sha after the last navigation
665
+ run_id TEXT, -- set when created via run-id sugar
666
+ worktree_dir TEXT NOT NULL,
667
+ created_at TEXT NOT NULL,
668
+ updated_at TEXT NOT NULL
669
+ );
670
+ CREATE INDEX IF NOT EXISTS idx_ask_worktrees_thread ON ask_worktrees (thread_id);
671
+ `;
672
+
673
+ /** v22: internal, line-anchored comments on a run's persisted diff. IF NOT EXISTS
674
+ * + INCREMENTAL_TABLES entries (the ask_worktrees precedent): reconcile-safe on divergent-
675
+ * stamp DBs. The anchor is (project_key, path, side, line_no) against
676
+ * diff-patch.patch; `line_text` is the server-captured snapshot of that row, which
677
+ * is what keeps a comment readable after the patch is gone. `source` and
678
+ * `external_url` are RESERVED for a future GitHub review-comment sync and are
679
+ * written by nothing today.
680
+ * NOTE the pipelines cascade is declarative only: pipeline-delete.mjs states
681
+ * the pipelines row is never DELETEd, so nothing may DEPEND on it firing — the
682
+ * archive path deletes these rows explicitly. The ask_card_comments cascade DOES
683
+ * fire (foreign_keys=ON in _configure) and is relied on.
684
+ * ask_card_comments carries a proposal's commentIds from propose_run (which never
685
+ * touches the card block) to POST /api/run, where they move onto
686
+ * ask_run_links.comment_ids and are stamped onto sent_run_id by the first `state`
687
+ * event.
688
+ * This table is deliberately NOT `WITHOUT ROWID`: its implicit rowid is the
689
+ * monotonic insertion counter listDiffComments orders by (D17). */
690
+ const DIFF_COMMENTS_DDL = `
691
+ CREATE TABLE IF NOT EXISTS diff_comments (
692
+ id TEXT PRIMARY KEY, -- 'dc_' + 8 hex
693
+ store_key TEXT NOT NULL, -- '<projectKey>' | 'workspaces/<workspaceId>'
694
+ pipeline_id TEXT NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
695
+ project_key TEXT, -- member project of a workspace patch; NULL otherwise
696
+ path TEXT NOT NULL, -- NEW-side path of the anchored section
697
+ old_path TEXT, -- the section's source path (rename): the read-time both-sides guard
698
+ side TEXT NOT NULL, -- 'old' | 'new'
699
+ line_no INTEGER NOT NULL,
700
+ line_text TEXT, -- snapshot of the anchored row, captured server-side
701
+ body TEXT NOT NULL,
702
+ author TEXT NOT NULL, -- 'user' | 'ask'
703
+ resolved INTEGER NOT NULL DEFAULT 0,
704
+ resolved_at TEXT,
705
+ sent_run_id TEXT, -- 8-hex pipeline id; NEVER auto-resolves
706
+ source TEXT, -- RESERVED (GitHub sync) — unused
707
+ external_url TEXT, -- RESERVED (GitHub sync) — unused
708
+ created_at TEXT NOT NULL
709
+ );
710
+ CREATE INDEX IF NOT EXISTS idx_diff_comments_run ON diff_comments (store_key, pipeline_id);
711
+ CREATE TABLE IF NOT EXISTS ask_card_comments (
712
+ card_id TEXT NOT NULL, -- proposal card id; the block itself is JSON in ask_messages
713
+ comment_id TEXT NOT NULL REFERENCES diff_comments(id) ON DELETE CASCADE,
714
+ created_at TEXT NOT NULL,
715
+ PRIMARY KEY (card_id, comment_id)
716
+ );
717
+ `;
718
+
552
719
  const SCHEMA_V11 = `
553
720
  ALTER TABLE config_workflow_nodes ADD COLUMN ask_questions INTEGER;
554
721
  ${STEP_QUESTIONS_DDL}
@@ -568,22 +735,83 @@ const INCREMENTAL_COLUMNS = {
568
735
  pipelines: { resume_point: 'TEXT', owner_pid: 'INTEGER', owner_host: 'TEXT', heartbeat_at: 'TEXT',
569
736
  source_type: "TEXT DEFAULT 'prompt'", source_ref: 'TEXT', guardrails_id: 'TEXT',
570
737
  archived_at: 'TEXT', cost_cap_override: 'INTEGER NOT NULL DEFAULT 0',
571
- pr_url: 'TEXT', pr_number: 'INTEGER', pr_state: 'TEXT', pr_checked_at: 'TEXT' },
572
- pipeline_steps: { session_id: 'TEXT', skills: 'TEXT', graphify_count: 'INTEGER' },
573
- sub_agents: { ui_phase: 'TEXT', skills: 'TEXT', subagent_type: 'TEXT', graphify_count: 'INTEGER' },
574
- workflows: { domain: 'TEXT', origin: 'TEXT' },
575
- config_workflow_nodes: { ask_questions: 'INTEGER' },
738
+ pr_url: 'TEXT', pr_number: 'INTEGER', pr_state: 'TEXT', pr_checked_at: 'TEXT',
739
+ outcome: 'TEXT' },
740
+ pipeline_steps: { session_id: 'TEXT', skills: 'TEXT', graphify_count: 'INTEGER',
741
+ execution_id: 'TEXT', exec_kind: 'TEXT', agent_key: 'TEXT', ended_at: 'TEXT',
742
+ exec_trigger: 'TEXT', exec_result: 'TEXT', exec_meta: 'TEXT' },
743
+ sub_agents: { ui_phase: 'TEXT', skills: 'TEXT', subagent_type: 'TEXT', graphify_count: 'INTEGER',
744
+ run_model: 'TEXT' }, // v25: the model the child actually ran on
745
+ workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
746
+ config_workflow_nodes: { ask_questions: 'INTEGER', subagent_model: 'TEXT' }, // v25: sub-agent model policy
747
+ ask_run_links: { comment_ids: 'TEXT' }, // v22: JSON array of dc_ ids pending at launch
748
+ };
749
+
750
+ /** v23: per-loop-wire cycle budgets, the graph-engine twin of
751
+ * config_workflow_feedbacks (which becomes vestigial at the v1 kill list, never
752
+ * dropped). IF NOT EXISTS + an INCREMENTAL_TABLES entry: some DBs already carry
753
+ * this table from an earlier branch, with the PK columns in a different ORDER —
754
+ * harmless, because every statement names its columns. */
755
+ const CONFIG_WORKFLOW_WIRES_DDL = `
756
+ CREATE TABLE IF NOT EXISTS config_workflow_wires (
757
+ project_key TEXT NOT NULL,
758
+ workflow_id TEXT NOT NULL,
759
+ wire_id TEXT NOT NULL,
760
+ max_cycles INTEGER NOT NULL,
761
+ PRIMARY KEY (project_key, workflow_id, wire_id)
762
+ );
763
+ `;
764
+
765
+ /**
766
+ * Tables added after v1, keyed by name -> their IF-NOT-EXISTS DDL. Same hazard
767
+ * as INCREMENTAL_COLUMNS: a divergent ladder in another checkout can stamp the
768
+ * user_version past the step that creates one, so schemaGaps probes for them
769
+ * version-independently rather than trusting the stamp. A DDL block that creates
770
+ * several tables (ASK_DDL, DIFF_COMMENTS_DDL) is listed under EACH of them, so a
771
+ * DB missing only one is healed; repairSchemaGaps de-duplicates at exec time.
772
+ */
773
+ const INCREMENTAL_TABLES = {
774
+ config_workflow_wires: CONFIG_WORKFLOW_WIRES_DDL,
775
+ step_questions: STEP_QUESTIONS_DDL,
776
+ guardrail_sets: GUARDRAIL_SETS_DDL,
777
+ cost_ledger: COST_LEDGER_DDL,
778
+ model_cost_flags: MODEL_COST_FLAGS_DDL,
779
+ source_bindings: SOURCE_BINDINGS_DDL,
780
+ ask_threads: ASK_DDL,
781
+ ask_messages: ASK_DDL,
782
+ ask_attachments: ASK_DDL,
783
+ ask_run_links: ASK_DDL,
784
+ ask_cost_ledger: ASK_COST_LEDGER_DDL,
785
+ ask_worktrees: ASK_WORKTREES_DDL,
786
+ diff_comments: DIFF_COMMENTS_DDL,
787
+ ask_card_comments: DIFF_COMMENTS_DDL,
576
788
  };
577
789
 
578
790
  /**
579
- * Return [{table, col, type}] for every INCREMENTAL_COLUMNS entry absent from the
580
- * live schema, plus `stepQuestionsTable`/`guardrailSetsTable: true` flags when
581
- * those IF-NOT-EXISTS tables are missing (safe to reassert on any stamped DB).
582
- * Cheap and read-only: one PRAGMA table_info per known table + one sqlite_master
583
- * probe each, no writes. A table absent from INCREMENTAL_COLUMNS' map (table_info
584
- * returns []) is skipped — creating base tables is the version ladder's job.
791
+ * Indexes added after their host table shipped, keyed by index name. Same hazard
792
+ * as INCREMENTAL_TABLES, but a missing index cannot be inferred from a missing
793
+ * table: idx_ask_attachments_thread was added to ASK_DDL after v19 shipped, so a
794
+ * DB that already HAS ask_attachments never re-runs that DDL. Probed only when
795
+ * the host table exists otherwise INCREMENTAL_TABLES fires the DDL, which
796
+ * carries the CREATE INDEX itself.
585
797
  */
586
- function schemaGaps(db) {
798
+ const INCREMENTAL_INDEXES = {
799
+ idx_ask_attachments_thread: {
800
+ table: 'ask_attachments',
801
+ ddl: 'CREATE INDEX IF NOT EXISTS idx_ask_attachments_thread ON ask_attachments (thread_id)',
802
+ },
803
+ };
804
+
805
+ /**
806
+ * The INCREMENTAL_COLUMNS entries absent from the live schema, as
807
+ * [{table, col, type}]. A table absent entirely (table_info returns []) is
808
+ * skipped — creating base tables is the version ladder's / the gap DDLs' job.
809
+ * Split out of schemaGaps() so repairSchemaGaps can RE-probe after its CREATEs:
810
+ * a table one repair pass creates (ask_run_links via ASK_DDL) has an empty
811
+ * table_info when that pass's gaps were computed, so its incremental columns are
812
+ * invisible until the tables exist.
813
+ */
814
+ function missingColumns(db) {
587
815
  const missing = [];
588
816
  for (const [table, cols] of Object.entries(INCREMENTAL_COLUMNS)) {
589
817
  const have = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
@@ -592,37 +820,47 @@ function schemaGaps(db) {
592
820
  if (!have.has(col)) missing.push({ table, col, type });
593
821
  }
594
822
  }
595
- const hasStepQuestions = db.prepare(
596
- "SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name='step_questions'"
597
- ).get().n > 0;
598
- const hasGuardrailSets = db.prepare(
599
- "SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name='guardrail_sets'"
600
- ).get().n > 0;
601
- const hasCostLedger = db.prepare(
602
- "SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name='cost_ledger'"
603
- ).get().n > 0;
604
- const hasModelCostFlags = db.prepare(
605
- "SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name='model_cost_flags'"
606
- ).get().n > 0;
607
- return {
608
- columns: missing,
609
- stepQuestionsTable: !hasStepQuestions,
610
- guardrailSetsTable: !hasGuardrailSets,
611
- costLedgerTable: !hasCostLedger,
612
- modelCostFlagsTable: !hasModelCostFlags,
613
- };
823
+ return missing;
824
+ }
825
+
826
+ const hasSqliteObject = (db, type, name) => db.prepare(
827
+ "SELECT count(*) AS n FROM sqlite_master WHERE type=? AND name=?"
828
+ ).get(type, name).n > 0;
829
+
830
+ /**
831
+ * The INCREMENTAL_COLUMNS gaps plus the names of any INCREMENTAL_TABLES /
832
+ * INCREMENTAL_INDEXES that do not exist yet (all CREATEs are IF NOT EXISTS, so
833
+ * reasserting one on any stamped DB is safe). Cheap and read-only: PRAGMA
834
+ * table_info per known table plus one sqlite_master probe each, no writes.
835
+ */
836
+ function schemaGaps(db) {
837
+ const tables = Object.keys(INCREMENTAL_TABLES)
838
+ .filter((t) => !hasSqliteObject(db, 'table', t));
839
+ const indexes = Object.entries(INCREMENTAL_INDEXES)
840
+ .filter(([name, { table }]) => hasSqliteObject(db, 'table', table)
841
+ && !hasSqliteObject(db, 'index', name))
842
+ .map(([name]) => name);
843
+ return { columns: missingColumns(db), tables, indexes };
614
844
  }
615
845
 
616
846
  /** Apply the gap repairs with NO transaction control of its own — the caller owns
617
- * the transaction (the ladder tx in migrate(), or reconcileSchema's own lock). */
847
+ * the transaction (the ladder tx in migrate(), or reconcileSchema's own lock).
848
+ * ORDER IS LOAD-BEARING: tables and indexes FIRST, then the columns RE-probed
849
+ * against the post-CREATE schema. `gaps.columns` was computed BEFORE this pass
850
+ * ran, so it cannot see an incremental column on a table this pass is about to
851
+ * create (ask_run_links.comment_ids on a >=20-stamped DB missing the ask
852
+ * tables) — the ALTER would be skipped and the DB stamped current with the
853
+ * column absent, and only a LATER migrate() would heal it. No gap DDL
854
+ * references an INCREMENTAL_COLUMNS column, so nothing here needs an ALTER to
855
+ * run first. */
618
856
  function repairSchemaGaps(db, gaps) {
619
- for (const { table, col, type } of gaps.columns) {
857
+ // One DDL block can create several tables (ASK_DDL, DIFF_COMMENTS_DDL) — the
858
+ // Set collapses the duplicate keys to a single idempotent exec.
859
+ for (const ddl of new Set((gaps.tables || []).map((t) => INCREMENTAL_TABLES[t]))) db.exec(ddl);
860
+ for (const name of gaps.indexes || []) db.exec(INCREMENTAL_INDEXES[name].ddl);
861
+ for (const { table, col, type } of missingColumns(db)) {
620
862
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
621
863
  }
622
- if (gaps.stepQuestionsTable) db.exec(STEP_QUESTIONS_DDL);
623
- if (gaps.guardrailSetsTable) db.exec(GUARDRAIL_SETS_DDL);
624
- if (gaps.costLedgerTable) db.exec(COST_LEDGER_DDL);
625
- if (gaps.modelCostFlagsTable) db.exec(MODEL_COST_FLAGS_DDL);
626
864
  }
627
865
 
628
866
  /**
@@ -638,8 +876,8 @@ function repairSchemaGaps(db, gaps) {
638
876
  */
639
877
  function reconcileSchema(db) {
640
878
  const gaps = schemaGaps(db);
641
- if (gaps.columns.length === 0 && !gaps.stepQuestionsTable && !gaps.guardrailSetsTable
642
- && !gaps.costLedgerTable && !gaps.modelCostFlagsTable) return; // clean — no lock
879
+ if (gaps.columns.length === 0 && gaps.tables.length === 0
880
+ && gaps.indexes.length === 0) return; // clean — no lock
643
881
  db.exec('BEGIN IMMEDIATE');
644
882
  try {
645
883
  repairSchemaGaps(db, schemaGaps(db)); // re-probe under the lock: race-safe
@@ -650,6 +888,29 @@ function reconcileSchema(db) {
650
888
  }
651
889
  }
652
890
 
891
+ /**
892
+ * The fs→db import runs after migrate(), so v1 templates it brings in miss the
893
+ * V24 archive pass. Cheap probe first (hand-seeded upgrade fixtures reach getDb()
894
+ * with a partial `workflows` table — without the column probe this throws
895
+ * "no such column: version" and takes the whole open down), then one idempotent
896
+ * reconcile in its own transaction. No seeding on this path — the seeds, if any,
897
+ * landed during the ladder. Its own report key keeps the break's account intact.
898
+ */
899
+ function reconcileAfterFsImport(db) {
900
+ const cols = new Set(db.prepare('PRAGMA table_info(workflows)').all().map((c) => c.name));
901
+ if (!cols.has('version') || !cols.has('archived_at')) return;
902
+ const hit = db.prepare('SELECT 1 AS n FROM workflows WHERE version = 1 AND archived_at IS NULL LIMIT 1').get();
903
+ if (!hit) return;
904
+ db.exec('BEGIN IMMEDIATE');
905
+ let report;
906
+ try { report = reconcileV1Workflows(db, { seed: false }); db.exec('COMMIT'); }
907
+ catch (err) { db.exec('ROLLBACK'); throw err; }
908
+ if (hasSqliteTable(db, 'store_meta')) {
909
+ db.prepare('INSERT OR REPLACE INTO store_meta (key, kind, data) VALUES (?, ?, ?)')
910
+ .run('migration:v24:fs-import', 'migration', JSON.stringify(report));
911
+ }
912
+ }
913
+
653
914
  /**
654
915
  * Incremental v11 -> v12 REPAIR migration for the collision documented on
655
916
  * INCREMENTAL_COLUMNS: DBs stamped 11 by the ai-enablement-onboarding branch's
@@ -666,7 +927,7 @@ function applySchemaV12(db) {
666
927
  /**
667
928
  * Incremental v12 -> v13 migration (plugin task-sources, spec 2026-07-12 §10):
668
929
  * pipelines.source_type TEXT DEFAULT 'prompt' -- 'prompt' | 'markdown' | 'plugin'
669
- * pipelines.source_ref TEXT -- JSON {plugin,sourceId,taskId,url,title}; NULL unless plugin
930
+ * pipelines.source_ref TEXT -- JSON {plugin,sourceId,taskId,profile,inputs,url,title}; NULL unless plugin
670
931
  * workflows.origin TEXT -- 'plugin:<name>' provenance; NULL = user-created
671
932
  * Implemented as a CONDITIONAL repair (same shape as applySchemaV12), NOT a plain
672
933
  * DDL string: the three columns live in INCREMENTAL_COLUMNS (hard rule above), so
@@ -684,7 +945,7 @@ function applySchemaV13(db) {
684
945
  * guardrail_sets table -- named guardrail sets (built-ins are virtual, never rows)
685
946
  * pipelines.guardrails_id TEXT -- the run's selected set id; NULL = legacy/pre-entity row
686
947
  * A CONDITIONAL repair like applySchemaV12/13, NOT plain DDL: the column lives in
687
- * INCREMENTAL_COLUMNS and the table in the schemaGaps flags, so earlier heals on a
948
+ * INCREMENTAL_COLUMNS and the table in INCREMENTAL_TABLES, so earlier heals on a
688
949
  * ladder pass from <12 have ALREADY added them — an unconditional ALTER/CREATE
689
950
  * would throw "duplicate column"/"table already exists" on every fresh DB.
690
951
  */
@@ -742,6 +1003,358 @@ function applySchemaV16(db) {
742
1003
  }
743
1004
  }
744
1005
 
1006
+ /**
1007
+ * v19 -> v20 (ask-cost-statistics-design.md §6): ask_cost_ledger — the
1008
+ * append-only, FK-free Ask Worca spend ledger (survives thread deletion) —
1009
+ * plus a backfill of one row per already-persisted costed assistant message,
1010
+ * so pre-upgrade chat spend lands in Statistics. Gap-repair first, v12-v16
1011
+ * style; the NOT EXISTS guard keeps re-runs (divergent stamps) idempotent.
1012
+ * Threads deleted before the upgrade left no messages (CASCADE) — accepted.
1013
+ * The backfill runs only on a ladder pass through <20; a binary downgrade
1014
+ * after v20 leaves its chat spend un-ledgered forever (the stamp stays 20) —
1015
+ * same accepted posture as cost_ledger.
1016
+ */
1017
+ function applySchemaV20(db) {
1018
+ repairSchemaGaps(db, schemaGaps(db));
1019
+ // A hand-built or divergent DB (minimal test seeds) can lack ask_messages
1020
+ // columns entirely — such a DB never stored a chat cost, nothing to backfill.
1021
+ const has = (table, col) =>
1022
+ db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
1023
+ if (!has('ask_messages', 'cost_usd')) return;
1024
+ const rows = db.prepare(`
1025
+ SELECT id, thread_id, cost_usd, usage, model, created_at
1026
+ FROM ask_messages
1027
+ WHERE cost_usd > 0
1028
+ AND NOT EXISTS (SELECT 1 FROM ask_cost_ledger l WHERE l.message_id = ask_messages.id)
1029
+ `).all();
1030
+ const ins = db.prepare(
1031
+ 'INSERT INTO ask_cost_ledger (thread_id, message_id, amount_usd, tokens, model, ts) VALUES (?, ?, ?, ?, ?, ?)');
1032
+ for (const r of rows) {
1033
+ const ts = Date.parse(r.created_at ?? '');
1034
+ if (!Number.isFinite(ts)) continue;
1035
+ let tokens = null;
1036
+ try {
1037
+ const u = JSON.parse(r.usage ?? 'null');
1038
+ if (u && typeof u === 'object') {
1039
+ tokens = ['input', 'output', 'cacheRead', 'cacheCreation']
1040
+ .reduce((a, k) => a + (Number(u[k]) || 0), 0);
1041
+ }
1042
+ } catch { /* unreadable usage — tokens stay NULL */ }
1043
+ ins.run(r.thread_id, r.id, r.cost_usd, tokens, r.model, ts);
1044
+ }
1045
+ }
1046
+
1047
+ /** v22: both new tables are IF NOT EXISTS and the one new ask_run_links column
1048
+ * lives in INCREMENTAL_COLUMNS, so the whole step IS the reconcile — the
1049
+ * applySchemaV12/13/14/15 shape, with nothing to backfill.
1050
+ * On a FRESH DB (indeed any ladder pass from <12) this step is already a no-op
1051
+ * by the time it runs: applySchemaV12 is the ladder's FIRST repairSchemaGaps, so
1052
+ * it fires ASK_DDL and DIFF_COMMENTS_DDL, and — because repairSchemaGaps ALTERs
1053
+ * its columns AFTER its CREATEs, against a re-probe — adds
1054
+ * ask_run_links.comment_ids in that same pass. This step is what serves an
1055
+ * EXISTING v20/v21 DB (and any stamp that first materialises ask_run_links right
1056
+ * here), and re-running it is idempotent by construction.
1057
+ */
1058
+ function applySchemaV22(db) {
1059
+ repairSchemaGaps(db, schemaGaps(db));
1060
+ }
1061
+
1062
+ /** v23 (node-graph v2): workflows.graph/archived_at, the pipeline_steps execution
1063
+ * ledger columns, pipelines.outcome and config_workflow_wires. Every piece lives
1064
+ * in INCREMENTAL_COLUMNS/INCREMENTAL_TABLES, so the whole step IS the reconcile —
1065
+ * the applySchemaV22 shape, with nothing to backfill. Purely additive: no row is
1066
+ * read, rewritten or archived here (that is the v24 break). */
1067
+ function applySchemaV23(db) {
1068
+ repairSchemaGaps(db, schemaGaps(db));
1069
+ }
1070
+
1071
+ /** v25 (sub-agent model policy): config_workflow_nodes.subagent_model holds the
1072
+ * per-node setting, sub_agents.run_model records the model a spawned child
1073
+ * actually ran on. Both are plain additive columns declared in
1074
+ * INCREMENTAL_COLUMNS — and this repairSchemaGaps call is what CREATES them on
1075
+ * the one real upgrade path: a DB stamped exactly 24 takes the LADDER, where
1076
+ * this is the only repair before the version stamp (reconcileSchema runs only
1077
+ * on the fast path, user_version >= SCHEMA_VERSION). Do NOT delete this step
1078
+ * as redundant; test/db-migrate-v25.test.mjs pins the stamped-24 path. */
1079
+ function applySchemaV25(db) {
1080
+ repairSchemaGaps(db, schemaGaps(db));
1081
+ }
1082
+
1083
+ /** Audit channel for V24 (dev convention: one console.warn per decision). */
1084
+ const auditV24 = (msg) => console.warn(`[worca] V24: ${msg}`);
1085
+
1086
+ /** The pipeline_events line + stderr audit a swept v1 run gets. VERBATIM. */
1087
+ export const V1_RUN_RETIRED = 'paused on the v1 engine before the graph rework — not resumable';
1088
+
1089
+ /** Absolute path of the OPEN handle's main database file ('' for :memory:). */
1090
+ function mainDbFile(db) {
1091
+ try {
1092
+ const row = db.prepare('PRAGMA database_list').all().find((r) => r.name === 'main');
1093
+ return row && typeof row.file === 'string' ? row.file : '';
1094
+ } catch { return ''; }
1095
+ }
1096
+
1097
+ /** A .pre-v24.bak we would actually restore from: opens as SQLite and is stamped
1098
+ * BEFORE the break (a partial file fails to open, a 0-byte file reads 0).
1099
+ * databaseSyncCtor() is db.mjs's own lazy accessor — node:sqlite is deliberately
1100
+ * NOT imported at module-link time (see the file header), so never write
1101
+ * `new DatabaseSync(...)` here. */
1102
+ function usableBackup(bak) {
1103
+ if (!existsSync(bak)) return false;
1104
+ let probe = null;
1105
+ try {
1106
+ probe = new (databaseSyncCtor())(bak, { readOnly: true });
1107
+ const v = probe.prepare('PRAGMA user_version').get().user_version;
1108
+ return v > 0 && v < 24;
1109
+ } catch { return false; }
1110
+ finally { try { if (probe) probe.close(); } catch { /* ignore */ } }
1111
+ }
1112
+
1113
+ /**
1114
+ * V24 is the ONLY ladder step that rewrites user data, so an existing DB is
1115
+ * snapshotted BEFORE the transaction opens (`VACUUM INTO` cannot run inside one
1116
+ * — measured: "cannot VACUUM from within a transaction"). Skipped for a fresh
1117
+ * file (nothing to lose) and for `:memory:` (PRAGMA database_list gives file '').
1118
+ * A throw here is FATAL on purpose: no backup, no break.
1119
+ *
1120
+ * Two hazards the naive `existsSync` guard misses, both measured on node 25:
1121
+ * - a CONCURRENT migrator (CLI + UI first launch) can win the race and leave us
1122
+ * with `output file already exists` (errcode 1) — NOT a busy-shaped error, so
1123
+ * getDb()'s retry loop would rethrow and kill this process. A backup someone
1124
+ * else just took is a SUCCESS, so re-check and return.
1125
+ * - a 0-byte / half-written .bak from a crashed attempt would be read as "done".
1126
+ * VACUUM INTO overwrites an empty file happily, so re-take unless the file is
1127
+ * a readable SQLite DB carrying the pre-break user_version.
1128
+ */
1129
+ function backupBeforeV24(db) {
1130
+ const current = db.prepare('PRAGMA user_version').get().user_version;
1131
+ if (current <= 0 || current >= 24) return;
1132
+ const file = mainDbFile(db);
1133
+ if (!file) return; // :memory:
1134
+ const bak = `${file}.pre-v24.bak`;
1135
+ if (usableBackup(bak)) return;
1136
+ try {
1137
+ db.exec(`VACUUM INTO '${bak.replace(/'/g, "''")}'`);
1138
+ } catch (err) {
1139
+ // Someone else took it while we were deciding — that is the point of the file.
1140
+ if (usableBackup(bak)) return;
1141
+ throw new Error(`worca cannot take the pre-v24 database backup at ${bak}: `
1142
+ + `${err && err.message ? err.message : err}. The v2 upgrade rewrites saved `
1143
+ + 'pipelines, so it refuses to run without one — free disk space or make '
1144
+ + `${file.replace(/\/[^/]*$/, '')} writable and start worca again.`, { cause: err });
1145
+ }
1146
+ }
1147
+
1148
+ /** v24: the break. Runs INSIDE the ladder transaction. */
1149
+ function applySchemaV24(db, { existing }) {
1150
+ repairSchemaGaps(db, schemaGaps(db)); // heal a divergent stamp first
1151
+ const report = reconcileV1Workflows(db, { seed: existing });
1152
+ report.sweptRuns = sweepV1Runs(db);
1153
+ // Minimal hand-seeded ladders (migrate-v20, db-migrate-v23) have no store_meta
1154
+ // table: there is nothing to audit and the INSERT would abort the ladder tx.
1155
+ if (!hasSqliteTable(db, 'store_meta')) return;
1156
+ // INSERT OR IGNORE, deliberately: this row records THE BREAK. A later
1157
+ // reconcile pass (fs-import, a divergent re-stamp) must not overwrite the
1158
+ // account of what the upgrade actually did — it writes its own key instead.
1159
+ db.prepare('INSERT OR IGNORE INTO store_meta (key, kind, data) VALUES (?, ?, ?)')
1160
+ .run('migration:v24', 'migration', JSON.stringify(report));
1161
+ }
1162
+
1163
+ /** Does this handle carry a table with that name? (Hand-seeded ladder fixtures build a
1164
+ * two-column `pipelines` and nothing else, so every V24 pass probes first.) */
1165
+ function hasSqliteTable(db, name) {
1166
+ return !!db.prepare("SELECT 1 AS n FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
1167
+ }
1168
+
1169
+ /**
1170
+ * Archive every LIVE v1 template row (D7: kept, hidden, never converted, never
1171
+ * deleted), insert the 7 seed graphs on an EXISTING DB, fold the coexistence
1172
+ * alias, re-attach the static overlay maps and reset an archived active
1173
+ * workflow. Fully idempotent: a second run changes nothing.
1174
+ * @param {DatabaseSync} db
1175
+ * @param {{seed?:boolean}} [opts] seed=true only for a DB that existed before V24
1176
+ * @returns {{at:string, archived:string[], seeded:string[], seedsSkipped:string[],
1177
+ * overlayNodes:number, overlayWires:number, aliasRemapped:number,
1178
+ * overlaysDisplaced:number, activeReset:string[], sweptRuns:string[]}}
1179
+ */
1180
+ export function reconcileV1Workflows(db, { seed = false } = {}) {
1181
+ const report = { at: new Date().toISOString(), archived: [], seeded: [], seedsSkipped: [],
1182
+ overlayNodes: 0, overlayWires: 0, aliasRemapped: 0, overlaysDisplaced: 0,
1183
+ activeReset: [], sweptRuns: [] };
1184
+ const cols = new Set(db.prepare('PRAGMA table_info(workflows)').all().map((c) => c.name));
1185
+ for (const need of ['version', 'steps', 'feedbacks', 'created_at', 'updated_at', 'graph', 'archived_at']) {
1186
+ if (!cols.has(need)) return report; // minimal hand-seeded test schema: nothing to do
1187
+ }
1188
+ // Hand-seeded upgrade fixtures (subagent-migration*.test.mjs seed a faithful
1189
+ // v1 schema) carry `workflows` + `config_workflow_nodes` but NOT
1190
+ // config_workflow_feedbacks / config_workflow_wires / project_config: the base
1191
+ // DDL that creates them only runs for current < 1. Probe before every pass.
1192
+ const hasTable = (t) => hasSqliteTable(db, t);
1193
+ const now = new Date().toISOString();
1194
+
1195
+ // 1) Archive — the WHERE makes it idempotent and keeps an already-archived row's stamp.
1196
+ const live = db.prepare('SELECT id, name FROM workflows WHERE version = 1 AND archived_at IS NULL').all();
1197
+ const archive = db.prepare('UPDATE workflows SET archived_at = ? WHERE id = ? AND version = 1 AND archived_at IS NULL');
1198
+ for (const row of live) {
1199
+ archive.run(now, row.id);
1200
+ report.archived.push(row.id);
1201
+ auditV24(`archived v1 workflow ${row.id} (${row.name}) — v1 templates are not runnable on the graph engine`);
1202
+ }
1203
+
1204
+ // 2) Seeds — EXISTING DBs only (fresh installs keep Default only, decision D7).
1205
+ if (seed) {
1206
+ const find = db.prepare('SELECT id, archived_at FROM workflows WHERE id = ?');
1207
+ const insert = db.prepare(`INSERT OR IGNORE INTO workflows
1208
+ (id, name, version, domain, origin, steps, feedbacks, graph, created_at, updated_at, archived_at)
1209
+ VALUES (?, ?, 2, ?, NULL, '[]', '[]', ?, ?, ?, NULL)`);
1210
+ for (const t of SEED_TEMPLATES) {
1211
+ const row = find.get(t.id);
1212
+ if (row) {
1213
+ if (row.archived_at) {
1214
+ report.seedsSkipped.push(t.id);
1215
+ auditV24(`seed ${t.id} skipped — id held by an archived template`);
1216
+ }
1217
+ continue; // a LIVE v2 row with that id is the user's own
1218
+ }
1219
+ insert.run(t.id, t.name, t.domain, JSON.stringify({ nodes: t.nodes, wires: t.wires }), t.createdAt, now);
1220
+ report.seeded.push(t.id);
1221
+ }
1222
+ }
1223
+ // 3) The coexistence alias dies FIRST, and it WINS: an overlay the user set on
1224
+ // the graph default under its alias is NEWER than any v1-era row on the same
1225
+ // workflow id. INSERT OR REPLACE (not UPDATE OR IGNORE) so the alias row
1226
+ // overwrites a colliding target instead of being silently dropped, then the
1227
+ // alias rows are removed. Every displaced row is audited.
1228
+ // Running this BEFORE the NODE_ID_MAP remap is the whole point: the other
1229
+ // order mints wf_default/n_plan from the LEGACY s0_0 row first, and the
1230
+ // fold then hits the PK and is skipped — the user's newer value lost,
1231
+ // aliasRemapped 0, no audit line, the alias rows still there.
1232
+ // The column list is read from the LIVE schema, never hardcoded: a fixed
1233
+ // list would silently blank config_workflow_nodes.ask_questions (an
1234
+ // INCREMENTAL_COLUMNS column) on every folded row.
1235
+ const ALIAS = 'wf_default_v2';
1236
+ const DEFAULT_ID = 'wf_default';
1237
+ for (const [table, keyCol] of [
1238
+ ['config_workflow_nodes', 'node_id'],
1239
+ ['config_workflow_wires', 'wire_id'],
1240
+ ]) {
1241
+ if (!hasTable(table)) continue;
1242
+ const colNames = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
1243
+ const colList = colNames.join(', ');
1244
+ const sel = colNames.map((c) => (c === 'workflow_id' ? `'${DEFAULT_ID}' AS workflow_id` : c)).join(', ');
1245
+ const displaced = db.prepare(
1246
+ `SELECT count(*) AS n FROM ${table} a WHERE a.workflow_id = ?
1247
+ AND EXISTS (SELECT 1 FROM ${table} b WHERE b.project_key = a.project_key
1248
+ AND b.workflow_id = ? AND b.${keyCol} = a.${keyCol})`).get(ALIAS, DEFAULT_ID).n;
1249
+ if (displaced) auditV24(`${table}: ${displaced} wf_default overlay row(s) replaced by the newer wf_default_v2 value`);
1250
+ report.aliasRemapped += db.prepare(
1251
+ `INSERT OR REPLACE INTO ${table} (${colList}) SELECT ${sel} FROM ${table} WHERE workflow_id = ?`).run(ALIAS).changes;
1252
+ db.prepare(`DELETE FROM ${table} WHERE workflow_id = ?`).run(ALIAS);
1253
+ }
1254
+ if (hasTable('project_config')) {
1255
+ report.aliasRemapped += db.prepare('UPDATE project_config SET active_workflow_id = ? WHERE active_workflow_id = ?')
1256
+ .run(DEFAULT_ID, ALIAS).changes;
1257
+ }
1258
+ const pipeCols = new Set(db.prepare('PRAGMA table_info(pipelines)').all().map((c) => c.name));
1259
+ if (pipeCols.has('resume_point')) {
1260
+ // Only a v2 point can name the alias meaningfully; a v1 point that names it
1261
+ // is swept by sweepV1Runs, so the `version = 2` filter is load-bearing.
1262
+ report.aliasRemapped += db.prepare(`UPDATE pipelines
1263
+ SET resume_point = json_set(resume_point, '$.workflowId', ?)
1264
+ WHERE resume_point IS NOT NULL AND json_valid(resume_point)
1265
+ AND json_extract(resume_point, '$.version') = 2
1266
+ AND json_extract(resume_point, '$.workflowId') = ?`).run(DEFAULT_ID, ALIAS).changes;
1267
+ }
1268
+ if (report.aliasRemapped) {
1269
+ auditV24(`remapped ${report.aliasRemapped} row(s) from the wf_default_v2 alias to wf_default`);
1270
+ }
1271
+
1272
+ // 4) Static overlay maps, AFTER the fold (idempotent). A rename that would
1273
+ // collide with a row the fold just installed is DROPPED — the v2 value wins
1274
+ // — and the stale v1-keyed row is deleted so no v1 node id survives.
1275
+ if (hasTable('config_workflow_nodes')) {
1276
+ const remapNode = db.prepare(
1277
+ 'UPDATE OR IGNORE config_workflow_nodes SET node_id = ? WHERE workflow_id = ? AND node_id = ?');
1278
+ const dropStale = db.prepare('DELETE FROM config_workflow_nodes WHERE workflow_id = ? AND node_id = ?');
1279
+ for (const [wfId, map] of Object.entries(NODE_ID_MAP)) {
1280
+ for (const [oldId, newId] of Object.entries(map)) {
1281
+ report.overlayNodes += remapNode.run(newId, wfId, oldId).changes;
1282
+ const left = dropStale.run(wfId, oldId).changes; // 0 unless the rename was ignored
1283
+ if (left) {
1284
+ report.overlaysDisplaced += left;
1285
+ auditV24(`${wfId}: dropped ${left} stale "${oldId}" overlay row(s) — "${newId}" already carries a newer value`);
1286
+ }
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ // 5) config_workflow_feedbacks rows are COPIED (never moved) onto their wire
1292
+ // ids: the table stays vestigial but readable, so the migration is reversible.
1293
+ if (hasTable('config_workflow_feedbacks') && hasTable('config_workflow_wires')) {
1294
+ const copyWire = db.prepare(`INSERT OR IGNORE INTO config_workflow_wires
1295
+ (project_key, workflow_id, wire_id, max_cycles)
1296
+ SELECT project_key, workflow_id, ?, max_cycles
1297
+ FROM config_workflow_feedbacks WHERE workflow_id = ? AND fb_id = ?`);
1298
+ for (const [wfId, map] of Object.entries(FB_WIRE_MAP)) {
1299
+ for (const [fbId, wireId] of Object.entries(map)) {
1300
+ report.overlayWires += copyWire.run(wireId, wfId, fbId).changes;
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ // 6) An active workflow that just got archived is not runnable — fall back.
1306
+ // The WHERE is scoped to ARCHIVED rows: a project pointing at a live seed
1307
+ // keeps its choice.
1308
+ const stranded = hasTable('project_config') ? db.prepare(`SELECT project_key, active_workflow_id FROM project_config
1309
+ WHERE active_workflow_id IN (SELECT id FROM workflows WHERE archived_at IS NOT NULL)`).all() : [];
1310
+ if (stranded.length) {
1311
+ const reset = db.prepare('UPDATE project_config SET active_workflow_id = ? WHERE project_key = ?');
1312
+ for (const r of stranded) {
1313
+ reset.run(DEFAULT_ID, r.project_key);
1314
+ report.activeReset.push(r.project_key);
1315
+ auditV24(`project ${r.project_key}: active pipeline ${r.active_workflow_id} was archived — reset to wf_default`);
1316
+ }
1317
+ }
1318
+ return report;
1319
+ }
1320
+
1321
+ /**
1322
+ * Retire every run that can only be resumed by the v1 engine: paused OR
1323
+ * interrupted with a resume point that is not `version: 2`. The row keeps its
1324
+ * honest status trail — status becomes 'interrupted', the resume point is
1325
+ * NULLed (so History hides Resume and removePluginWorkflows can never be
1326
+ * stranded on it) and a pipeline_events line records why. `json_valid` guards a
1327
+ * corrupt blob: it is swept too, and json_extract never throws on it (the spec's
1328
+ * predicate is `!= 2` alone; the guard is this plan's addition so a corrupt blob
1329
+ * cannot abort the migration transaction).
1330
+ * Exported and callable without an argument so boot/reconcile paths can run it
1331
+ * on a DB that a divergent ladder stamped past 24.
1332
+ * @param {DatabaseSync} [db]
1333
+ * @returns {string[]} the ids swept
1334
+ */
1335
+ export function sweepV1Runs(db = getDb()) {
1336
+ // Minimal hand-seeded test schemas (migrate-v20, db-migrate-v23 build a
1337
+ // `pipelines (id TEXT PRIMARY KEY)` table and run the whole ladder on it) have
1338
+ // neither the columns nor pipeline_events: nothing to sweep, and the SELECT
1339
+ // below would throw "no such column: status" INSIDE the ladder transaction.
1340
+ const cols = new Set(db.prepare('PRAGMA table_info(pipelines)').all().map((c) => c.name));
1341
+ if (!cols.has('status') || !cols.has('resume_point')) return [];
1342
+ if (!hasSqliteTable(db, 'pipeline_events')) return [];
1343
+ const rows = db.prepare(`SELECT id FROM pipelines
1344
+ WHERE status IN ('paused', 'interrupted') AND resume_point IS NOT NULL
1345
+ AND (json_valid(resume_point) = 0 OR json_extract(resume_point, '$.version') != 2)`).all();
1346
+ if (!rows.length) return [];
1347
+ const clear = db.prepare("UPDATE pipelines SET status = 'interrupted', resume_point = NULL WHERE id = ?");
1348
+ const event = db.prepare('INSERT INTO pipeline_events (pipeline_id, ts, text) VALUES (?, ?, ?)');
1349
+ const now = new Date().toISOString();
1350
+ for (const r of rows) {
1351
+ clear.run(r.id);
1352
+ event.run(r.id, now, V1_RUN_RETIRED);
1353
+ auditV24(`run ${r.id}: ${V1_RUN_RETIRED}`);
1354
+ }
1355
+ return rows.map((r) => r.id);
1356
+ }
1357
+
745
1358
  /**
746
1359
  * Idempotent, versioned, CONCURRENCY-SAFE schema migration. Fast-path no-op when
747
1360
  * PRAGMA user_version already == SCHEMA_VERSION. Otherwise it takes the write lock
@@ -770,6 +1383,9 @@ export function migrate(db) {
770
1383
  // up front (a deferred BEGIN would not lock until the first write, letting two
771
1384
  // migrators both pass the gate and double-apply SCHEMA_V1 → "table projects already
772
1385
  // exists"). Under the lock we re-read user_version and no-op if the winner stamped it.
1386
+ // V24 rewrites data (archive + seed + sweep): snapshot the file first. Outside
1387
+ // the tx by necessity — VACUUM cannot run inside one — and fatal on throw.
1388
+ backupBeforeV24(db);
773
1389
  db.exec('BEGIN IMMEDIATE');
774
1390
  try {
775
1391
  const current = db.prepare('PRAGMA user_version').get().user_version; // re-check under lock
@@ -791,6 +1407,16 @@ export function migrate(db) {
791
1407
  if (current < 15) applySchemaV15(db);
792
1408
  if (current < 16) applySchemaV16(db);
793
1409
  if (current < 17) db.exec(MODEL_COST_FLAGS_DDL); // IF NOT EXISTS — reconcile-safe
1410
+ // v17 -> v18 (task-source profiles): source_bindings. IF NOT EXISTS —
1411
+ // reconcileSchema may already have created it on a divergently-stamped DB.
1412
+ if (current < 18) db.exec(SOURCE_BINDINGS_DDL);
1413
+ if (current < 19) db.exec(ASK_DDL); // IF NOT EXISTS — reconcile-safe
1414
+ if (current < 20) applySchemaV20(db); // ask_cost_ledger + backfill
1415
+ if (current < 21) db.exec(ASK_WORKTREES_DDL); // IF NOT EXISTS — reconcile-safe
1416
+ if (current < 22) applySchemaV22(db); // tables + the ask_run_links column
1417
+ if (current < 23) applySchemaV23(db); // graph columns + config_workflow_wires
1418
+ if (current < 24) applySchemaV24(db, { existing: current >= 1 }); // the v2 break
1419
+ if (current < 25) applySchemaV25(db); // sub-agent model policy + recorded child model
794
1420
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
795
1421
  db.exec('COMMIT');
796
1422
  } catch (err) {
@@ -818,6 +1444,17 @@ export function closeDb() {
818
1444
  * Not re-entrant: SQLite has no nested BEGIN, so a tx() inside a tx() throws
819
1445
  * rather than silently joining (or corrupting) the outer transaction. Compose by
820
1446
  * passing data between calls, not by nesting.
1447
+ *
1448
+ * BEGIN IMMEDIATE, not a deferred BEGIN — every tx() here is a WRITE transaction,
1449
+ * and most of them read first (MAX(seq)+1, a read-modify-write of a JSON column, a
1450
+ * uniqueness scan). A deferred BEGIN takes only a WAL read snapshot on that first
1451
+ * SELECT and then has to UPGRADE at the first write; if another process committed
1452
+ * in between, SQLite answers SQLITE_BUSY_SNAPSHOT, which the busy handler does NOT
1453
+ * retry (busy_timeout cannot help: the snapshot is already stale). The whole tx()
1454
+ * threw "database is locked" and the write was silently lost. Taking the write
1455
+ * lock up front makes busy_timeout apply instead, so a second writer QUEUES.
1456
+ * migrate()/reconcileSchema() take the same lock for the same reason. Keep every
1457
+ * tx() body short and synchronous: the lock is held for its whole duration.
821
1458
  * @template T
822
1459
  * @param {() => T} fn
823
1460
  * @returns {T}
@@ -825,7 +1462,7 @@ export function closeDb() {
825
1462
  export function tx(fn) {
826
1463
  if (_txDepth > 0) throw new Error('tx(): a transaction is already active (nested tx is not supported)');
827
1464
  const db = getDb();
828
- db.exec('BEGIN');
1465
+ db.exec('BEGIN IMMEDIATE');
829
1466
  _txDepth = 1;
830
1467
  try {
831
1468
  const result = fn();