kimi-plugin-cc 2.4.5 → 2.5.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +103 -29
- package/dist/commands/setup.js +78 -51
- package/dist/dsh/companion.js +177 -0
- package/dist/dsh/index.js +169 -0
- package/dist/hooks/install-paths.js +2 -2
- package/dist/hooks/managed-block.js +86 -20
- package/dist/version.js +1 -1
- package/docs/migration.md +4 -2
- package/docs/models.md +5 -1
- package/docs/registry-releases.md +84 -20
- package/docs/safety.md +25 -10
- package/docs/upstream-2.4.1-certification.md +6 -0
- package/package.json +75 -8
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/commands/setup.js +78 -51
- package/plugins/kimi-codex/dist/dsh/companion.js +177 -0
- package/plugins/kimi-codex/dist/dsh/index.js +169 -0
- package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
- package/plugins/kimi-codex/dist/hooks/managed-block.js +86 -20
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-dsh/LICENSE +201 -0
- package/plugins/kimi-dsh/README.md +83 -0
- package/plugins/kimi-dsh/cordis.patch.yml +45 -0
- package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
- package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
- package/plugins/kimi-dsh/dist/cli-client.js +876 -0
- package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
- package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
- package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
- package/plugins/kimi-dsh/dist/commands/models.js +140 -0
- package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
- package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
- package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
- package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
- package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
- package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
- package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
- package/plugins/kimi-dsh/dist/commands/result.js +66 -0
- package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
- package/plugins/kimi-dsh/dist/commands/review.js +220 -0
- package/plugins/kimi-dsh/dist/commands/setup.js +1074 -0
- package/plugins/kimi-dsh/dist/commands/status.js +27 -0
- package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
- package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
- package/plugins/kimi-dsh/dist/companion.js +97 -0
- package/plugins/kimi-dsh/dist/config.js +22 -0
- package/plugins/kimi-dsh/dist/dsh/companion.js +177 -0
- package/plugins/kimi-dsh/dist/dsh/index.js +169 -0
- package/plugins/kimi-dsh/dist/errors.js +40 -0
- package/plugins/kimi-dsh/dist/git.js +224 -0
- package/plugins/kimi-dsh/dist/help.js +43 -0
- package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
- package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
- package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
- package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
- package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
- package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
- package/plugins/kimi-dsh/dist/hooks/managed-block.js +685 -0
- package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
- package/plugins/kimi-dsh/dist/job-id.js +12 -0
- package/plugins/kimi-dsh/dist/job-store.js +659 -0
- package/plugins/kimi-dsh/dist/jobs.js +217 -0
- package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
- package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
- package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
- package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
- package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
- package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
- package/plugins/kimi-dsh/dist/logging.js +22 -0
- package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
- package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
- package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
- package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
- package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
- package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
- package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
- package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
- package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
- package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
- package/plugins/kimi-dsh/dist/model-command.js +32 -0
- package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
- package/plugins/kimi-dsh/dist/package.json +1 -0
- package/plugins/kimi-dsh/dist/parsing.js +620 -0
- package/plugins/kimi-dsh/dist/paths.js +27 -0
- package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
- package/plugins/kimi-dsh/dist/process-tree.js +479 -0
- package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
- package/plugins/kimi-dsh/dist/render.js +177 -0
- package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
- package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
- package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
- package/plugins/kimi-dsh/dist/session-title.js +293 -0
- package/plugins/kimi-dsh/dist/stream-json.js +346 -0
- package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
- package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
- package/plugins/kimi-dsh/dist/types.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
- package/plugins/kimi-dsh/dist/version.js +7 -0
- package/plugins/kimi-dsh/package.json +78 -0
- package/plugins/kimi-dsh/scripts/companion.sh +37 -0
- package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { runReviewGateStopHook } from "../commands/review-gate.js";
|
|
3
|
+
import { formatError } from "../errors.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const raw = readFileSync(0, "utf8");
|
|
6
|
+
const input = JSON.parse(raw);
|
|
7
|
+
const context = {
|
|
8
|
+
cwd: process.env.KIMI_PLUGIN_CC_WORKSPACE_CWD || process.cwd(),
|
|
9
|
+
env: process.env,
|
|
10
|
+
stdout: process.stdout,
|
|
11
|
+
stderr: process.stderr,
|
|
12
|
+
};
|
|
13
|
+
const output = await runReviewGateStopHook(input, context);
|
|
14
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
15
|
+
}
|
|
16
|
+
main().catch((error) => {
|
|
17
|
+
process.stderr.write(`${formatError(error)}\n`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { RuntimeError } from "./errors.js";
|
|
3
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
|
+
export function allocateJobId(context) {
|
|
5
|
+
const fixed = context.mcpJobId;
|
|
6
|
+
if (fixed === undefined)
|
|
7
|
+
return randomUUID();
|
|
8
|
+
if (!UUID_PATTERN.test(fixed)) {
|
|
9
|
+
throw new RuntimeError("MCP_INVALID_JOB_ID", "The supervised job ID must be a valid UUID.", "mcp.child");
|
|
10
|
+
}
|
|
11
|
+
return fixed;
|
|
12
|
+
}
|
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { RuntimeError, formatError } from "./errors.js";
|
|
3
|
+
export class JobStore {
|
|
4
|
+
db;
|
|
5
|
+
constructor(paths) {
|
|
6
|
+
// Open the adapter into a local before assigning to this.db so the
|
|
7
|
+
// catch can release the handle even if pragma throws AFTER the
|
|
8
|
+
// adapter opened — `journal_mode = WAL` on a read-only mount, for
|
|
9
|
+
// example, would otherwise leak the handle because the constructor
|
|
10
|
+
// exits via throw before any caller reference exists for cleanup.
|
|
11
|
+
let db;
|
|
12
|
+
try {
|
|
13
|
+
db = createSqliteAdapter(paths.stateDbPath);
|
|
14
|
+
// busy_timeout FIRST: switching the journal mode takes a brief write lock,
|
|
15
|
+
// and a second opener (status/result racing a worker) otherwise fails
|
|
16
|
+
// immediately with SQLITE_BUSY instead of waiting (seen 2026-10-14).
|
|
17
|
+
db.pragma("busy_timeout = 5000");
|
|
18
|
+
db.pragma("journal_mode = WAL");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
try {
|
|
22
|
+
db?.close();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Best-effort — original error takes priority over close failure.
|
|
26
|
+
}
|
|
27
|
+
throw translateSqliteError(error);
|
|
28
|
+
}
|
|
29
|
+
this.db = db;
|
|
30
|
+
// Every `this.db.exec` / `tableHasColumn` below can throw on a
|
|
31
|
+
// corrupt DB, full disk, mid-migration failure, etc. Catch
|
|
32
|
+
// internally, close the adapter, and rethrow the translated error
|
|
33
|
+
// so the SQLite handle never leaks past a failed migration.
|
|
34
|
+
try {
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
37
|
+
job_id TEXT PRIMARY KEY,
|
|
38
|
+
repo_id TEXT NOT NULL,
|
|
39
|
+
command_type TEXT NOT NULL,
|
|
40
|
+
created_at TEXT NOT NULL,
|
|
41
|
+
updated_at TEXT NOT NULL,
|
|
42
|
+
cwd TEXT NOT NULL,
|
|
43
|
+
model TEXT,
|
|
44
|
+
thinking INTEGER,
|
|
45
|
+
background INTEGER NOT NULL,
|
|
46
|
+
pid INTEGER,
|
|
47
|
+
kimi_pid INTEGER,
|
|
48
|
+
status TEXT NOT NULL,
|
|
49
|
+
kimi_session_id TEXT,
|
|
50
|
+
operation_kind TEXT,
|
|
51
|
+
intended_engine TEXT,
|
|
52
|
+
observed_engine TEXT,
|
|
53
|
+
kimi_version TEXT,
|
|
54
|
+
system_version TEXT,
|
|
55
|
+
kimi_command TEXT,
|
|
56
|
+
kimi_prefix_args TEXT,
|
|
57
|
+
plan_certification TEXT,
|
|
58
|
+
resumed_from_job_id TEXT,
|
|
59
|
+
safety_profile TEXT,
|
|
60
|
+
agent_profile TEXT NOT NULL,
|
|
61
|
+
prompt_digest TEXT NOT NULL,
|
|
62
|
+
summary TEXT NOT NULL,
|
|
63
|
+
phase TEXT,
|
|
64
|
+
budget_ms INTEGER,
|
|
65
|
+
final_output_path TEXT,
|
|
66
|
+
stream_log_path TEXT NOT NULL,
|
|
67
|
+
error TEXT
|
|
68
|
+
);
|
|
69
|
+
`);
|
|
70
|
+
if (!tableHasColumn(this.db, "phase")) {
|
|
71
|
+
this.db.exec(`ALTER TABLE jobs ADD COLUMN phase TEXT;`);
|
|
72
|
+
}
|
|
73
|
+
if (!tableHasColumn(this.db, "budget_ms")) {
|
|
74
|
+
this.db.exec(`ALTER TABLE jobs ADD COLUMN budget_ms INTEGER;`);
|
|
75
|
+
}
|
|
76
|
+
for (const column of ["kimi_session_id_source", "budget_source", "thinking_effort", "hook_escapes"]) {
|
|
77
|
+
if (!tableHasColumn(this.db, column)) {
|
|
78
|
+
this.db.exec("ALTER TABLE jobs ADD COLUMN " + column + " TEXT;");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Additive provenance migration. Existing rows deliberately remain NULL:
|
|
82
|
+
// v1.9.5 plus kimi-code 0.33/0.34 could have routed to native v2 before the
|
|
83
|
+
// legacy pin landed, so a blanket historical v1 backfill would invent fact.
|
|
84
|
+
const provenanceColumns = [
|
|
85
|
+
"operation_kind",
|
|
86
|
+
"intended_engine",
|
|
87
|
+
"observed_engine",
|
|
88
|
+
"kimi_version",
|
|
89
|
+
"system_version",
|
|
90
|
+
"kimi_command",
|
|
91
|
+
"kimi_prefix_args",
|
|
92
|
+
"plan_certification",
|
|
93
|
+
"resumed_from_job_id",
|
|
94
|
+
"safety_profile",
|
|
95
|
+
];
|
|
96
|
+
for (const column of provenanceColumns) {
|
|
97
|
+
if (!tableHasColumn(this.db, column)) {
|
|
98
|
+
this.db.exec(`ALTER TABLE jobs ADD COLUMN ${column} TEXT;`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.db.exec(`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS mcp_tasks (
|
|
103
|
+
task_id TEXT PRIMARY KEY,
|
|
104
|
+
operation_kind TEXT NOT NULL,
|
|
105
|
+
repo_id TEXT NOT NULL,
|
|
106
|
+
workspace_cwd TEXT NOT NULL,
|
|
107
|
+
companion_pid INTEGER,
|
|
108
|
+
companion_identity TEXT,
|
|
109
|
+
kimi_identity TEXT,
|
|
110
|
+
stdout_path TEXT NOT NULL,
|
|
111
|
+
stderr_path TEXT NOT NULL,
|
|
112
|
+
launch_state TEXT NOT NULL,
|
|
113
|
+
launch_error TEXT,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
attached_at TEXT,
|
|
116
|
+
updated_at TEXT NOT NULL
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
CREATE INDEX IF NOT EXISTS mcp_tasks_workspace_updated_idx
|
|
120
|
+
ON mcp_tasks (workspace_cwd, updated_at DESC);
|
|
121
|
+
|
|
122
|
+
CREATE INDEX IF NOT EXISTS jobs_repo_updated_idx
|
|
123
|
+
ON jobs (repo_id, updated_at DESC);
|
|
124
|
+
|
|
125
|
+
CREATE TABLE IF NOT EXISTS swarm_recovery (job_id TEXT PRIMARY KEY, payload TEXT NOT NULL, recovery_owner TEXT);
|
|
126
|
+
|
|
127
|
+
CREATE INDEX IF NOT EXISTS jobs_repo_type_updated_idx
|
|
128
|
+
ON jobs (repo_id, command_type, updated_at DESC);
|
|
129
|
+
|
|
130
|
+
CREATE INDEX IF NOT EXISTS jobs_session_id_idx ON jobs (kimi_session_id);
|
|
131
|
+
|
|
132
|
+
CREATE INDEX IF NOT EXISTS jobs_session_idx
|
|
133
|
+
ON jobs (repo_id, kimi_session_id, updated_at DESC);
|
|
134
|
+
|
|
135
|
+
DROP INDEX IF EXISTS jobs_running_rescue_session_idx;
|
|
136
|
+
|
|
137
|
+
-- Reconcile orphaned duplicate running rows before creating the unique
|
|
138
|
+
-- partial index. A 0.1.3 database with a hard-crashed worker can contain
|
|
139
|
+
-- multiple (repo_id, command_type, kimi_session_id) rows at status='running',
|
|
140
|
+
-- which would fail the CREATE UNIQUE INDEX at open and brick the store.
|
|
141
|
+
-- Keep the most recently updated row per group; mark the rest as failed.
|
|
142
|
+
UPDATE jobs
|
|
143
|
+
SET status = 'failed',
|
|
144
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
145
|
+
error = COALESCE(
|
|
146
|
+
error,
|
|
147
|
+
'{"code":"JOB_STORE_ORPHANED_ON_UPGRADE","message":"Orphaned duplicate running job reconciled on 0.1.4 schema upgrade","stage":"job-store.migrate"}'
|
|
148
|
+
)
|
|
149
|
+
WHERE status = 'running'
|
|
150
|
+
AND command_type IN ('rescue', 'ask')
|
|
151
|
+
AND kimi_session_id IS NOT NULL
|
|
152
|
+
AND rowid NOT IN (
|
|
153
|
+
SELECT rowid FROM (
|
|
154
|
+
SELECT rowid,
|
|
155
|
+
ROW_NUMBER() OVER (
|
|
156
|
+
PARTITION BY repo_id, command_type, kimi_session_id
|
|
157
|
+
ORDER BY updated_at DESC, rowid DESC
|
|
158
|
+
) AS rn
|
|
159
|
+
FROM jobs
|
|
160
|
+
WHERE status = 'running'
|
|
161
|
+
AND command_type IN ('rescue', 'ask')
|
|
162
|
+
AND kimi_session_id IS NOT NULL
|
|
163
|
+
)
|
|
164
|
+
WHERE rn = 1
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
CREATE UNIQUE INDEX IF NOT EXISTS jobs_running_session_idx
|
|
168
|
+
ON jobs (repo_id, command_type, kimi_session_id)
|
|
169
|
+
WHERE status = 'running' AND command_type IN ('rescue', 'ask');
|
|
170
|
+
|
|
171
|
+
-- 0.1.6 migration: rename adversarial_review to challenge
|
|
172
|
+
UPDATE jobs SET command_type = 'challenge' WHERE command_type = 'adversarial_review';
|
|
173
|
+
`);
|
|
174
|
+
if (!mcpTableHasColumn(this.db, "kimi_identity")) {
|
|
175
|
+
this.db.exec("ALTER TABLE mcp_tasks ADD COLUMN kimi_identity TEXT;");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
// Migration failed — close the adapter so the OS handle is released
|
|
180
|
+
// before propagating. Wrap close() in its own try so a double-fault
|
|
181
|
+
// doesn't mask the original migration error.
|
|
182
|
+
try {
|
|
183
|
+
this.db.close();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// intentionally swallow — original error takes priority
|
|
187
|
+
}
|
|
188
|
+
throw translateSqliteError(error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
getSwarmRecovery(jobId) {
|
|
192
|
+
const row = this.db.get("SELECT payload, recovery_owner FROM swarm_recovery WHERE job_id = ?", jobId);
|
|
193
|
+
return row ? { record: JSON.parse(row.payload), owner: row.recovery_owner } : null;
|
|
194
|
+
}
|
|
195
|
+
saveSwarmRecovery(jobId, record) {
|
|
196
|
+
this.db.run("INSERT INTO swarm_recovery (job_id, payload) VALUES (?, ?) ON CONFLICT(job_id) DO UPDATE SET payload = excluded.payload", [jobId, JSON.stringify(record)]);
|
|
197
|
+
}
|
|
198
|
+
claimSwarmRecovery(jobId, expected, owner) {
|
|
199
|
+
this.db.run("UPDATE swarm_recovery SET recovery_owner = ? WHERE job_id = ? AND recovery_owner IS ?", [owner, jobId, expected]);
|
|
200
|
+
return this.db.get("SELECT changes() AS count").count === 1;
|
|
201
|
+
}
|
|
202
|
+
close() {
|
|
203
|
+
this.db.close();
|
|
204
|
+
}
|
|
205
|
+
createJob(input) {
|
|
206
|
+
const now = new Date().toISOString();
|
|
207
|
+
try {
|
|
208
|
+
this.db.run(`
|
|
209
|
+
INSERT INTO jobs (
|
|
210
|
+
job_id, repo_id, command_type, created_at, updated_at, cwd, model, thinking, thinking_effort,
|
|
211
|
+
background, pid, kimi_pid, status, kimi_session_id, kimi_session_id_source, budget_source,
|
|
212
|
+
operation_kind, intended_engine, observed_engine, kimi_version, system_version,
|
|
213
|
+
kimi_command, kimi_prefix_args, plan_certification, resumed_from_job_id, safety_profile,
|
|
214
|
+
agent_profile, prompt_digest, summary, phase, budget_ms, final_output_path, stream_log_path, error
|
|
215
|
+
)
|
|
216
|
+
VALUES (
|
|
217
|
+
@job_id, @repo_id, @command_type, @created_at, @updated_at, @cwd, @model, @thinking, @thinking_effort,
|
|
218
|
+
@background, @pid, @kimi_pid, @status, @kimi_session_id, @kimi_session_id_source, @budget_source,
|
|
219
|
+
@operation_kind, @intended_engine, @observed_engine, @kimi_version, @system_version,
|
|
220
|
+
@kimi_command, @kimi_prefix_args, @plan_certification, @resumed_from_job_id, @safety_profile,
|
|
221
|
+
@agent_profile, @prompt_digest, @summary, @phase, @budget_ms, @final_output_path, @stream_log_path, @error
|
|
222
|
+
)
|
|
223
|
+
`, {
|
|
224
|
+
...serializeRecord(input),
|
|
225
|
+
created_at: now,
|
|
226
|
+
updated_at: now,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (isSqliteConstraintError(error)) {
|
|
231
|
+
if (input.command_type === "rescue") {
|
|
232
|
+
throw new RuntimeError("RESCUE_ALREADY_RUNNING", `A rescue job for session ${input.kimi_session_id ?? "<unknown>"} is already running in this repo. Use /kimi:status to find it.`, "rescue.resume", error instanceof Error ? { cause: error } : undefined);
|
|
233
|
+
}
|
|
234
|
+
if (input.command_type === "ask") {
|
|
235
|
+
throw new RuntimeError("ASK_ALREADY_RUNNING", `An ask session ${input.kimi_session_id ?? "<unknown>"} is already running in this repo. Use /kimi:status to find it.`, "ask.resume", error instanceof Error ? { cause: error } : undefined);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw translateSqliteError(error);
|
|
239
|
+
}
|
|
240
|
+
return this.getJob(input.job_id);
|
|
241
|
+
}
|
|
242
|
+
getJob(jobId) {
|
|
243
|
+
const row = this.db.get("SELECT * FROM jobs WHERE job_id = ?", jobId);
|
|
244
|
+
return row ? hydrateRow(row) : null;
|
|
245
|
+
}
|
|
246
|
+
/** Global across repos and terminal states; optionally exclude the current row. */
|
|
247
|
+
hasSessionId(sessionId, exceptJobId = "") {
|
|
248
|
+
return Boolean(this.db.get("SELECT 1 FROM jobs WHERE kimi_session_id = ? AND job_id != ? LIMIT 1", sessionId, exceptJobId));
|
|
249
|
+
}
|
|
250
|
+
findLatestJob(options) {
|
|
251
|
+
const conditions = ["repo_id = @repo_id"];
|
|
252
|
+
const params = {
|
|
253
|
+
repo_id: options.repoId,
|
|
254
|
+
};
|
|
255
|
+
if (options.commandType) {
|
|
256
|
+
conditions.push("command_type = @command_type");
|
|
257
|
+
params.command_type = options.commandType;
|
|
258
|
+
}
|
|
259
|
+
if (options.terminalOnly) {
|
|
260
|
+
conditions.push("status IN ('completed', 'failed', 'cancelled')");
|
|
261
|
+
}
|
|
262
|
+
if (options.runningOnly) {
|
|
263
|
+
conditions.push("status = 'running'");
|
|
264
|
+
}
|
|
265
|
+
const row = this.db.get(`SELECT * FROM jobs WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC LIMIT 1`, params);
|
|
266
|
+
return row ? hydrateRow(row) : null;
|
|
267
|
+
}
|
|
268
|
+
listRunningJobsWithProcessHints() {
|
|
269
|
+
const rows = this.db.all(`SELECT * FROM jobs WHERE status = 'running' AND (pid IS NOT NULL OR kimi_pid IS NOT NULL)`);
|
|
270
|
+
return rows.map(hydrateRow);
|
|
271
|
+
}
|
|
272
|
+
findRescueJobBySession(repoId, sessionId) {
|
|
273
|
+
const row = this.db.get(`
|
|
274
|
+
SELECT *
|
|
275
|
+
FROM jobs
|
|
276
|
+
WHERE repo_id = ?
|
|
277
|
+
AND command_type = 'rescue'
|
|
278
|
+
AND kimi_session_id = ?
|
|
279
|
+
ORDER BY updated_at DESC
|
|
280
|
+
LIMIT 1
|
|
281
|
+
`, repoId, sessionId);
|
|
282
|
+
return row ? hydrateRow(row) : null;
|
|
283
|
+
}
|
|
284
|
+
findAskJobBySession(repoId, sessionId) {
|
|
285
|
+
const row = this.db.get(`
|
|
286
|
+
SELECT *
|
|
287
|
+
FROM jobs
|
|
288
|
+
WHERE repo_id = ?
|
|
289
|
+
AND command_type = 'ask'
|
|
290
|
+
AND kimi_session_id = ?
|
|
291
|
+
ORDER BY updated_at DESC
|
|
292
|
+
LIMIT 1
|
|
293
|
+
`, repoId, sessionId);
|
|
294
|
+
return row ? hydrateRow(row) : null;
|
|
295
|
+
}
|
|
296
|
+
createMcpTask(input) {
|
|
297
|
+
const now = new Date().toISOString();
|
|
298
|
+
try {
|
|
299
|
+
this.db.run(`
|
|
300
|
+
INSERT INTO mcp_tasks (
|
|
301
|
+
task_id, operation_kind, repo_id, workspace_cwd, companion_pid, companion_identity, kimi_identity,
|
|
302
|
+
stdout_path, stderr_path, launch_state, launch_error, created_at, attached_at, updated_at
|
|
303
|
+
) VALUES (
|
|
304
|
+
@task_id, @operation_kind, @repo_id, @workspace_cwd, NULL, NULL, NULL,
|
|
305
|
+
@stdout_path, @stderr_path, 'starting', NULL, @created_at, NULL, @updated_at
|
|
306
|
+
)
|
|
307
|
+
`, { ...input, created_at: now, updated_at: now });
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw translateSqliteError(error);
|
|
311
|
+
}
|
|
312
|
+
return this.getMcpTask(input.task_id);
|
|
313
|
+
}
|
|
314
|
+
getMcpTask(taskId) {
|
|
315
|
+
return this.db.get("SELECT * FROM mcp_tasks WHERE task_id = ?", taskId) ?? null;
|
|
316
|
+
}
|
|
317
|
+
updateMcpTask(taskId, patch) {
|
|
318
|
+
const updates = ["updated_at = @updated_at"];
|
|
319
|
+
const params = { task_id: taskId, updated_at: new Date().toISOString() };
|
|
320
|
+
for (const key of ["companion_pid", "companion_identity", "kimi_identity", "launch_state", "launch_error", "attached_at"]) {
|
|
321
|
+
const value = patch[key];
|
|
322
|
+
if (value === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
updates.push(`${key} = @${key}`);
|
|
325
|
+
params[key] = value;
|
|
326
|
+
}
|
|
327
|
+
this.db.run(`UPDATE mcp_tasks SET ${updates.join(", ")} WHERE task_id = @task_id`, params);
|
|
328
|
+
return this.getMcpTask(taskId);
|
|
329
|
+
}
|
|
330
|
+
updateRunningJob(jobId, patch) {
|
|
331
|
+
return this.updateWhere(jobId, patch, "status = 'running'");
|
|
332
|
+
}
|
|
333
|
+
/** Recheck uniqueness atomically: another worker can claim an ID during the
|
|
334
|
+
* asynchronous directory scan. Ordinary announced/resumed IDs may be shared. */
|
|
335
|
+
claimRecoveredSessionId(jobId, sessionId) {
|
|
336
|
+
this.db.run(`UPDATE jobs
|
|
337
|
+
SET kimi_session_id = @session_id, kimi_session_id_source = 'recovered_session_dir',
|
|
338
|
+
updated_at = @updated_at
|
|
339
|
+
WHERE job_id = @job_id AND status = 'running' AND kimi_session_id IS NULL
|
|
340
|
+
AND NOT EXISTS (SELECT 1 FROM jobs WHERE kimi_session_id = @session_id AND job_id != @job_id)`, { job_id: jobId, session_id: sessionId, updated_at: new Date().toISOString() });
|
|
341
|
+
const job = this.getJob(jobId);
|
|
342
|
+
return job?.status === "running" && job.kimi_session_id === sessionId &&
|
|
343
|
+
job.kimi_session_id_source === "recovered_session_dir" ? job : null;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Evidence-only backfill for pre-provenance rows. COALESCE prevents forensic
|
|
347
|
+
* inspection from overwriting a field already recorded at execution time.
|
|
348
|
+
* updated_at is intentionally unchanged so a status lookup cannot reorder
|
|
349
|
+
* historical jobs merely because their logs were inspected.
|
|
350
|
+
*/
|
|
351
|
+
backfillHistoricalProvenance(jobId, patch) {
|
|
352
|
+
this.db.run(`
|
|
353
|
+
UPDATE jobs
|
|
354
|
+
SET operation_kind = COALESCE(operation_kind, @operation_kind),
|
|
355
|
+
observed_engine = COALESCE(observed_engine, @observed_engine),
|
|
356
|
+
kimi_version = COALESCE(kimi_version, @kimi_version),
|
|
357
|
+
system_version = COALESCE(system_version, @system_version)
|
|
358
|
+
WHERE job_id = @job_id
|
|
359
|
+
`, {
|
|
360
|
+
job_id: jobId,
|
|
361
|
+
operation_kind: patch.operation_kind ?? null,
|
|
362
|
+
observed_engine: patch.observed_engine ?? null,
|
|
363
|
+
kimi_version: patch.kimi_version ?? null,
|
|
364
|
+
system_version: patch.system_version ?? null,
|
|
365
|
+
});
|
|
366
|
+
return this.getJob(jobId);
|
|
367
|
+
}
|
|
368
|
+
markCompleted(jobId, patch) {
|
|
369
|
+
return this.updateWhere(jobId, { ...patch, status: "completed", pid: null, kimi_pid: null }, "status = 'running'");
|
|
370
|
+
}
|
|
371
|
+
markFailed(jobId, patch) {
|
|
372
|
+
return this.updateWhere(jobId, { ...patch, status: "failed", pid: null, kimi_pid: null }, "status = 'running'");
|
|
373
|
+
}
|
|
374
|
+
markCancelled(jobId, patch) {
|
|
375
|
+
return this.updateWhere(jobId, { ...patch, status: "cancelled", pid: null, kimi_pid: null }, "status = 'running'");
|
|
376
|
+
}
|
|
377
|
+
static JOBS_COLUMNS = new Set([
|
|
378
|
+
"status", "summary", "phase", "budget_ms", "budget_source", "kimi_session_id_source", "pid", "kimi_pid",
|
|
379
|
+
"final_output_path", "stream_log_path", "kimi_session_id",
|
|
380
|
+
"error", "command_type", "prompt_digest", "repo_id",
|
|
381
|
+
"cwd", "model", "thinking", "thinking_effort", "hook_escapes", "background", "agent_profile",
|
|
382
|
+
"operation_kind", "intended_engine", "observed_engine", "kimi_version",
|
|
383
|
+
"system_version", "kimi_command", "kimi_prefix_args", "plan_certification",
|
|
384
|
+
"resumed_from_job_id", "safety_profile",
|
|
385
|
+
]);
|
|
386
|
+
updateWhere(jobId, patch, whereClause) {
|
|
387
|
+
const updates = ["updated_at = @updated_at"];
|
|
388
|
+
const params = {
|
|
389
|
+
job_id: jobId,
|
|
390
|
+
updated_at: new Date().toISOString(),
|
|
391
|
+
};
|
|
392
|
+
for (const [key, value] of Object.entries(serializePatch(patch))) {
|
|
393
|
+
if (key === "job_id" || key === "created_at" || key === "updated_at" || value === undefined) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (!JobStore.JOBS_COLUMNS.has(key))
|
|
397
|
+
continue;
|
|
398
|
+
updates.push(`${key} = @${key}`);
|
|
399
|
+
params[key] = value;
|
|
400
|
+
}
|
|
401
|
+
const statement = `
|
|
402
|
+
UPDATE jobs
|
|
403
|
+
SET ${updates.join(", ")}
|
|
404
|
+
WHERE job_id = @job_id${whereClause ? ` AND ${whereClause}` : ""}
|
|
405
|
+
`;
|
|
406
|
+
this.db.run(statement, params);
|
|
407
|
+
return this.getJob(jobId);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function hydrateRow(row) {
|
|
411
|
+
return {
|
|
412
|
+
...row,
|
|
413
|
+
thinking: row.thinking === null ? null : Boolean(row.thinking),
|
|
414
|
+
background: Boolean(row.background),
|
|
415
|
+
error: row.error ? JSON.parse(row.error) : null,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function serializeRecord(record) {
|
|
419
|
+
return {
|
|
420
|
+
...record,
|
|
421
|
+
thinking_effort: record.thinking_effort ?? null,
|
|
422
|
+
phase: record.phase ?? null,
|
|
423
|
+
budget_ms: record.budget_ms ?? null,
|
|
424
|
+
budget_source: record.budget_source ?? null,
|
|
425
|
+
kimi_session_id_source: record.kimi_session_id_source ?? null,
|
|
426
|
+
operation_kind: record.operation_kind ?? null,
|
|
427
|
+
intended_engine: record.intended_engine ?? null,
|
|
428
|
+
observed_engine: record.observed_engine ?? null,
|
|
429
|
+
kimi_version: record.kimi_version ?? null,
|
|
430
|
+
system_version: record.system_version ?? null,
|
|
431
|
+
kimi_command: record.kimi_command ?? null,
|
|
432
|
+
kimi_prefix_args: record.kimi_prefix_args ?? null,
|
|
433
|
+
plan_certification: record.plan_certification ?? null,
|
|
434
|
+
resumed_from_job_id: record.resumed_from_job_id ?? null,
|
|
435
|
+
safety_profile: record.safety_profile ?? null,
|
|
436
|
+
thinking: record.thinking === null ? null : Number(record.thinking),
|
|
437
|
+
background: Number(record.background),
|
|
438
|
+
error: record.error ? JSON.stringify(record.error) : null,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function serializePatch(patch) {
|
|
442
|
+
const result = {};
|
|
443
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
444
|
+
if (key === "thinking") {
|
|
445
|
+
result[key] = value === null ? null : Number(Boolean(value));
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (key === "background") {
|
|
449
|
+
result[key] = Number(Boolean(value));
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (key === "error") {
|
|
453
|
+
result[key] = value ? JSON.stringify(value) : null;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
result[key] = value;
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
function createSqliteAdapter(filename) {
|
|
461
|
+
const require = createRequire(import.meta.url);
|
|
462
|
+
if (typeof Bun !== "undefined") {
|
|
463
|
+
const { Database } = require("bun:sqlite");
|
|
464
|
+
const db = new Database(filename);
|
|
465
|
+
return {
|
|
466
|
+
pragma(statement) {
|
|
467
|
+
db.exec(`PRAGMA ${statement}`);
|
|
468
|
+
},
|
|
469
|
+
exec(statement) {
|
|
470
|
+
db.exec(statement);
|
|
471
|
+
},
|
|
472
|
+
run(statement, params) {
|
|
473
|
+
const query = db.query(rewriteNamedParamsForBun(statement));
|
|
474
|
+
if (Array.isArray(params)) {
|
|
475
|
+
query.run(...params);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
query.run(rewriteNamedBindingsForBun((params ?? {})));
|
|
479
|
+
},
|
|
480
|
+
get(statement, ...params) {
|
|
481
|
+
const query = db.query(rewriteNamedParamsForBun(statement));
|
|
482
|
+
if (params.length === 1) {
|
|
483
|
+
const first = params[0];
|
|
484
|
+
return query.get(typeof first === "object" && first !== null && !Array.isArray(first)
|
|
485
|
+
? rewriteNamedBindingsForBun(first)
|
|
486
|
+
: first);
|
|
487
|
+
}
|
|
488
|
+
return query.get(...params);
|
|
489
|
+
},
|
|
490
|
+
all(statement, ...params) {
|
|
491
|
+
const query = db.query(rewriteNamedParamsForBun(statement));
|
|
492
|
+
if (params.length === 1) {
|
|
493
|
+
const first = params[0];
|
|
494
|
+
return query.all(typeof first === "object" && first !== null && !Array.isArray(first)
|
|
495
|
+
? rewriteNamedBindingsForBun(first)
|
|
496
|
+
: first);
|
|
497
|
+
}
|
|
498
|
+
return query.all(...params);
|
|
499
|
+
},
|
|
500
|
+
close() {
|
|
501
|
+
db.close();
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
// Node 22.5+ ships node:sqlite as a built-in. We use it via createRequire so the compiled
|
|
506
|
+
// dist/ has zero native dependencies — no `bun install` step required for drop-in plugin
|
|
507
|
+
// installs, no platform-specific compiled binaries to ship. See package.json engines.node.
|
|
508
|
+
let nodeSqlite;
|
|
509
|
+
try {
|
|
510
|
+
nodeSqlite = require("node:sqlite");
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
throw new RuntimeError("JOB_STORE_UNSUPPORTED_RUNTIME", "kimi-plugin-cc requires Node >= 22.5 for its built-in node:sqlite module. Upgrade Node, or set KIMI_PLUGIN_CC_NODE_BIN to a qualifying binary.", "job-store", error instanceof Error ? { cause: error } : undefined);
|
|
514
|
+
}
|
|
515
|
+
const db = new nodeSqlite.DatabaseSync(filename);
|
|
516
|
+
return {
|
|
517
|
+
pragma(statement) {
|
|
518
|
+
db.exec(`PRAGMA ${statement}`);
|
|
519
|
+
},
|
|
520
|
+
exec(statement) {
|
|
521
|
+
db.exec(statement);
|
|
522
|
+
},
|
|
523
|
+
run(statement, params) {
|
|
524
|
+
// Cast through `any` so the prepared-statement methods are called as methods (preserving
|
|
525
|
+
// `this`). Pulling them off the instance via `const fn = stmt.run` strips the binding and
|
|
526
|
+
// node:sqlite throws "Illegal invocation".
|
|
527
|
+
const stmt = db.prepare(statement);
|
|
528
|
+
if (Array.isArray(params)) {
|
|
529
|
+
stmt.run(...params);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
stmt.run(normalizeNodeSqliteBindings((params ?? {})));
|
|
533
|
+
},
|
|
534
|
+
get(statement, ...params) {
|
|
535
|
+
const stmt = db.prepare(statement);
|
|
536
|
+
if (params.length === 1 && isPlainObject(params[0])) {
|
|
537
|
+
return stmt.get(normalizeNodeSqliteBindings(params[0]));
|
|
538
|
+
}
|
|
539
|
+
return stmt.get(...params);
|
|
540
|
+
},
|
|
541
|
+
all(statement, ...params) {
|
|
542
|
+
const stmt = db.prepare(statement);
|
|
543
|
+
if (params.length === 1 && isPlainObject(params[0])) {
|
|
544
|
+
return stmt.all(normalizeNodeSqliteBindings(params[0]));
|
|
545
|
+
}
|
|
546
|
+
return stmt.all(...params);
|
|
547
|
+
},
|
|
548
|
+
close() {
|
|
549
|
+
db.close();
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
// node:sqlite's prepared-statement binding API accepts objects whose keys omit the parameter
|
|
554
|
+
// prefix (so SQL `@job_id` binds from `{ job_id: ... }`). Normalize optional values to null,
|
|
555
|
+
// coerce booleans to 0/1, and fail fast on unsupported binding types before they reach the
|
|
556
|
+
// native layer as opaque SQLITE_MISMATCH errors.
|
|
557
|
+
function normalizeNodeSqliteBindings(bindings) {
|
|
558
|
+
const result = {};
|
|
559
|
+
for (const [key, value] of Object.entries(bindings)) {
|
|
560
|
+
if (value === undefined) {
|
|
561
|
+
result[key] = null;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (value === null) {
|
|
565
|
+
result[key] = null;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (typeof value === "boolean") {
|
|
569
|
+
result[key] = value ? 1 : 0;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (typeof value === "string" ||
|
|
573
|
+
typeof value === "number" ||
|
|
574
|
+
typeof value === "bigint" ||
|
|
575
|
+
value instanceof Uint8Array) {
|
|
576
|
+
result[key] = value;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
throw new Error(`node:sqlite binding for ${key} has unsupported type ${typeof value} (${value?.constructor?.name ?? "unknown"})`);
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
function isPlainObject(value) {
|
|
584
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
585
|
+
}
|
|
586
|
+
function rewriteNamedParamsForBun(statement) {
|
|
587
|
+
return statement.replaceAll(/@([A-Za-z0-9_]+)/g, (_, key) => `$${key}`);
|
|
588
|
+
}
|
|
589
|
+
function rewriteNamedBindingsForBun(bindings) {
|
|
590
|
+
return Object.fromEntries(Object.entries(bindings).map(([key, value]) => [`$${key}`, value]));
|
|
591
|
+
}
|
|
592
|
+
function tableHasColumn(db, columnName) {
|
|
593
|
+
const columns = db.all(`PRAGMA table_info(jobs)`);
|
|
594
|
+
return columns.some((column) => column.name === columnName);
|
|
595
|
+
}
|
|
596
|
+
function mcpTableHasColumn(db, columnName) {
|
|
597
|
+
return db.all("PRAGMA table_info(mcp_tasks)").some((column) => column.name === columnName);
|
|
598
|
+
}
|
|
599
|
+
function isSqliteConstraintError(error) {
|
|
600
|
+
const message = formatError(error).toLowerCase();
|
|
601
|
+
return message.includes("unique constraint") || message.includes("constraint failed");
|
|
602
|
+
}
|
|
603
|
+
function isSqliteBusyError(error) {
|
|
604
|
+
const message = formatError(error).toLowerCase();
|
|
605
|
+
return message.includes("database is locked") || message.includes("busy");
|
|
606
|
+
}
|
|
607
|
+
function translateSqliteError(error) {
|
|
608
|
+
if (isSqliteBusyError(error)) {
|
|
609
|
+
return new RuntimeError("JOB_STORE_BUSY", "The plugin job store is locked by another process. Wait a moment and retry, or check for stuck rescue workers with /kimi:status.", "job-store", error instanceof Error ? { cause: error } : undefined);
|
|
610
|
+
}
|
|
611
|
+
if (error instanceof RuntimeError) {
|
|
612
|
+
return error;
|
|
613
|
+
}
|
|
614
|
+
return new RuntimeError("JOB_STORE_ERROR", `Job store operation failed: ${formatError(error)}`, "job-store", error instanceof Error ? { cause: error } : undefined);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Run a function with a temporary `JobStore`, closing its handle on return or
|
|
618
|
+
* throw after construction succeeds. The constructor owns cleanup when opening
|
|
619
|
+
* or migrating the store fails.
|
|
620
|
+
*/
|
|
621
|
+
export async function withJobStore(paths, fn) {
|
|
622
|
+
// Keep construction inside try: the constructor cleans up its own failures,
|
|
623
|
+
// while this finally closes every successfully constructed store.
|
|
624
|
+
let store;
|
|
625
|
+
try {
|
|
626
|
+
store = new JobStore(paths);
|
|
627
|
+
return await fn(store);
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
store?.close();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Synchronous companion to `withJobStore` for callers that don't await inside. */
|
|
634
|
+
export function withJobStoreSync(paths, fn) {
|
|
635
|
+
let store;
|
|
636
|
+
try {
|
|
637
|
+
store = new JobStore(paths);
|
|
638
|
+
return fn(store);
|
|
639
|
+
}
|
|
640
|
+
finally {
|
|
641
|
+
store?.close();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/** Parsed `hook_escapes` column for JSON envelopes: [] when NULL or unreadable. */
|
|
645
|
+
export function parseHookEscapes(raw) {
|
|
646
|
+
if (!raw)
|
|
647
|
+
return [];
|
|
648
|
+
try {
|
|
649
|
+
const parsed = JSON.parse(raw);
|
|
650
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** Old non-null budgets have unknown provenance; never invent explicit intent. */
|
|
657
|
+
export function renderBudgetSource(job) {
|
|
658
|
+
return job.budget_source ?? (job.budget_ms === null ? "default" : "unknown");
|
|
659
|
+
}
|