limn-review 0.1.141 → 0.1.142
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/db/erase-account.js +4 -0
- package/dist/db/migrations/0138_team_claim_lines.sql +55 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/queries.js +4 -0
- package/dist/db/retention.js +4 -0
- package/dist/db/schema.sqlite.js +47 -0
- package/dist/db/team-claim-lines-delete.js +18 -0
- package/dist/review/agentic.js +5 -0
- package/dist/review/claude-review/auto.js +5 -0
- package/dist/review/claude-review/chat.js +71 -11
- package/dist/review/claude-review/manager.js +19 -2
- package/dist/review/claude-review/team-claim-ledger.js +135 -0
- package/dist/review/claude-review/team-claim-release.js +274 -0
- package/dist/review/claude-review/team-claim.js +200 -34
- package/dist/shared/ai-provider-words.js +10 -0
- package/package.json +1 -1
- package/public/assets/{AiFixTab-DCFKnCcv.js → AiFixTab-B3eAgc7u.js} +3 -3
- package/public/assets/ClaudeReviewTab-C7GoZdiF.js +20 -0
- package/public/assets/{PrBotBehaviourTab-Btzq2J3-.js → PrBotBehaviourTab-ChRG19sQ.js} +1 -1
- package/public/assets/{index-DInrk9cC.js → index-BvK15F-9.js} +44 -44
- package/public/index.html +1 -1
- package/public/assets/ClaudeReviewTab-CZxpjVme.js +0 -20
package/dist/db/erase-account.js
CHANGED
|
@@ -168,6 +168,8 @@ export async function eraseAccountData(accountId) {
|
|
|
168
168
|
// hold third-party analysis of the account's code. No FK on the snapshots, so this is the whole
|
|
169
169
|
// guarantee; workspace_sonar cascades from workspaces too, and is explicit anyway.
|
|
170
170
|
await tx.delete(schema.sonarSnapshots).where(eq(schema.sonarSnapshots.accountId, accountId)).execute();
|
|
171
|
+
// The team-claim ledger (migration 0138): GitHub logins and heads. No FK, so this is the whole guarantee.
|
|
172
|
+
await tx.delete(schema.teamClaimLines).where(eq(schema.teamClaimLines.accountId, accountId)).execute();
|
|
171
173
|
await tx.delete(schema.workspaceSonar).where(eq(schema.workspaceSonar.accountId, accountId)).execute();
|
|
172
174
|
// Sequence diagrams (migration 0115 / pg 0102): no FKs, so this is the whole guarantee (story rows
|
|
173
175
|
// carry no pr_id and are reached ONLY here).
|
|
@@ -330,6 +332,8 @@ export function accountScopedTables() {
|
|
|
330
332
|
{ name: 'workspaceTrackers', col: schema.workspaceTrackers.accountId, table: schema.workspaceTrackers },
|
|
331
333
|
// SonarCloud (migration 0114 / pg 0101), erased explicitly above; `workspaceSonar` holds a credential.
|
|
332
334
|
{ name: 'sonarSnapshots', col: schema.sonarSnapshots.accountId, table: schema.sonarSnapshots },
|
|
335
|
+
// The team-claim ledger (migration 0138), erased explicitly above.
|
|
336
|
+
{ name: 'teamClaimLines', col: schema.teamClaimLines.accountId, table: schema.teamClaimLines },
|
|
333
337
|
{ name: 'workspaceSonar', col: schema.workspaceSonar.accountId, table: schema.workspaceSonar },
|
|
334
338
|
// Sequence diagrams (migration 0115 / pg 0102), erased explicitly above.
|
|
335
339
|
{ name: 'sequenceDiagrams', col: schema.sequenceDiagrams.accountId, table: schema.sequenceDiagrams },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- THE TEAM-CLAIM LEDGER (docs/CLAUDE-REVIEW.md § Team claims → Releasing stale lines). One row per
|
|
2
|
+
-- GitHub status line (`limn/review-claude` | `limn/review-codex`) Limn holds or released on a PR
|
|
3
|
+
-- head, written BEFORE the pending line is posted, so a restart, a dropped run or a failed final write
|
|
4
|
+
-- never leaves this machine's line pending: the reconciler releases every open row whose run is not
|
|
5
|
+
-- running. `whose = 'teammate'` records a teammate's expired line released once.
|
|
6
|
+
CREATE TABLE `team_claim_lines` (
|
|
7
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
8
|
+
`account_id` integer NOT NULL,
|
|
9
|
+
`pr_id` integer NOT NULL,
|
|
10
|
+
`repo_id` integer,
|
|
11
|
+
`owner` text,
|
|
12
|
+
`name` text,
|
|
13
|
+
`head_sha` text NOT NULL,
|
|
14
|
+
`provider` text NOT NULL,
|
|
15
|
+
`review_id` integer,
|
|
16
|
+
`whose` text DEFAULT 'mine' NOT NULL,
|
|
17
|
+
`state` text NOT NULL,
|
|
18
|
+
`login` text,
|
|
19
|
+
`final_description` text,
|
|
20
|
+
`final_target_url` text,
|
|
21
|
+
`release_attempts` integer DEFAULT 0 NOT NULL,
|
|
22
|
+
`last_error` text,
|
|
23
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
24
|
+
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
25
|
+
);
|
|
26
|
+
--> statement-breakpoint
|
|
27
|
+
CREATE INDEX `team_claim_lines_state` ON `team_claim_lines` (`state`);
|
|
28
|
+
--> statement-breakpoint
|
|
29
|
+
CREATE INDEX `team_claim_lines_account_pr_head` ON `team_claim_lines` (`account_id`,`pr_id`,`head_sha`);
|
|
30
|
+
--> statement-breakpoint
|
|
31
|
+
-- Lines older Limn versions left PENDING (a failed run's "didn't finish · free to claim", or a run that
|
|
32
|
+
-- died mid-way): the newest auto run's record per (PR, head, context), when it is still pending, from
|
|
33
|
+
-- the last 14 days. The reconciler replaces each with the success release line at boot — only where the
|
|
34
|
+
-- head's newest line is still this account's pending one (it reads GitHub first).
|
|
35
|
+
INSERT INTO `team_claim_lines` (`account_id`, `pr_id`, `repo_id`, `owner`, `name`, `head_sha`, `provider`, `review_id`, `whose`, `state`)
|
|
36
|
+
SELECT cr.`account_id`, cr.`pr_id`, pr.`repo_id`, r.`owner`, r.`name`, cr.`head_sha`,
|
|
37
|
+
CASE json_extract(cr.`team_claim`, '$.context') WHEN 'limn/review-codex' THEN 'codex' ELSE 'claude' END,
|
|
38
|
+
cr.`id`, 'mine', 'pending'
|
|
39
|
+
FROM `claude_reviews` cr
|
|
40
|
+
JOIN `pull_requests` pr ON pr.`id` = cr.`pr_id`
|
|
41
|
+
JOIN `repos` r ON r.`id` = pr.`repo_id`
|
|
42
|
+
WHERE cr.`team_claim` IS NOT NULL
|
|
43
|
+
AND json_valid(cr.`team_claim`)
|
|
44
|
+
AND json_extract(cr.`team_claim`, '$.state') = 'marked'
|
|
45
|
+
AND json_extract(cr.`team_claim`, '$.lastLine') IN ('reviewing', 'unfinished')
|
|
46
|
+
AND cr.`created_at` >= unixepoch() - 14 * 86400
|
|
47
|
+
AND cr.`id` = (
|
|
48
|
+
SELECT MAX(c2.`id`) FROM `claude_reviews` c2
|
|
49
|
+
WHERE c2.`account_id` = cr.`account_id`
|
|
50
|
+
AND c2.`pr_id` = cr.`pr_id`
|
|
51
|
+
AND c2.`head_sha` = cr.`head_sha`
|
|
52
|
+
AND c2.`team_claim` IS NOT NULL
|
|
53
|
+
AND json_valid(c2.`team_claim`)
|
|
54
|
+
AND json_extract(c2.`team_claim`, '$.context') = json_extract(cr.`team_claim`, '$.context')
|
|
55
|
+
);
|
|
@@ -967,6 +967,13 @@
|
|
|
967
967
|
"when": 1792137624016,
|
|
968
968
|
"tag": "0137_review_pair_consolidation",
|
|
969
969
|
"breakpoints": true
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
"idx": 138,
|
|
973
|
+
"version": "6",
|
|
974
|
+
"when": 1792137625016,
|
|
975
|
+
"tag": "0138_team_claim_lines",
|
|
976
|
+
"breakpoints": true
|
|
970
977
|
}
|
|
971
978
|
]
|
|
972
979
|
}
|
package/dist/db/queries.js
CHANGED
|
@@ -18,6 +18,7 @@ const REASON_PRIORITY = [
|
|
|
18
18
|
];
|
|
19
19
|
import { db, schema } from './client.js';
|
|
20
20
|
import { dropRepoRelations } from './related-repos.js';
|
|
21
|
+
import { deleteSettledClaimLinesForPrs } from './team-claim-lines-delete.js';
|
|
21
22
|
import { runTransaction } from './client.js';
|
|
22
23
|
import { enrichMyTurn } from '../feed/my-turn.js';
|
|
23
24
|
import { resolvePrTickets } from '../pr/detail-enricher.js';
|
|
@@ -7873,6 +7874,9 @@ export async function deleteRepo(id, accountId) {
|
|
|
7873
7874
|
await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
|
|
7874
7875
|
// SonarCloud snapshots (migration 0114 / pg 0101). No FKs, so nothing would cascade.
|
|
7875
7876
|
await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
|
|
7877
|
+
// The team-claim ledger (migration 0138). No FKs, so nothing would cascade. This machine's OPEN
|
|
7878
|
+
// rows stay: their lines still need releasing on GitHub (db/team-claim-lines-delete.ts).
|
|
7879
|
+
await deleteSettledClaimLinesForPrs(tx, prIds);
|
|
7876
7880
|
// Sequence diagrams of these PRs, and the story diagrams naming any of them (migration 0115 /
|
|
7877
7881
|
// pg 0102). No FKs, so nothing would cascade. Before the PRs go: it reads their accounts.
|
|
7878
7882
|
await pruneSequenceDiagramsForPrs(tx, prIds);
|
package/dist/db/retention.js
CHANGED
|
@@ -4,6 +4,7 @@ import { config } from '../config.js';
|
|
|
4
4
|
import { pruneTicketReviewsForPrs } from './ticket-review-prune.js';
|
|
5
5
|
import { pruneCiReviewsForPrs } from './ci-review-prune.js';
|
|
6
6
|
import { pruneSequenceDiagramsForPrs } from './sequence-diagram-prune.js';
|
|
7
|
+
import { deleteSettledClaimLinesForPrs } from './team-claim-lines-delete.js';
|
|
7
8
|
// Time-based retention sweep. Per-account server data (events, PRs + their whole subtree)
|
|
8
9
|
// accumulates forever otherwise; this prunes anything past the retention window so the DB
|
|
9
10
|
// stays bounded (the pre-launch, tight-budget scalability lever). Runs periodically from
|
|
@@ -100,6 +101,9 @@ async function deletePrSubtree(tx, prIds) {
|
|
|
100
101
|
await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
|
|
101
102
|
// SonarCloud snapshots (migration 0114 / pg 0101). No FKs — explicit, pr-keyed.
|
|
102
103
|
await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
|
|
104
|
+
// The team-claim ledger (migration 0138). No FKs — explicit, pr-keyed. This machine's OPEN rows
|
|
105
|
+
// stay: their lines still need releasing on GitHub (db/team-claim-lines-delete.ts).
|
|
106
|
+
await deleteSettledClaimLinesForPrs(tx, prIds);
|
|
103
107
|
// Sequence diagrams of these PRs, and the story diagrams naming any of them (migration 0115 / pg
|
|
104
108
|
// 0102). No FKs. Before the PRs go: it reads their accounts.
|
|
105
109
|
await pruneSequenceDiagramsForPrs(tx, prIds);
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -3390,4 +3390,51 @@ export const storyDocsAlignments = sqliteTable('story_docs_alignments', {
|
|
|
3390
3390
|
foreignColumns: [workspaces.id, workspaces.accountId],
|
|
3391
3391
|
}).onDelete('cascade'),
|
|
3392
3392
|
}));
|
|
3393
|
+
// team_claim_lines — THE TEAM-CLAIM LEDGER (migration 0138; docs/CLAUDE-REVIEW.md § Team claims →
|
|
3394
|
+
// Releasing stale lines). ONE row per GitHub status line Limn holds or has released on a PR head, so a
|
|
3395
|
+
// line this machine posted is never left pending by a restart, a dropped run or a failed final write:
|
|
3396
|
+
// whose 'mine' a claim this machine made. Written as 'claiming' BEFORE the pending line is posted,
|
|
3397
|
+
// 'pending' once it is up, 'finished' when the success line ("reviewed") is up,
|
|
3398
|
+
// 'released' when the release line ("didn't finish · free to claim"), a Clear, a
|
|
3399
|
+
// newer claim at the same head, or GitHub's refusal ended it.
|
|
3400
|
+
// whose 'teammate' a teammate's expired line this machine released ("Released: bob's claim
|
|
3401
|
+
// expired"), recorded so it is posted ONCE. Always 'released'.
|
|
3402
|
+
// The reconciler (review/claude-review/team-claim-release.ts) releases every 'claiming' / 'pending'
|
|
3403
|
+
// row whose run is not queued or running in this process. No FK on pr_id (the sonar_snapshots
|
|
3404
|
+
// precedent): BOTH delete paths (deleteRepo, retention's deletePrSubtree) delete its rows EXCEPT this
|
|
3405
|
+
// machine's OPEN ones (a pending line on GitHub still needs its release; the row carries owner/name);
|
|
3406
|
+
// the erasure deletes everything.
|
|
3407
|
+
export const teamClaimLines = sqliteTable('team_claim_lines', {
|
|
3408
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
3409
|
+
accountId: integer('account_id').notNull(),
|
|
3410
|
+
prId: integer('pr_id').notNull(),
|
|
3411
|
+
repoId: integer('repo_id'),
|
|
3412
|
+
// The repo's owner/name when the row was written: an open line is still released after its PR or
|
|
3413
|
+
// repo left Limn (the delete paths keep OPEN 'mine' rows for exactly that).
|
|
3414
|
+
owner: text('owner'),
|
|
3415
|
+
name: text('name'),
|
|
3416
|
+
headSha: text('head_sha').notNull(),
|
|
3417
|
+
// 'claude' | 'codex' — the status context (`limn/review-<provider>`).
|
|
3418
|
+
provider: text('provider').notNull(),
|
|
3419
|
+
// The run the claim was for (NULL until its row is written, and on a teammate's line).
|
|
3420
|
+
reviewId: integer('review_id'),
|
|
3421
|
+
whose: text('whose', { enum: ['mine', 'teammate'] }).notNull().default('mine'),
|
|
3422
|
+
state: text('state', { enum: ['claiming', 'pending', 'finished', 'released'] }).notNull(),
|
|
3423
|
+
// The GitHub login the line names (NULL on a row backfilled from before the ledger: the
|
|
3424
|
+
// account's login is used).
|
|
3425
|
+
login: text('login'),
|
|
3426
|
+
// A success line that could not be written when the run finished: the reconciler posts IT
|
|
3427
|
+
// instead of the release line.
|
|
3428
|
+
finalDescription: text('final_description'),
|
|
3429
|
+
finalTargetUrl: text('final_target_url'),
|
|
3430
|
+
releaseAttempts: integer('release_attempts').notNull().default(0),
|
|
3431
|
+
lastError: text('last_error'),
|
|
3432
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
|
|
3433
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
|
|
3434
|
+
}, (t) => ({
|
|
3435
|
+
// The reconciler's read (open rows).
|
|
3436
|
+
stateIdx: index('team_claim_lines_state').on(t.state),
|
|
3437
|
+
// One head's rows (supersede, Clear, the tab's "Clear" offer).
|
|
3438
|
+
prHeadIdx: index('team_claim_lines_account_pr_head').on(t.accountId, t.prId, t.headSha),
|
|
3439
|
+
}));
|
|
3393
3440
|
//# sourceMappingURL=schema.sqlite.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The team-claim ledger's DELETE-PATH rule (migration 0138; docs/CLAUDE-REVIEW.md § Team claims →
|
|
2
|
+
// Releasing stale lines). When PRs leave Limn (deleteRepo, retention's deletePrSubtree), their ledger
|
|
3
|
+
// rows go — EXCEPT this machine's OPEN ones ('mine', 'claiming' / 'pending'): each stands for a line
|
|
4
|
+
// that may still be pending on GitHub, and the reconciler releases it from the row's own owner/name
|
|
5
|
+
// (it closes a row that has neither). The account erasure deletes every row regardless.
|
|
6
|
+
import { and, eq, inArray, not } from 'drizzle-orm';
|
|
7
|
+
import { schema } from './client.js';
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
export async function deleteSettledClaimLinesForPrs(tx, prIds) {
|
|
10
|
+
if (prIds.length === 0)
|
|
11
|
+
return;
|
|
12
|
+
const tl = schema.teamClaimLines;
|
|
13
|
+
await tx
|
|
14
|
+
.delete(tl)
|
|
15
|
+
.where(and(inArray(tl.prId, prIds), not(and(eq(tl.whose, 'mine'), inArray(tl.state, ['claiming', 'pending'])))))
|
|
16
|
+
.execute();
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=team-claim-lines-delete.js.map
|
package/dist/review/agentic.js
CHANGED
|
@@ -101,6 +101,11 @@ export async function startAgenticBackground(app) {
|
|
|
101
101
|
catch (err) {
|
|
102
102
|
app.log.warn({ err }, 'claude review: startup reconcile failed');
|
|
103
103
|
}
|
|
104
|
+
// TEAM CLAIMS: a run this process lost (a restart mid-review) left its limn/review-* line pending on
|
|
105
|
+
// GitHub; release every such line now, in the BACKGROUND (GitHub calls; never fails or delays the boot).
|
|
106
|
+
void import('./claude-review/team-claim-release.js')
|
|
107
|
+
.then((m) => m.releaseStaleTeamClaims(ctx))
|
|
108
|
+
.catch((err) => app.log.warn({ err }, 'team claim release at boot failed'));
|
|
104
109
|
try {
|
|
105
110
|
await reconcileThreadChecksOnStartup(ctx);
|
|
106
111
|
}
|
|
@@ -572,6 +572,11 @@ export function registerAutoReview(ctx) {
|
|
|
572
572
|
return;
|
|
573
573
|
ctx.registerScheduledJob(AUTO_REVIEW_CRON, async () => {
|
|
574
574
|
await runAutoReviewSweep(ctx);
|
|
575
|
+
// TEAM CLAIMS: release every limn/review-* line of ours no run holds any more, and teammates'
|
|
576
|
+
// expired ones (team-claim-release.ts). Never throws; never blocks the sweep.
|
|
577
|
+
await import('./team-claim-release.js')
|
|
578
|
+
.then((m) => m.releaseStaleTeamClaims(ctx))
|
|
579
|
+
.catch((err) => ctx.log.warn(`team claim release: ${err instanceof Error ? err.message : String(err)}`));
|
|
575
580
|
}, 'claude-auto-review');
|
|
576
581
|
}
|
|
577
582
|
//# sourceMappingURL=auto.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
|
-
import { CLAUDE_REVIEW_CHAT_EXPLAIN_DEFAULT_QUESTION, CLAUDE_REVIEW_CHAT_MAX_QUESTION_CHARS, CLAUDE_REVIEW_MODELS, aiRunProvider, DEFAULT_CLAUDE_REVIEW_MODEL, } from '../../shared/index.js';
|
|
2
|
+
import { CLAUDE_REVIEW_CHAT_EXPLAIN_DEFAULT_QUESTION, CLAUDE_REVIEW_CHAT_MAX_QUESTION_CHARS, CLAUDE_REVIEW_MODELS, aiRunProvider, findingSourceLabel, pairChatProvider, DEFAULT_CLAUDE_REVIEW_MODEL, } from '../../shared/index.js';
|
|
3
3
|
import { requestModelOf } from '../../ai/provider.js';
|
|
4
4
|
import { AGENTIC_AI_ENABLED, chatMayApplyAuthEnv } from './manager.js';
|
|
5
5
|
import { getReviewPrContext, getReviewView } from './persist.js';
|
|
@@ -7,6 +7,7 @@ import { pickReviewNonce } from './prompts.js';
|
|
|
7
7
|
import { EXPLAIN_INSTRUCTION, EXPLAIN_SYSTEM_ADDENDUM, explainSectionLines, explanationsMarkdown, findingPinLabel, findingPinText, parsePinRefs, questionWithPins, storyPinLabel, storyPinText, validateExplanations, } from './chat-explain.js';
|
|
8
8
|
import { providerName } from '../../ai/prompt-tools.js';
|
|
9
9
|
import { followUpProvider, preflightFor } from '../../ai/run-choice.js';
|
|
10
|
+
import { activeProviderId } from '../../ai/provider-setting.js';
|
|
10
11
|
// CLAUDE REVIEW CHAT (CORE, free, local-only like the rest of Claude Review). Questions about ONE
|
|
11
12
|
// succeeded review: a general thread (findingId null) and one thread per finding. The answer comes
|
|
12
13
|
// from `ctx.review.chat` — a core agent run that MIRRORS the review's mode (worktree → Read/Glob/
|
|
@@ -77,16 +78,49 @@ const messageCols = (msg) => ({
|
|
|
77
78
|
provider: msg.provider,
|
|
78
79
|
});
|
|
79
80
|
/** One thread's stored messages, oldest first. Callers have already proved the review is theirs. */
|
|
80
|
-
export async function listChatMessages(ctx, accountId,
|
|
81
|
+
export async function listChatMessages(ctx, accountId,
|
|
82
|
+
// One run's id, or a pair's thread ids (`chatThreadIds`).
|
|
83
|
+
reviewId, findingId) {
|
|
81
84
|
const { msg } = tables(ctx);
|
|
85
|
+
const ids = typeof reviewId === 'number' ? [reviewId] : [...reviewId];
|
|
82
86
|
const rows = (await ctx.db
|
|
83
87
|
.select(messageCols(msg))
|
|
84
88
|
.from(msg)
|
|
85
|
-
.where(and(eq(msg.accountId, accountId), eq(msg.reviewId, reviewId), findingId == null ? isNull(msg.findingId) : eq(msg.findingId, findingId)))
|
|
89
|
+
.where(and(eq(msg.accountId, accountId), ids.length === 1 ? eq(msg.reviewId, ids[0]) : inArray(msg.reviewId, ids), findingId == null ? isNull(msg.findingId) : eq(msg.findingId, findingId)))
|
|
86
90
|
.orderBy(asc(msg.id))
|
|
87
91
|
.execute());
|
|
88
92
|
return rows.map(toWire);
|
|
89
93
|
}
|
|
94
|
+
// ---- a pair's ONE chat ---------------------------------------------------------
|
|
95
|
+
/**
|
|
96
|
+
* Where a review's chat is STORED: a Claude + Codex pair's chat (general and per finding) is keyed to
|
|
97
|
+
* the pair's FIRST row id (`pair.pairId`, which always exists), whichever half is latest; an ordinary
|
|
98
|
+
* run's is its own id.
|
|
99
|
+
*/
|
|
100
|
+
export function chatStoreId(review) {
|
|
101
|
+
return review.pair?.pairId ?? review.id;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Every id a review's chat may be READ from: a pair's first row id, then each half's (a pair's chat was
|
|
105
|
+
* stored under its latest half's id before 2026-10-28, so both are read; nothing is moved).
|
|
106
|
+
*/
|
|
107
|
+
export function chatThreadIds(review) {
|
|
108
|
+
const ids = new Set([chatStoreId(review), review.id]);
|
|
109
|
+
for (const h of review.pair?.halves ?? [])
|
|
110
|
+
ids.add(h.reviewId);
|
|
111
|
+
return [...ids];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* WHO ANSWERS: an ordinary run's own provider; a pair's, the DEFAULT provider when its half succeeded,
|
|
115
|
+
* else the half that did (`pairChatProvider`). Either way only when it can answer now; else the
|
|
116
|
+
* default (`followUpProvider`).
|
|
117
|
+
*/
|
|
118
|
+
export function chatPreferredProvider(review, defaultProvider = activeProviderId) {
|
|
119
|
+
if (review.pair == null)
|
|
120
|
+
return aiRunProvider(review.provider, review.model);
|
|
121
|
+
const ok = (review.pair.halves ?? []).filter((h) => h.status === 'succeeded').map((h) => h.provider);
|
|
122
|
+
return pairChatProvider(defaultProvider(), ok);
|
|
123
|
+
}
|
|
90
124
|
/** Pair stored messages into question/answer turns (an unanswered question is skipped). */
|
|
91
125
|
export function pairTurns(messages) {
|
|
92
126
|
const out = [];
|
|
@@ -160,6 +194,10 @@ const findingRef = (findings, id) => `F${findings.findIndex((f) => f.id === id)
|
|
|
160
194
|
function findingText(f, ref) {
|
|
161
195
|
const where = f.line != null ? `${f.path}:${f.line}` : f.path;
|
|
162
196
|
const parts = [`${ref} [${f.severity}] ${where}`, `Title: ${f.title}`, clip(f.body, FINDING_BODY_CHARS)];
|
|
197
|
+
// A Claude + Codex pair's finding names who raised it (absent on an ordinary run: its prompt is
|
|
198
|
+
// unchanged).
|
|
199
|
+
if (f.source != null)
|
|
200
|
+
parts.splice(2, 0, `Raised by: ${findingSourceLabel(f.source)}`);
|
|
163
201
|
if (f.editedBody && f.editedBody.trim() && f.editedBody !== f.body) {
|
|
164
202
|
parts.push(`The developer reworded it as:\n${clip(f.editedBody, FINDING_BODY_CHARS)}`);
|
|
165
203
|
}
|
|
@@ -211,6 +249,10 @@ export function buildChatPrompt(input) {
|
|
|
211
249
|
fence(lines, 'PULL REQUEST', nonce, `Title: ${pr.title}\n\n${pr.body && pr.body.trim() ? clip(pr.body, PR_BODY_CHARS) : '(no description)'}`);
|
|
212
250
|
lines.push('');
|
|
213
251
|
lines.push('## Your review');
|
|
252
|
+
// A Claude + Codex pair is asked about as its ONE consolidated review.
|
|
253
|
+
if (review.pair != null) {
|
|
254
|
+
lines.push('This is one review by Claude and Codex together: the summary and verdict are the merged ones, and each finding says who raised it.');
|
|
255
|
+
}
|
|
214
256
|
fence(lines, 'REVIEW', nonce, `Verdict: ${review.verdict ?? 'COMMENT'}\n\n${clip(review.summary ?? '(no summary)', SUMMARY_CHARS)}`);
|
|
215
257
|
lines.push('');
|
|
216
258
|
lines.push(`## Findings (${review.findings.length})`);
|
|
@@ -421,15 +463,18 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
|
|
|
421
463
|
// the head has not moved. Once it has, the findings' own excerpts (and, in worktree mode, the
|
|
422
464
|
// files at the reviewed commit) are what the answer reads.
|
|
423
465
|
const prepared = headMoved ? null : await preparedFor(ctx, reviewId, pr);
|
|
424
|
-
|
|
466
|
+
// A pair's ONE chat: stored under its first row id, read from every id it may sit under.
|
|
467
|
+
const storeId = chatStoreId(review);
|
|
468
|
+
const stored = await listChatMessages(ctx, accountId, chatThreadIds(review), findingId);
|
|
425
469
|
const fit = fitTranscript(pairTurns(stored));
|
|
426
470
|
const base = { review, pr, mode, headMoved, prepared, findingId, turns: fit.turns, question, pins };
|
|
427
471
|
const nonce = pickReviewNonce(chatUntrustedTexts(base));
|
|
428
472
|
const prompt = buildChatPrompt({ ...base, nonce });
|
|
429
473
|
const model = chatModelFor(review.model);
|
|
430
474
|
// THE CHAT RUNS ON ITS REVIEW'S PROVIDER when that one is signed in and can answer; else on the
|
|
431
|
-
// default provider (Settings → AI). A Codex review is asked about on Codex, a Claude one on Claude
|
|
432
|
-
|
|
475
|
+
// default provider (Settings → AI). A Codex review is asked about on Codex, a Claude one on Claude;
|
|
476
|
+
// a Claude + Codex pair on the default provider when its half succeeded, else on the half that did.
|
|
477
|
+
const provider = followUpProvider(chatPreferredProvider(review), 'review_chat', preflightFor(ctx));
|
|
433
478
|
const abortController = new AbortController();
|
|
434
479
|
const timer = setTimeout(() => abortController.abort(), CHAT_TIMEOUT_MS);
|
|
435
480
|
let result;
|
|
@@ -495,7 +540,7 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
|
|
|
495
540
|
.values([
|
|
496
541
|
{
|
|
497
542
|
accountId,
|
|
498
|
-
reviewId,
|
|
543
|
+
reviewId: storeId,
|
|
499
544
|
findingId,
|
|
500
545
|
role: 'user',
|
|
501
546
|
content: question,
|
|
@@ -504,7 +549,7 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
|
|
|
504
549
|
},
|
|
505
550
|
{
|
|
506
551
|
accountId,
|
|
507
|
-
reviewId,
|
|
552
|
+
reviewId: storeId,
|
|
508
553
|
findingId,
|
|
509
554
|
role: 'assistant',
|
|
510
555
|
content: clip(answerText, STORED_ANSWER_CHARS),
|
|
@@ -548,6 +593,7 @@ export async function listChatHistoryForPr(ctx, accountId, prId) {
|
|
|
548
593
|
reviewMode: cr.reviewMode,
|
|
549
594
|
finishedAt: cr.finishedAt,
|
|
550
595
|
createdAt: cr.createdAt,
|
|
596
|
+
pairId: cr.pairId,
|
|
551
597
|
})
|
|
552
598
|
.from(cr)
|
|
553
599
|
.where(and(eq(cr.accountId, accountId), eq(cr.prId, prId)))
|
|
@@ -555,6 +601,19 @@ export async function listChatHistoryForPr(ctx, accountId, prId) {
|
|
|
555
601
|
.execute());
|
|
556
602
|
if (runs.length === 0)
|
|
557
603
|
return { prId, runs: [] };
|
|
604
|
+
// A Claude + Codex pair is ONE review with ONE chat: every half's messages are listed under the
|
|
605
|
+
// pair's view id (its LATEST half, the id the tab shows), so the current pair's chat never reappears
|
|
606
|
+
// as an "earlier review".
|
|
607
|
+
const viewIdOf = new Map();
|
|
608
|
+
const latestOfPair = new Map();
|
|
609
|
+
for (const r of runs) {
|
|
610
|
+
const key = r.pairId ?? null;
|
|
611
|
+
if (key == null)
|
|
612
|
+
continue;
|
|
613
|
+
latestOfPair.set(key, Math.max(latestOfPair.get(key) ?? 0, r.id));
|
|
614
|
+
}
|
|
615
|
+
for (const r of runs)
|
|
616
|
+
viewIdOf.set(r.id, r.pairId != null ? (latestOfPair.get(r.pairId) ?? r.id) : r.id);
|
|
558
617
|
const rows = (await ctx.db
|
|
559
618
|
.select({ ...messageCols(msg), reviewId: msg.reviewId })
|
|
560
619
|
.from(msg)
|
|
@@ -563,9 +622,10 @@ export async function listChatHistoryForPr(ctx, accountId, prId) {
|
|
|
563
622
|
.execute());
|
|
564
623
|
const byRun = new Map();
|
|
565
624
|
for (const r of rows) {
|
|
566
|
-
const
|
|
625
|
+
const key = viewIdOf.get(r.reviewId) ?? r.reviewId;
|
|
626
|
+
const list = byRun.get(key) ?? [];
|
|
567
627
|
list.push(toWire(r));
|
|
568
|
-
byRun.set(
|
|
628
|
+
byRun.set(key, list);
|
|
569
629
|
}
|
|
570
630
|
return {
|
|
571
631
|
prId,
|
|
@@ -623,7 +683,7 @@ export function registerClaudeReviewChatRoutes(app, ctx) {
|
|
|
623
683
|
return {
|
|
624
684
|
reviewId,
|
|
625
685
|
findingId,
|
|
626
|
-
messages: await listChatMessages(ctx, accountId,
|
|
686
|
+
messages: await listChatMessages(ctx, accountId, chatThreadIds(review), findingId),
|
|
627
687
|
headMoved: pr?.headSha != null && pr.headSha !== review.headSha,
|
|
628
688
|
answering: inFlight.get(accountId) === reviewId,
|
|
629
689
|
};
|
|
@@ -227,6 +227,23 @@ opts = {}) {
|
|
|
227
227
|
export function reviewHeldForPr(prId) {
|
|
228
228
|
return claimed.has(prId) || startingAuto.has(prId) || autoPending.some((a) => a.prId === prId);
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* TEAM CLAIMS — may a GitHub line for `provider` at `headSha` on this PR still be held by a run of
|
|
232
|
+
* THIS process? (team-claim-release.ts asks before releasing a ledger row.) True while an auto item is
|
|
233
|
+
* mid-start (its provider is not known yet), while a pair is between its halves at that head, and while
|
|
234
|
+
* a run at that head holds that provider's line — its own, or (a pair's second half) its first half's.
|
|
235
|
+
*/
|
|
236
|
+
export function reviewClaimBusy(accountId, prId, provider, headSha) {
|
|
237
|
+
if (startingAuto.get(prId)?.accountId === accountId)
|
|
238
|
+
return true;
|
|
239
|
+
const between = startingPair.get(prId);
|
|
240
|
+
if (between && between.accountId === accountId && between.headSha === headSha)
|
|
241
|
+
return true;
|
|
242
|
+
const run = runningItems.get(prId);
|
|
243
|
+
if (!run || run.accountId !== accountId || run.headSha !== headSha)
|
|
244
|
+
return false;
|
|
245
|
+
return run.provider === provider || run.pair?.first?.provider === provider;
|
|
246
|
+
}
|
|
230
247
|
/**
|
|
231
248
|
* Put ONE PR on the auto lane (the sweeper's only door into the queue). Synchronous and
|
|
232
249
|
* storage-free: the row is written when a slot opens (`startAutoItem`). Same model and budget as a
|
|
@@ -353,7 +370,7 @@ async function startAutoItem(a) {
|
|
|
353
370
|
if (pair)
|
|
354
371
|
pair.pairId = reviewId;
|
|
355
372
|
if (teamClaim)
|
|
356
|
-
await saveTeamClaimRecord(ctx, reviewId, teamClaim.record).catch(() => { });
|
|
373
|
+
await saveTeamClaimRecord(ctx, reviewId, teamClaim.record, teamClaim).catch(() => { });
|
|
357
374
|
reviewIdByPr.set(prId, reviewId);
|
|
358
375
|
startingAuto.delete(prId);
|
|
359
376
|
launched = true;
|
|
@@ -642,7 +659,7 @@ async function startPairSecond(first) {
|
|
|
642
659
|
const firstFindings = firstReview?.findings ?? [];
|
|
643
660
|
const reviewId = await insertQueuedReview(ctx, prId, first.headSha, first.model, accountId, [], first.trigger, provider, first.fresh === true, { role: 'second', pairId: pair.pairId });
|
|
644
661
|
if (teamClaim)
|
|
645
|
-
await saveTeamClaimRecord(ctx, reviewId, teamClaim.record).catch(() => { });
|
|
662
|
+
await saveTeamClaimRecord(ctx, reviewId, teamClaim.record, teamClaim).catch(() => { });
|
|
646
663
|
startingPair.delete(prId);
|
|
647
664
|
launched = true;
|
|
648
665
|
// The second half's row is running now; the end (consolidateAtPairEnd) marks the merge itself.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, lt } from 'drizzle-orm';
|
|
2
|
+
export const OPEN_CLAIM_STATES = ['claiming', 'pending'];
|
|
3
|
+
/** After this many failed releases a row is left (logged) — and out of the reconciler's read; the tab's
|
|
4
|
+
* Clear still finds it (`openClaimLinesAtHead`). */
|
|
5
|
+
export const RELEASE_MAX_ATTEMPTS = 5;
|
|
6
|
+
const t = (ctx) => ctx.schema.teamClaimLines;
|
|
7
|
+
const now = () => new Date();
|
|
8
|
+
const clipErr = (s) => (s == null ? null : s.slice(0, 300));
|
|
9
|
+
/**
|
|
10
|
+
* The INTENT, written before the pending line is posted ('claiming'). Our new line replaces any older
|
|
11
|
+
* line of ours at the same (PR, head, provider), so those rows are closed ('released', "superseded")
|
|
12
|
+
* in the same transaction. Returns the new row's id.
|
|
13
|
+
*/
|
|
14
|
+
export async function recordClaimIntent(ctx, row) {
|
|
15
|
+
const tl = t(ctx);
|
|
16
|
+
const prs = ctx.schema.pullRequests;
|
|
17
|
+
const repo = (await ctx.db
|
|
18
|
+
.select({ repoId: prs.repoId })
|
|
19
|
+
.from(prs)
|
|
20
|
+
.where(and(eq(prs.id, row.prId), eq(prs.accountId, row.accountId)))
|
|
21
|
+
.limit(1)
|
|
22
|
+
.execute());
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
const write = async (tx) => {
|
|
25
|
+
await tx
|
|
26
|
+
.update(tl)
|
|
27
|
+
.set({ state: 'released', lastError: 'superseded', updatedAt: now() })
|
|
28
|
+
.where(and(eq(tl.accountId, row.accountId), eq(tl.prId, row.prId), eq(tl.headSha, row.headSha), eq(tl.provider, row.provider), eq(tl.whose, 'mine'), inArray(tl.state, [...OPEN_CLAIM_STATES])))
|
|
29
|
+
.execute();
|
|
30
|
+
const [ins] = (await tx
|
|
31
|
+
.insert(tl)
|
|
32
|
+
.values({
|
|
33
|
+
accountId: row.accountId,
|
|
34
|
+
prId: row.prId,
|
|
35
|
+
repoId: repo[0]?.repoId ?? null,
|
|
36
|
+
owner: row.owner ?? null,
|
|
37
|
+
name: row.name ?? null,
|
|
38
|
+
headSha: row.headSha,
|
|
39
|
+
provider: row.provider,
|
|
40
|
+
whose: 'mine',
|
|
41
|
+
state: 'claiming',
|
|
42
|
+
login: row.login,
|
|
43
|
+
createdAt: now(),
|
|
44
|
+
updatedAt: now(),
|
|
45
|
+
})
|
|
46
|
+
.returning({ id: tl.id })
|
|
47
|
+
.execute());
|
|
48
|
+
return ins.id;
|
|
49
|
+
};
|
|
50
|
+
// Two DB statements, no network: close the older rows, then write the intent.
|
|
51
|
+
return write(ctx.db);
|
|
52
|
+
}
|
|
53
|
+
/** Move a row on (and stamp it). `patch` may set the run, the error, the counters. */
|
|
54
|
+
export async function setClaimLine(ctx, id, patch) {
|
|
55
|
+
const tl = t(ctx);
|
|
56
|
+
const set = { updatedAt: now() };
|
|
57
|
+
for (const [k, v] of Object.entries(patch))
|
|
58
|
+
if (v !== undefined)
|
|
59
|
+
set[k] = k === 'lastError' ? clipErr(v) : v;
|
|
60
|
+
await ctx.db.update(tl).set(set).where(eq(tl.id, id)).execute();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The open rows (ours: 'claiming' / 'pending') the reconciler may still try, oldest first, capped.
|
|
64
|
+
* ⚠ A row that used up its RELEASE_MAX_ATTEMPTS is left OUT here, so given-up rows can never fill the
|
|
65
|
+
* window and starve newer ones.
|
|
66
|
+
*/
|
|
67
|
+
export async function openClaimLines(ctx, limit = 50) {
|
|
68
|
+
const tl = t(ctx);
|
|
69
|
+
return (await ctx.db
|
|
70
|
+
.select()
|
|
71
|
+
.from(tl)
|
|
72
|
+
.where(and(eq(tl.whose, 'mine'), inArray(tl.state, [...OPEN_CLAIM_STATES]), lt(tl.releaseAttempts, RELEASE_MAX_ATTEMPTS)))
|
|
73
|
+
.orderBy(asc(tl.id))
|
|
74
|
+
.limit(limit)
|
|
75
|
+
.execute());
|
|
76
|
+
}
|
|
77
|
+
/** This machine's open rows at one PR head (the tab's "Clear" offer). */
|
|
78
|
+
export async function openClaimLinesAtHead(ctx, accountId, prId, headSha) {
|
|
79
|
+
const tl = t(ctx);
|
|
80
|
+
return (await ctx.db
|
|
81
|
+
.select()
|
|
82
|
+
.from(tl)
|
|
83
|
+
.where(and(eq(tl.accountId, accountId), eq(tl.prId, prId), eq(tl.headSha, headSha), eq(tl.whose, 'mine'), inArray(tl.state, [...OPEN_CLAIM_STATES])))
|
|
84
|
+
.orderBy(asc(tl.id))
|
|
85
|
+
.execute());
|
|
86
|
+
}
|
|
87
|
+
/** A Clear (or anything else that settled the head for one provider): close every open row there. */
|
|
88
|
+
export async function releaseClaimLinesAtHead(ctx, accountId, prId, headSha, provider, why) {
|
|
89
|
+
const tl = t(ctx);
|
|
90
|
+
const rows = (await ctx.db
|
|
91
|
+
.update(tl)
|
|
92
|
+
.set({ state: 'released', lastError: clipErr(why), updatedAt: now() })
|
|
93
|
+
.where(and(eq(tl.accountId, accountId), eq(tl.prId, prId), eq(tl.headSha, headSha), eq(tl.provider, provider), inArray(tl.state, [...OPEN_CLAIM_STATES])))
|
|
94
|
+
.returning({ id: tl.id })
|
|
95
|
+
.execute());
|
|
96
|
+
return rows.length;
|
|
97
|
+
}
|
|
98
|
+
/** Has this machine already released `login`'s line at this head (posted once, never again)? */
|
|
99
|
+
export async function teammateLineReleased(ctx, accountId, prId, headSha, provider, login) {
|
|
100
|
+
const tl = t(ctx);
|
|
101
|
+
const rows = (await ctx.db
|
|
102
|
+
.select({ id: tl.id, login: tl.login })
|
|
103
|
+
.from(tl)
|
|
104
|
+
.where(and(eq(tl.accountId, accountId), eq(tl.prId, prId), eq(tl.headSha, headSha), eq(tl.provider, provider), eq(tl.whose, 'teammate')))
|
|
105
|
+
.execute());
|
|
106
|
+
return rows.some((r) => (r.login ?? '').toLowerCase() === login.toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
/** Record a teammate's line this machine released (or tried to: `lastError`). */
|
|
109
|
+
export async function recordTeammateRelease(ctx, row) {
|
|
110
|
+
const tl = t(ctx);
|
|
111
|
+
const prs = ctx.schema.pullRequests;
|
|
112
|
+
const repo = (await ctx.db
|
|
113
|
+
.select({ repoId: prs.repoId })
|
|
114
|
+
.from(prs)
|
|
115
|
+
.where(and(eq(prs.id, row.prId), eq(prs.accountId, row.accountId)))
|
|
116
|
+
.limit(1)
|
|
117
|
+
.execute());
|
|
118
|
+
await ctx.db
|
|
119
|
+
.insert(tl)
|
|
120
|
+
.values({
|
|
121
|
+
accountId: row.accountId,
|
|
122
|
+
prId: row.prId,
|
|
123
|
+
repoId: repo[0]?.repoId ?? null,
|
|
124
|
+
headSha: row.headSha,
|
|
125
|
+
provider: row.provider,
|
|
126
|
+
whose: 'teammate',
|
|
127
|
+
state: 'released',
|
|
128
|
+
login: row.login,
|
|
129
|
+
lastError: clipErr(row.lastError),
|
|
130
|
+
createdAt: now(),
|
|
131
|
+
updatedAt: now(),
|
|
132
|
+
})
|
|
133
|
+
.execute();
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=team-claim-ledger.js.map
|