@saadnvd1/agent-os 0.3.498 → 0.3.501
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 +70 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/data/usage/index.ts +28 -0
- package/lib/chat/drivers/claude-mapper.ts +1 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/events.ts +3 -0
- package/lib/chat/worker/host.ts +21 -0
- package/lib/chat/worker/protocol.ts +8 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +86 -0
- package/lib/orchestrator/armed.ts +2 -1
- package/lib/orchestrator/deliver.ts +49 -7
- package/lib/orchestrator/events.ts +35 -2
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
package/lib/usage/check-runs.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { db } from "../db";
|
|
9
|
+
import { recordCheckRunCall } from "./calls";
|
|
9
10
|
import { formatTokens } from "../chat/context";
|
|
10
11
|
|
|
11
12
|
export interface RunUsage {
|
|
@@ -93,27 +94,47 @@ export function recordCheckRun(
|
|
|
93
94
|
u: RunUsage,
|
|
94
95
|
at = Date.now()
|
|
95
96
|
): void {
|
|
96
|
-
db
|
|
97
|
-
|
|
97
|
+
const info = db
|
|
98
|
+
.prepare(
|
|
99
|
+
`INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
|
|
98
100
|
input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
|
|
99
101
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
102
|
+
)
|
|
103
|
+
.run(
|
|
104
|
+
target.workspaceId,
|
|
105
|
+
target.sessionId,
|
|
106
|
+
target.label,
|
|
107
|
+
target.prNumber,
|
|
108
|
+
target.sha,
|
|
109
|
+
target.kind,
|
|
110
|
+
u.model,
|
|
111
|
+
u.provider ?? "anthropic",
|
|
112
|
+
u.inputTokens,
|
|
113
|
+
u.cacheWriteTokens,
|
|
114
|
+
u.cacheReadTokens,
|
|
115
|
+
u.outputTokens,
|
|
116
|
+
u.costUsd,
|
|
117
|
+
u.durationMs,
|
|
118
|
+
at
|
|
119
|
+
);
|
|
120
|
+
try {
|
|
121
|
+
recordCheckRunCall(Number(info.lastInsertRowid), {
|
|
122
|
+
workspace_id: target.workspaceId,
|
|
123
|
+
session_id: target.sessionId,
|
|
124
|
+
label: target.label,
|
|
125
|
+
kind: target.kind,
|
|
126
|
+
model: u.model,
|
|
127
|
+
provider: u.provider ?? "anthropic",
|
|
128
|
+
input_tokens: u.inputTokens,
|
|
129
|
+
cache_write_tokens: u.cacheWriteTokens,
|
|
130
|
+
cache_read_tokens: u.cacheReadTokens,
|
|
131
|
+
output_tokens: u.outputTokens,
|
|
132
|
+
at,
|
|
133
|
+
});
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// The nightly backfill picks it up.
|
|
136
|
+
console.error(`[check-runs] ledger ${target.label}:`, e);
|
|
137
|
+
}
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
// A recorder for one check, that never lets a write break the check.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ratchet's thresholds, kept in the settings table so they can change
|
|
3
|
+
* without a deploy. Each falls back to its default for a missing or invalid
|
|
4
|
+
* value.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { db } from "../db";
|
|
8
|
+
|
|
9
|
+
export const USAGE_KEYS = {
|
|
10
|
+
costPct: "usage.alert_cost_pct",
|
|
11
|
+
cleanPoints: "usage.alert_clean_points",
|
|
12
|
+
baselineDays: "usage.baseline_days",
|
|
13
|
+
avgDays: "usage.avg_days",
|
|
14
|
+
minMerges: "usage.alert_min_merges",
|
|
15
|
+
weeklyStaleHours: "usage.weekly_stale_hours",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export interface UsageConfig {
|
|
19
|
+
// Alert when the 3-day cost per merged PR is this many percent over baseline.
|
|
20
|
+
costPct: number;
|
|
21
|
+
// Alert when the clean-merge rate is this many points under baseline.
|
|
22
|
+
cleanPoints: number;
|
|
23
|
+
baselineDays: number;
|
|
24
|
+
avgDays: number;
|
|
25
|
+
// Fewer merges than this over the average window: too little to judge.
|
|
26
|
+
minMerges: number;
|
|
27
|
+
weeklyStaleHours: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const USAGE_DEFAULTS: UsageConfig = {
|
|
31
|
+
costPct: 15,
|
|
32
|
+
cleanPoints: 10,
|
|
33
|
+
baselineDays: 7,
|
|
34
|
+
avgDays: 3,
|
|
35
|
+
minMerges: 3,
|
|
36
|
+
weeklyStaleHours: 6,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const DAY_COUNTS = new Set<keyof UsageConfig>(["baselineDays", "avgDays"]);
|
|
40
|
+
|
|
41
|
+
export function readUsageConfig(): UsageConfig {
|
|
42
|
+
const out: UsageConfig = { ...USAGE_DEFAULTS };
|
|
43
|
+
for (const [name, key] of Object.entries(USAGE_KEYS)) {
|
|
44
|
+
const row = db
|
|
45
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
46
|
+
.get(key) as { value: string } | undefined;
|
|
47
|
+
const v = row ? Number(row.value) : NaN;
|
|
48
|
+
const key2 = name as keyof UsageConfig;
|
|
49
|
+
// A day count is a whole number of days, at least one.
|
|
50
|
+
const ok = DAY_COUNTS.has(key2)
|
|
51
|
+
? Number.isInteger(v) && v >= 1
|
|
52
|
+
: Number.isFinite(v) && v >= 0;
|
|
53
|
+
if (ok) out[key2] = v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nightly rollup, inside the server, with no model: it imports the
|
|
3
|
+
* transcripts it couldn't watch, rolls each recent day up, moves the
|
|
4
|
+
* ratchet's baseline, and alerts once when the last days regressed. A light
|
|
5
|
+
* pass every half hour keeps today's row (and cost on task cards) current.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { notifyPhone } from "../notify";
|
|
10
|
+
import { backfillCheckRuns } from "./calls";
|
|
11
|
+
import { readUsageConfig } from "./config";
|
|
12
|
+
import type { Gh } from "./quality";
|
|
13
|
+
import {
|
|
14
|
+
applyAlertState,
|
|
15
|
+
evaluateAlerts,
|
|
16
|
+
loadBaseline,
|
|
17
|
+
nextAlertState,
|
|
18
|
+
nextBaseline,
|
|
19
|
+
openAlerts,
|
|
20
|
+
saveBaseline,
|
|
21
|
+
} from "./ratchet";
|
|
22
|
+
import { dayKey, dayStart, loadRollups, rollupDay, saveRollup } from "./rollup";
|
|
23
|
+
import { importTranscripts } from "./transcript-import";
|
|
24
|
+
|
|
25
|
+
const DAY = 24 * 3600 * 1000;
|
|
26
|
+
const LAST_RUN = "usage.last_nightly";
|
|
27
|
+
const TICK_MS = 30 * 60 * 1000;
|
|
28
|
+
// A day's escapes settle three days after it ends.
|
|
29
|
+
const SETTLE_MS = 3 * DAY;
|
|
30
|
+
|
|
31
|
+
export interface NightlyDeps {
|
|
32
|
+
now: () => number;
|
|
33
|
+
gh?: Gh;
|
|
34
|
+
notify: (text: string) => void;
|
|
35
|
+
transcriptsRoot?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const realDeps: NightlyDeps = {
|
|
39
|
+
now: Date.now,
|
|
40
|
+
notify: (text) => notifyPhone("usage", text),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const addDays = (day: string, n: number) =>
|
|
44
|
+
dayKey(dayStart(day) + n * DAY + DAY / 2);
|
|
45
|
+
|
|
46
|
+
function setting(key: string): string | null {
|
|
47
|
+
const r = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key) as
|
|
48
|
+
| { value: string }
|
|
49
|
+
| undefined;
|
|
50
|
+
return r?.value ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Imports what's new and keeps today's row current.
|
|
54
|
+
export async function lightPass(deps: NightlyDeps): Promise<void> {
|
|
55
|
+
const now = deps.now();
|
|
56
|
+
const seeded = loadRollups(1).length > 0;
|
|
57
|
+
await importTranscripts(
|
|
58
|
+
now - (seeded ? 3 : readUsageConfig().baselineDays + 1) * DAY,
|
|
59
|
+
400,
|
|
60
|
+
deps.transcriptsRoot
|
|
61
|
+
);
|
|
62
|
+
backfillCheckRuns();
|
|
63
|
+
const today = dayKey(now);
|
|
64
|
+
saveRollup(await rollupDay(today, now, undefined), false, now);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The once-a-day pass.
|
|
68
|
+
export async function nightly(deps: NightlyDeps): Promise<void> {
|
|
69
|
+
const now = deps.now();
|
|
70
|
+
const cfg = readUsageConfig();
|
|
71
|
+
const today = dayKey(now);
|
|
72
|
+
await lightPass(deps);
|
|
73
|
+
const existing = loadRollups(60);
|
|
74
|
+
const last = existing.filter((r) => r.day < today).at(-1)?.day;
|
|
75
|
+
let day = last ? addDays(last, -3) : addDays(today, -cfg.baselineDays);
|
|
76
|
+
for (; day < today; day = addDays(day, 1)) {
|
|
77
|
+
const final = dayStart(day) + DAY + SETTLE_MS <= now;
|
|
78
|
+
saveRollup(await rollupDay(day, now, deps.gh), final, now);
|
|
79
|
+
}
|
|
80
|
+
const complete = loadRollups(60).filter((r) => r.day < today);
|
|
81
|
+
const baseline = nextBaseline(loadBaseline(), complete, cfg, now);
|
|
82
|
+
if (baseline) saveBaseline(baseline);
|
|
83
|
+
const state = nextAlertState(
|
|
84
|
+
openAlerts(),
|
|
85
|
+
evaluateAlerts(complete, baseline, cfg)
|
|
86
|
+
);
|
|
87
|
+
applyAlertState(state, now);
|
|
88
|
+
for (const a of state.notify) deps.notify(a.message);
|
|
89
|
+
db.prepare(
|
|
90
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
91
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
92
|
+
).run(LAST_RUN, today);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Whether this server runs the rollup: a dev server only when asked, so it
|
|
96
|
+
// never alerts for a database that isn't real.
|
|
97
|
+
export function usageRollupEnabled(
|
|
98
|
+
env: Record<string, string | undefined>
|
|
99
|
+
): boolean {
|
|
100
|
+
const dev = env.AGENTOS_DEV === "1" || env.NODE_ENV !== "production";
|
|
101
|
+
return dev
|
|
102
|
+
? env.AGENTOS_USAGE_ROLLUP === "on"
|
|
103
|
+
: env.AGENTOS_USAGE_ROLLUP !== "off";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const g = globalThis as unknown as {
|
|
107
|
+
__agentosUsage?: { stop: () => void };
|
|
108
|
+
__agentosUsageBusy?: boolean;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Runs a pass unless one is already running, in this process: the timer and
|
|
112
|
+
// the API route share the flag (on globalThis, as the server and the routes
|
|
113
|
+
// load separate copies of this module). Returns whether it ran.
|
|
114
|
+
export async function exclusive(pass: () => Promise<void>): Promise<boolean> {
|
|
115
|
+
if (g.__agentosUsageBusy) return false;
|
|
116
|
+
g.__agentosUsageBusy = true;
|
|
117
|
+
try {
|
|
118
|
+
await pass();
|
|
119
|
+
} finally {
|
|
120
|
+
g.__agentosUsageBusy = false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const usageBusy = () => !!g.__agentosUsageBusy;
|
|
126
|
+
|
|
127
|
+
// The due pass: the full one once a day, just after midnight.
|
|
128
|
+
const duePass = (deps: NightlyDeps) =>
|
|
129
|
+
setting(LAST_RUN) !== dayKey(deps.now()) ? nightly(deps) : lightPass(deps);
|
|
130
|
+
|
|
131
|
+
export function startUsageRollup(deps: NightlyDeps = realDeps): {
|
|
132
|
+
stop: () => void;
|
|
133
|
+
} {
|
|
134
|
+
if (g.__agentosUsage) return g.__agentosUsage;
|
|
135
|
+
let timer: NodeJS.Timeout | undefined;
|
|
136
|
+
let stopped = false;
|
|
137
|
+
const run = () =>
|
|
138
|
+
exclusive(() => duePass(deps)).catch((error) =>
|
|
139
|
+
console.error("[usage] rollup failed:", error)
|
|
140
|
+
);
|
|
141
|
+
const next = () => {
|
|
142
|
+
if (stopped) return;
|
|
143
|
+
timer = setTimeout(() => void run().finally(next), TICK_MS);
|
|
144
|
+
timer.unref();
|
|
145
|
+
};
|
|
146
|
+
// Not at boot: the server is busy resuming sessions then.
|
|
147
|
+
timer = setTimeout(() => void run().finally(next), 60_000);
|
|
148
|
+
timer.unref();
|
|
149
|
+
g.__agentosUsage = {
|
|
150
|
+
stop: () => {
|
|
151
|
+
stopped = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
g.__agentosUsage = undefined;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
return g.__agentosUsage;
|
|
157
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality of the work that merged, from data AgentOS already keeps (the
|
|
3
|
+
* orchestrator's checks, gate failures, asks and brief amendments) and, for
|
|
4
|
+
* escapes, the merged PR list from gh. No model is involved.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { db } from "../db";
|
|
8
|
+
import { run as runCmd } from "../tasks/gh";
|
|
9
|
+
|
|
10
|
+
export interface MergedTask {
|
|
11
|
+
id: string;
|
|
12
|
+
projectId: string | null;
|
|
13
|
+
prNumber: number | null;
|
|
14
|
+
mergedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TaskQuality {
|
|
18
|
+
id: string;
|
|
19
|
+
reviewed: boolean;
|
|
20
|
+
clean: boolean;
|
|
21
|
+
// Heads checked after the first reviewed one.
|
|
22
|
+
fixRounds: number;
|
|
23
|
+
gateFailures: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DayQuality {
|
|
27
|
+
merged: number;
|
|
28
|
+
clean: number;
|
|
29
|
+
fixRounds: number;
|
|
30
|
+
escalations: number;
|
|
31
|
+
scopeChanges: number;
|
|
32
|
+
// Null when gh couldn't say.
|
|
33
|
+
escapes: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CheckRow {
|
|
37
|
+
id: number;
|
|
38
|
+
session_id: string;
|
|
39
|
+
sha: string;
|
|
40
|
+
kind: string;
|
|
41
|
+
status: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Clean: the first reviewed head passed review and CI, nothing failed a
|
|
45
|
+
// gate, and no later head was checked. A merge with no review at all isn't
|
|
46
|
+
// clean: it can't be told apart from one that skipped the gate.
|
|
47
|
+
export function taskQuality(
|
|
48
|
+
id: string,
|
|
49
|
+
checks: CheckRow[],
|
|
50
|
+
gateFailures: number
|
|
51
|
+
): TaskQuality {
|
|
52
|
+
const mine = checks
|
|
53
|
+
.filter((c) => c.session_id === id)
|
|
54
|
+
.sort((a, b) => a.id - b.id);
|
|
55
|
+
const first = mine.find(
|
|
56
|
+
(c) => c.kind === "review" && (c.status === "pass" || c.status === "block")
|
|
57
|
+
);
|
|
58
|
+
if (!first)
|
|
59
|
+
return { id, reviewed: false, clean: false, fixRounds: 0, gateFailures };
|
|
60
|
+
const ci = mine.find((c) => c.kind === "ci" && c.sha === first.sha);
|
|
61
|
+
const later = new Set(
|
|
62
|
+
mine.filter((c) => c.id > first.id && c.sha !== first.sha).map((c) => c.sha)
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
reviewed: true,
|
|
67
|
+
clean:
|
|
68
|
+
first.status === "pass" &&
|
|
69
|
+
ci?.status === "pass" &&
|
|
70
|
+
gateFailures === 0 &&
|
|
71
|
+
later.size === 0,
|
|
72
|
+
fixRounds: later.size,
|
|
73
|
+
gateFailures,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sqlTime = (ms: number) =>
|
|
78
|
+
new Date(ms).toISOString().slice(0, 19).replace("T", " ");
|
|
79
|
+
|
|
80
|
+
export function mergedTasks(from: number, to: number): MergedTask[] {
|
|
81
|
+
return (
|
|
82
|
+
db
|
|
83
|
+
.prepare(
|
|
84
|
+
`SELECT id, project_id AS projectId, pr_number AS prNumber, merged_at AS mergedAt
|
|
85
|
+
FROM sessions WHERE task_status = 'merged' AND merged_at >= ? AND merged_at < ?`
|
|
86
|
+
)
|
|
87
|
+
.all(sqlTime(from), sqlTime(to)) as MergedTask[]
|
|
88
|
+
).filter((t) => t.mergedAt);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function qualityOf(tasks: MergedTask[]): TaskQuality[] {
|
|
92
|
+
if (!tasks.length) return [];
|
|
93
|
+
const marks = tasks.map(() => "?").join(",");
|
|
94
|
+
const ids = tasks.map((t) => t.id);
|
|
95
|
+
const checks = db
|
|
96
|
+
.prepare(
|
|
97
|
+
`SELECT id, session_id, sha, kind, status FROM orchestrator_checks
|
|
98
|
+
WHERE session_id IN (${marks})`
|
|
99
|
+
)
|
|
100
|
+
.all(...ids) as CheckRow[];
|
|
101
|
+
const gates = new Map(
|
|
102
|
+
(
|
|
103
|
+
db
|
|
104
|
+
.prepare(
|
|
105
|
+
`SELECT session_id, SUM(count) AS n FROM orchestrator_gate_failures
|
|
106
|
+
WHERE session_id IN (${marks}) GROUP BY session_id`
|
|
107
|
+
)
|
|
108
|
+
.all(...ids) as { session_id: string; n: number }[]
|
|
109
|
+
).map((g) => [g.session_id, g.n])
|
|
110
|
+
);
|
|
111
|
+
return tasks.map((t) => taskQuality(t.id, checks, gates.get(t.id) ?? 0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Escalations to Saad in the range: gates that gave up on a task, and gate
|
|
115
|
+
// asks, counted once per task.
|
|
116
|
+
export function escalationsIn(from: number, to: number): number {
|
|
117
|
+
const f = sqlTime(from);
|
|
118
|
+
const t = sqlTime(to);
|
|
119
|
+
const rows = db
|
|
120
|
+
.prepare(
|
|
121
|
+
`SELECT session_id AS task FROM orchestrator_gate_failures
|
|
122
|
+
WHERE escalated_at >= ? AND escalated_at < ?
|
|
123
|
+
UNION
|
|
124
|
+
SELECT REPLACE(subject, 'task:', '') AS task FROM orchestrator_asks
|
|
125
|
+
WHERE kind = 'gate' AND created_at >= ? AND created_at < ?`
|
|
126
|
+
)
|
|
127
|
+
.all(f, t, f, t);
|
|
128
|
+
return rows.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function scopeChangesIn(from: number, to: number): number {
|
|
132
|
+
return (
|
|
133
|
+
db
|
|
134
|
+
.prepare(
|
|
135
|
+
`SELECT COUNT(*) AS n FROM brief_amendments WHERE created_at >= ? AND created_at < ?`
|
|
136
|
+
)
|
|
137
|
+
.get(sqlTime(from), sqlTime(to)) as { n: number }
|
|
138
|
+
).n;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface MergedPR {
|
|
142
|
+
number: number;
|
|
143
|
+
title: string;
|
|
144
|
+
mergedAt: number;
|
|
145
|
+
files: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const FIX = /^(revert|fix)(\([^)]*\))?!?:|^revert\b/i;
|
|
149
|
+
export const ESCAPE_WINDOW_MS = 3 * 24 * 3600 * 1000;
|
|
150
|
+
|
|
151
|
+
// PRs in `merged` that a later revert or fix PR touched the same files of,
|
|
152
|
+
// within three days of the merge.
|
|
153
|
+
export function escapesOf(merged: MergedPR[], all: MergedPR[]): MergedPR[] {
|
|
154
|
+
return merged.filter((p) =>
|
|
155
|
+
all.some(
|
|
156
|
+
(q) =>
|
|
157
|
+
q.number !== p.number &&
|
|
158
|
+
q.mergedAt > p.mergedAt &&
|
|
159
|
+
q.mergedAt - p.mergedAt <= ESCAPE_WINDOW_MS &&
|
|
160
|
+
FIX.test(q.title.trim()) &&
|
|
161
|
+
q.files.some((f) => p.files.includes(f))
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type Gh = (args: string[], cwd: string) => Promise<string>;
|
|
167
|
+
const realGh: Gh = (args, cwd) => runCmd("gh", args, cwd, 60_000);
|
|
168
|
+
|
|
169
|
+
function repoOf(projectId: string | null): string | null {
|
|
170
|
+
if (!projectId) return null;
|
|
171
|
+
const r = db
|
|
172
|
+
.prepare(
|
|
173
|
+
`SELECT path FROM project_repositories WHERE project_id = ?
|
|
174
|
+
ORDER BY is_primary DESC, sort_order LIMIT 1`
|
|
175
|
+
)
|
|
176
|
+
.get(projectId) as { path: string } | undefined;
|
|
177
|
+
return r?.path ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Escapes among the tasks merged in [from, to), looking three days past each
|
|
181
|
+
// merge. Null when gh couldn't say for any repo: unknown is not zero.
|
|
182
|
+
export async function escapesIn(
|
|
183
|
+
tasks: MergedTask[],
|
|
184
|
+
gh: Gh = realGh
|
|
185
|
+
): Promise<number | null> {
|
|
186
|
+
const withPR = tasks.filter((t) => t.prNumber !== null);
|
|
187
|
+
if (!withPR.length) return 0;
|
|
188
|
+
const byRepo = new Map<string, MergedTask[]>();
|
|
189
|
+
for (const t of withPR) {
|
|
190
|
+
const repo = repoOf(t.projectId);
|
|
191
|
+
if (!repo) return null;
|
|
192
|
+
byRepo.set(repo, [...(byRepo.get(repo) ?? []), t]);
|
|
193
|
+
}
|
|
194
|
+
let total = 0;
|
|
195
|
+
for (const [repo, ts] of byRepo) {
|
|
196
|
+
try {
|
|
197
|
+
const since = new Date(
|
|
198
|
+
Math.min(
|
|
199
|
+
...ts.map((t) => Date.parse(t.mergedAt.replace(" ", "T") + "Z"))
|
|
200
|
+
) - 86_400_000
|
|
201
|
+
)
|
|
202
|
+
.toISOString()
|
|
203
|
+
.slice(0, 10);
|
|
204
|
+
const out = await gh(
|
|
205
|
+
[
|
|
206
|
+
"pr",
|
|
207
|
+
"list",
|
|
208
|
+
"--state",
|
|
209
|
+
"merged",
|
|
210
|
+
"--search",
|
|
211
|
+
`merged:>=${since}`,
|
|
212
|
+
"--json",
|
|
213
|
+
"number,title,mergedAt,files",
|
|
214
|
+
"--limit",
|
|
215
|
+
"300",
|
|
216
|
+
],
|
|
217
|
+
repo
|
|
218
|
+
);
|
|
219
|
+
const all = (
|
|
220
|
+
JSON.parse(out) as {
|
|
221
|
+
number: number;
|
|
222
|
+
title: string;
|
|
223
|
+
mergedAt: string;
|
|
224
|
+
files?: { path: string }[];
|
|
225
|
+
}[]
|
|
226
|
+
).map((p) => ({
|
|
227
|
+
number: p.number,
|
|
228
|
+
title: p.title,
|
|
229
|
+
mergedAt: Date.parse(p.mergedAt),
|
|
230
|
+
files: (p.files ?? []).map((f) => f.path),
|
|
231
|
+
}));
|
|
232
|
+
const mine = all.filter((p) => ts.some((t) => t.prNumber === p.number));
|
|
233
|
+
total += escapesOf(mine, all).length;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return total;
|
|
239
|
+
}
|