@open-tomato/rafa 0.28.0 → 0.32.0
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 +234 -18
- package/dist/bundled/skills/dev-planner/SKILL.md +4 -4
- package/dist/cli.js +35526 -25992
- package/dist/{describe-vbwqxj1f.js → describe-d31jy6dk.js} +3 -3
- package/dist/effort/store/index.js +4 -4
- package/dist/{index-em1vd7kd.js → index-22stnzde.js} +5384 -4755
- package/dist/{index-xwr54rzt.js → index-5tftj69y.js} +2 -2
- package/dist/{index-rv7y8y1j.js → index-9aph6ft9.js} +211 -31
- package/dist/{index-4j1e3h26.js → index-mt6hpsry.js} +1 -1
- package/dist/{index-p1265xgj.js → index-pbeyv281.js} +3 -3
- package/dist/{index-6jkj0dcx.js → index-qgayjcym.js} +15112 -8205
- package/dist/{index-sf2brh4w.js → index-rs79dq9e.js} +16 -71
- package/dist/{index-4rwvr6t4.js → index-wvm2s4pr.js} +1578 -100
- package/dist/index.js +17 -13
- package/dist/plan/index.js +3 -3
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
CONFIG_DEFAULTS,
|
|
4
4
|
CONFIG_FILE,
|
|
5
|
+
ConfigError,
|
|
5
6
|
FINDING_DOMAINS,
|
|
6
7
|
FINDING_KINDS,
|
|
7
8
|
FINDING_SIGNALS,
|
|
@@ -10,20 +11,24 @@ import {
|
|
|
10
11
|
configFilePath,
|
|
11
12
|
createTextOutput,
|
|
12
13
|
isStampableStub,
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
parseConfigText,
|
|
15
|
+
readRafaBlocks,
|
|
16
|
+
resolveConfig
|
|
17
|
+
} from "./index-9aph6ft9.js";
|
|
15
18
|
import {
|
|
16
19
|
bless,
|
|
17
20
|
merge
|
|
18
21
|
} from "./index-d6d0942q.js";
|
|
19
22
|
import {
|
|
23
|
+
CommandExit,
|
|
20
24
|
commandSpelling
|
|
21
|
-
} from "./index-
|
|
25
|
+
} from "./index-mt6hpsry.js";
|
|
22
26
|
import {
|
|
23
27
|
BUSY_TIMEOUT_MAX_MS,
|
|
24
28
|
BUSY_TIMEOUT_MIN_MS,
|
|
25
29
|
RAFA_INSTINCTS_PATH,
|
|
26
30
|
STORE_BACKENDS,
|
|
31
|
+
SYNC_STRATEGIES,
|
|
27
32
|
bundledBinDirectory,
|
|
28
33
|
describeValue,
|
|
29
34
|
isBusyTimeoutMs,
|
|
@@ -31,7 +36,7 @@ import {
|
|
|
31
36
|
messageOf,
|
|
32
37
|
resolveInstinctScopes,
|
|
33
38
|
tierExists
|
|
34
|
-
} from "./index-
|
|
39
|
+
} from "./index-rs79dq9e.js";
|
|
35
40
|
import {
|
|
36
41
|
actionHash,
|
|
37
42
|
triggerKey
|
|
@@ -244,7 +249,7 @@ import { Database } from "bun:sqlite";
|
|
|
244
249
|
|
|
245
250
|
// package.json
|
|
246
251
|
var name = "@open-tomato/rafa";
|
|
247
|
-
var version = "0.
|
|
252
|
+
var version = "0.32.0";
|
|
248
253
|
|
|
249
254
|
// src/cli/version.ts
|
|
250
255
|
var RAFA_VERSION = version;
|
|
@@ -254,7 +259,7 @@ function versionLine() {
|
|
|
254
259
|
|
|
255
260
|
// src/effort/store/development-build.ts
|
|
256
261
|
import { tmpdir as tmpdir2 } from "os";
|
|
257
|
-
import { basename as basename2, dirname as dirname7, isAbsolute as
|
|
262
|
+
import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute5 } from "path";
|
|
258
263
|
|
|
259
264
|
// src/loop/sessions.ts
|
|
260
265
|
import {
|
|
@@ -266,7 +271,7 @@ import {
|
|
|
266
271
|
rmSync as rmSync3,
|
|
267
272
|
writeFileSync as writeFileSync3
|
|
268
273
|
} from "fs";
|
|
269
|
-
import { basename, dirname as dirname5, join as join7 } from "path";
|
|
274
|
+
import { basename, dirname as dirname5, isAbsolute as isAbsolute4, join as join7 } from "path";
|
|
270
275
|
|
|
271
276
|
// src/next/hop-record.ts
|
|
272
277
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
@@ -280,7 +285,73 @@ import { dirname as dirname3, join as join5 } from "path";
|
|
|
280
285
|
|
|
281
286
|
// src/project/scope.ts
|
|
282
287
|
import { existsSync as existsSync3, realpathSync as realpathSync2 } from "fs";
|
|
283
|
-
import { dirname as dirname2, isAbsolute as
|
|
288
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
289
|
+
|
|
290
|
+
// src/project/worktree-root.ts
|
|
291
|
+
import { isAbsolute as isAbsolute2 } from "path";
|
|
292
|
+
|
|
293
|
+
// src/pr/git.ts
|
|
294
|
+
import { spawnSync } from "child_process";
|
|
295
|
+
function createGitRunner(cwd) {
|
|
296
|
+
return (args) => {
|
|
297
|
+
const result = spawnSync("git", [...args], {
|
|
298
|
+
cwd,
|
|
299
|
+
encoding: "utf8",
|
|
300
|
+
env: { ...process.env, LC_ALL: "C" }
|
|
301
|
+
});
|
|
302
|
+
if (result.error !== undefined) {
|
|
303
|
+
return { ok: false, stdout: "", stderr: `could not run git in ${cwd}: ${messageOf(result.error)}` };
|
|
304
|
+
}
|
|
305
|
+
return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function gitSaid(result) {
|
|
309
|
+
return [result.stderr, result.stdout].map((stream) => stream.trim()).filter((stream) => stream !== "").join(`
|
|
310
|
+
`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/project/worktree-root.ts
|
|
314
|
+
var NOT_A_REPOSITORY = "fatal: not a git repository (or any ";
|
|
315
|
+
var WORKTREE_LIST_ARGS = Object.freeze(["worktree", "list", "--porcelain", "-z"]);
|
|
316
|
+
var WORKTREE_PREFIX = "worktree ";
|
|
317
|
+
var BARE_LINE = "bare";
|
|
318
|
+
|
|
319
|
+
class WorktreeRootError extends Error {
|
|
320
|
+
constructor(message, options) {
|
|
321
|
+
super(`rafa worktree root: ${message}`, options);
|
|
322
|
+
this.name = "WorktreeRootError";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function firstRecordOf(stdout) {
|
|
326
|
+
const lines = stdout.split("\x00");
|
|
327
|
+
const end = lines.indexOf("");
|
|
328
|
+
return end === -1 ? lines : lines.slice(0, end);
|
|
329
|
+
}
|
|
330
|
+
function readListing(dir, stdout) {
|
|
331
|
+
const record = firstRecordOf(stdout);
|
|
332
|
+
const head = record[0];
|
|
333
|
+
if (head === undefined || !head.startsWith(WORKTREE_PREFIX)) {
|
|
334
|
+
throw new WorktreeRootError(`git worktree list in ${dir} printed no worktree line first`);
|
|
335
|
+
}
|
|
336
|
+
if (record.includes(BARE_LINE))
|
|
337
|
+
return null;
|
|
338
|
+
return head.slice(WORKTREE_PREFIX.length);
|
|
339
|
+
}
|
|
340
|
+
function readFailure(dir, result) {
|
|
341
|
+
if (result.stderr.startsWith(NOT_A_REPOSITORY))
|
|
342
|
+
return null;
|
|
343
|
+
throw new WorktreeRootError(`git worktree list failed in ${dir}: ${gitSaid(result)}`);
|
|
344
|
+
}
|
|
345
|
+
function mainCheckoutOf(dir, seams = {}) {
|
|
346
|
+
if (!isAbsolute2(dir)) {
|
|
347
|
+
throw new WorktreeRootError(`directory ${JSON.stringify(dir)} is not an absolute path`);
|
|
348
|
+
}
|
|
349
|
+
const git = (seams.openGit ?? createGitRunner)(dir);
|
|
350
|
+
const result = git(WORKTREE_LIST_ARGS);
|
|
351
|
+
return result.ok ? readListing(dir, result.stdout) : readFailure(dir, result);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/project/scope.ts
|
|
284
355
|
var SCOPE_DIR = dirname2(CONFIG_FILE);
|
|
285
356
|
var INIT_COMMAND = "rafa init";
|
|
286
357
|
var DISK_FILE_SYSTEM = Object.freeze({
|
|
@@ -297,17 +368,19 @@ class ScopeError extends Error {
|
|
|
297
368
|
function scopeAt(base) {
|
|
298
369
|
return { dir: join4(base, SCOPE_DIR), configFile: configFilePath(base) };
|
|
299
370
|
}
|
|
300
|
-
function initHint(start, passedUserConfig = null) {
|
|
371
|
+
function initHint(start, passedUserConfig = null, probedMainCheckout = null) {
|
|
301
372
|
const passed = passedUserConfig === null ? [] : [`${passedUserConfig} is the user scope's config and marks no project`];
|
|
373
|
+
const main = probedMainCheckout === null ? [] : [`nor in ${probedMainCheckout}, the main checkout of the repository holding it`];
|
|
302
374
|
return [
|
|
303
375
|
`not inside a rafa project: no ${CONFIG_FILE} in ${start} or any directory above it`,
|
|
376
|
+
...main,
|
|
304
377
|
...passed,
|
|
305
378
|
`run \`${INIT_COMMAND}\` to set one up`
|
|
306
379
|
].join(`
|
|
307
380
|
`);
|
|
308
381
|
}
|
|
309
382
|
function requireAbsolute(label, path) {
|
|
310
|
-
if (!
|
|
383
|
+
if (!isAbsolute3(path)) {
|
|
311
384
|
throw new ScopeError(`${label} is ${describeValue(path)}, expected an absolute path`);
|
|
312
385
|
}
|
|
313
386
|
}
|
|
@@ -341,17 +414,36 @@ function walkUp(realStart, realHome, fs) {
|
|
|
341
414
|
}
|
|
342
415
|
return { root: null, passedUserConfig };
|
|
343
416
|
}
|
|
417
|
+
function mainCheckoutAt(realStart, mainCheckout) {
|
|
418
|
+
try {
|
|
419
|
+
return mainCheckout(realStart);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (!(error instanceof WorktreeRootError))
|
|
422
|
+
throw error;
|
|
423
|
+
throw new ScopeError(`cannot read the main checkout of ${realStart} (${error.message})`, { cause: error });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function fallBack(realStart, realHome, fs, mainCheckout) {
|
|
427
|
+
const main = mainCheckoutAt(realStart, mainCheckout);
|
|
428
|
+
if (main === null || main === realHome || selfAndAncestors(realStart).includes(main)) {
|
|
429
|
+
return { root: null, probed: null };
|
|
430
|
+
}
|
|
431
|
+
return fs.exists(configFilePath(main)) ? { root: main, probed: main } : { root: null, probed: main };
|
|
432
|
+
}
|
|
344
433
|
function resolveScope(start, seams) {
|
|
345
|
-
const { home, fs = DISK_FILE_SYSTEM } = seams;
|
|
434
|
+
const { home, fs = DISK_FILE_SYSTEM, mainCheckout = mainCheckoutOf } = seams;
|
|
346
435
|
requireAbsolute("start directory", start);
|
|
347
436
|
requireAbsolute("home directory", home);
|
|
348
437
|
const user = scopeAt(home);
|
|
349
|
-
const
|
|
350
|
-
|
|
438
|
+
const realStart = realStartOf(start, fs);
|
|
439
|
+
const realHome = realHomeOf(home, fs);
|
|
440
|
+
const walk = walkUp(realStart, realHome, fs);
|
|
441
|
+
const fallback = walk.root === null ? fallBack(realStart, realHome, fs, mainCheckout) : { root: walk.root, probed: null };
|
|
442
|
+
if (fallback.root === null) {
|
|
351
443
|
const passed = walk.passedUserConfig ? user.configFile : null;
|
|
352
|
-
return { found: false, start, home, user, hint: initHint(start, passed) };
|
|
444
|
+
return { found: false, start, home, user, hint: initHint(start, passed, fallback.probed) };
|
|
353
445
|
}
|
|
354
|
-
return { found: true, root:
|
|
446
|
+
return { found: true, root: fallback.root, home, project: scopeAt(fallback.root), user };
|
|
355
447
|
}
|
|
356
448
|
|
|
357
449
|
// src/project/position.ts
|
|
@@ -612,6 +704,12 @@ function hopProblem(fields) {
|
|
|
612
704
|
const hop2 = field(fields, "hop");
|
|
613
705
|
return asHopRecord(hop2) === null ? `hop is ${describeValue2(hop2)}, expected a hop record` : null;
|
|
614
706
|
}
|
|
707
|
+
function worktreeProblem(fields) {
|
|
708
|
+
if (!Object.hasOwn(fields, "worktree"))
|
|
709
|
+
return null;
|
|
710
|
+
const worktree = field(fields, "worktree");
|
|
711
|
+
return isText(worktree) && isAbsolute4(worktree) ? null : `worktree is ${describeValue2(worktree)}, expected an absolute path`;
|
|
712
|
+
}
|
|
615
713
|
function sessionIdProblem(sessionId, file) {
|
|
616
714
|
if (typeof sessionId !== "string" || !isSessionId(sessionId)) {
|
|
617
715
|
return `sessionId is ${describeValue2(sessionId)}, expected a plain file name`;
|
|
@@ -636,7 +734,8 @@ function recordProblems(fields, file) {
|
|
|
636
734
|
typeof startedAt === "string" && !Number.isNaN(Date.parse(startedAt)) ? null : `startedAt is ${describeValue2(startedAt)}, expected a timestamp`,
|
|
637
735
|
SESSION_STATES.includes(state) ? null : `state is ${describeValue2(state)}, expected one of ${SESSION_STATES.join(", ")}`,
|
|
638
736
|
taskProblem(field(fields, "task")),
|
|
639
|
-
hopProblem(fields)
|
|
737
|
+
hopProblem(fields),
|
|
738
|
+
worktreeProblem(fields)
|
|
640
739
|
];
|
|
641
740
|
return problems.filter((problem) => problem !== null);
|
|
642
741
|
}
|
|
@@ -647,6 +746,7 @@ function freezeHop(value) {
|
|
|
647
746
|
function freezeRecord(fields) {
|
|
648
747
|
const task = field(fields, "task");
|
|
649
748
|
const hop2 = field(fields, "hop");
|
|
749
|
+
const worktree = field(fields, "worktree");
|
|
650
750
|
return Object.freeze({
|
|
651
751
|
sessionId: field(fields, "sessionId"),
|
|
652
752
|
planStub: field(fields, "planStub"),
|
|
@@ -656,7 +756,8 @@ function freezeRecord(fields) {
|
|
|
656
756
|
startedAt: field(fields, "startedAt"),
|
|
657
757
|
state: field(fields, "state"),
|
|
658
758
|
task: isObject(task) ? Object.freeze({ line: field(task, "line"), text: field(task, "text") }) : null,
|
|
659
|
-
...hop2 === undefined ? {} : { hop: freezeHop(hop2) }
|
|
759
|
+
...hop2 === undefined ? {} : { hop: freezeHop(hop2) },
|
|
760
|
+
...worktree === undefined ? {} : { worktree }
|
|
660
761
|
});
|
|
661
762
|
}
|
|
662
763
|
function parseSessionRecord(text, file) {
|
|
@@ -850,7 +951,7 @@ function isOwnedStore(path, env, tempDir) {
|
|
|
850
951
|
if (isUnderTempDir(path, tempDir))
|
|
851
952
|
return true;
|
|
852
953
|
const override = env[EFFORT_DIR_VARIABLE];
|
|
853
|
-
if (override === undefined || override === "" || !
|
|
954
|
+
if (override === undefined || override === "" || !isAbsolute5(override))
|
|
854
955
|
return false;
|
|
855
956
|
return storeProjectRoot(path) === null && isUnderTempDir(path, override);
|
|
856
957
|
}
|
|
@@ -1482,26 +1583,6 @@ import { createHmac } from "crypto";
|
|
|
1482
1583
|
import { readFileSync as readFileSync6, realpathSync as realpathSync4, statSync } from "fs";
|
|
1483
1584
|
import { hostname, platform as currentPlatform } from "os";
|
|
1484
1585
|
|
|
1485
|
-
// src/pr/git.ts
|
|
1486
|
-
import { spawnSync } from "child_process";
|
|
1487
|
-
function createGitRunner(cwd) {
|
|
1488
|
-
return (args) => {
|
|
1489
|
-
const result = spawnSync("git", [...args], {
|
|
1490
|
-
cwd,
|
|
1491
|
-
encoding: "utf8",
|
|
1492
|
-
env: { ...process.env, LC_ALL: "C" }
|
|
1493
|
-
});
|
|
1494
|
-
if (result.error !== undefined) {
|
|
1495
|
-
return { ok: false, stdout: "", stderr: `could not run git in ${cwd}: ${messageOf(result.error)}` };
|
|
1496
|
-
}
|
|
1497
|
-
return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1500
|
-
function gitSaid(result) {
|
|
1501
|
-
return [result.stderr, result.stdout].map((stream) => stream.trim()).filter((stream) => stream !== "").join(`
|
|
1502
|
-
`);
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
1586
|
// src/schema/project-id.ts
|
|
1506
1587
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
1507
1588
|
var PROJECT_ID_LENGTH = 12;
|
|
@@ -1778,17 +1859,1390 @@ function openSqliteStore(repoRoot) {
|
|
|
1778
1859
|
};
|
|
1779
1860
|
}
|
|
1780
1861
|
|
|
1862
|
+
// src/effort/sync/file.ts
|
|
1863
|
+
import { existsSync as existsSync12 } from "fs";
|
|
1864
|
+
import { join as join12, resolve as resolve5 } from "path";
|
|
1865
|
+
|
|
1866
|
+
// src/effort/store/fix-schema.ts
|
|
1867
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1868
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
1869
|
+
|
|
1870
|
+
// src/effort/store/rebuild-aside.ts
|
|
1871
|
+
import { existsSync as existsSync6, renameSync as renameSync4, rmSync as rmSync4 } from "fs";
|
|
1872
|
+
|
|
1873
|
+
class RebuildRefusal extends Error {
|
|
1874
|
+
constructor(message) {
|
|
1875
|
+
super(message);
|
|
1876
|
+
this.name = "RebuildRefusal";
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
function quoted(name2) {
|
|
1880
|
+
return `"${name2.replaceAll('"', '""')}"`;
|
|
1881
|
+
}
|
|
1882
|
+
function count(db, sql) {
|
|
1883
|
+
return db.query(sql).get()?.n ?? 0;
|
|
1884
|
+
}
|
|
1885
|
+
function refuseInFlight(path) {
|
|
1886
|
+
for (const suffix of ["-journal", "-wal"]) {
|
|
1887
|
+
if (existsSync6(`${path}${suffix}`)) {
|
|
1888
|
+
throw new RebuildRefusal(`${path}${suffix} is there, so a write is in flight or was interrupted; stop whatever uses the store` + " and open it once with rafa before repairing it");
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
function checkedCounts(db, tables, original, added = {}) {
|
|
1893
|
+
return tables.map((table) => {
|
|
1894
|
+
const rows = count(db, `SELECT count(*) AS n FROM main.${quoted(table)}`);
|
|
1895
|
+
const originalRows = count(db, `SELECT count(*) AS n FROM ${original}.${quoted(table)}`);
|
|
1896
|
+
const addedRows = added[table] ?? 0;
|
|
1897
|
+
if (rows !== originalRows + addedRows) {
|
|
1898
|
+
const plus = addedRows === 0 ? "" : ` plus ${String(addedRows)} added`;
|
|
1899
|
+
throw new RebuildRefusal(`the rebuilt ${table} holds ${String(rows)} rows, the store ${String(originalRows)}${plus}`);
|
|
1900
|
+
}
|
|
1901
|
+
return { table, rows };
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
function refuseCorrupt(db) {
|
|
1905
|
+
const answer = db.query("PRAGMA main.integrity_check").get()?.integrity_check;
|
|
1906
|
+
if (answer !== "ok")
|
|
1907
|
+
throw new RebuildRefusal(`the rebuilt store failed integrity_check: ${answer ?? "no answer"}`);
|
|
1908
|
+
}
|
|
1909
|
+
function removeParallel(parallelPath) {
|
|
1910
|
+
rmSync4(parallelPath, { force: true });
|
|
1911
|
+
rmSync4(`${parallelPath}-journal`, { force: true });
|
|
1912
|
+
}
|
|
1913
|
+
function swapIn(path, parallelPath, backupPath) {
|
|
1914
|
+
renameSync4(path, backupPath);
|
|
1915
|
+
try {
|
|
1916
|
+
renameSync4(parallelPath, path);
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
renameSync4(backupPath, path);
|
|
1919
|
+
throw error;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
function refuseTaken(paths) {
|
|
1923
|
+
for (const taken of paths) {
|
|
1924
|
+
if (existsSync6(taken))
|
|
1925
|
+
throw new RebuildRefusal(`${taken} is already there; move it aside or pass another stamp`);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
function rebuildAside(options) {
|
|
1929
|
+
const { path, parallelPath, backupPath, dryRun, build } = options;
|
|
1930
|
+
refuseInFlight(path);
|
|
1931
|
+
refuseTaken([parallelPath, backupPath]);
|
|
1932
|
+
let built;
|
|
1933
|
+
try {
|
|
1934
|
+
built = build(parallelPath);
|
|
1935
|
+
} catch (error) {
|
|
1936
|
+
removeParallel(parallelPath);
|
|
1937
|
+
throw error;
|
|
1938
|
+
}
|
|
1939
|
+
if (dryRun) {
|
|
1940
|
+
removeParallel(parallelPath);
|
|
1941
|
+
return { built, backupPath: null };
|
|
1942
|
+
}
|
|
1943
|
+
swapIn(path, parallelPath, backupPath);
|
|
1944
|
+
return { built, backupPath };
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// src/effort/store/fix-schema.ts
|
|
1948
|
+
var REPAIRABLE_REASONS = [
|
|
1949
|
+
"pre-log-unreleased",
|
|
1950
|
+
"gate-mismatch",
|
|
1951
|
+
"edited",
|
|
1952
|
+
"unknown-breaks-readers",
|
|
1953
|
+
"unknown-breaks-writers"
|
|
1954
|
+
];
|
|
1955
|
+
var LIVE = "live";
|
|
1956
|
+
function tableNames(db, schema) {
|
|
1957
|
+
return db.query(`SELECT name FROM ${schema}.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> ? ORDER BY name`).all(MIGRATION_LOG_TABLE).map((row) => row.name);
|
|
1958
|
+
}
|
|
1959
|
+
function columnNames(db, schema, table) {
|
|
1960
|
+
return db.query("SELECT name FROM pragma_table_info(?, ?)").all(table, schema).map((row) => row.name);
|
|
1961
|
+
}
|
|
1962
|
+
function readOnlySchema(path) {
|
|
1963
|
+
const db = new Database2(path, { readonly: true });
|
|
1964
|
+
try {
|
|
1965
|
+
return readStoreSchema(db, path);
|
|
1966
|
+
} finally {
|
|
1967
|
+
db.close();
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function refuseRemoved(db, known, live) {
|
|
1971
|
+
for (const table of known) {
|
|
1972
|
+
if (!live.has(table)) {
|
|
1973
|
+
throw new RebuildRefusal(`the store holds no table ${table}, which this rafa writes; its newer schema is not additive`);
|
|
1974
|
+
}
|
|
1975
|
+
const liveColumns = new Set(columnNames(db, LIVE, table));
|
|
1976
|
+
const missing = columnNames(db, "main", table).filter((column) => !liveColumns.has(column));
|
|
1977
|
+
if (missing.length > 0) {
|
|
1978
|
+
throw new RebuildRefusal(`the store holds no column ${missing.map((column) => `${table}.${column}`).join(", ")}, which this rafa` + " writes; its newer schema is not additive");
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
function copyKnown(db, known) {
|
|
1983
|
+
db.transaction(() => {
|
|
1984
|
+
for (const table of known) {
|
|
1985
|
+
const columns = columnNames(db, "main", table).map(quoted).join(", ");
|
|
1986
|
+
db.run(`INSERT INTO main.${quoted(table)} (${columns}) SELECT ${columns} FROM ${LIVE}.${quoted(table)}`);
|
|
1987
|
+
}
|
|
1988
|
+
})();
|
|
1989
|
+
}
|
|
1990
|
+
function leftBehind(db, known, live) {
|
|
1991
|
+
const leftTables = live.filter((table) => !known.has(table)).map((table) => ({ table, rows: count(db, `SELECT count(*) AS n FROM ${LIVE}.${quoted(table)}`) }));
|
|
1992
|
+
const leftColumns = live.filter((table) => known.has(table)).flatMap((table) => {
|
|
1993
|
+
const kept = new Set(columnNames(db, "main", table));
|
|
1994
|
+
return columnNames(db, LIVE, table).filter((column) => !kept.has(column)).map((column) => ({
|
|
1995
|
+
table,
|
|
1996
|
+
column,
|
|
1997
|
+
values: count(db, `SELECT count(*) AS n FROM ${LIVE}.${quoted(table)} WHERE ${quoted(column)} IS NOT NULL`)
|
|
1998
|
+
}));
|
|
1999
|
+
});
|
|
2000
|
+
return { leftTables, leftColumns };
|
|
2001
|
+
}
|
|
2002
|
+
function refuseUnusable(db, parallelPath, migrations) {
|
|
2003
|
+
const plan = planSchema(readStoreSchema(db, parallelPath), sqliteCatalogue(migrations), "write", "open");
|
|
2004
|
+
if (plan.verdict === "refuse")
|
|
2005
|
+
throw new RebuildRefusal(`the rebuilt store is refused as ${plan.reason}: ${plan.message}`);
|
|
2006
|
+
const needs = writeNames(plan);
|
|
2007
|
+
if (needs.length > 0)
|
|
2008
|
+
throw new RebuildRefusal(`the rebuilt store still needs ${needs.join(", ")}`);
|
|
2009
|
+
}
|
|
2010
|
+
function buildParallel(path, parallelPath, migrations, stamp) {
|
|
2011
|
+
const db = new Database2(parallelPath, { create: true, readwrite: true });
|
|
2012
|
+
try {
|
|
2013
|
+
bringForward(db, parallelPath, "write", "open", { migrations, ...stamp, builtAside: true });
|
|
2014
|
+
db.run(`ATTACH DATABASE ? AS ${LIVE}`, [path]);
|
|
2015
|
+
const known = tableNames(db, "main");
|
|
2016
|
+
const live = tableNames(db, LIVE);
|
|
2017
|
+
refuseRemoved(db, known, new Set(live));
|
|
2018
|
+
copyKnown(db, known);
|
|
2019
|
+
const kept = checkedCounts(db, known, LIVE);
|
|
2020
|
+
const left = leftBehind(db, new Set(known), live);
|
|
2021
|
+
db.run(`DETACH DATABASE ${LIVE}`);
|
|
2022
|
+
refuseCorrupt(db);
|
|
2023
|
+
refuseUnusable(db, parallelPath, migrations);
|
|
2024
|
+
return { kept, ...left };
|
|
2025
|
+
} finally {
|
|
2026
|
+
db.close();
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
function asidePaths(path, version2, stamp) {
|
|
2030
|
+
return { parallelPath: `${path}.fix-${stamp}`, backupPath: `${path}.v${String(version2)}-${stamp}.bak` };
|
|
2031
|
+
}
|
|
2032
|
+
function isRepairable(reason) {
|
|
2033
|
+
return REPAIRABLE_REASONS.includes(reason);
|
|
2034
|
+
}
|
|
2035
|
+
function refuseDevelopmentSwap(path, reason, identity) {
|
|
2036
|
+
if (identity.kind !== "development")
|
|
2037
|
+
return;
|
|
2038
|
+
throw new RebuildRefusal(`${path} needs a rebuild (${reason}), and this rafa is a development build (${identity.checkout}), whose` + " migrations may be a branch's; a development build never swaps a rebuild in. Nothing was changed, and" + " --dry-run runs from here. Run the swap from an installed rafa. Next safe step: rafa effort fix-schema");
|
|
2039
|
+
}
|
|
2040
|
+
function fixStoreSchema(options) {
|
|
2041
|
+
const { path, dryRun, stamp } = options;
|
|
2042
|
+
guardTestProcess(path);
|
|
2043
|
+
const migrations = options.migrations ?? SQLITE_MIGRATIONS;
|
|
2044
|
+
const identity = options.identity ?? readRuntimeIdentity();
|
|
2045
|
+
const appliedBy = appliedByName(identity);
|
|
2046
|
+
const known = migrations.map(({ id }) => id);
|
|
2047
|
+
const nothing = { known, appliedBy, pending: [], kept: [], leftTables: [], leftColumns: [], backupPath: null };
|
|
2048
|
+
if (!existsSync7(path)) {
|
|
2049
|
+
return { path, status: "missing", storeVersion: null, logged: false, reason: null, refusal: null, unknown: [], ...nothing };
|
|
2050
|
+
}
|
|
2051
|
+
refuseInFlight(path);
|
|
2052
|
+
const store = readOnlySchema(path);
|
|
2053
|
+
const knownIds = new Set(known);
|
|
2054
|
+
const found = {
|
|
2055
|
+
path,
|
|
2056
|
+
storeVersion: store.userVersion,
|
|
2057
|
+
logged: store.log !== null,
|
|
2058
|
+
unknown: (store.log ?? []).filter(({ id }) => !knownIds.has(id))
|
|
2059
|
+
};
|
|
2060
|
+
const plan = planSchema(store, sqliteCatalogue(migrations), "write", "open");
|
|
2061
|
+
if (plan.verdict === "use") {
|
|
2062
|
+
const pending = writeNames(plan);
|
|
2063
|
+
const status2 = pending.length === 0 ? "current" : "behind";
|
|
2064
|
+
return { ...found, ...nothing, status: status2, reason: null, refusal: null, pending };
|
|
2065
|
+
}
|
|
2066
|
+
if (!isRepairable(plan.reason)) {
|
|
2067
|
+
throw new RebuildRefusal(`a rebuild at this rafa's migrations does not repair ${plan.reason}. ${plan.message}`);
|
|
2068
|
+
}
|
|
2069
|
+
if (!dryRun)
|
|
2070
|
+
refuseDevelopmentSwap(path, plan.reason, identity);
|
|
2071
|
+
const { built, backupPath } = rebuildAside({
|
|
2072
|
+
path,
|
|
2073
|
+
...asidePaths(path, store.userVersion, stamp),
|
|
2074
|
+
dryRun,
|
|
2075
|
+
build: (parallelPath) => buildParallel(path, parallelPath, migrations, {
|
|
2076
|
+
appliedBy,
|
|
2077
|
+
now: options.now ?? (() => new Date)
|
|
2078
|
+
})
|
|
2079
|
+
});
|
|
2080
|
+
const status = dryRun ? "would-rebuild" : "rebuilt";
|
|
2081
|
+
const refusal = withoutWayOut(plan.message, plan.nextStep);
|
|
2082
|
+
return { ...found, ...nothing, status, reason: plan.reason, refusal, ...built, backupPath };
|
|
2083
|
+
}
|
|
2084
|
+
function withoutWayOut(message, nextStep) {
|
|
2085
|
+
const ending = ` Next safe step: ${nextStep}`;
|
|
2086
|
+
return message.endsWith(ending) ? message.slice(0, -ending.length) : message;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// src/commands/plan/plan-files.ts
|
|
2090
|
+
import { statSync as statSync2 } from "fs";
|
|
2091
|
+
import { resolve as resolve3 } from "path";
|
|
2092
|
+
|
|
2093
|
+
// src/config-load.ts
|
|
2094
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7, realpathSync as realpathSync5 } from "fs";
|
|
2095
|
+
import { isAbsolute as isAbsolute6 } from "path";
|
|
2096
|
+
function readConfigFile(root) {
|
|
2097
|
+
const path = configFilePath(root);
|
|
2098
|
+
if (!existsSync8(path))
|
|
2099
|
+
return null;
|
|
2100
|
+
let text;
|
|
2101
|
+
try {
|
|
2102
|
+
text = readFileSync7(path, "utf8");
|
|
2103
|
+
} catch (error) {
|
|
2104
|
+
const problem = `${path}: cannot be read (${messageOf(error)})`;
|
|
2105
|
+
throw new ConfigError([problem], { cause: error });
|
|
2106
|
+
}
|
|
2107
|
+
return parseConfigText(text, path);
|
|
2108
|
+
}
|
|
2109
|
+
function isSameDirectory(first, second) {
|
|
2110
|
+
try {
|
|
2111
|
+
return realpathSync5(first) === realpathSync5(second);
|
|
2112
|
+
} catch {
|
|
2113
|
+
return false;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
function readUserConfigFile({ root, home }) {
|
|
2117
|
+
if (!isAbsolute6(home)) {
|
|
2118
|
+
throw new ConfigError([
|
|
2119
|
+
`home directory is ${describeValue(home)}, expected an absolute path`
|
|
2120
|
+
]);
|
|
2121
|
+
}
|
|
2122
|
+
return isSameDirectory(root, home) ? null : readConfigFile(home);
|
|
2123
|
+
}
|
|
2124
|
+
function printWarning(message) {
|
|
2125
|
+
activeOutput().warn(message);
|
|
2126
|
+
}
|
|
2127
|
+
function loadConfig(roots, cli = {}, warn = printWarning) {
|
|
2128
|
+
const user = readUserConfigFile(roots);
|
|
2129
|
+
const file = readConfigFile(roots.root);
|
|
2130
|
+
const resolved = resolveConfig({ cli, file, user });
|
|
2131
|
+
for (const warning of resolved.warnings)
|
|
2132
|
+
warn(warning);
|
|
2133
|
+
setActiveStoreSettings({ busyTimeoutMs: resolved.config.effortBusyTimeoutMs });
|
|
2134
|
+
return resolved;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// src/commands/plan/plan-files.ts
|
|
2138
|
+
function plansDirAt(root, planDir) {
|
|
2139
|
+
return { label: planDir, path: resolve3(root, planDir) };
|
|
2140
|
+
}
|
|
2141
|
+
function requireProject(context, command) {
|
|
2142
|
+
if (context.project === null)
|
|
2143
|
+
throw new Error(`${command} runs inside a project, and was handed none`);
|
|
2144
|
+
return context.project;
|
|
2145
|
+
}
|
|
2146
|
+
function resolvePlansDir(project, command, warn) {
|
|
2147
|
+
return plansDirAt(project.root, resolveProjectConfig(project, command, warn).planDir);
|
|
2148
|
+
}
|
|
2149
|
+
function resolveProjectConfig(project, command, warn) {
|
|
2150
|
+
try {
|
|
2151
|
+
return loadConfig({ root: project.root, home: project.home }, {}, warn).config;
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
if (!(error instanceof ConfigError))
|
|
2154
|
+
throw error;
|
|
2155
|
+
throw new CommandExit(1, [
|
|
2156
|
+
`\u274C ${command}: the config cannot be used:`,
|
|
2157
|
+
...error.problems.map((problem) => ` ${problem}`)
|
|
2158
|
+
].join(`
|
|
2159
|
+
`));
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
var CHECKBOXES = {
|
|
2163
|
+
unchecked: "[ ]",
|
|
2164
|
+
blocked: "[BLOCKED]",
|
|
2165
|
+
done: "[x]"
|
|
2166
|
+
};
|
|
2167
|
+
function countTasks(tasks) {
|
|
2168
|
+
const done = tasks.filter((task) => task.status === "done").length;
|
|
2169
|
+
const blocked = tasks.filter((task) => task.status === "blocked").length;
|
|
2170
|
+
return { total: tasks.length, done, blocked, open: tasks.length - done - blocked };
|
|
2171
|
+
}
|
|
2172
|
+
function formatCounts(counts) {
|
|
2173
|
+
return `${counts.done}/${counts.total} done, ${counts.blocked} blocked, ${counts.open} open`;
|
|
2174
|
+
}
|
|
2175
|
+
function checkbox(status) {
|
|
2176
|
+
return CHECKBOXES[status];
|
|
2177
|
+
}
|
|
2178
|
+
function planFileName(stub, tracker) {
|
|
2179
|
+
return tracker ? `PLAN_TRACKER-${stub}.md` : `PLAN-${stub}.md`;
|
|
2180
|
+
}
|
|
2181
|
+
function stubOfPlanFile(name2) {
|
|
2182
|
+
const stub = /^PLAN-(.+)\.md$/.exec(name2)?.[1];
|
|
2183
|
+
return stub !== undefined && isStampableStub(stub) ? stub : null;
|
|
2184
|
+
}
|
|
2185
|
+
function isFile(path) {
|
|
2186
|
+
return statSync2(path, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
2187
|
+
}
|
|
2188
|
+
function issueLine(file, issue) {
|
|
2189
|
+
return `${file}:${issue.line}: ${issue.reason}: ${issue.text}`;
|
|
2190
|
+
}
|
|
2191
|
+
function plural(count2, noun) {
|
|
2192
|
+
return count2 === 1 ? `${count2} ${noun}` : `${count2} ${noun}s`;
|
|
2193
|
+
}
|
|
2194
|
+
function expectNoArgument(args, usage) {
|
|
2195
|
+
if (args.length === 0)
|
|
2196
|
+
return;
|
|
2197
|
+
throw new CommandExit(1, `\u274C Expected no argument, got ${args.length}: ${args.join(" ")}
|
|
2198
|
+
Usage: ${usage}`);
|
|
2199
|
+
}
|
|
2200
|
+
function expectOneArgument(args, usage) {
|
|
2201
|
+
const [only] = args;
|
|
2202
|
+
if (args.length === 1 && only !== undefined)
|
|
2203
|
+
return only;
|
|
2204
|
+
const got = args.length === 0 ? "none" : `${args.length}: ${args.join(" ")}`;
|
|
2205
|
+
throw new CommandExit(1, `\u274C Expected one argument, got ${got}
|
|
2206
|
+
Usage: ${usage}`);
|
|
2207
|
+
}
|
|
2208
|
+
function expectAtMostOneArgument(args, usage) {
|
|
2209
|
+
const [only] = args;
|
|
2210
|
+
if (only === undefined)
|
|
2211
|
+
return null;
|
|
2212
|
+
if (args.length === 1)
|
|
2213
|
+
return only;
|
|
2214
|
+
throw new CommandExit(1, `\u274C Expected at most one argument, got ${args.length}: ${args.join(" ")}
|
|
2215
|
+
Usage: ${usage}`);
|
|
2216
|
+
}
|
|
2217
|
+
function readSwitch(name2, value, hint) {
|
|
2218
|
+
if (value === undefined || value === false || value === "false")
|
|
2219
|
+
return false;
|
|
2220
|
+
if (value === true || value === "true")
|
|
2221
|
+
return true;
|
|
2222
|
+
throw new CommandExit(1, `\u274C --${name2} takes no value, and read "${value}" as one. ${hint}`);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// src/commands/effort/fix-schema.ts
|
|
2226
|
+
var COMMAND_NAME = "rafa effort fix-schema";
|
|
2227
|
+
var FIX_SCHEMA_USAGE = "rafa effort fix-schema [--dry-run]";
|
|
2228
|
+
var DRY_RUN_FLAG = "dry-run";
|
|
2229
|
+
function fileStamp(date) {
|
|
2230
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
2231
|
+
}
|
|
2232
|
+
function refuseLiveLoop(root, isAlive) {
|
|
2233
|
+
let records;
|
|
2234
|
+
try {
|
|
2235
|
+
records = readSessions(root, isAlive === undefined ? {} : { isAlive });
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
throw new CommandExit(1, `\u274C ${COMMAND_NAME}: the loop's run records cannot be read, so a live loop cannot be ruled out: ${String(error)}`);
|
|
2238
|
+
}
|
|
2239
|
+
const live = records.find((record) => record.state === "running" || record.state === "paused");
|
|
2240
|
+
if (live === undefined)
|
|
2241
|
+
return;
|
|
2242
|
+
throw new CommandExit(1, `\u274C ${COMMAND_NAME}: loop session ${live.sessionId} on ${live.branch} is ${live.state} and writes the store;` + ` stop it with \`rafa loop stop -s ${live.sessionId}\` first. \`--dry-run\` runs beside it.`);
|
|
2243
|
+
}
|
|
2244
|
+
function migrationNames(result) {
|
|
2245
|
+
return result.unknown.map(({ id, appliedBy }) => `${id} (applied by ${appliedBy})`).join(", ");
|
|
2246
|
+
}
|
|
2247
|
+
function rebuildLines(result) {
|
|
2248
|
+
const rows = result.kept.reduce((sum, table) => sum + table.rows, 0);
|
|
2249
|
+
const kept = result.kept.map((table) => `${table.table} ${String(table.rows)}`).join(", ");
|
|
2250
|
+
const left = [
|
|
2251
|
+
...result.unknown.map(({ id, appliedBy }) => `migration ${id} (applied by ${appliedBy})`),
|
|
2252
|
+
...result.leftTables.map((table) => `table ${table.table} (${String(table.rows)} rows)`),
|
|
2253
|
+
...result.leftColumns.map((column) => `column ${column.table}.${column.column} (${String(column.values)} values)`)
|
|
2254
|
+
];
|
|
2255
|
+
const newer = result.unknown.length === 0 ? [] : [`A rafa that knows ${result.unknown.map(({ id }) => id).join(", ")} uses the store as it is and keeps that data` + " in it; install one instead where you can."];
|
|
2256
|
+
return [
|
|
2257
|
+
`Refused as ${String(result.reason)}: ${String(result.refusal)}`,
|
|
2258
|
+
`Rebuilds it at the ${String(result.known.length)} migrations this rafa knows, logged as applied by ${result.appliedBy}.`,
|
|
2259
|
+
`Keeps ${String(result.kept.length)} tables, ${String(rows)} rows: ${kept}.`,
|
|
2260
|
+
left.length === 0 ? "Leaves nothing behind." : `Leaves behind, kept only in the backup: ${left.join("; ")}.`,
|
|
2261
|
+
...newer
|
|
2262
|
+
];
|
|
2263
|
+
}
|
|
2264
|
+
function unknownAdditiveLines(result) {
|
|
2265
|
+
return result.unknown.length === 0 ? [] : [`It logs ${migrationNames(result)}, which this rafa does not know; each is additive, so this rafa uses the store` + " through them."];
|
|
2266
|
+
}
|
|
2267
|
+
function renderFixSchema(result) {
|
|
2268
|
+
switch (result.status) {
|
|
2269
|
+
case "missing":
|
|
2270
|
+
return [`No effort store at ${result.path}: nothing to repair.`];
|
|
2271
|
+
case "current":
|
|
2272
|
+
return [
|
|
2273
|
+
`\u2705 ${result.path} is current: this rafa reads and writes it as it is. Nothing to repair.`,
|
|
2274
|
+
...unknownAdditiveLines(result)
|
|
2275
|
+
];
|
|
2276
|
+
case "behind":
|
|
2277
|
+
return [
|
|
2278
|
+
`${result.path} is behind this rafa's migrations; the next command that opens it records` + ` ${result.pending.join(", ")}. Nothing to repair.`,
|
|
2279
|
+
...unknownAdditiveLines(result)
|
|
2280
|
+
];
|
|
2281
|
+
case "would-rebuild":
|
|
2282
|
+
return [
|
|
2283
|
+
...rebuildLines(result),
|
|
2284
|
+
"\uD83D\uDD0D Dry run: the rebuild was built, checked (row counts, integrity_check, this rafa's schema plan) and" + " deleted. Run without `--dry-run` to swap it in."
|
|
2285
|
+
];
|
|
2286
|
+
case "rebuilt":
|
|
2287
|
+
return [
|
|
2288
|
+
...rebuildLines(result),
|
|
2289
|
+
`\u2705 Rebuilt at this rafa's migrations. The original is kept whole at ${String(result.backupPath)};` + " rename it back to undo."
|
|
2290
|
+
];
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
function runFixSchema(context, seams) {
|
|
2294
|
+
expectNoArgument(context.args, FIX_SCHEMA_USAGE);
|
|
2295
|
+
const dryRun = readSwitch(DRY_RUN_FLAG, context.flags[DRY_RUN_FLAG], `Usage: ${FIX_SCHEMA_USAGE}`);
|
|
2296
|
+
if (context.project === null)
|
|
2297
|
+
throw new Error(`${COMMAND_NAME} runs inside a project, and was handed none`);
|
|
2298
|
+
const root = context.project.root;
|
|
2299
|
+
if (!dryRun)
|
|
2300
|
+
refuseLiveLoop(root, seams.isAlive);
|
|
2301
|
+
const now = seams.now ?? (() => new Date);
|
|
2302
|
+
let result;
|
|
2303
|
+
try {
|
|
2304
|
+
result = fixStoreSchema({
|
|
2305
|
+
path: sqliteStorePath(root),
|
|
2306
|
+
dryRun,
|
|
2307
|
+
stamp: fileStamp(now()),
|
|
2308
|
+
now,
|
|
2309
|
+
...seams.identity === undefined ? {} : { identity: seams.identity }
|
|
2310
|
+
});
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
if (error instanceof RebuildRefusal)
|
|
2313
|
+
throw new CommandExit(1, `\u274C ${COMMAND_NAME}: ${error.message}`);
|
|
2314
|
+
throw error;
|
|
2315
|
+
}
|
|
2316
|
+
if (context.outputMode === "json") {
|
|
2317
|
+
context.output.result(result);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
for (const line of renderFixSchema(result))
|
|
2321
|
+
context.output.info(line);
|
|
2322
|
+
}
|
|
2323
|
+
function createFixSchemaCommand(seams = {}) {
|
|
2324
|
+
const command = {
|
|
2325
|
+
name: "effort fix-schema",
|
|
2326
|
+
subject: "effort",
|
|
2327
|
+
action: "fix-schema",
|
|
2328
|
+
summary: "rebuild an effort store this rafa refuses at the migrations it knows, keeping the original",
|
|
2329
|
+
description: "Repairs `.rafa/effort/effort.sqlite`, or the store under `RAFA_EFFORT_DIR`, when this rafa refuses" + " it for a reason a rebuild repairs: no migration log past the legacy entries (pre-log-unreleased), a log" + " with a schema version it does not match (gate-mismatch), a logged checksum this rafa holds otherwise" + " (edited), or a logged migration this rafa does not know that breaks older readers or writers" + " (unknown-breaks-readers, unknown-breaks-writers). Installing a rafa that knows the store's migrations" + " comes first where one exists. It builds a parallel store beside it at this rafa's migrations, with a" + " migration log naming this rafa, copies every table and column this rafa knows, checks the row counts," + " SQLite's integrity_check and the schema plan, and lists what only the newer schema holds, unknown" + " migrations included. Then it renames the original to `effort.sqlite.v<user_version>-<stamp>.bak`," + " whole, and moves the rebuild into its place. A store this rafa uses, current or behind, or no store, is" + " left alone. It refuses, changing nothing, while a loop session under the project is running or paused," + " from a development build, while a journal beside the store shows a write in flight, when the newer" + " schema lacks a table or column this rafa writes, and on a refusal a rebuild does not repair. With" + " `--output=json` the outcome is the data of the terminal result event. Starts no session.",
|
|
2330
|
+
args: [],
|
|
2331
|
+
flags: [
|
|
2332
|
+
{
|
|
2333
|
+
name: DRY_RUN_FLAG,
|
|
2334
|
+
description: "Build and check the rebuild, print what it keeps and leaves behind, then delete it. The" + " live store is only read, so this runs beside a live loop and from a development build, and can be" + " repeated.",
|
|
2335
|
+
type: "boolean"
|
|
2336
|
+
}
|
|
2337
|
+
],
|
|
2338
|
+
examples: [
|
|
2339
|
+
{
|
|
2340
|
+
cmd: "rafa effort fix-schema --dry-run",
|
|
2341
|
+
note: "Prints what a rebuild would keep and leave behind, changing nothing."
|
|
2342
|
+
},
|
|
2343
|
+
{
|
|
2344
|
+
cmd: "rafa effort fix-schema",
|
|
2345
|
+
note: "Swaps the rebuild in and prints where the original was kept."
|
|
2346
|
+
}
|
|
2347
|
+
],
|
|
2348
|
+
outputs: ["text", "json"],
|
|
2349
|
+
run: async (context) => {
|
|
2350
|
+
runFixSchema(context, seams);
|
|
2351
|
+
await Promise.resolve();
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
return Object.freeze(command);
|
|
2355
|
+
}
|
|
2356
|
+
var fix_schema_default = createFixSchemaCommand();
|
|
2357
|
+
|
|
2358
|
+
// src/effort/store/copy.ts
|
|
2359
|
+
import { copyFileSync, constants, existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync2, rmSync as rmSync5, statSync as statSync3 } from "fs";
|
|
2360
|
+
import { join as join10, resolve as resolve4 } from "path";
|
|
2361
|
+
import { Database as Database3 } from "bun:sqlite";
|
|
2362
|
+
var NDJSON_KINDS = { sessions: true, commits: true };
|
|
2363
|
+
var EFFORT_COPY_FILE_NAMES = [
|
|
2364
|
+
SQLITE_STORE_FILE_NAME,
|
|
2365
|
+
...Object.keys(NDJSON_KINDS).map((kind) => effortStoreFileName(kind))
|
|
2366
|
+
];
|
|
2367
|
+
|
|
2368
|
+
class EffortCopyRefusal extends Error {
|
|
2369
|
+
name = "EffortCopyRefusal";
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
class EffortCopyFailure extends Error {
|
|
2373
|
+
name = "EffortCopyFailure";
|
|
2374
|
+
}
|
|
2375
|
+
function refuseUsedTarget(target) {
|
|
2376
|
+
if (!existsSync9(target))
|
|
2377
|
+
return;
|
|
2378
|
+
let entries;
|
|
2379
|
+
try {
|
|
2380
|
+
if (!statSync3(target).isDirectory()) {
|
|
2381
|
+
throw new EffortCopyRefusal(`${target} is a file, not a directory; name an empty or absent directory with --to=<dir>.`);
|
|
2382
|
+
}
|
|
2383
|
+
entries = readdirSync2(target);
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
if (error instanceof EffortCopyRefusal)
|
|
2386
|
+
throw error;
|
|
2387
|
+
throw new EffortCopyFailure(`${target} cannot be read: ${String(error)}`);
|
|
2388
|
+
}
|
|
2389
|
+
if (entries.length === 0)
|
|
2390
|
+
return;
|
|
2391
|
+
throw new EffortCopyRefusal(`${target} is not empty (${String(entries.length)} entries); nothing was copied.` + " Name an empty or absent directory with --to=<dir>.");
|
|
2392
|
+
}
|
|
2393
|
+
function vacuumInto(source, target) {
|
|
2394
|
+
const db = new Database3(source, { readonly: true });
|
|
2395
|
+
try {
|
|
2396
|
+
db.run(`PRAGMA busy_timeout = ${String(activeStoreSettings().busyTimeoutMs)}`);
|
|
2397
|
+
db.run("VACUUM INTO ?", [target]);
|
|
2398
|
+
} finally {
|
|
2399
|
+
db.close();
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
function copyOne(name2, source, target) {
|
|
2403
|
+
if (name2 === SQLITE_STORE_FILE_NAME) {
|
|
2404
|
+
vacuumInto(join10(source, name2), join10(target, name2));
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
copyFileSync(join10(source, name2), join10(target, name2), constants.COPYFILE_EXCL);
|
|
2408
|
+
}
|
|
2409
|
+
function undo(target, written, madeDir) {
|
|
2410
|
+
try {
|
|
2411
|
+
if (madeDir === undefined) {
|
|
2412
|
+
for (const name2 of written)
|
|
2413
|
+
rmSync5(join10(target, name2), { force: true });
|
|
2414
|
+
} else {
|
|
2415
|
+
rmSync5(madeDir, { recursive: true, force: true });
|
|
2416
|
+
}
|
|
2417
|
+
return "";
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
return ` Removing what it made failed too: ${String(error)}`;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
function copyEffortStore(request) {
|
|
2423
|
+
const source = resolve4(request.source);
|
|
2424
|
+
const target = resolve4(request.target);
|
|
2425
|
+
guardTestProcess(source);
|
|
2426
|
+
guardTestProcess(target);
|
|
2427
|
+
const files = EFFORT_COPY_FILE_NAMES.filter((name2) => existsSync9(join10(source, name2)));
|
|
2428
|
+
if (files.length === 0) {
|
|
2429
|
+
throw new EffortCopyRefusal(`no effort store at ${source}: it holds none of ${EFFORT_COPY_FILE_NAMES.join(", ")}. Nothing to copy.`);
|
|
2430
|
+
}
|
|
2431
|
+
refuseUsedTarget(target);
|
|
2432
|
+
const written = [];
|
|
2433
|
+
let madeDir;
|
|
2434
|
+
try {
|
|
2435
|
+
madeDir = mkdirSync6(target, { recursive: true });
|
|
2436
|
+
for (const name2 of files) {
|
|
2437
|
+
written.push(name2);
|
|
2438
|
+
copyOne(name2, source, target);
|
|
2439
|
+
}
|
|
2440
|
+
} catch (error) {
|
|
2441
|
+
const left = undo(target, written, madeDir);
|
|
2442
|
+
throw new EffortCopyFailure(`copying ${source} to ${target} failed: ${String(error)}.${left}`);
|
|
2443
|
+
}
|
|
2444
|
+
return Object.freeze({ source, directory: target, files: Object.freeze(files) });
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// src/effort/store/merge-store.ts
|
|
2448
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2449
|
+
import { existsSync as existsSync11, mkdtempSync, rmSync as rmSync6 } from "fs";
|
|
2450
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
2451
|
+
import { dirname as dirname10, join as join11 } from "path";
|
|
2452
|
+
import { Database as Database5, SQLiteError as SQLiteError2 } from "bun:sqlite";
|
|
2453
|
+
|
|
2454
|
+
// src/effort/commits.ts
|
|
2455
|
+
import { execFileSync } from "child_process";
|
|
2456
|
+
var COMMIT_RECORD_MARKER = "@@ralph-commit@@";
|
|
2457
|
+
var HEADER_PLACEHOLDERS = ["%H", "%aI", "%P", "%an", "%S", "%s"];
|
|
2458
|
+
var FIELD_SEPARATOR = "\t";
|
|
2459
|
+
var COMMIT_LOG_FORMAT = [
|
|
2460
|
+
COMMIT_RECORD_MARKER,
|
|
2461
|
+
...HEADER_PLACEHOLDERS
|
|
2462
|
+
].join("%x09");
|
|
2463
|
+
var MS_PER_MINUTE = 60000;
|
|
2464
|
+
var ELAPSED_DECIMALS = 3;
|
|
2465
|
+
function normaliseBranch(source) {
|
|
2466
|
+
if (source.length === 0 || source === "HEAD")
|
|
2467
|
+
return null;
|
|
2468
|
+
return source.startsWith("refs/heads/") ? source.slice("refs/heads/".length) : source;
|
|
2469
|
+
}
|
|
2470
|
+
function parseStatCount(field2) {
|
|
2471
|
+
return /^\d+$/.test(field2) ? Number(field2) : null;
|
|
2472
|
+
}
|
|
2473
|
+
function parseCommitHeader(line) {
|
|
2474
|
+
const parts = line.split(FIELD_SEPARATOR);
|
|
2475
|
+
if (parts[0] !== COMMIT_RECORD_MARKER)
|
|
2476
|
+
return null;
|
|
2477
|
+
if (parts.length <= HEADER_PLACEHOLDERS.length)
|
|
2478
|
+
return null;
|
|
2479
|
+
const [sha, timestamp, parents, author, source] = parts.slice(1);
|
|
2480
|
+
if (sha === undefined || sha.length === 0)
|
|
2481
|
+
return null;
|
|
2482
|
+
return {
|
|
2483
|
+
sha,
|
|
2484
|
+
timestamp: timestamp ?? "",
|
|
2485
|
+
subject: parts.slice(HEADER_PLACEHOLDERS.length).join(FIELD_SEPARATOR),
|
|
2486
|
+
author: author ?? "",
|
|
2487
|
+
branch: normaliseBranch(source ?? ""),
|
|
2488
|
+
filesChanged: 0,
|
|
2489
|
+
insertions: 0,
|
|
2490
|
+
deletions: 0,
|
|
2491
|
+
parentCount: countParents(parents ?? ""),
|
|
2492
|
+
minutesSincePrevious: null
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
function countParents(parents) {
|
|
2496
|
+
return parents.trim().length === 0 ? 0 : parents.trim().split(/ +/).length;
|
|
2497
|
+
}
|
|
2498
|
+
function applyStatLine(row, line) {
|
|
2499
|
+
const parts = line.split(FIELD_SEPARATOR);
|
|
2500
|
+
if (parts.length < 3)
|
|
2501
|
+
return false;
|
|
2502
|
+
const added = parts[0] ?? "";
|
|
2503
|
+
const removed = parts[1] ?? "";
|
|
2504
|
+
const isBinary = added === "-" && removed === "-";
|
|
2505
|
+
const addedCount = parseStatCount(added);
|
|
2506
|
+
const removedCount = parseStatCount(removed);
|
|
2507
|
+
if (!isBinary && (addedCount === null || removedCount === null)) {
|
|
2508
|
+
return false;
|
|
2509
|
+
}
|
|
2510
|
+
row.filesChanged += 1;
|
|
2511
|
+
row.insertions += addedCount ?? 0;
|
|
2512
|
+
row.deletions += removedCount ?? 0;
|
|
2513
|
+
return true;
|
|
2514
|
+
}
|
|
2515
|
+
function minutesBetween(earlier, later) {
|
|
2516
|
+
const from = Date.parse(earlier);
|
|
2517
|
+
const to = Date.parse(later);
|
|
2518
|
+
if (Number.isNaN(from) || Number.isNaN(to))
|
|
2519
|
+
return null;
|
|
2520
|
+
const minutes = (to - from) / MS_PER_MINUTE;
|
|
2521
|
+
return Number(minutes.toFixed(ELAPSED_DECIMALS));
|
|
2522
|
+
}
|
|
2523
|
+
function assignElapsedMinutes(rows) {
|
|
2524
|
+
const ordered = rows.map((row, index) => ({ index, epoch: Date.parse(row.timestamp) })).filter((entry) => !Number.isNaN(entry.epoch)).sort((a, b) => a.epoch - b.epoch);
|
|
2525
|
+
for (let position = 1;position < ordered.length; position += 1) {
|
|
2526
|
+
const entry = ordered[position];
|
|
2527
|
+
const previous = ordered[position - 1];
|
|
2528
|
+
if (entry === undefined || previous === undefined)
|
|
2529
|
+
continue;
|
|
2530
|
+
const row = rows[entry.index];
|
|
2531
|
+
const earlier = rows[previous.index];
|
|
2532
|
+
if (row === undefined || earlier === undefined)
|
|
2533
|
+
continue;
|
|
2534
|
+
row.minutesSincePrevious = minutesBetween(earlier.timestamp, row.timestamp);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
function parseCommitLog(text) {
|
|
2538
|
+
const rows = [];
|
|
2539
|
+
let lineCount = 0;
|
|
2540
|
+
let unparsedLineCount = 0;
|
|
2541
|
+
let open = null;
|
|
2542
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2543
|
+
const line = rawLine.trimEnd();
|
|
2544
|
+
if (line.trim().length === 0)
|
|
2545
|
+
continue;
|
|
2546
|
+
lineCount += 1;
|
|
2547
|
+
const header = parseCommitHeader(line);
|
|
2548
|
+
if (header !== null) {
|
|
2549
|
+
rows.push(header);
|
|
2550
|
+
open = header;
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (open === null || !applyStatLine(open, line)) {
|
|
2554
|
+
unparsedLineCount += 1;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
assignElapsedMinutes(rows);
|
|
2558
|
+
return { rows, lineCount, unparsedLineCount };
|
|
2559
|
+
}
|
|
2560
|
+
function commitLogArgs(options = {}) {
|
|
2561
|
+
const args = [
|
|
2562
|
+
"log",
|
|
2563
|
+
"--source",
|
|
2564
|
+
`--format=${COMMIT_LOG_FORMAT}`,
|
|
2565
|
+
"--numstat"
|
|
2566
|
+
];
|
|
2567
|
+
if (options.since !== undefined)
|
|
2568
|
+
args.push(`--since=${options.since}`);
|
|
2569
|
+
if (options.maxCount !== undefined) {
|
|
2570
|
+
args.push(`--max-count=${options.maxCount}`);
|
|
2571
|
+
}
|
|
2572
|
+
if (options.revisionRange !== undefined)
|
|
2573
|
+
args.push(options.revisionRange);
|
|
2574
|
+
return args;
|
|
2575
|
+
}
|
|
2576
|
+
function readCommitLog(options = {}) {
|
|
2577
|
+
const stdout = execFileSync("git", commitLogArgs(options), {
|
|
2578
|
+
cwd: options.cwd ?? process.cwd(),
|
|
2579
|
+
encoding: "utf8",
|
|
2580
|
+
maxBuffer: 64 * 1024 * 1024
|
|
2581
|
+
});
|
|
2582
|
+
return parseCommitLog(stdout);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// src/effort/store/merge-commit-gaps.ts
|
|
2586
|
+
var GAP_FIELD = "minutesSincePrevious";
|
|
2587
|
+
var UPDATE_GAP = "UPDATE commits SET row_json = ? WHERE seq = ?";
|
|
2588
|
+
function parsedObject2(text) {
|
|
2589
|
+
try {
|
|
2590
|
+
const value = JSON.parse(text);
|
|
2591
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
2592
|
+
} catch {
|
|
2593
|
+
return null;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
function withoutGap(rowJson) {
|
|
2597
|
+
const row = parsedObject2(rowJson);
|
|
2598
|
+
if (row === null)
|
|
2599
|
+
return rowJson;
|
|
2600
|
+
const rest = Object.fromEntries(Object.entries(row).filter(([key]) => key !== GAP_FIELD));
|
|
2601
|
+
return JSON.stringify(rest);
|
|
2602
|
+
}
|
|
2603
|
+
function readCommits(db) {
|
|
2604
|
+
return db.query("SELECT seq, sha, row_json FROM commits").all().map(({ seq, sha, row_json: rowJson }) => {
|
|
2605
|
+
const row = parsedObject2(rowJson);
|
|
2606
|
+
const timestamp = typeof row?.timestamp === "string" ? row.timestamp : null;
|
|
2607
|
+
return { seq, sha, row, timestamp, epoch: timestamp === null ? Number.NaN : Date.parse(timestamp) };
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
function timeOrder(entries) {
|
|
2611
|
+
return entries.filter((entry) => !Number.isNaN(entry.epoch)).sort((left, right) => left.epoch - right.epoch || compareText(left.sha, right.sha));
|
|
2612
|
+
}
|
|
2613
|
+
function compareText(left, right) {
|
|
2614
|
+
if (left === right)
|
|
2615
|
+
return 0;
|
|
2616
|
+
return left < right ? -1 : 1;
|
|
2617
|
+
}
|
|
2618
|
+
function storedGap(entry) {
|
|
2619
|
+
const value = entry.row?.[GAP_FIELD];
|
|
2620
|
+
return typeof value === "number" ? value : null;
|
|
2621
|
+
}
|
|
2622
|
+
function namedRows(inserted, positions, order) {
|
|
2623
|
+
const position = positions.get(inserted.seq);
|
|
2624
|
+
if (position === undefined)
|
|
2625
|
+
return [inserted.seq];
|
|
2626
|
+
const next = order[position + 1];
|
|
2627
|
+
return next === undefined ? [inserted.seq] : [inserted.seq, next.seq];
|
|
2628
|
+
}
|
|
2629
|
+
function recomputedGap(entry, positions, order) {
|
|
2630
|
+
const position = positions.get(entry.seq);
|
|
2631
|
+
if (position === undefined || position === 0)
|
|
2632
|
+
return null;
|
|
2633
|
+
const previous = order[position - 1];
|
|
2634
|
+
if (previous?.timestamp == null || entry.timestamp === null)
|
|
2635
|
+
return null;
|
|
2636
|
+
return minutesBetween(previous.timestamp, entry.timestamp);
|
|
2637
|
+
}
|
|
2638
|
+
function recomputeCommitGaps(db, inserted) {
|
|
2639
|
+
return db.transaction(() => {
|
|
2640
|
+
const entries = readCommits(db);
|
|
2641
|
+
const bySeq = new Map(entries.map((entry) => [entry.seq, entry]));
|
|
2642
|
+
const order = timeOrder(entries);
|
|
2643
|
+
const positions = new Map(order.map((entry, position) => [entry.seq, position]));
|
|
2644
|
+
const named = new Set(inserted.flatMap((seq) => {
|
|
2645
|
+
const entry = bySeq.get(seq);
|
|
2646
|
+
return entry === undefined ? [] : namedRows(entry, positions, order);
|
|
2647
|
+
}));
|
|
2648
|
+
const targets = [...named].map((seq) => bySeq.get(seq)).filter((entry) => entry?.row != null).sort((left, right) => (positions.get(left.seq) ?? -1) - (positions.get(right.seq) ?? -1));
|
|
2649
|
+
return targets.flatMap((entry) => {
|
|
2650
|
+
const before = storedGap(entry);
|
|
2651
|
+
const after = recomputedGap(entry, positions, order);
|
|
2652
|
+
if (before === after && entry.row !== null && Object.hasOwn(entry.row, GAP_FIELD))
|
|
2653
|
+
return [];
|
|
2654
|
+
db.query(UPDATE_GAP).run(JSON.stringify({ ...entry.row, [GAP_FIELD]: after }), entry.seq);
|
|
2655
|
+
return [{ seq: entry.seq, sha: entry.sha, before, after }];
|
|
2656
|
+
});
|
|
2657
|
+
})();
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// src/effort/store/merge-rules.ts
|
|
2661
|
+
var SET_ONCE = "set-once";
|
|
2662
|
+
var RECOMPUTED = "recomputed";
|
|
2663
|
+
var APPEND_ONLY = {};
|
|
2664
|
+
function appendOnly(...identity) {
|
|
2665
|
+
return { scope: "merged", identity, edited: APPEND_ONLY };
|
|
2666
|
+
}
|
|
2667
|
+
var MERGE_RULES = {
|
|
2668
|
+
sessions: appendOnly("session_id"),
|
|
2669
|
+
commits: { scope: "merged", identity: ["sha"], edited: { row_json: RECOMPUTED } },
|
|
2670
|
+
findings: { scope: "merged", identity: ["id"], edited: { tracker_ref: SET_ONCE } },
|
|
2671
|
+
blockers: appendOnly("id"),
|
|
2672
|
+
out_of_scope_bugs: appendOnly("id"),
|
|
2673
|
+
report_absences: appendOnly("id"),
|
|
2674
|
+
task_reports: appendOnly("id"),
|
|
2675
|
+
preflight: appendOnly("run_id", "position"),
|
|
2676
|
+
dispatches: appendOnly("session_id"),
|
|
2677
|
+
changes: appendOnly("id"),
|
|
2678
|
+
skill_invocations: appendOnly("session_id", "name", "sidechain"),
|
|
2679
|
+
plan_ci: appendOnly("pr", "head_sha", "read_at"),
|
|
2680
|
+
schema_migrations: { scope: "local" },
|
|
2681
|
+
store_meta: { scope: "local" },
|
|
2682
|
+
merges: { scope: "local" },
|
|
2683
|
+
merge_conflicts: { scope: "local" }
|
|
2684
|
+
};
|
|
2685
|
+
|
|
2686
|
+
// src/effort/store/merge-union.ts
|
|
2687
|
+
import { SQLiteError } from "bun:sqlite";
|
|
2688
|
+
class UnionSchemaMismatch extends Error {
|
|
2689
|
+
constructor(message) {
|
|
2690
|
+
super(message);
|
|
2691
|
+
this.name = "UnionSchemaMismatch";
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
var UNIQUE_REFUSAL = "SQLITE_CONSTRAINT_UNIQUE";
|
|
2695
|
+
function mergedTables() {
|
|
2696
|
+
return Object.entries(MERGE_RULES).flatMap(([table, rule]) => rule.scope === "merged" ? [{ table, identity: rule.identity }] : []);
|
|
2697
|
+
}
|
|
2698
|
+
function carriedColumns(db, table) {
|
|
2699
|
+
return db.query(`PRAGMA table_info(${quoted(table)})`).all().map(({ name: name2 }) => name2).filter((name2) => name2 !== "seq");
|
|
2700
|
+
}
|
|
2701
|
+
function refuseColumnMismatch(local, other, tables) {
|
|
2702
|
+
const faults = tables.flatMap(({ table }) => {
|
|
2703
|
+
const here = new Set(carriedColumns(local, table));
|
|
2704
|
+
const there = new Set(carriedColumns(other, table));
|
|
2705
|
+
const onlyHere = [...here].filter((column) => !there.has(column)).map((column) => `${table}.${column} only here`);
|
|
2706
|
+
const onlyThere = [...there].filter((column) => !here.has(column)).map((column) => `${table}.${column} only there`);
|
|
2707
|
+
return [...onlyHere, ...onlyThere];
|
|
2708
|
+
});
|
|
2709
|
+
if (faults.length > 0) {
|
|
2710
|
+
throw new UnionSchemaMismatch(`the two stores hold different columns (${faults.join(", ")}); bring both to the same migrations first`);
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
function valueOf(row, column) {
|
|
2714
|
+
return row[column] ?? null;
|
|
2715
|
+
}
|
|
2716
|
+
function byOrigin(local, table, row) {
|
|
2717
|
+
const store = valueOf(row, "origin_store");
|
|
2718
|
+
const seq = valueOf(row, "origin_seq");
|
|
2719
|
+
if (store === null || seq === null)
|
|
2720
|
+
return null;
|
|
2721
|
+
return local.query(`SELECT seq FROM ${quoted(table)} WHERE origin_store = ? AND origin_seq = ?`).get(store, seq)?.seq ?? null;
|
|
2722
|
+
}
|
|
2723
|
+
function byIdentity(local, { table, identity }, row) {
|
|
2724
|
+
const where = identity.map((column) => `${quoted(column)} IS ?`).join(" AND ");
|
|
2725
|
+
return local.query(`SELECT seq FROM ${quoted(table)} WHERE ${where} ORDER BY seq LIMIT 1`).get(...identity.map((column) => valueOf(row, column)))?.seq ?? null;
|
|
2726
|
+
}
|
|
2727
|
+
function insertRow(local, table, columns, row, incomingSeq) {
|
|
2728
|
+
const columnList = columns.map(quoted).join(", ");
|
|
2729
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
2730
|
+
const nextSeq = `(SELECT COALESCE(MAX(seq), 0) + 1 FROM ${quoted(table)})`;
|
|
2731
|
+
try {
|
|
2732
|
+
const inserted = local.query(`INSERT INTO ${quoted(table)} (seq, ${columnList}) VALUES (${nextSeq}, ${placeholders}) RETURNING seq`).get(...columns.map((column) => valueOf(row, column)));
|
|
2733
|
+
if (inserted === null)
|
|
2734
|
+
throw new Error(`the insert into ${table} returned no seq`);
|
|
2735
|
+
return { kind: "added", localSeq: inserted.seq };
|
|
2736
|
+
} catch (error) {
|
|
2737
|
+
if (!(error instanceof SQLiteError) || error.code !== UNIQUE_REFUSAL)
|
|
2738
|
+
throw error;
|
|
2739
|
+
return { kind: "collided", collision: { incomingSeq, reason: error.message } };
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
function placeRow(local, merged, columns, row) {
|
|
2743
|
+
const incomingSeq = Number(valueOf(row, "seq"));
|
|
2744
|
+
const origin = byOrigin(local, merged.table, row);
|
|
2745
|
+
if (origin !== null)
|
|
2746
|
+
return { kind: "matched", match: { localSeq: origin, incomingSeq, by: "origin" } };
|
|
2747
|
+
const identity = byIdentity(local, merged, row);
|
|
2748
|
+
if (identity !== null)
|
|
2749
|
+
return { kind: "matched", match: { localSeq: identity, incomingSeq, by: "identity" } };
|
|
2750
|
+
return insertRow(local, merged.table, columns, row, incomingSeq);
|
|
2751
|
+
}
|
|
2752
|
+
function unionTable(local, other, merged) {
|
|
2753
|
+
const columns = carriedColumns(local, merged.table);
|
|
2754
|
+
const incoming = other.query(`SELECT seq, ${columns.map(quoted).join(", ")} FROM ${quoted(merged.table)} ORDER BY seq`).all();
|
|
2755
|
+
const outcomes = incoming.map((row) => placeRow(local, merged, columns, row));
|
|
2756
|
+
return {
|
|
2757
|
+
table: merged.table,
|
|
2758
|
+
added: outcomes.flatMap((outcome) => outcome.kind === "added" ? [outcome.localSeq] : []),
|
|
2759
|
+
matched: outcomes.flatMap((outcome) => outcome.kind === "matched" ? [outcome.match] : []),
|
|
2760
|
+
collided: outcomes.flatMap((outcome) => outcome.kind === "collided" ? [outcome.collision] : [])
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
function unionStores(local, other) {
|
|
2764
|
+
const tables = mergedTables();
|
|
2765
|
+
refuseColumnMismatch(local, other, tables);
|
|
2766
|
+
return local.transaction(() => tables.map((merged) => unionTable(local, other, merged)))();
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// src/effort/store/merge-conflicts.ts
|
|
2770
|
+
var SET_ONCE_FILLS = {
|
|
2771
|
+
"findings.tracker_ref": "UPDATE findings SET tracker_ref = ? WHERE seq = ? AND tracker_ref IS NULL"
|
|
2772
|
+
};
|
|
2773
|
+
var RECOMPUTED_COMPARISONS = {
|
|
2774
|
+
"commits.row_json": withoutGap
|
|
2775
|
+
};
|
|
2776
|
+
var NOT_CONTENT = new Set(["seq", "origin_store", "origin_seq"]);
|
|
2777
|
+
var INSERT_CONFLICT = `INSERT INTO merge_conflicts (merge_id, table_name, local_seq, field, incoming, recorded_at)
|
|
2778
|
+
VALUES (?, ?, ?, ?, ?, ?)`;
|
|
2779
|
+
function rowAt(db, table, columns, seq, side) {
|
|
2780
|
+
const row = db.query(`SELECT seq, ${columns.map(quoted).join(", ")} FROM ${quoted(table)} WHERE seq = ?`).get(seq);
|
|
2781
|
+
if (row === null)
|
|
2782
|
+
throw new Error(`the union matched ${table} row ${seq} ${side}, which that store does not hold`);
|
|
2783
|
+
return row;
|
|
2784
|
+
}
|
|
2785
|
+
function sameValue(left, right) {
|
|
2786
|
+
if (left instanceof Uint8Array && right instanceof Uint8Array)
|
|
2787
|
+
return Buffer.from(left).equals(right);
|
|
2788
|
+
return (left ?? null) === (right ?? null);
|
|
2789
|
+
}
|
|
2790
|
+
function fillStatement(table, field2) {
|
|
2791
|
+
const statement = SET_ONCE_FILLS[`${table}.${field2}`];
|
|
2792
|
+
if (statement === undefined) {
|
|
2793
|
+
throw new Error(`${table}.${field2} is set-once in MERGE_RULES and has no statement in SET_ONCE_FILLS`);
|
|
2794
|
+
}
|
|
2795
|
+
return statement;
|
|
2796
|
+
}
|
|
2797
|
+
function settleSetOnce(local, table, field2, here, there) {
|
|
2798
|
+
const incoming = there[field2] ?? null;
|
|
2799
|
+
if (incoming === null)
|
|
2800
|
+
return { skipped: true, filled: [], conflictFields: [] };
|
|
2801
|
+
if ((here[field2] ?? null) !== null)
|
|
2802
|
+
return { skipped: false, filled: [], conflictFields: [field2] };
|
|
2803
|
+
local.query(fillStatement(table, field2)).run(incoming, Number(here.seq));
|
|
2804
|
+
return { skipped: true, filled: [field2], conflictFields: [] };
|
|
2805
|
+
}
|
|
2806
|
+
function settleRecomputed(table, field2, here, there) {
|
|
2807
|
+
const comparable = RECOMPUTED_COMPARISONS[`${table}.${field2}`];
|
|
2808
|
+
if (comparable === undefined) {
|
|
2809
|
+
throw new Error(`${table}.${field2} is recomputed in MERGE_RULES and has no entry in RECOMPUTED_COMPARISONS`);
|
|
2810
|
+
}
|
|
2811
|
+
const hereValue = here[field2];
|
|
2812
|
+
const thereValue = there[field2];
|
|
2813
|
+
const same = typeof hereValue === "string" && typeof thereValue === "string" && comparable(hereValue) === comparable(thereValue);
|
|
2814
|
+
return same ? { skipped: true, filled: [], conflictFields: [] } : { skipped: false, filled: [], conflictFields: [null] };
|
|
2815
|
+
}
|
|
2816
|
+
function settlePair(local, table, edited, here, there) {
|
|
2817
|
+
const differing = Object.keys(here).filter((column) => !NOT_CONTENT.has(column) && !sameValue(here[column], there[column]));
|
|
2818
|
+
if (differing.length === 0)
|
|
2819
|
+
return { skipped: true, filled: [], conflictFields: [] };
|
|
2820
|
+
if (differing.some((column) => !Object.hasOwn(edited, column)))
|
|
2821
|
+
return { skipped: false, filled: [], conflictFields: [null] };
|
|
2822
|
+
const outcomes = differing.map((field2) => {
|
|
2823
|
+
if (edited[field2] === SET_ONCE)
|
|
2824
|
+
return settleSetOnce(local, table, field2, here, there);
|
|
2825
|
+
if (edited[field2] === RECOMPUTED)
|
|
2826
|
+
return settleRecomputed(table, field2, here, there);
|
|
2827
|
+
throw new Error(`${table}.${field2} has the rule ${edited[field2]}, which the merge does not apply`);
|
|
2828
|
+
});
|
|
2829
|
+
return {
|
|
2830
|
+
skipped: outcomes.every((outcome) => outcome.skipped),
|
|
2831
|
+
filled: outcomes.flatMap((outcome) => outcome.filled),
|
|
2832
|
+
conflictFields: outcomes.flatMap((outcome) => outcome.conflictFields)
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
function settleTable(local, other, union, trail) {
|
|
2836
|
+
const rule = MERGE_RULES[union.table];
|
|
2837
|
+
if (rule?.scope !== "merged")
|
|
2838
|
+
throw new Error(`${union.table} is not a merged table in MERGE_RULES`);
|
|
2839
|
+
const columns = carriedColumns(local, union.table);
|
|
2840
|
+
const settled = union.matched.map((match) => {
|
|
2841
|
+
const here = rowAt(local, union.table, columns, match.localSeq, "here");
|
|
2842
|
+
const there = rowAt(other, union.table, columns, match.incomingSeq, "in the other store");
|
|
2843
|
+
const outcome = settlePair(local, union.table, rule.edited, here, there);
|
|
2844
|
+
const conflicts = outcome.conflictFields.map((field2) => {
|
|
2845
|
+
local.query(INSERT_CONFLICT).run(trail.mergeId, union.table, match.localSeq, field2, JSON.stringify(there), trail.recordedAt);
|
|
2846
|
+
return { localSeq: match.localSeq, incomingSeq: match.incomingSeq, field: field2 };
|
|
2847
|
+
});
|
|
2848
|
+
return { match, outcome, conflicts };
|
|
2849
|
+
});
|
|
2850
|
+
return {
|
|
2851
|
+
table: union.table,
|
|
2852
|
+
skipped: settled.flatMap(({ match, outcome }) => outcome.skipped ? [match.localSeq] : []),
|
|
2853
|
+
filled: settled.flatMap(({ match, outcome }) => outcome.filled.map((field2) => ({ localSeq: match.localSeq, field: field2 }))),
|
|
2854
|
+
conflicts: settled.flatMap(({ conflicts }) => conflicts)
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
function settleMatches(local, other, unions, trail) {
|
|
2858
|
+
return local.transaction(() => unions.map((union) => settleTable(local, other, union, trail)))();
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// src/effort/store/migrate.ts
|
|
2862
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2863
|
+
import { Database as Database4 } from "bun:sqlite";
|
|
2864
|
+
var LIVE2 = "live";
|
|
2865
|
+
function asidePaths2(path, first, stamp) {
|
|
2866
|
+
return { parallelPath: `${path}.migrate-${stamp}`, backupPath: `${path}.before-${first}-${stamp}.bak` };
|
|
2867
|
+
}
|
|
2868
|
+
function liveLoop(path, options, nothingDone = "Nothing was migrated.") {
|
|
2869
|
+
const root = storeProjectRoot(path);
|
|
2870
|
+
if (root === null)
|
|
2871
|
+
return;
|
|
2872
|
+
let records;
|
|
2873
|
+
try {
|
|
2874
|
+
records = readSessions(root, { isAlive: options.isAlive ?? isPidAlive });
|
|
2875
|
+
} catch (error) {
|
|
2876
|
+
throw new RebuildRefusal(`the loop's run records cannot be read, so a live loop on ${path} cannot be ruled out` + ` (${messageOf(error)}). ${nothingDone}`);
|
|
2877
|
+
}
|
|
2878
|
+
return records.find((record) => record.state === "running" || record.state === "paused");
|
|
2879
|
+
}
|
|
2880
|
+
function refuseLiveLoop2(path, breaking, options) {
|
|
2881
|
+
const loop = liveLoop(path, options);
|
|
2882
|
+
if (loop === undefined)
|
|
2883
|
+
return;
|
|
2884
|
+
const named = `loop ${loop.sessionId} (pid ${String(loop.pid)}, plan ${loop.planStub ?? loop.plan}) is running on this store`;
|
|
2885
|
+
const [first] = breaking;
|
|
2886
|
+
const opening = first === undefined ? `REFUSED \u2014 ${named}, and swapping the migrated store in under it would lose what it writes.` : `REFUSED \u2014 migration ${first} breaks older runtimes, and ${named}.`;
|
|
2887
|
+
throw new RebuildRefusal(`${opening} Nothing was migrated. Finish or stop that loop, then run it again.`);
|
|
2888
|
+
}
|
|
2889
|
+
function buildMigrated(path, parallelPath, migrations, stamp) {
|
|
2890
|
+
vacuumInto(path, parallelPath);
|
|
2891
|
+
const db = new Database4(parallelPath, { readwrite: true });
|
|
2892
|
+
try {
|
|
2893
|
+
const brought = bringForward(db, parallelPath, "write", "migrate", { migrations, ...stamp, builtAside: true });
|
|
2894
|
+
db.run(`ATTACH DATABASE ? AS ${LIVE2}`, [path]);
|
|
2895
|
+
const live = new Set(tableNames(db, LIVE2));
|
|
2896
|
+
const counted = checkedCounts(db, tableNames(db, "main").filter((table) => live.has(table)), LIVE2);
|
|
2897
|
+
db.run(`DETACH DATABASE ${LIVE2}`);
|
|
2898
|
+
refuseCorrupt(db);
|
|
2899
|
+
refuseUnusable(db, parallelPath, migrations);
|
|
2900
|
+
return { userVersion: brought.userVersion, counted };
|
|
2901
|
+
} finally {
|
|
2902
|
+
db.close();
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
function migrateStore(options) {
|
|
2906
|
+
const { path, dryRun, stamp } = options;
|
|
2907
|
+
guardTestProcess(path);
|
|
2908
|
+
const migrations = options.migrations ?? SQLITE_MIGRATIONS;
|
|
2909
|
+
const appliedBy = appliedByName(options.identity ?? readRuntimeIdentity());
|
|
2910
|
+
const nothing = { applying: [], breaking: [], unknown: [], userVersion: null, counted: [], backupPath: null };
|
|
2911
|
+
if (!existsSync10(path)) {
|
|
2912
|
+
return { path, status: "missing", storeVersion: null, logged: false, appliedBy, ...nothing };
|
|
2913
|
+
}
|
|
2914
|
+
refuseInFlight(path);
|
|
2915
|
+
const store = readOnlySchema(path);
|
|
2916
|
+
const found = { path, storeVersion: store.userVersion, logged: store.log !== null, appliedBy };
|
|
2917
|
+
const plan = planSchema(store, sqliteCatalogue(migrations), "write", "migrate");
|
|
2918
|
+
if (plan.verdict === "refuse")
|
|
2919
|
+
throw new RebuildRefusal(plan.message);
|
|
2920
|
+
const applying = writeNames(plan);
|
|
2921
|
+
const planned = {
|
|
2922
|
+
applying,
|
|
2923
|
+
breaking: plan.pending.filter(({ breaks }) => breaks.length > 0).map(({ id }) => id),
|
|
2924
|
+
unknown: plan.unknown
|
|
2925
|
+
};
|
|
2926
|
+
if (applying.length === 0)
|
|
2927
|
+
return { ...found, ...nothing, ...planned, status: "current" };
|
|
2928
|
+
if (!dryRun) {
|
|
2929
|
+
refuseUnownedDevelopmentWrite(path, applying, options);
|
|
2930
|
+
refuseLiveLoop2(path, planned.breaking, options);
|
|
2931
|
+
}
|
|
2932
|
+
const first = plan.pending[0]?.id ?? ADOPTION_NAME;
|
|
2933
|
+
const { built, backupPath } = rebuildAside({
|
|
2934
|
+
path,
|
|
2935
|
+
...asidePaths2(path, first, stamp),
|
|
2936
|
+
dryRun,
|
|
2937
|
+
build: (parallelPath) => buildMigrated(path, parallelPath, migrations, {
|
|
2938
|
+
appliedBy,
|
|
2939
|
+
now: options.now ?? (() => new Date)
|
|
2940
|
+
})
|
|
2941
|
+
});
|
|
2942
|
+
const status = dryRun ? "would-migrate" : "migrated";
|
|
2943
|
+
return { ...found, ...planned, ...built, status, backupPath };
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// src/effort/store/merge-store.ts
|
|
2947
|
+
var MOVE_TO_SQLITE = "rafa effort move --to=sqlite";
|
|
2948
|
+
|
|
2949
|
+
class MergeRefusal extends RebuildRefusal {
|
|
2950
|
+
reason;
|
|
2951
|
+
constructor(reason, message) {
|
|
2952
|
+
super(message);
|
|
2953
|
+
this.name = "MergeRefusal";
|
|
2954
|
+
this.reason = reason;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
var LIVE3 = "live";
|
|
2958
|
+
var MERGES_TABLE = "merges";
|
|
2959
|
+
var INSERT_MERGE = `INSERT INTO merges (id, other_store, merged_at, rows_added, rows_skipped, rows_in_conflict)
|
|
2960
|
+
VALUES (?, ?, ?, ?, ?, ?)`;
|
|
2961
|
+
var NOTHING_MERGED = "Nothing was merged.";
|
|
2962
|
+
function refuseNdjson(backend) {
|
|
2963
|
+
if (backend !== "ndjson")
|
|
2964
|
+
return;
|
|
2965
|
+
throw new MergeRefusal("ndjson", `REFUSED \u2014 this project keeps its sessions and commits as NDJSON (store: ndjson), and a merge needs the SQLite store. ${NOTHING_MERGED}` + ` Next safe step: ${MOVE_TO_SQLITE}`);
|
|
2966
|
+
}
|
|
2967
|
+
function refuseMissing(path, which) {
|
|
2968
|
+
if (existsSync11(path))
|
|
2969
|
+
return;
|
|
2970
|
+
throw new MergeRefusal("missing", `REFUSED \u2014 ${which} ${path} is not there. ${NOTHING_MERGED}`);
|
|
2971
|
+
}
|
|
2972
|
+
function refuseDamaged(path) {
|
|
2973
|
+
let answers;
|
|
2974
|
+
try {
|
|
2975
|
+
const db = new Database5(path, { readonly: true });
|
|
2976
|
+
try {
|
|
2977
|
+
answers = db.query("PRAGMA integrity_check").all().map((row) => row.integrity_check);
|
|
2978
|
+
} finally {
|
|
2979
|
+
db.close();
|
|
2980
|
+
}
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
if (!(error instanceof SQLiteError2))
|
|
2983
|
+
throw error;
|
|
2984
|
+
throw new MergeRefusal("damaged", `REFUSED \u2014 ${path} cannot be read as a SQLite store (${error.message}). ${NOTHING_MERGED}` + " Take a consistent copy on its device with rafa effort copy, and merge that.");
|
|
2985
|
+
}
|
|
2986
|
+
if (answers.length === 1 && answers[0] === "ok")
|
|
2987
|
+
return;
|
|
2988
|
+
throw new MergeRefusal("damaged", `REFUSED \u2014 ${path} failed integrity_check: ${answers.join("; ")}. ${NOTHING_MERGED}` + " Take a consistent copy on its device with rafa effort copy, and merge that.");
|
|
2989
|
+
}
|
|
2990
|
+
function writablePlan(path, migrations) {
|
|
2991
|
+
const plan = planSchema(readOnlySchema(path), sqliteCatalogue(migrations), "write", "open");
|
|
2992
|
+
if (plan.verdict === "refuse")
|
|
2993
|
+
throw new MergeRefusal("schema", `REFUSED \u2014 ${path}: ${plan.message}`);
|
|
2994
|
+
return plan;
|
|
2995
|
+
}
|
|
2996
|
+
function recordedMeta(path) {
|
|
2997
|
+
const db = new Database5(path, { readonly: true });
|
|
2998
|
+
try {
|
|
2999
|
+
const hasTable = db.query("SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'store_meta'").get()?.n === 1;
|
|
3000
|
+
return hasTable ? readStoreMeta(db) : null;
|
|
3001
|
+
} finally {
|
|
3002
|
+
db.close();
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
function refuseOtherProject(options) {
|
|
3006
|
+
const other = recordedMeta(options.otherPath);
|
|
3007
|
+
const here = recordedMeta(options.path)?.projectRootCommit ?? (options.readProject ?? readProjectIdentity)(dirname10(options.path)).rootCommit;
|
|
3008
|
+
const there = other?.projectRootCommit ?? null;
|
|
3009
|
+
if (here !== null && there !== null && here !== there) {
|
|
3010
|
+
throw new MergeRefusal("other-project", `REFUSED \u2014 ${options.otherPath} is the effort store of another project (root commit ${there});` + ` this store's project has root commit ${here}. ${NOTHING_MERGED}`);
|
|
3011
|
+
}
|
|
3012
|
+
return other?.storeId ?? null;
|
|
3013
|
+
}
|
|
3014
|
+
function refuseLiveLoop3(path, options) {
|
|
3015
|
+
const loop = liveLoop(path, options, NOTHING_MERGED);
|
|
3016
|
+
if (loop === undefined)
|
|
3017
|
+
return;
|
|
3018
|
+
throw new MergeRefusal("live-loop", `REFUSED \u2014 loop ${loop.sessionId} (pid ${String(loop.pid)}, plan ${loop.planStub ?? loop.plan}) is running on this store,` + ` and swapping the merged store in under it would lose what it writes. ${NOTHING_MERGED}` + " Finish or stop that loop, then run it again.");
|
|
3019
|
+
}
|
|
3020
|
+
function openOther(otherPath, plan, stamp) {
|
|
3021
|
+
const needs = writeNames(plan);
|
|
3022
|
+
if (needs.length === 0) {
|
|
3023
|
+
const db = new Database5(otherPath, { readonly: true });
|
|
3024
|
+
return { db, broughtForward: [], release: () => db.close() };
|
|
3025
|
+
}
|
|
3026
|
+
const dir = mkdtempSync(join11(tmpdir3(), "rafa-merge-other-"));
|
|
3027
|
+
const release = (db) => {
|
|
3028
|
+
db?.close();
|
|
3029
|
+
rmSync6(dir, { recursive: true, force: true });
|
|
3030
|
+
};
|
|
3031
|
+
try {
|
|
3032
|
+
const copyPath = join11(dir, "other.sqlite");
|
|
3033
|
+
vacuumInto(otherPath, copyPath);
|
|
3034
|
+
const db = new Database5(copyPath, { readwrite: true });
|
|
3035
|
+
try {
|
|
3036
|
+
bringForward(db, copyPath, "write", "open", { ...stamp, builtAside: true });
|
|
3037
|
+
} catch (error) {
|
|
3038
|
+
release(db);
|
|
3039
|
+
throw error;
|
|
3040
|
+
}
|
|
3041
|
+
return { db, broughtForward: needs, release: () => release(db) };
|
|
3042
|
+
} catch (error) {
|
|
3043
|
+
release();
|
|
3044
|
+
throw error;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
function mergeInto(db, other, mergeId, otherStore, mergedAt) {
|
|
3048
|
+
return db.transaction(() => {
|
|
3049
|
+
const unions = unionStores(db, other);
|
|
3050
|
+
const settlements = settleMatches(db, other, unions, { mergeId, recordedAt: mergedAt });
|
|
3051
|
+
const gaps = recomputeCommitGaps(db, unions.find(({ table }) => table === "commits")?.added ?? []);
|
|
3052
|
+
const tables = unions.map((union, index) => {
|
|
3053
|
+
const settled = settlements[index];
|
|
3054
|
+
return {
|
|
3055
|
+
table: union.table,
|
|
3056
|
+
added: union.added.length,
|
|
3057
|
+
skipped: settled?.skipped.length ?? 0,
|
|
3058
|
+
inConflict: settled?.conflicts.length ?? 0,
|
|
3059
|
+
filled: settled?.filled.length ?? 0,
|
|
3060
|
+
collided: union.collided.length
|
|
3061
|
+
};
|
|
3062
|
+
});
|
|
3063
|
+
const sum = (field2) => tables.reduce((total, entry) => total + entry[field2], 0);
|
|
3064
|
+
const totals = { rowsAdded: sum("added"), rowsSkipped: sum("skipped"), rowsInConflict: sum("inConflict") };
|
|
3065
|
+
db.query(INSERT_MERGE).run(mergeId, otherStore, mergedAt, totals.rowsAdded, totals.rowsSkipped, totals.rowsInConflict);
|
|
3066
|
+
return { tables, ...totals, gapsRewritten: gaps.length };
|
|
3067
|
+
})();
|
|
3068
|
+
}
|
|
3069
|
+
function addedRows(merged) {
|
|
3070
|
+
return {
|
|
3071
|
+
...Object.fromEntries(merged.tables.map(({ table, added }) => [table, added])),
|
|
3072
|
+
[MERGES_TABLE]: 1,
|
|
3073
|
+
merge_conflicts: merged.rowsInConflict
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
function buildMerged(path, parallelPath, other, merge2) {
|
|
3077
|
+
vacuumInto(path, parallelPath);
|
|
3078
|
+
const db = new Database5(parallelPath, { readwrite: true });
|
|
3079
|
+
try {
|
|
3080
|
+
bringForward(db, parallelPath, "write", "open", { ...merge2.stamp, builtAside: true });
|
|
3081
|
+
const merged = mergeInto(db, other, merge2.id, merge2.otherStore, merge2.stamp.now().toISOString());
|
|
3082
|
+
db.run(`ATTACH DATABASE ? AS ${LIVE3}`, [path]);
|
|
3083
|
+
const live = new Set(tableNames(db, LIVE3));
|
|
3084
|
+
checkedCounts(db, tableNames(db, "main").filter((table) => live.has(table)), LIVE3, addedRows(merged));
|
|
3085
|
+
db.run(`DETACH DATABASE ${LIVE3}`);
|
|
3086
|
+
refuseCorrupt(db);
|
|
3087
|
+
refuseUnusable(db, parallelPath, merge2.stamp.migrations);
|
|
3088
|
+
return merged;
|
|
3089
|
+
} finally {
|
|
3090
|
+
db.close();
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
function refuseUnmergeable(options, migrations) {
|
|
3094
|
+
const { path, otherPath } = options;
|
|
3095
|
+
refuseNdjson(options.backend);
|
|
3096
|
+
refuseMissing(path, "this store");
|
|
3097
|
+
refuseMissing(otherPath, "the other store");
|
|
3098
|
+
refuseInFlight(path);
|
|
3099
|
+
refuseInFlight(otherPath);
|
|
3100
|
+
refuseDamaged(otherPath);
|
|
3101
|
+
const otherPlan = writablePlan(otherPath, migrations);
|
|
3102
|
+
const localPlan = writablePlan(path, migrations);
|
|
3103
|
+
const otherStore = refuseOtherProject(options);
|
|
3104
|
+
if (!options.dryRun) {
|
|
3105
|
+
refuseUnownedDevelopmentWrite(path, [...writeNames(localPlan), MERGES_TABLE], options);
|
|
3106
|
+
refuseLiveLoop3(path, options);
|
|
3107
|
+
}
|
|
3108
|
+
return { otherPlan, otherStore };
|
|
3109
|
+
}
|
|
3110
|
+
function mergeStore(options) {
|
|
3111
|
+
const { path, otherPath, dryRun, stamp } = options;
|
|
3112
|
+
guardTestProcess(path);
|
|
3113
|
+
guardTestProcess(otherPath);
|
|
3114
|
+
const migrations = options.migrations ?? SQLITE_MIGRATIONS;
|
|
3115
|
+
const { otherPlan, otherStore } = refuseUnmergeable(options, migrations);
|
|
3116
|
+
const mergeStamp = {
|
|
3117
|
+
migrations,
|
|
3118
|
+
appliedBy: appliedByName(options.identity ?? readRuntimeIdentity()),
|
|
3119
|
+
now: options.now ?? (() => new Date)
|
|
3120
|
+
};
|
|
3121
|
+
const mergeId = (options.newMergeId ?? randomUUID4)();
|
|
3122
|
+
const other = openOther(otherPath, otherPlan, mergeStamp);
|
|
3123
|
+
try {
|
|
3124
|
+
const { built, backupPath } = rebuildAside({
|
|
3125
|
+
path,
|
|
3126
|
+
parallelPath: `${path}.merge-${stamp}`,
|
|
3127
|
+
backupPath: `${path}.before-merge-${stamp}.bak`,
|
|
3128
|
+
dryRun,
|
|
3129
|
+
build: (parallelPath) => buildMerged(path, parallelPath, other.db, { id: mergeId, otherStore, stamp: mergeStamp })
|
|
3130
|
+
});
|
|
3131
|
+
const status = dryRun ? "would-merge" : "merged";
|
|
3132
|
+
return { path, otherPath, status, mergeId, otherStore, otherBroughtForward: other.broughtForward, ...built, backupPath };
|
|
3133
|
+
} finally {
|
|
3134
|
+
other.release();
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// src/effort/sync/file.ts
|
|
3139
|
+
var REFUSAL = "effort sync (file)";
|
|
3140
|
+
var NOTHING_COPIED = "Nothing was copied.";
|
|
3141
|
+
|
|
3142
|
+
class FileSyncRefusal extends Error {
|
|
3143
|
+
name = "FileSyncRefusal";
|
|
3144
|
+
}
|
|
3145
|
+
function requirePath(field2, value) {
|
|
3146
|
+
if (typeof value === "string" && value !== "")
|
|
3147
|
+
return value;
|
|
3148
|
+
throw new TypeError(`${REFUSAL}: ${field2} is ${describeValue(value)}, expected a path`);
|
|
3149
|
+
}
|
|
3150
|
+
function refuseUncarriable(backend, source) {
|
|
3151
|
+
if (backend === "ndjson") {
|
|
3152
|
+
throw new FileSyncRefusal(`${REFUSAL}: this project keeps its sessions and commits as NDJSON (store: ndjson), and the file carried` + ` is effort.sqlite, which would hold none of them. ${NOTHING_COPIED} Next safe step: ${MOVE_TO_SQLITE}`);
|
|
3153
|
+
}
|
|
3154
|
+
if (!existsSync12(join12(source, SQLITE_STORE_FILE_NAME))) {
|
|
3155
|
+
throw new FileSyncRefusal(`${REFUSAL}: no ${SQLITE_STORE_FILE_NAME} in ${source}. ${NOTHING_COPIED}`);
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
function mergeSeams(options) {
|
|
3159
|
+
const { identity, env, tempDir, isAlive, migrations, newMergeId, readProject } = options;
|
|
3160
|
+
const seams = { identity, env, tempDir, isAlive, migrations, newMergeId, readProject };
|
|
3161
|
+
return Object.fromEntries(Object.entries(seams).filter(([, value]) => value !== undefined));
|
|
3162
|
+
}
|
|
3163
|
+
function createFileSync(options) {
|
|
3164
|
+
const { repoRoot, backend } = options;
|
|
3165
|
+
const now = options.now ?? (() => new Date);
|
|
3166
|
+
const storeDir = () => effortStoreDir(repoRoot, options.env);
|
|
3167
|
+
const push = async ({ to }) => {
|
|
3168
|
+
const target = resolve5(repoRoot, requirePath("to", to));
|
|
3169
|
+
const source = storeDir();
|
|
3170
|
+
refuseUncarriable(backend, source);
|
|
3171
|
+
const copied = copyEffortStore({ source, target });
|
|
3172
|
+
return Object.freeze({ status: "pushed", path: join12(copied.directory, SQLITE_STORE_FILE_NAME) });
|
|
3173
|
+
};
|
|
3174
|
+
const pull = async ({ from, dryRun }) => {
|
|
3175
|
+
const otherPath = resolve5(repoRoot, requirePath("from", from));
|
|
3176
|
+
const path = join12(storeDir(), SQLITE_STORE_FILE_NAME);
|
|
3177
|
+
const merge2 = mergeStore({ ...mergeSeams(options), path, otherPath, backend, dryRun, stamp: fileStamp(now()), now });
|
|
3178
|
+
return Object.freeze({ status: "pulled", merge: merge2 });
|
|
3179
|
+
};
|
|
3180
|
+
return Object.freeze({ kind: "file", push, pull });
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// src/effort/sync/select.ts
|
|
3184
|
+
var REFUSAL2 = "effort sync";
|
|
3185
|
+
var MODULE_SYNC_STRATEGIES = Object.freeze(["git", "service", "p2p"]);
|
|
3186
|
+
var MODULE_DIRECTORY_PLACEHOLDER = "<module directory>";
|
|
3187
|
+
var MODULE_NAME_PLACEHOLDER = "<module name>";
|
|
3188
|
+
var NOTHING_TO_SYNC = Object.freeze({ status: "nothing-to-sync" });
|
|
3189
|
+
function createLocalSync() {
|
|
3190
|
+
return Object.freeze({
|
|
3191
|
+
kind: "local",
|
|
3192
|
+
push: async () => NOTHING_TO_SYNC,
|
|
3193
|
+
pull: async () => NOTHING_TO_SYNC
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
function listed(registered) {
|
|
3197
|
+
return registered.length === 0 ? "none" : registered.join(", ");
|
|
3198
|
+
}
|
|
3199
|
+
function moduleMissingMessage(kind, registered) {
|
|
3200
|
+
return [
|
|
3201
|
+
`${REFUSAL2}: effort.sync is "${kind}", and no module registers a sync adapter of that kind` + ` (registered: ${listed(registered)}). Core ships no ${kind} strategy; load a module that provides` + " one with these lines in .rafa/config.yaml:",
|
|
3202
|
+
"modules:",
|
|
3203
|
+
` - path: ${MODULE_DIRECTORY_PLACEHOLDER}`,
|
|
3204
|
+
"allowList:",
|
|
3205
|
+
` - ${MODULE_NAME_PLACEHOLDER}`
|
|
3206
|
+
].join(`
|
|
3207
|
+
`);
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
class SyncModuleMissing extends Error {
|
|
3211
|
+
name = "SyncModuleMissing";
|
|
3212
|
+
kind;
|
|
3213
|
+
constructor(kind, registered) {
|
|
3214
|
+
super(moduleMissingMessage(kind, registered));
|
|
3215
|
+
this.kind = kind;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
function isModuleStrategy(kind) {
|
|
3219
|
+
return MODULE_SYNC_STRATEGIES.includes(kind);
|
|
3220
|
+
}
|
|
3221
|
+
function selectSync(repoRoot, config, registry = CORE_ADAPTER_REGISTRY) {
|
|
3222
|
+
const kind = config.effortSync;
|
|
3223
|
+
const adapter = typeof kind === "string" ? registry.find("sync", kind) : undefined;
|
|
3224
|
+
if (adapter !== undefined) {
|
|
3225
|
+
return adapter.create(config.store === undefined ? { repoRoot } : { repoRoot, store: config.store });
|
|
3226
|
+
}
|
|
3227
|
+
const registered = registry.kinds("sync");
|
|
3228
|
+
if (isModuleStrategy(kind))
|
|
3229
|
+
throw new SyncModuleMissing(kind, registered);
|
|
3230
|
+
const known = SYNC_STRATEGIES.includes(kind);
|
|
3231
|
+
const expected = known ? `registered: ${listed(registered)}` : `expected one of: ${SYNC_STRATEGIES.join(", ")}`;
|
|
3232
|
+
throw new TypeError(`${REFUSAL2}: effort.sync is ${describeValue(kind)}, and no sync adapter is registered under it; ${expected}`);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
1781
3235
|
// src/adapters/learning/local.ts
|
|
1782
3236
|
import { appendFile, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
1783
|
-
import { join as
|
|
3237
|
+
import { join as join15 } from "path";
|
|
1784
3238
|
|
|
1785
3239
|
// src/commands/instinct/instinct-records.ts
|
|
1786
|
-
import { readdirSync as
|
|
1787
|
-
import { join as
|
|
3240
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync5 } from "fs";
|
|
3241
|
+
import { join as join14 } from "path";
|
|
1788
3242
|
|
|
1789
3243
|
// src/check/layout.ts
|
|
1790
|
-
import { readdirSync as
|
|
1791
|
-
import { join as
|
|
3244
|
+
import { readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
3245
|
+
import { join as join13, relative as relative2 } from "path";
|
|
1792
3246
|
var SKILL_FILE = "SKILL.md";
|
|
1793
3247
|
var MARKDOWN_EXTENSION = ".md";
|
|
1794
3248
|
var LAYOUT_SEVERITY = Object.freeze({
|
|
@@ -1806,7 +3260,7 @@ function layoutIssue(code, message) {
|
|
|
1806
3260
|
}
|
|
1807
3261
|
function entryKind(path) {
|
|
1808
3262
|
try {
|
|
1809
|
-
const stats =
|
|
3263
|
+
const stats = statSync4(path);
|
|
1810
3264
|
if (stats.isFile())
|
|
1811
3265
|
return "file";
|
|
1812
3266
|
if (stats.isDirectory())
|
|
@@ -1817,7 +3271,7 @@ function entryKind(path) {
|
|
|
1817
3271
|
}
|
|
1818
3272
|
}
|
|
1819
3273
|
function visibleNames(dir) {
|
|
1820
|
-
return
|
|
3274
|
+
return readdirSync3(dir).filter((name2) => !name2.startsWith(".")).sort((left, right) => left.localeCompare(right));
|
|
1821
3275
|
}
|
|
1822
3276
|
function isMarkdown(name2) {
|
|
1823
3277
|
return name2.endsWith(MARKDOWN_EXTENSION);
|
|
@@ -1905,7 +3359,7 @@ function scanGroupMember(root, dir, name2) {
|
|
|
1905
3359
|
const children = visibleNames(dir);
|
|
1906
3360
|
if (children.length === 0)
|
|
1907
3361
|
return [dotfileOnlyEntry("skill", root, dir)];
|
|
1908
|
-
const skillPath =
|
|
3362
|
+
const skillPath = join13(dir, SKILL_FILE);
|
|
1909
3363
|
if (children.includes(SKILL_FILE) && entryKind(skillPath) === "file") {
|
|
1910
3364
|
return [nestedGroupEntry(root, skillPath, name2)];
|
|
1911
3365
|
}
|
|
@@ -1914,7 +3368,7 @@ function scanGroupMember(root, dir, name2) {
|
|
|
1914
3368
|
function scanSkillGroup(root, dir, children) {
|
|
1915
3369
|
const entries = [];
|
|
1916
3370
|
for (const child of children) {
|
|
1917
|
-
const path =
|
|
3371
|
+
const path = join13(dir, child);
|
|
1918
3372
|
const found = entryKind(path);
|
|
1919
3373
|
if (found === null) {
|
|
1920
3374
|
entries.push(unreadableEntry("skill", root, path));
|
|
@@ -1935,7 +3389,7 @@ function scanSkillDirectory(root, dir, name2) {
|
|
|
1935
3389
|
const children = visibleNames(dir);
|
|
1936
3390
|
if (children.length === 0)
|
|
1937
3391
|
return [dotfileOnlyEntry("skill", root, dir)];
|
|
1938
|
-
const skillPath =
|
|
3392
|
+
const skillPath = join13(dir, SKILL_FILE);
|
|
1939
3393
|
if (children.includes(SKILL_FILE) && entryKind(skillPath) === "file") {
|
|
1940
3394
|
return [skillFileEntry(skillPath, name2)];
|
|
1941
3395
|
}
|
|
@@ -1944,7 +3398,7 @@ function scanSkillDirectory(root, dir, name2) {
|
|
|
1944
3398
|
function scanSkillRoot(root) {
|
|
1945
3399
|
const entries = [];
|
|
1946
3400
|
for (const name2 of visibleNames(root)) {
|
|
1947
|
-
const path =
|
|
3401
|
+
const path = join13(root, name2);
|
|
1948
3402
|
const found = entryKind(path);
|
|
1949
3403
|
if (found === null) {
|
|
1950
3404
|
entries.push(unreadableEntry("skill", root, path));
|
|
@@ -1965,7 +3419,7 @@ function scanInstinctSubdirectory(root, dir) {
|
|
|
1965
3419
|
return [dotfileOnlyEntry("instinct", root, dir)];
|
|
1966
3420
|
const entries = [];
|
|
1967
3421
|
for (const child of children) {
|
|
1968
|
-
const path =
|
|
3422
|
+
const path = join13(dir, child);
|
|
1969
3423
|
const found = entryKind(path);
|
|
1970
3424
|
if (found === null) {
|
|
1971
3425
|
entries.push(unreadableEntry("instinct", root, path));
|
|
@@ -1983,7 +3437,7 @@ function scanInstinctSubdirectory(root, dir) {
|
|
|
1983
3437
|
function scanInstinctRoot(root) {
|
|
1984
3438
|
const entries = [];
|
|
1985
3439
|
for (const name2 of visibleNames(root)) {
|
|
1986
|
-
const path =
|
|
3440
|
+
const path = join13(root, name2);
|
|
1987
3441
|
const found = entryKind(path);
|
|
1988
3442
|
if (found === null) {
|
|
1989
3443
|
entries.push(unreadableEntry("instinct", root, path));
|
|
@@ -2436,7 +3890,7 @@ function unreadableIssue(message) {
|
|
|
2436
3890
|
function readRecord(path, scope, id) {
|
|
2437
3891
|
let text;
|
|
2438
3892
|
try {
|
|
2439
|
-
text =
|
|
3893
|
+
text = readFileSync8(path, "utf8");
|
|
2440
3894
|
} catch (error) {
|
|
2441
3895
|
const message = error instanceof Error ? error.message : String(error);
|
|
2442
3896
|
return { scope, path, id, instinct: null, issues: [unreadableIssue(message)] };
|
|
@@ -2444,15 +3898,15 @@ function readRecord(path, scope, id) {
|
|
|
2444
3898
|
const parsed = parseInstinct(text);
|
|
2445
3899
|
return { scope, path, id, instinct: parsed.instinct, issues: parsed.issues };
|
|
2446
3900
|
}
|
|
2447
|
-
function
|
|
3901
|
+
function isFile2(path) {
|
|
2448
3902
|
try {
|
|
2449
|
-
return
|
|
3903
|
+
return statSync5(path).isFile();
|
|
2450
3904
|
} catch {
|
|
2451
3905
|
return false;
|
|
2452
3906
|
}
|
|
2453
3907
|
}
|
|
2454
3908
|
function recordFiles(dir) {
|
|
2455
|
-
return
|
|
3909
|
+
return readdirSync4(dir).filter((name2) => isRecordFile(name2) && isFile2(join14(dir, name2))).sort((left, right) => left.localeCompare(right));
|
|
2456
3910
|
}
|
|
2457
3911
|
function readScope(scope, dir) {
|
|
2458
3912
|
if (!tierExists(dir))
|
|
@@ -2461,7 +3915,7 @@ function readScope(scope, dir) {
|
|
|
2461
3915
|
scope,
|
|
2462
3916
|
dir,
|
|
2463
3917
|
exists: true,
|
|
2464
|
-
records: recordFiles(dir).map((name2) => readRecord(
|
|
3918
|
+
records: recordFiles(dir).map((name2) => readRecord(join14(dir, name2), scope, recordId(name2)))
|
|
2465
3919
|
};
|
|
2466
3920
|
}
|
|
2467
3921
|
function readScopes(seams) {
|
|
@@ -2480,7 +3934,7 @@ function instinctProject(context, name2) {
|
|
|
2480
3934
|
}
|
|
2481
3935
|
|
|
2482
3936
|
// src/adapters/learning/held.ts
|
|
2483
|
-
function
|
|
3937
|
+
function compareText2(left, right) {
|
|
2484
3938
|
if (left < right) {
|
|
2485
3939
|
return -1;
|
|
2486
3940
|
}
|
|
@@ -2490,7 +3944,7 @@ function compareText(left, right) {
|
|
|
2490
3944
|
return 0;
|
|
2491
3945
|
}
|
|
2492
3946
|
function byAge(left, right) {
|
|
2493
|
-
return
|
|
3947
|
+
return compareText2(left.createdAt, right.createdAt) || compareText2(left.id, right.id);
|
|
2494
3948
|
}
|
|
2495
3949
|
function toHeldRecord(instinct) {
|
|
2496
3950
|
return {
|
|
@@ -2764,7 +4218,7 @@ function heldChanges(dir, held, result, described) {
|
|
|
2764
4218
|
if (issue2 !== undefined) {
|
|
2765
4219
|
throw new Error(`${PUSH_REFUSAL}: ${file} would not read back: ${issue2.field}: ${issue2.message}`);
|
|
2766
4220
|
}
|
|
2767
|
-
const path =
|
|
4221
|
+
const path = join15(dir, file);
|
|
2768
4222
|
if (held.unreadable.has(path)) {
|
|
2769
4223
|
throw new Error(`${PUSH_REFUSAL}: it would overwrite ${path}, which could not be read`);
|
|
2770
4224
|
}
|
|
@@ -2794,18 +4248,18 @@ function pushLogLines(sourceId, described, result) {
|
|
|
2794
4248
|
});
|
|
2795
4249
|
}
|
|
2796
4250
|
function localInstinctsDir(repoRoot) {
|
|
2797
|
-
return
|
|
4251
|
+
return join15(repoRoot, RAFA_INSTINCTS_PATH);
|
|
2798
4252
|
}
|
|
2799
4253
|
function userInstinctsDir(home) {
|
|
2800
|
-
return
|
|
4254
|
+
return join15(home, RAFA_INSTINCTS_PATH);
|
|
2801
4255
|
}
|
|
2802
4256
|
function createLocalLearning(options) {
|
|
2803
4257
|
const { instinctsDir, minConfidence } = options;
|
|
2804
4258
|
const userDir = userInstinctsDir(options.home);
|
|
2805
4259
|
const now = options.now ?? (() => new Date().toISOString());
|
|
2806
4260
|
const warn = options.warn ?? warnThroughActiveOutput;
|
|
2807
|
-
const instinctsPath =
|
|
2808
|
-
const flagsPath =
|
|
4261
|
+
const instinctsPath = join15(instinctsDir, INSTINCTS_FILE);
|
|
4262
|
+
const flagsPath = join15(instinctsDir, FLAGS_FILE);
|
|
2809
4263
|
function readHeld(scope, dir) {
|
|
2810
4264
|
const { records } = readScope(scope, dir);
|
|
2811
4265
|
const unreadable = new Set;
|
|
@@ -2922,9 +4376,9 @@ function createJsonOutput({
|
|
|
2922
4376
|
}
|
|
2923
4377
|
|
|
2924
4378
|
// src/adapters/planner/claude.ts
|
|
2925
|
-
import { existsSync as
|
|
4379
|
+
import { existsSync as existsSync13 } from "fs";
|
|
2926
4380
|
import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
|
|
2927
|
-
import { posix, resolve as
|
|
4381
|
+
import { posix, resolve as resolve6 } from "path";
|
|
2928
4382
|
|
|
2929
4383
|
// src/board/spec-review.ts
|
|
2930
4384
|
var SPEC_REVIEW_KIND = "spec-review";
|
|
@@ -2960,8 +4414,8 @@ function describeValue3(value) {
|
|
|
2960
4414
|
function messageOf2(error) {
|
|
2961
4415
|
return error instanceof Error ? error.message : String(error);
|
|
2962
4416
|
}
|
|
2963
|
-
function counted(
|
|
2964
|
-
return
|
|
4417
|
+
function counted(count2, noun) {
|
|
4418
|
+
return count2 === 1 ? `1 ${noun}` : `${String(count2)} ${noun}s`;
|
|
2965
4419
|
}
|
|
2966
4420
|
function valueAt(mapping, key) {
|
|
2967
4421
|
return Object.hasOwn(mapping, key) ? mapping[key] ?? null : null;
|
|
@@ -3187,17 +4641,18 @@ function guardSpend() {
|
|
|
3187
4641
|
if (refusal !== null)
|
|
3188
4642
|
throw new UndeclaredSpendError(refusal);
|
|
3189
4643
|
}
|
|
3190
|
-
async function spawnClaude(args, prompt) {
|
|
4644
|
+
async function spawnClaude(args, prompt, options = {}) {
|
|
3191
4645
|
guardSpend();
|
|
3192
4646
|
if (activeOutputMode() === "json") {
|
|
3193
|
-
const { exitCode } = await spawnCaptured(args, prompt);
|
|
4647
|
+
const { exitCode } = await spawnCaptured(args, prompt, options);
|
|
3194
4648
|
return exitCode;
|
|
3195
4649
|
}
|
|
3196
4650
|
const proc = Bun.spawn([CLAUDE_BIN, ...args], {
|
|
3197
4651
|
stdin: new TextEncoder().encode(prompt),
|
|
3198
4652
|
stdout: "inherit",
|
|
3199
4653
|
stderr: "inherit",
|
|
3200
|
-
env: sessionSpawnEnv(process.env)
|
|
4654
|
+
env: sessionSpawnEnv(process.env),
|
|
4655
|
+
...cwdOption(options)
|
|
3201
4656
|
});
|
|
3202
4657
|
liveSessions.add(proc);
|
|
3203
4658
|
try {
|
|
@@ -3206,8 +4661,11 @@ async function spawnClaude(args, prompt) {
|
|
|
3206
4661
|
liveSessions.delete(proc);
|
|
3207
4662
|
}
|
|
3208
4663
|
}
|
|
3209
|
-
function runClaude(prompt, settingSources, flags = [], spawn = spawnClaude, served = []) {
|
|
3210
|
-
return spawn(claudeArgs(settingSources, flags, served), prompt);
|
|
4664
|
+
function runClaude(prompt, settingSources, flags = [], spawn = spawnClaude, served = [], options = {}) {
|
|
4665
|
+
return spawn(claudeArgs(settingSources, flags, served), prompt, options);
|
|
4666
|
+
}
|
|
4667
|
+
function cwdOption(options) {
|
|
4668
|
+
return options.cwd === undefined ? {} : { cwd: options.cwd };
|
|
3211
4669
|
}
|
|
3212
4670
|
function writeWholeLines(text, write) {
|
|
3213
4671
|
const lines = text.split(`
|
|
@@ -3251,7 +4709,7 @@ async function spawnCaptured(args, prompt, options = {}) {
|
|
|
3251
4709
|
stdout: "pipe",
|
|
3252
4710
|
stderr: "inherit",
|
|
3253
4711
|
env: sessionSpawnEnv(process.env),
|
|
3254
|
-
...options
|
|
4712
|
+
...cwdOption(options)
|
|
3255
4713
|
});
|
|
3256
4714
|
liveSessions.add(proc);
|
|
3257
4715
|
let stdout;
|
|
@@ -3263,8 +4721,8 @@ async function spawnCaptured(args, prompt, options = {}) {
|
|
|
3263
4721
|
}
|
|
3264
4722
|
return { exitCode: await proc.exited, stdout };
|
|
3265
4723
|
}
|
|
3266
|
-
function runClaudeCaptured(prompt, settingSources, flags = [], spawn = spawnClaudeCaptured, served = []) {
|
|
3267
|
-
return spawn(claudeArgs(settingSources, flags, served), prompt);
|
|
4724
|
+
function runClaudeCaptured(prompt, settingSources, flags = [], spawn = spawnClaudeCaptured, served = [], options = {}) {
|
|
4725
|
+
return spawn(claudeArgs(settingSources, flags, served), prompt, options);
|
|
3268
4726
|
}
|
|
3269
4727
|
|
|
3270
4728
|
// src/adapters/planner/claude.ts
|
|
@@ -3285,23 +4743,24 @@ class ClaudePlannerError extends Error {
|
|
|
3285
4743
|
}
|
|
3286
4744
|
function createClaudePlanner(options) {
|
|
3287
4745
|
const { repoRoot, planDir, settingSources, buildPrompt, spawn = spawnClaudeCaptured } = options;
|
|
4746
|
+
const root = resolve6(repoRoot);
|
|
3288
4747
|
const create = async ({ specPath, stub }) => {
|
|
3289
4748
|
const planPath = planFilePath(planDir, `PLAN-${stub}.md`);
|
|
3290
4749
|
const prerequisitesPath = planFilePath(planDir, `PREREQUISITES-${stub}.md`);
|
|
3291
|
-
const isWritten = (path) =>
|
|
4750
|
+
const isWritten = (path) => existsSync13(resolve6(root, path));
|
|
3292
4751
|
if (isWritten(planPath)) {
|
|
3293
4752
|
throw new ClaudePlannerError(`${planPath} already exists`, FAILURE_EXIT_CODE);
|
|
3294
4753
|
}
|
|
3295
|
-
const specContent = await readFile2(
|
|
3296
|
-
await mkdir2(
|
|
3297
|
-
const session = await
|
|
4754
|
+
const specContent = await readFile2(resolve6(root, specPath), "utf8");
|
|
4755
|
+
await mkdir2(resolve6(root, planDir), { recursive: true });
|
|
4756
|
+
const session = await spawn(claudeArgs(settingSources), buildPrompt(specContent, stub), { cwd: root });
|
|
3298
4757
|
const review = parseSpecReview(session.stdout);
|
|
3299
4758
|
const { exitCode } = session;
|
|
3300
4759
|
if (exitCode !== 0) {
|
|
3301
4760
|
throw new ClaudePlannerError(`Plan generation failed (exit ${exitCode}).`, exitCode, review);
|
|
3302
4761
|
}
|
|
3303
4762
|
if (!isWritten(planPath)) {
|
|
3304
|
-
throw new ClaudePlannerError(`The session finished but ${planPath} was not created \u2014 inspect the output above.`, FAILURE_EXIT_CODE, review);
|
|
4763
|
+
throw new ClaudePlannerError(`The session finished but ${planPath} was not created under ${root} \u2014 inspect the output above.`, FAILURE_EXIT_CODE, review);
|
|
3305
4764
|
}
|
|
3306
4765
|
return {
|
|
3307
4766
|
planPath,
|
|
@@ -3404,8 +4863,8 @@ async function withDeadline(work, timeoutMs) {
|
|
|
3404
4863
|
if (timeoutMs === undefined)
|
|
3405
4864
|
return work;
|
|
3406
4865
|
let timer;
|
|
3407
|
-
const deadline = new Promise((
|
|
3408
|
-
timer = setTimeout(() =>
|
|
4866
|
+
const deadline = new Promise((resolve7) => {
|
|
4867
|
+
timer = setTimeout(() => resolve7(TIMED_OUT), timeoutMs);
|
|
3409
4868
|
});
|
|
3410
4869
|
try {
|
|
3411
4870
|
return await Promise.race([work, deadline]);
|
|
@@ -3710,7 +5169,7 @@ function createGithubTracker(options) {
|
|
|
3710
5169
|
|
|
3711
5170
|
// src/adapters/tracker/local.ts
|
|
3712
5171
|
import { mkdir as mkdir3, readdir, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
3713
|
-
import { join as
|
|
5172
|
+
import { join as join16 } from "path";
|
|
3714
5173
|
var PREFIX3 = "local tracker";
|
|
3715
5174
|
var FRONTMATTER = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
3716
5175
|
var ISSUE_NUMBER2 = /^[1-9]\d*$/;
|
|
@@ -3767,7 +5226,7 @@ function issueNumberOfFile(name2) {
|
|
|
3767
5226
|
return name2.endsWith(ISSUE_EXTENSION) ? parseIssueNumber(name2.slice(0, -ISSUE_EXTENSION.length)) : null;
|
|
3768
5227
|
}
|
|
3769
5228
|
function issuePath(issuesDir, number) {
|
|
3770
|
-
return
|
|
5229
|
+
return join16(issuesDir, `${number}${ISSUE_EXTENSION}`);
|
|
3771
5230
|
}
|
|
3772
5231
|
async function issueNumbers(issuesDir) {
|
|
3773
5232
|
let names;
|
|
@@ -3781,7 +5240,7 @@ async function issueNumbers(issuesDir) {
|
|
|
3781
5240
|
return names.map(issueNumberOfFile).filter((number) => number !== null).sort((a, b) => a - b);
|
|
3782
5241
|
}
|
|
3783
5242
|
function localIssuesDir(repoRoot) {
|
|
3784
|
-
return
|
|
5243
|
+
return join16(repoRoot, ".rafa", "issues");
|
|
3785
5244
|
}
|
|
3786
5245
|
function renderLocalIssue(record) {
|
|
3787
5246
|
const { draft } = record;
|
|
@@ -3960,40 +5419,41 @@ function createLocalTracker(options) {
|
|
|
3960
5419
|
}
|
|
3961
5420
|
|
|
3962
5421
|
// src/adapters/registry.ts
|
|
3963
|
-
var
|
|
5422
|
+
var REFUSAL3 = "adapter registry";
|
|
3964
5423
|
var PORT_VERSIONS = Object.freeze({
|
|
3965
5424
|
tracker: 1,
|
|
3966
5425
|
store: 1,
|
|
3967
5426
|
learning: 1,
|
|
3968
5427
|
output: 1,
|
|
3969
|
-
planner: 1
|
|
5428
|
+
planner: 1,
|
|
5429
|
+
sync: 1
|
|
3970
5430
|
});
|
|
3971
5431
|
var SERVED_VERSIONS = new Map(Object.entries(PORT_VERSIONS));
|
|
3972
5432
|
var PORT_TYPE_LIST = [...SERVED_VERSIONS.keys()].join(", ");
|
|
3973
5433
|
function checkPortType(port) {
|
|
3974
5434
|
if (!SERVED_VERSIONS.has(port)) {
|
|
3975
|
-
throw new TypeError(`${
|
|
5435
|
+
throw new TypeError(`${REFUSAL3}: port is ${describeValue(port)}, expected one of: ${PORT_TYPE_LIST}`);
|
|
3976
5436
|
}
|
|
3977
5437
|
}
|
|
3978
5438
|
function accept(adapter, held) {
|
|
3979
5439
|
if (typeof adapter !== "object" || adapter === null) {
|
|
3980
|
-
throw new TypeError(`${
|
|
5440
|
+
throw new TypeError(`${REFUSAL3}: an adapter is ${describeValue(adapter)}, expected a mapping`);
|
|
3981
5441
|
}
|
|
3982
5442
|
const { port, kind, portVersion, create } = adapter;
|
|
3983
5443
|
checkPortType(port);
|
|
3984
5444
|
if (typeof kind !== "string" || kind === "") {
|
|
3985
|
-
throw new TypeError(`${
|
|
5445
|
+
throw new TypeError(`${REFUSAL3}: a ${port} adapter has kind ${describeValue(kind)}, expected a non-empty name`);
|
|
3986
5446
|
}
|
|
3987
5447
|
const key = `${port}/${kind}`;
|
|
3988
5448
|
if (typeof create !== "function") {
|
|
3989
|
-
throw new TypeError(`${
|
|
5449
|
+
throw new TypeError(`${REFUSAL3}: ${key} has create ${describeValue(create)}, expected a function`);
|
|
3990
5450
|
}
|
|
3991
5451
|
const served = SERVED_VERSIONS.get(port);
|
|
3992
5452
|
if (portVersion !== served) {
|
|
3993
|
-
throw new Error(`${
|
|
5453
|
+
throw new Error(`${REFUSAL3}: ${key} implements ${port} port version ${describeValue(portVersion)},` + ` and core serves ${port} port version ${served}`);
|
|
3994
5454
|
}
|
|
3995
5455
|
if (held.some((other) => other.port === port && other.kind === kind)) {
|
|
3996
|
-
throw new Error(`${
|
|
5456
|
+
throw new Error(`${REFUSAL3}: ${key} is already registered`);
|
|
3997
5457
|
}
|
|
3998
5458
|
return Object.freeze({ port, kind, portVersion, create });
|
|
3999
5459
|
}
|
|
@@ -4004,7 +5464,8 @@ function indexByPort(adapters) {
|
|
|
4004
5464
|
store: only("store"),
|
|
4005
5465
|
learning: only("learning"),
|
|
4006
5466
|
output: only("output"),
|
|
4007
|
-
planner: only("planner")
|
|
5467
|
+
planner: only("planner"),
|
|
5468
|
+
sync: only("sync")
|
|
4008
5469
|
};
|
|
4009
5470
|
}
|
|
4010
5471
|
function createAdapterRegistry(adapters = []) {
|
|
@@ -4019,8 +5480,8 @@ function createAdapterRegistry(adapters = []) {
|
|
|
4019
5480
|
if (adapter !== undefined)
|
|
4020
5481
|
return adapter;
|
|
4021
5482
|
const registered = [...byPort[port].keys()];
|
|
4022
|
-
const
|
|
4023
|
-
throw new Error(`${
|
|
5483
|
+
const listed2 = registered.length === 0 ? "none" : registered.join(", ");
|
|
5484
|
+
throw new Error(`${REFUSAL3}: no ${port} adapter is registered as ${describeValue(kind)};` + ` registered: ${listed2}`);
|
|
4024
5485
|
},
|
|
4025
5486
|
kinds: (port) => {
|
|
4026
5487
|
checkPortType(port);
|
|
@@ -4087,16 +5548,33 @@ var CORE_ADAPTERS = [
|
|
|
4087
5548
|
portVersion: PORT_VERSIONS.planner,
|
|
4088
5549
|
create: ({ repoRoot, settingSources, planPrompt, planDir, claude }) => {
|
|
4089
5550
|
if (!Array.isArray(settingSources)) {
|
|
4090
|
-
throw new TypeError(`${
|
|
5551
|
+
throw new TypeError(`${REFUSAL3}: planner/claude has settingSources ${describeValue(settingSources)} in its context,` + " expected a list of setting sources");
|
|
4091
5552
|
}
|
|
4092
5553
|
if (typeof planPrompt !== "function") {
|
|
4093
|
-
throw new TypeError(`${
|
|
5554
|
+
throw new TypeError(`${REFUSAL3}: planner/claude has planPrompt ${describeValue(planPrompt)} in its context,` + " expected a function");
|
|
4094
5555
|
}
|
|
4095
5556
|
if (typeof planDir !== "string") {
|
|
4096
|
-
throw new TypeError(`${
|
|
5557
|
+
throw new TypeError(`${REFUSAL3}: planner/claude has planDir ${describeValue(planDir)} in its context,` + " expected a directory path");
|
|
4097
5558
|
}
|
|
4098
5559
|
return createClaudePlanner({ repoRoot, planDir, settingSources, buildPrompt: planPrompt, spawn: claude });
|
|
4099
5560
|
}
|
|
5561
|
+
},
|
|
5562
|
+
{
|
|
5563
|
+
port: "sync",
|
|
5564
|
+
kind: "local",
|
|
5565
|
+
portVersion: PORT_VERSIONS.sync,
|
|
5566
|
+
create: () => createLocalSync()
|
|
5567
|
+
},
|
|
5568
|
+
{
|
|
5569
|
+
port: "sync",
|
|
5570
|
+
kind: "file",
|
|
5571
|
+
portVersion: PORT_VERSIONS.sync,
|
|
5572
|
+
create: ({ repoRoot, store }) => {
|
|
5573
|
+
if (!STORE_BACKENDS.includes(store)) {
|
|
5574
|
+
throw new TypeError(`${REFUSAL3}: sync/file has store ${describeValue(store)} in its context,` + ` expected one of: ${STORE_BACKENDS.join(", ")}`);
|
|
5575
|
+
}
|
|
5576
|
+
return createFileSync({ repoRoot, backend: store });
|
|
5577
|
+
}
|
|
4100
5578
|
}
|
|
4101
5579
|
];
|
|
4102
5580
|
var CORE_ADAPTER_REGISTRY = createAdapterRegistry(CORE_ADAPTERS);
|
|
@@ -4118,4 +5596,4 @@ function selectEffortStore(repoRoot, config) {
|
|
|
4118
5596
|
return adapter.create({ repoRoot });
|
|
4119
5597
|
}
|
|
4120
5598
|
|
|
4121
|
-
export { EFFORT_STORE_DIR, effortStoreFileName, EFFORT_DIR_VARIABLE, effortStoreDir, guardTestProcess, EFFORT_KEY_PROJECTIONS, openNdjsonStore, name, version, RAFA_VERSION, versionLine, SCOPE_DIR, INIT_COMMAND, DISK_FILE_SYSTEM, ScopeError, scopeAt, initHint, selfAndAncestors, resolveScope, POSITION_FILE, positionFilePath, readPositionFile, writePositionFile, positionAt, rehome, hop, goHome, HOP_FILE, hopFilePath, readHopRecord, writeHopRecord, staleAgainst, SessionRecordError, SessionConflictError, SessionStateError, isSessionId, runsDir, sessionFilePath, errorCode, isPidAlive, readSessions, readSession, beginSession, updateSession,
|
|
5599
|
+
export { EFFORT_STORE_DIR, effortStoreFileName, EFFORT_DIR_VARIABLE, effortStoreDir, guardTestProcess, EFFORT_KEY_PROJECTIONS, openNdjsonStore, name, version, RAFA_VERSION, versionLine, createGitRunner, gitSaid, NOT_A_REPOSITORY, mainCheckoutOf, SCOPE_DIR, INIT_COMMAND, DISK_FILE_SYSTEM, ScopeError, scopeAt, initHint, selfAndAncestors, resolveScope, POSITION_FILE, positionFilePath, readPositionFile, writePositionFile, positionAt, rehome, hop, goHome, HOP_FILE, hopFilePath, readHopRecord, writeHopRecord, staleAgainst, SessionRecordError, SessionConflictError, SessionStateError, isSessionId, runsDir, sessionFilePath, errorCode, isPidAlive, readSessions, readSession, beginSession, updateSession, DEVELOPMENT_NEXT_STEP, DEVELOPMENT_MARK, DevelopmentBuildRefusedError, refuseUnownedDevelopmentWrite, LEGACY_GATE_OPEN, LEGACY_GATE_CLOSED, SQLITE_MIGRATIONS, sqliteCatalogue, planSchema, SchemaRefusedError, readStoreSchema, writeNames, ACROSS_STORES_ORDER, STAMPED_COLUMNS, stampedValues, activeStoreSettings, normalizeRemote, gitRemoteUrl, readStoreMeta, SQLITE_STORE_FILE_NAME, sqliteStorePath, LONE_SURROGATE, withSqliteStore, writeSqliteStore, openSqliteStore, RebuildRefusal, readConfigFile, loadConfig, plansDirAt, requireProject, resolvePlansDir, resolveProjectConfig, countTasks, formatCounts, checkbox, planFileName, stubOfPlanFile, isFile, issueLine, plural, expectNoArgument, expectOneArgument, expectAtMostOneArgument, readSwitch, fileStamp, fix_schema_default, EffortCopyRefusal, EffortCopyFailure, copyEffortStore, minutesBetween, readCommitLog, UnionSchemaMismatch, migrateStore, MOVE_TO_SQLITE, MergeRefusal, mergeStore, createFileSync, SyncModuleMissing, selectSync, SKILL_FILE, MARKDOWN_EXTENSION, scanLayout, declaredNameIssue, readFrontmatterDocument, readFrontmatter, renderFrontmatter, mergeFrontmatter, writeFrontmatter, ACTION_HEADING, CAUSE_HEADING, parseInstinct, writeInstinct, readScopes, allRecords, findRecords, instinctProject, toHeldRecords, sessionSpawnEnv, getClaudeUsagePercent, checkUsage, CLAUDE_BIN, claudeArgs, interruptClaudeSessions, spawnClaude, runClaude, spawnClaudeCaptured, runClaudeCaptured, planFilePath, ClaudePlannerError, ISSUE_TYPES, ISSUE_PRIORITIES, ISSUE_STATES, GITHUB_LABELS, createGhRunner, typeOfLabels, moduleOfLabels, createLocalTracker, PORT_VERSIONS, createAdapterRegistry, CORE_ADAPTER_REGISTRY, selectEffortStore };
|