@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.
- package/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- 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
|
-
|
|
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
|
|
103
|
-
* structured errcode
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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 &&
|
|
642
|
-
&&
|
|
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
|
|
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();
|