mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat: add compaction enforcement controls
This commit is contained in:
1 parent
fa81f15e06
commit
56858cbea5
19 files changed
+2185
-35
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface CompactionControlDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => Pick<DaemonClient, "get" | "post">;
|
||||
}
|
||||
|
||||
function defaultDeps(): CompactionControlDeps {
|
||||
return {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
}
|
||||
|
||||
async function getClient(deps: CompactionControlDeps) {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
function printResponse(
|
||||
response: { status: number; data: unknown },
|
||||
json: boolean | undefined,
|
||||
success: string,
|
||||
): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(response.data));
|
||||
} else if (response.status < 400) {
|
||||
console.log(success);
|
||||
} else {
|
||||
const data = response.data as { error?: string; message?: string };
|
||||
console.error(data.message ?? data.error ?? `Request failed (HTTP ${response.status})`);
|
||||
}
|
||||
if (response.status >= 400) process.exitCode = response.status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function compactionControlCommand(depsOverride?: CompactionControlDeps): Command {
|
||||
const command = new Command("compaction-control")
|
||||
.description("Create, inspect, and clear generation-scoped Claude compaction decisions");
|
||||
const deps = () => depsOverride ?? defaultDeps();
|
||||
|
||||
command.addCommand(
|
||||
new Command("hold")
|
||||
.argument("<session>", "Target Claude session")
|
||||
.requiredOption("--reason <text>", "Why automatic compaction must not act")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (session: string, options: { reason: string; json?: boolean }) => {
|
||||
const client = await getClient(deps());
|
||||
if (!client) return;
|
||||
const response = await client.post("/api/compaction/control", {
|
||||
session,
|
||||
direction: "hold",
|
||||
reason: options.reason,
|
||||
}, { headers: terminalAuthHeaders() });
|
||||
printResponse(response, options.json, `Compaction hold created for ${session}.`);
|
||||
}),
|
||||
);
|
||||
|
||||
command.addCommand(
|
||||
new Command("authorize")
|
||||
.argument("<session>", "Target Claude session")
|
||||
.requiredOption(
|
||||
"--automatic-reason <reason>",
|
||||
"One refusal to lift: disabled, post_restore_cooldown, or stale_generation",
|
||||
)
|
||||
.requiredOption("--reason <text>", "Why this one attempt is authorized")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
session: string,
|
||||
options: { automaticReason: string; reason: string; json?: boolean },
|
||||
) => {
|
||||
const client = await getClient(deps());
|
||||
if (!client) return;
|
||||
const response = await client.post("/api/compaction/control", {
|
||||
session,
|
||||
direction: "authorize",
|
||||
automaticReason: options.automaticReason,
|
||||
reason: options.reason,
|
||||
}, { headers: terminalAuthHeaders() });
|
||||
printResponse(response, options.json, `One compaction attempt authorized for ${session}.`);
|
||||
}),
|
||||
);
|
||||
|
||||
command.addCommand(
|
||||
new Command("list")
|
||||
.option("--session <session>", "Limit decisions to one session")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (options: { session?: string; json?: boolean }) => {
|
||||
const client = await getClient(deps());
|
||||
if (!client) return;
|
||||
const query = options.session ? `?session=${encodeURIComponent(options.session)}` : "";
|
||||
const response = await client.get(`/api/compaction/control${query}`, {
|
||||
headers: terminalAuthHeaders(),
|
||||
});
|
||||
printResponse(response, options.json, "Compaction decisions listed.");
|
||||
}),
|
||||
);
|
||||
|
||||
command.addCommand(
|
||||
new Command("clear")
|
||||
.argument("<decision-id>", "Decision ID")
|
||||
.requiredOption("--reason <text>", "Why the decision is being cleared")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (decisionId: string, options: { reason: string; json?: boolean }) => {
|
||||
const client = await getClient(deps());
|
||||
if (!client) return;
|
||||
const response = await client.post(
|
||||
`/api/compaction/control/${encodeURIComponent(decisionId)}/clear`,
|
||||
{ reason: options.reason },
|
||||
{ headers: terminalAuthHeaders() },
|
||||
);
|
||||
printResponse(response, options.json, `Compaction decision ${decisionId} cleared.`);
|
||||
}),
|
||||
);
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -72,7 +72,8 @@ Keys:
|
||||
workflow.* exception_routing (orchestrator | human_only — the maturity-dial host default)
|
||||
policies.claude_compaction.*
|
||||
enabled, threshold_percent, compact_instruction,
|
||||
message_inline, message_file_path
|
||||
message_inline, message_file_path,
|
||||
authorize_ttl_minutes
|
||||
policies.idle_gate_qitem.scan_interval_seconds
|
||||
policies.idle_gate_qitem.active_wake_interval_seconds
|
||||
snapshots.periodic.* enabled, interval_seconds, retention_keep
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface RiggedConfig {
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
postRestoreAuditInstruction: string;
|
||||
authorizeTtlMinutes: number;
|
||||
};
|
||||
idleGateQitem: {
|
||||
scanIntervalSeconds: number;
|
||||
@@ -233,6 +234,7 @@ const DEFAULTS = {
|
||||
messageInline: DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION,
|
||||
messageFilePath: DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH,
|
||||
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
|
||||
authorizeTtlMinutes: 15,
|
||||
},
|
||||
idleGateQitem: {
|
||||
scanIntervalSeconds: 60,
|
||||
@@ -326,6 +328,7 @@ export const VALID_KEYS = [
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
"policies.claude_compaction.authorize_ttl_minutes",
|
||||
"policies.idle_gate_qitem.scan_interval_seconds",
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds",
|
||||
"snapshots.periodic.enabled",
|
||||
@@ -397,6 +400,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
|
||||
"policies.claude_compaction.authorize_ttl_minutes": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES" },
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS" },
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS" },
|
||||
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
|
||||
@@ -456,6 +460,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
|
||||
"policies.claude_compaction.authorize_ttl_minutes": ["policies", "claudeCompaction", "authorizeTtlMinutes"],
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": ["policies", "idleGateQitem", "scanIntervalSeconds"],
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds": ["policies", "idleGateQitem", "activeWakeIntervalSeconds"],
|
||||
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
|
||||
@@ -635,6 +640,19 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
);
|
||||
}
|
||||
},
|
||||
"policies.claude_compaction.authorize_ttl_minutes": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed) || typeof coerced !== "number" || !Number.isInteger(coerced)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: expected an integer in [1, 60], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (coerced < 1 || coerced > 60) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: must be in [1, 60], got ${coerced}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
"snapshots.periodic.interval_seconds": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim())) {
|
||||
throw new Error(
|
||||
@@ -839,6 +857,7 @@ export class ConfigStore {
|
||||
messageInline: v("policies.claude_compaction.message_inline") as string,
|
||||
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
|
||||
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
|
||||
authorizeTtlMinutes: v("policies.claude_compaction.authorize_ttl_minutes") as number,
|
||||
},
|
||||
idleGateQitem: {
|
||||
scanIntervalSeconds: v("policies.idle_gate_qitem.scan_interval_seconds") as number,
|
||||
|
||||
@@ -77,6 +77,10 @@ import { restoreCheckCommand } from "./commands/restore-check.js";
|
||||
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
|
||||
import { compactCommand, type CompactDeps } from "./commands/compact.js";
|
||||
import {
|
||||
compactionControlCommand,
|
||||
type CompactionControlDeps,
|
||||
} from "./commands/compaction-control.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, handoverCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { rigPolicyCommand, type RigPolicyDeps } from "./commands/rig-policy.js";
|
||||
@@ -144,6 +148,7 @@ export interface ProgramDeps {
|
||||
restorePacketDeps?: RestorePacketDeps;
|
||||
compactPlanDeps?: CompactPlanDeps;
|
||||
compactDeps?: CompactDeps;
|
||||
compactionControlDeps?: CompactionControlDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
rigPolicyDeps?: RigPolicyDeps;
|
||||
@@ -235,6 +240,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
|
||||
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
|
||||
program.addCommand(compactCommand(depsOverride?.compactDeps));
|
||||
program.addCommand(compactionControlCommand(depsOverride?.compactionControlDeps));
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(handoverCommand(depsOverride?.seatDeps));
|
||||
|
||||
@@ -6,6 +6,7 @@ import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore, VALID_KEYS } from "../src/config-store.js";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
import { SETTINGS_VALID_KEYS } from "../../daemon/src/domain/user-settings/settings-store.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
@@ -426,3 +427,114 @@ describe("Config CLI", () => {
|
||||
expect(combined).toContain("policies.idle_gate_qitem.active_wake_interval_seconds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("W4 compaction authorization TTL config", () => {
|
||||
const key = "policies.claude_compaction.authorize_ttl_minutes";
|
||||
const envKey = "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES";
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let savedEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "w4-config-test-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
savedEnv = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
if (savedEnv === undefined) delete process.env[envKey];
|
||||
else process.env[envKey] = savedEnv;
|
||||
});
|
||||
|
||||
it("keeps the daemon and CLI compaction key registries exactly aligned", () => {
|
||||
const compactionKeys = (keys: readonly string[]) =>
|
||||
keys.filter((candidate) => candidate.startsWith("policies.claude_compaction."));
|
||||
expect(compactionKeys(VALID_KEYS)).toEqual(compactionKeys(SETTINGS_VALID_KEYS));
|
||||
expect(VALID_KEYS).toContain(key);
|
||||
});
|
||||
|
||||
it("defaults to 15 and exposes the typed policy field", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource(key)).toEqual({
|
||||
value: 15,
|
||||
source: "default",
|
||||
defaultValue: 15,
|
||||
});
|
||||
expect(store.resolve().policies.claudeCompaction.authorizeTtlMinutes).toBe(15);
|
||||
});
|
||||
|
||||
it("accepts a valid environment override", () => {
|
||||
process.env[envKey] = "30";
|
||||
expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({
|
||||
value: 30,
|
||||
source: "env",
|
||||
defaultValue: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["0", "61", "15.5", "bad"])(
|
||||
"invalid env %s falls back to 15 and reports loudly",
|
||||
(raw) => {
|
||||
process.env[envKey] = raw;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({
|
||||
value: 15,
|
||||
source: "default",
|
||||
defaultValue: 15,
|
||||
});
|
||||
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
|
||||
`env override for ${key} rejected`,
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 61, 15.5, "bad"])(
|
||||
"invalid file value %s falls back to 15 and reports loudly",
|
||||
(raw) => {
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
policies: { claudeCompaction: { authorizeTtlMinutes: raw } },
|
||||
}));
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({
|
||||
value: 15,
|
||||
source: "default",
|
||||
defaultValue: 15,
|
||||
});
|
||||
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
|
||||
`file value for ${key} rejected`,
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts exactly 1..60 through set and rejects values outside the range", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
for (const value of ["1", "15", "60"]) {
|
||||
store.set(key, value);
|
||||
expect(store.get(key)).toBe(Number(value));
|
||||
}
|
||||
for (const value of ["0", "61", "15.5", "bad"]) {
|
||||
expect(() => store.set(key, value)).toThrow(/integer|\[1, 60\]|number/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("prints the literal key in rig config help", () => {
|
||||
const command = configCommand(configPath);
|
||||
let output = "";
|
||||
command.configureOutput({
|
||||
writeOut: (text) => { output += text; },
|
||||
writeErr: () => {},
|
||||
});
|
||||
command.outputHelp();
|
||||
expect(output).toContain(key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Command } from "commander";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
const COMMAND_MODULE = ["../src/commands", "compaction-control.js"].join("/");
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface TestDeps {
|
||||
lifecycleDeps: Record<string, never>;
|
||||
clientFactory: () => {
|
||||
get(path: string): Promise<{ status: number; data: unknown }>;
|
||||
post(path: string, body: unknown): Promise<{ status: number; data: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
type CommandFactory = (deps: TestDeps) => Command;
|
||||
|
||||
async function loadCommand(): Promise<CommandFactory> {
|
||||
let loaded: { compactionControlCommand?: CommandFactory } | null = null;
|
||||
try {
|
||||
loaded = await import(/* @vite-ignore */ COMMAND_MODULE);
|
||||
} catch {
|
||||
// Assertion below keeps the RED feature-shaped instead of surfacing a loader error.
|
||||
}
|
||||
expect(loaded, "compaction-control command module must exist").not.toBeNull();
|
||||
expect(loaded?.compactionControlCommand).toBeTypeOf("function");
|
||||
return loaded!.compactionControlCommand!;
|
||||
}
|
||||
|
||||
function makeDeps() {
|
||||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||||
const deps: TestDeps = {
|
||||
lifecycleDeps: {},
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
decisions: [{
|
||||
decisionId: "hold-1",
|
||||
sessionName: "claude-seat@rig",
|
||||
direction: "hold",
|
||||
lastObservedAt: "2026-08-08T18:00:30.000Z",
|
||||
lastObservedOutcome: "human_hold",
|
||||
}],
|
||||
},
|
||||
};
|
||||
}),
|
||||
post: vi.fn(async (path: string, body: unknown) => {
|
||||
calls.push({ method: "POST", path, body });
|
||||
return { status: path.endsWith("/clear") ? 200 : 201, data: { ok: true, decisionId: "decision-1" } };
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
describe("rig compaction-control", () => {
|
||||
let logs: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("is registered as one grouped command with hold, authorize, list, and clear", () => {
|
||||
const program = createProgram({ compactionControlDeps: makeDeps().deps } as never);
|
||||
const command = program.commands.find((candidate) => candidate.name() === "compaction-control");
|
||||
expect(command).toBeDefined();
|
||||
expect(command!.commands.map((child) => child.name()).sort()).toEqual([
|
||||
"authorize",
|
||||
"clear",
|
||||
"hold",
|
||||
"list",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hold posts the target and required human reason without a body actor", async () => {
|
||||
const factory = await loadCommand();
|
||||
const { calls, deps } = makeDeps();
|
||||
const command = factory(deps);
|
||||
await command.parseAsync([
|
||||
"node", "rig", "hold", "claude-seat@rig",
|
||||
"--reason", "finish the atomic action",
|
||||
"--json",
|
||||
]);
|
||||
expect(calls).toEqual([{
|
||||
method: "POST",
|
||||
path: "/api/compaction/control",
|
||||
body: {
|
||||
session: "claude-seat@rig",
|
||||
direction: "hold",
|
||||
reason: "finish the atomic action",
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
it("authorize posts exactly one named lift and no caller-controlled expiry", async () => {
|
||||
const factory = await loadCommand();
|
||||
const { calls, deps } = makeDeps();
|
||||
const command = factory(deps);
|
||||
await command.parseAsync([
|
||||
"node", "rig", "authorize", "claude-seat@rig",
|
||||
"--automatic-reason", "disabled",
|
||||
"--reason", "allow one attempt",
|
||||
"--json",
|
||||
]);
|
||||
expect(calls).toEqual([{
|
||||
method: "POST",
|
||||
path: "/api/compaction/control",
|
||||
body: {
|
||||
session: "claude-seat@rig",
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "allow one attempt",
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
it("list uses the scoped read route and renders durable hold observation", async () => {
|
||||
const factory = await loadCommand();
|
||||
const { calls, deps } = makeDeps();
|
||||
const command = factory(deps);
|
||||
await command.parseAsync([
|
||||
"node", "rig", "list", "--session", "claude-seat@rig", "--json",
|
||||
]);
|
||||
expect(calls).toEqual([{
|
||||
method: "GET",
|
||||
path: "/api/compaction/control?session=claude-seat%40rig",
|
||||
}]);
|
||||
expect(JSON.parse(logs.join(""))).toMatchObject({
|
||||
decisions: [{
|
||||
decisionId: "hold-1",
|
||||
lastObservedAt: "2026-08-08T18:00:30.000Z",
|
||||
lastObservedOutcome: "human_hold",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("clear records a human reason through the decision-specific route", async () => {
|
||||
const factory = await loadCommand();
|
||||
const { calls, deps } = makeDeps();
|
||||
const command = factory(deps);
|
||||
await command.parseAsync([
|
||||
"node", "rig", "clear", "hold-1", "--reason", "atomic action completed", "--json",
|
||||
]);
|
||||
expect(calls).toEqual([{
|
||||
method: "POST",
|
||||
path: "/api/compaction/control/hold-1/clear",
|
||||
body: { reason: "atomic action completed" },
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ import { projectionManifestSchema } from "./migrations/064_projection_manifest.j
|
||||
import { identityProvenanceSchema } from "./migrations/065_identity_provenance.js";
|
||||
import { watchdogTargetGenerationSchema } from "./migrations/066_watchdog_target_generation.js";
|
||||
import { i3IdentityProvenanceSchema } from "./migrations/067_i3_identity_provenance.js";
|
||||
import { enforcerDecisionsSchema } from "./migrations/068_enforcer_decisions.js";
|
||||
import type { Migration } from "./migrate.js";
|
||||
|
||||
/** Ordered 001→067 (064/066 from main, 065 + 067 the P21 identity_provenance era-stamps — all additive/independent). */
|
||||
@@ -141,4 +142,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
identityProvenanceSchema,
|
||||
watchdogTargetGenerationSchema,
|
||||
i3IdentityProvenanceSchema,
|
||||
enforcerDecisionsSchema,
|
||||
];
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** W4 — durable, attributable human hold/authorize decisions for enforcers. */
|
||||
export const enforcerDecisionsSchema: Migration = {
|
||||
name: "068_enforcer_decisions.sql",
|
||||
sql: `
|
||||
CREATE TABLE enforcer_decisions (
|
||||
decision_id TEXT PRIMARY KEY,
|
||||
enforcer_kind TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
generation_uuid TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('hold', 'authorize')),
|
||||
automatic_reason TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
actor_session TEXT NOT NULL,
|
||||
identity_provenance TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
release_kind TEXT,
|
||||
released_at TEXT,
|
||||
released_by_session TEXT,
|
||||
release_identity_provenance TEXT,
|
||||
release_reason TEXT,
|
||||
consumed_at TEXT,
|
||||
consumed_by_enforcer_kind TEXT,
|
||||
lifted_reason TEXT,
|
||||
attempt_outcome TEXT,
|
||||
attempt_failure_reason TEXT,
|
||||
last_observed_at TEXT,
|
||||
last_observed_outcome TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_enforcer_decisions_one_active
|
||||
ON enforcer_decisions(enforcer_kind, session_name, generation_uuid)
|
||||
WHERE active = 1;
|
||||
|
||||
CREATE INDEX idx_enforcer_decisions_lookup
|
||||
ON enforcer_decisions(enforcer_kind, session_name, direction, active, generation_uuid);
|
||||
`,
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SessionTransport } from "./session-transport.js";
|
||||
import type { SendOpts, SendResult, SessionTransport } from "./session-transport.js";
|
||||
import type { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import type {
|
||||
AuthorizableCompactionReason,
|
||||
EnforcerDecision,
|
||||
EnforcerDecisionStore,
|
||||
} from "./enforcer-decision-store.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -68,8 +73,8 @@ export interface EnforcerInput {
|
||||
}
|
||||
|
||||
export type EnforcerOutcome =
|
||||
| { triggered: true }
|
||||
| { triggered: false; reason: EnforcerSkipReason };
|
||||
| { triggered: true; decisionId?: string; liftedReason?: AuthorizableCompactionReason }
|
||||
| { triggered: false; reason: EnforcerSkipReason; decisionId?: string };
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.14 — manual compaction trigger surfaced stages (AC-3). `preparing`
|
||||
@@ -109,7 +114,8 @@ export type EnforcerSkipReason =
|
||||
| "post_restore_cooldown"
|
||||
| "send_failed"
|
||||
| "invalid_policy"
|
||||
| "stale_generation";
|
||||
| "stale_generation"
|
||||
| "human_hold";
|
||||
|
||||
function buildCompactCommand(compactInstruction: string): string {
|
||||
const normalized = compactInstruction.trim().replace(/\s+/g, " ");
|
||||
@@ -312,11 +318,34 @@ export class ClaudeCompactionEnforcer {
|
||||
// retired-generation stage → refuse. Injected resolver (atom-B's currentOccupantTenure by session).
|
||||
private readonly pendingStageGeneration = new Map<string, string | null>();
|
||||
private readonly resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
private readonly decisionStore?: Pick<
|
||||
EnforcerDecisionStore,
|
||||
| "findActiveHold"
|
||||
| "findMatchingAuthorization"
|
||||
| "observeHold"
|
||||
| "consumeAuthorizationForAttempt"
|
||||
| "recordAuthorizationAttempt"
|
||||
>;
|
||||
|
||||
constructor(
|
||||
settingsStore: SettingsStore,
|
||||
sessionTransport: SessionTransport,
|
||||
opts?: { dedupWindowMs?: number; openrigHome?: string; postCompactRestoreCooldownMs?: number; manualPrepWaitMs?: number; postCompactSendWaitMs?: number; resolveOccupantGeneration?: (sessionName: string) => string | null },
|
||||
opts?: {
|
||||
dedupWindowMs?: number;
|
||||
openrigHome?: string;
|
||||
postCompactRestoreCooldownMs?: number;
|
||||
manualPrepWaitMs?: number;
|
||||
postCompactSendWaitMs?: number;
|
||||
resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
decisionStore?: Pick<
|
||||
EnforcerDecisionStore,
|
||||
| "findActiveHold"
|
||||
| "findMatchingAuthorization"
|
||||
| "observeHold"
|
||||
| "consumeAuthorizationForAttempt"
|
||||
| "recordAuthorizationAttempt"
|
||||
>;
|
||||
},
|
||||
) {
|
||||
this.settingsStore = settingsStore;
|
||||
this.sessionTransport = sessionTransport;
|
||||
@@ -326,6 +355,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.manualPrepWaitMs = opts?.manualPrepWaitMs ?? MANUAL_PREP_WAIT_MS_DEFAULT;
|
||||
this.postCompactSendWaitMs = opts?.postCompactSendWaitMs ?? POST_COMPACT_SEND_WAIT_MS_DEFAULT;
|
||||
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
|
||||
this.decisionStore = opts?.decisionStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,6 +371,102 @@ export class ClaudeCompactionEnforcer {
|
||||
return { triggered: false, reason: "no_usage_data" };
|
||||
}
|
||||
|
||||
const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
|
||||
const activeHold = this.decisionStore?.findActiveHold({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: input.sessionName,
|
||||
liveGenerationUuid,
|
||||
});
|
||||
if (activeHold) {
|
||||
this.decisionStore?.observeHold({
|
||||
decisionId: activeHold.decisionId,
|
||||
outcome: "human_hold",
|
||||
});
|
||||
return {
|
||||
triggered: false,
|
||||
reason: "human_hold",
|
||||
decisionId: activeHold.decisionId,
|
||||
};
|
||||
}
|
||||
|
||||
let authorization: {
|
||||
decisionId: string;
|
||||
liftedReason: AuthorizableCompactionReason;
|
||||
} | null = null;
|
||||
const lift = (automaticReason: AuthorizableCompactionReason): boolean => {
|
||||
const found = this.decisionStore?.findMatchingAuthorization({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: input.sessionName,
|
||||
liveGenerationUuid,
|
||||
automaticReason,
|
||||
}) as EnforcerDecision | null | undefined;
|
||||
if (!found) return false;
|
||||
authorization = { decisionId: found.decisionId, liftedReason: automaticReason };
|
||||
return true;
|
||||
};
|
||||
const sendAttempt = async (
|
||||
message: string,
|
||||
opts?: SendOpts,
|
||||
): Promise<{ ok: boolean; result: SendResult | null; outcome: EnforcerOutcome }> => {
|
||||
const lifted = authorization as {
|
||||
decisionId: string;
|
||||
liftedReason: AuthorizableCompactionReason;
|
||||
} | null;
|
||||
if (lifted && !this.decisionStore?.consumeAuthorizationForAttempt({
|
||||
decisionId: lifted.decisionId,
|
||||
enforcerKind: "claude_compaction",
|
||||
liftedReason: lifted.liftedReason,
|
||||
})) {
|
||||
return {
|
||||
ok: false,
|
||||
result: null,
|
||||
outcome: { triggered: false, reason: lifted.liftedReason },
|
||||
};
|
||||
}
|
||||
|
||||
let result: SendResult;
|
||||
try {
|
||||
result = await this.sessionTransport.send(input.sessionName, message, opts);
|
||||
} catch (error) {
|
||||
if (!lifted) throw error;
|
||||
this.decisionStore?.recordAuthorizationAttempt({
|
||||
decisionId: lifted.decisionId,
|
||||
outcome: "failed",
|
||||
failureReason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
result: null,
|
||||
outcome: { triggered: false, reason: "send_failed" },
|
||||
};
|
||||
}
|
||||
if (lifted) {
|
||||
this.decisionStore?.recordAuthorizationAttempt({
|
||||
decisionId: lifted.decisionId,
|
||||
outcome: result.ok ? "succeeded" : "failed",
|
||||
...(result.ok ? {} : { failureReason: result.reason ?? result.error ?? "send_failed" }),
|
||||
});
|
||||
}
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
result,
|
||||
outcome: { triggered: false, reason: "send_failed" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result,
|
||||
outcome: lifted
|
||||
? {
|
||||
triggered: true,
|
||||
decisionId: lifted.decisionId,
|
||||
liftedReason: lifted.liftedReason,
|
||||
}
|
||||
: { triggered: true },
|
||||
};
|
||||
};
|
||||
|
||||
const policy = this.settingsStore.resolveClaudeCompactionPolicy();
|
||||
// Defense in depth: the CLI + daemon set() paths reject invalid
|
||||
// threshold values, but a hand-edited ~/.openrig/config.json could
|
||||
@@ -371,7 +497,7 @@ export class ClaudeCompactionEnforcer {
|
||||
// generations residue is covered by fix (b)'s generation gate (layered defense). Interpretation
|
||||
// surfaced in the handoff for the PM evidence read (veto there if the literal reading was meant).
|
||||
if (!policy.enabled && this.manualCompactionState.get(input.sessionName)?.operatorInitiated !== true) {
|
||||
return { triggered: false, reason: "disabled" };
|
||||
if (!lift("disabled")) return { triggered: false, reason: "disabled" };
|
||||
}
|
||||
// GHOST-STAGE (b): gen-scoped stages. A stage minted by a RETIRED occupant generation must be
|
||||
// undeliverable to the successor. Compare the queue-time generation to the LIVE one. NOTE-2: an
|
||||
@@ -382,30 +508,30 @@ export class ClaudeCompactionEnforcer {
|
||||
if (stageGen != null) {
|
||||
const liveGen = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
|
||||
if (liveGen != null && liveGen !== stageGen) {
|
||||
this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage
|
||||
return { triggered: false, reason: "stale_generation" };
|
||||
if (!lift("stale_generation")) {
|
||||
this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage
|
||||
return { triggered: false, reason: "stale_generation" };
|
||||
}
|
||||
}
|
||||
}
|
||||
const pendingStage = this.pendingPostCompactRestore.get(input.sessionName);
|
||||
if (pendingStage === "turn_boundary") {
|
||||
const boundary = await this.sessionTransport.send(
|
||||
input.sessionName,
|
||||
const boundary = await sendAttempt(
|
||||
buildPostCompactTurnBoundaryPrompt(),
|
||||
{ waitForIdleMs: this.postCompactSendWaitMs },
|
||||
);
|
||||
if (!boundary.ok) {
|
||||
// Busy/never-idle → no delivery, no advance; the SAME stage retries next tick.
|
||||
return { triggered: false, reason: "send_failed" };
|
||||
return boundary.outcome;
|
||||
}
|
||||
this.pendingPostCompactRestore.set(input.sessionName, "restore_prompt");
|
||||
return { triggered: true };
|
||||
return boundary.outcome;
|
||||
}
|
||||
if (pendingStage === "restore_prompt") {
|
||||
// OPR.0.4.1.09: resolve the extra FOR THIS SEAT (per-seat preferred; the legacy
|
||||
// global is refused if it declares a different seat) - never inject wrong-seat state.
|
||||
const extra = resolvePostCompactExtra(input.sessionName, this.openrigHome, policy.messageFilePath);
|
||||
const restore = await this.sessionTransport.send(
|
||||
input.sessionName,
|
||||
const restore = await sendAttempt(
|
||||
buildPostCompactRestorePrompt({
|
||||
sessionName: input.sessionName,
|
||||
openrigHome: this.openrigHome,
|
||||
@@ -421,23 +547,22 @@ export class ClaudeCompactionEnforcer {
|
||||
// Restore is exact-once + operator-authorized: if the seat is still busy
|
||||
// (mid-compaction/boundary), do NOT advance to restore-sent on an
|
||||
// undelivered send — retry the SAME stage next tick.
|
||||
return { triggered: false, reason: "send_failed" };
|
||||
return restore.outcome;
|
||||
}
|
||||
this.pendingPostCompactRestore.set(input.sessionName, "compliance_prompt");
|
||||
// OPR.0.4.3.14 — surface manual-trigger progress (no-op for auto seats).
|
||||
this.advanceManualStage(input.sessionName, "compact-sent", "restore-sent");
|
||||
return { triggered: true };
|
||||
return restore.outcome;
|
||||
}
|
||||
if (pendingStage === "compliance_prompt") {
|
||||
const compliance = await this.sessionTransport.send(
|
||||
input.sessionName,
|
||||
const compliance = await sendAttempt(
|
||||
buildPostCompactCompliancePrompt(policy.postRestoreAuditInstruction),
|
||||
{ waitForIdleMs: this.postCompactSendWaitMs },
|
||||
);
|
||||
if (!compliance.ok) {
|
||||
// Audit cannot overtake restore: only advances once the restore turn is
|
||||
// idle and this send delivers; a busy tick retries the SAME stage.
|
||||
return { triggered: false, reason: "send_failed" };
|
||||
return compliance.outcome;
|
||||
}
|
||||
this.pendingPostCompactRestore.delete(input.sessionName);
|
||||
this.pendingStageGeneration.delete(input.sessionName); // GHOST-STAGE (b): stage completed → drop its gen
|
||||
@@ -448,7 +573,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.triggeredAboveThreshold.delete(input.sessionName);
|
||||
// OPR.0.4.3.14 — terminal manual-trigger stage (no-op for auto seats).
|
||||
this.advanceManualStage(input.sessionName, "restore-sent", "audit-sent");
|
||||
return { triggered: true };
|
||||
return compliance.outcome;
|
||||
}
|
||||
this.triggeredAboveThreshold.delete(input.sessionName);
|
||||
this.pendingPreCompactPrep.delete(input.sessionName);
|
||||
@@ -465,16 +590,19 @@ export class ClaudeCompactionEnforcer {
|
||||
// constant policy), while a manual trigger's restore/audit half can finish
|
||||
// via this single shared path even when auto-compaction is disabled.
|
||||
if (!policy.enabled) {
|
||||
return { triggered: false, reason: "disabled" };
|
||||
if (!lift("disabled")) return { triggered: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const postRestoreCooldownUntil = this.postCompactRestoreCooldownUntil.get(input.sessionName);
|
||||
if (postRestoreCooldownUntil !== undefined) {
|
||||
if (now < postRestoreCooldownUntil) {
|
||||
return { triggered: false, reason: "post_restore_cooldown" };
|
||||
if (!lift("post_restore_cooldown")) {
|
||||
return { triggered: false, reason: "post_restore_cooldown" };
|
||||
}
|
||||
} else {
|
||||
this.postCompactRestoreCooldownUntil.delete(input.sessionName);
|
||||
}
|
||||
this.postCompactRestoreCooldownUntil.delete(input.sessionName);
|
||||
}
|
||||
|
||||
const last = this.lastAutoCompactAt.get(input.sessionName);
|
||||
@@ -487,8 +615,7 @@ export class ClaudeCompactionEnforcer {
|
||||
|
||||
const preCompactStage = this.pendingPreCompactPrep.get(input.sessionName);
|
||||
if (preCompactStage === undefined) {
|
||||
const prep = await this.sessionTransport.send(
|
||||
input.sessionName,
|
||||
const prep = await sendAttempt(
|
||||
buildPreCompactPrepPrompt({
|
||||
usedPercentage: input.usedPercentage,
|
||||
thresholdPercent: policy.thresholdPercent,
|
||||
@@ -496,18 +623,17 @@ export class ClaudeCompactionEnforcer {
|
||||
}),
|
||||
);
|
||||
if (!prep.ok) {
|
||||
return { triggered: false, reason: "send_failed" };
|
||||
return prep.outcome;
|
||||
}
|
||||
this.pendingPreCompactPrep.set(input.sessionName, "prep_prompt_sent");
|
||||
return { triggered: true };
|
||||
return prep.outcome;
|
||||
}
|
||||
|
||||
const result = await this.sessionTransport.send(
|
||||
input.sessionName,
|
||||
const attempt = await sendAttempt(
|
||||
buildCompactCommand(policy.compactInstruction),
|
||||
);
|
||||
if (!result.ok) {
|
||||
return { triggered: false, reason: "send_failed" };
|
||||
if (!attempt.ok) {
|
||||
return attempt.outcome;
|
||||
}
|
||||
this.lastAutoCompactAt.set(input.sessionName, now);
|
||||
this.triggeredAboveThreshold.add(input.sessionName);
|
||||
@@ -515,7 +641,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.pendingPostCompactRestore.set(input.sessionName, "turn_boundary");
|
||||
// GHOST-STAGE (b): capture the occupant generation at queue time (or null when unknown).
|
||||
this.pendingStageGeneration.set(input.sessionName, this.resolveOccupantGeneration?.(input.sessionName) ?? null);
|
||||
return { triggered: true };
|
||||
return attempt.outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const CLAUDE_COMPACTION_ENFORCER_KIND = "claude_compaction" as const;
|
||||
export const AUTHORIZABLE_COMPACTION_REASONS = [
|
||||
"disabled",
|
||||
"post_restore_cooldown",
|
||||
"stale_generation",
|
||||
] as const;
|
||||
|
||||
export type AuthorizableCompactionReason = typeof AUTHORIZABLE_COMPACTION_REASONS[number];
|
||||
export type EnforcerDecisionDirection = "hold" | "authorize";
|
||||
|
||||
export interface EnforcerDecision {
|
||||
decisionId: string;
|
||||
enforcerKind: string;
|
||||
sessionName: string;
|
||||
generationUuid: string;
|
||||
direction: EnforcerDecisionDirection;
|
||||
automaticReason: string | null;
|
||||
reason: string;
|
||||
actorSession: string;
|
||||
identityProvenance: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
active: boolean;
|
||||
releaseKind: string | null;
|
||||
releasedAt: string | null;
|
||||
releasedBySession: string | null;
|
||||
releaseIdentityProvenance: string | null;
|
||||
releaseReason: string | null;
|
||||
consumedAt: string | null;
|
||||
consumedByEnforcerKind: string | null;
|
||||
liftedReason: string | null;
|
||||
attemptOutcome: string | null;
|
||||
attemptFailureReason: string | null;
|
||||
lastObservedAt: string | null;
|
||||
lastObservedOutcome: string | null;
|
||||
}
|
||||
|
||||
export interface CreateEnforcerDecisionInput {
|
||||
enforcerKind: string;
|
||||
sessionName: string;
|
||||
generationUuid: string;
|
||||
direction: EnforcerDecisionDirection;
|
||||
automaticReason?: string | null;
|
||||
reason: string;
|
||||
actorSession: string;
|
||||
identityProvenance: string;
|
||||
}
|
||||
|
||||
interface DecisionRow {
|
||||
decision_id: string;
|
||||
enforcer_kind: string;
|
||||
session_name: string;
|
||||
generation_uuid: string;
|
||||
direction: EnforcerDecisionDirection;
|
||||
automatic_reason: string | null;
|
||||
reason: string;
|
||||
actor_session: string;
|
||||
identity_provenance: string;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
active: number;
|
||||
release_kind: string | null;
|
||||
released_at: string | null;
|
||||
released_by_session: string | null;
|
||||
release_identity_provenance: string | null;
|
||||
release_reason: string | null;
|
||||
consumed_at: string | null;
|
||||
consumed_by_enforcer_kind: string | null;
|
||||
lifted_reason: string | null;
|
||||
attempt_outcome: string | null;
|
||||
attempt_failure_reason: string | null;
|
||||
last_observed_at: string | null;
|
||||
last_observed_outcome: string | null;
|
||||
}
|
||||
|
||||
const AUTHORIZABLE = new Set<string>(AUTHORIZABLE_COMPACTION_REASONS);
|
||||
|
||||
function required(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function mapDecision(row: DecisionRow): EnforcerDecision {
|
||||
return {
|
||||
decisionId: row.decision_id,
|
||||
enforcerKind: row.enforcer_kind,
|
||||
sessionName: row.session_name,
|
||||
generationUuid: row.generation_uuid,
|
||||
direction: row.direction,
|
||||
automaticReason: row.automatic_reason,
|
||||
reason: row.reason,
|
||||
actorSession: row.actor_session,
|
||||
identityProvenance: row.identity_provenance,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
active: row.active === 1,
|
||||
releaseKind: row.release_kind,
|
||||
releasedAt: row.released_at,
|
||||
releasedBySession: row.released_by_session,
|
||||
releaseIdentityProvenance: row.release_identity_provenance,
|
||||
releaseReason: row.release_reason,
|
||||
consumedAt: row.consumed_at,
|
||||
consumedByEnforcerKind: row.consumed_by_enforcer_kind,
|
||||
liftedReason: row.lifted_reason,
|
||||
attemptOutcome: row.attempt_outcome,
|
||||
attemptFailureReason: row.attempt_failure_reason,
|
||||
lastObservedAt: row.last_observed_at,
|
||||
lastObservedOutcome: row.last_observed_outcome,
|
||||
};
|
||||
}
|
||||
|
||||
export class EnforcerDecisionStore {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly opts: { now: () => Date; authorizeTtlMinutes: () => number },
|
||||
) {}
|
||||
|
||||
private nowIso(): string {
|
||||
return this.opts.now().toISOString();
|
||||
}
|
||||
|
||||
private expireAuthorizations(now = this.nowIso()): void {
|
||||
this.db.prepare(`
|
||||
UPDATE enforcer_decisions
|
||||
SET active = 0,
|
||||
release_kind = 'expired',
|
||||
released_at = ?
|
||||
WHERE active = 1
|
||||
AND direction = 'authorize'
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at <= ?
|
||||
`).run(now, now);
|
||||
}
|
||||
|
||||
private getById(decisionId: string): EnforcerDecision | null {
|
||||
const row = this.db.prepare(
|
||||
"SELECT * FROM enforcer_decisions WHERE decision_id = ?",
|
||||
).get(decisionId) as DecisionRow | undefined;
|
||||
return row ? mapDecision(row) : null;
|
||||
}
|
||||
|
||||
create(input: CreateEnforcerDecisionInput): EnforcerDecision {
|
||||
const enforcerKind = required(input.enforcerKind, "enforcer kind");
|
||||
const sessionName = required(input.sessionName, "session name");
|
||||
const generationUuid = required(input.generationUuid, "generation UUID");
|
||||
const reason = required(input.reason, "reason");
|
||||
const actorSession = required(input.actorSession, "actor session");
|
||||
const identityProvenance = required(input.identityProvenance, "identity provenance");
|
||||
if (input.direction !== "hold" && input.direction !== "authorize") {
|
||||
throw new Error("direction must be hold or authorize");
|
||||
}
|
||||
|
||||
let automaticReason: string | null = null;
|
||||
if (input.direction === "authorize") {
|
||||
automaticReason = required(input.automaticReason, "automatic reason");
|
||||
if (!AUTHORIZABLE.has(automaticReason)) {
|
||||
throw new Error(`automatic reason '${automaticReason}' is not authorizable`);
|
||||
}
|
||||
}
|
||||
|
||||
const now = this.nowIso();
|
||||
this.expireAuthorizations(now);
|
||||
const decisionId = randomUUID();
|
||||
const ttlMinutes = this.opts.authorizeTtlMinutes();
|
||||
const expiresAt = input.direction === "authorize"
|
||||
? new Date(this.opts.now().getTime() + ttlMinutes * 60_000).toISOString()
|
||||
: null;
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
const active = this.db.prepare(`
|
||||
SELECT direction FROM enforcer_decisions
|
||||
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? AND active = 1
|
||||
`).get(enforcerKind, sessionName, generationUuid) as { direction: EnforcerDecisionDirection } | undefined;
|
||||
|
||||
if (input.direction === "authorize" && active?.direction === "hold") {
|
||||
throw new Error("cannot authorize while an active hold exists");
|
||||
}
|
||||
if (active?.direction === input.direction) {
|
||||
throw new Error(`an active ${input.direction} decision already exists`);
|
||||
}
|
||||
if (input.direction === "hold" && active?.direction === "authorize") {
|
||||
this.db.prepare(`
|
||||
UPDATE enforcer_decisions
|
||||
SET active = 0,
|
||||
release_kind = 'revoked_by_hold',
|
||||
released_at = ?,
|
||||
released_by_session = ?,
|
||||
release_identity_provenance = ?,
|
||||
release_reason = ?
|
||||
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? AND active = 1
|
||||
`).run(
|
||||
now,
|
||||
actorSession,
|
||||
identityProvenance,
|
||||
reason,
|
||||
enforcerKind,
|
||||
sessionName,
|
||||
generationUuid,
|
||||
);
|
||||
}
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO enforcer_decisions (
|
||||
decision_id, enforcer_kind, session_name, generation_uuid, direction,
|
||||
automatic_reason, reason, actor_session, identity_provenance,
|
||||
created_at, expires_at, active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(
|
||||
decisionId,
|
||||
enforcerKind,
|
||||
sessionName,
|
||||
generationUuid,
|
||||
input.direction,
|
||||
automaticReason,
|
||||
reason,
|
||||
actorSession,
|
||||
identityProvenance,
|
||||
now,
|
||||
expiresAt,
|
||||
);
|
||||
});
|
||||
transaction.immediate();
|
||||
return this.getById(decisionId)!;
|
||||
}
|
||||
|
||||
list(input: { sessionName?: string } = {}): EnforcerDecision[] {
|
||||
this.expireAuthorizations();
|
||||
const rows = input.sessionName
|
||||
? this.db.prepare(`
|
||||
SELECT * FROM enforcer_decisions WHERE session_name = ?
|
||||
ORDER BY created_at DESC, rowid DESC
|
||||
`).all(input.sessionName) as DecisionRow[]
|
||||
: this.db.prepare(`
|
||||
SELECT * FROM enforcer_decisions ORDER BY created_at DESC, rowid DESC
|
||||
`).all() as DecisionRow[];
|
||||
return rows.map(mapDecision);
|
||||
}
|
||||
|
||||
clear(input: {
|
||||
decisionId: string;
|
||||
actorSession: string;
|
||||
identityProvenance: string;
|
||||
reason: string;
|
||||
}): EnforcerDecision {
|
||||
const decisionId = required(input.decisionId, "decision ID");
|
||||
const actorSession = required(input.actorSession, "actor session");
|
||||
const identityProvenance = required(input.identityProvenance, "identity provenance");
|
||||
const reason = required(input.reason, "reason");
|
||||
const result = this.db.prepare(`
|
||||
UPDATE enforcer_decisions
|
||||
SET active = 0,
|
||||
release_kind = 'cleared',
|
||||
released_at = ?,
|
||||
released_by_session = ?,
|
||||
release_identity_provenance = ?,
|
||||
release_reason = ?
|
||||
WHERE decision_id = ? AND active = 1
|
||||
`).run(this.nowIso(), actorSession, identityProvenance, reason, decisionId);
|
||||
if (result.changes !== 1) throw new Error("active decision not found");
|
||||
return this.getById(decisionId)!;
|
||||
}
|
||||
|
||||
findActiveHold(input: {
|
||||
enforcerKind: string;
|
||||
sessionName: string;
|
||||
liveGenerationUuid: string | null;
|
||||
}): EnforcerDecision | null {
|
||||
this.expireAuthorizations();
|
||||
const row = input.liveGenerationUuid === null
|
||||
? this.db.prepare(`
|
||||
SELECT * FROM enforcer_decisions
|
||||
WHERE enforcer_kind = ? AND session_name = ? AND direction = 'hold' AND active = 1
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT 1
|
||||
`).get(input.enforcerKind, input.sessionName) as DecisionRow | undefined
|
||||
: this.db.prepare(`
|
||||
SELECT * FROM enforcer_decisions
|
||||
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ?
|
||||
AND direction = 'hold' AND active = 1
|
||||
LIMIT 1
|
||||
`).get(input.enforcerKind, input.sessionName, input.liveGenerationUuid) as DecisionRow | undefined;
|
||||
return row ? mapDecision(row) : null;
|
||||
}
|
||||
|
||||
findMatchingAuthorization(input: {
|
||||
enforcerKind: string;
|
||||
sessionName: string;
|
||||
liveGenerationUuid: string | null;
|
||||
automaticReason: string;
|
||||
}): EnforcerDecision | null {
|
||||
this.expireAuthorizations();
|
||||
if (input.liveGenerationUuid === null) return null;
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM enforcer_decisions
|
||||
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ?
|
||||
AND direction = 'authorize' AND automatic_reason = ? AND active = 1
|
||||
LIMIT 1
|
||||
`).get(
|
||||
input.enforcerKind,
|
||||
input.sessionName,
|
||||
input.liveGenerationUuid,
|
||||
input.automaticReason,
|
||||
) as DecisionRow | undefined;
|
||||
return row ? mapDecision(row) : null;
|
||||
}
|
||||
|
||||
observeHold(input: { decisionId: string; outcome: "human_hold" }): void {
|
||||
this.db.prepare(`
|
||||
UPDATE enforcer_decisions
|
||||
SET last_observed_at = ?, last_observed_outcome = ?
|
||||
WHERE decision_id = ? AND direction = 'hold' AND active = 1
|
||||
`).run(this.nowIso(), input.outcome, input.decisionId);
|
||||
}
|
||||
|
||||
consumeAuthorizationForAttempt(input: {
|
||||
decisionId: string;
|
||||
enforcerKind: string;
|
||||
liftedReason: string;
|
||||
}): boolean {
|
||||
const now = this.nowIso();
|
||||
this.expireAuthorizations(now);
|
||||
const result = this.db.prepare(`
|
||||
UPDATE enforcer_decisions
|
||||
SET active = 0,
|
||||
consumed_at = ?,
|
||||
consumed_by_enforcer_kind = ?,
|
||||
lifted_reason = ?
|
||||
WHERE decision_id = ?
|
||||
AND direction = 'authorize'
|
||||
AND automatic_reason = ?
|
||||
AND active = 1
|
||||
AND expires_at > ?
|
||||
`).run(
|
||||
now,
|
||||
input.enforcerKind,
|
||||
input.liftedReason,
|
||||
input.decisionId,
|
||||
input.liftedReason,
|
||||
now,
|
||||
);
|
||||
return result.changes === 1;
|
||||
}
|
||||
|
||||
recordAuthorizationAttempt(input: {
|
||||
decisionId: string;
|
||||
outcome: "succeeded" | "failed";
|
||||
failureReason?: string;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
UPDATE enforcer_decisions
|
||||
SET attempt_outcome = ?, attempt_failure_reason = ?
|
||||
WHERE decision_id = ? AND consumed_at IS NOT NULL
|
||||
`).run(input.outcome, input.failureReason ?? null, input.decisionId);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,7 @@ export const SETTINGS_VALID_KEYS = [
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
"policies.claude_compaction.authorize_ttl_minutes",
|
||||
"policies.idle_gate_qitem.scan_interval_seconds",
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds",
|
||||
"snapshots.periodic.enabled",
|
||||
@@ -192,6 +193,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
|
||||
"policies.claude_compaction.authorize_ttl_minutes": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES" },
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS" },
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS" },
|
||||
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
|
||||
@@ -249,6 +251,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
|
||||
"policies.claude_compaction.authorize_ttl_minutes": ["policies", "claudeCompaction", "authorizeTtlMinutes"],
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": ["policies", "idleGateQitem", "scanIntervalSeconds"],
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds": ["policies", "idleGateQitem", "activeWakeIntervalSeconds"],
|
||||
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
|
||||
@@ -477,6 +480,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
case "policies.claude_compaction.message_inline": return DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION;
|
||||
case "policies.claude_compaction.message_file_path": return defaultClaudeCompactionExtraInstructionFilePath();
|
||||
case "policies.claude_compaction.post_restore_audit_instruction": return DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION;
|
||||
case "policies.claude_compaction.authorize_ttl_minutes": return 15;
|
||||
case "policies.idle_gate_qitem.scan_interval_seconds": return 60;
|
||||
case "policies.idle_gate_qitem.active_wake_interval_seconds": return 900;
|
||||
case "snapshots.periodic.enabled": return true;
|
||||
@@ -552,6 +556,19 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
);
|
||||
}
|
||||
},
|
||||
"policies.claude_compaction.authorize_ttl_minutes": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed) || typeof coerced !== "number" || !Number.isInteger(coerced)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: expected an integer in [1, 60], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (coerced < 1 || coerced > 60) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: must be in [1, 60], got ${coerced}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
"snapshots.periodic.interval_seconds": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim())) {
|
||||
throw new Error(
|
||||
@@ -643,6 +660,7 @@ export interface ClaudeCompactionPolicy {
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
postRestoreAuditInstruction: string;
|
||||
authorizeTtlMinutes: number;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
@@ -763,6 +781,7 @@ export class SettingsStore {
|
||||
messageInline: this.resolveOne("policies.claude_compaction.message_inline", fc, wr).value as string,
|
||||
messageFilePath: this.resolveOne("policies.claude_compaction.message_file_path", fc, wr).value as string,
|
||||
postRestoreAuditInstruction: this.resolveOne("policies.claude_compaction.post_restore_audit_instruction", fc, wr).value as string,
|
||||
authorizeTtlMinutes: this.resolveOne("policies.claude_compaction.authorize_ttl_minutes", fc, wr).value as number,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,14 @@ import type Database from "better-sqlite3";
|
||||
import type { ClaudeCompactionEnforcer } from "../domain/claude-compaction-enforcer.js";
|
||||
import type { ContextUsageStore } from "../domain/context-usage-store.js";
|
||||
import type { SessionTransport } from "../domain/session-transport.js";
|
||||
import type { SessionRegistry } from "../domain/session-registry.js";
|
||||
import {
|
||||
AUTHORIZABLE_COMPACTION_REASONS,
|
||||
CLAUDE_COMPACTION_ENFORCER_KIND,
|
||||
type EnforcerDecisionStore,
|
||||
} from "../domain/enforcer-decision-store.js";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { requireSenderIdentity } from "./require-sender-identity.js";
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.14 — manual configurable compaction trigger route.
|
||||
@@ -21,6 +28,98 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
const router = new Hono();
|
||||
router.use("*", authBearerTokenMiddleware({ expectedToken: opts?.bearerToken ?? null }));
|
||||
|
||||
router.post("/control", async (c) => {
|
||||
const identity = requireSenderIdentity(c, { verb: "compaction control create" });
|
||||
if (!identity.ok) return identity.response;
|
||||
const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined;
|
||||
const sessionRegistry = c.get("sessionRegistry" as never) as SessionRegistry | undefined;
|
||||
if (!store || !sessionRegistry) {
|
||||
return c.json({ ok: false, error: "compaction_control_unavailable" }, 503);
|
||||
}
|
||||
|
||||
type ControlBody = {
|
||||
session?: string;
|
||||
direction?: string;
|
||||
automaticReason?: string;
|
||||
reason?: string;
|
||||
};
|
||||
const body = await c.req.json<ControlBody>().catch((): ControlBody => ({}));
|
||||
const sessionName = body.session?.trim();
|
||||
if (!sessionName) return c.json({ ok: false, error: "session_required" }, 400);
|
||||
const reason = body.reason?.trim();
|
||||
if (!reason) return c.json({ ok: false, error: "reason_required" }, 400);
|
||||
if (body.direction !== "hold" && body.direction !== "authorize") {
|
||||
return c.json({ ok: false, error: "direction_invalid" }, 400);
|
||||
}
|
||||
|
||||
let automaticReason: string | null = null;
|
||||
if (body.direction === "authorize") {
|
||||
automaticReason = body.automaticReason?.trim() ?? "";
|
||||
if (!automaticReason) {
|
||||
return c.json({ ok: false, error: "automatic_reason_required" }, 400);
|
||||
}
|
||||
if (!(AUTHORIZABLE_COMPACTION_REASONS as readonly string[]).includes(automaticReason)) {
|
||||
return c.json({ ok: false, error: "automatic_reason_not_authorizable" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const generationUuid = sessionRegistry.currentOccupantGenerationForSession(sessionName);
|
||||
if (!generationUuid) {
|
||||
return c.json({ ok: false, error: "generation_unavailable" }, 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const decision = store.create({
|
||||
enforcerKind: CLAUDE_COMPACTION_ENFORCER_KIND,
|
||||
sessionName,
|
||||
generationUuid,
|
||||
direction: body.direction,
|
||||
automaticReason,
|
||||
reason,
|
||||
actorSession: identity.session,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
return c.json({ ok: true, decision }, 201);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = /active hold|already exists/i.test(message) ? 409 : 400;
|
||||
return c.json({ ok: false, error: "decision_rejected", message }, status as 400 | 409);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/control", (c) => {
|
||||
const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined;
|
||||
if (!store) return c.json({ ok: false, error: "compaction_control_unavailable" }, 503);
|
||||
const sessionName = c.req.query("session")?.trim();
|
||||
return c.json({
|
||||
ok: true,
|
||||
decisions: store.list(sessionName ? { sessionName } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/control/:decisionId/clear", async (c) => {
|
||||
const identity = requireSenderIdentity(c, { verb: "compaction control clear" });
|
||||
if (!identity.ok) return identity.response;
|
||||
const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined;
|
||||
if (!store) return c.json({ ok: false, error: "compaction_control_unavailable" }, 503);
|
||||
type ClearBody = { reason?: string };
|
||||
const body = await c.req.json<ClearBody>().catch((): ClearBody => ({}));
|
||||
const reason = body.reason?.trim();
|
||||
if (!reason) return c.json({ ok: false, error: "reason_required" }, 400);
|
||||
try {
|
||||
const decision = store.clear({
|
||||
decisionId: c.req.param("decisionId"),
|
||||
actorSession: identity.session,
|
||||
identityProvenance: "transport:v1",
|
||||
reason,
|
||||
});
|
||||
return c.json({ ok: true, decision });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return c.json({ ok: false, error: "decision_not_active", message }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/trigger", async (c) => {
|
||||
const enforcer = c.get("compactionEnforcer" as never) as ClaudeCompactionEnforcer | undefined;
|
||||
const transport = c.get("sessionTransport" as never) as SessionTransport | undefined;
|
||||
|
||||
@@ -292,6 +292,7 @@ export interface AppDeps {
|
||||
* auto share one back-half state machine — no second restore path).
|
||||
*/
|
||||
compactionEnforcer?: ClaudeCompactionEnforcer;
|
||||
enforcerDecisionStore?: import("./domain/enforcer-decision-store.js").EnforcerDecisionStore;
|
||||
/**
|
||||
* GHOST-STAGE (e/Class-B) — the canonical OccupantInvalidator, constructed once in startup and
|
||||
* injected so SeatHandoverService.commit()'s re-key call actually FIRES (dev-driver's fold added
|
||||
@@ -530,6 +531,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("contextUsageStore" as never, deps.contextUsageStore);
|
||||
c.set("contextMonitor" as never, deps.contextMonitor);
|
||||
c.set("compactionEnforcer" as never, deps.compactionEnforcer);
|
||||
c.set("enforcerDecisionStore" as never, deps.enforcerDecisionStore);
|
||||
c.set("occupantInvalidator" as never, deps.occupantInvalidator);
|
||||
c.set("nodeCmuxService" as never, deps.nodeCmuxService);
|
||||
c.set("agentActivityStore" as never, deps.agentActivityStore);
|
||||
|
||||
@@ -1757,14 +1757,24 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// the type narrow for downstream consumers).
|
||||
const { ContextMonitor } = await import("./domain/context-monitor.js");
|
||||
const { ClaudeCompactionEnforcer } = await import("./domain/claude-compaction-enforcer.js");
|
||||
const { EnforcerDecisionStore } = await import("./domain/enforcer-decision-store.js");
|
||||
const compactionSettingsStore = new ContextPackSettingsStore();
|
||||
const enforcerDecisionStore = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date(),
|
||||
authorizeTtlMinutes: () => compactionSettingsStore.resolveClaudeCompactionPolicy().authorizeTtlMinutes,
|
||||
});
|
||||
deps.enforcerDecisionStore = enforcerDecisionStore;
|
||||
const compactionEnforcer = deps.sessionTransport
|
||||
? new ClaudeCompactionEnforcer(
|
||||
new ContextPackSettingsStore(),
|
||||
compactionSettingsStore,
|
||||
deps.sessionTransport,
|
||||
// GHOST-STAGE (b): resolve the LIVE occupant generation (atom-B tenure ledger) for a session
|
||||
// so a stage minted by a retired generation is refused for the successor. null = UNKNOWN → the
|
||||
// gate is inert (never compares a stale generation as if live).
|
||||
{ resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName) },
|
||||
{
|
||||
resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName),
|
||||
decisionStore: enforcerDecisionStore,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
// 51-08 A1 — the over-time series rides the SAME 30s tick (PM decision 1):
|
||||
|
||||
@@ -49,6 +49,7 @@ function clearEnv(): () => void {
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES",
|
||||
"OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS",
|
||||
"OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS",
|
||||
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
|
||||
@@ -125,6 +126,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
"policies.claude_compaction.authorize_ttl_minutes",
|
||||
// OPR.0.5.1 51-06 W2c — tunable daemon auto-registration cadence.
|
||||
"policies.idle_gate_qitem.scan_interval_seconds",
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds",
|
||||
@@ -339,6 +341,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(policy.messageFilePath).toMatch(/^\//);
|
||||
expect(policy.messageFilePath.endsWith(DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX)).toBe(true);
|
||||
expect(policy.postRestoreAuditInstruction).toContain(DEFAULT_AUDIT_INSTRUCTION_FRAGMENT);
|
||||
expect(policy.authorizeTtlMinutes).toBe(15);
|
||||
});
|
||||
|
||||
it("HG-10: resolveClaudeCompactionPolicy picks up direct config.json edits without daemon restart (single resolve call rereads file)", () => {
|
||||
@@ -357,6 +360,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
messageInline: "carry-forward note",
|
||||
messageFilePath: "",
|
||||
postRestoreAuditInstruction: "verify the reads",
|
||||
authorizeTtlMinutes: 30,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -370,6 +374,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(updated.messageInline).toBe("carry-forward note");
|
||||
expect(updated.messageFilePath.endsWith(DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX)).toBe(true);
|
||||
expect(updated.postRestoreAuditInstruction).toBe("verify the reads");
|
||||
expect(updated.authorizeTtlMinutes).toBe(30);
|
||||
});
|
||||
|
||||
it("Slice 27: ensureDefaultClaudeCompactionFiles creates the user-owned extra instruction placeholder without overwriting edits", () => {
|
||||
@@ -396,6 +401,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
store.set("policies.claude_compaction.message_inline", "rehydrate the agent");
|
||||
store.set("policies.claude_compaction.message_file_path", "/tmp/msg.txt");
|
||||
store.set("policies.claude_compaction.post_restore_audit_instruction", "audit reads");
|
||||
store.set("policies.claude_compaction.authorize_ttl_minutes", "30");
|
||||
|
||||
const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8"));
|
||||
expect(raw.policies.claudeCompaction.enabled).toBe(true);
|
||||
@@ -405,6 +411,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(raw.policies.claudeCompaction.messageInline).toBe("rehydrate the agent");
|
||||
expect(raw.policies.claudeCompaction.messageFilePath).toBe("/tmp/msg.txt");
|
||||
expect(raw.policies.claudeCompaction.postRestoreAuditInstruction).toBe("audit reads");
|
||||
expect(raw.policies.claudeCompaction.authorizeTtlMinutes).toBe(30);
|
||||
|
||||
expect(store.resolveOne("policies.claude_compaction.enabled").value).toBe(true);
|
||||
expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(60);
|
||||
@@ -413,6 +420,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(store.resolveOne("policies.claude_compaction.message_inline").value).toBe("rehydrate the agent");
|
||||
expect(store.resolveOne("policies.claude_compaction.message_file_path").value).toBe("/tmp/msg.txt");
|
||||
expect(store.resolveOne("policies.claude_compaction.post_restore_audit_instruction").value).toBe("audit reads");
|
||||
expect(store.resolveOne("policies.claude_compaction.authorize_ttl_minutes").value).toBe(30);
|
||||
});
|
||||
|
||||
it("HG-1: invalid threshold rejected by coerceValue (non-numeric raises)", () => {
|
||||
@@ -420,6 +428,70 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
describe("W4 authorize TTL config contract", () => {
|
||||
it("accepts the exact inclusive 1..60 range through the write path", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
for (const value of ["1", "15", "60"]) {
|
||||
store.set("policies.claude_compaction.authorize_ttl_minutes", value);
|
||||
expect(store.resolveOne("policies.claude_compaction.authorize_ttl_minutes")).toMatchObject({
|
||||
value: Number(value),
|
||||
source: "file",
|
||||
defaultValue: 15,
|
||||
});
|
||||
}
|
||||
for (const value of ["0", "61", "15.5", "bad"]) {
|
||||
expect(() => store.set("policies.claude_compaction.authorize_ttl_minutes", value)).toThrow(/integer|\[1, 60\]|number/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a valid env override", () => {
|
||||
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"] = "30";
|
||||
expect(new SettingsStore(configPath).resolveOne(
|
||||
"policies.claude_compaction.authorize_ttl_minutes",
|
||||
)).toMatchObject({ value: 30, source: "env", defaultValue: 15 });
|
||||
});
|
||||
|
||||
it.each(["0", "61", "15.5", "bad"])(
|
||||
"invalid env %s falls back to 15 and reports loudly",
|
||||
(raw) => {
|
||||
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"] = raw;
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const resolved = new SettingsStore(configPath).resolveOne(
|
||||
"policies.claude_compaction.authorize_ttl_minutes",
|
||||
);
|
||||
expect(resolved).toMatchObject({ value: 15, source: "default", defaultValue: 15 });
|
||||
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
|
||||
"env override for policies.claude_compaction.authorize_ttl_minutes rejected",
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 61, 15.5, "bad"])(
|
||||
"invalid file value %s falls back to 15 and reports loudly",
|
||||
(raw) => {
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
policies: { claudeCompaction: { authorizeTtlMinutes: raw } },
|
||||
}));
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const resolved = new SettingsStore(configPath).resolveOne(
|
||||
"policies.claude_compaction.authorize_ttl_minutes",
|
||||
);
|
||||
expect(resolved).toMatchObject({ value: 15, source: "default", defaultValue: 15 });
|
||||
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
|
||||
"file value for policies.claude_compaction.authorize_ttl_minutes rejected",
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX-2 — env + file source resolution must also
|
||||
// reject invalid threshold values. Without this, an env override
|
||||
// (OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc) or a
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { ContextUsageStore } from "../src/domain/context-usage-store.js";
|
||||
import { ContextMonitor } from "../src/domain/context-monitor.js";
|
||||
import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import type { SessionTransport } from "../src/domain/session-transport.js";
|
||||
|
||||
const STORE_MODULE = ["../src/domain", "enforcer-decision-store.js"].join("/");
|
||||
const SESSION = "claude-seat@rig";
|
||||
|
||||
type DecisionStore = {
|
||||
create(input: Record<string, unknown>): Record<string, unknown>;
|
||||
list(input?: Record<string, unknown>): Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type DecisionStoreConstructor = new (
|
||||
db: Database.Database,
|
||||
opts: { now: () => Date; authorizeTtlMinutes: () => number },
|
||||
) => DecisionStore;
|
||||
|
||||
async function loadDecisionStore(): Promise<DecisionStoreConstructor> {
|
||||
let mod: { EnforcerDecisionStore?: DecisionStoreConstructor } | null = null;
|
||||
try {
|
||||
mod = await import(/* @vite-ignore */ STORE_MODULE);
|
||||
} catch {
|
||||
// Assertion below keeps the RED feature-shaped instead of surfacing a loader error.
|
||||
}
|
||||
expect(mod, "durable enforcer decision store module must exist").not.toBeNull();
|
||||
expect(mod?.EnforcerDecisionStore).toBeTypeOf("function");
|
||||
return mod!.EnforcerDecisionStore!;
|
||||
}
|
||||
|
||||
describe("W4 ContextMonitor enforcement observation", () => {
|
||||
let db: Database.Database;
|
||||
let stateDir: string;
|
||||
let contextStore: ContextUsageStore;
|
||||
let monitor: ContextMonitor | undefined;
|
||||
let generationUuid: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
stateDir = join(tmpdir(), `w4-context-monitor-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(join(stateDir, "context"), { recursive: true });
|
||||
contextStore = new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir });
|
||||
|
||||
const rigs = new RigRepository(db);
|
||||
const sessions = new SessionRegistry(db);
|
||||
const rig = rigs.createRig("rig");
|
||||
const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" });
|
||||
const session = sessions.registerSession(node.id, SESSION);
|
||||
generationUuid = sessions.currentOccupantGenerationForSession(SESSION)!;
|
||||
db.prepare("UPDATE sessions SET status = 'running' WHERE id = ?").run(session.id);
|
||||
|
||||
writeFileSync(
|
||||
join(stateDir, "context", `${SESSION}.json`),
|
||||
JSON.stringify({
|
||||
session_name: SESSION,
|
||||
sampled_at: "2026-08-08T18:00:30.000Z",
|
||||
context_window: {
|
||||
context_window_size: 200_000,
|
||||
used_percentage: 90,
|
||||
remaining_percentage: 10,
|
||||
total_input_tokens: 170_000,
|
||||
total_output_tokens: 10_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
monitor?.stop();
|
||||
db.close();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("a real poll honors a hold with unknown live generation and exposes its observation", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const decisions = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:30.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const hold = decisions.create({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: SESSION,
|
||||
generationUuid,
|
||||
direction: "hold",
|
||||
reason: "finish the current atomic action",
|
||||
actorSession: "orch-lead@rig",
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
const send = vi.fn(async () => ({ ok: true as const }));
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
{
|
||||
resolveClaudeCompactionPolicy: () => ({
|
||||
enabled: true,
|
||||
thresholdPercent: 80,
|
||||
preCompactInstruction: "prepare",
|
||||
compactInstruction: "",
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
postRestoreAuditInstruction: "audit",
|
||||
authorizeTtlMinutes: 15,
|
||||
}),
|
||||
} as never,
|
||||
{ send } as unknown as SessionTransport,
|
||||
{
|
||||
resolveOccupantGeneration: () => null,
|
||||
decisionStore: decisions,
|
||||
} as never,
|
||||
);
|
||||
monitor = new ContextMonitor(
|
||||
db,
|
||||
contextStore,
|
||||
{ ensureContextCollector: vi.fn() },
|
||||
enforcer,
|
||||
);
|
||||
|
||||
await monitor.pollOnce();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(decisions.list({ sessionName: SESSION })).toContainEqual(expect.objectContaining({
|
||||
decisionId: hold["decisionId"],
|
||||
lastObservedAt: "2026-08-08T18:00:30.000Z",
|
||||
lastObservedOutcome: "human_hold",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { compactionRoutes } from "../src/routes/compaction.js";
|
||||
|
||||
const IDENTITY = "orch-lead@rig";
|
||||
const SEAT = "claude-seat@rig";
|
||||
|
||||
describe("W4 compaction-control routes", () => {
|
||||
let db: Database.Database;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let generationUuid: string;
|
||||
let decisions: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const rigs = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
const rig = rigs.createRig("rig");
|
||||
const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" });
|
||||
sessionRegistry.registerSession(node.id, SEAT);
|
||||
generationUuid = sessionRegistry.currentOccupantGenerationForSession(SEAT)!;
|
||||
decisions = {
|
||||
create: vi.fn((input) => ({ decisionId: "decision-1", active: true, ...input })),
|
||||
list: vi.fn(() => [{
|
||||
decisionId: "hold-1",
|
||||
sessionName: SEAT,
|
||||
direction: "hold",
|
||||
active: true,
|
||||
lastObservedAt: "2026-08-08T18:00:30.000Z",
|
||||
lastObservedOutcome: "human_hold",
|
||||
}]),
|
||||
clear: vi.fn((input) => ({ decisionId: input.decisionId, active: false, releaseKind: "cleared" })),
|
||||
};
|
||||
app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db" as never, db);
|
||||
c.set("sessionRegistry" as never, sessionRegistry);
|
||||
c.set("enforcerDecisionStore" as never, decisions);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/compaction", compactionRoutes());
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
const request = (path: string, body?: Record<string, unknown>, identity = IDENTITY) =>
|
||||
app.request(path, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
...(identity ? { "X-OpenRig-Session": identity } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
it("refuses an unattributable hold before writing any record", async () => {
|
||||
const response = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
direction: "hold",
|
||||
reason: "mid-fold",
|
||||
}, "");
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({ error: "unattributable_sender" });
|
||||
expect(decisions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses blank reasons and blanket authorizations loudly", async () => {
|
||||
const blank = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
direction: "hold",
|
||||
reason: " ",
|
||||
});
|
||||
expect(blank.status).toBe(400);
|
||||
expect(await blank.json()).toMatchObject({ error: "reason_required" });
|
||||
|
||||
const blanket = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
direction: "authorize",
|
||||
reason: "ignore refusals",
|
||||
});
|
||||
expect(blanket.status).toBe(400);
|
||||
expect(await blanket.json()).toMatchObject({ error: "automatic_reason_required" });
|
||||
expect(decisions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"runtime_filter",
|
||||
"no_usage_data",
|
||||
"invalid_policy",
|
||||
"below_threshold",
|
||||
"already_triggered_above_threshold",
|
||||
"dedup_window",
|
||||
"send_failed",
|
||||
])("refuses excluded authorization reason %s", async (automaticReason) => {
|
||||
const response = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
direction: "authorize",
|
||||
automaticReason,
|
||||
reason: "not in locked set",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ error: "automatic_reason_not_authorizable" });
|
||||
expect(decisions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("derives actor/provenance and generation when creating a hold", async () => {
|
||||
const response = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
direction: "hold",
|
||||
reason: "finish current atomic action",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(decisions.create).toHaveBeenCalledWith({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: SEAT,
|
||||
generationUuid,
|
||||
direction: "hold",
|
||||
automaticReason: null,
|
||||
reason: "finish current atomic action",
|
||||
actorSession: IDENTITY,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["disabled", "post_restore_cooldown", "stale_generation"])(
|
||||
"creates one reason-matched authorization for %s without accepting caller expiry",
|
||||
async (automaticReason) => {
|
||||
const response = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
direction: "authorize",
|
||||
automaticReason,
|
||||
reason: `allow ${automaticReason} once`,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(decisions.create).toHaveBeenCalledWith({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: SEAT,
|
||||
generationUuid,
|
||||
direction: "authorize",
|
||||
automaticReason,
|
||||
reason: `allow ${automaticReason} once`,
|
||||
actorSession: IDENTITY,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
expect(decisions.create.mock.calls[0]?.[0]).not.toHaveProperty("expiresAt");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses decision creation when live generation cannot be resolved", async () => {
|
||||
const response = await request("/api/compaction/control", {
|
||||
session: "missing-seat@rig",
|
||||
direction: "hold",
|
||||
reason: "cannot scope without a generation",
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({ error: "generation_unavailable" });
|
||||
expect(decisions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists durable observation fields without requiring a write identity", async () => {
|
||||
const response = await request(`/api/compaction/control?session=${encodeURIComponent(SEAT)}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
ok: true,
|
||||
decisions: [{
|
||||
decisionId: "hold-1",
|
||||
sessionName: SEAT,
|
||||
direction: "hold",
|
||||
active: true,
|
||||
lastObservedAt: "2026-08-08T18:00:30.000Z",
|
||||
lastObservedOutcome: "human_hold",
|
||||
}],
|
||||
});
|
||||
expect(decisions.list).toHaveBeenCalledWith({ sessionName: SEAT });
|
||||
});
|
||||
|
||||
it("clear derives its human actor and records a required release reason", async () => {
|
||||
const response = await request("/api/compaction/control/hold-1/clear", {
|
||||
reason: "atomic action completed",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(decisions.clear).toHaveBeenCalledWith({
|
||||
decisionId: "hold-1",
|
||||
actorSession: IDENTITY,
|
||||
identityProvenance: "transport:v1",
|
||||
reason: "atomic action completed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe("W4 production wiring", () => {
|
||||
it("constructs one decision store and shares it with the enforcer and routes", () => {
|
||||
const startup = readFileSync(join(here, "../src/startup.ts"), "utf8");
|
||||
const server = readFileSync(join(here, "../src/server.ts"), "utf8");
|
||||
|
||||
expect(startup).toContain('import("./domain/enforcer-decision-store.js")');
|
||||
expect(startup.match(/new EnforcerDecisionStore\(/g)).toHaveLength(1);
|
||||
expect(startup).toMatch(/decisionStore:\s*enforcerDecisionStore/);
|
||||
expect(startup).toMatch(/deps\.enforcerDecisionStore\s*=\s*enforcerDecisionStore/);
|
||||
|
||||
expect(server).toMatch(/enforcerDecisionStore\?:\s*import\("\.\/domain\/enforcer-decision-store\.js"\)\.EnforcerDecisionStore/);
|
||||
expect(server).toContain('c.set("enforcerDecisionStore" as never, deps.enforcerDecisionStore)');
|
||||
expect(server).toContain("compactionRoutes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,633 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import {
|
||||
ClaudeCompactionEnforcer,
|
||||
type EnforcerInput,
|
||||
} from "../src/domain/claude-compaction-enforcer.js";
|
||||
import type { SessionTransport } from "../src/domain/session-transport.js";
|
||||
|
||||
const STORE_MODULE = ["../src/domain", "enforcer-decision-store.js"].join("/");
|
||||
const KIND = "claude_compaction";
|
||||
const SEAT = "claude-seat@rig";
|
||||
|
||||
type DecisionStoreConstructor = new (
|
||||
db: Database.Database,
|
||||
opts: { now: () => Date; authorizeTtlMinutes: () => number },
|
||||
) => {
|
||||
create(input: Record<string, unknown>): Record<string, unknown>;
|
||||
list(input?: Record<string, unknown>): Array<Record<string, unknown>>;
|
||||
clear(input: Record<string, unknown>): Record<string, unknown>;
|
||||
findActiveHold(input: Record<string, unknown>): Record<string, unknown> | null;
|
||||
consumeAuthorizationForAttempt(input: Record<string, unknown>): boolean;
|
||||
recordAuthorizationAttempt(input: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
async function loadDecisionStore(): Promise<DecisionStoreConstructor> {
|
||||
let mod: { EnforcerDecisionStore?: DecisionStoreConstructor } | null = null;
|
||||
try {
|
||||
mod = await import(/* @vite-ignore */ STORE_MODULE);
|
||||
} catch {
|
||||
// Assertion below keeps the RED feature-shaped instead of surfacing a loader error.
|
||||
}
|
||||
expect(mod, "durable enforcer decision store module must exist").not.toBeNull();
|
||||
expect(mod?.EnforcerDecisionStore).toBeTypeOf("function");
|
||||
return mod!.EnforcerDecisionStore!;
|
||||
}
|
||||
|
||||
function makeDb(): Database.Database {
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
return db;
|
||||
}
|
||||
|
||||
function humanInput(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
enforcerKind: KIND,
|
||||
sessionName: SEAT,
|
||||
generationUuid: "gen-1",
|
||||
direction: "hold",
|
||||
reason: "seat is inside a load-bearing atomic step",
|
||||
actorSession: "orch-lead@rig",
|
||||
identityProvenance: "transport:v1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("W4 durable enforcer decision store", () => {
|
||||
it("registers migration 068 and creates the durable decision table", () => {
|
||||
expect(ALL_MIGRATIONS.map((migration) => migration.name)).toContain(
|
||||
"068_enforcer_decisions.sql",
|
||||
);
|
||||
const db = makeDb();
|
||||
try {
|
||||
const row = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'enforcer_decisions'",
|
||||
).get() as { name: string } | undefined;
|
||||
expect(row?.name).toBe("enforcer_decisions");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists an attributable generation-scoped hold with enforcer kind", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const created = store.create(humanInput());
|
||||
expect(created).toMatchObject({
|
||||
enforcerKind: KIND,
|
||||
sessionName: SEAT,
|
||||
generationUuid: "gen-1",
|
||||
direction: "hold",
|
||||
actorSession: "orch-lead@rig",
|
||||
identityProvenance: "transport:v1",
|
||||
reason: "seat is inside a load-bearing atomic step",
|
||||
active: true,
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("makes authorize expiring, reason-matched, and limited to the locked lift set", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
for (const [index, automaticReason] of [
|
||||
"disabled",
|
||||
"post_restore_cooldown",
|
||||
"stale_generation",
|
||||
].entries()) {
|
||||
const created = store.create(humanInput({
|
||||
generationUuid: `gen-${index + 1}`,
|
||||
direction: "authorize",
|
||||
automaticReason,
|
||||
reason: `lift ${automaticReason} once`,
|
||||
}));
|
||||
expect(created).toMatchObject({
|
||||
direction: "authorize",
|
||||
automaticReason,
|
||||
expiresAt: "2026-08-08T18:15:00.000Z",
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const automaticReason of [
|
||||
"runtime_filter",
|
||||
"no_usage_data",
|
||||
"invalid_policy",
|
||||
"below_threshold",
|
||||
"already_triggered_above_threshold",
|
||||
"dedup_window",
|
||||
"send_failed",
|
||||
"human_hold",
|
||||
]) {
|
||||
expect(() => store.create(humanInput({
|
||||
generationUuid: `excluded-${automaticReason}`,
|
||||
direction: "authorize",
|
||||
automaticReason,
|
||||
reason: "must be refused",
|
||||
}))).toThrow(/automatic reason|not authorizable|liftable/i);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces one active decision and resolves conflicts toward hold", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const authorization = store.create(humanInput({
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "one disabled-policy attempt",
|
||||
}));
|
||||
const hold = store.create(humanInput({ reason: "new work began; stop before acting" }));
|
||||
expect(hold).toMatchObject({ direction: "hold", active: true });
|
||||
const rows = store.list({ sessionName: SEAT });
|
||||
expect(rows.find((row) => row["decisionId"] === authorization["decisionId"])).toMatchObject({
|
||||
active: false,
|
||||
releaseKind: "revoked_by_hold",
|
||||
releasedBySession: "orch-lead@rig",
|
||||
releaseIdentityProvenance: "transport:v1",
|
||||
});
|
||||
expect(() => store.create(humanInput({
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "must not outrank hold",
|
||||
}))).toThrow(/active hold/i);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes one authorization atomically for one machine send attempt", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const created = store.create(humanInput({
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "allow one disabled-policy attempt",
|
||||
}));
|
||||
const consumeInput = {
|
||||
decisionId: created["decisionId"],
|
||||
enforcerKind: KIND,
|
||||
liftedReason: "disabled",
|
||||
};
|
||||
expect(store.consumeAuthorizationForAttempt(consumeInput)).toBe(true);
|
||||
expect(store.consumeAuthorizationForAttempt(consumeInput)).toBe(false);
|
||||
store.recordAuthorizationAttempt({
|
||||
decisionId: created["decisionId"],
|
||||
outcome: "failed",
|
||||
failureReason: "send_failed",
|
||||
});
|
||||
expect(store.list({ sessionName: SEAT })[0]).toMatchObject({
|
||||
consumedByEnforcerKind: KIND,
|
||||
liftedReason: "disabled",
|
||||
attemptOutcome: "failed",
|
||||
attemptFailureReason: "send_failed",
|
||||
});
|
||||
expect(store.list({ sessionName: SEAT })[0]).not.toHaveProperty("consumedByActorSession");
|
||||
expect(store.list({ sessionName: SEAT })[0]).not.toHaveProperty("consumeIdentityProvenance");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("records explicit human clear and lazy expiry without erasing history", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
let now = new Date("2026-08-08T18:00:00.000Z");
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => now,
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const hold = store.create(humanInput());
|
||||
store.clear({
|
||||
decisionId: hold["decisionId"],
|
||||
actorSession: "orch-lead@rig",
|
||||
identityProvenance: "transport:v1",
|
||||
reason: "atomic step completed",
|
||||
});
|
||||
expect(store.list({ sessionName: SEAT })[0]).toMatchObject({
|
||||
active: false,
|
||||
releaseKind: "cleared",
|
||||
releasedBySession: "orch-lead@rig",
|
||||
releaseIdentityProvenance: "transport:v1",
|
||||
releaseReason: "atomic step completed",
|
||||
});
|
||||
|
||||
store.create(humanInput({
|
||||
generationUuid: "gen-2",
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "short-lived one-shot",
|
||||
}));
|
||||
now = new Date("2026-08-08T18:16:00.000Z");
|
||||
const expired = store.list({ sessionName: SEAT }).find((row) => row["generationUuid"] === "gen-2");
|
||||
expect(expired).toMatchObject({ active: false, releaseKind: "expired" });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses anonymous, reasonless, and blanket decisions loudly", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
expect(() => store.create(humanInput({ actorSession: "" }))).toThrow(/actor|required|attribut/i);
|
||||
expect(() => store.create(humanInput({ reason: " " }))).toThrow(/reason|required/i);
|
||||
expect(() => store.create(humanInput({
|
||||
generationUuid: "gen-blanket",
|
||||
direction: "authorize",
|
||||
automaticReason: undefined,
|
||||
reason: "ignore refusals",
|
||||
}))).toThrow(/automatic reason|required|blanket/i);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const BASE_POLICY = {
|
||||
enabled: true,
|
||||
thresholdPercent: 80,
|
||||
preCompactInstruction: "prepare",
|
||||
compactInstruction: "",
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
postRestoreAuditInstruction: "audit",
|
||||
authorizeTtlMinutes: 15,
|
||||
};
|
||||
|
||||
function settings(policy: Record<string, unknown> = BASE_POLICY) {
|
||||
return { resolveClaudeCompactionPolicy: () => policy } as never;
|
||||
}
|
||||
|
||||
function transport() {
|
||||
const send = vi.fn(async () => ({ ok: true as const }));
|
||||
return { send, value: { send } as unknown as SessionTransport };
|
||||
}
|
||||
|
||||
function decisionHarness(opts: {
|
||||
hold?: (generation: string | null) => Record<string, unknown> | null;
|
||||
authorizationReason?: string;
|
||||
} = {}) {
|
||||
const findActiveHold = vi.fn((input: { liveGenerationUuid: string | null }) =>
|
||||
opts.hold?.(input.liveGenerationUuid) ?? null);
|
||||
const findMatchingAuthorization = vi.fn((input: { automaticReason: string }) =>
|
||||
input.automaticReason === opts.authorizationReason
|
||||
? { decisionId: "authorize-1", automaticReason: input.automaticReason }
|
||||
: null);
|
||||
return {
|
||||
findActiveHold,
|
||||
findMatchingAuthorization,
|
||||
observeHold: vi.fn(),
|
||||
consumeAuthorizationForAttempt: vi.fn(() => true),
|
||||
recordAuthorizationAttempt: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const highInput = (usedPercentage = 90): EnforcerInput => ({
|
||||
sessionName: SEAT,
|
||||
runtime: "claude-code",
|
||||
usedPercentage,
|
||||
transcriptPath: "/tmp/claude.jsonl",
|
||||
});
|
||||
|
||||
describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
|
||||
it("a hold prevents firing and reports human_hold durably", async () => {
|
||||
const tx = transport();
|
||||
const decisions = decisionHarness({ hold: () => ({ decisionId: "hold-1" }) });
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
resolveOccupantGeneration: () => "gen-1",
|
||||
decisionStore: decisions,
|
||||
} as never);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "human_hold",
|
||||
decisionId: "hold-1",
|
||||
});
|
||||
expect(tx.send).not.toHaveBeenCalled();
|
||||
expect(decisions.observeHold).toHaveBeenCalledWith(expect.objectContaining({
|
||||
decisionId: "hold-1",
|
||||
outcome: "human_hold",
|
||||
}));
|
||||
});
|
||||
|
||||
it("a null live generation fails closed on a hold, then known N+1 makes the N hold inert", async () => {
|
||||
const tx = transport();
|
||||
let liveGeneration: string | null = null;
|
||||
const decisions = decisionHarness({
|
||||
hold: (generation) => generation === null ? { decisionId: "hold-N" } : null,
|
||||
});
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
resolveOccupantGeneration: () => liveGeneration,
|
||||
decisionStore: decisions,
|
||||
} as never);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "human_hold",
|
||||
decisionId: "hold-N",
|
||||
});
|
||||
liveGeneration = "gen-N+1";
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true });
|
||||
expect(tx.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("matches a persisted generation-N hold and makes it inert for generation N+1", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const hold = store.create(humanInput({ generationUuid: "gen-N" }));
|
||||
const tx = transport();
|
||||
let liveGeneration = "gen-N";
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
resolveOccupantGeneration: () => liveGeneration,
|
||||
decisionStore: store,
|
||||
} as never);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "human_hold",
|
||||
decisionId: hold["decisionId"],
|
||||
});
|
||||
expect(tx.send).not.toHaveBeenCalled();
|
||||
|
||||
liveGeneration = "gen-N+1";
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true });
|
||||
expect(tx.send).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes a persisted authorization only for matching known generation N and only once", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const authorization = store.create(humanInput({
|
||||
generationUuid: "gen-N",
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "lift disabled once for generation N",
|
||||
}));
|
||||
const row = () => store.list({ sessionName: SEAT }).find(
|
||||
(candidate) => candidate["decisionId"] === authorization["decisionId"],
|
||||
);
|
||||
const tx = transport();
|
||||
let liveGeneration: string | null = null;
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
settings({ ...BASE_POLICY, enabled: false }),
|
||||
tx.value,
|
||||
{
|
||||
resolveOccupantGeneration: () => liveGeneration,
|
||||
decisionStore: store,
|
||||
} as never,
|
||||
);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
expect(row()).toMatchObject({ active: true });
|
||||
expect(row()?.["consumedAt"] ?? null).toBeNull();
|
||||
expect(tx.send).not.toHaveBeenCalled();
|
||||
|
||||
liveGeneration = "gen-N+1";
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
expect(row()).toMatchObject({ active: true });
|
||||
expect(row()?.["consumedAt"] ?? null).toBeNull();
|
||||
expect(tx.send).not.toHaveBeenCalled();
|
||||
|
||||
liveGeneration = "gen-N";
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: true,
|
||||
decisionId: authorization["decisionId"],
|
||||
liftedReason: "disabled",
|
||||
});
|
||||
expect(row()).toMatchObject({
|
||||
active: false,
|
||||
consumedByEnforcerKind: KIND,
|
||||
liftedReason: "disabled",
|
||||
attemptOutcome: "succeeded",
|
||||
});
|
||||
expect(tx.send).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
expect(tx.send).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lifts disabled once and reports decisionId + liftedReason on the triggered outcome", async () => {
|
||||
const tx = transport();
|
||||
const decisions = decisionHarness({ authorizationReason: "disabled" });
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
settings({ ...BASE_POLICY, enabled: false }),
|
||||
tx.value,
|
||||
{
|
||||
resolveOccupantGeneration: () => "gen-1",
|
||||
decisionStore: decisions,
|
||||
} as never,
|
||||
);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: true,
|
||||
decisionId: "authorize-1",
|
||||
liftedReason: "disabled",
|
||||
});
|
||||
expect(tx.send).toHaveBeenCalledTimes(1);
|
||||
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
decisionId: "authorize-1",
|
||||
outcome: "succeeded",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not consume authorization when its reason is not the refusal that releases a send", async () => {
|
||||
const tx = transport();
|
||||
const decisions = decisionHarness({ authorizationReason: "disabled" });
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
resolveOccupantGeneration: () => "gen-1",
|
||||
decisionStore: decisions,
|
||||
} as never);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true });
|
||||
expect(tx.send).toHaveBeenCalledTimes(1);
|
||||
expect(decisions.consumeAuthorizationForAttempt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lifts post_restore_cooldown for one send attempt", async () => {
|
||||
const tx = transport();
|
||||
const decisions = decisionHarness({ authorizationReason: "post_restore_cooldown" });
|
||||
let now = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
try {
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
dedupWindowMs: 0,
|
||||
postCompactRestoreCooldownMs: 60_000,
|
||||
openrigHome: "/tmp/openrig-w4",
|
||||
resolveOccupantGeneration: () => "gen-1",
|
||||
decisionStore: decisions,
|
||||
} as never);
|
||||
|
||||
await enforcer.maybeAutoCompact(highInput());
|
||||
now += 1_000;
|
||||
await enforcer.maybeAutoCompact(highInput());
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
now += 1_000;
|
||||
await enforcer.maybeAutoCompact(highInput(20));
|
||||
}
|
||||
now += 1_000;
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: true,
|
||||
decisionId: "authorize-1",
|
||||
liftedReason: "post_restore_cooldown",
|
||||
});
|
||||
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("checks stale_generation authorization before deleting the queued stage", async () => {
|
||||
const tx = transport();
|
||||
const decisions = decisionHarness({ authorizationReason: "stale_generation" });
|
||||
let generation = "gen-N";
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
dedupWindowMs: 0,
|
||||
postCompactRestoreCooldownMs: 0,
|
||||
openrigHome: "/tmp/openrig-w4",
|
||||
resolveOccupantGeneration: () => generation,
|
||||
decisionStore: decisions,
|
||||
} as never);
|
||||
|
||||
await enforcer.maybeAutoCompact(highInput());
|
||||
await enforcer.maybeAutoCompact(highInput());
|
||||
generation = "gen-N+1";
|
||||
await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual({
|
||||
triggered: true,
|
||||
decisionId: "authorize-1",
|
||||
liftedReason: "stale_generation",
|
||||
});
|
||||
expect(tx.send.mock.calls.at(-1)?.[1]).toContain("post-compaction turn boundary");
|
||||
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("consumes authorization on a failed attempt and records the actual failure", async () => {
|
||||
const send = vi.fn(async () => ({ ok: false as const, reason: "send_failed" }));
|
||||
const decisions = decisionHarness({ authorizationReason: "disabled" });
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
settings({ ...BASE_POLICY, enabled: false }),
|
||||
{ send } as unknown as SessionTransport,
|
||||
{
|
||||
resolveOccupantGeneration: () => "gen-1",
|
||||
decisionStore: decisions,
|
||||
} as never,
|
||||
);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "send_failed",
|
||||
});
|
||||
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({
|
||||
decisionId: "authorize-1",
|
||||
outcome: "failed",
|
||||
failureReason: "send_failed",
|
||||
}));
|
||||
});
|
||||
|
||||
it("records a thrown authorized send as failed and keeps the authorization one-shot", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const authorization = store.create(humanInput({
|
||||
generationUuid: "gen-N",
|
||||
direction: "authorize",
|
||||
automaticReason: "disabled",
|
||||
reason: "allow one attempted send",
|
||||
}));
|
||||
const send = vi.fn(async () => {
|
||||
throw new Error("transport exploded");
|
||||
});
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
settings({ ...BASE_POLICY, enabled: false }),
|
||||
{ send } as unknown as SessionTransport,
|
||||
{
|
||||
resolveOccupantGeneration: () => "gen-N",
|
||||
decisionStore: store,
|
||||
} as never,
|
||||
);
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "send_failed",
|
||||
});
|
||||
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
|
||||
decisionId: authorization["decisionId"],
|
||||
active: false,
|
||||
consumedByEnforcerKind: KIND,
|
||||
liftedReason: "disabled",
|
||||
attemptOutcome: "failed",
|
||||
attemptFailureReason: "transport exploded",
|
||||
}));
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "disabled",
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user