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