@worca/app 0.0.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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
package/src/core/db.mjs
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
// src/core/db.mjs
|
|
2
|
+
// Singleton SQLite database for all of Worca CC's structured state. Uses the
|
|
3
|
+
// built-in, SYNCHRONOUS node:sqlite (DatabaseSync) — matching the existing
|
|
4
|
+
// synchronous worcaHome()/getWorcaRoot() resolution, so no async refactor is
|
|
5
|
+
// needed anywhere. The DB lives at <worcaHome>/worca-cc.db (WAL), resolved fresh
|
|
6
|
+
// on first open via projects.mjs#worcaHome() (WORCA_HOME env > settings.json
|
|
7
|
+
// root > OS home), exactly like every other module's data path.
|
|
8
|
+
//
|
|
9
|
+
// node:sqlite is loaded LAZILY (synchronous createRequire, like preflight-node.mjs)
|
|
10
|
+
// inside databaseSyncCtor() rather than via a top-level `import`. A top-level import
|
|
11
|
+
// is linked when the whole static ESM graph links — BEFORE any entry-point statement
|
|
12
|
+
// runs — so node:sqlite's one-time ExperimentalWarning would fire before the entry
|
|
13
|
+
// points (src/cli/worca-cc.mjs, ui/server.mjs) install their `process.on('warning')`
|
|
14
|
+
// filter, leaking the warning on flagless direct-bin runs. Deferring the load to the
|
|
15
|
+
// first getDb() (which only happens at runtime, after the filter is installed) lets
|
|
16
|
+
// the filter suppress it. createRequire keeps the load SYNCHRONOUS — `await import`
|
|
17
|
+
// would make getDb() async and break the synchronous data layer.
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { mkdirSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { worcaHome } from './projects.mjs';
|
|
23
|
+
import { maybeMigrateFromFs } from './migrate-fs-to-db.mjs';
|
|
24
|
+
|
|
25
|
+
const _require = createRequire(import.meta.url);
|
|
26
|
+
let _DatabaseSync; // cached node:sqlite DatabaseSync ctor (lazy-loaded once)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lazily and SYNCHRONOUSLY resolve the node:sqlite DatabaseSync constructor. The
|
|
30
|
+
* load is deferred out of module-link time (see header) so the entry points can
|
|
31
|
+
* install their ExperimentalWarning filter first; node:sqlite is a builtin so
|
|
32
|
+
* createRequire resolves it synchronously even from this ESM module.
|
|
33
|
+
* @returns {typeof import('node:sqlite').DatabaseSync}
|
|
34
|
+
*/
|
|
35
|
+
function databaseSyncCtor() {
|
|
36
|
+
if (!_DatabaseSync) ({ DatabaseSync: _DatabaseSync } = _require('node:sqlite'));
|
|
37
|
+
return _DatabaseSync;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let _db = null; // the singleton handle, or null when closed/never-opened
|
|
41
|
+
let _txDepth = 0; // guards against re-entrant tx(): node:sqlite has no nested BEGIN
|
|
42
|
+
let _stmtCache = new Map(); // sql text -> cached StatementSync (per open handle)
|
|
43
|
+
|
|
44
|
+
/** WAL busy-timeout: wait up to 5s for a competing writer (CLI + UI). */
|
|
45
|
+
const BUSY_TIMEOUT_MS = 5000;
|
|
46
|
+
|
|
47
|
+
/** First-launch open retries (spec §8): a competing process can make the journal_mode=
|
|
48
|
+
* WAL switch or the schema migration return SQLITE_BUSY that the busy-handler will not
|
|
49
|
+
* itself retry. Bounded retry with a short synchronous backoff covers it. */
|
|
50
|
+
const OPEN_RETRY_LIMIT = 100;
|
|
51
|
+
const OPEN_BACKOFF_MS = 15;
|
|
52
|
+
|
|
53
|
+
/** Latest schema version. Bump + append a new migration step when the DDL grows. */
|
|
54
|
+
const SCHEMA_VERSION = 17;
|
|
55
|
+
|
|
56
|
+
/** Absolute path to the database file: <worcaHome>/worca-cc.db. */
|
|
57
|
+
export function dbPath() {
|
|
58
|
+
return join(worcaHome(), 'worca-cc.db');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Open (lazily) and return the singleton DatabaseSync. First open creates
|
|
63
|
+
* <worcaHome> if needed and opens the file. (Pragmas + migrate + fs→db hook are
|
|
64
|
+
* layered on in later tasks of this phase.)
|
|
65
|
+
* @returns {DatabaseSync}
|
|
66
|
+
*/
|
|
67
|
+
export function getDb() {
|
|
68
|
+
if (_db) return _db;
|
|
69
|
+
const home = worcaHome();
|
|
70
|
+
mkdirSync(home, { recursive: true }); // chicken/egg: ensure the dir before open
|
|
71
|
+
const db = _openConfiguredMigrated(); // open + pragmas + migrate, retried on BUSY
|
|
72
|
+
maybeMigrateFromFs(db); // one-shot fs→db import (other phase; self-guarded)
|
|
73
|
+
_db = db; // publish only after the DB is fully ready
|
|
74
|
+
return _db;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Open the DB file, apply pragmas, and migrate — retrying the whole sequence on a
|
|
79
|
+
* transient SQLITE_BUSY / "database is locked". First launch can race a second process
|
|
80
|
+
* (CLI + UI, spec §8): the journal_mode=WAL header switch and the schema migration each
|
|
81
|
+
* need a brief exclusive lock, and the WAL-mode switch in particular returns BUSY that
|
|
82
|
+
* the busy-handler does NOT retry. A bounded synchronous retry makes concurrent first
|
|
83
|
+
* launch deterministic. node:sqlite is sync, so the backoff blocks this thread inline.
|
|
84
|
+
*/
|
|
85
|
+
function _openConfiguredMigrated() {
|
|
86
|
+
for (let attempt = 0; ; attempt++) {
|
|
87
|
+
let db = null;
|
|
88
|
+
try {
|
|
89
|
+
db = new (databaseSyncCtor())(dbPath());
|
|
90
|
+
_configure(db);
|
|
91
|
+
migrate(db);
|
|
92
|
+
return db;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
try { if (db) db.close(); } catch { /* ignore close error during recovery */ }
|
|
95
|
+
if (_isBusyError(err) && attempt < OPEN_RETRY_LIMIT) { _sleepMs(OPEN_BACKOFF_MS); continue; }
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
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
|
+
*/
|
|
107
|
+
function _isBusyError(err) {
|
|
108
|
+
if (err && (err.errcode === 5 || err.errcode === 6)) return true;
|
|
109
|
+
const msg = err && err.message ? err.message : String(err);
|
|
110
|
+
return /locked|busy/i.test(msg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Synchronous sleep (node:sqlite is sync; we must block this thread, not yield it). */
|
|
114
|
+
function _sleepMs(ms) {
|
|
115
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Apply the connection pragmas exactly once on open. journal_mode=WAL is durable
|
|
120
|
+
* (sticks to the file); foreign_keys/busy_timeout/synchronous are per-connection
|
|
121
|
+
* and must be re-applied every open. Done via exec() in one batch.
|
|
122
|
+
*/
|
|
123
|
+
function _configure(db) {
|
|
124
|
+
// busy_timeout is set FIRST so the busy-handler is armed before the first contended
|
|
125
|
+
// operation. NOTE: this only REDUCES (does not eliminate) the journal_mode=WAL switch
|
|
126
|
+
// race — SQLite does not run the busy-handler for the WAL-mode switch, so a colliding
|
|
127
|
+
// first-launch process can still get "database is locked" here. The actual backstop is
|
|
128
|
+
// the open-retry loop in _openConfiguredMigrated(); do NOT remove it on the assumption
|
|
129
|
+
// that pragma ordering alone suffices.
|
|
130
|
+
db.exec(`
|
|
131
|
+
PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS};
|
|
132
|
+
PRAGMA journal_mode = WAL;
|
|
133
|
+
PRAGMA foreign_keys = ON;
|
|
134
|
+
PRAGMA synchronous = NORMAL;
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The FULL, FINAL v1 schema (SQLITE-MIGRATION-SPEC §3). Applied in one transaction
|
|
140
|
+
* by migrate(). All "JSON" columns are TEXT holding a JSON string (SQLite has no
|
|
141
|
+
* JSON type); the owning service modules (de)serialize at their API boundary.
|
|
142
|
+
* COLLATE NOCASE is applied where the spec requires case-insensitive uniqueness
|
|
143
|
+
* (projects.name, workspaces.name), matching the existing duplicate checks.
|
|
144
|
+
*/
|
|
145
|
+
const SCHEMA_V1 = `
|
|
146
|
+
-- projects: the named project registry (was projects.json: [{name,path}]).
|
|
147
|
+
-- key is the stable projectKey (store.mjs). name is case-insensitively unique.
|
|
148
|
+
CREATE TABLE projects (
|
|
149
|
+
key TEXT PRIMARY KEY,
|
|
150
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
151
|
+
path TEXT NOT NULL,
|
|
152
|
+
created_at TEXT NOT NULL
|
|
153
|
+
);
|
|
154
|
+
CREATE UNIQUE INDEX idx_projects_name ON projects (name COLLATE NOCASE);
|
|
155
|
+
|
|
156
|
+
-- workspaces: named sets of 2+ projects (was workspaces.json header fields).
|
|
157
|
+
-- id is the frozen workspaceKey (wks-<slug>-<sha1[:8]>). name is CI-unique.
|
|
158
|
+
CREATE TABLE workspaces (
|
|
159
|
+
id TEXT PRIMARY KEY,
|
|
160
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
161
|
+
description TEXT NOT NULL DEFAULT '',
|
|
162
|
+
created_at TEXT NOT NULL,
|
|
163
|
+
updated_at TEXT NOT NULL
|
|
164
|
+
);
|
|
165
|
+
CREATE UNIQUE INDEX idx_workspaces_name ON workspaces (name COLLATE NOCASE);
|
|
166
|
+
|
|
167
|
+
-- workspace_projects: the ordered projectPaths[] of a workspace (was the array).
|
|
168
|
+
-- ordinal preserves the PERSISTED member order; (workspace_id, ordinal) is the PK.
|
|
169
|
+
-- project_key holds the ABSOLUTE member PATH (ordinal-ordered), NOT a key (A1);
|
|
170
|
+
-- the real projectKey is recomputed on read via store.projectKey(path) (one-way
|
|
171
|
+
-- hash). projectKeys/exists are derived on read (not stored), per
|
|
172
|
+
-- workspaces.mjs#annotate.
|
|
173
|
+
CREATE TABLE workspace_projects (
|
|
174
|
+
workspace_id TEXT NOT NULL,
|
|
175
|
+
project_key TEXT NOT NULL,
|
|
176
|
+
ordinal INTEGER NOT NULL,
|
|
177
|
+
PRIMARY KEY (workspace_id, ordinal),
|
|
178
|
+
FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
-- workflows: user workflow templates (was workflows/<id>.json). DEFAULT_WORKFLOW
|
|
182
|
+
-- stays built-in (not a row). steps/feedbacks are JSON (topology arrays).
|
|
183
|
+
CREATE TABLE workflows (
|
|
184
|
+
id TEXT PRIMARY KEY,
|
|
185
|
+
name TEXT NOT NULL,
|
|
186
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
187
|
+
steps TEXT NOT NULL DEFAULT '[]', -- JSON: [[ {id,key} ]]
|
|
188
|
+
feedbacks TEXT NOT NULL DEFAULT '[]', -- JSON: [ {id,from,to} ]
|
|
189
|
+
created_at TEXT NOT NULL,
|
|
190
|
+
updated_at TEXT NOT NULL
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
-- project_config: per-project model/effort selection (was <projectDir>/.worca-cc/
|
|
194
|
+
-- config.json). steps/custom_models are JSON (the legacy {steps,customModels}
|
|
195
|
+
-- view). active_workflow_id remembers the last New-Pipeline choice. extra is JSON
|
|
196
|
+
-- preserving unknown top-level keys (e.g. webUiTesting).
|
|
197
|
+
CREATE TABLE project_config (
|
|
198
|
+
project_key TEXT PRIMARY KEY,
|
|
199
|
+
steps TEXT NOT NULL DEFAULT '{}', -- JSON: { role: {model?,effort?,fanOut?} }
|
|
200
|
+
custom_models TEXT NOT NULL DEFAULT '[]', -- JSON: [ {id,label} ]
|
|
201
|
+
active_workflow_id TEXT,
|
|
202
|
+
extra TEXT NOT NULL DEFAULT '{}' -- JSON: unknown top-level keys
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
-- config_workflow_nodes: normalized per-node overrides (was config.json
|
|
206
|
+
-- workflows[wf].nodes[nodeId] = {model?,effort?,fanOut?}). One row per node.
|
|
207
|
+
CREATE TABLE config_workflow_nodes (
|
|
208
|
+
project_key TEXT NOT NULL,
|
|
209
|
+
workflow_id TEXT NOT NULL,
|
|
210
|
+
node_id TEXT NOT NULL,
|
|
211
|
+
model TEXT,
|
|
212
|
+
effort TEXT,
|
|
213
|
+
fan_out INTEGER, -- nullable boolean (0/1); NULL = inherit
|
|
214
|
+
PRIMARY KEY (project_key, workflow_id, node_id)
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
-- config_workflow_feedbacks: normalized feedback cycle counts (was config.json
|
|
218
|
+
-- workflows[wf].feedbacks[fbId] = {maxCycles}). max_cycles is an integer >= 1.
|
|
219
|
+
CREATE TABLE config_workflow_feedbacks (
|
|
220
|
+
project_key TEXT NOT NULL,
|
|
221
|
+
workflow_id TEXT NOT NULL,
|
|
222
|
+
fb_id TEXT NOT NULL,
|
|
223
|
+
max_cycles INTEGER NOT NULL,
|
|
224
|
+
PRIMARY KEY (project_key, workflow_id, fb_id)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
-- pipelines: one run = one row (was state.json, scalar fields). workspace_key is
|
|
228
|
+
-- the composite "workspaces/<key>" tag for workspace runs (NULL for single-project).
|
|
229
|
+
-- target is 'project' | 'workspace'. date_prefix/base_name link plan/review md
|
|
230
|
+
-- files (pipeline-delete.mjs#deriveNames). branch/workspace_meta/stepper/tools are
|
|
231
|
+
-- JSON (objects/manifests). prompt is the resolved prompt body.
|
|
232
|
+
CREATE TABLE pipelines (
|
|
233
|
+
id TEXT PRIMARY KEY,
|
|
234
|
+
project_key TEXT NOT NULL,
|
|
235
|
+
workspace_key TEXT,
|
|
236
|
+
target TEXT NOT NULL DEFAULT 'project',
|
|
237
|
+
title TEXT,
|
|
238
|
+
base_name TEXT,
|
|
239
|
+
date_prefix TEXT,
|
|
240
|
+
status TEXT NOT NULL DEFAULT 'created',
|
|
241
|
+
phase TEXT NOT NULL DEFAULT 'created',
|
|
242
|
+
cycle INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
started_at TEXT,
|
|
244
|
+
updated_at TEXT,
|
|
245
|
+
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
246
|
+
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
|
247
|
+
prompt TEXT,
|
|
248
|
+
branch TEXT, -- JSON: { source, feature, worktreeDir, reusedExisting, ... }
|
|
249
|
+
workspace_meta TEXT, -- JSON: { workspaceId, workspaceName, projectKeys, projects[], checkpointRefs, branches, workspaceDescription }
|
|
250
|
+
stepper TEXT, -- JSON: buildStepperManifest() snapshot
|
|
251
|
+
tools TEXT -- JSON: detectTools()/resolved tool descriptor
|
|
252
|
+
-- resume_point TEXT (added v5): JSON dispatch position of a paused run (NULL otherwise)
|
|
253
|
+
);
|
|
254
|
+
CREATE INDEX idx_pipelines_project_started ON pipelines (project_key, started_at);
|
|
255
|
+
CREATE INDEX idx_pipelines_workspace_started ON pipelines (workspace_key, started_at);
|
|
256
|
+
CREATE INDEX idx_pipelines_status ON pipelines (status);
|
|
257
|
+
|
|
258
|
+
-- pipeline_steps: one row per state.steps[] entry (orchestrator _nodeStep/
|
|
259
|
+
-- _recordStep). key is the stable step key "<stepIndex>:<nodeId>[#cycle]".
|
|
260
|
+
-- running_since is the resume timestamp (null when paused); active_ms accumulates.
|
|
261
|
+
CREATE TABLE pipeline_steps (
|
|
262
|
+
pipeline_id TEXT NOT NULL,
|
|
263
|
+
key TEXT NOT NULL,
|
|
264
|
+
node_id TEXT,
|
|
265
|
+
phase TEXT,
|
|
266
|
+
step_index INTEGER,
|
|
267
|
+
cycle INTEGER,
|
|
268
|
+
status TEXT,
|
|
269
|
+
started_at TEXT,
|
|
270
|
+
updated_at TEXT,
|
|
271
|
+
active_ms INTEGER NOT NULL DEFAULT 0,
|
|
272
|
+
running_since TEXT,
|
|
273
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
274
|
+
-- session_id TEXT (added v5): Claude Code session id from the stream-json init event
|
|
275
|
+
PRIMARY KEY (pipeline_id, key),
|
|
276
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
-- pipeline_events: append-only audit trail (was pipeline.md timeline lines, one
|
|
280
|
+
-- "- \`<ISO ts>\` <text>" per appendAudit call). id AUTOINCREMENT preserves order.
|
|
281
|
+
CREATE TABLE pipeline_events (
|
|
282
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
283
|
+
pipeline_id TEXT NOT NULL,
|
|
284
|
+
ts TEXT NOT NULL,
|
|
285
|
+
text TEXT NOT NULL,
|
|
286
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
287
|
+
);
|
|
288
|
+
CREATE INDEX idx_pipeline_events_pipeline ON pipeline_events (pipeline_id, id);
|
|
289
|
+
|
|
290
|
+
-- clarify: one row per pipeline (was clarify.json + clarify-answers.json).
|
|
291
|
+
-- questions/answers are JSON ({questions:[...]} / {answers:[...]} payloads).
|
|
292
|
+
CREATE TABLE clarify (
|
|
293
|
+
pipeline_id TEXT PRIMARY KEY,
|
|
294
|
+
questions TEXT, -- JSON: { questions: [ {id,question,options[2..4],allowFreeText} ] }
|
|
295
|
+
answers TEXT, -- JSON: { answers: [ {id,question,choice} ] }
|
|
296
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
-- reviews: per-cycle review verdicts (was *-review-cycleN.json). kind is one of
|
|
300
|
+
-- refine|impl|plan|ws|webui (5-value open set, A2); verdict is JSON {issues:[...],summary}.
|
|
301
|
+
CREATE TABLE reviews (
|
|
302
|
+
pipeline_id TEXT NOT NULL,
|
|
303
|
+
kind TEXT NOT NULL,
|
|
304
|
+
cycle INTEGER NOT NULL,
|
|
305
|
+
verdict TEXT, -- JSON: { issues:[{severity,title,detail,location}], summary }
|
|
306
|
+
PRIMARY KEY (pipeline_id, kind, cycle),
|
|
307
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
-- store_meta: per-project / per-workspace meta.json (artifacts.mjs ensureMeta/
|
|
311
|
+
-- ensureWorkspaceMeta). key is the store key; kind is 'project' | 'workspace';
|
|
312
|
+
-- data is the full meta JSON ({key,path,name,firstSeenAt} or the workspace shape).
|
|
313
|
+
CREATE TABLE store_meta (
|
|
314
|
+
key TEXT PRIMARY KEY,
|
|
315
|
+
kind TEXT NOT NULL,
|
|
316
|
+
data TEXT NOT NULL -- JSON: the meta.json object
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
-- artifacts: NEW index of the FS markdown + extras paths kept on disk after the
|
|
320
|
+
-- migration. kind is e.g. plan|review|manual-checklist|webui-review|extra; rel_path
|
|
321
|
+
-- is relative to the pipeline/store dir. Replaces baseName-derivation in
|
|
322
|
+
-- pipeline-delete.mjs with an exact lookup.
|
|
323
|
+
CREATE TABLE artifacts (
|
|
324
|
+
pipeline_id TEXT NOT NULL,
|
|
325
|
+
kind TEXT NOT NULL,
|
|
326
|
+
rel_path TEXT NOT NULL,
|
|
327
|
+
PRIMARY KEY (pipeline_id, kind, rel_path),
|
|
328
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
329
|
+
);
|
|
330
|
+
`;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Incremental v1 -> v2 migration (sub-agent indicators feature). Adds the sub_agents
|
|
334
|
+
* table: one row per Task/Agent child agent a pipeline node spawned, persisted so the
|
|
335
|
+
* History UI reconstructs the live "sub-agents" view. Applied by migrate()'s stepwise
|
|
336
|
+
* ladder only when the open DB is below v2 — it NEVER re-runs SCHEMA_V1.
|
|
337
|
+
*
|
|
338
|
+
* FK is to pipelines(id) ONLY (NOT pipeline_steps): writeState() does a DELETE-all +
|
|
339
|
+
* re-INSERT of pipeline_steps on every persist, so a FK to pipeline_steps would
|
|
340
|
+
* cascade-wipe these rows on the next state write. step_key is therefore a plain
|
|
341
|
+
* column (the "<stepIndex>:<nodeId>[#cycle]" key) used for grouping, not a foreign key.
|
|
342
|
+
* Writes are idempotent UPSERTs (upsertSubAgent), never the delete-all path.
|
|
343
|
+
*/
|
|
344
|
+
const SCHEMA_V2 = `
|
|
345
|
+
-- sub_agents: one row per Task/Agent child agent a node spawned (canonical key is the
|
|
346
|
+
-- spawning tool_use id). PK (pipeline_id, id); FK to pipelines ONLY (ON DELETE CASCADE).
|
|
347
|
+
-- status ∈ running|finished|error|stopped. duration_ms/tokens/cost_usd are nullable
|
|
348
|
+
-- telemetry (populated only by the feature-detected hook-events path). step_key is a
|
|
349
|
+
-- plain grouping column (NO FK — survives writeState's pipeline_steps delete-all).
|
|
350
|
+
CREATE TABLE sub_agents (
|
|
351
|
+
pipeline_id TEXT NOT NULL,
|
|
352
|
+
id TEXT NOT NULL,
|
|
353
|
+
step_key TEXT,
|
|
354
|
+
node_id TEXT,
|
|
355
|
+
step_index INTEGER,
|
|
356
|
+
cycle INTEGER,
|
|
357
|
+
label TEXT,
|
|
358
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
359
|
+
started_at TEXT,
|
|
360
|
+
finished_at TEXT,
|
|
361
|
+
duration_ms INTEGER,
|
|
362
|
+
tokens INTEGER,
|
|
363
|
+
cost_usd REAL,
|
|
364
|
+
PRIMARY KEY (pipeline_id, id),
|
|
365
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
366
|
+
);
|
|
367
|
+
CREATE INDEX idx_sub_agents_pipeline ON sub_agents (pipeline_id);
|
|
368
|
+
CREATE INDEX idx_sub_agents_step ON sub_agents (pipeline_id, step_key);
|
|
369
|
+
`;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Incremental v2 -> v3 migration. Adds sub_agents.ui_phase: the node's UI phase
|
|
373
|
+
* (plan|refine|implement|review), stamped at spawn. Lets the live/history views
|
|
374
|
+
* resolve a sub-agent to its graph node + dropdown label by uiPhase as a FALLBACK
|
|
375
|
+
* when the run's real (s0_0-keyed) stepper manifest has not arrived yet. Nullable;
|
|
376
|
+
* legacy rows derive their phase from node_id via the manifest at render time.
|
|
377
|
+
*/
|
|
378
|
+
const SCHEMA_V3 = `ALTER TABLE sub_agents ADD COLUMN ui_phase TEXT;`;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Incremental v3 -> v4 migration (Decomposer feature). Adds two tables recording a
|
|
382
|
+
* run's decomposition: pipeline_phases (ordered phases) and pipeline_tasks (the
|
|
383
|
+
* self-contained task files, each linked to its dynamically-created implementer
|
|
384
|
+
* node via node_id). Both FK to pipelines ONLY (ON DELETE CASCADE) and are written
|
|
385
|
+
* via idempotent UPSERTs — NEVER the writeState delete-all path — so task/phase
|
|
386
|
+
* status survives the pipeline_steps refresh, exactly like sub_agents.
|
|
387
|
+
*/
|
|
388
|
+
const SCHEMA_V4 = `
|
|
389
|
+
CREATE TABLE pipeline_phases (
|
|
390
|
+
pipeline_id TEXT NOT NULL,
|
|
391
|
+
ordinal INTEGER NOT NULL,
|
|
392
|
+
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|error
|
|
393
|
+
started_at TEXT,
|
|
394
|
+
finished_at TEXT,
|
|
395
|
+
PRIMARY KEY (pipeline_id, ordinal),
|
|
396
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
CREATE TABLE pipeline_tasks (
|
|
400
|
+
pipeline_id TEXT NOT NULL,
|
|
401
|
+
id TEXT NOT NULL,
|
|
402
|
+
phase_ordinal INTEGER NOT NULL,
|
|
403
|
+
task_index INTEGER NOT NULL,
|
|
404
|
+
title TEXT,
|
|
405
|
+
file_rel_path TEXT,
|
|
406
|
+
node_id TEXT,
|
|
407
|
+
status TEXT NOT NULL DEFAULT 'pending', -- pending|running|done|error
|
|
408
|
+
started_at TEXT,
|
|
409
|
+
finished_at TEXT,
|
|
410
|
+
PRIMARY KEY (pipeline_id, id),
|
|
411
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
412
|
+
);
|
|
413
|
+
CREATE INDEX idx_pipeline_tasks_pipeline ON pipeline_tasks (pipeline_id);
|
|
414
|
+
`;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Incremental v4 -> v5 migration (Pause/Resume feature). resume_point holds the
|
|
418
|
+
* serialized dispatch position of a paused run (null otherwise); session_id is the
|
|
419
|
+
* Claude Code session captured from the stream-json init event, recorded eagerly so
|
|
420
|
+
* even a crashed run leaves a resumable trail.
|
|
421
|
+
*/
|
|
422
|
+
const SCHEMA_V5 = `
|
|
423
|
+
ALTER TABLE pipelines ADD COLUMN resume_point TEXT;
|
|
424
|
+
ALTER TABLE pipeline_steps ADD COLUMN session_id TEXT;
|
|
425
|
+
`;
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Incremental v5 -> v6 migration (Skills-used indicator). Adds a nullable JSON
|
|
429
|
+
* `skills` column to BOTH agent tables: sub_agents (per sub-agent) and
|
|
430
|
+
* pipeline_steps (per main step agent). Each holds JSON.stringify of a deduped
|
|
431
|
+
* string[] of kind-tagged labels; legacy rows stay NULL and render as no pills.
|
|
432
|
+
* pipeline_steps is delete-all-rewritten on every persist, so its skills live on
|
|
433
|
+
* the live state.steps[] record — like cost_usd.
|
|
434
|
+
*
|
|
435
|
+
* The labels are OPAQUE strings to this layer, which is why §7's per-tool
|
|
436
|
+
* granularity needed no migration: three-part "mcp:<server>:<tool>" tags, the
|
|
437
|
+
* legacy two-part "mcp:<server>" rows written before it, "skill:<slug>", and the
|
|
438
|
+
* §7.1 "overflow:<n>" cap sentinel all coexist inside this one column.
|
|
439
|
+
*/
|
|
440
|
+
const SCHEMA_V6 = `
|
|
441
|
+
ALTER TABLE sub_agents ADD COLUMN skills TEXT;
|
|
442
|
+
ALTER TABLE pipeline_steps ADD COLUMN skills TEXT;
|
|
443
|
+
`;
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Incremental v6 -> v7 migration (Sub-agent type pill). Adds a nullable
|
|
447
|
+
* `subagent_type` column to sub_agents holding the raw Task/Agent subagent_type
|
|
448
|
+
* (e.g. 'general-purpose', 'Explore', 'worca-cc-planner'). Legacy rows stay NULL
|
|
449
|
+
* and render with no type pill — exactly like the v6 skills column.
|
|
450
|
+
*/
|
|
451
|
+
const SCHEMA_V7 = `
|
|
452
|
+
ALTER TABLE sub_agents ADD COLUMN subagent_type TEXT;
|
|
453
|
+
`;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Incremental v7 -> v8 migration (graphify-usage counter). Adds a nullable INTEGER
|
|
457
|
+
* `graphify_count` to BOTH agent tables — pipeline_steps (per MAIN agent) and
|
|
458
|
+
* sub_agents (per sub-agent) — holding how many times that agent invoked the
|
|
459
|
+
* graphify CLI via Bash. Legacy rows stay NULL and render no count, exactly like
|
|
460
|
+
* the v6 skills / v7 subagent_type columns.
|
|
461
|
+
*/
|
|
462
|
+
const SCHEMA_V8 = `
|
|
463
|
+
ALTER TABLE sub_agents ADD COLUMN graphify_count INTEGER;
|
|
464
|
+
ALTER TABLE pipeline_steps ADD COLUMN graphify_count INTEGER;
|
|
465
|
+
`;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Incremental v8 -> v9 migration (domain tag for workflows). Adds a nullable TEXT
|
|
469
|
+
* `domain` to the workflows table so the picker can group/filter by domain
|
|
470
|
+
* (coding, marketing, financing, …). Legacy rows stay NULL and read back as
|
|
471
|
+
* 'general' via the store layer's COALESCE — organizational only, no enforcement.
|
|
472
|
+
*/
|
|
473
|
+
const SCHEMA_V9 = `
|
|
474
|
+
ALTER TABLE workflows ADD COLUMN domain TEXT;
|
|
475
|
+
`;
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Incremental v9 -> v10 migration (crash-recovery liveness). Adds three nullable
|
|
479
|
+
* columns to pipelines so the startup sweep can tell a crashed run from a live one
|
|
480
|
+
* by owner identity + heartbeat, not just row age:
|
|
481
|
+
* owner_pid INTEGER — process.pid of the process currently driving the run
|
|
482
|
+
* owner_host TEXT — os.hostname() of that process (pid is only meaningful per host)
|
|
483
|
+
* heartbeat_at TEXT — ISO ts refreshed every ~30s while running/pausing
|
|
484
|
+
* All NULL on legacy rows → treated as "unknown owner": swept only by the existing
|
|
485
|
+
* time arm, never PID-probed.
|
|
486
|
+
*/
|
|
487
|
+
const SCHEMA_V10 = `
|
|
488
|
+
ALTER TABLE pipelines ADD COLUMN owner_pid INTEGER;
|
|
489
|
+
ALTER TABLE pipelines ADD COLUMN owner_host TEXT;
|
|
490
|
+
ALTER TABLE pipelines ADD COLUMN heartbeat_at TEXT;
|
|
491
|
+
`;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Incremental v10 -> v11 migration (per-agent user questions, spec 2026-07-11).
|
|
495
|
+
* ask_questions: nullable boolean per-node override (NULL = inherit the
|
|
496
|
+
* manifest default). step_questions: one row per (pipeline, step, round) of the
|
|
497
|
+
* ask-then-resume gate — mirrors the clarify table, keyed by the step record's
|
|
498
|
+
* stable "<stepIndex>:<nodeId>[#cycle]" key plus the round number. node_id is
|
|
499
|
+
* denormalized so prior answers can be re-injected per node without parsing
|
|
500
|
+
* step_key.
|
|
501
|
+
*/
|
|
502
|
+
const STEP_QUESTIONS_DDL = `
|
|
503
|
+
CREATE TABLE IF NOT EXISTS step_questions (
|
|
504
|
+
pipeline_id TEXT NOT NULL,
|
|
505
|
+
step_key TEXT NOT NULL,
|
|
506
|
+
round INTEGER NOT NULL,
|
|
507
|
+
node_id TEXT,
|
|
508
|
+
agent_key TEXT,
|
|
509
|
+
questions TEXT, -- JSON: { questions: [ {id,question,options[2..4],allowFreeText} ] }
|
|
510
|
+
answers TEXT, -- JSON: { answers: [ {id,question,choice} ] }
|
|
511
|
+
PRIMARY KEY (pipeline_id, step_key, round),
|
|
512
|
+
FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
|
|
513
|
+
);
|
|
514
|
+
`;
|
|
515
|
+
|
|
516
|
+
const GUARDRAIL_SETS_DDL = `
|
|
517
|
+
CREATE TABLE IF NOT EXISTS guardrail_sets (
|
|
518
|
+
id TEXT PRIMARY KEY,
|
|
519
|
+
name TEXT NOT NULL,
|
|
520
|
+
settings TEXT NOT NULL DEFAULT '{}', -- JSON: the 5-key guardrails shape (sanitizeGuardrails on read)
|
|
521
|
+
origin TEXT, -- 'plugin:<name>' provenance; NULL = user-created; built-ins are never rows
|
|
522
|
+
created_at TEXT NOT NULL,
|
|
523
|
+
updated_at TEXT NOT NULL
|
|
524
|
+
);
|
|
525
|
+
`;
|
|
526
|
+
|
|
527
|
+
/** v15: append-only spend ledger. NO foreign key on pipeline_id: spend is a
|
|
528
|
+
* permanent financial fact and must survive any row surgery. */
|
|
529
|
+
const COST_LEDGER_DDL = `
|
|
530
|
+
CREATE TABLE IF NOT EXISTS cost_ledger (
|
|
531
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
532
|
+
pipeline_id TEXT NOT NULL,
|
|
533
|
+
step_key TEXT,
|
|
534
|
+
amount_usd REAL NOT NULL,
|
|
535
|
+
ts INTEGER NOT NULL
|
|
536
|
+
);
|
|
537
|
+
CREATE INDEX IF NOT EXISTS idx_cost_ledger_ts ON cost_ledger (ts);
|
|
538
|
+
`;
|
|
539
|
+
|
|
540
|
+
/** v17: per-model cost-reliability observations (configurable-models-design.md
|
|
541
|
+
* §4.6). DERIVED state, not user config (which is why it lives here and not in
|
|
542
|
+
* settings.json): a row means an env-routed model reported no/zero cost while
|
|
543
|
+
* consuming tokens; the row is deleted when a later run reports a positive
|
|
544
|
+
* cost. COLLATE NOCASE mirrors the catalog's case-insensitive id semantics. */
|
|
545
|
+
const MODEL_COST_FLAGS_DDL = `
|
|
546
|
+
CREATE TABLE IF NOT EXISTS model_cost_flags (
|
|
547
|
+
model_id TEXT PRIMARY KEY COLLATE NOCASE,
|
|
548
|
+
flagged_at TEXT NOT NULL
|
|
549
|
+
);
|
|
550
|
+
`;
|
|
551
|
+
|
|
552
|
+
const SCHEMA_V11 = `
|
|
553
|
+
ALTER TABLE config_workflow_nodes ADD COLUMN ask_questions INTEGER;
|
|
554
|
+
${STEP_QUESTIONS_DDL}
|
|
555
|
+
`;
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Every column ever added by an incremental ALTER step, per table. The version
|
|
559
|
+
* ladder alone cannot be trusted for these: one shared ~/.worca-cc DB serves every
|
|
560
|
+
* checkout, and a DIVERGENT ladder can stamp user_version past a step this build
|
|
561
|
+
* needs (it happened twice: branch ai-enablement-onboarding minted its own v11 as
|
|
562
|
+
* a data-only workflow seed and stamped a clean-v10 DB to 11, so this branch's
|
|
563
|
+
* v11 DDL was skipped forever → "no such column: ask_questions"; earlier the same
|
|
564
|
+
* collision class produced "no column named domain"). schemaGaps() diffs this map
|
|
565
|
+
* against the live schema so the gap can be healed regardless of the stamp.
|
|
566
|
+
*/
|
|
567
|
+
const INCREMENTAL_COLUMNS = {
|
|
568
|
+
pipelines: { resume_point: 'TEXT', owner_pid: 'INTEGER', owner_host: 'TEXT', heartbeat_at: 'TEXT',
|
|
569
|
+
source_type: "TEXT DEFAULT 'prompt'", source_ref: 'TEXT', guardrails_id: 'TEXT',
|
|
570
|
+
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' },
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
/**
|
|
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.
|
|
585
|
+
*/
|
|
586
|
+
function schemaGaps(db) {
|
|
587
|
+
const missing = [];
|
|
588
|
+
for (const [table, cols] of Object.entries(INCREMENTAL_COLUMNS)) {
|
|
589
|
+
const have = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
|
|
590
|
+
if (have.size === 0) continue; // base table absent entirely — not our repair
|
|
591
|
+
for (const [col, type] of Object.entries(cols)) {
|
|
592
|
+
if (!have.has(col)) missing.push({ table, col, type });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
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
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** 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). */
|
|
618
|
+
function repairSchemaGaps(db, gaps) {
|
|
619
|
+
for (const { table, col, type } of gaps.columns) {
|
|
620
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
|
|
621
|
+
}
|
|
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
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Version-independent self-heal for a DB whose user_version is already >=
|
|
630
|
+
* SCHEMA_VERSION (so the version ladder no-ops) but is missing an incremental
|
|
631
|
+
* column/table because a divergent ladder stamped it (see INCREMENTAL_COLUMNS).
|
|
632
|
+
* Reads first and returns WITHOUT taking a lock when nothing is missing — the
|
|
633
|
+
* common every-open case, so a healthy DB sees no contention. When repairs are
|
|
634
|
+
* needed it takes the write lock (BEGIN IMMEDIATE) and RE-CHECKS under the lock,
|
|
635
|
+
* so a colliding process that already repaired is a no-op, not a duplicate-column
|
|
636
|
+
* error.
|
|
637
|
+
* @param {DatabaseSync} db
|
|
638
|
+
*/
|
|
639
|
+
function reconcileSchema(db) {
|
|
640
|
+
const gaps = schemaGaps(db);
|
|
641
|
+
if (gaps.columns.length === 0 && !gaps.stepQuestionsTable && !gaps.guardrailSetsTable
|
|
642
|
+
&& !gaps.costLedgerTable && !gaps.modelCostFlagsTable) return; // clean — no lock
|
|
643
|
+
db.exec('BEGIN IMMEDIATE');
|
|
644
|
+
try {
|
|
645
|
+
repairSchemaGaps(db, schemaGaps(db)); // re-probe under the lock: race-safe
|
|
646
|
+
db.exec('COMMIT');
|
|
647
|
+
} catch (err) {
|
|
648
|
+
db.exec('ROLLBACK');
|
|
649
|
+
throw err;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Incremental v11 -> v12 REPAIR migration for the collision documented on
|
|
655
|
+
* INCREMENTAL_COLUMNS: DBs stamped 11 by the ai-enablement-onboarding branch's
|
|
656
|
+
* data-only ladder carry a clean v10 schema, so this re-applies the v11 DDL
|
|
657
|
+
* conditionally and corrects the stamp. No-op on a correct v11 DB and on the
|
|
658
|
+
* fresh path (where SCHEMA_V11 just ran in the same transaction). Future
|
|
659
|
+
* collisions of the same class are caught version-independently by
|
|
660
|
+
* reconcileSchema() on migrate()'s fast path.
|
|
661
|
+
*/
|
|
662
|
+
function applySchemaV12(db) {
|
|
663
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Incremental v12 -> v13 migration (plugin task-sources, spec 2026-07-12 §10):
|
|
668
|
+
* pipelines.source_type TEXT DEFAULT 'prompt' -- 'prompt' | 'markdown' | 'plugin'
|
|
669
|
+
* pipelines.source_ref TEXT -- JSON {plugin,sourceId,taskId,url,title}; NULL unless plugin
|
|
670
|
+
* workflows.origin TEXT -- 'plugin:<name>' provenance; NULL = user-created
|
|
671
|
+
* Implemented as a CONDITIONAL repair (same shape as applySchemaV12), NOT a plain
|
|
672
|
+
* DDL string: the three columns live in INCREMENTAL_COLUMNS (hard rule above), so
|
|
673
|
+
* on any ladder pass from <12 applySchemaV12's version-independent heal has ALREADY
|
|
674
|
+
* added them — an unconditional ALTER here would then throw "duplicate column" on
|
|
675
|
+
* every fresh DB. repairSchemaGaps re-probes under the ladder's transaction and
|
|
676
|
+
* adds only what is missing, which also self-heals divergent cross-branch stamps.
|
|
677
|
+
*/
|
|
678
|
+
function applySchemaV13(db) {
|
|
679
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Incremental v13 -> v14 migration (guardrails-entity spec 2026-08-02, per-run model):
|
|
684
|
+
* guardrail_sets table -- named guardrail sets (built-ins are virtual, never rows)
|
|
685
|
+
* pipelines.guardrails_id TEXT -- the run's selected set id; NULL = legacy/pre-entity row
|
|
686
|
+
* 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
|
|
688
|
+
* ladder pass from <12 have ALREADY added them — an unconditional ALTER/CREATE
|
|
689
|
+
* would throw "duplicate column"/"table already exists" on every fresh DB.
|
|
690
|
+
*/
|
|
691
|
+
function applySchemaV14(db) {
|
|
692
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* v14 -> v15 (cost limits + archive + PR persistence, spec 2026-08-07):
|
|
697
|
+
* cost_ledger table; pipelines archived_at / cost_cap_override / pr_url /
|
|
698
|
+
* pr_number / pr_state / pr_checked_at. CONDITIONAL repair like v12-v14 —
|
|
699
|
+
* the columns live in INCREMENTAL_COLUMNS and the table in the schemaGaps
|
|
700
|
+
* flags, so earlier self-heals may already have applied them.
|
|
701
|
+
*/
|
|
702
|
+
function applySchemaV15(db) {
|
|
703
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* v15 -> v16 (spend-ledger backfill): v15 created cost_ledger EMPTY and only the
|
|
708
|
+
* live orchestrator writes it, so every pre-upgrade run reads $0 in windowed
|
|
709
|
+
* spend (stats week/month, sidebar budget) while History shows its
|
|
710
|
+
* pipelines.total_cost_usd. Insert ONE synthetic row per costed pipeline that
|
|
711
|
+
* has NO ledger rows at all (a live-recorded run's total again would double-
|
|
712
|
+
* count), amount = the same fallback-aware figure the all-time sums use (row
|
|
713
|
+
* total, else step sum), ts = the run's start (cohort semantics — the money
|
|
714
|
+
* lands in the same week/month bucket as the run), step_key NULL. Pipelines
|
|
715
|
+
* with no parseable timestamp are skipped — they still count in the all-time
|
|
716
|
+
* totals via the pipelines fallback. Gap-repair first, v12-v15 style: a
|
|
717
|
+
* divergent stamp can sit at 15 without the table.
|
|
718
|
+
*/
|
|
719
|
+
function applySchemaV16(db) {
|
|
720
|
+
repairSchemaGaps(db, schemaGaps(db));
|
|
721
|
+
// A hand-built or divergent DB (minimal test seeds) can reach this step
|
|
722
|
+
// without the cost columns the base DDL has always carried — such a DB never
|
|
723
|
+
// stored a cost, so there is nothing to backfill.
|
|
724
|
+
const has = (table, col) =>
|
|
725
|
+
db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
|
|
726
|
+
if (!has('pipelines', 'total_cost_usd') || !has('pipeline_steps', 'cost_usd')) return;
|
|
727
|
+
const rows = db.prepare(`
|
|
728
|
+
SELECT p.id,
|
|
729
|
+
CASE WHEN p.total_cost_usd > 0 THEN p.total_cost_usd ELSE COALESCE(s.sc, 0) END AS cost,
|
|
730
|
+
COALESCE(p.started_at, p.updated_at) AS ts_iso
|
|
731
|
+
FROM pipelines p
|
|
732
|
+
LEFT JOIN (SELECT pipeline_id, SUM(cost_usd) sc
|
|
733
|
+
FROM pipeline_steps GROUP BY pipeline_id) s ON s.pipeline_id = p.id
|
|
734
|
+
WHERE NOT EXISTS (SELECT 1 FROM cost_ledger cl WHERE cl.pipeline_id = p.id)
|
|
735
|
+
`).all();
|
|
736
|
+
const ins = db.prepare(
|
|
737
|
+
'INSERT INTO cost_ledger (pipeline_id, step_key, amount_usd, ts) VALUES (?, NULL, ?, ?)');
|
|
738
|
+
for (const r of rows) {
|
|
739
|
+
const ts = Date.parse(r.ts_iso ?? '');
|
|
740
|
+
if (!(r.cost > 0) || !Number.isFinite(ts)) continue;
|
|
741
|
+
ins.run(r.id, r.cost, ts);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Idempotent, versioned, CONCURRENCY-SAFE schema migration. Fast-path no-op when
|
|
747
|
+
* PRAGMA user_version already == SCHEMA_VERSION. Otherwise it takes the write lock
|
|
748
|
+
* (BEGIN IMMEDIATE) BEFORE re-reading user_version, so two first-launch migrators cannot
|
|
749
|
+
* both pass the gate and double-apply SCHEMA_V1; the loser waits on busy_timeout, re-
|
|
750
|
+
* checks under the lock, and no-ops. The pending DDL + the user_version stamp commit in
|
|
751
|
+
* one transaction. (The WAL-mode switch itself is made race-safe by getDb's open retry,
|
|
752
|
+
* since the busy-handler does not retry that pragma.) node:sqlite is sync, so this runs
|
|
753
|
+
* inline on the calling thread.
|
|
754
|
+
*
|
|
755
|
+
* NOTE: PRAGMA user_version cannot be parameterized, so the version is inlined as a
|
|
756
|
+
* literal integer (SCHEMA_VERSION is module-controlled, never user input).
|
|
757
|
+
* @param {DatabaseSync} db
|
|
758
|
+
*/
|
|
759
|
+
export function migrate(db) {
|
|
760
|
+
// Fast path: an already-migrated DB needs no version ladder. It is NOT a full
|
|
761
|
+
// no-op — a DB stamped to this version by a DIVERGENT ladder (second checkout,
|
|
762
|
+
// renumbered step) can still be missing an incremental column/table, so
|
|
763
|
+
// reconcile before returning (lock-free when healthy).
|
|
764
|
+
if (db.prepare('PRAGMA user_version').get().user_version >= SCHEMA_VERSION) {
|
|
765
|
+
reconcileSchema(db);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// First launch may have a competing migrator. BEGIN IMMEDIATE takes the write lock
|
|
770
|
+
// up front (a deferred BEGIN would not lock until the first write, letting two
|
|
771
|
+
// migrators both pass the gate and double-apply SCHEMA_V1 → "table projects already
|
|
772
|
+
// exists"). Under the lock we re-read user_version and no-op if the winner stamped it.
|
|
773
|
+
db.exec('BEGIN IMMEDIATE');
|
|
774
|
+
try {
|
|
775
|
+
const current = db.prepare('PRAGMA user_version').get().user_version; // re-check under lock
|
|
776
|
+
if (current >= SCHEMA_VERSION) { db.exec('COMMIT'); reconcileSchema(db); return; }
|
|
777
|
+
if (current < 1) db.exec(SCHEMA_V1);
|
|
778
|
+
if (current < 2) db.exec(SCHEMA_V2);
|
|
779
|
+
if (current < 3) db.exec(SCHEMA_V3);
|
|
780
|
+
if (current < 4) db.exec(SCHEMA_V4);
|
|
781
|
+
if (current < 5) db.exec(SCHEMA_V5);
|
|
782
|
+
if (current < 6) db.exec(SCHEMA_V6);
|
|
783
|
+
if (current < 7) db.exec(SCHEMA_V7);
|
|
784
|
+
if (current < 8) db.exec(SCHEMA_V8);
|
|
785
|
+
if (current < 9) db.exec(SCHEMA_V9);
|
|
786
|
+
if (current < 10) db.exec(SCHEMA_V10);
|
|
787
|
+
if (current < 11) db.exec(SCHEMA_V11);
|
|
788
|
+
if (current < 12) applySchemaV12(db);
|
|
789
|
+
if (current < 13) applySchemaV13(db);
|
|
790
|
+
if (current < 14) applySchemaV14(db);
|
|
791
|
+
if (current < 15) applySchemaV15(db);
|
|
792
|
+
if (current < 16) applySchemaV16(db);
|
|
793
|
+
if (current < 17) db.exec(MODEL_COST_FLAGS_DDL); // IF NOT EXISTS — reconcile-safe
|
|
794
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
795
|
+
db.exec('COMMIT');
|
|
796
|
+
} catch (err) {
|
|
797
|
+
db.exec('ROLLBACK');
|
|
798
|
+
throw err;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Close the singleton handle (no-op when already closed). */
|
|
803
|
+
export function closeDb() {
|
|
804
|
+
if (_db) {
|
|
805
|
+
_db.close();
|
|
806
|
+
_db = null;
|
|
807
|
+
}
|
|
808
|
+
_stmtCache = new Map();
|
|
809
|
+
_txDepth = 0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Run `fn` inside a single SQLite transaction on the singleton handle. Commits
|
|
814
|
+
* when `fn` returns, rolls back if it throws (re-throwing the original error).
|
|
815
|
+
* Returns whatever `fn` returns. node:sqlite is synchronous, so `fn` must be
|
|
816
|
+
* synchronous too — do all DB work inside it and return a value.
|
|
817
|
+
*
|
|
818
|
+
* Not re-entrant: SQLite has no nested BEGIN, so a tx() inside a tx() throws
|
|
819
|
+
* rather than silently joining (or corrupting) the outer transaction. Compose by
|
|
820
|
+
* passing data between calls, not by nesting.
|
|
821
|
+
* @template T
|
|
822
|
+
* @param {() => T} fn
|
|
823
|
+
* @returns {T}
|
|
824
|
+
*/
|
|
825
|
+
export function tx(fn) {
|
|
826
|
+
if (_txDepth > 0) throw new Error('tx(): a transaction is already active (nested tx is not supported)');
|
|
827
|
+
const db = getDb();
|
|
828
|
+
db.exec('BEGIN');
|
|
829
|
+
_txDepth = 1;
|
|
830
|
+
try {
|
|
831
|
+
const result = fn();
|
|
832
|
+
db.exec('COMMIT');
|
|
833
|
+
_txDepth = 0;
|
|
834
|
+
return result;
|
|
835
|
+
} catch (err) {
|
|
836
|
+
try { db.exec('ROLLBACK'); } finally { _txDepth = 0; }
|
|
837
|
+
throw err;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Prepare (and cache) a StatementSync by exact SQL text. Re-preparing the same
|
|
843
|
+
* SQL returns the cached statement — node:sqlite statements are reusable across
|
|
844
|
+
* runs (bind fresh params each .run()/.get()/.all()). The cache is keyed to the
|
|
845
|
+
* current handle and cleared by closeDb()/_resetForTests().
|
|
846
|
+
* @param {string} sql
|
|
847
|
+
* @returns {import('node:sqlite').StatementSync}
|
|
848
|
+
*/
|
|
849
|
+
export function prepare(sql) {
|
|
850
|
+
const hit = _stmtCache.get(sql);
|
|
851
|
+
if (hit) return hit;
|
|
852
|
+
const stmt = getDb().prepare(sql);
|
|
853
|
+
_stmtCache.set(sql, stmt);
|
|
854
|
+
return stmt;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* TEST-ONLY: drop the cached handle, prepared-statement cache, and transaction
|
|
859
|
+
* guard so the next getDb() reopens against the current WORCA_HOME. Lets each
|
|
860
|
+
* test run on a pristine DB at its own home.
|
|
861
|
+
*/
|
|
862
|
+
export function _resetForTests() {
|
|
863
|
+
closeDb();
|
|
864
|
+
}
|