@miroshnik/elves 0.0.0 → 2026.1018.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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +203 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +221 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4854 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +231 -0
- package/skills/task-env/proxy/compose.yaml +51 -0
- package/skills/task-env/reference.md +254 -0
- package/skills/task-env/scripts/task-env.ts +1017 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's "Sessions" tab — "In progress" tasks, their Claude Code sessions and what each one waits for.
|
|
3
|
+
*
|
|
4
|
+
* A task is a GitHub project item with the "In progress" status, its session is the `github task status` pin file
|
|
5
|
+
* (`<configuration directory>/sessions/<session id>` with an `owner/repo#N` line), the session's state is its
|
|
6
|
+
* transcript (`~/.claude/projects/<directory>/<id>.jsonl`), the task's PR and the tasks' questions come from GitHub, the
|
|
7
|
+
* addresses of its services — from the routes of the task-env proxy. Writes nothing to GitHub.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { questionItems } from "../../github/scripts/github.ts";
|
|
15
|
+
import locale from "../../github/scripts/locale.ts";
|
|
16
|
+
import { branchIssueNumber, cloudSessionsIn, encodePath, EstError, loadPins, PROJECTS_DIR } from "../../est/scripts/est.ts";
|
|
17
|
+
import { addressUrl, hostProject, taskAddress } from "../../task-env/scripts/task-env.ts";
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #280 schemaless JSON from transcripts and GitHub
|
|
20
|
+
type Any = any;
|
|
21
|
+
|
|
22
|
+
/** A session is silent if it has had no records for more than 15 min and nothing runs in the background. */
|
|
23
|
+
export const SILENCE = 15 * 60;
|
|
24
|
+
/**
|
|
25
|
+
* A background task without a completion notification runs no longer than 2 h — the Claude Code background command
|
|
26
|
+
* cap; after that it is lost.
|
|
27
|
+
*/
|
|
28
|
+
export const BG_MAX = 2 * 3600;
|
|
29
|
+
/** A session without an "In progress" task gets into "Waiting for the owner" if it wrote within the last day. */
|
|
30
|
+
export const RECENT = 86400;
|
|
31
|
+
/** "Decided auto" — answers of tasks whose description changed within the last day. */
|
|
32
|
+
export const AUTO_WINDOW = 86400;
|
|
33
|
+
/** The tab refreshes itself: seconds. */
|
|
34
|
+
export const REFRESH = 30;
|
|
35
|
+
/**
|
|
36
|
+
* The dashboard is shared by all repositories of the machine: labels are in English, and the names it parses (the
|
|
37
|
+
* "In progress" status, the questions section and label, the session's last line) — in any language of the github
|
|
38
|
+
* skill's locales dictionary.
|
|
39
|
+
*/
|
|
40
|
+
const IN_WORK = locale.every((n) => n.status.inProgress);
|
|
41
|
+
|
|
42
|
+
// ----------------------------------------------------------------------------
|
|
43
|
+
// Transcript
|
|
44
|
+
// ----------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** A session's background task: a command, a subagent or a monitor; `at` — the start, epoch seconds. */
|
|
47
|
+
export interface Bg {
|
|
48
|
+
id: string;
|
|
49
|
+
at: number;
|
|
50
|
+
what: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the session's transcript tells about it. */
|
|
54
|
+
export interface Transcript {
|
|
55
|
+
id: string;
|
|
56
|
+
/** The session's latest name; not renamed — null. */
|
|
57
|
+
title: string | null;
|
|
58
|
+
/** Time of the last record with a timestamp (and of subagent records), epoch seconds; no records — null. */
|
|
59
|
+
last: number | null;
|
|
60
|
+
/** Background tasks started without a completion notification, in start order. */
|
|
61
|
+
bg: Bg[];
|
|
62
|
+
/** The last turn is a model answer without a tool call: its text; otherwise null. */
|
|
63
|
+
answer: string | null;
|
|
64
|
+
/** The last record is a tool call without a result: the tool's name; otherwise null. */
|
|
65
|
+
pendingTool: string | null;
|
|
66
|
+
/** The session archived itself with its last turn. */
|
|
67
|
+
archived: boolean;
|
|
68
|
+
/** A routine: the session was started by a schedule prompt `<scheduled-task …>`, there's no human in it. */
|
|
69
|
+
routine: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parse = (line: string): Any => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(line);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const tsOf = (r: Any): number | null => {
|
|
80
|
+
const t = Date.parse(r?.timestamp ?? "");
|
|
81
|
+
return Number.isFinite(t) ? t / 1000 : null;
|
|
82
|
+
};
|
|
83
|
+
const blocks = (r: Any): Any[] => (Array.isArray(r?.message?.content) ? r.message.content.filter((b: Any) => b && typeof b === "object") : []);
|
|
84
|
+
const textOf = (c: unknown): string => (typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join("\n") : "");
|
|
85
|
+
const isToolResult = (r: Any) => blocks(r).some((b) => b.type === "tool_result");
|
|
86
|
+
const TASK_ID_RE = /<task-id>([^<\s]+)<\/task-id>/g;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parsing a Claude Code transcript. A background task is a tool result with `backgroundTaskId` (a command), `agentId`
|
|
90
|
+
* and `isAsync` (a subagent) or `taskId` (Monitor); its completion is a `<task-notification>` record with the same id
|
|
91
|
+
* or a TaskStop result. The last turn is the records from the end back to the prompt it started with. Lines are parsed
|
|
92
|
+
* only if they contain what's needed: a transcript can be tens of megabytes, and the tab re-reads it every half
|
|
93
|
+
* minute.
|
|
94
|
+
*/
|
|
95
|
+
export function parseTranscript(id: string, text: string): Transcript {
|
|
96
|
+
const lines = text.split("\n");
|
|
97
|
+
let title: string | null = null;
|
|
98
|
+
let first: string | null = null; // the session's first prompt
|
|
99
|
+
const launched: (Bg & { tool: string })[] = [];
|
|
100
|
+
const finished = new Set<string>();
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
if (first === null && line.includes('"type":"user"')) {
|
|
103
|
+
const r = parse(line);
|
|
104
|
+
if (r?.type === "user" && !r.isMeta && !r.isSidechain && !isToolResult(r)) first = textOf(r.message?.content);
|
|
105
|
+
}
|
|
106
|
+
if (line.includes('"custom-title"')) {
|
|
107
|
+
const r = parse(line);
|
|
108
|
+
if (r?.type === "custom-title" && r.customTitle) title = String(r.customTitle);
|
|
109
|
+
} else if (line.includes("<task-notification>")) {
|
|
110
|
+
const r = parse(line);
|
|
111
|
+
// a notification is a session prompt or one still queued for it; a tool's text that merely mentions it is not
|
|
112
|
+
const body = r?.type === "user" ? textOf(r.message?.content) : r?.type === "queue-operation" ? String(r.content ?? "") : "";
|
|
113
|
+
if (body.trimStart().startsWith("<task-notification>")) for (const m of body.matchAll(TASK_ID_RE)) finished.add(m[1]!);
|
|
114
|
+
} else if (line.includes("Successfully stopped task")) {
|
|
115
|
+
// a background task stopped by TaskStop sends no notification: its completion is the stop's result
|
|
116
|
+
const u = parse(line)?.toolUseResult;
|
|
117
|
+
const task = u?.task_id ?? u?.shell_id;
|
|
118
|
+
if (typeof task === "string" && /^Successfully stopped task/.test(String(u.message ?? ""))) finished.add(task);
|
|
119
|
+
} else if (line.includes('"toolUseResult"') && /"(backgroundTaskId|agentId|taskId)"/.test(line)) {
|
|
120
|
+
const r = parse(line);
|
|
121
|
+
const u = r?.type === "user" ? r.toolUseResult : null;
|
|
122
|
+
const task = u?.backgroundTaskId ?? (u?.isAsync ? u.agentId : null) ?? u?.taskId;
|
|
123
|
+
const at = tsOf(r);
|
|
124
|
+
const tool = blocks(r).find((b) => b.type === "tool_result")?.tool_use_id;
|
|
125
|
+
if (typeof task === "string" && at !== null) launched.push({ id: task, at, what: String(u.description ?? ""), tool: String(tool ?? "") });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// what a background task does — the description of its call
|
|
129
|
+
const want = new Set(launched.filter((b) => b.tool).map((b) => b.tool));
|
|
130
|
+
const what = new Map<string, string>();
|
|
131
|
+
if (want.size)
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
if (!line.includes('"tool_use"') || ![...want].some((t) => line.includes(t))) continue;
|
|
134
|
+
for (const b of blocks(parse(line))) if (b.type === "tool_use" && want.has(b.id)) what.set(b.id, String(b.input?.description || b.input?.command || b.name));
|
|
135
|
+
}
|
|
136
|
+
const bg = launched.filter((b) => !finished.has(b.id)).map((b) => ({ id: b.id, at: b.at, what: what.get(b.tool) || b.what || "background task" }));
|
|
137
|
+
|
|
138
|
+
let last: number | null = null;
|
|
139
|
+
const turn: Any[] = []; // records of the last turn, from the end
|
|
140
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
141
|
+
const line = lines[i]!;
|
|
142
|
+
if (!line || (last !== null && !line.includes('"type":"user"') && !line.includes('"type":"assistant"'))) continue;
|
|
143
|
+
const r = parse(line);
|
|
144
|
+
if (!r) continue;
|
|
145
|
+
last ??= tsOf(r);
|
|
146
|
+
if ((r.type !== "user" && r.type !== "assistant") || r.isSidechain) continue;
|
|
147
|
+
turn.push(r);
|
|
148
|
+
// a turn starts with a prompt: a human's or a notification; a meta insert (isMeta) and a tool result are inside it
|
|
149
|
+
if (r.type === "user" && !isToolResult(r) && !r.isMeta) break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let answer: string | null = null;
|
|
153
|
+
let pendingTool: string | null = null;
|
|
154
|
+
const end = turn[0];
|
|
155
|
+
if (end?.type === "assistant") {
|
|
156
|
+
const calls = blocks(end).filter((b) => b.type === "tool_use");
|
|
157
|
+
if (calls.length) pendingTool = String(calls[calls.length - 1].name);
|
|
158
|
+
else if (end.message?.stop_reason !== "tool_use") {
|
|
159
|
+
// the answer is the text of all records of the model's last message: the app writes a message's blocks one record
|
|
160
|
+
// per block
|
|
161
|
+
const texts: string[] = [];
|
|
162
|
+
for (const r of turn) {
|
|
163
|
+
if (r.type !== "assistant" || r.message?.id !== end.message?.id) break;
|
|
164
|
+
texts.unshift(...blocks(r).filter((b) => b.type === "text").map((b) => String(b.text ?? "")));
|
|
165
|
+
}
|
|
166
|
+
answer = texts.join("\n\n").trim();
|
|
167
|
+
}
|
|
168
|
+
} else if (end?.type === "user" && /^\[Request interrupted by user/.test(textOf(end.message?.content))) answer = "turn interrupted by a human";
|
|
169
|
+
const archived = turn.some((r) => r.type === "assistant" && blocks(r).some((b) => b.type === "tool_use" && /archive_session$/.test(String(b.name)) && b.input?.session_id === "self"));
|
|
170
|
+
return { id, title, last, bg, answer, pendingTool, archived, routine: (first ?? "").trimStart().startsWith("<scheduled-task") };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ----------------------------------------------------------------------------
|
|
174
|
+
// Questions
|
|
175
|
+
// ----------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
// session end lines per the canon (AGENTS.md, "Epic refresh on task close") in any language of the dictionary,
|
|
178
|
+
// markup included
|
|
179
|
+
const alt = (xs: string[]) => xs.map((x) => locale.escapeRe(x.replace(/[:.]$/, ""))).join("|");
|
|
180
|
+
const ASK_RE = new RegExp(`^[\\s*_>]*(?:${alt([...locale.every((n) => n.lastLine.questions), ...locale.every((n) => n.lastLine.remaining)])})`);
|
|
181
|
+
const DONE_RE = new RegExp(`(?:${alt(locale.every((n) => n.lastLine.done))})\\.?\\s*$`);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A question to the owner in the session's answer: from the line "All done, but there are questions…" or
|
|
185
|
+
* "Remaining: …" to the end of the answer; without them — the last paragraph. "All done. The session can be closed."
|
|
186
|
+
* isn't a question: null. `strict` — for a session without an "In progress" task: its last answer is often just an
|
|
187
|
+
* answer, and without these lines a question is only a paragraph that ends with "?".
|
|
188
|
+
*/
|
|
189
|
+
export function questionOf(answer: string, strict = false): string | null {
|
|
190
|
+
const text = answer.trim();
|
|
191
|
+
const lines = text.split("\n");
|
|
192
|
+
const i = lines.findIndex((l) => ASK_RE.test(l));
|
|
193
|
+
if (i >= 0) return lines.slice(i).join("\n").trim();
|
|
194
|
+
if (DONE_RE.test(text.replace(/[*_]/g, ""))) return null;
|
|
195
|
+
const paras = text.split(/\n\s*\n/);
|
|
196
|
+
const last = paras[paras.length - 1]!.trim();
|
|
197
|
+
return strict && !/\?[\s*_)»"]*$/.test(last) ? null : last;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A task's open questions — the first lines of the items of the questions section ("## Questions" in any language of
|
|
202
|
+
* the dictionary) without ✅; the section is parsed by the github skill, as `task actualize` parses it.
|
|
203
|
+
*/
|
|
204
|
+
export function openQuestions(body: string): string[] {
|
|
205
|
+
return questionItems(body).filter((x) => !x.answered).map((x) => x.text);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A question the session answered itself in auto mode, and its answer. */
|
|
209
|
+
export interface AutoAnswer {
|
|
210
|
+
question: string;
|
|
211
|
+
answer: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The auto-answer mark — `autoAnswer` of any language of the dictionary, with markup around it: `task answer --auto`
|
|
216
|
+
* puts it before the question, by hand it was put after the question and on a continuation line, and «✅ auto» in a
|
|
217
|
+
* project of another language too.
|
|
218
|
+
*/
|
|
219
|
+
const AUTO_RE = new RegExp(`[*_]*(?:${alt(locale.every((n) => n.autoAnswer)).replace(/ /g, "\\s*")})(?![\\p{L}\\p{N}])[*_:.]*`, "iu");
|
|
220
|
+
/** The end of a question: "?" at the end of a word, closing markup included. */
|
|
221
|
+
const QMARK_RE = /\?[»"'*_)]*(?=\s|$)/;
|
|
222
|
+
/** A bold label before the answer — `**Answer:**` of the `task answer` format, `**Decision:**` by hand. */
|
|
223
|
+
const LABEL_RE = /\*\*[^*\n]{1,40}:\*\*/g;
|
|
224
|
+
|
|
225
|
+
/** A dash between the question and the answer: «Question? — answer». */
|
|
226
|
+
const dashes = { start: /^[\s—–]+/, end: /[\s—–]+$/ };
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The question and the answer of an item text with the mark at its start or end: before a bold label, otherwise up to
|
|
230
|
+
* the first "?", otherwise the first line is the question, and a single line — up to the first « — ».
|
|
231
|
+
*/
|
|
232
|
+
function splitAnswer(text: string): AutoAnswer {
|
|
233
|
+
const cut = (at: number, skip: number) => ({ question: text.slice(0, at).replace(dashes.end, ""), answer: text.slice(at + skip).replace(dashes.start, "") });
|
|
234
|
+
const label = [...text.matchAll(LABEL_RE)].find((l) => text.slice(0, l.index).trim());
|
|
235
|
+
if (label) return cut(label.index, label[0].length);
|
|
236
|
+
const q = QMARK_RE.exec(text);
|
|
237
|
+
if (q) return cut(q.index + q[0].length, 0);
|
|
238
|
+
const nl = text.indexOf("\n");
|
|
239
|
+
if (nl >= 0) return cut(nl, 1);
|
|
240
|
+
const dash = text.indexOf(" — ");
|
|
241
|
+
return dash > 0 ? cut(dash, 3) : { question: text, answer: "" };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Answers marked `autoAnswer` in the questions section. The mark between text is itself the separator: before it is
|
|
246
|
+
* the question (a recommendation stays with it), after it — the answer; a bold label in the answer doesn't cut it.
|
|
247
|
+
* The mark at the start or end — the question and the answer are split by the text (`splitAnswer`).
|
|
248
|
+
*/
|
|
249
|
+
export function autoAnswers(body: string): AutoAnswer[] {
|
|
250
|
+
return questionItems(body).flatMap((item) => {
|
|
251
|
+
const text = item.lines.map((l) => l.trim()).join("\n").replace(/^\d+\.\s*/, "");
|
|
252
|
+
const m = AUTO_RE.exec(text);
|
|
253
|
+
if (!m) return [];
|
|
254
|
+
const before = text.slice(0, m.index).replace(dashes.end, "");
|
|
255
|
+
const after = text.slice(m.index + m[0].length).replace(dashes.start, "");
|
|
256
|
+
if (before && after) return [{ question: before, answer: after }];
|
|
257
|
+
return before || after ? [splitAnswer(before || after)] : [];
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ----------------------------------------------------------------------------
|
|
262
|
+
// What a session waits for
|
|
263
|
+
// ----------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/** Checks state: running, red, green; no checks — null. */
|
|
266
|
+
export type Checks = "pending" | "failure" | "success" | null;
|
|
267
|
+
|
|
268
|
+
/** The task's PR: open or merged, its checks and conflict; `deploy` — the merge commit's checks. */
|
|
269
|
+
export interface PrInfo {
|
|
270
|
+
number: number;
|
|
271
|
+
url: string;
|
|
272
|
+
branch: string;
|
|
273
|
+
/** Tasks the PR closes (`Closes #N`). */
|
|
274
|
+
closes: number[];
|
|
275
|
+
merged: boolean;
|
|
276
|
+
checks: Checks;
|
|
277
|
+
conflict: boolean;
|
|
278
|
+
deploy: Checks;
|
|
279
|
+
/** The commits have the cloud session trailer `Claude-Session`. */
|
|
280
|
+
cloud: boolean;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type WaitKey = "human" | "merge" | "silent" | "ci-red" | "ci" | "deploy" | "working" | "done" | "none" | "cloud";
|
|
284
|
+
|
|
285
|
+
export interface Wait {
|
|
286
|
+
key: WaitKey;
|
|
287
|
+
label: string;
|
|
288
|
+
detail: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const LABEL: Record<WaitKey, string> = {
|
|
292
|
+
human: "human reply",
|
|
293
|
+
merge: "merge",
|
|
294
|
+
silent: "silence",
|
|
295
|
+
"ci-red": "CI red",
|
|
296
|
+
ci: "CI running",
|
|
297
|
+
deploy: "deploy",
|
|
298
|
+
working: "working",
|
|
299
|
+
done: "can be closed",
|
|
300
|
+
none: "no session",
|
|
301
|
+
cloud: "cloud, state not visible",
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What a task's session waits for. A running background task wakes the session itself — it outranks the answer: what
|
|
306
|
+
* it waits for shows from the PR (open and checks running — CI, merged and the merge commit's checks running —
|
|
307
|
+
* deploy). A model answer without a tool call — the human's turn; if the PR is green and mergeable meanwhile, it
|
|
308
|
+
* waits for the merge. Neither, and no records for longer than the threshold — silence.
|
|
309
|
+
*/
|
|
310
|
+
export function waitOf(s: Transcript | null, pr: PrInfo | null, now: number): Wait {
|
|
311
|
+
const w = (key: WaitKey, detail = ""): Wait => ({ key, label: LABEL[key], detail });
|
|
312
|
+
// a cloud session doesn't pin its task: GitHub projects are unreachable for it, its state isn't visible from here
|
|
313
|
+
if (!s) return pr?.cloud ? w("cloud") : w("none");
|
|
314
|
+
if (s.archived) return w("done", "session archived");
|
|
315
|
+
const bg = s.bg.filter((b) => now - b.at <= BG_MAX);
|
|
316
|
+
if (bg.length) {
|
|
317
|
+
const what = `in background: ${bg.map((b) => b.what).join("; ")}`;
|
|
318
|
+
if (pr && !pr.merged && pr.checks === "pending") return w("ci", what);
|
|
319
|
+
if (pr && !pr.merged && pr.checks === "failure") return w("ci-red", what);
|
|
320
|
+
if (pr?.merged && pr.deploy === "pending") return w("deploy", what);
|
|
321
|
+
return w("working", what);
|
|
322
|
+
}
|
|
323
|
+
if (s.answer !== null) {
|
|
324
|
+
const q = questionOf(s.answer);
|
|
325
|
+
if (q === null) return w("done");
|
|
326
|
+
const first = q.split("\n")[0]!;
|
|
327
|
+
return pr && !pr.merged && pr.checks === "success" && !pr.conflict ? w("merge", first) : w("human", first);
|
|
328
|
+
}
|
|
329
|
+
if (s.last === null || now - s.last > SILENCE) return w("silent", s.pendingTool ? `call ${s.pendingTool} without a result — waiting for permission or stuck` : "no records, nothing in background");
|
|
330
|
+
return w("working");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ----------------------------------------------------------------------------
|
|
334
|
+
// Tab
|
|
335
|
+
// ----------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** An "In progress" task and its epic. */
|
|
338
|
+
export interface Task {
|
|
339
|
+
number: number;
|
|
340
|
+
title: string;
|
|
341
|
+
parent: { number: number; title: string } | null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* What GitHub knows about a repository: "In progress" tasks (without epics), PRs, tasks with open questions, tasks
|
|
346
|
+
* changed within the last day — `edited` is when their description was edited (never — created), epoch seconds.
|
|
347
|
+
*/
|
|
348
|
+
export interface Live {
|
|
349
|
+
tasks: Task[];
|
|
350
|
+
prs: PrInfo[];
|
|
351
|
+
questions: { number: number; title: string; body: string }[];
|
|
352
|
+
recent: { number: number; title: string; body: string; edited: number }[];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface SessionsInput {
|
|
356
|
+
/** Pins: `owner/repo#N` → session ids. */
|
|
357
|
+
pins: Record<string, string[]>;
|
|
358
|
+
/** Transcripts of pinned sessions by id. */
|
|
359
|
+
transcripts: Record<string, Transcript>;
|
|
360
|
+
/** GitHub by repository. */
|
|
361
|
+
live: Record<string, Live>;
|
|
362
|
+
/** Recent sessions of the repository's directories — their questions, if they have no "In progress" task. */
|
|
363
|
+
others: Record<string, Transcript[]>;
|
|
364
|
+
/** Hosts of the task-env proxy's routes; the proxy isn't running — none. */
|
|
365
|
+
hosts: string[];
|
|
366
|
+
/** The proxy's port: the address URL carries it unless it is 80. */
|
|
367
|
+
port: string;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A link to a service of the task's stack. */
|
|
371
|
+
export interface Address {
|
|
372
|
+
service: string;
|
|
373
|
+
url: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface SessionRow {
|
|
377
|
+
repo: string;
|
|
378
|
+
number: number;
|
|
379
|
+
title: string;
|
|
380
|
+
url: string;
|
|
381
|
+
epic: { number: number; title: string; url: string } | null;
|
|
382
|
+
session: Transcript | null;
|
|
383
|
+
pr: PrInfo | null;
|
|
384
|
+
wait: Wait;
|
|
385
|
+
/** Addresses of the task stack's services; no stack — none. */
|
|
386
|
+
addresses: Address[];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A question to the owner: from a task (its session's answer), from a session without an "In progress" task or from
|
|
391
|
+
* a task with the "questions" label.
|
|
392
|
+
*/
|
|
393
|
+
export interface Ask {
|
|
394
|
+
/** `issue` — an "In progress" task with the "questions" label, `backlog` — the other tasks with the label. */
|
|
395
|
+
kind: "task" | "session" | "issue" | "backlog";
|
|
396
|
+
repo: string;
|
|
397
|
+
head: string;
|
|
398
|
+
url: string | null;
|
|
399
|
+
session: Transcript | null;
|
|
400
|
+
lines: string[];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A task whose description got answers marked `autoAnswer` within the last day. */
|
|
404
|
+
export interface Decided {
|
|
405
|
+
repo: string;
|
|
406
|
+
number: number;
|
|
407
|
+
title: string;
|
|
408
|
+
url: string;
|
|
409
|
+
edited: number;
|
|
410
|
+
answers: AutoAnswer[];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export interface SessionsView {
|
|
414
|
+
rows: SessionRow[];
|
|
415
|
+
asks: Ask[];
|
|
416
|
+
autos: Decided[];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const issueUrl = (repo: string, n: number) => `https://github.com/${repo}/issues/${n}`;
|
|
420
|
+
const OWNER = new Set<WaitKey>(["human", "merge"]);
|
|
421
|
+
// on top — those waiting for the owner, then silent and red ones: they're looked at first
|
|
422
|
+
const RANK: Partial<Record<WaitKey, number>> = { human: 0, merge: 0, silent: 1, "ci-red": 1 };
|
|
423
|
+
|
|
424
|
+
/** The task's PR: an open one, otherwise the last merged; the link is `Closes #N` or the branch `<type>/<N>-…`. */
|
|
425
|
+
function prFor(live: Live, n: number): PrInfo | null {
|
|
426
|
+
const mine = live.prs.filter((p) => p.closes.includes(n) || branchIssueNumber(p.branch) === n);
|
|
427
|
+
return mine.find((p) => !p.merged) ?? mine[0] ?? null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Addresses of a task's services — the proxy's hosts `<service>.<N>.<project>.localhost` with its number and its
|
|
432
|
+
* repository as the project (task-env). e2e addresses aren't shown: that stack is recreated on every run, a link to it
|
|
433
|
+
* lives minutes (#396, question 4).
|
|
434
|
+
*/
|
|
435
|
+
function addressesOf(inp: SessionsInput, repo: string, n: number): Address[] {
|
|
436
|
+
const project = hostProject(repo.slice(repo.indexOf("/") + 1));
|
|
437
|
+
return inp.hosts
|
|
438
|
+
.flatMap((host) => {
|
|
439
|
+
const a = taskAddress(host);
|
|
440
|
+
return a && !a.e2e && a.task === n && a.project === project ? [{ service: a.service, url: addressUrl(host, inp.port) }] : [];
|
|
441
|
+
})
|
|
442
|
+
.sort((a, b) => a.service.localeCompare(b.service));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** "In progress" task lines and questions to the owner. */
|
|
446
|
+
export function sessionsView(inp: SessionsInput, now: number): SessionsView {
|
|
447
|
+
const pinned = new Set<string>();
|
|
448
|
+
const rows: SessionRow[] = Object.entries(inp.live).flatMap(([repo, live]) =>
|
|
449
|
+
live.tasks.map((t) => {
|
|
450
|
+
const ids = inp.pins[`${repo}#${t.number}`] ?? [];
|
|
451
|
+
ids.forEach((id) => pinned.add(id));
|
|
452
|
+
// after /clear the task has a new session and the old pin stays: the one that wrote last is taken
|
|
453
|
+
const session = ids.map((id) => inp.transcripts[id]).filter((s): s is Transcript => !!s).sort((a, b) => (b.last ?? 0) - (a.last ?? 0))[0] ?? null;
|
|
454
|
+
const pr = prFor(live, t.number);
|
|
455
|
+
return {
|
|
456
|
+
repo, number: t.number, title: t.title, url: issueUrl(repo, t.number), session, pr, wait: waitOf(session, pr, now),
|
|
457
|
+
epic: t.parent ? { ...t.parent, url: issueUrl(repo, t.parent.number) } : null, addresses: addressesOf(inp, repo, t.number),
|
|
458
|
+
};
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
rows.sort((a, b) => (RANK[a.wait.key] ?? 2) - (RANK[b.wait.key] ?? 2) || a.repo.localeCompare(b.repo) || a.number - b.number);
|
|
462
|
+
|
|
463
|
+
const asks: Ask[] = rows.filter((r) => OWNER.has(r.wait.key)).map((r) => ({ kind: "task", repo: r.repo, head: `#${r.number} ${r.title}`, url: r.url, session: r.session, lines: questionOf(r.session!.answer ?? "")!.split("\n") }));
|
|
464
|
+
for (const [repo, list] of Object.entries(inp.others)) {
|
|
465
|
+
// a routine and an answer without an explicit question aren't for the owner: otherwise the block drowns in
|
|
466
|
+
// scheduled reports
|
|
467
|
+
const recent = list.flatMap((s) => {
|
|
468
|
+
const q = !pinned.has(s.id) && !s.routine && s.last !== null && now - s.last <= RECENT && waitOf(s, null, now).key === "human" ? questionOf(s.answer!, true) : null;
|
|
469
|
+
return q === null ? [] : [{ s, q }];
|
|
470
|
+
});
|
|
471
|
+
for (const { s, q } of recent.sort((a, b) => b.s.last! - a.s.last!)) asks.push({ kind: "session", repo, head: s.title ?? `session ${s.id.slice(0, 8)}`, url: null, session: s, lines: q.split("\n") });
|
|
472
|
+
}
|
|
473
|
+
const issues = Object.entries(inp.live).flatMap(([repo, live]) =>
|
|
474
|
+
live.questions.map((q): Ask => {
|
|
475
|
+
const inWork = live.tasks.some((t) => t.number === q.number);
|
|
476
|
+
return { kind: inWork ? "issue" : "backlog", repo, head: `#${q.number} ${q.title}`, url: issueUrl(repo, q.number), session: null, lines: openQuestions(q.body) };
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
asks.push(...issues.filter((a) => a.kind === "issue"), ...issues.filter((a) => a.kind === "backlog"));
|
|
480
|
+
// the window is by the description's edit, not by the task's update: a comment or a close would bring back old answers
|
|
481
|
+
const autos = Object.entries(inp.live).flatMap(([repo, live]) =>
|
|
482
|
+
live.recent.flatMap((i): Decided[] => {
|
|
483
|
+
const answers = now - i.edited <= AUTO_WINDOW ? autoAnswers(i.body) : [];
|
|
484
|
+
return answers.length ? [{ repo, number: i.number, title: i.title, url: issueUrl(repo, i.number), edited: i.edited, answers }] : [];
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
autos.sort((a, b) => b.edited - a.edited || a.repo.localeCompare(b.repo) || a.number - b.number);
|
|
488
|
+
return { rows, asks, autos };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
492
|
+
const link = (url: string, text: string) => `<a href="${esc(url)}">${esc(text)}</a>`;
|
|
493
|
+
|
|
494
|
+
/** Time elapsed: "4 min ago", "2 h 5 min ago", "3 d ago". */
|
|
495
|
+
function ago(sec: number): string {
|
|
496
|
+
const m = Math.floor(sec / 60);
|
|
497
|
+
if (m < 1) return "just now";
|
|
498
|
+
if (m < 60) return `${m} min ago`;
|
|
499
|
+
const h = Math.floor(m / 60);
|
|
500
|
+
if (h < 24) return `${h} h${m % 60 ? ` ${m % 60} min` : ""} ago`;
|
|
501
|
+
return `${Math.floor(h / 24)} d ago`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const when = (s: Transcript, now: number) => (s.last === null ? "no records" : ago(now - s.last));
|
|
505
|
+
/** The session name, if it doesn't repeat the task: by the canon a session is named "#N <task name>". */
|
|
506
|
+
const ownTitle = (s: Transcript, task: string) => (s.title === null ? `untitled, ${s.id.slice(0, 8)}` : s.title === task ? null : s.title);
|
|
507
|
+
const CHECKS: Record<string, string> = { pending: "checks running", failure: "checks red", success: "checks green", null: "no checks" };
|
|
508
|
+
const DEPLOY: Record<string, string> = { pending: " · deploy running", failure: " · deploy red", success: "", null: "" };
|
|
509
|
+
|
|
510
|
+
function prCell(p: PrInfo | null): string {
|
|
511
|
+
if (!p) return "—";
|
|
512
|
+
const state = p.merged ? `merged${DEPLOY[String(p.deploy)]}` : `${CHECKS[String(p.checks)]}${p.conflict ? " · conflict" : ""}`;
|
|
513
|
+
return `${link(p.url, `#${p.number}`)} ${esc(state)}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function asksBlock(asks: Ask[], now: number, many: boolean): string {
|
|
517
|
+
const item = (a: Ask) => {
|
|
518
|
+
const head = `${many ? a.repo : ""}${a.head}`;
|
|
519
|
+
const top = (sub: string) => `<div class="ask-head">${a.url ? link(a.url, head) : esc(head)} <span class="minor">${esc(sub)}</span></div>`;
|
|
520
|
+
const text = (lines: string[]) => `<div class="ask-text">${lines.map(esc).join("<br>")}</div>`;
|
|
521
|
+
// tasks with questions number in the dozens: as a line, the questions themselves — under a disclosure
|
|
522
|
+
if (a.kind === "issue" || a.kind === "backlog")
|
|
523
|
+
return `<li>${top(a.lines.length ? `questions label · open: ${a.lines.length}` : "questions label")}${a.lines.length ? `<details data-key="${esc(`${a.repo}${a.head}`)}"><summary>questions</summary>${text(a.lines)}</details>` : ""}</li>`;
|
|
524
|
+
const name = a.kind === "task" ? ownTitle(a.session!, a.head) : null;
|
|
525
|
+
return `<li>${top(a.kind === "task" ? `session${name ? ` «${name}»` : ""} · ${when(a.session!, now)}` : `session without a task in progress · ${when(a.session!, now)}`)}${text(a.lines)}</li>`;
|
|
526
|
+
};
|
|
527
|
+
const live = asks.filter((a) => a.kind !== "backlog");
|
|
528
|
+
const backlog = asks.filter((a) => a.kind === "backlog");
|
|
529
|
+
// backlog questions wait until the task is taken: collapsed so as not to obscure the sessions that are stuck now
|
|
530
|
+
const rest = backlog.length ? `<details class="backlog" data-key="backlog"><summary>tasks with questions, not in progress: ${backlog.length}</summary><ul>${backlog.map(item).join("")}</ul></details>` : "";
|
|
531
|
+
return `<section class="asks card"><h2>Waiting for the owner</h2>${live.length ? `<ul>${live.map(item).join("")}</ul>` : `<p class="note">no questions</p>`}${rest}</section>`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function rowsTable(rows: SessionRow[], now: number, many: boolean): string {
|
|
535
|
+
if (!rows.length) return `<p class="card">No tasks in progress.</p>`;
|
|
536
|
+
const body = rows.map((r) => {
|
|
537
|
+
const addresses = r.addresses.length ? `<div class="minor addresses">services: ${r.addresses.map((a) => `<a href="${esc(a.url)}" title="${esc(a.url)}">${esc(a.service)}</a>`).join(" · ")}</div>` : "";
|
|
538
|
+
const task = `${link(r.url, `${many ? r.repo : ""}#${r.number}`)} ${esc(r.title)}${r.epic ? `<div class="minor">epic ${link(r.epic.url, `#${r.epic.number} ${r.epic.title}`)}</div>` : ""}${addresses}`;
|
|
539
|
+
const name = r.session && ownTitle(r.session, `#${r.number} ${r.title}`);
|
|
540
|
+
const session = r.session ? `${esc(when(r.session, now))}${name ? `<div class="minor">${esc(name)}</div>` : ""}` : "—";
|
|
541
|
+
const wait = `<span class="wait ${r.wait.key}">${esc(r.wait.label)}</span>${r.wait.detail ? `<div class="minor">${esc(r.wait.detail)}</div>` : ""}`;
|
|
542
|
+
return `<tr data-task="${esc(`${r.repo}#${r.number}`)}"><td>${task}</td><td>${session}</td><td>${wait}</td><td>${prCell(r.pr)}</td></tr>`;
|
|
543
|
+
});
|
|
544
|
+
return `<section class="card scroll"><table class="sessions"><thead><tr><th>Task</th><th>Session</th><th>Waiting for</th><th>PR</th></tr></thead><tbody>${body.join("")}</tbody></table></section>`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** "Decided auto": a line per answer — the task (once for its answers), the question, the answer; none — no block. */
|
|
548
|
+
function autosBlock(autos: Decided[], now: number, many: boolean): string {
|
|
549
|
+
if (!autos.length) return "";
|
|
550
|
+
const lines = (s: string) => s.split("\n").map(esc).join("<br>");
|
|
551
|
+
const body = autos.flatMap((d) =>
|
|
552
|
+
d.answers.map((a, i) => {
|
|
553
|
+
const task = i ? "" : `<td rowspan="${d.answers.length}">${link(d.url, `${many ? d.repo : ""}#${d.number} ${d.title}`)}<div class="minor">edited ${esc(ago(now - d.edited))}</div></td>`;
|
|
554
|
+
return `<tr>${task}<td>${lines(a.question)}</td><td>${lines(a.answer)}</td></tr>`;
|
|
555
|
+
}),
|
|
556
|
+
);
|
|
557
|
+
return `<section class="autos card scroll"><h2>Decided auto</h2><p class="note">answers marked ✅ auto in tasks whose description changed within a day</p><table class="autos"><thead><tr><th>Task</th><th>Question</th><th>Answer</th></tr></thead><tbody>${body.join("")}</tbody></table></section>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The tab's content: "Waiting for the owner" on top, below it — a line per "In progress" task, at the bottom — what
|
|
562
|
+
* sessions decided themselves: it's looked at after the fact.
|
|
563
|
+
*/
|
|
564
|
+
export function sessionsBody(v: SessionsView, now: number, many: boolean): string {
|
|
565
|
+
return asksBlock(v.asks, now, many) + rowsTable(v.rows, now, many) + autosBlock(v.autos, now, many);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The tab's self-refresh: every `sec` seconds and on returning to the tab — the page afresh and `<main>` replaced,
|
|
570
|
+
* what's expanded stays expanded; a hidden tab doesn't refresh. A reload (meta refresh) would collapse expanded
|
|
571
|
+
* questions every half minute.
|
|
572
|
+
*/
|
|
573
|
+
export function refreshScript(sec: number): string {
|
|
574
|
+
return `<script>async function refreshMain() {
|
|
575
|
+
let html;
|
|
576
|
+
try { const r = await fetch(location.href, { cache: "no-store" }); if (!r.ok) return; html = await r.text(); } catch { return; }
|
|
577
|
+
const next = new DOMParser().parseFromString(html, "text/html").querySelector("main"); if (!next) return;
|
|
578
|
+
const open = new Set([...document.querySelectorAll("details[open][data-key]")].map((d) => d.dataset.key));
|
|
579
|
+
document.querySelector("main").replaceWith(next);
|
|
580
|
+
next.querySelectorAll("details[data-key]").forEach((d) => { if (open.has(d.dataset.key)) d.open = true; });
|
|
581
|
+
}
|
|
582
|
+
setInterval(() => document.hidden || refreshMain(), ${sec * 1000});
|
|
583
|
+
document.addEventListener("visibilitychange", () => document.hidden || refreshMain());</script>`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The tab's styles; colors are the page's theme variables. */
|
|
587
|
+
export const SESSIONS_CSS = `
|
|
588
|
+
h2{font-size:16px;margin:0 0 8px}
|
|
589
|
+
.asks ul{list-style:none;margin:0;padding:0}
|
|
590
|
+
.asks li{padding:8px 0;border-top:1px solid var(--grid)}
|
|
591
|
+
.asks li:first-child{border-top:0;padding-top:0}
|
|
592
|
+
.ask-head{font-weight:600}.ask-head .minor{font-weight:400}
|
|
593
|
+
.ask-text{margin-top:2px;white-space:pre-wrap}
|
|
594
|
+
.asks details{margin-top:2px}.asks summary{cursor:pointer;color:var(--ink2);font-size:13px}
|
|
595
|
+
.asks details.backlog{margin-top:8px}.asks details.backlog>summary{font-size:14px}.asks details.backlog ul{margin-top:8px}
|
|
596
|
+
.minor{color:var(--ink2);font-size:13px}
|
|
597
|
+
table.sessions td{text-align:left;white-space:normal;vertical-align:top}
|
|
598
|
+
table.sessions td:nth-child(1){min-width:220px}
|
|
599
|
+
table.sessions td:nth-child(2){min-width:0;white-space:nowrap}
|
|
600
|
+
table.sessions td:nth-child(3){min-width:160px}
|
|
601
|
+
table.sessions th{text-align:left}
|
|
602
|
+
.autos .note{margin:0 0 8px}
|
|
603
|
+
table.autos th,table.autos td{text-align:left;white-space:normal;vertical-align:top}
|
|
604
|
+
table.autos td:first-child{min-width:200px}
|
|
605
|
+
table.autos td:not(:first-child){min-width:220px}
|
|
606
|
+
.wait{display:inline-block;padding:1px 8px;border-radius:999px;font-size:13px;border:1px solid var(--border);white-space:nowrap}
|
|
607
|
+
.wait.human,.wait.merge{background:var(--warn);color:#000;border-color:transparent}
|
|
608
|
+
.wait.silent,.wait.ci-red{background:var(--bad);color:#fff;border-color:transparent}
|
|
609
|
+
.wait.ci,.wait.deploy,.wait.working{color:var(--fact)}
|
|
610
|
+
.wait.done{color:var(--good)}
|
|
611
|
+
.wait.none,.wait.cloud{color:var(--muted)}
|
|
612
|
+
`;
|
|
613
|
+
|
|
614
|
+
// ----------------------------------------------------------------------------
|
|
615
|
+
// Reading: pins, transcripts, GitHub
|
|
616
|
+
// ----------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Pins of tasks to sessions: `owner/repo#N` → session ids — est reads the `sessions/<id>` files of the configuration
|
|
620
|
+
* directory (loadPins): the dashboard imports est anyway, its own parse would be a third copy (tests/standards/session-pin).
|
|
621
|
+
*/
|
|
622
|
+
export function readPins(dir?: string): Record<string, string[]> {
|
|
623
|
+
const out: Record<string, string[]> = {};
|
|
624
|
+
for (const [id, task] of loadPins(dir)) (out[task] ??= []).push(id);
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const parsed = new Map<string, { key: string; t: Transcript }>();
|
|
629
|
+
|
|
630
|
+
/** A file's transcript; re-parsed only if the file changed. Subagent records are a sign of the session's life too. */
|
|
631
|
+
function readTranscript(file: string): Transcript {
|
|
632
|
+
const st = statSync(file);
|
|
633
|
+
const key = `${st.size}:${st.mtimeMs}`;
|
|
634
|
+
let t = parsed.get(file)?.key === key ? parsed.get(file)!.t : null;
|
|
635
|
+
if (!t) {
|
|
636
|
+
t = parseTranscript(path.basename(file, ".jsonl"), readFileSync(file, "utf8"));
|
|
637
|
+
parsed.set(file, { key, t });
|
|
638
|
+
}
|
|
639
|
+
// a subagent writes to its own transcript (<id>/subagents/), and the session waits for it without records
|
|
640
|
+
const sub = path.join(file.slice(0, -".jsonl".length), "subagents");
|
|
641
|
+
let subAt = 0;
|
|
642
|
+
try {
|
|
643
|
+
for (const f of readdirSync(sub)) subAt = Math.max(subAt, statSync(path.join(sub, f)).mtimeMs / 1000);
|
|
644
|
+
} catch {
|
|
645
|
+
// no subagents
|
|
646
|
+
}
|
|
647
|
+
return subAt > (t.last ?? 0) ? { ...t, last: subAt } : t;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Transcript files `<directory>/<name>.jsonl` of the `projectsDir` directories that match `match`. */
|
|
651
|
+
function transcriptFiles(match: (dir: string, name: string) => boolean, projectsDir: string): string[] {
|
|
652
|
+
let dirs: string[];
|
|
653
|
+
try {
|
|
654
|
+
dirs = readdirSync(projectsDir);
|
|
655
|
+
} catch {
|
|
656
|
+
return [];
|
|
657
|
+
}
|
|
658
|
+
const out: string[] = [];
|
|
659
|
+
for (const d of dirs) {
|
|
660
|
+
let names: string[];
|
|
661
|
+
try {
|
|
662
|
+
names = readdirSync(path.join(projectsDir, d));
|
|
663
|
+
} catch {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
for (const n of names) if (n.endsWith(".jsonl") && match(d, n)) out.push(path.join(projectsDir, d, n));
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Session transcripts by id — in any project's directory: a session also works from someone else's directory. */
|
|
672
|
+
export function findTranscripts(ids: string[], projectsDir = PROJECTS_DIR): Record<string, Transcript> {
|
|
673
|
+
const want = new Set(ids.map((id) => `${id}.jsonl`));
|
|
674
|
+
const out: Record<string, Transcript> = {};
|
|
675
|
+
if (!want.size) return out;
|
|
676
|
+
for (const f of transcriptFiles((_, n) => want.has(n), projectsDir)) {
|
|
677
|
+
const t = readTranscript(f);
|
|
678
|
+
// one id in two directories (the app moved the transcript) — the fresh one
|
|
679
|
+
if ((t.last ?? 0) >= (out[t.id]?.last ?? 0)) out[t.id] = t;
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Transcripts of the repository's directories (`paths` of the est registry and their worktrees) modified no earlier
|
|
686
|
+
* than `since`.
|
|
687
|
+
*/
|
|
688
|
+
export function repoTranscripts(paths: string[], since: number, projectsDir = PROJECTS_DIR): Transcript[] {
|
|
689
|
+
const encs = paths.map(encodePath);
|
|
690
|
+
if (!encs.length) return [];
|
|
691
|
+
const files = transcriptFiles((d) => encs.some((e) => d.startsWith(e)), projectsDir);
|
|
692
|
+
return files.filter((f) => statSync(f).mtimeMs / 1000 >= since).map(readTranscript);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const ROLLUP: Record<string, Checks> = { SUCCESS: "success", FAILURE: "failure", ERROR: "failure", PENDING: "pending", EXPECTED: "pending" };
|
|
696
|
+
const rollup = (state: unknown): Checks => ROLLUP[String(state)] ?? null;
|
|
697
|
+
const EPIC_TYPES = locale.every((n) => n.issueTypes.epic).map((t) => t.toLowerCase());
|
|
698
|
+
const isEpic = (i: Any) => (i.labels?.nodes ?? []).some((l: Any) => l.name === "epic") || EPIC_TYPES.includes(String(i.issueType?.name ?? "").toLowerCase());
|
|
699
|
+
const PR_FIELDS = "number url headRefName mergeable closingIssuesReferences(first:10){nodes{number}} commits(last:20){nodes{commit{messageBody statusCheckRollup{state}}}}";
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* "In progress" tasks, PRs and tasks with the "questions" label from the GitHub response; an epic isn't a line: its
|
|
703
|
+
* session plans rather than does.
|
|
704
|
+
*/
|
|
705
|
+
function packLive(data: Any): Live {
|
|
706
|
+
const pack = (p: Any, merged: boolean): PrInfo => {
|
|
707
|
+
const commits: Any[] = p.commits?.nodes ?? [];
|
|
708
|
+
return {
|
|
709
|
+
number: p.number, url: p.url, branch: p.headRefName ?? "", closes: (p.closingIssuesReferences?.nodes ?? []).map((n: Any) => n.number), merged,
|
|
710
|
+
checks: rollup(commits[commits.length - 1]?.commit?.statusCheckRollup?.state), conflict: p.mergeable === "CONFLICTING",
|
|
711
|
+
deploy: merged ? rollup(p.mergeCommit?.statusCheckRollup?.state) : null, cloud: commits.some((c) => cloudSessionsIn(c.commit?.messageBody ?? "").length > 0),
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
const issues = (data?.project?.items?.nodes ?? []).map((it: Any) => it?.content).filter((c: Any) => c?.__typename === "Issue" && c.state === "OPEN" && !isEpic(c));
|
|
715
|
+
const repo = data?.repository;
|
|
716
|
+
return {
|
|
717
|
+
tasks: issues.map((c: Any) => ({ number: c.number, title: c.title, parent: c.parent ? { number: c.parent.number, title: c.parent.title } : null })).sort((a: Task, b: Task) => a.number - b.number),
|
|
718
|
+
prs: [...(repo?.open?.nodes ?? []).map((p: Any) => pack(p, false)), ...(repo?.merged?.nodes ?? []).map((p: Any) => pack(p, true))],
|
|
719
|
+
questions: (repo?.questions?.nodes ?? []).map((q: Any) => ({ number: q.number, title: q.title, body: q.body ?? "" })),
|
|
720
|
+
recent: (repo?.recent?.nodes ?? [])
|
|
721
|
+
.map((i: Any) => ({ number: i.number, title: i.title, body: i.body ?? "", edited: Date.parse(i.lastEditedAt ?? i.createdAt ?? "") / 1000 }))
|
|
722
|
+
.filter((i: { edited: number }) => Number.isFinite(i.edited)),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** `gh api graphql` without blocking the server: the tab answers from the cache while GitHub gathers fresh data. */
|
|
727
|
+
function graphql(query: string, variables: Record<string, unknown>): Promise<Any> {
|
|
728
|
+
return new Promise((resolve, reject) => {
|
|
729
|
+
const p = spawn("gh", ["api", "graphql", "--input", "-"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
730
|
+
let out = "";
|
|
731
|
+
let err = "";
|
|
732
|
+
p.stdout.on("data", (d) => (out += d));
|
|
733
|
+
p.stderr.on("data", (d) => (err += d));
|
|
734
|
+
p.on("error", (e) => reject(new EstError((e as NodeJS.ErrnoException).code === "ENOENT" ? "command 'gh' not found; the gh CLI must be installed" : `gh api graphql: ${e.message}`)));
|
|
735
|
+
p.on("close", (code) => {
|
|
736
|
+
if (code !== 0) return reject(new EstError(`command gh api graphql… exited with code ${code}: ${err.trim().slice(0, 500)}`));
|
|
737
|
+
let data: Any;
|
|
738
|
+
try {
|
|
739
|
+
data = JSON.parse(out);
|
|
740
|
+
} catch {
|
|
741
|
+
return reject(new EstError(`gh api graphql: the response isn't JSON: ${out.slice(0, 200)}`));
|
|
742
|
+
}
|
|
743
|
+
// a partial response (no access to one task) — there is data, take it
|
|
744
|
+
if (data.errors?.length && !data.data) return reject(new EstError(`GraphQL: ${data.errors.map((e: Any) => e.message ?? "").join("; ")}`));
|
|
745
|
+
resolve(data.data);
|
|
746
|
+
});
|
|
747
|
+
p.stdin.end(JSON.stringify({ query, variables }));
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* One request per repository: "In progress" tasks with their epic — by a project filter on GitHub's side (whole
|
|
753
|
+
* project rows, with comments, as for the "Estimate and actual" tab, take tens of seconds), open and recently merged
|
|
754
|
+
* PRs, tasks with the "questions" label, tasks updated within the last day — GitHub filters by any update, the window
|
|
755
|
+
* by the description's edit is applied by `sessionsView`.
|
|
756
|
+
*/
|
|
757
|
+
export async function liveQuery(full: string, projectId: string): Promise<Live> {
|
|
758
|
+
// a cloud session has Projects v2 closed to it; dashboard exits earlier, this is for a call that bypasses it
|
|
759
|
+
if (process.env.CLAUDE_CODE_REMOTE === "true") throw new EstError("Claude Code cloud session: GitHub projects aren't reachable from here (docs/cloud-sessions.md in Elves)");
|
|
760
|
+
const [o, r] = full.split("/");
|
|
761
|
+
const q = `query($o:String!,$r:String!,$p:ID!,$q:String!,$since:DateTime!){
|
|
762
|
+
project: node(id:$p){ ... on ProjectV2{ items(first:100, query:$q){ nodes{ content{ __typename
|
|
763
|
+
... on Issue{ number title state issueType{name} labels(first:15){nodes{name}} parent{ number title } } } } } } }
|
|
764
|
+
repository(owner:$o,name:$r){
|
|
765
|
+
open: pullRequests(states:OPEN, first:50, orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ ${PR_FIELDS} } }
|
|
766
|
+
merged: pullRequests(states:MERGED, first:30, orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ ${PR_FIELDS} mergeCommit{ statusCheckRollup{state} } } }
|
|
767
|
+
questions: issues(states:OPEN, labels:${JSON.stringify(locale.every((n) => n.labels.questions))}, first:50){ nodes{ number title body } }
|
|
768
|
+
recent: issues(filterBy:{since:$since}, first:100, orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ number title body createdAt lastEditedAt } } } }`;
|
|
769
|
+
const since = new Date(Date.now() - AUTO_WINDOW * 1000).toISOString();
|
|
770
|
+
// the project filter: comma-separated values — any of them
|
|
771
|
+
return packLive(await graphql(q, { o, r, p: projectId, q: `status:${IN_WORK.map((s) => JSON.stringify(s)).join(",")}`, since }));
|
|
772
|
+
}
|