crewhaus 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11601 -964
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -65
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item #57 — durable sessions index. Before a session JSONL is evicted (30-day
|
|
3
|
+
* TTL), it is summarized into a compact index entry under
|
|
4
|
+
* `.crewhaus/sessions-index/<id>.json` so the harness retains long-term
|
|
5
|
+
* knowledge (outcome, tools used, ratings, key facts) past raw-transcript
|
|
6
|
+
* retention. These entries feed the memory-store / few-shot / FAQ features.
|
|
7
|
+
*
|
|
8
|
+
* The summary logic lives in `@crewhaus/session-store` (`summarizeSession`, a
|
|
9
|
+
* deterministic no-model reducer). This module is the CLI-side glue: read a
|
|
10
|
+
* session's JSONL, summarize it, and write the index entry idempotently. Kept
|
|
11
|
+
* thin + separately testable, mirroring `feedback.ts` / `dataset-mine.ts`.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { summarizeSession } from "@crewhaus/session-store";
|
|
16
|
+
/** The index directory, relative to a session root's PARENT `.crewhaus`. */
|
|
17
|
+
export const SESSIONS_INDEX_DIRNAME = "sessions-index";
|
|
18
|
+
/** Parse a JSONL blob into `{ kind, payload }` events, skipping bad lines. */
|
|
19
|
+
export function parseSessionLog(text) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const line of text.split("\n")) {
|
|
22
|
+
if (line.trim() === "")
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
out.push(JSON.parse(line));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A single malformed line must not abort the summary.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Summarize the session whose `.jsonl` lives at `logPath` into `indexDir`,
|
|
35
|
+
* returning the written summary (or undefined when the log is missing/empty).
|
|
36
|
+
* Idempotent: re-writing the same session overwrites its entry rather than
|
|
37
|
+
* duplicating. `now` is injectable for deterministic tests.
|
|
38
|
+
*/
|
|
39
|
+
export function summarizeSessionIntoIndex(sessionId, logPath, indexDir, now = () => new Date()) {
|
|
40
|
+
if (!existsSync(logPath))
|
|
41
|
+
return undefined;
|
|
42
|
+
const events = parseSessionLog(readFileSync(logPath, "utf-8"));
|
|
43
|
+
if (events.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
const summary = summarizeSession(sessionId, events, { now });
|
|
46
|
+
mkdirSync(indexDir, { recursive: true });
|
|
47
|
+
writeFileSync(join(indexDir, `${sessionId}.json`), `${JSON.stringify(summary, null, 2)}\n`, {
|
|
48
|
+
mode: 0o600,
|
|
49
|
+
});
|
|
50
|
+
return summary;
|
|
51
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type PricingTable } from "@crewhaus/cost-tracker";
|
|
2
|
+
/**
|
|
3
|
+
* A metrics-collector histogram series (one label set). `counts[i]` is the
|
|
4
|
+
* CUMULATIVE count at or below `buckets[i]` (as `Histogram.observe` stores it),
|
|
5
|
+
* `total` is the overall count. Used for the Prometheus-scrape fallback source.
|
|
6
|
+
*/
|
|
7
|
+
export type HistogramSeries = {
|
|
8
|
+
readonly buckets: ReadonlyArray<number>;
|
|
9
|
+
readonly counts: ReadonlyArray<number>;
|
|
10
|
+
readonly total: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Derive an approximate p-th percentile from a cumulative histogram's buckets —
|
|
14
|
+
* the coarse bucket-boundary estimate a Prometheus `histogram_quantile` gives.
|
|
15
|
+
* Returns the upper edge of the first bucket whose cumulative count reaches the
|
|
16
|
+
* rank. Empty ⇒ 0. The registry stores `model_ttft_seconds` in SECONDS, so the
|
|
17
|
+
* caller multiplies by 1000 for a millisecond comparison.
|
|
18
|
+
*/
|
|
19
|
+
export declare function percentileFromHistogram(series: HistogramSeries, p: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* Compute the trailing-N mean of the durable per-session `ttftP95Seconds`
|
|
22
|
+
* values from a sessions.jsonl text (torn lines skipped). Returns undefined
|
|
23
|
+
* when there is no usable history, so the probe can report "no data" rather
|
|
24
|
+
* than a spurious 0ms. `windowSessions` bounds how much recency the probe
|
|
25
|
+
* trusts (default 20 — a working week of runs).
|
|
26
|
+
*/
|
|
27
|
+
export declare function recentTtftP95Ms(sessionsJsonl: string, windowSessions?: number): number | undefined;
|
|
28
|
+
export type FasterCandidate = {
|
|
29
|
+
readonly modelString: string;
|
|
30
|
+
readonly blendedPer1M: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Name faster-candidate models for a slow slot, cheapest blended price first.
|
|
34
|
+
* Cheaper models are smaller and — as a first-order proxy — faster to first
|
|
35
|
+
* token; the probe surfaces them as CANDIDATES to eval, never as an automatic
|
|
36
|
+
* swap (a spec change stays human-reviewed). Excludes the current model's own
|
|
37
|
+
* family and known sunsets. Empty when the current model isn't table-backed
|
|
38
|
+
* (local / azure / named-host) — the probe then just reports the breach.
|
|
39
|
+
*/
|
|
40
|
+
export declare function nameFasterCandidates(currentModel: string, opts?: {
|
|
41
|
+
readonly pricing?: PricingTable;
|
|
42
|
+
readonly limit?: number;
|
|
43
|
+
}): FasterCandidate[];
|
|
44
|
+
export type SloProbeResult = {
|
|
45
|
+
/** 0 = within SLO / no data (a probe must not flap on a cold store); 1 = breach. */
|
|
46
|
+
readonly exitCode: number;
|
|
47
|
+
readonly lines: ReadonlyArray<string>;
|
|
48
|
+
};
|
|
49
|
+
export type SloProbeInput = {
|
|
50
|
+
/** SLO ttft target in ms, from the lowered spec. Undefined ⇒ nothing to probe. */
|
|
51
|
+
readonly ttftTargetMs: number | undefined;
|
|
52
|
+
/** The spec's current agent model string (for candidate naming). */
|
|
53
|
+
readonly currentModel: string | undefined;
|
|
54
|
+
/** Durable sessions.jsonl text (`.crewhaus/metrics/sessions.jsonl`), or "". */
|
|
55
|
+
readonly sessionsJsonl: string;
|
|
56
|
+
/** Optional histogram fallback source (metrics-collector snapshot). */
|
|
57
|
+
readonly histogram?: HistogramSeries;
|
|
58
|
+
readonly pricing?: PricingTable;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Run the TTFT probe: build the human-readable report + the exit code. Pure —
|
|
62
|
+
* the CLI reads the files and owns `process.exit`. Precedence: durable
|
|
63
|
+
* sessions.jsonl first (the alert-watchdog's own history), then the histogram
|
|
64
|
+
* fallback. No target ⇒ exit 0 with a "nothing declared" note; no data ⇒ exit 0
|
|
65
|
+
* (a HEALTHCHECK must not flap on a store that has not accrued history yet).
|
|
66
|
+
*/
|
|
67
|
+
export declare function runSloProbe(input: SloProbeInput): SloProbeResult;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { DEFAULT_PRICING, enumerateCandidates, providerOfSpecString, } from "@crewhaus/cost-tracker";
|
|
2
|
+
/**
|
|
3
|
+
* Derive an approximate p-th percentile from a cumulative histogram's buckets —
|
|
4
|
+
* the coarse bucket-boundary estimate a Prometheus `histogram_quantile` gives.
|
|
5
|
+
* Returns the upper edge of the first bucket whose cumulative count reaches the
|
|
6
|
+
* rank. Empty ⇒ 0. The registry stores `model_ttft_seconds` in SECONDS, so the
|
|
7
|
+
* caller multiplies by 1000 for a millisecond comparison.
|
|
8
|
+
*/
|
|
9
|
+
export function percentileFromHistogram(series, p) {
|
|
10
|
+
if (series.total === 0 || series.buckets.length === 0)
|
|
11
|
+
return 0;
|
|
12
|
+
const rank = p * series.total;
|
|
13
|
+
for (let i = 0; i < series.buckets.length; i += 1) {
|
|
14
|
+
if ((series.counts[i] ?? 0) >= rank)
|
|
15
|
+
return series.buckets[i] ?? 0;
|
|
16
|
+
}
|
|
17
|
+
// Above the top finite bucket → report the top edge (the +Inf bucket has no
|
|
18
|
+
// upper bound to name).
|
|
19
|
+
return series.buckets[series.buckets.length - 1] ?? 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Compute the trailing-N mean of the durable per-session `ttftP95Seconds`
|
|
23
|
+
* values from a sessions.jsonl text (torn lines skipped). Returns undefined
|
|
24
|
+
* when there is no usable history, so the probe can report "no data" rather
|
|
25
|
+
* than a spurious 0ms. `windowSessions` bounds how much recency the probe
|
|
26
|
+
* trusts (default 20 — a working week of runs).
|
|
27
|
+
*/
|
|
28
|
+
export function recentTtftP95Ms(sessionsJsonl, windowSessions = 20) {
|
|
29
|
+
const values = [];
|
|
30
|
+
for (const line of sessionsJsonl.split("\n")) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (trimmed === "")
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
const snap = JSON.parse(trimmed);
|
|
36
|
+
if (typeof snap.ttftP95Seconds === "number" && Number.isFinite(snap.ttftP95Seconds)) {
|
|
37
|
+
values.push(snap.ttftP95Seconds * 1000);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// torn/append-in-progress line — skip.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (values.length === 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const window = values.slice(-windowSessions);
|
|
47
|
+
return window.reduce((s, v) => s + v, 0) / window.length;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Name faster-candidate models for a slow slot, cheapest blended price first.
|
|
51
|
+
* Cheaper models are smaller and — as a first-order proxy — faster to first
|
|
52
|
+
* token; the probe surfaces them as CANDIDATES to eval, never as an automatic
|
|
53
|
+
* swap (a spec change stays human-reviewed). Excludes the current model's own
|
|
54
|
+
* family and known sunsets. Empty when the current model isn't table-backed
|
|
55
|
+
* (local / azure / named-host) — the probe then just reports the breach.
|
|
56
|
+
*/
|
|
57
|
+
export function nameFasterCandidates(currentModel, opts = {}) {
|
|
58
|
+
const parsed = providerOfSpecString(currentModel);
|
|
59
|
+
if (parsed === undefined)
|
|
60
|
+
return [];
|
|
61
|
+
const pricing = opts.pricing ?? DEFAULT_PRICING;
|
|
62
|
+
const candidates = enumerateCandidates(parsed, {
|
|
63
|
+
pricing,
|
|
64
|
+
sameProviderOnly: true,
|
|
65
|
+
excludeCurrent: true,
|
|
66
|
+
excludeSunsets: true,
|
|
67
|
+
});
|
|
68
|
+
const limit = opts.limit ?? 3;
|
|
69
|
+
return candidates.slice(0, limit).map((c) => ({
|
|
70
|
+
modelString: c.modelString,
|
|
71
|
+
blendedPer1M: c.blendedPer1M,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run the TTFT probe: build the human-readable report + the exit code. Pure —
|
|
76
|
+
* the CLI reads the files and owns `process.exit`. Precedence: durable
|
|
77
|
+
* sessions.jsonl first (the alert-watchdog's own history), then the histogram
|
|
78
|
+
* fallback. No target ⇒ exit 0 with a "nothing declared" note; no data ⇒ exit 0
|
|
79
|
+
* (a HEALTHCHECK must not flap on a store that has not accrued history yet).
|
|
80
|
+
*/
|
|
81
|
+
export function runSloProbe(input) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
if (input.ttftTargetMs === undefined) {
|
|
84
|
+
lines.push("~ SLO ttft: no observability.slo.ttft_ms declared in the cwd spec — nothing to probe");
|
|
85
|
+
return { exitCode: 0, lines };
|
|
86
|
+
}
|
|
87
|
+
let observedMs = recentTtftP95Ms(input.sessionsJsonl);
|
|
88
|
+
let source = ".crewhaus/metrics/sessions.jsonl";
|
|
89
|
+
if (observedMs === undefined && input.histogram !== undefined) {
|
|
90
|
+
observedMs = percentileFromHistogram(input.histogram, 0.95) * 1000;
|
|
91
|
+
source = "metrics histogram (model_ttft_seconds)";
|
|
92
|
+
}
|
|
93
|
+
if (observedMs === undefined) {
|
|
94
|
+
lines.push("~ SLO ttft: no recent TTFT history yet (run the agent a few times so `.crewhaus/metrics/sessions.jsonl` accrues) — probe skipped");
|
|
95
|
+
return { exitCode: 0, lines };
|
|
96
|
+
}
|
|
97
|
+
const target = input.ttftTargetMs;
|
|
98
|
+
const within = observedMs <= target;
|
|
99
|
+
const fmt = (n) => `${Math.round(n)}ms`;
|
|
100
|
+
if (within) {
|
|
101
|
+
lines.push(`✓ SLO ttft: p95 ${fmt(observedMs)} within target ${fmt(target)} (source: ${source})`);
|
|
102
|
+
return { exitCode: 0, lines };
|
|
103
|
+
}
|
|
104
|
+
lines.push(`✗ SLO ttft: p95 ${fmt(observedMs)} exceeds target ${fmt(target)} (source: ${source})`);
|
|
105
|
+
const candidates = input.currentModel !== undefined
|
|
106
|
+
? nameFasterCandidates(input.currentModel, {
|
|
107
|
+
...(input.pricing !== undefined ? { pricing: input.pricing } : {}),
|
|
108
|
+
})
|
|
109
|
+
: [];
|
|
110
|
+
if (candidates.length > 0) {
|
|
111
|
+
const list = candidates.map((c) => c.modelString).join(",");
|
|
112
|
+
lines.push(` faster candidates (cheaper ⇒ smaller ⇒ lower TTFT, same provider): ${list}`);
|
|
113
|
+
lines.push(` confirm the swap holds quality: crewhaus eval --models ${input.currentModel},${list}`);
|
|
114
|
+
}
|
|
115
|
+
else if (input.currentModel !== undefined) {
|
|
116
|
+
lines.push(` no table-backed faster candidates for ${input.currentModel} (local/named-host model) — measure alternatives with crewhaus eval --models <m1,m2>`);
|
|
117
|
+
}
|
|
118
|
+
return { exitCode: 1, lines };
|
|
119
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops item 37 — construct the `SloMitigationSink` the runtime SLO monitor walks
|
|
3
|
+
* on a sustained breach. Three ladder rungs beyond the trace event, all
|
|
4
|
+
* best-effort + injected so this entry-file module stays testable and no
|
|
5
|
+
* deploy/gateway/audit I/O happens at import time:
|
|
6
|
+
*
|
|
7
|
+
* - `audit` → a tamper-evident `slo_mitigation` record on the
|
|
8
|
+
* hash-chained audit log (so the mitigation history is
|
|
9
|
+
* itself auditable), for EVERY attempted rung;
|
|
10
|
+
* - `alert` → reuses the same delivery the alert watchdog builds
|
|
11
|
+
* (settings.json `alert` hook + webhook POST) — an SLO
|
|
12
|
+
* breach is an alert;
|
|
13
|
+
* - `pauseIntake` → flips a durable intake gate (`.crewhaus/slo/intake.json`
|
|
14
|
+
* `{ paused: true }`) that the gateway/managed daemon reads
|
|
15
|
+
* to send new requests down the 429 `budget_exceeded` path;
|
|
16
|
+
* the SAME writer is invoked with `paused:false` (resume)
|
|
17
|
+
* when the breach clears, so admission re-opens without
|
|
18
|
+
* operator intervention;
|
|
19
|
+
* - `rollback` → auto-rollback the env pin to the LAST-KNOWN-GOOD version —
|
|
20
|
+
* the version that was pinned to the env immediately before
|
|
21
|
+
* the current one, read from the `deployment_action` audit
|
|
22
|
+
* history (see {@link lastKnownGoodFromAuditRecords}), NOT a
|
|
23
|
+
* lexicographic guess — via the injected deployment-controller.
|
|
24
|
+
*
|
|
25
|
+
* Side-effect-free + injected (audit log, alert delivery, intake writer, rollback
|
|
26
|
+
* closure) mirroring `alert-sink.ts`. The CLI `run` path wires the real
|
|
27
|
+
* `openAuditLog`, the reused alert sink, an fs-backed intake writer, and a
|
|
28
|
+
* deployment-controller `rollback` when a spec-registry + `slo.rollback` target
|
|
29
|
+
* are present.
|
|
30
|
+
*/
|
|
31
|
+
import type { SloMitigationEvent, SloMitigationSink } from "@crewhaus/runtime-core";
|
|
32
|
+
/** Minimal audit seam — structurally satisfied by `@crewhaus/audit-log`. */
|
|
33
|
+
export type SloAuditSink = {
|
|
34
|
+
append(input: {
|
|
35
|
+
readonly kind: "slo_mitigation";
|
|
36
|
+
readonly payload: unknown;
|
|
37
|
+
}): Promise<unknown>;
|
|
38
|
+
};
|
|
39
|
+
/** The intake-gate writer seam. Flips a durable pause flag the daemon reads.
|
|
40
|
+
* Called with `paused:true` on the `pause-intake` rung and `paused:false` when
|
|
41
|
+
* the breach clears (resume), so the gate can honestly re-open admission. */
|
|
42
|
+
export type IntakeGateWriter = (paused: boolean, reason: string) => Promise<void>;
|
|
43
|
+
/** The rollback seam — a deployment-controller `rollback` closure bound to the
|
|
44
|
+
* harness name + env + last-known-good version. */
|
|
45
|
+
export type SloRollback = (event: SloMitigationEvent) => Promise<void>;
|
|
46
|
+
/** The alert-delivery seam — reuses the alert watchdog's fireAlertHook shape. */
|
|
47
|
+
export type SloAlertDelivery = (event: SloMitigationEvent) => Promise<void>;
|
|
48
|
+
export type BuildSloSinkOptions = {
|
|
49
|
+
/** Durable audit log (undefined ⇒ no audit records). */
|
|
50
|
+
readonly audit?: SloAuditSink;
|
|
51
|
+
/** Alert delivery (undefined ⇒ the `alert` rung is a no-op, still audited). */
|
|
52
|
+
readonly alert?: SloAlertDelivery;
|
|
53
|
+
/** Intake-gate writer (undefined ⇒ the `pause-intake` rung is a no-op). */
|
|
54
|
+
readonly pauseIntake?: IntakeGateWriter;
|
|
55
|
+
/** Rollback closure (undefined ⇒ the `rollback` rung is a no-op). */
|
|
56
|
+
readonly rollback?: SloRollback;
|
|
57
|
+
/** Warn sink for best-effort failures (defaults to stderr). */
|
|
58
|
+
readonly warn?: (line: string) => void;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Build the `SloMitigationSink`, or `undefined` when NO channel is configured
|
|
62
|
+
* (the caller then spreads nothing and the monitor keeps only its trace-event
|
|
63
|
+
* behaviour). Every rung is best-effort: a failure is warned, never thrown — a
|
|
64
|
+
* mitigation-delivery hiccup must not fail a run (the monitor's own catch also
|
|
65
|
+
* guards this, but a double floor is cheap).
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildSloSink(opts: BuildSloSinkOptions): SloMitigationSink | undefined;
|
|
68
|
+
/** The on-disk intake-gate shape read by the gateway/managed daemon. Paused
|
|
69
|
+
* ⇒ new requests get the 429 `budget_exceeded` path until an operator clears
|
|
70
|
+
* the flag (`crewhaus ... ` or by deleting the file). */
|
|
71
|
+
export type IntakeGateFile = {
|
|
72
|
+
readonly version: 1;
|
|
73
|
+
readonly paused: boolean;
|
|
74
|
+
readonly reason?: string;
|
|
75
|
+
readonly ts: number;
|
|
76
|
+
};
|
|
77
|
+
/** Build the JSON payload for the durable intake gate (pure — the caller owns
|
|
78
|
+
* the write). Kept next to the reader shape so the two can't drift. */
|
|
79
|
+
export declare function intakeGatePayload(paused: boolean, reason: string, now?: number): IntakeGateFile;
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the LAST-KNOWN-GOOD version to roll `name`'s `env` pin back to, from
|
|
82
|
+
* the `deployment_action` audit history — the actual version that was pinned to
|
|
83
|
+
* `env` immediately BEFORE `current`, never a lexicographic guess.
|
|
84
|
+
*
|
|
85
|
+
* The predecessor is the `fromVersion` of the most recent `deployment_action`
|
|
86
|
+
* that set `env` to `current` (a promote with `toEnv === env` OR a rollback with
|
|
87
|
+
* `env === env`, in both cases `toVersion === current`). Records are consulted
|
|
88
|
+
* newest-first by `ts` so an env that flip-flopped (v1 → v9 → v1 → v9) rolls
|
|
89
|
+
* back to whatever was live just before THIS pin, not an ancient one.
|
|
90
|
+
*
|
|
91
|
+
* Returns `undefined` when the history reveals no distinct predecessor (e.g. the
|
|
92
|
+
* env was pinned once via a raw `spec pin`, or the recorded predecessor equals
|
|
93
|
+
* `current`). The caller MUST treat `undefined` as "cannot safely roll back —
|
|
94
|
+
* skip", never fall back to a lexicographic pick.
|
|
95
|
+
*/
|
|
96
|
+
export declare function lastKnownGoodFromAuditRecords(records: ReadonlyArray<unknown>, name: string, env: string, current: string | undefined): string | undefined;
|
package/dist/slo-sink.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the `SloMitigationSink`, or `undefined` when NO channel is configured
|
|
3
|
+
* (the caller then spreads nothing and the monitor keeps only its trace-event
|
|
4
|
+
* behaviour). Every rung is best-effort: a failure is warned, never thrown — a
|
|
5
|
+
* mitigation-delivery hiccup must not fail a run (the monitor's own catch also
|
|
6
|
+
* guards this, but a double floor is cheap).
|
|
7
|
+
*/
|
|
8
|
+
export function buildSloSink(opts) {
|
|
9
|
+
const hasAny = opts.audit !== undefined ||
|
|
10
|
+
opts.alert !== undefined ||
|
|
11
|
+
opts.pauseIntake !== undefined ||
|
|
12
|
+
opts.rollback !== undefined;
|
|
13
|
+
if (!hasAny)
|
|
14
|
+
return undefined;
|
|
15
|
+
const warn = opts.warn ?? ((line) => process.stderr.write(`${line}\n`));
|
|
16
|
+
const sink = {};
|
|
17
|
+
if (opts.audit !== undefined) {
|
|
18
|
+
sink.audit = async (event) => {
|
|
19
|
+
await opts.audit?.append({ kind: "slo_mitigation", payload: event }).catch((err) => {
|
|
20
|
+
warn(`[slo] audit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (opts.alert !== undefined) {
|
|
25
|
+
sink.alert = async (event) => {
|
|
26
|
+
await opts.alert?.(event).catch((err) => {
|
|
27
|
+
warn(`[slo] alert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (opts.pauseIntake !== undefined) {
|
|
32
|
+
sink.pauseIntake = async (event) => {
|
|
33
|
+
await opts.pauseIntake?.(true, `SLO breach: ${event.breach.detail}`).catch((err) => {
|
|
34
|
+
warn(`[slo] pause-intake failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
// Resume is the SAME durable gate flipped back to `paused:false` when the
|
|
38
|
+
// breach clears, so admission re-opens without operator intervention.
|
|
39
|
+
sink.resumeIntake = async (event) => {
|
|
40
|
+
await opts.pauseIntake?.(false, `SLO recovered: ${event.breach.detail}`).catch((err) => {
|
|
41
|
+
warn(`[slo] resume-intake failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (opts.rollback !== undefined) {
|
|
46
|
+
sink.rollback = async (event) => {
|
|
47
|
+
await opts.rollback?.(event).catch((err) => {
|
|
48
|
+
warn(`[slo] rollback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return sink;
|
|
53
|
+
}
|
|
54
|
+
/** Build the JSON payload for the durable intake gate (pure — the caller owns
|
|
55
|
+
* the write). Kept next to the reader shape so the two can't drift. */
|
|
56
|
+
export function intakeGatePayload(paused, reason, now = Date.now()) {
|
|
57
|
+
return { version: 1, paused, ...(reason !== "" ? { reason } : {}), ts: now };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the LAST-KNOWN-GOOD version to roll `name`'s `env` pin back to, from
|
|
61
|
+
* the `deployment_action` audit history — the actual version that was pinned to
|
|
62
|
+
* `env` immediately BEFORE `current`, never a lexicographic guess.
|
|
63
|
+
*
|
|
64
|
+
* The predecessor is the `fromVersion` of the most recent `deployment_action`
|
|
65
|
+
* that set `env` to `current` (a promote with `toEnv === env` OR a rollback with
|
|
66
|
+
* `env === env`, in both cases `toVersion === current`). Records are consulted
|
|
67
|
+
* newest-first by `ts` so an env that flip-flopped (v1 → v9 → v1 → v9) rolls
|
|
68
|
+
* back to whatever was live just before THIS pin, not an ancient one.
|
|
69
|
+
*
|
|
70
|
+
* Returns `undefined` when the history reveals no distinct predecessor (e.g. the
|
|
71
|
+
* env was pinned once via a raw `spec pin`, or the recorded predecessor equals
|
|
72
|
+
* `current`). The caller MUST treat `undefined` as "cannot safely roll back —
|
|
73
|
+
* skip", never fall back to a lexicographic pick.
|
|
74
|
+
*/
|
|
75
|
+
export function lastKnownGoodFromAuditRecords(records, name, env, current) {
|
|
76
|
+
let best;
|
|
77
|
+
for (const raw of records) {
|
|
78
|
+
if (raw === null || typeof raw !== "object")
|
|
79
|
+
continue;
|
|
80
|
+
const rec = raw;
|
|
81
|
+
if (rec.kind !== "deployment_action")
|
|
82
|
+
continue;
|
|
83
|
+
const p = rec.payload;
|
|
84
|
+
if (p === null || typeof p !== "object")
|
|
85
|
+
continue;
|
|
86
|
+
const payload = p;
|
|
87
|
+
if (payload.name !== name)
|
|
88
|
+
continue;
|
|
89
|
+
// The env this action targeted: promote → toEnv, rollback → env.
|
|
90
|
+
const targetEnv = payload.action === "promote" ? payload.toEnv : payload.env;
|
|
91
|
+
if (targetEnv !== env)
|
|
92
|
+
continue;
|
|
93
|
+
// Only actions that produced the CURRENT pin tell us its predecessor. When
|
|
94
|
+
// `current` is unknown (no live pin), any prior good pin for the env works,
|
|
95
|
+
// so accept the newest action's fromVersion.
|
|
96
|
+
if (current !== undefined && payload.toVersion !== current)
|
|
97
|
+
continue;
|
|
98
|
+
const from = payload.fromVersion;
|
|
99
|
+
if (typeof from !== "string" || from === "" || from === current)
|
|
100
|
+
continue;
|
|
101
|
+
// Prefer the record with the LATEST timestamp (payload.ts, else record ts).
|
|
102
|
+
const ts = typeof payload.ts === "number" ? payload.ts : (rec.ts ?? 0);
|
|
103
|
+
if (best === undefined || ts >= best.ts)
|
|
104
|
+
best = { fromVersion: from, ts };
|
|
105
|
+
}
|
|
106
|
+
return best?.fromVersion;
|
|
107
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type WriteBackHeaderInfo } from "@crewhaus/spec-patch";
|
|
2
|
+
import type { RegistryAdapter } from "@crewhaus/spec-registry";
|
|
3
|
+
/** The per-spec changelog file, beside the registry's `manifest.json`. */
|
|
4
|
+
export declare const CHANGELOG_FILE = "CHANGELOG.md";
|
|
5
|
+
/**
|
|
6
|
+
* Map a spec's display name (spec `safeName`: letters, digits, spaces,
|
|
7
|
+
* `_ . - :`) onto the registry's stricter name grammar: runs of characters
|
|
8
|
+
* outside `[A-Za-z0-9_.-]` collapse to a single `-`, leading dots are
|
|
9
|
+
* stripped (the registry hides dot-dirs), and an empty result falls back to
|
|
10
|
+
* `"spec"`. Deterministic, so the same spec always lands in the same slot.
|
|
11
|
+
*/
|
|
12
|
+
export declare function registrySpecName(specName: string): string;
|
|
13
|
+
/** SHA-256 hex of the exact YAML text (the registry stores bytes verbatim). */
|
|
14
|
+
export declare function contentHash(yaml: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Next auto-version: `v<max+1>` over the existing `v<N>` versions (manifest
|
|
17
|
+
* order, numeric — `v10` beats `v2`). Manually-put versions that don't match
|
|
18
|
+
* `v<N>` (e.g. `2.0`, `release-1`) are ignored for numbering.
|
|
19
|
+
*/
|
|
20
|
+
export declare function nextVersion(existing: ReadonlyArray<string>): string;
|
|
21
|
+
/**
|
|
22
|
+
* Optimizer provenance for a changelog entry: the write-back header fields
|
|
23
|
+
* plus the `rationale` string the optimizer stored in that run's
|
|
24
|
+
* `patch.json` (the SpecPatch field documented as existing "for audit /
|
|
25
|
+
* write-back commit messages" — this is where it finally surfaces).
|
|
26
|
+
*/
|
|
27
|
+
export type OptimizeMetadata = WriteBackHeaderInfo & {
|
|
28
|
+
readonly rationale?: string;
|
|
29
|
+
};
|
|
30
|
+
export type ExtractOptimizeMetadataOptions = {
|
|
31
|
+
/** The YAML being registered (its leading comment block is scanned). */
|
|
32
|
+
readonly yaml: string;
|
|
33
|
+
/** Root of the optimizer run dirs (`.crewhaus/optimize`) for `<runId>/patch.json` lookup. */
|
|
34
|
+
readonly optimizeRootDir?: string | undefined;
|
|
35
|
+
/** Explicit patch.json path — the optimize --write-back hook knows its own out-dir (which `-o` may relocate). */
|
|
36
|
+
readonly patchJsonPath?: string | undefined;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* When the YAML carries a `formatWriteBackHeader` stamp, recover the run
|
|
40
|
+
* metadata and — if that run's `patch.json` is readable — its `rationale`.
|
|
41
|
+
* Returns `undefined` for content that was never written back. Never throws:
|
|
42
|
+
* provenance is best-effort garnish on the changelog, not a gate.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractOptimizeMetadata(opts: ExtractOptimizeMetadataOptions): OptimizeMetadata | undefined;
|
|
45
|
+
export type ChangelogEntryOptions = {
|
|
46
|
+
readonly version: string;
|
|
47
|
+
readonly yaml: string;
|
|
48
|
+
/** The previous version's YAML; omit for a spec's first version. */
|
|
49
|
+
readonly previousYaml?: string | undefined;
|
|
50
|
+
readonly optimizeRootDir?: string | undefined;
|
|
51
|
+
readonly patchJsonPath?: string | undefined;
|
|
52
|
+
/** Injectable clock for deterministic tests. */
|
|
53
|
+
readonly now?: Date | undefined;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Render one changelog entry (pure): `## <version> — <date>` followed by
|
|
57
|
+
* the field-level diff bullets vs the previous version (or `initial
|
|
58
|
+
* version`) and, when present, the optimizer provenance + rationale.
|
|
59
|
+
*/
|
|
60
|
+
export declare function renderChangelogEntry(opts: ChangelogEntryOptions): string;
|
|
61
|
+
/**
|
|
62
|
+
* Insert an entry into a changelog text, NEWEST FIRST (pure): the new entry
|
|
63
|
+
* lands above the previous top `## ` heading, under the `# Changelog — …`
|
|
64
|
+
* title. `crewhaus spec log` can therefore print the file verbatim and read
|
|
65
|
+
* most-recent-first.
|
|
66
|
+
*/
|
|
67
|
+
export declare function insertNewestFirst(existing: string, name: string, entry: string): string;
|
|
68
|
+
export type AppendChangelogOptions = ChangelogEntryOptions & {
|
|
69
|
+
/** Registry root (`.crewhaus/specs`) — the changelog lives beside `<name>/manifest.json`. */
|
|
70
|
+
readonly registryRootDir: string;
|
|
71
|
+
/** Registry-grammar spec name (already sanitized/validated by the caller's `put`). */
|
|
72
|
+
readonly name: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Append (newest-first) one entry to `<registryRoot>/<name>/CHANGELOG.md`,
|
|
76
|
+
* creating the file with its title on first use. Returns the file path.
|
|
77
|
+
*/
|
|
78
|
+
export declare function appendChangelogEntry(opts: AppendChangelogOptions): string;
|
|
79
|
+
export type AutoRegisterOptions = {
|
|
80
|
+
readonly registry: RegistryAdapter;
|
|
81
|
+
readonly registryRootDir: string;
|
|
82
|
+
/** The spec's display name straight from the YAML (sanitized internally). */
|
|
83
|
+
readonly specName: string;
|
|
84
|
+
/** The exact YAML text to register (including any write-back header). */
|
|
85
|
+
readonly yaml: string;
|
|
86
|
+
readonly optimizeRootDir?: string | undefined;
|
|
87
|
+
readonly patchJsonPath?: string | undefined;
|
|
88
|
+
readonly now?: Date | undefined;
|
|
89
|
+
};
|
|
90
|
+
export type AutoRegisterResult = {
|
|
91
|
+
readonly status: "registered" | "unchanged";
|
|
92
|
+
/** The registry-grammar name the spec landed under. */
|
|
93
|
+
readonly name: string;
|
|
94
|
+
readonly version: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Content-hash gated auto-put: when some stored version already carries this
|
|
98
|
+
* exact content, report `unchanged` (with that version); otherwise `put` the
|
|
99
|
+
* next `vN` and append the distilled changelog entry. The previous-latest
|
|
100
|
+
* version (manifest order) is the diff baseline.
|
|
101
|
+
*/
|
|
102
|
+
export declare function autoRegisterSpecVersion(opts: AutoRegisterOptions): Promise<AutoRegisterResult>;
|