limn-review 0.1.142 → 0.1.144
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/dist/config.js +3 -0
- package/dist/db/client.js +43 -14
- package/dist/review/claude-review/auto-post.js +13 -3
- package/dist/review/claude-review/chat-explain.js +6 -3
- package/dist/review/claude-review/manager.js +1 -1
- package/dist/review/claude-review/ticket.js +5 -2
- package/dist/review/diagrams/fingerprint.js +4 -1
- package/dist/review/diagrams/persist.js +27 -0
- package/dist/review/diagrams/routes.js +6 -2
- package/dist/review/diagrams/trigger.js +136 -39
- package/dist/review/ticket-review/manager.js +18 -0
- package/dist/review/ticket-review/persist.js +34 -5
- package/dist/review/ticket-review/post-item.js +23 -6
- package/dist/review/ticket-review/prompts.js +6 -4
- package/dist/review/ticket-review/reconcile.js +11 -5
- package/dist/review/ticket-review/routes.js +7 -2
- package/dist/review/ticket-review/schema.js +2 -2
- package/dist/review/ticket-review/sweep-waits.js +20 -0
- package/dist/review/ticket-review/sweep.js +53 -7
- package/dist/shared/index.js +1 -0
- package/dist/shared/prose.js +89 -0
- package/dist/shared/sequence-diagrams.js +5 -0
- package/dist/shared/types.js +14 -0
- package/package.json +1 -1
- package/public/assets/{AiFixTab-B3eAgc7u.js → AiFixTab-C3QTCWh3.js} +1 -1
- package/public/assets/{ClaudeReviewTab-C7GoZdiF.js → ClaudeReviewTab-ITKwJAxq.js} +1 -1
- package/public/assets/{PrBotBehaviourTab-ChRG19sQ.js → PrBotBehaviourTab-ChfF8zOq.js} +1 -1
- package/public/assets/index-BS5YZ0nk.css +1 -0
- package/public/assets/{index-BvK15F-9.js → index-CFlzr-u4.js} +48 -47
- package/public/index.html +2 -2
- package/public/assets/index-CY_rIeEn.css +0 -1
package/dist/config.js
CHANGED
|
@@ -378,6 +378,9 @@ export const config = {
|
|
|
378
378
|
// AUTOMATIC ticket reviews (trigger 'auto' / 'cascade') per workspace per UTC day. Not shared with
|
|
379
379
|
// the PR review's own auto cap. Manual runs never count. Default = shared TICKET_REVIEW_DAILY_CAP.
|
|
380
380
|
ticketReviewDailyCap: intFromEnv('TICKET_REVIEW_DAILY_CAP', 20),
|
|
381
|
+
// Automatic re-checks of ONE ticket that only follow pushes, per UTC day (review/ticket-review/sweep.ts).
|
|
382
|
+
// A PR joining, leaving or merging, or the story changing, is not limited by it.
|
|
383
|
+
ticketReviewPushRechecksPerDay: intFromEnv('TICKET_REVIEW_PUSH_RECHECKS_PER_DAY', 3),
|
|
381
384
|
// ---- CI review (one run per (PR, head, failing-check set) — review/ci-review/) ----
|
|
382
385
|
// Its OWN spend cap and turn cap: a CI run reads log excerpts and the files they point at, nothing
|
|
383
386
|
// more. Env only — no route sets it.
|
package/dist/db/client.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The app talks to `db` through the async surface only (`await builder` / `.execute()` /
|
|
4
4
|
// `.returning().execute()` / runTransaction). Postgres support is gone; the query layer keeps
|
|
5
5
|
// that style because nothing gains from rewriting it.
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
7
|
import { chmodSync, mkdirSync } from 'node:fs';
|
|
7
8
|
import { dirname } from 'node:path';
|
|
8
9
|
import Database from 'better-sqlite3';
|
|
@@ -40,23 +41,51 @@ export async function closeDb() {
|
|
|
40
41
|
}
|
|
41
42
|
// The one transaction entry. better-sqlite3 rejects async transaction callbacks ("Transaction
|
|
42
43
|
// function cannot return a promise"), so this wraps a manual BEGIN/COMMIT around the awaited
|
|
43
|
-
// (synchronously-resolving) drizzle ops
|
|
44
|
-
//
|
|
44
|
+
// (synchronously-resolving) drizzle ops. The callback only awaits in-process sqlite work, so no
|
|
45
|
+
// MACROtask interleaves — but MICROtasks do: two runTransaction calls started by separate async
|
|
46
|
+
// chains in the same tick both reached BEGIN, and the second threw "cannot start a transaction
|
|
47
|
+
// within a transaction" (two ticket runs started in the same second, 2026-10-28). So callers queue
|
|
48
|
+
// on ONE in-process lock, in call order.
|
|
49
|
+
// ⚠ A NESTED call (runTransaction inside a runTransaction callback) would wait on itself for ever;
|
|
50
|
+
// it throws instead, as the bare BEGIN always did. Detected by the holder's token in async context,
|
|
51
|
+
// so work the callback merely STARTED (fire-and-forget) that runs a transaction after the commit
|
|
52
|
+
// waits its turn rather than throwing.
|
|
53
|
+
let txTail = Promise.resolve();
|
|
54
|
+
let txHolder = null;
|
|
55
|
+
const txContext = new AsyncLocalStorage();
|
|
45
56
|
export async function runTransaction(fn) {
|
|
46
|
-
|
|
57
|
+
const outer = txContext.getStore();
|
|
58
|
+
if (outer != null && outer === txHolder) {
|
|
59
|
+
throw new Error('runTransaction: a nested transaction is not supported');
|
|
60
|
+
}
|
|
61
|
+
const prev = txTail;
|
|
62
|
+
let release;
|
|
63
|
+
txTail = new Promise((resolve) => (release = resolve));
|
|
64
|
+
await prev;
|
|
65
|
+
const token = Symbol('tx');
|
|
66
|
+
txHolder = token;
|
|
47
67
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
return await txContext.run(token, async () => {
|
|
69
|
+
sqliteConn.exec('BEGIN');
|
|
70
|
+
try {
|
|
71
|
+
const result = await fn(db);
|
|
72
|
+
sqliteConn.exec('COMMIT');
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
sqliteConn.exec('ROLLBACK');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* rollback best-effort */
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
51
85
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
/* rollback best-effort */
|
|
58
|
-
}
|
|
59
|
-
throw err;
|
|
86
|
+
finally {
|
|
87
|
+
txHolder = null;
|
|
88
|
+
release();
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
//# sourceMappingURL=client.js.map
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
// ⚠ Manual runs are never auto-posted: a review you start yourself keeps the Post button.
|
|
59
59
|
// ⚠ Never throws: auto-posting failing must never touch the review.
|
|
60
60
|
import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
|
|
61
|
+
import { clipAtSentence as clipAtSentenceShared } from '../../shared/index.js';
|
|
61
62
|
import {} from '../../shared/index.js';
|
|
62
63
|
import { readWorkspaceAutoPostForPr } from './auto-settings.js';
|
|
63
64
|
import { similarTitles } from './settled-by-reply.js';
|
|
@@ -222,14 +223,23 @@ export function liftBlockMessage(by = 'Claude') {
|
|
|
222
223
|
return `${by}'s blocking findings are fixed.`;
|
|
223
224
|
}
|
|
224
225
|
export const LIFT_BLOCK_MESSAGE = liftBlockMessage('Claude');
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
// The lead's ceiling. A summary's first paragraph is normally far shorter; past this it is cut at the
|
|
227
|
+
// last sentence end that fits, never mid-sentence (GitHub allows 65,536 characters in a review body).
|
|
228
|
+
const LEAD_MAX_CHARS = 3_000;
|
|
229
|
+
/** Cut `text` to ≤ `max` characters at the last sentence end that fits; failing that, at the last space
|
|
230
|
+
* (with "…"). Text that fits is returned whole. The fold is shared (`clipAtSentence`, shared prose.ts). */
|
|
231
|
+
export function clipAtSentence(text, max = LEAD_MAX_CHARS) {
|
|
232
|
+
return clipAtSentenceShared(text, max);
|
|
233
|
+
}
|
|
234
|
+
/** The review body's lead: the summary's first plain paragraph (whole, unless it passes
|
|
235
|
+
* LEAD_MAX_CHARS), else a count. `by` names the provider that ran the review (default Claude — its
|
|
236
|
+
* text is unchanged). */
|
|
227
237
|
export function autoReviewBodyLead(summary, commentCount, by = 'Claude') {
|
|
228
238
|
for (const raw of (summary ?? '').split('\n')) {
|
|
229
239
|
const line = raw.trim();
|
|
230
240
|
if (line === '' || /^([-*+]|\d+[.)])\s/.test(line) || line.startsWith('#'))
|
|
231
241
|
continue;
|
|
232
|
-
return line
|
|
242
|
+
return clipAtSentence(line);
|
|
233
243
|
}
|
|
234
244
|
if (commentCount === 0)
|
|
235
245
|
return `Reviewed by ${by}.`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLAUDE_REVIEW_CHAT_MAX_PINS, } from '../../shared/index.js';
|
|
1
|
+
import { CLAUDE_REVIEW_CHAT_MAX_PINS, criterionTextOnly, } from '../../shared/index.js';
|
|
2
2
|
import { loadZod } from '../../ai/runtime.js';
|
|
3
3
|
// Per-field caps on what the model sends back (it is stored and rendered as plain text).
|
|
4
4
|
const CARD_FIELD_CHARS = 4_000;
|
|
@@ -69,8 +69,11 @@ export function findingPinLabel(f) {
|
|
|
69
69
|
return `${SEVERITY_WORD[f.severity] ?? 'Finding'} · ${oneLine(f.title, LABEL_TITLE_CHARS)}`;
|
|
70
70
|
}
|
|
71
71
|
export function storyPinLabel(item, ticketKey) {
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// A missing item's 'M1'… ref is internal (the reader never sees it): it is named by its title only.
|
|
73
|
+
const missing = item.status === 'missing';
|
|
74
|
+
const head = [ticketKey, missing ? null : item.ref].filter((x) => x != null && x !== '').join(' ');
|
|
75
|
+
const title = oneLine(missing ? item.title : criterionTextOnly(item.title, item.ref), LABEL_TITLE_CHARS);
|
|
76
|
+
return `${head !== '' ? `${head} · ` : ''}${STORY_STATUS_WORD[item.status]} · ${title}`;
|
|
74
77
|
}
|
|
75
78
|
const clip = (t, max) => {
|
|
76
79
|
const s = t.replace(/\s+$/, '');
|
|
@@ -540,7 +540,7 @@ function runEnded(item, end) {
|
|
|
540
540
|
// trigger never throws and only queues, so it can neither block nor fail this review.
|
|
541
541
|
if (succeeded) {
|
|
542
542
|
void import('../diagrams/trigger.js')
|
|
543
|
-
.then((m) => m.maybeDrawDiagramsAfterReview(ctx, { accountId: item.accountId, prId, reviewTrigger: item.trigger }))
|
|
543
|
+
.then(async (m) => m.logTriggerLimits(ctx, prId, await m.maybeDrawDiagramsAfterReview(ctx, { accountId: item.accountId, prId, reviewTrigger: item.trigger })))
|
|
544
544
|
.catch((err) => ctx.log.warn(`sequence diagrams pr ${prId}: ${err instanceof Error ? err.message : String(err)}`));
|
|
545
545
|
}
|
|
546
546
|
// AUTO FIX: a SUCCEEDED auto run may start a review-seeded fix on the reader's OWN PR
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLAUDE_REVIEW_TICKET_MAX_CRITERIA, storedList, ticketRef } from '../../shared/index.js';
|
|
1
|
+
import { CLAUDE_REVIEW_TICKET_MAX_CRITERIA, criterionTextForMatch, storedList, ticketRef } from '../../shared/index.js';
|
|
2
2
|
export const TICKET_SUMMARY_CHARS = 1_000;
|
|
3
3
|
export const TICKET_EXPLANATION_CHARS = 1_000;
|
|
4
4
|
export const TICKET_GAP_TITLE_CHARS = 200;
|
|
@@ -143,6 +143,9 @@ export function storyMatchKey(f) {
|
|
|
143
143
|
if (!f.story)
|
|
144
144
|
return null;
|
|
145
145
|
const kind = /^M\d+$/.test(f.story.ref) ? 'missing' : 'criterion';
|
|
146
|
-
|
|
146
|
+
// A criterion's leading "AC2: " is not part of its identity: older runs stored it in the title,
|
|
147
|
+
// newer ones drop it (reconcile.ts), and the two must stay one item (its posted state carries over).
|
|
148
|
+
const text = kind === 'criterion' ? criterionTextForMatch(f.title) : f.title;
|
|
149
|
+
return `${kind}\u0000${text.replace(/\s+/g, ' ').trim().toLowerCase()}`;
|
|
147
150
|
}
|
|
148
151
|
//# sourceMappingURL=ticket.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { repeatRunLimit } from '../../shared/index.js';
|
|
2
3
|
import { codeLocFor, isNonCodeFile } from '../../db/code-loc.js';
|
|
3
4
|
// "IS THIS DIAGRAM STILL TRUE?" and "SHOULD A FINISHED REVIEW DRAW ONE?" — PURE, clock-free.
|
|
4
5
|
//
|
|
@@ -51,8 +52,10 @@ export function diagramSkipReason(a) {
|
|
|
51
52
|
return 'drawn';
|
|
52
53
|
if (a.inFlight)
|
|
53
54
|
return 'in_flight';
|
|
54
|
-
if (a.usedToday >= a.dailyCap)
|
|
55
|
+
if (a.usedToday >= (a.redraw != null ? repeatRunLimit(a.dailyCap) : a.dailyCap))
|
|
55
56
|
return 'daily_cap';
|
|
57
|
+
if (a.redraw != null && a.redraw.subjectDrawsToday >= a.redraw.subjectCap)
|
|
58
|
+
return 'redraw_limit';
|
|
56
59
|
return null;
|
|
57
60
|
}
|
|
58
61
|
//# sourceMappingURL=fingerprint.js.map
|
|
@@ -250,6 +250,33 @@ export async function countReviewDiagramsSince(ctx, accountId, dayStartMs) {
|
|
|
250
250
|
.execute());
|
|
251
251
|
return rows.length;
|
|
252
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* One subject's draw history for the trigger's REDRAW rule (trigger.ts): how many review-started rows
|
|
255
|
+
* of it count against the cap today, and the members of its newest drawn row (any trigger, any status,
|
|
256
|
+
* no server refusal) — null members when that row has none (a PR diagram); `drawnBefore` false when
|
|
257
|
+
* the subject was never drawn.
|
|
258
|
+
*/
|
|
259
|
+
export async function subjectDrawHistory(ctx, accountId, kind, subject, dayStartMs) {
|
|
260
|
+
const d = t(ctx);
|
|
261
|
+
const today = (await ctx.db
|
|
262
|
+
.select({ id: d.id })
|
|
263
|
+
.from(d)
|
|
264
|
+
.where(and(eq(d.accountId, accountId), eq(d.kind, kind), eq(d.subject, subject), eq(d.trigger, 'review'), or(isNull(d.refusal), notInArray(d.refusal, [...FREE_REFUSALS])), gte(d.createdAt, new Date(dayStartMs))))
|
|
265
|
+
.execute());
|
|
266
|
+
const last = (await ctx.db
|
|
267
|
+
.select({ members: d.members })
|
|
268
|
+
.from(d)
|
|
269
|
+
.where(and(eq(d.accountId, accountId), eq(d.kind, kind), eq(d.subject, subject), isNull(d.refusal)))
|
|
270
|
+
.orderBy(desc(d.createdAt), desc(d.id))
|
|
271
|
+
.limit(1)
|
|
272
|
+
.execute());
|
|
273
|
+
const row = last[0];
|
|
274
|
+
return {
|
|
275
|
+
drawsToday: today.length,
|
|
276
|
+
drawnBefore: row != null,
|
|
277
|
+
lastMemberIds: row?.members != null ? row.members.map((m) => m.prId) : null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
253
280
|
// ---- the wire ----
|
|
254
281
|
export function toDiagramView(row, current) {
|
|
255
282
|
const iso = (v) => v == null ? null : (v instanceof Date ? v : new Date(v)).toISOString();
|
|
@@ -4,7 +4,7 @@ import { AGENTIC_AI_ENABLED } from '../claude-review/manager.js';
|
|
|
4
4
|
import { prDiagramFingerprint, storedCodeAnswer, storyDiagramFingerprint } from './fingerprint.js';
|
|
5
5
|
import { liveStoryMembers, startDiagram } from './manager.js';
|
|
6
6
|
import { getDiagramPrContext, latestDiagram, latestSucceededDiagram, toDiagramView, } from './persist.js';
|
|
7
|
-
import { MAX_TICKETS, toDiagramMembers } from './trigger.js';
|
|
7
|
+
import { MAX_TICKETS, diagramAutoSkip, toDiagramMembers } from './trigger.js';
|
|
8
8
|
import { providerName } from '../../ai/prompt-tools.js';
|
|
9
9
|
// THE DESIGN-DIAGRAM API (CORE, free, LOCAL ONLY; paths keep "sequence-diagrams") — docs/API.md § Design diagrams. Registered by
|
|
10
10
|
// `registerAgenticRoutes` (review/agentic.ts), so absent in cloud and under LIMN_AI_DISABLED; the
|
|
@@ -43,13 +43,17 @@ function featureOff(reply) {
|
|
|
43
43
|
/** What to draw and, beside it, the newest run when that is another one. `liveFp` null ⇒ not current. */
|
|
44
44
|
export async function slotFor(ctx, accountId, kind, subject, liveFp) {
|
|
45
45
|
const latest = await latestDiagram(ctx, accountId, kind, subject);
|
|
46
|
+
// Why the last finished review did not draw it at the live inputs (a daily or redraw limit).
|
|
47
|
+
const skipped = diagramAutoSkip(accountId, kind, subject, liveFp);
|
|
48
|
+
const autoSkipped = skipped != null ? { autoSkipped: skipped } : {};
|
|
46
49
|
if (latest == null)
|
|
47
|
-
return { diagram: null, latest: null };
|
|
50
|
+
return { diagram: null, latest: null, ...autoSkipped };
|
|
48
51
|
const good = latest.status === 'succeeded' ? latest : await latestSucceededDiagram(ctx, accountId, kind, subject);
|
|
49
52
|
const view = (r) => toDiagramView(r, liveFp != null && r.fingerprint === liveFp);
|
|
50
53
|
return {
|
|
51
54
|
diagram: good != null ? view(good) : null,
|
|
52
55
|
latest: good != null && good.id === latest.id ? null : view(latest),
|
|
56
|
+
...autoSkipped,
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
async function storySlot(ctx, accountId, ident, ticketKey) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { parseTicketIdent } from '../../shared/index.js';
|
|
1
|
+
import { SEQUENCE_DIAGRAM_SUBJECT_DAILY_DRAWS, parseTicketIdent, } from '../../shared/index.js';
|
|
2
2
|
import { config } from '../../config.js';
|
|
3
3
|
import { getTicketSource } from '../../tracker/ticket-source.js';
|
|
4
4
|
import { automaticRunAllowed } from '../budget-gate.js';
|
|
5
5
|
import { diagramSkipReason, prDiagramFingerprint, storedCodeAnswer, storyDiagramFingerprint, } from './fingerprint.js';
|
|
6
|
-
import { countReviewDiagramsSince, diagramExistsAt, diagramsSwitchedOnForPr, getDiagramPrContext, getPrFileStats, markDiagramRefused, insertQueuedDiagram, } from './persist.js';
|
|
6
|
+
import { countReviewDiagramsSince, diagramExistsAt, diagramsSwitchedOnForPr, getDiagramPrContext, getPrFileStats, markDiagramRefused, insertQueuedDiagram, subjectDrawHistory, } from './persist.js';
|
|
7
7
|
// "A REVIEW JUST FINISHED — DRAW ITS DIAGRAMS?" The one hook between Claude Review and this process.
|
|
8
8
|
// claude-review/manager.ts calls it fire-and-forget after EVERY succeeded run, MANUAL OR AUTO, and
|
|
9
9
|
// nothing it does can reach the review: it never throws (every error is logged and swallowed) and it
|
|
@@ -18,6 +18,15 @@ import { countReviewDiagramsSince, diagramExistsAt, diagramsSwitchedOnForPr, get
|
|
|
18
18
|
// at most MAX_TICKETS. A ticket with MORE THAN ONE open or merged PR queues ONE
|
|
19
19
|
// 'story' diagram under the same rules, its fingerprint over every member's head and
|
|
20
20
|
// state — so a push to ANY member redraws it after that member's next review.
|
|
21
|
+
// ⚠ A REDRAW IS LIMITED (2026-10-28: one story drawn 11 times and one PR 9 times spent the cap of 30
|
|
22
|
+
// by 14:52, and every later review drew nothing, silently). A subject drawn before —
|
|
23
|
+
// a story with the SAME pull requests — is drawn at most SEQUENCE_DIAGRAM_SUBJECT_DAILY_DRAWS
|
|
24
|
+
// (2) times a UTC day, its first draw included, and never takes the cap's last slots
|
|
25
|
+
// (shared `repeatRunLimit`). A first draw, or a story whose PRs changed, is held only
|
|
26
|
+
// by the cap. A skip for a limit is LOGGED (`logTriggerLimits`) and kept in memory
|
|
27
|
+
// for the slot (`diagramAutoSkip` → `autoSkipped`), so the pane can say why.
|
|
28
|
+
// After a TICKET REVIEW succeeds (`maybeDrawStoryDiagramAfterTicketReview`), its story is asked again
|
|
29
|
+
// under the same rules: a PR's ticket link can land after that PR's own review ended.
|
|
21
30
|
//
|
|
22
31
|
// ⚠ THE PER-WORKSPACE SWITCH (`workspaces.auto_diagrams_enabled`, migration 0126, ON by default):
|
|
23
32
|
// switched off for the PR's workspace ⇒ 'switched_off' and nothing is queued or stored. It gates
|
|
@@ -36,6 +45,7 @@ const defaultDeps = {
|
|
|
36
45
|
switchedOn: diagramsSwitchedOnForPr,
|
|
37
46
|
exists: diagramExistsAt,
|
|
38
47
|
usedToday: countReviewDiagramsSince,
|
|
48
|
+
history: subjectDrawHistory,
|
|
39
49
|
held: async (accountId, kind, subject) => (await manager()).diagramHeld(accountId, kind, subject),
|
|
40
50
|
ticketsForPr: async (accountId, prId) => (await getTicketSource().ticketsForPr(accountId, prId)).map((t) => ({ ident: t.ident, key: t.ticket.key ?? null })),
|
|
41
51
|
storyMembers: async (ctx, accountId, ident) => (await manager()).liveStoryMembers(ctx, accountId, ident),
|
|
@@ -46,6 +56,7 @@ const defaultDeps = {
|
|
|
46
56
|
await markDiagramRefused(ctx, a.accountId, id, 'no_code');
|
|
47
57
|
},
|
|
48
58
|
dailyCap: config.sequenceDiagramDailyCap,
|
|
59
|
+
subjectCap: SEQUENCE_DIAGRAM_SUBJECT_DAILY_DRAWS,
|
|
49
60
|
// The ONE agentic switch (config.aiEnabled); the routes and the manager check it too.
|
|
50
61
|
enabled: config.aiEnabled,
|
|
51
62
|
};
|
|
@@ -67,6 +78,85 @@ function fold(codes) {
|
|
|
67
78
|
return 'unknown';
|
|
68
79
|
return 'no';
|
|
69
80
|
}
|
|
81
|
+
// ---- why a design was not redrawn (show the wait) ----
|
|
82
|
+
// `${accountId}|${kind}|${subject}` → the skip and the fingerprint it was skipped at. The slot reads it
|
|
83
|
+
// only while that fingerprint is still the live one (routes.ts `slotFor`), so a later draw or a new
|
|
84
|
+
// head clears it by itself. In memory: a restart forgets it, and the next review asks again.
|
|
85
|
+
const autoSkips = new Map();
|
|
86
|
+
const skipKey = (accountId, kind, subject) => `${accountId}|${kind}|${subject}`;
|
|
87
|
+
function noteSkip(accountId, kind, subject, fp, outcome) {
|
|
88
|
+
const k = skipKey(accountId, kind, subject);
|
|
89
|
+
if (outcome === 'daily_cap')
|
|
90
|
+
autoSkips.set(k, { skip: 'daily_limit', fingerprint: fp });
|
|
91
|
+
else if (outcome === 'redraw_limit')
|
|
92
|
+
autoSkips.set(k, { skip: 'redraw_limit', fingerprint: fp });
|
|
93
|
+
else if (outcome === 'queued' || outcome === 'drawn')
|
|
94
|
+
autoSkips.delete(k);
|
|
95
|
+
}
|
|
96
|
+
/** Why the last finished review did not draw this subject at `liveFp`; null when it did, or nothing is known. */
|
|
97
|
+
export function diagramAutoSkip(accountId, kind, subject, liveFp) {
|
|
98
|
+
const s = autoSkips.get(skipKey(accountId, kind, subject));
|
|
99
|
+
return s != null && liveFp != null && s.fingerprint === liveFp ? s.skip : null;
|
|
100
|
+
}
|
|
101
|
+
export function _resetDiagramSkipsForTest() {
|
|
102
|
+
autoSkips.clear();
|
|
103
|
+
}
|
|
104
|
+
/** The redraw rule's input for one subject: null for a FIRST draw (never drawn, or a story whose
|
|
105
|
+
* pull requests changed since its last design). */
|
|
106
|
+
async function redrawOf(ctx, deps, accountId, kind, subject, dayStartMs, memberIds) {
|
|
107
|
+
const h = await deps.history(ctx, accountId, kind, subject, dayStartMs);
|
|
108
|
+
if (!h.drawnBefore)
|
|
109
|
+
return null;
|
|
110
|
+
if (memberIds != null) {
|
|
111
|
+
const before = new Set(h.lastMemberIds ?? []);
|
|
112
|
+
const same = h.lastMemberIds != null && before.size === memberIds.length && memberIds.every((id) => before.has(id));
|
|
113
|
+
if (!same)
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return { subjectDrawsToday: h.drawsToday, subjectCap: deps.subjectCap };
|
|
117
|
+
}
|
|
118
|
+
/** One ticket's story design, after a review of one of its PRs or after its ticket review. */
|
|
119
|
+
async function drawStory(ctx, deps, accountId, t, used, dayStartMs) {
|
|
120
|
+
const members = await deps.storyMembers(ctx, accountId, t.ident);
|
|
121
|
+
const stats = await deps.fileStats(ctx, accountId, members.map((m) => m.prId));
|
|
122
|
+
const code = fold(members.map((m) => (stats.has(m.prId) ? storedCodeAnswer(stats.get(m.prId)) : 'unknown')));
|
|
123
|
+
const fp = storyDiagramFingerprint(t.ident, members);
|
|
124
|
+
const drawn = members.length >= 2 && (await deps.exists(ctx, accountId, 'story', t.ident, fp));
|
|
125
|
+
const storyArgs = {
|
|
126
|
+
accountId,
|
|
127
|
+
kind: 'story',
|
|
128
|
+
subject: t.ident,
|
|
129
|
+
prId: null,
|
|
130
|
+
ticketKey: t.key,
|
|
131
|
+
fingerprint: fp,
|
|
132
|
+
headSha: null,
|
|
133
|
+
members: toDiagramMembers(members),
|
|
134
|
+
trigger: 'review',
|
|
135
|
+
};
|
|
136
|
+
const redraw = members.length >= 2 && !drawn
|
|
137
|
+
? await redrawOf(ctx, deps, accountId, 'story', t.ident, dayStartMs, members.map((m) => m.prId))
|
|
138
|
+
: null;
|
|
139
|
+
const skip = diagramSkipReason({
|
|
140
|
+
code,
|
|
141
|
+
drawnAtFingerprint: drawn,
|
|
142
|
+
inFlight: await deps.held(accountId, 'story', t.ident),
|
|
143
|
+
usedToday: used.n,
|
|
144
|
+
dailyCap: deps.dailyCap,
|
|
145
|
+
memberCount: members.length,
|
|
146
|
+
redraw,
|
|
147
|
+
});
|
|
148
|
+
if (skip != null) {
|
|
149
|
+
noteSkip(accountId, 'story', t.ident, fp, skip);
|
|
150
|
+
if (skip === 'no_code' && !drawn)
|
|
151
|
+
await deps.refuseNoCode(ctx, storyArgs);
|
|
152
|
+
return skip;
|
|
153
|
+
}
|
|
154
|
+
const r = await deps.enqueue(ctx, storyArgs);
|
|
155
|
+
noteSkip(accountId, 'story', t.ident, fp, r.outcome);
|
|
156
|
+
if (r.outcome === 'queued')
|
|
157
|
+
used.n += 1;
|
|
158
|
+
return r.outcome;
|
|
159
|
+
}
|
|
70
160
|
/** Never throws. Manual and auto reviews both draw; `reviewTrigger` decides only whether the Claude
|
|
71
161
|
* spend budget is asked (auto only). */
|
|
72
162
|
export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, nowMs = Date.now()) {
|
|
@@ -91,7 +181,8 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
|
|
|
91
181
|
result.pr = 'no_head';
|
|
92
182
|
return result;
|
|
93
183
|
}
|
|
94
|
-
|
|
184
|
+
const dayStartMs = utcDayStartMs(nowMs);
|
|
185
|
+
const used = { n: await deps.usedToday(ctx, accountId, dayStartMs) };
|
|
95
186
|
// ---- the PR's own diagram ----
|
|
96
187
|
const prSubject = String(prId);
|
|
97
188
|
const prFp = prDiagramFingerprint(prId, pr.headSha);
|
|
@@ -111,19 +202,22 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
|
|
|
111
202
|
code: storedCodeAnswer(pr),
|
|
112
203
|
drawnAtFingerprint: prDrawn,
|
|
113
204
|
inFlight: await deps.held(accountId, 'pr', prSubject),
|
|
114
|
-
usedToday: used,
|
|
205
|
+
usedToday: used.n,
|
|
115
206
|
dailyCap: deps.dailyCap,
|
|
207
|
+
redraw: prDrawn ? null : await redrawOf(ctx, deps, accountId, 'pr', prSubject, dayStartMs, null),
|
|
116
208
|
});
|
|
117
209
|
if (prSkip != null) {
|
|
118
210
|
result.pr = prSkip;
|
|
211
|
+
noteSkip(accountId, 'pr', prSubject, prFp, prSkip);
|
|
119
212
|
if (prSkip === 'no_code' && !prDrawn)
|
|
120
213
|
await deps.refuseNoCode(ctx, prArgs);
|
|
121
214
|
}
|
|
122
215
|
else {
|
|
123
216
|
const r = await deps.enqueue(ctx, prArgs);
|
|
124
217
|
result.pr = r.outcome;
|
|
218
|
+
noteSkip(accountId, 'pr', prSubject, prFp, r.outcome);
|
|
125
219
|
if (r.outcome === 'queued')
|
|
126
|
-
used += 1;
|
|
220
|
+
used.n += 1;
|
|
127
221
|
}
|
|
128
222
|
// ---- the story diagrams of its tickets ----
|
|
129
223
|
const tickets = (await deps.ticketsForPr(accountId, prId).catch(() => []))
|
|
@@ -133,40 +227,7 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
|
|
|
133
227
|
})
|
|
134
228
|
.slice(0, MAX_TICKETS);
|
|
135
229
|
for (const t of tickets) {
|
|
136
|
-
|
|
137
|
-
const stats = await deps.fileStats(ctx, accountId, members.map((m) => m.prId));
|
|
138
|
-
const code = fold(members.map((m) => (stats.has(m.prId) ? storedCodeAnswer(stats.get(m.prId)) : 'unknown')));
|
|
139
|
-
const fp = storyDiagramFingerprint(t.ident, members);
|
|
140
|
-
const drawn = members.length >= 2 && (await deps.exists(ctx, accountId, 'story', t.ident, fp));
|
|
141
|
-
const storyArgs = {
|
|
142
|
-
accountId,
|
|
143
|
-
kind: 'story',
|
|
144
|
-
subject: t.ident,
|
|
145
|
-
prId: null,
|
|
146
|
-
ticketKey: t.key,
|
|
147
|
-
fingerprint: fp,
|
|
148
|
-
headSha: null,
|
|
149
|
-
members: toDiagramMembers(members),
|
|
150
|
-
trigger: 'review',
|
|
151
|
-
};
|
|
152
|
-
const skip = diagramSkipReason({
|
|
153
|
-
code,
|
|
154
|
-
drawnAtFingerprint: drawn,
|
|
155
|
-
inFlight: await deps.held(accountId, 'story', t.ident),
|
|
156
|
-
usedToday: used,
|
|
157
|
-
dailyCap: deps.dailyCap,
|
|
158
|
-
memberCount: members.length,
|
|
159
|
-
});
|
|
160
|
-
if (skip != null) {
|
|
161
|
-
result.stories.push({ ident: t.ident, outcome: skip });
|
|
162
|
-
if (skip === 'no_code' && !drawn)
|
|
163
|
-
await deps.refuseNoCode(ctx, storyArgs);
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
const r = await deps.enqueue(ctx, storyArgs);
|
|
167
|
-
result.stories.push({ ident: t.ident, outcome: r.outcome });
|
|
168
|
-
if (r.outcome === 'queued')
|
|
169
|
-
used += 1;
|
|
230
|
+
result.stories.push({ ident: t.ident, outcome: await drawStory(ctx, deps, accountId, t, used, dayStartMs) });
|
|
170
231
|
}
|
|
171
232
|
return result;
|
|
172
233
|
}
|
|
@@ -175,4 +236,40 @@ export async function maybeDrawDiagramsAfterReview(ctx, a, deps = defaultDeps, n
|
|
|
175
236
|
return result;
|
|
176
237
|
}
|
|
177
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Never throws. A TICKET REVIEW of a tracker ticket just succeeded: draw its story design under the
|
|
241
|
+
* same rules. It closes a race the PR trigger cannot: a PR's ticket link is read when the PR is
|
|
242
|
+
* received, and can land AFTER that PR's review ended — then the PR review saw a one-PR ticket and
|
|
243
|
+
* drew no story. A ticket review runs once the link exists (a PR joining is what re-runs it).
|
|
244
|
+
* `viaPrId` is any member, for the workspace switch.
|
|
245
|
+
*/
|
|
246
|
+
export async function maybeDrawStoryDiagramAfterTicketReview(ctx, a, deps = defaultDeps, nowMs = Date.now()) {
|
|
247
|
+
if (!deps.enabled)
|
|
248
|
+
return 'disabled';
|
|
249
|
+
const p = parseTicketIdent(a.ident);
|
|
250
|
+
if (p == null || p.kind === 'manual')
|
|
251
|
+
return 'disabled';
|
|
252
|
+
try {
|
|
253
|
+
if (!(await deps.switchedOn(ctx, a.accountId, a.viaPrId)))
|
|
254
|
+
return 'switched_off';
|
|
255
|
+
if (a.reviewTrigger === 'auto' && !(await automaticRunAllowed(ctx, a.accountId, 'diagrams')))
|
|
256
|
+
return 'over_budget';
|
|
257
|
+
const dayStartMs = utcDayStartMs(nowMs);
|
|
258
|
+
const used = { n: await deps.usedToday(ctx, a.accountId, dayStartMs) };
|
|
259
|
+
return await drawStory(ctx, deps, a.accountId, { ident: a.ident, key: a.ticketKey }, used, dayStartMs);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
ctx.log.warn(`story design after the ticket review of ${a.ident}: ${err instanceof Error ? err.message : String(err)}`);
|
|
263
|
+
return 'disabled';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** One log line when a finished review drew nothing because of a limit (silent otherwise). */
|
|
267
|
+
export function logTriggerLimits(ctx, prId, r) {
|
|
268
|
+
const limited = [
|
|
269
|
+
r.pr === 'daily_cap' || r.pr === 'redraw_limit' ? `pr ${prId}: ${r.pr}` : null,
|
|
270
|
+
...r.stories.filter((s) => s.outcome === 'daily_cap' || s.outcome === 'redraw_limit').map((s) => `${s.ident}: ${s.outcome}`),
|
|
271
|
+
].filter((x) => x != null);
|
|
272
|
+
if (limited.length > 0)
|
|
273
|
+
ctx.log.info(`sequence diagrams not drawn (${limited.join(', ')})`);
|
|
274
|
+
}
|
|
178
275
|
//# sourceMappingURL=trigger.js.map
|
|
@@ -181,6 +181,24 @@ function launch(job) {
|
|
|
181
181
|
progress({ phase: 'preparing' });
|
|
182
182
|
void runPipeline(job, controller, progress)
|
|
183
183
|
.then(() => {
|
|
184
|
+
// THE STORY DESIGN (review/diagrams/trigger.ts): a SUCCEEDED run of a ticket with 2+ PRs asks
|
|
185
|
+
// for its story design under the trigger's own rules — a PR's ticket link can land after that
|
|
186
|
+
// PR's review ended, so the PR review alone can miss it. Fire-and-forget; it only queues.
|
|
187
|
+
void getTicketReviewById(ctx, job.accountId, runId)
|
|
188
|
+
.then(async (r) => {
|
|
189
|
+
const via = r?.members[0]?.prId;
|
|
190
|
+
if (r?.status !== 'succeeded' || r.members.length < 2 || via == null)
|
|
191
|
+
return;
|
|
192
|
+
const m = await import('../diagrams/trigger.js');
|
|
193
|
+
await m.maybeDrawStoryDiagramAfterTicketReview(ctx, {
|
|
194
|
+
accountId: job.accountId,
|
|
195
|
+
ident: job.ident,
|
|
196
|
+
ticketKey: r.ticketKey,
|
|
197
|
+
viaPrId: via,
|
|
198
|
+
reviewTrigger: isAuto(job) ? 'auto' : 'manual',
|
|
199
|
+
});
|
|
200
|
+
})
|
|
201
|
+
.catch((err) => ctx.log.warn(`story design after ticket review ${runId}: ${err instanceof Error ? err.message : String(err)}`));
|
|
184
202
|
// AUTO-POSTING: an AUTOMATIC run that succeeded may post its story gaps on their owner PRs
|
|
185
203
|
// (./auto-post.ts decides, reads the run's status itself, never throws, never retries).
|
|
186
204
|
if (!isAuto(job))
|
|
@@ -550,11 +550,21 @@ export async function getTicketStateInputs(ctx, accountId, idents) {
|
|
|
550
550
|
latestRowsByIdent(ctx, accountId, idents, ['queued', 'running']),
|
|
551
551
|
latestRowsByIdent(ctx, accountId, idents, null),
|
|
552
552
|
]);
|
|
553
|
-
|
|
553
|
+
// A newer attempt than the latest success (it failed, was refused or was cancelled) reads its own
|
|
554
|
+
// members too.
|
|
555
|
+
const newerAttempts = [...any.values()].filter((a) => a.id > (succeeded.get(a.ticketIdent)?.id ?? 0));
|
|
556
|
+
const memberRows = await readMembers(ctx, accountId, [
|
|
557
|
+
...[...succeeded.values()].map((r) => r.id),
|
|
558
|
+
...newerAttempts.map((r) => r.id),
|
|
559
|
+
]);
|
|
560
|
+
const membersOf = (runId) => memberRows
|
|
561
|
+
.filter((m) => m.ticketReviewId === runId)
|
|
562
|
+
.map((m) => ({ prId: m.prId, headSha: m.headSha, state: m.prState }));
|
|
554
563
|
for (const ident of new Set(idents)) {
|
|
555
564
|
const s = succeeded.get(ident);
|
|
556
565
|
const run = live.get(ident);
|
|
557
566
|
const a = any.get(ident);
|
|
567
|
+
const newer = a != null && a.id > (s?.id ?? 0);
|
|
558
568
|
out.set(ident, {
|
|
559
569
|
latest: s != null
|
|
560
570
|
? {
|
|
@@ -564,13 +574,19 @@ export async function getTicketStateInputs(ctx, accountId, idents) {
|
|
|
564
574
|
alignment: asAlignment(s.alignment),
|
|
565
575
|
assessment: s.assessment ?? null,
|
|
566
576
|
completedAt: asDate(s.completedAt),
|
|
567
|
-
members:
|
|
568
|
-
.filter((m) => m.ticketReviewId === s.id)
|
|
569
|
-
.map((m) => ({ prId: m.prId, headSha: m.headSha, state: m.prState })),
|
|
577
|
+
members: membersOf(s.id),
|
|
570
578
|
}
|
|
571
579
|
: null,
|
|
572
580
|
runningRunId: run?.id ?? null,
|
|
573
|
-
latestAttempt: a != null
|
|
581
|
+
latestAttempt: a != null
|
|
582
|
+
? {
|
|
583
|
+
id: a.id,
|
|
584
|
+
status: a.status,
|
|
585
|
+
fingerprint: a.fingerprint,
|
|
586
|
+
refused: a.refused,
|
|
587
|
+
...(newer ? { ticketHash: a.ticketHash, members: membersOf(a.id) } : {}),
|
|
588
|
+
}
|
|
589
|
+
: null,
|
|
574
590
|
});
|
|
575
591
|
}
|
|
576
592
|
return out;
|
|
@@ -619,6 +635,19 @@ export async function getTicketItemPostContext(ctx, accountId, runId, itemId) {
|
|
|
619
635
|
const item = run.items.find((i) => i.id === itemId);
|
|
620
636
|
return item != null ? { run, item } : null;
|
|
621
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* The ticket's 'cascade' runs (automatic re-checks) since `dayStartMs` — the per-ticket limit on
|
|
640
|
+
* re-checks that only follow pushes (sweep.ts). Every workspace counts: it is the ticket's own budget.
|
|
641
|
+
*/
|
|
642
|
+
export async function countCascadeTicketReviewsSince(ctx, accountId, ident, dayStartMs) {
|
|
643
|
+
const { tr } = tables(ctx);
|
|
644
|
+
const rows = (await ctx.db
|
|
645
|
+
.select({ id: tr.id })
|
|
646
|
+
.from(tr)
|
|
647
|
+
.where(and(eq(tr.accountId, accountId), eq(tr.ticketIdent, ident), eq(tr.trigger, 'cascade'), gte(tr.createdAt, new Date(dayStartMs))))
|
|
648
|
+
.execute());
|
|
649
|
+
return rows.length;
|
|
650
|
+
}
|
|
622
651
|
/**
|
|
623
652
|
* Automatic ticket reviews (trigger 'auto' or 'cascade') a workspace started since `dayStartMs` —
|
|
624
653
|
* the TICKET_REVIEW_DAILY_CAP counter. Manual runs never count.
|