@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
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// src/core/migrate-fs-to-db.mjs
|
|
2
|
+
// One-time importer: legacy JSON (the pre-SQLite filesystem state) -> the SQLite DB.
|
|
3
|
+
//
|
|
4
|
+
// Called once by db.mjs#getDb() after migrate(db) stamps the schema, before the
|
|
5
|
+
// singleton handle is published. SYNCHRONOUS (it runs inside the sync open
|
|
6
|
+
// sequence): all FS access is node:fs sync; all inserts run in ONE transaction on
|
|
7
|
+
// the passed `db`. Self-guarded + idempotent: a no-op unless the DB has no migrated
|
|
8
|
+
// data AND legacy JSON is present. Crash-safe: COMMIT first, then archive consumed
|
|
9
|
+
// JSON into <worcaHome>/backup-<ts>/ (mirroring the relative layout); an
|
|
10
|
+
// interrupted archive is harmless because the row-count guard makes a re-run a
|
|
11
|
+
// no-op. Markdown agent outputs + extras/ are NEVER moved (FS keeps them; spec §4/§5).
|
|
12
|
+
//
|
|
13
|
+
// DECOUPLED from the service layer: it does NOT call config/projects/workspaces/
|
|
14
|
+
// artifacts write paths (those become SQL in Phases 2/3 and would re-enter getDb()).
|
|
15
|
+
// It writes direct prepared INSERTs against the FINAL v1 DDL and uses ONLY pure
|
|
16
|
+
// path/key helpers (worcaHome, projectKey, store paths) that never touch the DB.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync, readFileSync, readdirSync, statSync,
|
|
20
|
+
mkdirSync, renameSync, cpSync, unlinkSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { join, resolve, dirname, relative } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { worcaHome } from './projects.mjs';
|
|
25
|
+
import {
|
|
26
|
+
projectKey, storeRoot, workspacesStoreRoot,
|
|
27
|
+
} from './store.mjs';
|
|
28
|
+
|
|
29
|
+
// ── tiny fail-safe IO helpers ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Parse a JSON file; missing/corrupt -> undefined (never throws). */
|
|
32
|
+
function readJsonSafe(file) {
|
|
33
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return undefined; }
|
|
34
|
+
}
|
|
35
|
+
/** Read a text file; missing -> undefined (never throws). */
|
|
36
|
+
function readTextSafe(file) {
|
|
37
|
+
try { return readFileSync(file, 'utf8'); } catch { return undefined; }
|
|
38
|
+
}
|
|
39
|
+
/** List a directory's dirents; missing -> [] (never throws). */
|
|
40
|
+
function readDirSafe(dir) {
|
|
41
|
+
try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
42
|
+
}
|
|
43
|
+
/** True when the path is an existing directory (never throws). */
|
|
44
|
+
function isDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } }
|
|
45
|
+
|
|
46
|
+
/** Coerce to a JSON TEXT column value: stringify when present, else null. */
|
|
47
|
+
function jsonCol(value) {
|
|
48
|
+
return value === undefined || value === null ? null : JSON.stringify(value);
|
|
49
|
+
}
|
|
50
|
+
/** Nullable boolean -> 1/0/null for an INTEGER column. */
|
|
51
|
+
function boolCol(v) { return typeof v === 'boolean' ? (v ? 1 : 0) : null; }
|
|
52
|
+
|
|
53
|
+
const nowIso = () => new Date().toISOString();
|
|
54
|
+
|
|
55
|
+
// ── the per-project config path (INLINED to stay fully decoupled from config.mjs) ──
|
|
56
|
+
function projectConfigFile(projectDir) {
|
|
57
|
+
return join(resolve(projectDir), '.worca-cc', 'config.json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── the set of pipeline-dir JSON/md files we CONSUME (move to backup) ───────────────
|
|
61
|
+
// pipeline.md is consumed (fully captured as pipeline_events). prompt.md stays
|
|
62
|
+
// (kept markdown; its body is also copied into pipelines.prompt). Everything else
|
|
63
|
+
// that is markdown stays on disk.
|
|
64
|
+
const REVIEW_FILE_RE = /^(refine|impl|plan|ws|webui)-review-cycle(\d+)\.json$/;
|
|
65
|
+
|
|
66
|
+
// A14: ESM namespace exports are non-configurable, so the Phase-1 hook-call test
|
|
67
|
+
// (db.test.mjs) cannot mock.method() this function. It instead asserts an
|
|
68
|
+
// OBSERVABLE effect — this module-level call counter — to prove getDb() invokes the
|
|
69
|
+
// hook exactly once, after migrate(), on first open (and not on a cached re-open).
|
|
70
|
+
// The real importer keeps the increment: it is harmless (a single bump at the top of
|
|
71
|
+
// every call, before any early return) and preserves that frozen Phase-1 contract.
|
|
72
|
+
let _callCount = 0;
|
|
73
|
+
|
|
74
|
+
// M3: one-time completion latch. The old guard (projects>0 OR pipelines>0) was a
|
|
75
|
+
// row-count PROXY: a DB with projects but zero finished pipelines read as "done",
|
|
76
|
+
// so a legacy pipeline JSON reappearing under store/ would be skipped forever; and
|
|
77
|
+
// legacyPresent() can never go false (store/ keeps markdown). Instead we stamp a
|
|
78
|
+
// sentinel store_meta row INSIDE the import tx and gate re-runs on it. store_meta
|
|
79
|
+
// has no CHECK on `kind`, and every reader looks it up by an exact key (never scans
|
|
80
|
+
// the table), so this reserved key is invisible to the service layer.
|
|
81
|
+
const MIGRATION_MARKER_KEY = '__fs_migration__';
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Migrate legacy JSON state into the DB on first run. Self-guarded + idempotent.
|
|
87
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
export function maybeMigrateFromFs(db) {
|
|
91
|
+
_callCount += 1; // A14: observable hook-fired effect (see above)
|
|
92
|
+
const home = worcaHome();
|
|
93
|
+
if (!legacyPresent(home)) return; // nothing on disk to import
|
|
94
|
+
if (alreadyMigrated(db)) return; // DB already holds migrated data
|
|
95
|
+
|
|
96
|
+
const plan = collectLegacy(home); // pure reads -> in-memory rows + consumed-file list
|
|
97
|
+
if (!plan.hasRows) return; // corrupt-only tree: leave JSON, no-op
|
|
98
|
+
|
|
99
|
+
db.exec('BEGIN');
|
|
100
|
+
try {
|
|
101
|
+
insertAll(db, plan);
|
|
102
|
+
db.exec('COMMIT');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
try { db.exec('ROLLBACK'); } catch { /* ignore secondary */ }
|
|
105
|
+
throw err; // DB left empty -> safe to retry on next open
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Commit succeeded -> the DB is authoritative. Archive consumed JSON best-effort;
|
|
109
|
+
// an interrupted archive is harmless (the row-count guard no-ops the re-run).
|
|
110
|
+
archive(home, plan.consumed);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Legacy data is "present" when ANY consumable source exists on disk. */
|
|
114
|
+
function legacyPresent(home) {
|
|
115
|
+
return (
|
|
116
|
+
existsSync(join(home, 'projects.json')) ||
|
|
117
|
+
existsSync(join(home, 'workspaces.json')) ||
|
|
118
|
+
isDir(join(home, 'workflows')) ||
|
|
119
|
+
isDir(join(home, 'store'))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* SELF-GUARD (M3): the fs->db import already completed when the one-time marker row
|
|
125
|
+
* exists. This is a real latch (stamped in the same tx as the import), NOT the old
|
|
126
|
+
* row-count proxy — so a legacy JSON file reappearing after a partial-looking DB
|
|
127
|
+
* (e.g. projects>0 but pipelines==0) is still imported until the marker is present.
|
|
128
|
+
*/
|
|
129
|
+
function alreadyMigrated(db) {
|
|
130
|
+
return db.prepare('SELECT 1 FROM store_meta WHERE key = ?').get(MIGRATION_MARKER_KEY) !== undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── collection (pure reads) ────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read the whole legacy tree into a plan: arrays of row tuples per table + the list
|
|
137
|
+
* of consumed files to archive. No DB access here. Never throws (fail-safe skips).
|
|
138
|
+
*/
|
|
139
|
+
function collectLegacy(home) {
|
|
140
|
+
const plan = {
|
|
141
|
+
// m14: each workspaces entry is { row:[...], members:[[id,path,ordinal],...] }
|
|
142
|
+
// so insertAll can emit a workspace's member rows ONLY when its parent
|
|
143
|
+
// `workspaces` row actually inserted (changes===1). A hand-edited registry with
|
|
144
|
+
// two CI-name-colliding entries (distinct ids) makes `INSERT OR IGNORE` drop the
|
|
145
|
+
// 2nd `workspaces` row; emitting its `workspace_projects` children unconditionally
|
|
146
|
+
// would then FK-fail (parent absent) and — since INSERT OR IGNORE does NOT swallow
|
|
147
|
+
// a FOREIGN KEY violation — throw, rolling the WHOLE import back and re-attempting
|
|
148
|
+
// it on every getDb() open (an infinite re-migrate loop). Carrying members with
|
|
149
|
+
// their parent lets us gate them on the parent insert.
|
|
150
|
+
projects: [], workspaces: [], workflows: [],
|
|
151
|
+
projectConfig: [], configNodes: [], configFeedbacks: [],
|
|
152
|
+
storeMeta: [], pipelines: [], pipelineSteps: [], pipelineEvents: [],
|
|
153
|
+
clarify: [], reviews: [], artifacts: [],
|
|
154
|
+
consumed: [], // { src, rel } where rel is relative to `home` (or special)
|
|
155
|
+
hasRows: false,
|
|
156
|
+
};
|
|
157
|
+
const ts = nowIso();
|
|
158
|
+
|
|
159
|
+
// 1) projects.json
|
|
160
|
+
const projectsFile = join(home, 'projects.json');
|
|
161
|
+
const registry = readJsonSafe(projectsFile);
|
|
162
|
+
const registryEntries = Array.isArray(registry)
|
|
163
|
+
? registry.filter((e) => e && typeof e.name === 'string' && typeof e.path === 'string')
|
|
164
|
+
: [];
|
|
165
|
+
const seenProjectKeys = new Set();
|
|
166
|
+
for (const e of registryEntries) {
|
|
167
|
+
const key = projectKey(e.path);
|
|
168
|
+
if (seenProjectKeys.has(key)) continue;
|
|
169
|
+
seenProjectKeys.add(key);
|
|
170
|
+
plan.projects.push([key, e.name, e.path, ts]);
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(registry)) plan.consumed.push({ src: projectsFile, rel: 'projects.json' });
|
|
173
|
+
|
|
174
|
+
// 2) workspaces.json
|
|
175
|
+
const workspacesFile = join(home, 'workspaces.json');
|
|
176
|
+
const wsRegistry = readJsonSafe(workspacesFile);
|
|
177
|
+
if (Array.isArray(wsRegistry)) {
|
|
178
|
+
for (const w of wsRegistry) {
|
|
179
|
+
if (!isValidWorkspace(w)) continue;
|
|
180
|
+
// A1: workspace_projects.project_key stores the ABSOLUTE member PATH
|
|
181
|
+
// (ordinal-ordered); the real projectKey is recomputed on read via
|
|
182
|
+
// store.projectKey(path) in workspaces.mjs#annotate (a projectKey is a
|
|
183
|
+
// one-way hash, so the path could not be recovered from it).
|
|
184
|
+
// m14: members ride WITH their parent so insertAll only emits them when the
|
|
185
|
+
// parent `workspaces` row inserts (changes===1) — never as FK-orphans.
|
|
186
|
+
const members = w.projectPaths.map((p, i) => [w.id, p, i]);
|
|
187
|
+
plan.workspaces.push({
|
|
188
|
+
row: [
|
|
189
|
+
w.id, w.name, typeof w.description === 'string' ? w.description : '',
|
|
190
|
+
(typeof w.createdAt === 'string' && w.createdAt) || ts,
|
|
191
|
+
(typeof w.updatedAt === 'string' && w.updatedAt) || ts,
|
|
192
|
+
],
|
|
193
|
+
members,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
plan.consumed.push({ src: workspacesFile, rel: 'workspaces.json' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 3) workflows/*.json
|
|
200
|
+
const wfDir = join(home, 'workflows');
|
|
201
|
+
for (const ent of readDirSafe(wfDir)) {
|
|
202
|
+
if (!ent.isFile() || !ent.name.endsWith('.json')) continue;
|
|
203
|
+
const file = join(wfDir, ent.name);
|
|
204
|
+
const wf = readJsonSafe(file);
|
|
205
|
+
if (!wf || typeof wf !== 'object' || !Array.isArray(wf.steps)) continue;
|
|
206
|
+
const id = (typeof wf.id === 'string' && wf.id) || ent.name.slice(0, -'.json'.length);
|
|
207
|
+
if (id === 'wf_default') { // built-in; never a row, but still archive a stray file
|
|
208
|
+
plan.consumed.push({ src: file, rel: join('workflows', ent.name) });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
plan.workflows.push([
|
|
212
|
+
id, (typeof wf.name === 'string' && wf.name) || 'Untitled',
|
|
213
|
+
Number(wf.version) || 1, JSON.stringify(wf.steps),
|
|
214
|
+
JSON.stringify(Array.isArray(wf.feedbacks) ? wf.feedbacks : []),
|
|
215
|
+
(typeof wf.createdAt === 'string' && wf.createdAt) || ts,
|
|
216
|
+
(typeof wf.updatedAt === 'string' && wf.updatedAt) || ts,
|
|
217
|
+
]);
|
|
218
|
+
plan.consumed.push({ src: file, rel: join('workflows', ent.name) });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 4) per-project .worca-cc/config.json (only for registered, still-present dirs)
|
|
222
|
+
for (const e of registryEntries) {
|
|
223
|
+
const dir = e.path;
|
|
224
|
+
if (!isDir(dir)) continue; // SKIP projects whose dir is gone
|
|
225
|
+
const file = projectConfigFile(dir);
|
|
226
|
+
const cfg = readJsonSafe(file);
|
|
227
|
+
if (!cfg || typeof cfg !== 'object') continue;
|
|
228
|
+
const key = projectKey(dir);
|
|
229
|
+
collectProjectConfig(plan, key, cfg);
|
|
230
|
+
// config.json lives OUTSIDE home -> namespaced archive path under project-config/<key>/
|
|
231
|
+
plan.consumed.push({ src: file, rel: null, dest: join('project-config', key, 'config.json') });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 5) store tree
|
|
235
|
+
collectStore(plan, home);
|
|
236
|
+
|
|
237
|
+
plan.hasRows =
|
|
238
|
+
plan.projects.length || plan.workspaces.length || plan.workflows.length ||
|
|
239
|
+
plan.projectConfig.length || plan.storeMeta.length || plan.pipelines.length;
|
|
240
|
+
return plan;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isValidWorkspace(e) {
|
|
244
|
+
return e && typeof e === 'object' && typeof e.id === 'string' &&
|
|
245
|
+
typeof e.name === 'string' && Array.isArray(e.projectPaths) &&
|
|
246
|
+
e.projectPaths.length >= 2 && e.projectPaths.every((p) => typeof p === 'string');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Decompose a per-project config into project_config + normalized node/feedback rows. */
|
|
250
|
+
function collectProjectConfig(plan, key, cfg) {
|
|
251
|
+
const steps = cfg.steps && typeof cfg.steps === 'object' ? cfg.steps : {};
|
|
252
|
+
const customModels = Array.isArray(cfg.customModels) ? cfg.customModels : [];
|
|
253
|
+
const activeWorkflowId =
|
|
254
|
+
typeof cfg.activeWorkflowId === 'string' && cfg.activeWorkflowId.trim()
|
|
255
|
+
? cfg.activeWorkflowId.trim() : null;
|
|
256
|
+
// extra = every top-level key except the four modeled ones.
|
|
257
|
+
const extra = {};
|
|
258
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
259
|
+
if (k === 'steps' || k === 'customModels' || k === 'activeWorkflowId' || k === 'workflows') continue;
|
|
260
|
+
extra[k] = v;
|
|
261
|
+
}
|
|
262
|
+
plan.projectConfig.push([
|
|
263
|
+
key, JSON.stringify(steps), JSON.stringify(customModels), activeWorkflowId,
|
|
264
|
+
JSON.stringify(extra),
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
const workflows = cfg.workflows && typeof cfg.workflows === 'object' ? cfg.workflows : {};
|
|
268
|
+
for (const [wfId, wf] of Object.entries(workflows)) {
|
|
269
|
+
if (!wf || typeof wf !== 'object') continue;
|
|
270
|
+
const nodes = wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {};
|
|
271
|
+
for (const [nodeId, sel] of Object.entries(nodes)) {
|
|
272
|
+
if (!sel || typeof sel !== 'object') continue;
|
|
273
|
+
plan.configNodes.push([
|
|
274
|
+
key, wfId, nodeId,
|
|
275
|
+
typeof sel.model === 'string' ? sel.model : null,
|
|
276
|
+
typeof sel.effort === 'string' ? sel.effort : null,
|
|
277
|
+
boolCol(sel.fanOut),
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
const feedbacks = wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {};
|
|
281
|
+
for (const [fbId, fb] of Object.entries(feedbacks)) {
|
|
282
|
+
if (!fb || typeof fb !== 'object') continue;
|
|
283
|
+
const maxCycles = Math.max(1, Math.floor(Number(fb.maxCycles) || 0) || 1);
|
|
284
|
+
plan.configFeedbacks.push([key, wfId, fbId, maxCycles]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Walk store/<key>/ (project keys) and store/workspaces/<wkey>/ (workspace keys). */
|
|
290
|
+
function collectStore(plan, home) {
|
|
291
|
+
const root = storeRoot();
|
|
292
|
+
for (const ent of readDirSafe(root)) {
|
|
293
|
+
if (!ent.isDirectory()) continue;
|
|
294
|
+
if (ent.name === 'workspaces') {
|
|
295
|
+
const wsRoot = workspacesStoreRoot();
|
|
296
|
+
for (const w of readDirSafe(wsRoot)) {
|
|
297
|
+
if (!w.isDirectory()) continue;
|
|
298
|
+
collectKeyDir(plan, home, join(wsRoot, w.name), w.name, 'workspace');
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
collectKeyDir(plan, home, join(root, ent.name), ent.name, 'project');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Import one key dir (project or workspace): meta.json -> store_meta, each
|
|
308
|
+
* pipelines/<runId>/ -> a pipeline + children, shared plans/reviews -> artifacts.
|
|
309
|
+
* @param {string} dirName the store key dir name (bare workspace key for workspaces)
|
|
310
|
+
* @param {'project'|'workspace'} kind
|
|
311
|
+
*/
|
|
312
|
+
function collectKeyDir(plan, home, keyDir, dirName, kind) {
|
|
313
|
+
// meta.json -> store_meta (key = meta.key when present, else the dir name)
|
|
314
|
+
const metaFile = join(keyDir, 'meta.json');
|
|
315
|
+
const meta = readJsonSafe(metaFile);
|
|
316
|
+
if (meta && typeof meta === 'object') {
|
|
317
|
+
const metaKey = typeof meta.key === 'string' && meta.key ? meta.key : dirName;
|
|
318
|
+
plan.storeMeta.push([metaKey, kind, JSON.stringify(meta)]);
|
|
319
|
+
plan.consumed.push({ src: metaFile, rel: relFromHome(home, metaFile) });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// pipelines/<runId>/
|
|
323
|
+
const pipelinesDir = join(keyDir, 'pipelines');
|
|
324
|
+
const runEntries = readDirSafe(pipelinesDir).filter((e) => e.isDirectory());
|
|
325
|
+
// remember each run's (id, datePrefix, baseCandidates) to attribute shared md later.
|
|
326
|
+
const runIndex = [];
|
|
327
|
+
for (const run of runEntries) {
|
|
328
|
+
const runDir = join(pipelinesDir, run.name);
|
|
329
|
+
const idAndBases = collectPipeline(plan, home, runDir, run.name, dirName, kind, meta);
|
|
330
|
+
if (idAndBases) runIndex.push(idAndBases);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// shared plans/*.md + reviews/*.md -> artifacts (attributed by datePrefix+base).
|
|
334
|
+
collectSharedMarkdown(plan, keyDir, runIndex);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Import one pipeline dir. Returns { id, datePrefix, bases:Set } for shared-md
|
|
339
|
+
* attribution, or null when state.json is unreadable (skip the whole pipeline).
|
|
340
|
+
*/
|
|
341
|
+
function collectPipeline(plan, home, runDir, runName, dirName, kind, keyMeta) {
|
|
342
|
+
const state = readJsonSafe(join(runDir, 'state.json'));
|
|
343
|
+
if (!state || typeof state !== 'object') return null; // corrupt -> skip this pipeline
|
|
344
|
+
|
|
345
|
+
const id = (typeof state.id === 'string' && state.id) || runName;
|
|
346
|
+
|
|
347
|
+
// project_key
|
|
348
|
+
let projectKeyVal;
|
|
349
|
+
if (kind === 'workspace') {
|
|
350
|
+
projectKeyVal =
|
|
351
|
+
(Array.isArray(state.projectKeys) && state.projectKeys[0]) ||
|
|
352
|
+
(keyMeta && Array.isArray(keyMeta.projectKeys) && keyMeta.projectKeys[0]) || '';
|
|
353
|
+
} else {
|
|
354
|
+
projectKeyVal =
|
|
355
|
+
(keyMeta && typeof keyMeta.key === 'string' && keyMeta.key) ||
|
|
356
|
+
(typeof state.projectDir === 'string' ? projectKey(state.projectDir) : '') ||
|
|
357
|
+
dirName;
|
|
358
|
+
}
|
|
359
|
+
// workspace_key: composite tag for workspace runs, null for project runs.
|
|
360
|
+
const workspaceKeyVal = kind === 'workspace' ? `workspaces/${dirName}` : null;
|
|
361
|
+
|
|
362
|
+
const prompt = readTextSafe(join(runDir, 'prompt.md')) ?? null;
|
|
363
|
+
|
|
364
|
+
const workspaceMeta = state.target === 'workspace'
|
|
365
|
+
? pruneUndefined({
|
|
366
|
+
workspaceId: state.workspaceId, workspaceKey: state.workspaceKey,
|
|
367
|
+
workspaceName: state.workspaceName, workspaceDescription: state.workspaceDescription,
|
|
368
|
+
projectKeys: state.projectKeys, projects: state.projects,
|
|
369
|
+
checkpointRefs: state.checkpointRefs, branches: state.branches,
|
|
370
|
+
})
|
|
371
|
+
: null;
|
|
372
|
+
|
|
373
|
+
plan.pipelines.push([
|
|
374
|
+
id, projectKeyVal, workspaceKeyVal,
|
|
375
|
+
typeof state.target === 'string' ? state.target : 'project',
|
|
376
|
+
state.title ?? null, state.baseName ?? null, state.datePrefix ?? null,
|
|
377
|
+
state.status || 'created', state.phase || 'created', Number(state.cycle) || 0,
|
|
378
|
+
state.startedAt ?? null, state.updatedAt ?? null,
|
|
379
|
+
Number(state.totalCostUsd) || 0, Number(state.totalActiveMs) || 0,
|
|
380
|
+
prompt,
|
|
381
|
+
jsonCol(state.branch), jsonCol(workspaceMeta), jsonCol(state.stepper), jsonCol(state.tools),
|
|
382
|
+
]);
|
|
383
|
+
plan.consumed.push({ src: join(runDir, 'state.json'), rel: relFromHome(home, join(runDir, 'state.json')) });
|
|
384
|
+
|
|
385
|
+
// steps
|
|
386
|
+
const seenStepKeys = new Set();
|
|
387
|
+
for (const s of Array.isArray(state.steps) ? state.steps : []) {
|
|
388
|
+
if (!s || typeof s !== 'object' || typeof s.key !== 'string' || !s.key) continue;
|
|
389
|
+
if (seenStepKeys.has(s.key)) continue;
|
|
390
|
+
seenStepKeys.add(s.key);
|
|
391
|
+
plan.pipelineSteps.push([
|
|
392
|
+
id, s.key, s.nodeId ?? null, s.phase ?? null,
|
|
393
|
+
Number.isInteger(s.stepIndex) ? s.stepIndex : null,
|
|
394
|
+
Number.isFinite(s.cycle) ? s.cycle : null,
|
|
395
|
+
s.status ?? null, s.startedAt ?? null, s.updatedAt ?? null,
|
|
396
|
+
Number(s.activeMs) || 0,
|
|
397
|
+
s.runningSince == null ? null : String(s.runningSince),
|
|
398
|
+
Number(s.costUsd) || 0,
|
|
399
|
+
]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// pipeline.md -> events
|
|
403
|
+
const md = readTextSafe(join(runDir, 'pipeline.md'));
|
|
404
|
+
if (md !== undefined) {
|
|
405
|
+
for (const ev of parseTimeline(md)) plan.pipelineEvents.push([id, ev.ts, ev.text]);
|
|
406
|
+
plan.consumed.push({ src: join(runDir, 'pipeline.md'), rel: relFromHome(home, join(runDir, 'pipeline.md')) });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// clarify + clarify-answers -> one clarify row
|
|
410
|
+
const clarify = readJsonSafe(join(runDir, 'clarify.json'));
|
|
411
|
+
const answers = readJsonSafe(join(runDir, 'clarify-answers.json'));
|
|
412
|
+
if (clarify !== undefined || answers !== undefined) {
|
|
413
|
+
plan.clarify.push([id, jsonCol(clarify), jsonCol(answers)]);
|
|
414
|
+
if (clarify !== undefined) plan.consumed.push({ src: join(runDir, 'clarify.json'), rel: relFromHome(home, join(runDir, 'clarify.json')) });
|
|
415
|
+
if (answers !== undefined) plan.consumed.push({ src: join(runDir, 'clarify-answers.json'), rel: relFromHome(home, join(runDir, 'clarify-answers.json')) });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// *-review-cycleN.json -> reviews; markdown artifacts inside the run dir -> artifacts
|
|
419
|
+
for (const ent of readDirSafe(runDir)) {
|
|
420
|
+
if (!ent.isFile()) continue;
|
|
421
|
+
const m = REVIEW_FILE_RE.exec(ent.name);
|
|
422
|
+
if (m) {
|
|
423
|
+
const body = readJsonSafe(join(runDir, ent.name));
|
|
424
|
+
plan.reviews.push([id, m[1], Number(m[2]), jsonCol(body)]);
|
|
425
|
+
plan.consumed.push({ src: join(runDir, ent.name), rel: relFromHome(home, join(runDir, ent.name)) });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ent.name === 'manual-tests-checklist.md') {
|
|
429
|
+
plan.artifacts.push([id, 'manual-checklist', ent.name]);
|
|
430
|
+
} else if (/^webui-review-cycle\d+\.md$/.test(ent.name)) {
|
|
431
|
+
plan.artifacts.push([id, 'webui-review', ent.name]);
|
|
432
|
+
} else if (ent.name === 'workspace-description.md') {
|
|
433
|
+
plan.artifacts.push([id, 'workspace-description', ent.name]);
|
|
434
|
+
} else if (ent.name.endsWith('.md') && ent.name !== 'pipeline.md' && ent.name !== 'prompt.md') {
|
|
435
|
+
plan.artifacts.push([id, 'extra-md', ent.name]);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// extras/<file> -> artifacts (kept on disk, never moved)
|
|
439
|
+
for (const ex of readDirSafe(join(runDir, 'extras'))) {
|
|
440
|
+
if (ex.isFile()) plan.artifacts.push([id, 'extra', join('extras', ex.name)]);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { id, datePrefix: state.datePrefix ?? null, bases: baseCandidates(state, runName) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Candidate base names for shared-md linkage (mirrors pipeline-delete.mjs#deriveNames). */
|
|
447
|
+
function baseCandidates(state, runName) {
|
|
448
|
+
const bases = new Set();
|
|
449
|
+
if (state && state.baseName) bases.add(String(state.baseName));
|
|
450
|
+
// dir slug: drop the "DD-MM-YY-" prefix and the trailing "-<id>".
|
|
451
|
+
const m = /^(\d{2}-\d{2}-\d{2})-(.+)$/.exec(runName);
|
|
452
|
+
if (m && state && state.id) {
|
|
453
|
+
const inner = m[2];
|
|
454
|
+
const suffix = `-${String(state.id)}`;
|
|
455
|
+
if (inner.endsWith(suffix)) bases.add(inner.slice(0, -suffix.length));
|
|
456
|
+
}
|
|
457
|
+
return bases;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Attribute plans/*.md + reviews/*.md to a pipeline in this key by datePrefix+base. */
|
|
461
|
+
function collectSharedMarkdown(plan, keyDir, runIndex) {
|
|
462
|
+
const PLAN_RE = /^(\d{2}-\d{2}-\d{2})-(.+?)(?:-v\d+)?\.md$/;
|
|
463
|
+
const REVIEW_RE = /^(\d{2}-\d{2}-\d{2})-(.+?)-(impl-review|plan-review|ws-review)\.md$/;
|
|
464
|
+
const matchRun = (datePrefix, base) =>
|
|
465
|
+
runIndex.find((r) => r.datePrefix === datePrefix && r.bases.has(base));
|
|
466
|
+
|
|
467
|
+
for (const ent of readDirSafe(join(keyDir, 'plans'))) {
|
|
468
|
+
if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
|
|
469
|
+
const m = PLAN_RE.exec(ent.name);
|
|
470
|
+
if (!m) continue;
|
|
471
|
+
const run = matchRun(m[1], m[2]);
|
|
472
|
+
if (run) plan.artifacts.push([run.id, 'plan', join('plans', ent.name)]);
|
|
473
|
+
}
|
|
474
|
+
for (const ent of readDirSafe(join(keyDir, 'reviews'))) {
|
|
475
|
+
if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
|
|
476
|
+
const m = REVIEW_RE.exec(ent.name);
|
|
477
|
+
if (!m) continue;
|
|
478
|
+
const run = matchRun(m[1], m[2]);
|
|
479
|
+
if (run) plan.artifacts.push([run.id, 'review', join('reviews', ent.name)]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Parse the `## Timeline` section of a pipeline.md into { ts, text } events. ONLY
|
|
485
|
+
* lines after the first "## Timeline" header that match the strict ISO-ts pattern
|
|
486
|
+
* are events. When there is NO "## Timeline" header we return [] rather than scanning
|
|
487
|
+
* the whole file: a prompt body can legitimately contain "- `<ISO ts>` ..." lines,
|
|
488
|
+
* and treating those as events would fabricate spurious pipeline_events rows.
|
|
489
|
+
*/
|
|
490
|
+
function parseTimeline(md) {
|
|
491
|
+
const lines = md.split(/\r?\n/);
|
|
492
|
+
const header = lines.findIndex((l) => l.trim() === '## Timeline');
|
|
493
|
+
if (header === -1) return []; // no Timeline section -> no events
|
|
494
|
+
const RE = /^- `(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)` (.*)$/;
|
|
495
|
+
const out = [];
|
|
496
|
+
for (let i = header + 1; i < lines.length; i++) {
|
|
497
|
+
const m = RE.exec(lines[i]);
|
|
498
|
+
if (m) out.push({ ts: m[1], text: m[2] });
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Drop undefined-valued keys so JSON.stringify omits them (keeps the column compact). */
|
|
504
|
+
function pruneUndefined(obj) {
|
|
505
|
+
const out = {};
|
|
506
|
+
for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Path of `file` relative to `home` (for mirroring into backup-<ts>/). */
|
|
511
|
+
function relFromHome(home, file) { return relative(home, file); }
|
|
512
|
+
|
|
513
|
+
// ── insertion (one transaction) ────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
function insertAll(db, plan) {
|
|
516
|
+
const ins = {
|
|
517
|
+
project: db.prepare('INSERT OR IGNORE INTO projects (key,name,path,created_at) VALUES (?,?,?,?)'),
|
|
518
|
+
workspace: db.prepare('INSERT OR IGNORE INTO workspaces (id,name,description,created_at,updated_at) VALUES (?,?,?,?,?)'),
|
|
519
|
+
wsProj: db.prepare('INSERT OR IGNORE INTO workspace_projects (workspace_id,project_key,ordinal) VALUES (?,?,?)'),
|
|
520
|
+
workflow: db.prepare('INSERT OR IGNORE INTO workflows (id,name,version,steps,feedbacks,created_at,updated_at) VALUES (?,?,?,?,?,?,?)'),
|
|
521
|
+
projectConfig: db.prepare('INSERT OR IGNORE INTO project_config (project_key,steps,custom_models,active_workflow_id,extra) VALUES (?,?,?,?,?)'),
|
|
522
|
+
configNode: db.prepare('INSERT OR IGNORE INTO config_workflow_nodes (project_key,workflow_id,node_id,model,effort,fan_out) VALUES (?,?,?,?,?,?)'),
|
|
523
|
+
configFb: db.prepare('INSERT OR IGNORE INTO config_workflow_feedbacks (project_key,workflow_id,fb_id,max_cycles) VALUES (?,?,?,?)'),
|
|
524
|
+
storeMeta: db.prepare('INSERT OR IGNORE INTO store_meta (key,kind,data) VALUES (?,?,?)'),
|
|
525
|
+
pipeline: db.prepare(`INSERT OR IGNORE INTO pipelines
|
|
526
|
+
(id,project_key,workspace_key,target,title,base_name,date_prefix,status,phase,cycle,
|
|
527
|
+
started_at,updated_at,total_cost_usd,total_active_ms,prompt,branch,workspace_meta,stepper,tools)
|
|
528
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
|
|
529
|
+
step: db.prepare(`INSERT OR IGNORE INTO pipeline_steps
|
|
530
|
+
(pipeline_id,key,node_id,phase,step_index,cycle,status,started_at,updated_at,active_ms,running_since,cost_usd)
|
|
531
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`),
|
|
532
|
+
event: db.prepare('INSERT INTO pipeline_events (pipeline_id,ts,text) VALUES (?,?,?)'),
|
|
533
|
+
clarify: db.prepare('INSERT OR IGNORE INTO clarify (pipeline_id,questions,answers) VALUES (?,?,?)'),
|
|
534
|
+
review: db.prepare('INSERT OR IGNORE INTO reviews (pipeline_id,kind,cycle,verdict) VALUES (?,?,?,?)'),
|
|
535
|
+
artifact: db.prepare('INSERT OR IGNORE INTO artifacts (pipeline_id,kind,rel_path) VALUES (?,?,?)'),
|
|
536
|
+
marker: db.prepare('INSERT OR REPLACE INTO store_meta (key,kind,data) VALUES (?,?,?)'),
|
|
537
|
+
};
|
|
538
|
+
for (const r of plan.projects) ins.project.run(...r);
|
|
539
|
+
// m14: emit a workspace's member rows ONLY when its parent `workspaces` row
|
|
540
|
+
// actually inserted (changes===1). When `INSERT OR IGNORE` drops the row (a
|
|
541
|
+
// CI-name or id collision → changes===0), skipping its members avoids the FK
|
|
542
|
+
// violation that `INSERT OR IGNORE` does NOT swallow (it throws), which would
|
|
543
|
+
// otherwise roll the whole import back and re-trigger it on every open.
|
|
544
|
+
for (const w of plan.workspaces) {
|
|
545
|
+
const inserted = ins.workspace.run(...w.row).changes === 1;
|
|
546
|
+
if (!inserted) continue;
|
|
547
|
+
for (const m of w.members) ins.wsProj.run(...m);
|
|
548
|
+
}
|
|
549
|
+
for (const r of plan.workflows) ins.workflow.run(...r);
|
|
550
|
+
for (const r of plan.projectConfig) ins.projectConfig.run(...r);
|
|
551
|
+
for (const r of plan.configNodes) ins.configNode.run(...r);
|
|
552
|
+
for (const r of plan.configFeedbacks) ins.configFb.run(...r);
|
|
553
|
+
for (const r of plan.storeMeta) ins.storeMeta.run(...r);
|
|
554
|
+
for (const r of plan.pipelines) ins.pipeline.run(...r);
|
|
555
|
+
for (const r of plan.pipelineSteps) ins.step.run(...r);
|
|
556
|
+
for (const r of plan.pipelineEvents) ins.event.run(...r);
|
|
557
|
+
for (const r of plan.clarify) ins.clarify.run(...r);
|
|
558
|
+
for (const r of plan.reviews) ins.review.run(...r);
|
|
559
|
+
for (const r of plan.artifacts) ins.artifact.run(...r);
|
|
560
|
+
// M3: stamp the one-time completion marker LAST, in this same tx (committed by the
|
|
561
|
+
// caller's COMMIT). INSERT OR REPLACE so a marker-less pre-marker DB re-stamps clean.
|
|
562
|
+
ins.marker.run(MIGRATION_MARKER_KEY, '_meta', JSON.stringify({ migrated: true, at: nowIso() }));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── archive (after commit; best-effort) ──────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Move every consumed file into <home>/backup-<ts>/ mirroring the relative layout.
|
|
569
|
+
* Files outside `home` (per-project config.json) carry an explicit `dest` rel path
|
|
570
|
+
* (namespaced by projectKey). Best-effort: a per-file failure is swallowed (the DB
|
|
571
|
+
* is already authoritative; a stuck file must not crash app open).
|
|
572
|
+
*/
|
|
573
|
+
function archive(home, consumed) {
|
|
574
|
+
if (!consumed.length) return;
|
|
575
|
+
const ts = nowIso().replace(/[:.]/g, '-');
|
|
576
|
+
const backupRoot = join(home, `backup-${ts}`);
|
|
577
|
+
for (const item of consumed) {
|
|
578
|
+
const rel = item.dest || item.rel;
|
|
579
|
+
if (!rel) continue;
|
|
580
|
+
const dest = join(backupRoot, rel);
|
|
581
|
+
try {
|
|
582
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
583
|
+
try {
|
|
584
|
+
renameSync(item.src, dest);
|
|
585
|
+
} catch (e) {
|
|
586
|
+
if (e && e.code === 'EXDEV') { // cross-device (per-project config): copy+unlink
|
|
587
|
+
cpSync(item.src, dest);
|
|
588
|
+
unlinkSync(item.src);
|
|
589
|
+
} else {
|
|
590
|
+
throw e;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} catch (err) {
|
|
594
|
+
// Best-effort: the DB is already authoritative, so a stuck file must NOT crash
|
|
595
|
+
// app open. But LOG it (M-min3) so a leftover un-archived legacy file in the
|
|
596
|
+
// live tree is diagnosable instead of vanishing silently.
|
|
597
|
+
try { console.warn(`worca: fs->db archive failed for ${item.src} -> ${dest}: ${err && err.message ? err.message : err}`); } catch { /* never let logging itself crash open */ }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── TEST-ONLY observability (A14; consumed by db.test.mjs's hook-call test) ─────────
|
|
603
|
+
|
|
604
|
+
/** TEST-ONLY: how many times maybeMigrateFromFs() has been called. */
|
|
605
|
+
export function _migrateFromFsCallCount() {
|
|
606
|
+
return _callCount;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** TEST-ONLY: reset the call counter so each test observes a fresh open. */
|
|
610
|
+
export function _resetMigrateFromFsCallCount() {
|
|
611
|
+
_callCount = 0;
|
|
612
|
+
}
|