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.
@@ -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.startsWith("[")) inMarketplace = false;
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(/^"|"$/g, "");
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.startsWith("[")) {
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(`^${key}[ \\t]*=`);
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.startsWith("[")) {
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.startsWith("[")) break;
120
+ if (isTomlHeaderLine(line)) break;
117
121
  if (/^[ \t]*($|#)/.test(line)) continue;
118
- if (!/^[A-Za-z0-9_.-]+[ \t]*=/.test(line)) continue;
119
- const key = line.slice(0, line.indexOf("=")).replace(/[ \t]+$/, "");
120
- replaceTopLevelSettingInFile(userConfig, key, line);
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 at ~/.docks-kit/state.json. The selection keeps
3
- * the omp harness opt-in. A missing or unreadable state file is represented by
4
- * undefined so callers resolve it to LEGACY_SELECTION and existing machines
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 { p } from "./exec";
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
- export function harnessStateFile(home: string): string {
45
- return p(home, ".docks-kit", "state.json");
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
- parsed = JSON.parse(readFileSync(harnessStateFile(home), "utf8")) as unknown;
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
- writeWholeState(home, { harnesses });
100
- }
101
-
102
- function isNonBlankString(value: unknown): value is string {
103
- return typeof value === "string" && value.trim() !== "";
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 entry falls
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
- const state = readWholeState(home);
110
- if (state === undefined) return undefined;
111
- const entry = state["ompSession"];
112
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined;
113
- const record = entry as Record<string, unknown>;
114
- if (!isNonBlankString(record["selector"])) {
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
- // Persist only non-blank levels so a switch to a level-free model leaves
136
- // no stale level behind in the stored record.
137
- const thinking = model.thinking;
138
- const advisorThinking = model.advisorThinking;
139
- const entry: OmpSessionModel = {
140
- selector: model.selector,
141
- ...(isNonBlankString(thinking) ? { thinking } : {}),
142
- ...(isNonBlankString(advisorThinking) ? { advisorThinking } : {}),
143
- };
144
-
145
- writeWholeState(home, { ompSession: entry });
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
+ }