@alphacoder-v0/pi-loops 0.19.3 → 0.22.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/CHANGELOG.md +164 -0
- package/CONTRACT.md +139 -0
- package/README.md +12 -7
- package/README.zh-CN.md +6 -4
- package/docs/cli.md +19 -1
- package/docs/downstream.md +60 -0
- package/docs/goal.md +4 -2
- package/docs/loops.md +55 -9
- package/docs/recipes.md +140 -18
- package/docs/session-archive.md +8 -3
- package/docs/web-ui-parity.md +11 -1
- package/package.json +6 -2
- package/recipes/_tracker/issue-tracker-local.md +3 -1
- package/recipes/autoresearch/experiment.md +30 -6
- package/recipes/autoresearch/recipe.toml +2 -0
- package/recipes/changelog-draft/recipe.toml +6 -1
- package/recipes/changelog-draft/release.md +25 -5
- package/recipes/ci-sweeper/README.md +9 -0
- package/recipes/ci-sweeper/recipe.toml +16 -0
- package/recipes/ci-sweeper/sweep.md +71 -0
- package/recipes/daily-digest/README.md +10 -0
- package/recipes/daily-digest/digest.md +65 -0
- package/recipes/daily-digest/recipe.toml +14 -0
- package/recipes/deps-sweeper/README.md +11 -0
- package/recipes/deps-sweeper/audit.md +99 -0
- package/recipes/deps-sweeper/recipe.toml +17 -0
- package/recipes/ecosystem/README.md +9 -0
- package/recipes/ecosystem/recipe.toml +15 -0
- package/recipes/ecosystem/scan.md +59 -0
- package/recipes/issue-loop/implement.md +17 -4
- package/recipes/issue-loop/labels.sh +18 -0
- package/recipes/issue-loop/recipe.toml +2 -0
- package/recipes/issue-loop/triage.md +16 -2
- package/recipes/pr-watch/README.md +9 -0
- package/recipes/pr-watch/recipe.toml +15 -0
- package/recipes/pr-watch/watch.md +70 -0
- package/skills/pi-loops/SKILL.md +3 -2
- package/src/archive.ts +97 -2
- package/src/cli.ts +103 -16
- package/src/goal.ts +11 -0
- package/src/hooks.ts +25 -1
- package/src/host-runtime.ts +4 -8
- package/src/inbox.ts +118 -55
- package/src/job-signal.ts +78 -0
- package/src/pi-loops.ts +177 -109
- package/src/presence.ts +5 -0
- package/src/protocol.ts +45 -2
- package/src/recipe.ts +158 -2
- package/src/scheduler.ts +6 -2
- package/src/store.ts +8 -3
- package/src/tools.ts +17 -11
- package/src/triggers.ts +8 -3
- package/src/web.mjs +73 -7
package/src/inbox.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { withFileLock, writeFileAtomic } from "./lock.ts";
|
|
12
|
-
import { INBOX_TEXT_MAX_CHARS, capChars } from "./protocol.ts";
|
|
12
|
+
import { INBOX_TEXT_MAX_CHARS, type FindingKind, capChars } from "./protocol.ts";
|
|
13
13
|
import { newId } from "./store.ts";
|
|
14
14
|
import { stamp } from "./schedule.ts";
|
|
15
15
|
|
|
@@ -18,25 +18,52 @@ export type InboxStatus = "new" | "claimed" | "dismissed";
|
|
|
18
18
|
/** Past this the inbox drops its oldest triaged entries (docs/configuration.md). */
|
|
19
19
|
export const INBOX_ROTATE_BYTES = 1_000_000;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A finding, in memory and on disk: the ten fields of the contract first, in their order, then
|
|
23
|
+
* pi-loops' own, which no interface names and which a line may be missing. This is the one shape —
|
|
24
|
+
* a line of `inbox.jsonl` is `JSON.stringify` of this, and `pi-loops inbox --json` is the first ten
|
|
25
|
+
* fields of it.
|
|
26
|
+
*/
|
|
21
27
|
export interface InboxEntry {
|
|
22
28
|
id: string;
|
|
23
|
-
|
|
29
|
+
/** RFC 3339, carrying this machine's UTC offset (`stamp()`). */
|
|
30
|
+
created_at: string;
|
|
31
|
+
status: InboxStatus;
|
|
32
|
+
/**
|
|
33
|
+
* `checkpoint` when the finding asks a person to decide (`protocol.findingKind`, read off the
|
|
34
|
+
* text when the run's findings are appended), `news` otherwise. Checkpoints are listed first,
|
|
35
|
+
* counted apart.
|
|
36
|
+
*/
|
|
37
|
+
kind: FindingKind;
|
|
24
38
|
/** Bounded origin label, e.g. "cron:check-issues" or "cron:cron-1a2b3c4d". */
|
|
25
39
|
source: string;
|
|
26
|
-
|
|
27
|
-
runId: string;
|
|
28
|
-
jobId: string;
|
|
40
|
+
run_id: string;
|
|
29
41
|
cwd: string;
|
|
42
|
+
text: string;
|
|
43
|
+
/** true = passed the checker; false = checker dropped it (never appended); null = no checker / unreviewed. */
|
|
44
|
+
verified: boolean | null;
|
|
45
|
+
/**
|
|
46
|
+
* Why a person dismissed it, when they said; null when they did not. A bare dismiss is silent; a
|
|
47
|
+
* reason goes back to the loop that reported the finding, in its next run's prompt
|
|
48
|
+
* (`Inbox.feedbackFor`).
|
|
49
|
+
*/
|
|
50
|
+
dismiss_reason: string | null;
|
|
51
|
+
job_id?: string;
|
|
30
52
|
/** Session that owned the loop when it reported; loops are machine-global here. */
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
claimedBy?: string;
|
|
34
|
-
/** true = passed the checker; false = checker dropped it (never appended); undefined = no checker / unreviewed. */
|
|
35
|
-
verified?: boolean;
|
|
53
|
+
session_id?: string;
|
|
54
|
+
claimed_by?: string;
|
|
36
55
|
/** Checker's reason when it kept a finding, if it gave one. */
|
|
37
|
-
|
|
56
|
+
verified_reason?: string;
|
|
57
|
+
/** When it was dismissed — what decides whether the loop's next run is shown the reason. */
|
|
58
|
+
dismissed_at?: string;
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
/** The fields a finding is, in their order — CONTRACT.md §2.2 and docs/downstream.md §3. */
|
|
62
|
+
export const FINDING_FIELDS = ["id", "created_at", "status", "kind", "source", "run_id", "cwd", "text", "verified", "dismiss_reason"] as const;
|
|
63
|
+
|
|
64
|
+
/** Reasons longer than a finding are a paragraph, and the prompt they go into is capped. */
|
|
65
|
+
const DISMISS_REASON_MAX_CHARS = INBOX_TEXT_MAX_CHARS;
|
|
66
|
+
|
|
40
67
|
export class Inbox {
|
|
41
68
|
readonly file: string;
|
|
42
69
|
private readonly lockPath: string;
|
|
@@ -46,14 +73,24 @@ export class Inbox {
|
|
|
46
73
|
this.lockPath = path.join(dir, "inbox.lock");
|
|
47
74
|
}
|
|
48
75
|
|
|
49
|
-
async append(entry: Omit<InboxEntry, "id" | "
|
|
76
|
+
async append(entry: Omit<InboxEntry, "id" | "created_at" | "status" | "kind" | "verified" | "dismiss_reason"> & Partial<Pick<InboxEntry, "kind" | "verified">>): Promise<InboxEntry> {
|
|
77
|
+
// Built in `FINDING_FIELDS` order, then pi-loops' own: the line is this object as it stands.
|
|
50
78
|
const full: InboxEntry = {
|
|
51
79
|
id: newId("inb"), // inb-<32 hex>, the shape every other id in pi-loops has
|
|
52
|
-
|
|
53
|
-
|
|
80
|
+
created_at: stamp(),
|
|
81
|
+
status: "new",
|
|
82
|
+
kind: entry.kind ?? "news",
|
|
54
83
|
source: capChars(entry.source, 80),
|
|
84
|
+
run_id: entry.run_id,
|
|
85
|
+
cwd: entry.cwd,
|
|
55
86
|
text: capChars(entry.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS),
|
|
56
|
-
|
|
87
|
+
verified: entry.verified ?? null,
|
|
88
|
+
dismiss_reason: null,
|
|
89
|
+
job_id: entry.job_id,
|
|
90
|
+
session_id: entry.session_id,
|
|
91
|
+
claimed_by: entry.claimed_by,
|
|
92
|
+
verified_reason: entry.verified_reason,
|
|
93
|
+
dismissed_at: entry.dismissed_at,
|
|
57
94
|
};
|
|
58
95
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
59
96
|
// Under the same lock as the triage rewrites: findings are appended by every pi window, the
|
|
@@ -62,7 +99,7 @@ export class Inbox {
|
|
|
62
99
|
// The lock is awaited, never spun on: a leftover lock directory from a killed process would
|
|
63
100
|
// otherwise block this process's event loop for the whole stale window.
|
|
64
101
|
await withFileLock(this.lockPath, () => {
|
|
65
|
-
fs.appendFileSync(this.file, `${JSON.stringify(
|
|
102
|
+
fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, "utf8");
|
|
66
103
|
this.rotate();
|
|
67
104
|
});
|
|
68
105
|
return full;
|
|
@@ -103,7 +140,7 @@ export class Inbox {
|
|
|
103
140
|
for (const line of text.split("\n")) {
|
|
104
141
|
if (!line.trim()) continue;
|
|
105
142
|
try {
|
|
106
|
-
const e =
|
|
143
|
+
const e = readFinding(JSON.parse(line));
|
|
107
144
|
if (e && typeof e.id === "string" && typeof e.text === "string") out.push(e);
|
|
108
145
|
} catch {
|
|
109
146
|
/* skip corrupt line */
|
|
@@ -112,8 +149,14 @@ export class Inbox {
|
|
|
112
149
|
return out;
|
|
113
150
|
}
|
|
114
151
|
|
|
152
|
+
/**
|
|
153
|
+
* What is waiting, in triage order: the checkpoints first — a decision a person owes — then the
|
|
154
|
+
* news, each group in the order it arrived. `/inbox` numbers this list and `claim`/`dismiss`
|
|
155
|
+
* resolve numbers against it, so the order is decided once, here.
|
|
156
|
+
*/
|
|
115
157
|
listNew(): InboxEntry[] {
|
|
116
|
-
|
|
158
|
+
const fresh = this.list().filter((e) => e.status === "new");
|
|
159
|
+
return [...fresh.filter((e) => e.kind === "checkpoint"), ...fresh.filter((e) => e.kind !== "checkpoint")];
|
|
117
160
|
}
|
|
118
161
|
|
|
119
162
|
/** Count of new entries; 0 on any error (badge path must never throw). */
|
|
@@ -125,13 +168,28 @@ export class Inbox {
|
|
|
125
168
|
}
|
|
126
169
|
}
|
|
127
170
|
|
|
128
|
-
|
|
171
|
+
/** How many of the new entries are checkpoints; 0 on any error, like `newCount`. */
|
|
172
|
+
decisionCount(): number {
|
|
173
|
+
try {
|
|
174
|
+
return this.listNew().filter((e) => e.kind === "checkpoint").length;
|
|
175
|
+
} catch {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `reason` is only read for a dismiss: what the person said, kept for the loop's next run. */
|
|
181
|
+
async setStatus(id: string, status: InboxStatus, claimedBy?: string, reason?: string): Promise<InboxEntry | undefined> {
|
|
129
182
|
return withFileLock(this.lockPath, () => {
|
|
130
183
|
const entries = this.list();
|
|
131
184
|
const entry = entries.find((e) => e.id === id);
|
|
132
185
|
if (!entry) return undefined;
|
|
133
186
|
entry.status = status;
|
|
134
|
-
if (claimedBy) entry.
|
|
187
|
+
if (claimedBy) entry.claimed_by = claimedBy;
|
|
188
|
+
if (status === "dismissed") {
|
|
189
|
+
entry.dismissed_at = stamp();
|
|
190
|
+
const why = reason?.replace(/\s+/g, " ").trim();
|
|
191
|
+
if (why) entry.dismiss_reason = capChars(why, DISMISS_REASON_MAX_CHARS);
|
|
192
|
+
}
|
|
135
193
|
this.rewrite(entries);
|
|
136
194
|
return entry;
|
|
137
195
|
});
|
|
@@ -141,10 +199,12 @@ export class Inbox {
|
|
|
141
199
|
async dismissAllNew(match?: (entry: InboxEntry) => boolean): Promise<number> {
|
|
142
200
|
return withFileLock(this.lockPath, () => {
|
|
143
201
|
const entries = this.list();
|
|
202
|
+
const at = stamp();
|
|
144
203
|
let changed = 0;
|
|
145
204
|
for (const e of entries) {
|
|
146
205
|
if (e.status === "new" && (!match || match(e))) {
|
|
147
206
|
e.status = "dismissed";
|
|
207
|
+
e.dismissed_at = at;
|
|
148
208
|
changed++;
|
|
149
209
|
}
|
|
150
210
|
}
|
|
@@ -153,35 +213,30 @@ export class Inbox {
|
|
|
153
213
|
});
|
|
154
214
|
}
|
|
155
215
|
|
|
216
|
+
/**
|
|
217
|
+
* What a person told one loop by dismissing its findings with a reason, since `since` (the start
|
|
218
|
+
* of the loop's previous run: everything before it was already shown to that run). Oldest first.
|
|
219
|
+
* A dismiss without a reason is not feedback — "not interesting" is not something the next run can
|
|
220
|
+
* act on, and the person did not ask it to — so it is not here. A reason dismissed before pi-loops
|
|
221
|
+
* stamped `dismissed_at` has no time to compare and is left out too.
|
|
222
|
+
*/
|
|
223
|
+
feedbackFor(jobId: string, since?: string): InboxEntry[] {
|
|
224
|
+
const after = since ? Date.parse(since) : Number.NEGATIVE_INFINITY;
|
|
225
|
+
return this.list().filter((e) => e.job_id === jobId && e.status === "dismissed" && !!e.dismiss_reason && !!e.dismissed_at && Date.parse(e.dismissed_at) > after);
|
|
226
|
+
}
|
|
227
|
+
|
|
156
228
|
private rewrite(entries: InboxEntry[]): void {
|
|
157
|
-
writeFileAtomic(this.file, entries.map((e) => JSON.stringify(
|
|
229
|
+
writeFileAtomic(this.file, entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""));
|
|
158
230
|
}
|
|
159
231
|
}
|
|
160
232
|
|
|
161
233
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
234
|
+
* A line as an entry. For a line this version wrote it is the identity; the two older shapes it
|
|
235
|
+
* still reads are pi-loops ≤ 0.1.2 (camelCase throughout) and the shape written until 0.21.0
|
|
236
|
+
* (`trace_id` for the run id, absent optional keys, no `kind` on news). A line in either is
|
|
237
|
+
* rewritten in this shape the next time its status changes.
|
|
166
238
|
*/
|
|
167
|
-
function
|
|
168
|
-
return {
|
|
169
|
-
id: e.id,
|
|
170
|
-
created_at: e.createdAt,
|
|
171
|
-
source: e.source,
|
|
172
|
-
text: e.text,
|
|
173
|
-
trace_id: e.runId,
|
|
174
|
-
session_id: e.sessionId ?? null,
|
|
175
|
-
status: e.status,
|
|
176
|
-
job_id: e.jobId,
|
|
177
|
-
cwd: e.cwd,
|
|
178
|
-
...(e.claimedBy !== undefined ? { claimed_by: e.claimedBy } : {}),
|
|
179
|
-
...(e.verified !== undefined ? { verified: e.verified } : {}),
|
|
180
|
-
...(e.verifiedReason !== undefined ? { verified_reason: e.verifiedReason } : {}),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function fromDisk(raw: any): InboxEntry | undefined {
|
|
239
|
+
function readFinding(raw: any): InboxEntry | undefined {
|
|
185
240
|
if (!raw || typeof raw !== "object") return undefined;
|
|
186
241
|
const pick = <T>(...keys: string[]): T | undefined => {
|
|
187
242
|
for (const k of keys) if (raw[k] !== undefined && raw[k] !== null) return raw[k] as T;
|
|
@@ -190,17 +245,20 @@ function fromDisk(raw: any): InboxEntry | undefined {
|
|
|
190
245
|
const status = pick<string>("status");
|
|
191
246
|
return {
|
|
192
247
|
id: String(raw.id ?? ""),
|
|
193
|
-
|
|
248
|
+
created_at: pick<string>("created_at", "createdAt") ?? "",
|
|
249
|
+
status: status === "claimed" || status === "dismissed" ? status : "new",
|
|
250
|
+
kind: raw.kind === "checkpoint" ? "checkpoint" : "news",
|
|
194
251
|
source: pick<string>("source") ?? "",
|
|
195
|
-
|
|
196
|
-
runId: pick<string>("trace_id", "runId") ?? "",
|
|
197
|
-
jobId: pick<string>("job_id", "jobId") ?? "",
|
|
252
|
+
run_id: pick<string>("run_id", "trace_id", "runId") ?? "",
|
|
198
253
|
cwd: pick<string>("cwd") ?? "",
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
254
|
+
text: String(raw.text ?? ""),
|
|
255
|
+
verified: pick<boolean>("verified") ?? null,
|
|
256
|
+
dismiss_reason: pick<string>("dismiss_reason") ?? null,
|
|
257
|
+
job_id: pick<string>("job_id", "jobId"),
|
|
258
|
+
session_id: pick<string>("session_id", "sessionId"),
|
|
259
|
+
claimed_by: pick<string>("claimed_by", "claimedBy"),
|
|
260
|
+
verified_reason: pick<string>("verified_reason", "verifiedReason"),
|
|
261
|
+
dismissed_at: pick<string>("dismissed_at"),
|
|
204
262
|
};
|
|
205
263
|
}
|
|
206
264
|
|
|
@@ -222,11 +280,16 @@ export function belongsToProject(entry: InboxEntry, cwd: string, sameProject: (a
|
|
|
222
280
|
return !entry.cwd || sameProject(entry.cwd, cwd);
|
|
223
281
|
}
|
|
224
282
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Resolve an id or a unique id prefix — and, with `ordinals: true`, "<n>", the 1-based position in
|
|
285
|
+
* `entries`. A number is a position in a list the caller printed, so only a caller that printed one
|
|
286
|
+
* may ask for it (`/inbox`, which numbers what it shows); everywhere else a finding is named by its
|
|
287
|
+
* id, and an all-digit ref resolves to nothing, as an unknown id does.
|
|
288
|
+
*/
|
|
289
|
+
export function resolveInboxRef(entries: InboxEntry[], ref: string, opts?: { ordinals?: boolean }): InboxEntry | undefined {
|
|
227
290
|
const trimmed = ref.trim();
|
|
228
291
|
if (!trimmed) return undefined;
|
|
229
|
-
if (/^\d+$/.test(trimmed)) return entries[Number(trimmed) - 1];
|
|
292
|
+
if (/^\d+$/.test(trimmed)) return opts?.ordinals ? entries[Number(trimmed) - 1] : undefined;
|
|
230
293
|
const matches = entries.filter((e) => e.id === trimmed || e.id.startsWith(trimmed));
|
|
231
294
|
return matches.length === 1 ? matches[0] : undefined;
|
|
232
295
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a loop's runs came to: findings filed, and what a person did with them.
|
|
3
|
+
*
|
|
4
|
+
* `/cron cost` has always said what a loop spends; nothing said whether anyone wanted what it
|
|
5
|
+
* bought. A loop whose every finding is dismissed is noise at whatever price, and a loop that has
|
|
6
|
+
* found nothing for a month is either watching a quiet thing or watching the wrong one. Both are
|
|
7
|
+
* questions about the run log and the inbox together, answerable without a scheduler or a clock —
|
|
8
|
+
* the shape of `job-health.ts`, for the other half of "is this loop worth keeping".
|
|
9
|
+
*/
|
|
10
|
+
import type { InboxEntry } from "./inbox.ts";
|
|
11
|
+
import type { RunRecord } from "./store.ts";
|
|
12
|
+
|
|
13
|
+
export interface LoopSignal {
|
|
14
|
+
/** Runs that finished in the window (failed ones included: they cost, and they are what `quiet` is measured against). */
|
|
15
|
+
runs: number;
|
|
16
|
+
/** Findings that reached the inbox in the window, from the run log. */
|
|
17
|
+
findings: number;
|
|
18
|
+
/** Of the findings the inbox still holds from this window: claimed, dismissed, and dismissed with a reason. */
|
|
19
|
+
claimed: number;
|
|
20
|
+
dismissed: number;
|
|
21
|
+
dismissedWithReason: number;
|
|
22
|
+
/** Consecutive finished runs, newest backwards over the whole log, that found nothing. Not bounded by the window. */
|
|
23
|
+
quiet: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** How long the `/cron` listing looks back. Long enough for a weekly loop to have a few runs in it. */
|
|
27
|
+
export const SIGNAL_WINDOW_MS = 30 * 86_400_000;
|
|
28
|
+
/** From here `/cron` marks a loop `quiet ×N`: three empty runs is where a loop stops looking merely between events. */
|
|
29
|
+
export const QUIET_MARK_AFTER = 3;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `runs` is the run log, any order; `inbox` the whole inbox. Both are filtered here so a caller can
|
|
33
|
+
* hand over what it already loaded. `sinceMs` bounds the counts; `quiet` is counted from the newest
|
|
34
|
+
* run backwards regardless of it, because a streak is a streak whenever it started.
|
|
35
|
+
*/
|
|
36
|
+
export function loopSignal(jobId: string, runs: RunRecord[], inbox: InboxEntry[], sinceMs: number): LoopSignal {
|
|
37
|
+
const own = runs.filter((r) => r.jobId === jobId);
|
|
38
|
+
const at = (iso: string) => Date.parse(iso);
|
|
39
|
+
const inWindow = own.filter((r) => {
|
|
40
|
+
const t = at(r.finishedAt || r.startedAt);
|
|
41
|
+
return Number.isFinite(t) && t >= sinceMs;
|
|
42
|
+
});
|
|
43
|
+
const findings = inWindow.reduce((n, r) => n + (r.findings ?? 0), 0);
|
|
44
|
+
let claimed = 0;
|
|
45
|
+
let dismissed = 0;
|
|
46
|
+
let dismissedWithReason = 0;
|
|
47
|
+
for (const e of inbox) {
|
|
48
|
+
if (e.job_id !== jobId) continue;
|
|
49
|
+
const t = at(e.created_at);
|
|
50
|
+
if (!Number.isFinite(t) || t < sinceMs) continue;
|
|
51
|
+
if (e.status === "claimed") claimed++;
|
|
52
|
+
else if (e.status === "dismissed") {
|
|
53
|
+
dismissed++;
|
|
54
|
+
if (e.dismiss_reason) dismissedWithReason++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// The log is appended in finishing order, so the tail is the newest; sort anyway in case a
|
|
58
|
+
// caller merged two sources. A run that failed found nothing, and counts toward the streak:
|
|
59
|
+
// a loop that has been erroring for a week is quiet in the sense that matters here too, and
|
|
60
|
+
// `job-health.ts` already says why.
|
|
61
|
+
const ordered = [...own].sort((a, b) => at(a.finishedAt || a.startedAt) - at(b.finishedAt || b.startedAt));
|
|
62
|
+
let quiet = 0;
|
|
63
|
+
for (let i = ordered.length - 1; i >= 0 && (ordered[i].findings ?? 0) === 0; i--) quiet++;
|
|
64
|
+
return { runs: inWindow.length, findings, claimed, dismissed, dismissedWithReason, quiet };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `9 findings · 2 claimed · 7 dismissed` — the counts a person reads a loop's worth off. Nothing
|
|
69
|
+
* when no finding was filed: a loop that found nothing has nothing to be judged by yet, and
|
|
70
|
+
* `quiet ×N` says that part.
|
|
71
|
+
*/
|
|
72
|
+
export function signalSummary(s: LoopSignal): string | undefined {
|
|
73
|
+
if (!s.findings) return undefined;
|
|
74
|
+
const parts = [`${s.findings} finding${s.findings === 1 ? "" : "s"}`];
|
|
75
|
+
if (s.claimed) parts.push(`${s.claimed} claimed`);
|
|
76
|
+
if (s.dismissed) parts.push(`${s.dismissed} dismissed${s.dismissedWithReason ? ` (${s.dismissedWithReason} with a reason)` : ""}`);
|
|
77
|
+
return parts.join(" · ");
|
|
78
|
+
}
|