@cmgmyr/hive 1.4.0 → 1.5.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/dist/build-info.json +1 -1
- package/dist/cli.js +60 -2
- package/dist/context.js +5 -3
- package/dist/help.js +5 -2
- package/dist/projectRemove.js +68 -0
- package/dist/queenAudit.js +14 -0
- package/dist/tmux.js +17 -6
- package/dist/tools/meta.js +40 -28
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.
|
|
1
|
+
{"version":"1.5.0","sha":"c050e7f","dirty":false,"build_id":"56840ccc-881f-49ae-bbc4-440224077976"}
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { checkoutUpgradeSteps, detectInstallShape, globalUpgradeSteps } from "./
|
|
|
19
19
|
import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
|
|
20
20
|
import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
|
|
21
21
|
import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
|
|
22
|
+
import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
|
|
22
23
|
import { ensureHooksFile } from "./hooks.js";
|
|
23
24
|
import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
|
|
24
25
|
import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
|
|
@@ -95,6 +96,9 @@ Usage:
|
|
|
95
96
|
hive todo <id> print one todo in full, comments included
|
|
96
97
|
hive backups list automatic store snapshots
|
|
97
98
|
hive restore <name> [--yes] [--force] overwrite the live store from a snapshot
|
|
99
|
+
hive project rm <id|path> [--yes]
|
|
100
|
+
remove another project and everything it owns,
|
|
101
|
+
after a snapshot; never the one you are in
|
|
98
102
|
hive runbook this project's standing process, vars resolved
|
|
99
103
|
hive posture the posture text this project's lead starts with
|
|
100
104
|
hive kickoff [--explain] SessionStart hook output; silent unless this is a lead checkout
|
|
@@ -1877,7 +1881,7 @@ function reportProjectScope(here) {
|
|
|
1877
1881
|
if (locked && project.id !== here?.id)
|
|
1878
1882
|
continue;
|
|
1879
1883
|
if (project.path === homePath || project.path === "/") {
|
|
1880
|
-
warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. The project_prune tool with this project's id (from
|
|
1884
|
+
warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. The project_prune tool with this project's id (from an unlocked session in another project) removes it once it holds nothing.`);
|
|
1881
1885
|
}
|
|
1882
1886
|
}
|
|
1883
1887
|
for (const parent of projects) {
|
|
@@ -2887,6 +2891,57 @@ function cmdPortfolio(argv) {
|
|
|
2887
2891
|
const l = report.totals.lanes;
|
|
2888
2892
|
console.log(`\n${report.totals.projects} project(s): ${l.waiting_on_you} waiting on you, ${l.stuck} stuck, ${l.moving} moving, ${l.quiet} quiet`);
|
|
2889
2893
|
}
|
|
2894
|
+
async function cmdProject(argv) {
|
|
2895
|
+
const parsed = parseArgs(argv, { flags: ["--yes", "-y"] });
|
|
2896
|
+
rejectUnknownFlags("project", parsed, "--yes/-y");
|
|
2897
|
+
const [verb, ref] = parsed.positional;
|
|
2898
|
+
if (verb !== "rm" || !ref || parsed.positional.length > 2) {
|
|
2899
|
+
console.log("Usage: hive project rm <id|path> [--yes]");
|
|
2900
|
+
process.exit(1);
|
|
2901
|
+
}
|
|
2902
|
+
if (process.env.HIVE_PROJECT_LOCK === "1" || agentProjectPin() != null) {
|
|
2903
|
+
console.log("hive project rm: this session is locked to one project (HIVE_PROJECT_LOCK=1) and cannot remove projects.");
|
|
2904
|
+
process.exit(1);
|
|
2905
|
+
}
|
|
2906
|
+
let target;
|
|
2907
|
+
if (/^\d+$/.test(ref))
|
|
2908
|
+
target = getProject(Number(ref));
|
|
2909
|
+
else
|
|
2910
|
+
target = getProjectByPath(ref) ?? getProjectByPath(canonicalPath(ref));
|
|
2911
|
+
if (!target) {
|
|
2912
|
+
console.log(`hive project rm: "${ref}" is not a registered project id or exact registered path. List them with the project_list tool.`);
|
|
2913
|
+
process.exit(1);
|
|
2914
|
+
}
|
|
2915
|
+
if (findProjectForCwd()?.id === target.id) {
|
|
2916
|
+
console.log(`hive project rm: project ${target.id} ("${target.name}") is the one this directory belongs to and is never removed from itself. Run it from another project.`);
|
|
2917
|
+
process.exit(1);
|
|
2918
|
+
}
|
|
2919
|
+
assertQueenCrossProjectWrite("hive project rm", target.id, {});
|
|
2920
|
+
const counts = projectRowCounts(target.id);
|
|
2921
|
+
console.log(`Project ${target.id}: "${target.name}" (${target.path})`);
|
|
2922
|
+
console.log(Object.keys(counts).length > 0 ? `It owns ${formatRowCounts(counts)}.` : "It owns no rows.");
|
|
2923
|
+
console.log("A snapshot of the store is taken first. Restore it with: hive restore <name>");
|
|
2924
|
+
if (!parsed.flags.has("--yes") && !parsed.flags.has("-y")) {
|
|
2925
|
+
const confirmed = await confirmYesNo("Remove this project and everything it owns? [y/N] ");
|
|
2926
|
+
if (confirmed === null) {
|
|
2927
|
+
console.log("Not removed: run hive interactively to confirm, or pass --yes.");
|
|
2928
|
+
process.exit(1);
|
|
2929
|
+
}
|
|
2930
|
+
if (!confirmed) {
|
|
2931
|
+
console.log("Not removed.");
|
|
2932
|
+
process.exit(1);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
const removed = removeProject(target.id, {
|
|
2936
|
+
snapshot: true,
|
|
2937
|
+
onRemoved: (r) => confirmQueenWrite("hive project rm", r.deleted.id, {}, { project_id: r.deleted.id, name: r.deleted.name, counts: r.counts }),
|
|
2938
|
+
});
|
|
2939
|
+
console.log(`Removed project ${removed.deleted.id} ("${removed.deleted.name}").`);
|
|
2940
|
+
if (Object.keys(removed.counts).length > 0)
|
|
2941
|
+
console.log(`Deleted ${formatRowCounts(removed.counts)}.`);
|
|
2942
|
+
console.log(`Snapshot: ${removed.snapshot}`);
|
|
2943
|
+
console.log("Its actors and any files it left on disk are untouched; actor_prune removes inert actors.");
|
|
2944
|
+
}
|
|
2890
2945
|
function cmdBackups() {
|
|
2891
2946
|
const snapshots = listSnapshots(dataDir);
|
|
2892
2947
|
if (snapshots.length === 0) {
|
|
@@ -3259,7 +3314,7 @@ if (command === "--version" || command === "-v") {
|
|
|
3259
3314
|
const COMMANDS = [
|
|
3260
3315
|
"lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "next", "setup", "upgrade", "doctor",
|
|
3261
3316
|
LEAD_PANE_EXITED_VERB,
|
|
3262
|
-
"pads", "pad", "todos", "todo", "backups", "restore", "runbook", "posture", "profile", "kickoff", "statusline",
|
|
3317
|
+
"pads", "pad", "todos", "todo", "backups", "restore", "project", "runbook", "posture", "profile", "kickoff", "statusline",
|
|
3263
3318
|
];
|
|
3264
3319
|
if (!COMMANDS.includes(command)) {
|
|
3265
3320
|
if (command.startsWith("--")) {
|
|
@@ -3344,6 +3399,9 @@ try {
|
|
|
3344
3399
|
case "restore":
|
|
3345
3400
|
await cmdRestore(rest);
|
|
3346
3401
|
break;
|
|
3402
|
+
case "project":
|
|
3403
|
+
await cmdProject(rest);
|
|
3404
|
+
break;
|
|
3347
3405
|
case "runbook":
|
|
3348
3406
|
cmdRunbook(rest[0]);
|
|
3349
3407
|
break;
|
package/dist/context.js
CHANGED
|
@@ -339,10 +339,11 @@ export const QUEEN_REACH = {
|
|
|
339
339
|
agent_close: "home",
|
|
340
340
|
project_select: "select",
|
|
341
341
|
project_add: "global",
|
|
342
|
-
project_prune: "
|
|
342
|
+
project_prune: "prune",
|
|
343
343
|
actor_prune: "global",
|
|
344
344
|
"hive lead": "spin_up",
|
|
345
345
|
"hive queen-audit": "read",
|
|
346
|
+
"hive project rm": "prune",
|
|
346
347
|
"hive init": "home",
|
|
347
348
|
"hive attach": "home",
|
|
348
349
|
"hive next": "home",
|
|
@@ -365,6 +366,7 @@ function foreignWriteProblem(reach, targetProjectId, args) {
|
|
|
365
366
|
switch (reach) {
|
|
366
367
|
case "spin_up":
|
|
367
368
|
case "todo":
|
|
369
|
+
case "prune":
|
|
368
370
|
return null;
|
|
369
371
|
case "lead_wake": {
|
|
370
372
|
if (args.scope !== undefined)
|
|
@@ -440,6 +442,6 @@ function queenRefusal(operation, targetProjectId, problem) {
|
|
|
440
442
|
const target = targetProjectId === null ? null : getProject(targetProjectId);
|
|
441
443
|
const where = target ? `in project ${target.id} ("${target.name}")` : "outside the queen's own project";
|
|
442
444
|
return new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
|
|
443
|
-
"queen may only todo_create, todo_comment, send text or wakes to its running lead,
|
|
444
|
-
"with `hive lead <path
|
|
445
|
+
"queen may only todo_create, todo_comment, send text or wakes to its running lead, start its lead " +
|
|
446
|
+
"with `hive lead <path>`, and run project_prune or `hive project rm` on it. Everything else there is its lead's to do.");
|
|
445
447
|
}
|
package/dist/help.js
CHANGED
|
@@ -252,10 +252,13 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
|
|
|
252
252
|
project_list — all registered projects plus the current selection
|
|
253
253
|
project_add(path?, name?) — register a directory (defaults to cwd)
|
|
254
254
|
project_select(project_id) — set this session's default scope
|
|
255
|
-
project_prune() — delete every registered project that owns no rows
|
|
255
|
+
project_prune(project_id?, confirm_name?) — delete every registered project that owns no rows
|
|
256
256
|
anywhere in the store, verified individually; never your own; refuses
|
|
257
257
|
under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours.
|
|
258
|
-
With project_id, removes only that project, and only if it owns no rows
|
|
258
|
+
With project_id, removes only that project, and only if it owns no rows.
|
|
259
|
+
With project_id and confirm_name (its exact name), removes it with every
|
|
260
|
+
row it owns after a snapshot; refuses while it has a running agent. From a
|
|
261
|
+
terminal: hive project rm <id|path> [--yes].
|
|
259
262
|
|
|
260
263
|
Resolution order: explicit project_id argument, then session selection, then
|
|
261
264
|
working-directory auto-detection. When the working directory matches no
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { backupNow } from "./backup.js";
|
|
2
|
+
import { dataDir, db } from "./db.js";
|
|
3
|
+
export const PROJECT_OWNER_TABLES = [
|
|
4
|
+
"pads",
|
|
5
|
+
"todos",
|
|
6
|
+
"kv",
|
|
7
|
+
"leases",
|
|
8
|
+
"agents",
|
|
9
|
+
"wakes",
|
|
10
|
+
"command_trust",
|
|
11
|
+
];
|
|
12
|
+
const COUNTED_TABLES = [
|
|
13
|
+
...PROJECT_OWNER_TABLES.map((t) => [t, `SELECT COUNT(*) AS n FROM ${t} WHERE project_id = ?`]),
|
|
14
|
+
["todo_comments", "SELECT COUNT(*) AS n FROM todo_comments WHERE todo_id IN (SELECT id FROM todos WHERE project_id = ?)"],
|
|
15
|
+
["agent_messages", "SELECT COUNT(*) AS n FROM agent_messages WHERE project_id = ?"],
|
|
16
|
+
];
|
|
17
|
+
export function projectRowCounts(projectId) {
|
|
18
|
+
const counts = {};
|
|
19
|
+
for (const [table, sql] of COUNTED_TABLES) {
|
|
20
|
+
const n = db.prepare(sql).get(projectId).n;
|
|
21
|
+
if (n > 0)
|
|
22
|
+
counts[table] = n;
|
|
23
|
+
}
|
|
24
|
+
return counts;
|
|
25
|
+
}
|
|
26
|
+
export function formatRowCounts(counts) {
|
|
27
|
+
return Object.entries(counts)
|
|
28
|
+
.map(([table, n]) => `${table}: ${n}`)
|
|
29
|
+
.join(", ");
|
|
30
|
+
}
|
|
31
|
+
function assertNoRunningAgents(projectId, name) {
|
|
32
|
+
const running = db
|
|
33
|
+
.prepare("SELECT id, name FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
34
|
+
.all(projectId);
|
|
35
|
+
if (running.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
const names = running.map((a) => `${a.name} (agent ${a.id})`).join(", ");
|
|
38
|
+
throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. Run hive doctor to close rows whose panes are gone, or agent_close them.`);
|
|
39
|
+
}
|
|
40
|
+
const deleteProjectRows = db.transaction((projectId, name, expected, after) => {
|
|
41
|
+
assertNoRunningAgents(projectId, name);
|
|
42
|
+
if (JSON.stringify(projectRowCounts(projectId)) !== JSON.stringify(expected)) {
|
|
43
|
+
throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
|
|
44
|
+
}
|
|
45
|
+
db.prepare("DELETE FROM agent_messages WHERE project_id = ?").run(projectId);
|
|
46
|
+
if (db.prepare("DELETE FROM projects WHERE id = ?").run(projectId).changes === 0) {
|
|
47
|
+
throw new Error(`no project ${projectId}.`);
|
|
48
|
+
}
|
|
49
|
+
after();
|
|
50
|
+
});
|
|
51
|
+
export function removeProject(projectId, opts) {
|
|
52
|
+
const target = db.prepare("SELECT id, name, path FROM projects WHERE id = ?").get(projectId);
|
|
53
|
+
if (!target)
|
|
54
|
+
throw new Error(`no project ${projectId}.`);
|
|
55
|
+
assertNoRunningAgents(projectId, target.name);
|
|
56
|
+
const counts = projectRowCounts(projectId);
|
|
57
|
+
let snapshot = null;
|
|
58
|
+
if (opts.snapshot) {
|
|
59
|
+
const result = backupNow(db, dataDir, "manual");
|
|
60
|
+
if (!result.ok)
|
|
61
|
+
throw new Error(`could not snapshot the store first, nothing deleted: ${result.error ?? "unknown error"}`);
|
|
62
|
+
snapshot = result.path ?? null;
|
|
63
|
+
}
|
|
64
|
+
opts.afterSnapshot?.();
|
|
65
|
+
const removed = { deleted: { id: target.id, name: target.name, path: target.path }, counts, snapshot };
|
|
66
|
+
deleteProjectRows.immediate(projectId, target.name, counts, () => opts.onRemoved?.(removed));
|
|
67
|
+
return removed;
|
|
68
|
+
}
|
package/dist/queenAudit.js
CHANGED
|
@@ -9,6 +9,10 @@ export const QUEEN_AUDIT_MAX_ROWS = 20_000;
|
|
|
9
9
|
function resource(type, value) {
|
|
10
10
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? { type, id: value } : null;
|
|
11
11
|
}
|
|
12
|
+
function removalSummary(r) {
|
|
13
|
+
const counts = Object.entries((r.counts ?? {})).map(([t, n]) => `${t} ${n}`);
|
|
14
|
+
return `removed project "${r.name}"${counts.length > 0 ? ` with ${counts.join(", ")}` : ""}`;
|
|
15
|
+
}
|
|
12
16
|
export const QUEEN_AUDIT_OPERATIONS = {
|
|
13
17
|
todo_create: {
|
|
14
18
|
mode: "database",
|
|
@@ -45,6 +49,16 @@ export const QUEEN_AUDIT_OPERATIONS = {
|
|
|
45
49
|
resource: (r) => r.sent === true ? resource("agent", r.agent_id) : null,
|
|
46
50
|
summary: (a, r) => `agent #${r.agent_id} ${a.submit === false ? "pasted without submit" : "submitted"}: ${a.text}`,
|
|
47
51
|
},
|
|
52
|
+
project_prune: {
|
|
53
|
+
mode: "database",
|
|
54
|
+
resource: (r) => resource("project", r.project_id),
|
|
55
|
+
summary: (_a, r) => removalSummary(r),
|
|
56
|
+
},
|
|
57
|
+
"hive project rm": {
|
|
58
|
+
mode: "database",
|
|
59
|
+
resource: (r) => resource("project", r.project_id),
|
|
60
|
+
summary: (_a, r) => removalSummary(r),
|
|
61
|
+
},
|
|
48
62
|
"hive lead": {
|
|
49
63
|
mode: "external",
|
|
50
64
|
resource: (r) => r.disposition === "started" || r.disposition === "adopted" ? resource("agent", r.agent_id) : null,
|
package/dist/tmux.js
CHANGED
|
@@ -1018,7 +1018,8 @@ export function paneChoiceCheck(target) {
|
|
|
1018
1018
|
// codex has no box border to anchor on, so the box's bottom edge used to be inferred from the literal
|
|
1019
1019
|
// text of its footer status line - but that text is not stable (todo 524: two different compositions
|
|
1020
1020
|
// seen on one codex version, one with no shared substring at all). The anchor below reads structure
|
|
1021
|
-
// instead of content: the pane's own last non-blank row IS the bottom edge, whatever it says
|
|
1021
|
+
// instead of content: the pane's own last non-blank row IS the bottom edge, whatever it says, except that a
|
|
1022
|
+
// "? for shortcuts" row or a right-aligned footer row pulls the edge up to the status row above it.
|
|
1022
1023
|
// codex's own choice-menu shape (see hive-internals), not claude's CHOICE_DIALOG wording. Declared
|
|
1023
1024
|
// above findCodexPromptBox because the box search itself now needs it to reject a choice row rather
|
|
1024
1025
|
// than mistake it for a live prompt.
|
|
@@ -1037,6 +1038,7 @@ function codexPromptContentStart(promptRow) {
|
|
|
1037
1038
|
i += 1;
|
|
1038
1039
|
return i;
|
|
1039
1040
|
}
|
|
1041
|
+
const CODEX_SHORTCUTS_ROW = /^\?\s+for shortcuts/;
|
|
1040
1042
|
function findCodexPromptBox(rows) {
|
|
1041
1043
|
const text = rows.map((row) => stripControlBytes(stripSgr(row)).trim());
|
|
1042
1044
|
let footer = text.length - 1;
|
|
@@ -1044,6 +1046,17 @@ function findCodexPromptBox(rows) {
|
|
|
1044
1046
|
footer -= 1;
|
|
1045
1047
|
if (footer < 0)
|
|
1046
1048
|
return null;
|
|
1049
|
+
// The row directly above "? for shortcuts" starts one footer block if it has codex's " · " status separator.
|
|
1050
|
+
if (CODEX_SHORTCUTS_ROW.test(text[footer]) && footer > 0 && text[footer - 1].includes(" · "))
|
|
1051
|
+
footer -= 1;
|
|
1052
|
+
else if (footer > 0 && text[footer - 1].includes(" · ")) {
|
|
1053
|
+
const footerRow = stripControlBytes(stripSgr(rows[footer]));
|
|
1054
|
+
const footerContent = footerRow.trim();
|
|
1055
|
+
const leadingWhitespace = footerRow.length - footerRow.trimStart().length;
|
|
1056
|
+
// Use the row's geometry so this remains independent of whatever Codex writes in its footer.
|
|
1057
|
+
if (leadingWhitespace > footerContent.length)
|
|
1058
|
+
footer -= 1;
|
|
1059
|
+
}
|
|
1047
1060
|
let prompt = -1;
|
|
1048
1061
|
for (let i = footer - 1; i >= 0 && footer - i <= BOX_MAX_ROWS; i--) {
|
|
1049
1062
|
// The arrow ALONE, against the trimmed row: capture-pane strips a trailing space, and a box with
|
|
@@ -1062,12 +1075,10 @@ function findCodexPromptBox(rows) {
|
|
|
1062
1075
|
// stale submitted prompt that also starts with "›").
|
|
1063
1076
|
if (CODEX_CHOICE_LINE.test(text[prompt]))
|
|
1064
1077
|
return null;
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
// box - a busy/mid-turn screen can have an old prompt sitting within BOX_MAX_ROWS of whatever text
|
|
1068
|
-
// is currently at the bottom, and only this check tells the two apart (see hive-internals).
|
|
1078
|
+
// Only indented continuation rows, then one blank gap, may sit between the prompt and footer.
|
|
1079
|
+
// Left-aligned output in that gap means the prompt is stale, not a live box (see hive-internals).
|
|
1069
1080
|
let i = prompt + 1;
|
|
1070
|
-
while (i <= footer && text[i] !== "")
|
|
1081
|
+
while (i <= footer && text[i] !== "" && /^\s/.test(stripControlBytes(stripSgr(rows[i]))))
|
|
1071
1082
|
i += 1;
|
|
1072
1083
|
while (i <= footer && text[i] === "")
|
|
1073
1084
|
i += 1;
|
package/dist/tools/meta.js
CHANGED
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
-
import { addProject, currentActor, listProjects, resolveProject, selectProjectById, TOUCH_INTERVAL_MS, trySelectedProject, } from "../context.js";
|
|
3
|
+
import { addProject, currentActor, listProjects, findProjectForCwd, resolveProject, selectProjectById, TOUCH_INTERVAL_MS, trySelectedProject, } from "../context.js";
|
|
4
|
+
import { confirmQueenWrite } from "../queenAudit.js";
|
|
4
5
|
import { errorMessage, run } from "../result.js";
|
|
5
6
|
import { HELP_TOPICS, helpOverview } from "../help.js";
|
|
6
7
|
import { idParam } from "./params.js";
|
|
8
|
+
import { formatRowCounts, PROJECT_OWNER_TABLES, projectRowCounts, removeProject } from "../projectRemove.js";
|
|
7
9
|
function refuseIfLocked(tool) {
|
|
8
10
|
if (process.env.HIVE_PROJECT_LOCK === "1") {
|
|
9
11
|
throw new Error(`${tool} sweeps the whole store, across every project and every actor. This session is locked to one project (HIVE_PROJECT_LOCK=1) and cannot run it. Run it from an unlocked session (a lead).`);
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
|
-
export const PROJECT_OWNER_TABLES = [
|
|
13
|
-
"pads",
|
|
14
|
-
"todos",
|
|
15
|
-
"kv",
|
|
16
|
-
"leases",
|
|
17
|
-
"agents",
|
|
18
|
-
"wakes",
|
|
19
|
-
"command_trust",
|
|
20
|
-
];
|
|
21
14
|
function existsWhere(table, column, value) {
|
|
22
15
|
return db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ? LIMIT 1`).get(value) !== undefined;
|
|
23
16
|
}
|
|
24
17
|
function projectOwnsRows(projectId) {
|
|
25
18
|
return PROJECT_OWNER_TABLES.some((table) => existsWhere(table, "project_id", projectId));
|
|
26
19
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
const pruneProjectIfEmpty = db.transaction((projectId) => {
|
|
34
|
-
if (projectOwnsRows(projectId))
|
|
20
|
+
const pruneProjectIfEmpty = db.transaction((project) => {
|
|
21
|
+
if (projectOwnsRows(project.id))
|
|
22
|
+
return false;
|
|
23
|
+
if (db.prepare("DELETE FROM projects WHERE id = ?").run(project.id).changes === 0)
|
|
35
24
|
return false;
|
|
36
|
-
|
|
25
|
+
confirmQueenWrite("project_prune", project.id, {}, { project_id: project.id, name: project.name });
|
|
26
|
+
return true;
|
|
37
27
|
});
|
|
38
28
|
export const ACTOR_OWNER_COLUMNS = [
|
|
39
29
|
["agents", "actor_id"],
|
|
@@ -149,30 +139,52 @@ export function registerMeta(server) {
|
|
|
149
139
|
inputSchema: { project_id: idParam },
|
|
150
140
|
}, ({ project_id }) => run(() => selectProjectById(project_id)));
|
|
151
141
|
server.registerTool("project_prune", {
|
|
152
|
-
description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
|
|
142
|
+
description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. With project_id AND confirm_name (the project's exact name), removes that one project even when it owns rows: it takes a snapshot first, refuses while the project has a running agent, then deletes the project with everything it owns (todos, comments, pads, kv, leases, agents, wakes, trust, agent messages) in one transaction and reports per-table counts and the snapshot path. queen_audit rows naming it are kept, and so are other projects' lead-idle subscriptions that target it, so a watch on its lead ends with a named reason. confirm_name without project_id is refused, and there is no forced sweep. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
|
|
153
143
|
annotations: {
|
|
154
144
|
readOnlyHint: false,
|
|
155
145
|
destructiveHint: true,
|
|
156
146
|
idempotentHint: true,
|
|
157
147
|
openWorldHint: false,
|
|
158
148
|
},
|
|
159
|
-
inputSchema: { project_id: idParam.optional() },
|
|
149
|
+
inputSchema: { project_id: idParam.optional(), confirm_name: z.string().optional() },
|
|
160
150
|
outputSchema: {
|
|
161
151
|
deleted: z.array(z.object({ id: idParam, name: z.string() })),
|
|
162
152
|
held_back: z.object({ project_id: idParam, reason: z.string() }),
|
|
163
153
|
errors: z.array(z.object({ id: idParam, name: z.string(), error: z.string() })).optional(),
|
|
154
|
+
counts: z.record(z.string(), z.number()).optional(),
|
|
155
|
+
snapshot: z.string().nullable().optional(),
|
|
164
156
|
},
|
|
165
|
-
}, ({ project_id }) => run(() => {
|
|
157
|
+
}, ({ project_id, confirm_name }) => run(() => {
|
|
166
158
|
refuseIfLocked("project_prune");
|
|
167
159
|
const homeId = resolveProject().id;
|
|
160
|
+
const cwdId = findProjectForCwd()?.id;
|
|
161
|
+
const isOwn = (id) => id === homeId || id === cwdId;
|
|
162
|
+
if (confirm_name !== undefined && project_id === undefined) {
|
|
163
|
+
throw new Error("project_prune: confirm_name needs project_id; there is no forced sweep.");
|
|
164
|
+
}
|
|
168
165
|
if (project_id !== undefined) {
|
|
169
|
-
if (project_id
|
|
166
|
+
if (isOwn(project_id))
|
|
170
167
|
throw new Error(`project_prune: project ${project_id} is the caller's own project and is never pruned.`);
|
|
171
168
|
const target = listProjects().find((p) => p.id === project_id);
|
|
172
169
|
if (!target)
|
|
173
170
|
throw new Error(`project_prune: no project ${project_id}. List them with project_list.`);
|
|
174
|
-
if (
|
|
175
|
-
|
|
171
|
+
if (confirm_name !== undefined) {
|
|
172
|
+
if (confirm_name !== target.name) {
|
|
173
|
+
throw new Error(`project_prune: confirm_name does not match project ${project_id}, whose name is "${target.name}". Nothing deleted.`);
|
|
174
|
+
}
|
|
175
|
+
const removed = removeProject(project_id, {
|
|
176
|
+
snapshot: true,
|
|
177
|
+
onRemoved: (r) => confirmQueenWrite("project_prune", project_id, {}, { project_id, name: r.deleted.name, counts: r.counts }),
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
deleted: [{ id: removed.deleted.id, name: removed.deleted.name }],
|
|
181
|
+
held_back: { project_id: homeId, reason: "caller's own project" },
|
|
182
|
+
counts: removed.counts,
|
|
183
|
+
snapshot: removed.snapshot,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (!pruneProjectIfEmpty.immediate(target)) {
|
|
187
|
+
throw new Error(`project_prune: project ${project_id} ("${target.name}") is not empty, nothing deleted. It owns ${formatRowCounts(projectRowCounts(project_id))}. To remove it with everything it owns, pass confirm_name with its exact name.`);
|
|
176
188
|
}
|
|
177
189
|
return {
|
|
178
190
|
deleted: [{ id: target.id, name: target.name }],
|
|
@@ -182,10 +194,10 @@ export function registerMeta(server) {
|
|
|
182
194
|
const deleted = [];
|
|
183
195
|
const errors = [];
|
|
184
196
|
for (const project of listProjects()) {
|
|
185
|
-
if (project.id
|
|
197
|
+
if (isOwn(project.id))
|
|
186
198
|
continue;
|
|
187
199
|
try {
|
|
188
|
-
if (pruneProjectIfEmpty.immediate(project
|
|
200
|
+
if (pruneProjectIfEmpty.immediate(project)) {
|
|
189
201
|
deleted.push({ id: project.id, name: project.name });
|
|
190
202
|
}
|
|
191
203
|
}
|
|
@@ -200,7 +212,7 @@ export function registerMeta(server) {
|
|
|
200
212
|
};
|
|
201
213
|
}));
|
|
202
214
|
server.registerTool("actor_prune", {
|
|
203
|
-
description: "Delete every actor that owns no rows anywhere in the store and has not been active in the last minute: agents.actor_id, agents.parent_actor_id, todos.locked_by, todo_comments.author, kv.updated_by, leases.owner, pads.updated_by, wakes.owner, wakes.deliver_actor, agent_state_log.actor_id. The scan is global across every project, never scoped to the caller's: actors carry no project_id, so an actor can own rows in a project the caller cannot see, and a project-scoped scan would misread that actor as inert and delete it. Never prunes the caller's own actor. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep. Run this after project_prune when sweeping the store:
|
|
215
|
+
description: "Delete every actor that owns no rows anywhere in the store and has not been active in the last minute: agents.actor_id, agents.parent_actor_id, todos.locked_by, todo_comments.author, kv.updated_by, leases.owner, pads.updated_by, wakes.owner, wakes.deliver_actor, agent_state_log.actor_id. The scan is global across every project, never scoped to the caller's: actors carry no project_id, so an actor can own rows in a project the caller cannot see, and a project-scoped scan would misread that actor as inert and delete it. Never prunes the caller's own actor. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep. Run this after project_prune when sweeping the store: project_prune with confirm_name removes a non-empty project together with its agents rows, so run this afterwards to clear that project's actors that own nothing else. Actors with agent_state_log rows stay, because that log is append-only and has no project, which means every actor that ever ran a lead or worker stays. Immediate, permanent: no dry-run mode.",
|
|
204
216
|
annotations: {
|
|
205
217
|
readOnlyHint: false,
|
|
206
218
|
destructiveHint: true,
|
package/package.json
CHANGED