@zhuxixi/pi-agent-board 0.4.2 → 0.5.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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Per-view code-refs artifact (`github.json`) plus the evidence→extraction
3
+ * hook helper.
4
+ *
5
+ * Mirrors evidence.mjs's normalize/read/write/summarize shape. Extraction is
6
+ * delegated to the pure engine in code-refs.mjs; this module only composes the
7
+ * engine input from a view's meta + evidence and persists the snapshot
8
+ * atomically. `meta` is passed in by callers (they already hold it) so this
9
+ * module never imports store.mjs — keeping the store ↔ artifact imports free
10
+ * of cycles.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { statSync } from "node:fs";
14
+ import { atomicWriteJson, readJson } from "./atomic.mjs";
15
+ import * as P from "./paths.mjs";
16
+ import {
17
+ extractCodeRefs,
18
+ loadProvidersWithErrors,
19
+ matchProvider,
20
+ parseRemoteHost,
21
+ parseRemotePath,
22
+ } from "./code-refs.mjs";
23
+ import { gitRemoteUrl } from "./repo.mjs";
24
+ import { appendDiagnostic } from "./diagnostics.mjs";
25
+
26
+ /**
27
+ * @typedef {Object} CodeRefsSnapshot
28
+ * @property {number} version
29
+ * @property {string} viewId
30
+ * @property {number} updatedAt
31
+ * @property {string|null} provider
32
+ * @property {string} issuePrefix Issue-number prefix resolved from the matched provider (default "#").
33
+ * @property {string} prPrefix PR/MR-number prefix resolved from the matched provider (default "▸#").
34
+ * @property {import("./code-refs.mjs").Ref|null} issue
35
+ * @property {import("./code-refs.mjs").Ref|null} pr
36
+ * @property {import("./code-refs.mjs").Ref[]} allRefs
37
+ */
38
+
39
+ /** @param {{ viewId:string, now?:number }} opts @returns {CodeRefsSnapshot} */
40
+ export function emptyCodeRefsSnapshot(opts) {
41
+ const now = opts.now ?? Date.now();
42
+ return {
43
+ version: 1,
44
+ viewId: opts.viewId,
45
+ updatedAt: now,
46
+ provider: null,
47
+ issuePrefix: "#",
48
+ prPrefix: "▸#",
49
+ issue: null,
50
+ pr: null,
51
+ allRefs: [],
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Defensive shape guard mirroring evidence's normalize: missing/garbage input
57
+ * yields an empty snapshot; valid fields pass through.
58
+ * @param {any} raw
59
+ * @param {{ viewId:string }} fallback
60
+ * @returns {CodeRefsSnapshot}
61
+ */
62
+ export function normalizeCodeRefsSnapshot(raw, fallback) {
63
+ const base = emptyCodeRefsSnapshot({ viewId: fallback.viewId });
64
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base;
65
+ return {
66
+ ...base,
67
+ ...raw,
68
+ viewId: typeof raw.viewId === "string" ? raw.viewId : base.viewId,
69
+ provider: typeof raw.provider === "string" ? raw.provider : (raw.provider === null ? null : base.provider),
70
+ issuePrefix: typeof raw.issuePrefix === "string" ? raw.issuePrefix : base.issuePrefix,
71
+ prPrefix: typeof raw.prPrefix === "string" ? raw.prPrefix : base.prPrefix,
72
+ issue: isRefObject(raw.issue) ? raw.issue : null,
73
+ pr: isRefObject(raw.pr) ? raw.pr : null,
74
+ allRefs: Array.isArray(raw.allRefs) ? raw.allRefs.filter(isValidRefElement) : [],
75
+ };
76
+ }
77
+
78
+ /** @param {any} value @returns {boolean} */
79
+ function isRefObject(value) {
80
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
81
+ }
82
+
83
+ /**
84
+ * A valid allRefs element is a ref-shaped object whose kind is issue/pr and
85
+ * whose number is a positive integer.
86
+ * @param {any} value @returns {boolean}
87
+ */
88
+ function isValidRefElement(value) {
89
+ return Boolean(
90
+ isRefObject(value) &&
91
+ (value.kind === "issue" || value.kind === "pr") &&
92
+ Number.isInteger(value.number) &&
93
+ value.number > 0
94
+ );
95
+ }
96
+
97
+ /** @param {string} root @param {string} viewId @returns {CodeRefsSnapshot} */
98
+ export function readCodeRefs(root, viewId) {
99
+ return normalizeCodeRefsSnapshot(readJson(P.codeRefsPath(root, viewId), null), { viewId });
100
+ }
101
+
102
+ /** @param {string} root @param {CodeRefsSnapshot} snapshot @returns {CodeRefsSnapshot} */
103
+ export function writeCodeRefs(root, snapshot) {
104
+ const normalized = normalizeCodeRefsSnapshot(snapshot, { viewId: snapshot.viewId });
105
+ normalized.updatedAt = Date.now();
106
+ atomicWriteJson(P.codeRefsPath(root, normalized.viewId), normalized);
107
+ return normalized;
108
+ }
109
+
110
+ /** @param {any} snapshot @returns {import("./types.mjs").CodeRefsSummary} */
111
+ export function summarizeCodeRefs(snapshot) {
112
+ const s = normalizeCodeRefsSnapshot(snapshot, { viewId: snapshot?.viewId ?? "" });
113
+ return {
114
+ provider: s.provider,
115
+ issuePrefix: s.issuePrefix,
116
+ prPrefix: s.prPrefix,
117
+ issue: s.issue,
118
+ pr: s.pr,
119
+ allRefs: s.allRefs,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Current branch of a working dir via `git branch --show-current`, cached per
125
+ * cwd for 60s. Best-effort like repo.mjs: off-repo or git failures yield null
126
+ * (also cached), but the TTL means a newly created branch shows up within a
127
+ * minute without needing an explicit cache clear.
128
+ * @type {Map<string, { at:number, branch:string|null }>}
129
+ */
130
+ const branchCache = new Map();
131
+ const BRANCH_CACHE_TTL_MS = 60_000;
132
+
133
+ /** @param {string|null} cwd @returns {string|null} */
134
+ function currentBranch(cwd) {
135
+ if (typeof cwd !== "string" || !cwd) return null;
136
+ const cached = branchCache.get(cwd);
137
+ if (cached && Date.now() - cached.at < BRANCH_CACHE_TTL_MS) return cached.branch;
138
+ let branch = null;
139
+ try {
140
+ const out = execFileSync("git", ["-C", cwd, "branch", "--show-current"], {
141
+ encoding: "utf8",
142
+ stdio: ["ignore", "pipe", "ignore"],
143
+ timeout: 2000,
144
+ });
145
+ branch = out.trim() || null;
146
+ } catch {
147
+ // not a repo or git unavailable
148
+ }
149
+ branchCache.set(cwd, { at: Date.now(), branch });
150
+ return branch;
151
+ }
152
+
153
+ /**
154
+ * Extract issue/PR refs from view evidence and persist the per-view snapshot.
155
+ * The hook helper for every `writeEvidence` call site: never throws, writes only
156
+ * when the serialized ref content actually changed, and is disabled entirely
157
+ * by `AGENT_BOARD_CODE_REFS=off` (returns false without writing).
158
+ *
159
+ * `meta` is a parameter (callers pass `row.meta` / the runner's readMeta
160
+ * result) so this module never imports store.mjs. repoRoot falls back to
161
+ * meta.cwd when the view has no recorded repo root.
162
+ * @param {string} root
163
+ * @param {string} viewId
164
+ * @param {import("./types.mjs").EvidenceSnapshot|any} evidence
165
+ * @param {{repoRoot?: string|null, cwd?: string|null, worktreePath?: string|null}|null|undefined} meta
166
+ * @returns {boolean} true when extraction ran and the snapshot is current; false when off or failed.
167
+ */
168
+ export function updateCodeRefsFromEvidence(root, viewId, evidence, meta) {
169
+ if (process.env.AGENT_BOARD_CODE_REFS === "off") return false;
170
+ try {
171
+ const hasCommands = Array.isArray(evidence?.commands) && evidence.commands.length > 0;
172
+ const hasAssistantEvidence = Array.isArray(evidence?.assistantEvidence) && evidence.assistantEvidence.length > 0;
173
+ if (!hasCommands && !hasAssistantEvidence) return false;
174
+ const repoRoot = meta?.repoRoot ?? meta?.cwd ?? null;
175
+ const remoteUrl = repoRoot ? gitRemoteUrl(repoRoot) : null;
176
+ const host = remoteUrl ? parseRemoteHost(remoteUrl) : null;
177
+ const repoUrl = remoteUrl ? parseRemotePath(remoteUrl) : null;
178
+ const { providers, errors } = loadProvidersWithErrors(root);
179
+ const provider = matchProvider(providers, host);
180
+ reportConfigErrors(root, viewId, evidence, errors);
181
+ const cwd = typeof meta?.cwd === "string" ? meta.cwd : null;
182
+ const worktreePath = typeof meta?.worktreePath === "string" ? meta.worktreePath : null;
183
+ const branch = currentBranch(cwd);
184
+ const input = buildEngineInput(evidence, { worktreePath, branch, repoUrl, host });
185
+ const result = extractCodeRefs(input, provider);
186
+ const existing = readCodeRefs(root, viewId);
187
+ // Carry forward earned refs per kind: job-runner resets view-level
188
+ // evidence at each run start, so a follow-up run whose events carry no
189
+ // signal for a kind must not null that kind's previously earned ref —
190
+ // absence of signal is not evidence of absence. (CR rounds 1-2)
191
+ const merged = mergeWithExisting(result, existing);
192
+ const next = {
193
+ version: 1,
194
+ viewId,
195
+ updatedAt: Date.now(),
196
+ provider: result.provider,
197
+ issuePrefix: provider.issuePrefix,
198
+ prPrefix: provider.prPrefix,
199
+ issue: merged.issue,
200
+ pr: merged.pr,
201
+ allRefs: merged.allRefs,
202
+ };
203
+ // Avoid churning the artifact (and its mtime) when the refs are unchanged.
204
+ if (contentOf(existing) === contentOf(next)) return true;
205
+ writeCodeRefs(root, next);
206
+ return true;
207
+ } catch (e) {
208
+ try {
209
+ appendDiagnostic(root, viewId, {
210
+ runId: evidence?.runId ?? null,
211
+ source: "evidence",
212
+ level: "error",
213
+ code: "code_refs_extract_failed",
214
+ message: `code-refs extraction failed: ${e instanceof Error ? e.message : String(e)}`,
215
+ });
216
+ } catch {
217
+ // diagnostics must never break the evidence flow either
218
+ }
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /** @param {any} s @returns {string} serialized ref content (updatedAt excluded) */
224
+ function contentOf(s) {
225
+ return JSON.stringify({
226
+ provider: s.provider,
227
+ issuePrefix: s.issuePrefix,
228
+ prPrefix: s.prPrefix,
229
+ issue: s.issue,
230
+ pr: s.pr,
231
+ allRefs: s.allRefs,
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Merge a fresh extraction with the stored snapshot: a kind with no signal in
237
+ * the new extraction inherits the stored ref (per-kind carry-forward), and
238
+ * stored refs missing from the new allRefs are appended (deduped by
239
+ * kind+number, capped at 10).
240
+ * @param {import("./code-refs.mjs").CodeRefsResult} result
241
+ * @param {any} existing normalized snapshot
242
+ * @returns {{issue: any, pr: any, allRefs: any[]}}
243
+ */
244
+ function mergeWithExisting(result, existing) {
245
+ const issue = result.issue ?? existing?.issue ?? null;
246
+ const pr = result.pr ?? existing?.pr ?? null;
247
+ const seen = new Set((result.allRefs ?? []).map((r) => `${r.kind}:${r.number}`));
248
+ const carried = [];
249
+ for (const r of [existing?.issue, existing?.pr, ...(Array.isArray(existing?.allRefs) ? existing.allRefs : [])]) {
250
+ if (!r || seen.has(`${r.kind}:${r.number}`)) continue;
251
+ seen.add(`${r.kind}:${r.number}`);
252
+ carried.push(r);
253
+ }
254
+ return { issue, pr, allRefs: [...(result.allRefs ?? []), ...carried].slice(0, 10) };
255
+ }
256
+
257
+ /** Last providers.json mtime per root for which a code_refs_config diagnostic was emitted. */
258
+ const reportedConfigMtime = new Map();
259
+
260
+ /**
261
+ * Emit ONE `code_refs_config` diagnostic per distinct providers.json mtime so a
262
+ * broken config is surfaced without spamming every evidence write.
263
+ * @param {string} root
264
+ * @param {string} viewId
265
+ * @param {import("./types.mjs").EvidenceSnapshot|any} evidence
266
+ * @param {string[]} errors
267
+ */
268
+ function reportConfigErrors(root, viewId, evidence, errors) {
269
+ if (errors.length === 0) return;
270
+ let mtimeMs = null;
271
+ try {
272
+ mtimeMs = statSync(P.providersPath(root)).mtimeMs;
273
+ } catch {
274
+ // file disappeared — nothing to report against
275
+ }
276
+ if (reportedConfigMtime.get(root) === mtimeMs) return;
277
+ reportedConfigMtime.set(root, mtimeMs);
278
+ try {
279
+ appendDiagnostic(root, viewId, {
280
+ runId: evidence?.runId ?? null,
281
+ source: "code-refs",
282
+ level: "warn",
283
+ code: "code_refs_config",
284
+ message: "providers.json has invalid entries",
285
+ details: { errors },
286
+ });
287
+ } catch {
288
+ // diagnostics must never break the evidence flow either
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Compose the pure engine input from evidence: the last 200 commands (each
294
+ * truncated to 4000 chars) plus the last 20 assistant claim texts.
295
+ * @param {any} evidence
296
+ * @param {{ worktreePath: string|null, branch: string|null, repoUrl: string|null, host: string|null }} ctx
297
+ */
298
+ function buildEngineInput(evidence, ctx) {
299
+ const rawCommands = Array.isArray(evidence?.commands) ? evidence.commands : [];
300
+ const commands = rawCommands.slice(-200).map((cmd) => {
301
+ if (cmd && typeof cmd.command === "string" && cmd.command.length > 4000) {
302
+ return { ...cmd, command: cmd.command.slice(0, 4000) };
303
+ }
304
+ return cmd;
305
+ });
306
+ const claims = Array.isArray(evidence?.assistantEvidence) ? evidence.assistantEvidence : [];
307
+ const assistantTexts = claims
308
+ .slice(-20)
309
+ .map((claim) => (claim && typeof claim.text === "string" ? claim.text : ""))
310
+ .filter((text) => text !== "");
311
+ return { commands, assistantTexts, worktreePath: ctx.worktreePath, branch: ctx.branch, repoUrl: ctx.repoUrl, host: ctx.host };
312
+ }