docks-kit 0.18.1 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -5
- package/README.md +9 -8
- package/cli/docs/flags.md +2 -2
- package/cli/docs/models.md +16 -7
- package/cli/docs/omp-models.md +2 -2
- package/cli/docs/overview.md +1 -1
- package/cli/docs/sync-layers.md +1 -1
- package/cli/docs/toolchain.md +10 -0
- package/cli/src/commands/docs.ts +8 -2
- package/cli/src/commands/harnesses.ts +2 -2
- package/cli/src/commands/model.ts +5 -2
- package/cli/src/commands/models.ts +50 -23
- package/cli/src/commands/toolchain.ts +36 -18
- package/cli/src/commands/update.ts +6 -6
- package/cli/src/engine-native/claudePluginPasses.ts +26 -11
- package/cli/src/engine-native/codexConfig.ts +6 -6
- package/cli/src/engine-native/codexPlugins.ts +6 -5
- package/cli/src/engine-native/codexToml.ts +10 -6
- package/cli/src/engine-native/harnesses.ts +52 -78
- package/cli/src/engine-native/index.ts +1 -1
- package/cli/src/engine-native/kitDb.ts +250 -0
- package/cli/src/engine-native/liveModels.ts +263 -0
- package/cli/src/engine-native/models.ts +32 -26
- package/cli/src/engine-native/modes.ts +22 -21
- package/cli/src/engine-native/parseArgs.ts +3 -2
- package/cli/src/engine-native/parseModifiers.ts +8 -5
- package/cli/src/engine-native/sharedTypes.d.ts +19 -0
- package/cli/src/engine-native/syncDispatch.ts +1 -1
- package/cli/src/engine-native/toolchain.ts +161 -2
- package/cli/src/generated/sotPayload.ts +4 -4
- package/cli/src/main.ts +2 -2
- package/cli/src/manifests.ts +2 -2
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import { payloadDisplayPath } from "../payload";
|
|
16
16
|
import { PLUGIN_TABLE_HEADER } from "./codexConfig";
|
|
17
|
+
import { isTomlHeaderLine } from "./codexToml";
|
|
17
18
|
import { p, spawnProcess } from "./exec";
|
|
18
19
|
import { recordFailure } from "./failures";
|
|
19
20
|
import type { Ctx } from "./index";
|
|
@@ -107,20 +108,20 @@ function mergeMarketplace(repo: Json, user: Json): Json {
|
|
|
107
108
|
// -------------------------------------------------------------- plugins ----
|
|
108
109
|
|
|
109
110
|
/** codex::_marketplace_source — first `source =` inside [marketplaces.<name>]. */
|
|
110
|
-
function marketplaceSource(marketplace: string, configFile: string): string {
|
|
111
|
+
export function marketplaceSource(marketplace: string, configFile: string): string {
|
|
111
112
|
if (!existsSync(configFile)) return "";
|
|
112
113
|
let inMarketplace = false;
|
|
113
114
|
for (const line of readFileSync(configFile, "utf8").split("\n")) {
|
|
114
|
-
if (line === `[marketplaces.${marketplace}]`) {
|
|
115
|
+
if (line.trim() === `[marketplaces.${marketplace}]`) {
|
|
115
116
|
inMarketplace = true;
|
|
116
117
|
continue;
|
|
117
118
|
}
|
|
118
|
-
if (line
|
|
119
|
+
if (isTomlHeaderLine(line)) inMarketplace = false;
|
|
119
120
|
if (inMarketplace && /^[ \t]*source[ \t]*=/.test(line)) {
|
|
120
121
|
return line
|
|
121
122
|
.replace(/^[^=]+=[ \t]*/, "")
|
|
122
123
|
.replace(/[ \t]*#.*/, "")
|
|
123
|
-
.replace(/^"
|
|
124
|
+
.replace(/^(["'])(.*)\1$/, "$2");
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
return "";
|
|
@@ -175,7 +176,7 @@ function enabledPluginIdsFromText(configText: string): Array<string> {
|
|
|
175
176
|
enabled = false;
|
|
176
177
|
continue;
|
|
177
178
|
}
|
|
178
|
-
if (line
|
|
179
|
+
if (isTomlHeaderLine(line)) {
|
|
179
180
|
flush();
|
|
180
181
|
plugin = "";
|
|
181
182
|
enabled = false;
|
|
@@ -12,15 +12,19 @@ import { resolveEffort } from "../efforts";
|
|
|
12
12
|
import type { Ctx } from "./index";
|
|
13
13
|
import type { SettingEdit } from "./sharedTypes";
|
|
14
14
|
|
|
15
|
+
export function isTomlHeaderLine(line: string): boolean {
|
|
16
|
+
return /^[ \t]*\[/.test(line);
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
export function replaceTopLevelSetting(content: string, key: string, replacement: string): string {
|
|
16
20
|
const lines = content.split("\n");
|
|
17
21
|
if (lines[lines.length - 1] === "") lines.pop(); // awk records exclude a trailing empty split artifact
|
|
18
|
-
const keyRe = new RegExp(
|
|
22
|
+
const keyRe = new RegExp(`^[ \\t]*${key}[ \\t]*=`);
|
|
19
23
|
const out: Array<string> = [];
|
|
20
24
|
let inTable = false;
|
|
21
25
|
let replaced = false;
|
|
22
26
|
for (const line of lines) {
|
|
23
|
-
if (line
|
|
27
|
+
if (isTomlHeaderLine(line)) {
|
|
24
28
|
if (!replaced) {
|
|
25
29
|
out.push(replacement);
|
|
26
30
|
replaced = true;
|
|
@@ -113,11 +117,11 @@ export function syncCodexEffort(ctx: Ctx, effort: string): void {
|
|
|
113
117
|
|
|
114
118
|
export function mergeTopLevelSettings(sotConfigText: string, userConfig: string): void {
|
|
115
119
|
for (const line of sotConfigText.split("\n")) {
|
|
116
|
-
if (line
|
|
120
|
+
if (isTomlHeaderLine(line)) break;
|
|
117
121
|
if (/^[ \t]*($|#)/.test(line)) continue;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
replaceTopLevelSettingInFile(userConfig,
|
|
122
|
+
const setting = /^[ \t]*([A-Za-z0-9_.-]+)[ \t]*=/.exec(line);
|
|
123
|
+
if (setting === null) continue;
|
|
124
|
+
replaceTopLevelSettingInFile(userConfig, setting[1]!, line);
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-machine harness selection
|
|
3
|
-
* the omp harness opt-in. A missing or unreadable
|
|
4
|
-
* undefined so callers resolve it to LEGACY_SELECTION
|
|
5
|
-
* keep today's behavior.
|
|
2
|
+
* Per-machine harness selection and omp session model, stored in ~/.docks-kit/kit.db (kitDb.ts).
|
|
3
|
+
* The selection keeps the omp harness opt-in. A missing or unreadable store is
|
|
4
|
+
* represented by undefined so callers resolve it to LEGACY_SELECTION.
|
|
6
5
|
*/
|
|
7
|
-
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
6
|
import { homedir } from "node:os";
|
|
9
7
|
|
|
10
|
-
import {
|
|
8
|
+
import { inTransaction, isNonBlankString, nonBlankOrNull, withKitDb } from "./kitDb";
|
|
11
9
|
import type { OmpSessionModel } from "./sharedTypes";
|
|
12
10
|
|
|
13
11
|
export type Harness = "claude" | "codex" | "agents" | "omp";
|
|
@@ -41,49 +39,21 @@ export function engineHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
41
39
|
return home !== undefined && home !== "" ? home : homedir();
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Read the whole state record so one key writer keeps sibling keys intact.
|
|
49
|
-
// A corrupt file degrades to undefined so callers fall back to defaults.
|
|
50
|
-
function readWholeState(home: string): Record<string, unknown> | undefined {
|
|
51
|
-
let parsed: unknown;
|
|
42
|
+
/** Read the stored selection; corruption, a too-new schema, or I/O errors yield undefined. */
|
|
43
|
+
export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
|
|
52
44
|
try {
|
|
53
|
-
|
|
45
|
+
const selection = withKitDb(home, "read", (db) =>
|
|
46
|
+
normalizeHarnesses(
|
|
47
|
+
db
|
|
48
|
+
.prepare("SELECT harness FROM harness_selection")
|
|
49
|
+
.all()
|
|
50
|
+
.map((row) => row["harness"]),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
return selection !== undefined && selection.length > 0 ? selection : undefined;
|
|
54
54
|
} catch {
|
|
55
55
|
return undefined;
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
59
|
-
const state = parsed as Record<string, unknown>;
|
|
60
|
-
if (state["version"] !== 1) return undefined;
|
|
61
|
-
return state;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Merge the patch over the stored record so independent keys never erase
|
|
65
|
-
// each other when only one writer runs.
|
|
66
|
-
function writeWholeState(home: string, patch: Record<string, unknown>): void {
|
|
67
|
-
const existing = readWholeState(home) ?? {};
|
|
68
|
-
const next = { ...existing, ...patch, version: 1 };
|
|
69
|
-
const directory = p(home, ".docks-kit");
|
|
70
|
-
const file = harnessStateFile(home);
|
|
71
|
-
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
72
|
-
// `mode` applies only when mkdir creates the path, so an existing permissive
|
|
73
|
-
// ~/.docks-kit would keep its mode.
|
|
74
|
-
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
75
|
-
chmodSync(directory, 0o700);
|
|
76
|
-
writeFileSync(file, text, { mode: 0o600 });
|
|
77
|
-
chmodSync(file, 0o600);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Read valid local state without allowing corruption to make sync unusable. */
|
|
81
|
-
export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
|
|
82
|
-
const state = readWholeState(home);
|
|
83
|
-
if (state === undefined || !Array.isArray(state["harnesses"])) return undefined;
|
|
84
|
-
|
|
85
|
-
const selection = normalizeHarnesses(state["harnesses"]);
|
|
86
|
-
return selection.length > 0 ? selection : undefined;
|
|
87
57
|
}
|
|
88
58
|
|
|
89
59
|
export function writeHarnessSelection(home: string, selection: ReadonlyArray<Harness>): void {
|
|
@@ -96,34 +66,37 @@ export function writeHarnessSelection(home: string, selection: ReadonlyArray<Har
|
|
|
96
66
|
throw new Error("Harness selection must contain at least one known harness name");
|
|
97
67
|
}
|
|
98
68
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
69
|
+
withKitDb(home, "write", (db) =>
|
|
70
|
+
inTransaction(db, () => {
|
|
71
|
+
db.exec("DELETE FROM harness_selection");
|
|
72
|
+
const insert = db.prepare("INSERT INTO harness_selection (harness) VALUES (?)");
|
|
73
|
+
for (const harness of harnesses) insert.run(harness);
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
104
76
|
}
|
|
105
77
|
|
|
106
|
-
// Read the stored session model without throwing so a corrupt
|
|
78
|
+
// Read the stored session model without throwing so a corrupt store falls
|
|
107
79
|
// back to the default instead of breaking sync.
|
|
108
80
|
export function readOmpSessionModel(home: string): OmpSessionModel | undefined {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
try {
|
|
82
|
+
return withKitDb(home, "read", (db) => {
|
|
83
|
+
const row = db
|
|
84
|
+
.prepare("SELECT selector, thinking, advisor_thinking FROM omp_session WHERE id = 1")
|
|
85
|
+
.get();
|
|
86
|
+
const selector = row?.["selector"];
|
|
87
|
+
if (!isNonBlankString(selector)) return undefined;
|
|
88
|
+
const thinking = row?.["thinking"];
|
|
89
|
+
const advisorThinking = row?.["advisor_thinking"];
|
|
90
|
+
const model: OmpSessionModel = {
|
|
91
|
+
selector,
|
|
92
|
+
...(isNonBlankString(thinking) ? { thinking } : {}),
|
|
93
|
+
...(isNonBlankString(advisorThinking) ? { advisorThinking } : {}),
|
|
94
|
+
};
|
|
95
|
+
return model;
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
115
98
|
return undefined;
|
|
116
99
|
}
|
|
117
|
-
// Each level stands alone, so a level-free model reads back with no
|
|
118
|
-
// levels while a half-corrupt entry keeps the valid level.
|
|
119
|
-
const thinking = record["thinking"];
|
|
120
|
-
const advisorThinking = record["advisorThinking"];
|
|
121
|
-
const model: OmpSessionModel = {
|
|
122
|
-
selector: record["selector"],
|
|
123
|
-
...(isNonBlankString(thinking) ? { thinking } : {}),
|
|
124
|
-
...(isNonBlankString(advisorThinking) ? { advisorThinking } : {}),
|
|
125
|
-
};
|
|
126
|
-
return model;
|
|
127
100
|
}
|
|
128
101
|
|
|
129
102
|
export function writeOmpSessionModel(home: string, model: OmpSessionModel): void {
|
|
@@ -132,15 +105,16 @@ export function writeOmpSessionModel(home: string, model: OmpSessionModel): void
|
|
|
132
105
|
if (!isNonBlankString(model.selector)) {
|
|
133
106
|
throw new Error("Omp session model selector must be a non-empty string");
|
|
134
107
|
}
|
|
135
|
-
//
|
|
136
|
-
// no stale level behind
|
|
137
|
-
const thinking = model.thinking;
|
|
138
|
-
const advisorThinking = model.advisorThinking;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
108
|
+
// Store blank or absent levels as NULL so a switch to a level-free model
|
|
109
|
+
// leaves no stale level behind.
|
|
110
|
+
const thinking = nonBlankOrNull(model.thinking);
|
|
111
|
+
const advisorThinking = nonBlankOrNull(model.advisorThinking);
|
|
112
|
+
withKitDb(home, "write", (db) =>
|
|
113
|
+
db
|
|
114
|
+
.prepare(
|
|
115
|
+
`INSERT INTO omp_session (id, selector, thinking, advisor_thinking) VALUES (1, ?, ?, ?)
|
|
116
|
+
ON CONFLICT(id) DO UPDATE SET selector = excluded.selector, thinking = excluded.thinking, advisor_thinking = excluded.advisor_thinking`,
|
|
117
|
+
)
|
|
118
|
+
.run(model.selector, thinking, advisorThinking),
|
|
119
|
+
);
|
|
146
120
|
}
|
|
@@ -48,7 +48,7 @@ export async function runEngineNative(
|
|
|
48
48
|
ctx = makeCtx(runServices);
|
|
49
49
|
switch (argv[0]) {
|
|
50
50
|
case "model":
|
|
51
|
-
return modeModel(ctx, argv.slice(1));
|
|
51
|
+
return await modeModel(ctx, argv.slice(1));
|
|
52
52
|
case "toolchain":
|
|
53
53
|
return await modeToolchain(ctx, argv.slice(1));
|
|
54
54
|
case "sync":
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-machine kit store at ~/.docks-kit/kit.db. This module is the only code
|
|
3
|
+
* that opens the database: it applies the schema migrations, imports the
|
|
4
|
+
* legacy ~/.docks-kit/state.json once, and serves the network lookup cache.
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
|
|
9
|
+
import { GENERATED_PACKAGE_VERSION } from "../generated/sotPayload";
|
|
10
|
+
import { p } from "./exec";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Append-only. Never edit or reorder a shipped entry. A release that changes the
|
|
14
|
+
* schema appends one SQL string. State tables (harness_selection, omp_session)
|
|
15
|
+
* get data-preserving migrations; cache_entry may be dropped and recreated
|
|
16
|
+
* because every row can be refetched.
|
|
17
|
+
*/
|
|
18
|
+
const MIGRATIONS: ReadonlyArray<string> = [
|
|
19
|
+
`CREATE TABLE harness_selection (
|
|
20
|
+
harness TEXT PRIMARY KEY CHECK (harness IN ('claude','codex','agents','omp'))
|
|
21
|
+
) STRICT, WITHOUT ROWID;
|
|
22
|
+
CREATE TABLE omp_session (
|
|
23
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
24
|
+
selector TEXT NOT NULL CHECK (trim(selector) <> ''),
|
|
25
|
+
thinking TEXT CHECK (thinking IS NULL OR trim(thinking) <> ''),
|
|
26
|
+
advisor_thinking TEXT CHECK (advisor_thinking IS NULL OR trim(advisor_thinking) <> '')
|
|
27
|
+
) STRICT;
|
|
28
|
+
CREATE TABLE cache_entry (
|
|
29
|
+
key TEXT PRIMARY KEY,
|
|
30
|
+
fingerprint TEXT NOT NULL,
|
|
31
|
+
fetched_at INTEGER NOT NULL,
|
|
32
|
+
payload TEXT NOT NULL
|
|
33
|
+
) STRICT, WITHOUT ROWID;`,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const KIT_DB_SCHEMA_VERSION: number = MIGRATIONS.length;
|
|
37
|
+
|
|
38
|
+
export class KitDbTooNewError extends Error {}
|
|
39
|
+
|
|
40
|
+
export function kitDbFile(home: string): string {
|
|
41
|
+
return p(home, ".docks-kit", "kit.db");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function userVersion(db: DatabaseSync): number {
|
|
45
|
+
const version = db.prepare("PRAGMA user_version").get()?.["user_version"];
|
|
46
|
+
return typeof version === "number" ? version : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run fn inside BEGIN IMMEDIATE; roll back and rethrow on any error. */
|
|
50
|
+
export function inTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|
51
|
+
db.exec("BEGIN IMMEDIATE");
|
|
52
|
+
try {
|
|
53
|
+
const result = fn();
|
|
54
|
+
db.exec("COMMIT");
|
|
55
|
+
return result;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
try {
|
|
58
|
+
db.exec("ROLLBACK");
|
|
59
|
+
} catch {
|
|
60
|
+
// SQLite already rolled back (for example on SQLITE_FULL); keep the original error.
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tooNew(version: number): KitDbTooNewError {
|
|
67
|
+
return new KitDbTooNewError(
|
|
68
|
+
`~/.docks-kit/kit.db uses schema ${version}; this docks-kit knows schema ${KIT_DB_SCHEMA_VERSION}. Upgrade docks-kit (docks-kit update).`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Returns true when this call applied migrations. */
|
|
73
|
+
function migrate(db: DatabaseSync): boolean {
|
|
74
|
+
const current = userVersion(db);
|
|
75
|
+
if (current > KIT_DB_SCHEMA_VERSION) throw tooNew(current);
|
|
76
|
+
if (current === KIT_DB_SCHEMA_VERSION) return false;
|
|
77
|
+
return inTransaction(db, () => {
|
|
78
|
+
// Another process, possibly a newer kit, can migrate between the first
|
|
79
|
+
// read and the lock; never lower its version.
|
|
80
|
+
const locked = userVersion(db);
|
|
81
|
+
if (locked > KIT_DB_SCHEMA_VERSION) throw tooNew(locked);
|
|
82
|
+
if (locked === KIT_DB_SCHEMA_VERSION) return false;
|
|
83
|
+
for (const sql of MIGRATIONS.slice(locked)) db.exec(sql);
|
|
84
|
+
db.exec(`PRAGMA user_version = ${KIT_DB_SCHEMA_VERSION}`);
|
|
85
|
+
return true;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface LegacyState {
|
|
90
|
+
readonly harnesses: ReadonlyArray<string>;
|
|
91
|
+
readonly session?: { selector: string; thinking: string | null; advisorThinking: string | null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isNonBlankString(value: unknown): value is string {
|
|
95
|
+
return typeof value === "string" && value.trim() !== "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A blank or non-string value is stored as NULL. */
|
|
99
|
+
export function nonBlankOrNull(value: unknown): string | null {
|
|
100
|
+
return isNonBlankString(value) ? value : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Same validation the JSON store applied: root object, version 1, non-blank
|
|
104
|
+
// selector and levels. Unknown or duplicate harness names are dropped by the
|
|
105
|
+
// table's CHECK and PRIMARY KEY through INSERT OR IGNORE.
|
|
106
|
+
function parseLegacyState(file: string): LegacyState | undefined {
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
114
|
+
if (!("version" in parsed) || parsed.version !== 1) return undefined;
|
|
115
|
+
|
|
116
|
+
const rawHarnesses = "harnesses" in parsed ? parsed.harnesses : undefined;
|
|
117
|
+
const harnesses = Array.isArray(rawHarnesses)
|
|
118
|
+
? rawHarnesses.filter((value): value is string => typeof value === "string")
|
|
119
|
+
: [];
|
|
120
|
+
const entry = "ompSession" in parsed ? parsed.ompSession : undefined;
|
|
121
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return { harnesses };
|
|
122
|
+
const selector = nonBlankOrNull("selector" in entry ? entry.selector : undefined);
|
|
123
|
+
if (selector === null) return { harnesses };
|
|
124
|
+
return {
|
|
125
|
+
harnesses,
|
|
126
|
+
session: {
|
|
127
|
+
selector,
|
|
128
|
+
thinking: nonBlankOrNull("thinking" in entry ? entry.thinking : undefined),
|
|
129
|
+
advisorThinking: nonBlankOrNull(
|
|
130
|
+
"advisorThinking" in entry ? entry.advisorThinking : undefined,
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function importLegacyState(db: DatabaseSync, legacy: string): void {
|
|
137
|
+
inTransaction(db, () => {
|
|
138
|
+
const rows = db
|
|
139
|
+
.prepare(
|
|
140
|
+
"SELECT (SELECT count(*) FROM harness_selection) + (SELECT count(*) FROM omp_session) AS n",
|
|
141
|
+
)
|
|
142
|
+
.get()?.["n"];
|
|
143
|
+
if (rows !== 0) return;
|
|
144
|
+
const state = parseLegacyState(legacy);
|
|
145
|
+
if (state === undefined) return;
|
|
146
|
+
const insertHarness = db.prepare(
|
|
147
|
+
"INSERT OR IGNORE INTO harness_selection (harness) VALUES (?)",
|
|
148
|
+
);
|
|
149
|
+
for (const harness of state.harnesses) insertHarness.run(harness);
|
|
150
|
+
if (state.session !== undefined) {
|
|
151
|
+
db.prepare(
|
|
152
|
+
"INSERT INTO omp_session (id, selector, thinking, advisor_thinking) VALUES (1, ?, ?, ?)",
|
|
153
|
+
).run(state.session.selector, state.session.thinking, state.session.advisorThinking);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
// An invalid file is renamed too, so no later run parses it again.
|
|
157
|
+
try {
|
|
158
|
+
renameSync(legacy, `${legacy}.migrated`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// A parallel process renamed it first.
|
|
161
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : "";
|
|
162
|
+
if (code !== "ENOENT") throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Open kit.db, migrate it, import the legacy state file once, and run fn.
|
|
168
|
+
* A read with neither kit.db nor state.json present creates nothing and
|
|
169
|
+
* returns undefined.
|
|
170
|
+
*/
|
|
171
|
+
export function withKitDb<T>(
|
|
172
|
+
home: string,
|
|
173
|
+
access: "read" | "write",
|
|
174
|
+
fn: (db: DatabaseSync) => T,
|
|
175
|
+
): T | undefined {
|
|
176
|
+
const file = kitDbFile(home);
|
|
177
|
+
const legacy = p(home, ".docks-kit", "state.json");
|
|
178
|
+
if (access === "read" && !existsSync(file) && !existsSync(legacy)) return undefined;
|
|
179
|
+
|
|
180
|
+
const directory = p(home, ".docks-kit");
|
|
181
|
+
// `mode` applies only when mkdir creates the path, so an existing permissive
|
|
182
|
+
// ~/.docks-kit would keep its mode.
|
|
183
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
184
|
+
chmodSync(directory, 0o700);
|
|
185
|
+
|
|
186
|
+
const db = new DatabaseSync(file);
|
|
187
|
+
try {
|
|
188
|
+
// SQLite gives the -wal and -shm files the mode of the main file.
|
|
189
|
+
if (process.platform !== "win32") chmodSync(file, 0o600);
|
|
190
|
+
// busy_timeout first: the switch to WAL needs a lock.
|
|
191
|
+
db.exec(
|
|
192
|
+
"PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON;",
|
|
193
|
+
);
|
|
194
|
+
const migrated = migrate(db);
|
|
195
|
+
const importing = existsSync(legacy);
|
|
196
|
+
if (importing) importLegacyState(db, legacy);
|
|
197
|
+
const result = fn(db);
|
|
198
|
+
// Fold the WAL into kit.db so a copy of kit.db alone holds every row.
|
|
199
|
+
// Reads can migrate or import too, so they checkpoint after writing.
|
|
200
|
+
if (access === "write" || migrated || importing) {
|
|
201
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
} finally {
|
|
205
|
+
db.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A new kit release refetches every cached row.
|
|
210
|
+
const CACHE_FINGERPRINT = GENERATED_PACKAGE_VERSION;
|
|
211
|
+
|
|
212
|
+
/** Return a fresh cached payload; every failure is a miss. */
|
|
213
|
+
export function readCache(
|
|
214
|
+
home: string,
|
|
215
|
+
key: string,
|
|
216
|
+
maxAgeMs: number,
|
|
217
|
+
now: number,
|
|
218
|
+
): string | undefined {
|
|
219
|
+
try {
|
|
220
|
+
return withKitDb(home, "read", (db) => {
|
|
221
|
+
const row = db
|
|
222
|
+
.prepare("SELECT fingerprint, fetched_at, payload FROM cache_entry WHERE key = ?")
|
|
223
|
+
.get(key);
|
|
224
|
+
const fetchedAt = row?.["fetched_at"];
|
|
225
|
+
const payload = row?.["payload"];
|
|
226
|
+
if (row?.["fingerprint"] !== CACHE_FINGERPRINT) return undefined;
|
|
227
|
+
if (typeof fetchedAt !== "number" || typeof payload !== "string") return undefined;
|
|
228
|
+
return now - fetchedAt <= maxAgeMs ? payload : undefined;
|
|
229
|
+
});
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Store a successful lookup; a failed write does nothing. */
|
|
236
|
+
export function writeCache(home: string, key: string, payload: string, now: number): void {
|
|
237
|
+
try {
|
|
238
|
+
withKitDb(home, "write", (db) =>
|
|
239
|
+
inTransaction(db, () => {
|
|
240
|
+
db.prepare("DELETE FROM cache_entry WHERE fingerprint <> ?").run(CACHE_FINGERPRINT);
|
|
241
|
+
db.prepare(
|
|
242
|
+
`INSERT INTO cache_entry (key, fingerprint, fetched_at, payload) VALUES (?, ?, ?, ?)
|
|
243
|
+
ON CONFLICT(key) DO UPDATE SET fingerprint = excluded.fingerprint, fetched_at = excluded.fetched_at, payload = excluded.payload`,
|
|
244
|
+
).run(key, CACHE_FINGERPRINT, now, payload);
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
} catch {
|
|
248
|
+
// The cache is an optimization; the next run fetches again.
|
|
249
|
+
}
|
|
250
|
+
}
|