-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsession.ts
More file actions
123 lines (109 loc) · 4.86 KB
/
Copy pathsession.ts
File metadata and controls
123 lines (109 loc) · 4.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// Opt-in, session-scoped budget ledger for the agent autonomy ladder.
//
// DiffGate is otherwise STATELESS and deterministic: `check --agent` is a pure function of the diff,
// which is what makes it a trustworthy guardrail across CI, IDE, pre-commit, and MCP. The autonomy
// budget (maxFixesPerTurn / escalateAfterTurns), however, is a property of an *agent loop* — a thing
// DiffGate cannot see in a single call. So instead of pretending to enforce it everywhere (which
// would make a CI gate flip verdicts based on unrelated prior runs), we make it enforceable ONLY in
// the contexts that genuinely have a session:
// • the MCP server (one long-lived process == one agent session), and
// • `check --agent --session=<id>` (or $DIFFGATE_AGENT_SESSION) — explicit opt-in.
// Everything else never touches this file and stays deterministic.
//
// The ledger counts, per finding identity, how many gate checks ("turns") it has survived. When a
// finding outlasts `escalateAfterTurns`, the caller escalates its rung (CLI) or warns the agent
// (MCP) — the external "stop looping, hand it to a human" signal the agent can't self-enforce.
import fs from "fs";
import path from "path";
import type { Finding } from "./types.js";
const DIR = ".diffgate";
const FILE = "session.json";
/** Idle window after which a session is considered finished and the ledger resets. */
export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
export interface SessionState {
sessionId: string;
updatedAt: number;
/** fingerprint → number of gate checks this finding has survived in the session. */
turns: Record<string, number>;
}
function sessionPath(root: string): string {
return path.join(root, DIR, FILE);
}
/** djb2 — small, stable, dependency-free. */
function hash(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(36);
}
/** Identity for a finding across turns: rule + file + flagged code. Deliberately excludes the line
* number, which shifts as the agent edits around the finding without changing what was flagged. */
export function findingFingerprint(file: string, f: Pick<Finding, "ruleId" | "code">): string {
return `${f.ruleId}:${hash(file + "\u0000" + (f.code || ""))}`;
}
function emptyState(sessionId: string): SessionState {
return { sessionId, updatedAt: Date.now(), turns: {} };
}
/** Load the ledger for `sessionId`, resetting if the id differs or the idle window has lapsed. */
export function loadSession(root: string, sessionId: string, ttlMs = DEFAULT_SESSION_TTL_MS): SessionState {
try {
const raw = JSON.parse(fs.readFileSync(sessionPath(root), "utf-8")) as Partial<SessionState>;
if (raw && raw.sessionId === sessionId && raw.turns && Date.now() - (raw.updatedAt || 0) < ttlMs) {
return { sessionId, updatedAt: raw.updatedAt || Date.now(), turns: { ...raw.turns } };
}
} catch {
/* no/stale ledger → fresh */
}
return emptyState(sessionId);
}
function save(root: string, state: SessionState): void {
try {
fs.mkdirSync(path.join(root, DIR), { recursive: true });
fs.writeFileSync(sessionPath(root), JSON.stringify(state, null, 2) + "\n");
} catch {
/* best-effort — never fail a gate because the ledger could not be written */
}
}
export interface BudgetResult {
/** Findings that have now survived ≥ escalateAfterTurns gate checks — escalate these to a human. */
overBudget: Set<string>;
/** Post-increment turn count for each finding seen this turn, keyed by fingerprint. */
turns: Map<string, number>;
}
/**
* Record one gate check ("turn") for the findings present, and report which have outlasted the
* budget. Each distinct finding present this turn has its survival counter bumped by one; findings
* absent this turn keep their prior count but do not grow.
*/
export function recordTurn(
root: string,
sessionId: string,
findings: Array<{ file: string; finding: Pick<Finding, "ruleId" | "code"> }>,
opts: { escalateAfterTurns: number; ttlMs?: number }
): BudgetResult {
const state = loadSession(root, sessionId, opts.ttlMs);
const seen = new Set<string>();
for (const { file, finding } of findings) {
const fp = findingFingerprint(file, finding);
if (seen.has(fp)) continue; // count each distinct finding once per turn
seen.add(fp);
state.turns[fp] = (state.turns[fp] || 0) + 1;
}
state.updatedAt = Date.now();
save(root, state);
const overBudget = new Set<string>();
const turns = new Map<string, number>();
for (const fp of seen) {
const n = state.turns[fp];
turns.set(fp, n);
if (n >= opts.escalateAfterTurns) overBudget.add(fp);
}
return { overBudget, turns };
}
/** Forget a session's ledger (e.g. the agent loop finished). Best-effort. */
export function clearSession(root: string): void {
try {
fs.rmSync(sessionPath(root));
} catch {
/* nothing to clear */
}
}