@orion-agents/orion-code 0.3.28 → 0.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -22,9 +22,59 @@ which is **not** a pass.
22
22
 
23
23
  ## [Unreleased]
24
24
 
25
- ## [0.3.28] — UNRELEASED
25
+ ## [0.3.29] — UNRELEASED
26
+
27
+ > **Status: candidate.** Open. The Web shared review loop
28
+ > (plan: `docs/plan/v0.3.29-plan.md`, PR3 core of the v0.3.27 review loop):
29
+ > the Host now starts the same bounded single-pass review runner the CLI uses
30
+ > (`runCodeReview`, v0.3.28) against the active Workspace. `POST /review/run`
31
+ > performs fast admission (one in-flight run per Host, typed
32
+ > `review_run_in_progress` 409 and `review_no_model` 422) and the model
33
+ > completion continues in the background; `GET /review/runs` and
34
+ > `GET /review/runs/:runId` read a bounded in-memory history where every
35
+ > receipt passes digest re-verification and is flagged `stale` when the
36
+ > repository revision changed after admission. The Review panel lists the runs
37
+ > with honest state banners (running / cancelled / failed / partial / stale
38
+ > never render as a green pass), groups findings P0→P3 with file:line anchors
39
+ > into the existing diff view, counts contract-invalid model output instead of
40
+ > hiding it, and can send each finding back to the Composer as a typed draft
41
+ > the user still confirms. The fileKey→path mapping is captured through an
42
+ > additive `onSnapshotCaptured` runner hook; the frozen receipt contract is
43
+ > unchanged. Not merged, tagged or published.
26
44
 
27
- > **Status: candidate.** Open. The typed `/review` runtime & CLI slice
45
+ ### Added
46
+
47
+ - `src/web/code-review-run-service.ts` — the Host-side review run service:
48
+ single-flight admission, background execution of the shared runner, bounded
49
+ receipt history (20 runs, oldest finished evicted first), digest
50
+ re-verification on read, repository-revision staleness flags, cancellation,
51
+ and the `parseWebReviewSelectorV1` request validation.
52
+ - `src/web/workbench-controller.ts` — `reviewRunStart` / `reviewRunList` /
53
+ `reviewRunGet` / `reviewRunCancel`, with the review model adapter derived
54
+ from the control-plane Runtime's `llm` (typed 422 when the Host has no
55
+ configured model).
56
+ - `src/web/server.ts` — `POST /review/run` and
57
+ `POST /review/runs/:runId/cancel` behind `assertMutation` +
58
+ `executeMutation`, plus read-only `GET /review/runs[/:runId]` with context
59
+ guards.
60
+ - `web/src/components/review/ReviewRunsSection.tsx` and
61
+ `review-runs-view.ts` — the Review panel results section: run button,
62
+ polling while a run is in flight, priority-grouped findings, honest banners,
63
+ diff anchoring and typed Composer drafts.
64
+ - Tests: `tests/web-code-review-run-service.test.ts` (10 unit tests),
65
+ `tests/web-review-run-endpoint.test.ts` (4 real-host tests on a temp Git
66
+ repository, including the 403 nonce guard and the 422 no-model path),
67
+ `tests/web-review-runs-view.test.tsx` (13 pure + SSR view tests).
68
+
69
+ ### Not run
70
+
71
+ - Real Host + Chrome full-chain E2E and the human-labeled quality benchmark
72
+ (PR4) — the local automation sandbox has no model credentials; recorded
73
+ honestly as `not_run`, not silently skipped.
74
+
75
+ ## [0.3.28] — 2026-10-22
76
+
77
+ > **Status: published.** The typed `/review` runtime & CLI slice
28
78
  > (plan: `docs/plan/v0.3.28-plan.md`, PR2 core of the v0.3.27 review loop):
29
79
  > `/review` arguments now parse into the frozen `ReviewSelectorV1` (worktree /
30
80
  > `--base` / repeatable `--path`, back-compat single-path filter) and unknown
@@ -38,7 +88,8 @@ which is **not** a pass.
38
88
  > reports "no changes under review" without calling the model, and partial,
39
89
  > failed, cancelled or inconclusive runs never render as a green pass.
40
90
  > Web wiring (PR3) and the qualification benchmark (PR4) remain subsequent
41
- > slices. Not merged, tagged or published.
91
+ > slices. Merged as #284, tagged `v0.3.28` on main (`8a4722b`), published to
92
+ > npm (registry `latest` = 0.3.28).
42
93
 
43
94
  ### Added
44
95
 
package/README.md CHANGED
@@ -2,15 +2,13 @@
2
2
 
3
3
  Local-first, goal-driven coding agent for the terminal and browser.
4
4
 
5
- > v0.3.28 candidate — open. The typed `/review` workflow: slash arguments are
6
- > parsed into the frozen review selector (worktree / `--base` / `--path`, with
7
- > usage errors instead of silent chat fallback), the review runs against the
8
- > admission-time frozen Git snapshot through a bounded single-pass model call,
9
- > model findings are validated against the contract and the snapshot manifest,
10
- > and the run renders a digest-verifiable receipt with honest state banners
11
- > (empty scope ≠ review pass; partial / failed / cancelled never green). The
12
- > previous release 0.3.27 is on npm (registry `latest`), tagged `v0.3.27`
13
- > (`0be5b18`, #283) and released on GitHub.
5
+ > v0.3.29 candidate — open. The Web shared review loop: the Host starts the same
6
+ > bounded `/review` runner the CLI uses, the Review panel lists digest-verifiable
7
+ > receipts with honest state banners (running / partial / failed / cancelled /
8
+ > stale never green), findings are grouped P0→P3 and anchor to the existing diff
9
+ > view, and each finding can be sent back to the Composer as a typed draft. The
10
+ > previous release 0.3.28 is on npm (registry `latest`), tagged `v0.3.28`
11
+ > (`8a4722b`, #284) and released on GitHub.
14
12
 
15
13
  [中文说明](README.zh-CN.md) ·
16
14
  [v0.3.24 plan](docs/plan/v0.3.24-plan.md) ·
@@ -173,10 +171,10 @@ user extension boundaries.
173
171
  Node.js 22.12+, 24, and 26 are supported. Use Node 24 LTS for production or Node 26 Current for
174
172
  current development environments. Node 20 is upstream EOL and is no longer a v0.3 runtime.
175
173
 
176
- To install the pinned v0.3.28 package when it is available on npm:
174
+ To install the pinned v0.3.29 package when it is available on npm:
177
175
 
178
176
  ```bash
179
- npm install -g @orion-agents/orion-code@0.3.28
177
+ npm install -g @orion-agents/orion-code@0.3.29
180
178
  orion --version
181
179
  orion doctor
182
180
  ```
package/README.zh-CN.md CHANGED
@@ -2,12 +2,11 @@
2
2
 
3
3
  面向终端与浏览器、本地优先的目标驱动 Coding Agent。
4
4
 
5
- > v0.3.28 候选版本:进行中。类型化 `/review` 审查工作流:slash 参数解析为冻结的审查
6
- > selector(worktree / `--base` / `--path`,未知参数返回用法而非静默转入普通聊天);审查
7
- > 基于 admission 时冻结的 Git snapshot,经有界单轮模型补全执行;模型 finding 按契约与
8
- > snapshot manifest 校验;输出 digest 可验证的 receipt 与如实状态横幅(空范围 ≠ 审查通过;
9
- > partial / failed / cancelled 永远不会显示为绿色)。上一版 0.3.27 已发布 npm
10
- > (registry `latest`)、打 tag `v0.3.27`(`0be5b18`,#283)并发布 GitHub Release。
5
+ > v0.3.29 候选版本:进行中。Web 共享审查闭环:Host 复用 CLI 同一有界审查运行器发起
6
+ > 审查;审阅面板列出 digest 可验证的 receipt 与如实状态横幅(running / partial /
7
+ > failed / cancelled / 过期永远不会显示为绿色);finding 按 P0→P3 分组并可定位到既有
8
+ > diff 视图;每个 finding 可作为类型化草稿送回对话。上一版 0.3.28 已发布 npm
9
+ > (registry `latest`)、打 tag `v0.3.28`(`8a4722b`,#284)并发布 GitHub Release。
11
10
 
12
11
  [English](README.md) ·
13
12
  [v0.3.24 方案](docs/plan/v0.3.24-plan.md) ·
@@ -102,10 +101,10 @@ MCP 仍是用户可见的扩展边界。
102
101
  支持 Node.js 22.12+、24 和 26。生产环境建议使用 Node 24 LTS,当前开发环境也支持 Node 26
103
102
  Current。Node 20 已结束上游维护,不再属于 v0.3 Runtime 合同。
104
103
 
105
- v0.3.28 在 npm 可用后,可安装固定版本:
104
+ v0.3.29 在 npm 可用后,可安装固定版本:
106
105
 
107
106
  ```bash
108
- npm install -g @orion-agents/orion-code@0.3.28
107
+ npm install -g @orion-agents/orion-code@0.3.29
109
108
  orion --version
110
109
  orion doctor
111
110
  ```
@@ -12,5 +12,5 @@ declare function continueAsSlashChat(name: string, args: string): CommandResult;
12
12
  * Git snapshot, and the receipt is rendered by the shared formatter.
13
13
  */
14
14
  declare function handleReview(ctx: CommandContext, args: string): Promise<CommandResult>;
15
- export { handlePlan, handleDiff, handleCommitPlan, continueAsSlashChat, handleReview, };
15
+ export { handlePlan, handleDiff, handleCommitPlan, continueAsSlashChat, handleReview };
16
16
  //# sourceMappingURL=workflow-command-handlers.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_REVIEW_BUDGETS, type CodeReviewResultV1, type ReviewBudgetsV1, type ReviewSelectorV1 } from './contract';
2
- import { type ReviewGitRunner } from './snapshot';
2
+ import { type CodeReviewSnapshotV1, type ReviewGitRunner } from './snapshot';
3
3
  /** One bounded model completion. CLI wiring adapts `LLMService.chat`. */
4
4
  export interface ReviewModelAdapterV1 {
5
5
  complete(messages: readonly {
@@ -29,6 +29,11 @@ export interface ReviewRunOptionsV1 {
29
29
  readonly abortSignal?: AbortSignal;
30
30
  readonly runGit?: ReviewGitRunner;
31
31
  readonly now?: () => number;
32
+ /** v0.3.29 — additive observation hook fired inside the admission window
33
+ * right after the frozen snapshot is captured. Lets a host keep the
34
+ * fileKey -> path mapping for diff anchoring without changing the frozen
35
+ * receipt contract. */
36
+ readonly onSnapshotCaptured?: (snapshot: CodeReviewSnapshotV1) => void;
32
37
  }
33
38
  export type ReviewRunFailureV1 = {
34
39
  readonly kind: 'budget';
@@ -94,7 +94,9 @@ function buildReviewSystemPrompt(rulesDigest) {
94
94
  function buildSnapshotMaterial(snapshot, diffText, budgets) {
95
95
  const sections = [
96
96
  `Snapshot: ${snapshot.snapshotId} (kind=${snapshot.kind}`,
97
- snapshot.kind === 'base' ? `base=${snapshot.baseOid} head=${snapshot.headOid})` : 'worktree scope)',
97
+ snapshot.kind === 'base'
98
+ ? `base=${snapshot.baseOid} head=${snapshot.headOid})`
99
+ : 'worktree scope)',
98
100
  `Input snapshot digest: ${snapshot.inputSnapshotDigest}`,
99
101
  'Changed files (fileKey -> path):',
100
102
  ...snapshot.files.map(file => {
@@ -145,8 +147,12 @@ function snapshotAuthorizesFile(snapshot, finding) {
145
147
  return false;
146
148
  }
147
149
  const relevant = file.hunks.filter(hunk => finding.side === 'old'
148
- ? hunk.oldLines > 0 && finding.startLine <= hunk.oldStart + hunk.oldLines - 1 && finding.endLine >= hunk.oldStart
149
- : hunk.newLines > 0 && finding.startLine <= hunk.newStart + hunk.newLines - 1 && finding.endLine >= hunk.newStart);
150
+ ? hunk.oldLines > 0 &&
151
+ finding.startLine <= hunk.oldStart + hunk.oldLines - 1 &&
152
+ finding.endLine >= hunk.oldStart
153
+ : hunk.newLines > 0 &&
154
+ finding.startLine <= hunk.newStart + hunk.newLines - 1 &&
155
+ finding.endLine >= hunk.newStart);
150
156
  // A finding whose lines touch no diff hunk is not anchored in the frozen
151
157
  // material; the model must cite the changes under review.
152
158
  return relevant.length > 0;
@@ -175,6 +181,7 @@ async function runCodeReview(options) {
175
181
  runGit,
176
182
  readWorktreeFile: (0, snapshot_1.createContainedWorktreeReader)(options.cwd),
177
183
  });
184
+ options.onSnapshotCaptured?.(snapshot);
178
185
  if (aborted())
179
186
  return { ok: false, failure: { kind: 'cancelled' } };
180
187
  // The exact same frozen diff arguments the snapshot used, against the
@@ -189,7 +196,11 @@ async function runCodeReview(options) {
189
196
  const code = error instanceof snapshot_1.ReviewSnapshotError ? error.code : 'not_a_repository';
190
197
  return {
191
198
  ok: false,
192
- failure: { kind: 'snapshot', code, message: error instanceof Error ? error.message : String(error) },
199
+ failure: {
200
+ kind: 'snapshot',
201
+ code,
202
+ message: error instanceof Error ? error.message : String(error),
203
+ },
193
204
  };
194
205
  }
195
206
  const rulesDigest = await computeReviewRulesDigest(options.cwd);
@@ -233,7 +244,10 @@ async function runCodeReview(options) {
233
244
  startedAt,
234
245
  endedAt,
235
246
  };
236
- return { ok: true, result: Object.freeze({ ...base, receiptDigest: (0, contract_1.computeReceiptDigest)(base) }) };
247
+ return {
248
+ ok: true,
249
+ result: Object.freeze({ ...base, receiptDigest: (0, contract_1.computeReceiptDigest)(base) }),
250
+ };
237
251
  }
238
252
  const material = buildSnapshotMaterial(snapshot, diffText, budgets.budgets);
239
253
  if (material.truncated) {
@@ -28,7 +28,10 @@ function looksLikeRelativePath(value) {
28
28
  * path (back-compat path filter). Anything else is a usage error.
29
29
  */
30
30
  function parseReviewSelector(rawArgs) {
31
- const tokens = rawArgs.trim().split(/\s+/).filter(token => token.length > 0);
31
+ const tokens = rawArgs
32
+ .trim()
33
+ .split(/\s+/)
34
+ .filter(token => token.length > 0);
32
35
  let base = null;
33
36
  const paths = [];
34
37
  for (let index = 0; index < tokens.length; index += 1) {
@@ -0,0 +1,68 @@
1
+ import { type CodeReviewResultV1, type ReviewRunStateV1, type ReviewSelectorV1 } from '../services/code-review/contract';
2
+ import { runCodeReview, type ReviewModelAdapterV1 } from '../services/code-review/runner';
3
+ export interface WebCodeReviewRunRecordV1 {
4
+ readonly runId: string;
5
+ readonly selector: ReviewSelectorV1;
6
+ readonly status: ReviewRunStateV1 | 'running';
7
+ readonly startedAt: number;
8
+ readonly endedAt: number | null;
9
+ /** Repository revision captured at admission; compared against the current
10
+ * value on read to mark a stored receipt stale. */
11
+ readonly repositoryRevisionAtAdmission: string;
12
+ /** fileKey -> path mapping captured with the frozen snapshot, so findings
13
+ * can be anchored to a diff without changing the receipt contract. */
14
+ readonly files: ReadonlyArray<{
15
+ readonly fileKey: string;
16
+ readonly path: string;
17
+ }>;
18
+ /** Present only for finished runs whose receipt passed digest re-verification. */
19
+ readonly result: CodeReviewResultV1 | null;
20
+ readonly failure: {
21
+ readonly kind: string;
22
+ readonly message: string;
23
+ } | null;
24
+ /** Recomputed on read: true when the repository changed since admission. */
25
+ readonly stale: boolean;
26
+ }
27
+ export interface WebCodeReviewRunServiceOptionsV1 {
28
+ /** Returns the review model adapter, or null when the Host has no model. */
29
+ readonly adapterProvider: () => ReviewModelAdapterV1 | null;
30
+ /** Workspace root used for the review snapshot; read at start time. */
31
+ readonly cwdProvider: () => string;
32
+ /** Current repository revision, sampled on every read for staleness. */
33
+ readonly repositoryRevisionProvider: () => Promise<string>;
34
+ /** Injectable runner (tests use fakes); defaults to the shared runner. */
35
+ readonly runReview?: typeof runCodeReview;
36
+ readonly now?: () => number;
37
+ /** Bounded receipt history; oldest finished records are evicted first. */
38
+ readonly maxRuns?: number;
39
+ }
40
+ export declare class WebCodeReviewRunServiceV1 {
41
+ private readonly records;
42
+ private readonly inflight;
43
+ private readonly adapterProvider;
44
+ private readonly cwdProvider;
45
+ private readonly repositoryRevisionProvider;
46
+ private readonly runReview;
47
+ private readonly now;
48
+ private readonly maxRuns;
49
+ constructor(options: WebCodeReviewRunServiceOptionsV1);
50
+ /** Admission is synchronous and fast: validate, record, return. The model
51
+ * completion continues in the background. */
52
+ start(selector: ReviewSelectorV1): Promise<{
53
+ readonly runId: string;
54
+ readonly status: 'running';
55
+ }>;
56
+ /** Cancels a running review; finished or unknown runs are a no-op. */
57
+ cancel(runId: string): void;
58
+ list(): Promise<readonly WebCodeReviewRunRecordV1[]>;
59
+ get(runId: string): Promise<WebCodeReviewRunRecordV1>;
60
+ private execute;
61
+ private evictFinished;
62
+ private present;
63
+ }
64
+ /** v0.3.29 — validates the JSON selector accepted by `POST /review/run`.
65
+ * Mirrors the CLI selector contract: worktree scope, base ref scope, optional
66
+ * path filters. Unknown shapes are typed 400s, never guesses. */
67
+ export declare function parseWebReviewSelectorV1(value: unknown): ReviewSelectorV1;
68
+ //# sourceMappingURL=code-review-run-service.d.ts.map
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebCodeReviewRunServiceV1 = void 0;
4
+ exports.parseWebReviewSelectorV1 = parseWebReviewSelectorV1;
5
+ /**
6
+ * v0.3.29 — Host-side shared code-review run service
7
+ * (docs/plan/v0.3.29-plan.md S1). The Web Host starts the same bounded
8
+ * single-pass review runner the CLI uses (v0.3.28 `runCodeReview`) against the
9
+ * active Workspace, keeps a bounded in-memory receipt history, and re-verifies
10
+ * every receipt digest plus repository revision freshness when results are
11
+ * read. No second review engine: receipts, budgets, validation and honest
12
+ * failure states come from the frozen `src/services/code-review` contract.
13
+ *
14
+ * Delivery model: `start()` validates admission and returns immediately; the
15
+ * model completion runs in the background and clients poll `list()`/`get()`.
16
+ * At most one run is in flight per Host (a second start is a typed 409), and
17
+ * a run may be cancelled to release the slot.
18
+ */
19
+ const crypto_1 = require("crypto");
20
+ const contract_1 = require("../services/code-review/contract");
21
+ const runner_1 = require("../services/code-review/runner");
22
+ const errors_1 = require("./errors");
23
+ class WebCodeReviewRunServiceV1 {
24
+ constructor(options) {
25
+ this.records = new Map();
26
+ this.inflight = new Map();
27
+ this.adapterProvider = options.adapterProvider;
28
+ this.cwdProvider = options.cwdProvider;
29
+ this.repositoryRevisionProvider = options.repositoryRevisionProvider;
30
+ this.runReview = options.runReview ?? runner_1.runCodeReview;
31
+ this.now = options.now ?? Date.now;
32
+ this.maxRuns = Math.max(1, options.maxRuns ?? 20);
33
+ }
34
+ /** Admission is synchronous and fast: validate, record, return. The model
35
+ * completion continues in the background. */
36
+ async start(selector) {
37
+ if (this.inflight.size > 0) {
38
+ throw new errors_1.WebWorkbenchError(409, 'A code review is already running in this Host. Wait for it to finish or cancel it.', 'review_run_in_progress');
39
+ }
40
+ const adapter = this.adapterProvider();
41
+ if (!adapter) {
42
+ throw new errors_1.WebWorkbenchError(422, 'The Host has no configured model. Configure a model first, then retry the review.', 'review_no_model');
43
+ }
44
+ const runId = (0, crypto_1.randomUUID)();
45
+ const repositoryRevisionAtAdmission = await this.repositoryRevisionProvider();
46
+ const startedAt = this.now();
47
+ let capturedFiles = [];
48
+ this.records.set(runId, {
49
+ runId,
50
+ selector,
51
+ status: 'running',
52
+ startedAt,
53
+ endedAt: null,
54
+ repositoryRevisionAtAdmission,
55
+ files: capturedFiles,
56
+ result: null,
57
+ failure: null,
58
+ stale: false,
59
+ });
60
+ const abort = new AbortController();
61
+ this.inflight.set(runId, abort);
62
+ const cwd = this.cwdProvider();
63
+ void this.execute({
64
+ runId,
65
+ selector,
66
+ cwd,
67
+ adapter,
68
+ abort,
69
+ onSnapshot: snapshot => {
70
+ capturedFiles = Object.freeze(snapshot.files.map(file => ({ fileKey: file.fileKey, path: file.path })));
71
+ const record = this.records.get(runId);
72
+ if (record)
73
+ this.records.set(runId, { ...record, files: capturedFiles });
74
+ },
75
+ });
76
+ return Object.freeze({ runId, status: 'running' });
77
+ }
78
+ /** Cancels a running review; finished or unknown runs are a no-op. */
79
+ cancel(runId) {
80
+ this.inflight.get(runId)?.abort();
81
+ }
82
+ async list() {
83
+ const currentRevision = await this.repositoryRevisionProvider();
84
+ return [...this.records.values()]
85
+ .sort((left, right) => right.startedAt - left.startedAt)
86
+ .map(record => this.present(record, currentRevision));
87
+ }
88
+ async get(runId) {
89
+ const record = this.records.get(runId);
90
+ if (!record) {
91
+ throw new errors_1.WebWorkbenchError(404, 'Unknown review run.', 'review_run_not_found');
92
+ }
93
+ const currentRevision = await this.repositoryRevisionProvider();
94
+ return this.present(record, currentRevision);
95
+ }
96
+ async execute(input) {
97
+ const { runId, selector, cwd, adapter, abort, onSnapshot } = input;
98
+ const options = {
99
+ cwd,
100
+ selector,
101
+ adapter,
102
+ identity: {
103
+ workspaceIdentity: cwd,
104
+ // Web control-plane runs are their own session identity; they are not
105
+ // attributed to any chat Session actor.
106
+ sessionId: `web-host:${runId}`,
107
+ threadId: `web-host:${runId}`,
108
+ turnId: runId,
109
+ },
110
+ abortSignal: abort.signal,
111
+ ...(onSnapshot ? { onSnapshotCaptured: onSnapshot } : {}),
112
+ };
113
+ let failure = null;
114
+ let result = null;
115
+ try {
116
+ const outcome = await this.runReview(options);
117
+ if (outcome.ok) {
118
+ result = outcome.result;
119
+ }
120
+ else {
121
+ failure = {
122
+ kind: outcome.failure.kind,
123
+ message: failureMessage(outcome.failure),
124
+ };
125
+ }
126
+ }
127
+ catch (error) {
128
+ failure = {
129
+ kind: 'model_error',
130
+ message: error instanceof Error ? error.message : String(error),
131
+ };
132
+ }
133
+ this.inflight.delete(runId);
134
+ const existing = this.records.get(runId);
135
+ if (!existing)
136
+ return;
137
+ const finished = {
138
+ ...existing,
139
+ status: result ? result.state : failure?.kind === 'cancelled' ? 'cancelled' : 'failed',
140
+ endedAt: this.now(),
141
+ result,
142
+ failure,
143
+ };
144
+ this.records.set(runId, finished);
145
+ this.evictFinished();
146
+ }
147
+ evictFinished() {
148
+ const finished = [...this.records.values()]
149
+ .filter(record => record.status !== 'running')
150
+ .sort((left, right) => (left.endedAt ?? left.startedAt) - (right.endedAt ?? right.startedAt));
151
+ let excess = this.records.size - this.maxRuns;
152
+ for (const record of finished) {
153
+ if (excess <= 0)
154
+ break;
155
+ this.records.delete(record.runId);
156
+ excess -= 1;
157
+ }
158
+ }
159
+ present(record, currentRevision) {
160
+ const stale = record.result !== null && record.repositoryRevisionAtAdmission !== currentRevision;
161
+ if (record.result && !(0, contract_1.verifyReceiptDigest)(record.result)) {
162
+ // A receipt that no longer verifies must never be presented as findings;
163
+ // surface it as a failed run with an honest message instead.
164
+ return {
165
+ ...record,
166
+ status: 'failed',
167
+ stale,
168
+ result: null,
169
+ failure: {
170
+ kind: 'receipt_digest_mismatch',
171
+ message: 'The stored review receipt failed digest re-verification.',
172
+ },
173
+ };
174
+ }
175
+ return { ...record, stale };
176
+ }
177
+ }
178
+ exports.WebCodeReviewRunServiceV1 = WebCodeReviewRunServiceV1;
179
+ function failureMessage(failure) {
180
+ switch (failure.kind) {
181
+ case 'cancelled':
182
+ return 'Review cancelled.';
183
+ case 'budget':
184
+ return `Review budget rejected: ${failure.field ?? 'unknown'}`;
185
+ case 'snapshot':
186
+ return `Review scope error (${failure.code ?? 'unknown'}): ${failure.message ?? ''}`;
187
+ case 'model_error':
188
+ return `Review model call failed: ${failure.message ?? ''}`;
189
+ case 'unparsable_output':
190
+ return `Review output could not be parsed: ${failure.message ?? ''}`;
191
+ default:
192
+ return failure.message ?? 'Review failed.';
193
+ }
194
+ }
195
+ /** v0.3.29 — validates the JSON selector accepted by `POST /review/run`.
196
+ * Mirrors the CLI selector contract: worktree scope, base ref scope, optional
197
+ * path filters. Unknown shapes are typed 400s, never guesses. */
198
+ function parseWebReviewSelectorV1(value) {
199
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
200
+ throw new errors_1.WebWorkbenchError(400, 'Review selector must be an object.', 'review_selector_invalid');
201
+ }
202
+ const record = value;
203
+ const keys = Object.keys(record).sort();
204
+ const allowed = record.kind === 'base' ? ['kind', 'paths', 'ref'] : ['kind', 'paths'];
205
+ if (keys.some(key => !allowed.includes(key))) {
206
+ throw new errors_1.WebWorkbenchError(400, `Review selector accepts only ${allowed.join(', ')}.`, 'review_selector_invalid');
207
+ }
208
+ const paths = parseSelectorPaths(record.paths);
209
+ if (record.kind === 'base') {
210
+ if (typeof record.ref !== 'string' || record.ref.trim() === '') {
211
+ throw new errors_1.WebWorkbenchError(400, 'Review selector base ref must be a non-empty string.', 'review_selector_invalid');
212
+ }
213
+ return Object.freeze({ kind: 'base', ref: record.ref, ...(paths ? { paths } : {}) });
214
+ }
215
+ if (record.kind === 'worktree') {
216
+ return Object.freeze({ kind: 'worktree', ...(paths ? { paths } : {}) });
217
+ }
218
+ throw new errors_1.WebWorkbenchError(400, 'Review selector kind must be "worktree" or "base".', 'review_selector_invalid');
219
+ }
220
+ function parseSelectorPaths(value) {
221
+ if (value === undefined)
222
+ return undefined;
223
+ if (!Array.isArray(value) || value.some(path => typeof path !== 'string' || path.trim() === '')) {
224
+ throw new errors_1.WebWorkbenchError(400, 'Review selector paths must be an array of non-empty strings.', 'review_selector_invalid');
225
+ }
226
+ return Object.freeze([...value]);
227
+ }
228
+ //# sourceMappingURL=code-review-run-service.js.map
@@ -9,6 +9,7 @@ const version_1 = require("../product/version");
9
9
  const redaction_1 = require("../services/redaction");
10
10
  const protocol_1 = require("./protocol");
11
11
  const errors_1 = require("./errors");
12
+ const code_review_run_service_1 = require("./code-review-run-service");
12
13
  const cli_options_1 = require("./cli-options");
13
14
  const terminal_server_1 = require("./terminal-server");
14
15
  const workbench_controller_1 = require("./workbench-controller");
@@ -885,6 +886,42 @@ async function handleRequest(context) {
885
886
  sendJson(response, 200, await context.workbench.review(requireContextGuardQuery(url)));
886
887
  return;
887
888
  }
889
+ // v0.3.29 — shared code-review runs. Start is a fast mutation (admission
890
+ // only; the model completion runs in the background and clients poll the
891
+ // read-only list/get endpoints below).
892
+ if (method === 'POST' && path === '/review/run') {
893
+ assertMutation(request, context.nonce, context.origin);
894
+ const body = requireRecord(await readJson(request), 'Review run request');
895
+ assertOnlyKeys(body, ['requestId', 'selector', 'expectedContextRevision', 'workspaceId']);
896
+ const requestId = requireUuid(body.requestId, 'requestId');
897
+ const contextGuard = requireContextGuardRecord(body);
898
+ const selector = (0, code_review_run_service_1.parseWebReviewSelectorV1)(body.selector);
899
+ const result = await context.workbench.executeMutation(requestId, 'review.run', { selector, ...contextGuard }, () => context.workbench.reviewRunStart(selector, contextGuard));
900
+ sendJson(response, 200, result);
901
+ return;
902
+ }
903
+ if (method === 'GET' && path === '/review/runs') {
904
+ sendJson(response, 200, await context.workbench.reviewRunList(requireContextGuardQuery(url)));
905
+ return;
906
+ }
907
+ const reviewRunMatch = path.match(/^\/review\/runs\/([^/]+)$/);
908
+ if (method === 'GET' && reviewRunMatch) {
909
+ const runId = requireUuid(safeDecodePathSegment(reviewRunMatch[1]), 'runId');
910
+ sendJson(response, 200, await context.workbench.reviewRunGet(runId, requireContextGuardQuery(url)));
911
+ return;
912
+ }
913
+ const reviewRunCancelMatch = path.match(/^\/review\/runs\/([^/]+)\/cancel$/);
914
+ if (method === 'POST' && reviewRunCancelMatch) {
915
+ assertMutation(request, context.nonce, context.origin);
916
+ const body = requireRecord(await readJson(request), 'Review cancel request');
917
+ assertOnlyKeys(body, ['requestId', 'expectedContextRevision', 'workspaceId']);
918
+ const requestId = requireUuid(body.requestId, 'requestId');
919
+ const contextGuard = requireContextGuardRecord(body);
920
+ const runId = requireUuid(safeDecodePathSegment(reviewRunCancelMatch[1]), 'runId');
921
+ const result = await context.workbench.executeMutation(requestId, 'review.run.cancel', { runId, ...contextGuard }, () => context.workbench.reviewRunCancel(runId, contextGuard));
922
+ sendJson(response, 200, result);
923
+ return;
924
+ }
888
925
  if (method === 'GET' && path === '/terminals') {
889
926
  const contextGuard = requireContextGuardQuery(url);
890
927
  sendJson(response, 200, collectionPage(url, 'terminals', context.workbench.listTerminals(contextGuard), terminal => terminal.id));
@@ -10,6 +10,7 @@ import { type WebComposerActionResultV1, type WebComposerActionV1, type WebCompo
10
10
  import { type NativeDirectoryPickResult, type NativeDirectoryPicker } from './native-directory-picker';
11
11
  import { WorkspaceDiscoveryTelemetryV1 } from './workspace-discovery';
12
12
  import { WorkspaceRootsRegistryV1 } from '../services/workspace-roots-registry';
13
+ import type { ReviewSelectorV1 } from '../services/code-review/contract';
13
14
  import { type WebSessionRuntimeSummaryV1 } from './session-runtime-registry';
14
15
  import { TerminalManagerV1 } from './terminal-manager';
15
16
  export { WebWorkbenchError } from './errors';
@@ -70,6 +71,8 @@ export declare class WebWorkbenchController {
70
71
  private fileWriteService;
71
72
  private gitService;
72
73
  private reviewService;
74
+ /** v0.3.29 — shared code-review run history for the Review panel. */
75
+ private readonly codeReviewRuns;
73
76
  private contextRevisionValue;
74
77
  private composerRevisionValue;
75
78
  private composerAuthorityDigest;
@@ -281,6 +284,19 @@ export declare class WebWorkbenchController {
281
284
  gitLog(context: WebContextGuardV1, input: Parameters<GitReadModelServiceV1['log']>[0]): Promise<import("./git-read-model-service").WebGitLogPageV1>;
282
285
  gitDiff(context: WebContextGuardV1, input: Parameters<GitReadModelServiceV1['diff']>[0]): Promise<import("./git-read-model-service").WebGitDiffPageV1>;
283
286
  review(context: WebContextGuardV1): Promise<import("./review-service").WebReviewSnapshotV1>;
287
+ /** v0.3.29 — starts a shared code review run (fast admission, background run). */
288
+ reviewRunStart(selector: ReviewSelectorV1, context: WebContextGuardV1): Promise<{
289
+ readonly runId: string;
290
+ readonly status: 'running';
291
+ }>;
292
+ /** v0.3.29 — bounded list of review runs with freshness flags. */
293
+ reviewRunList(context: WebContextGuardV1): Promise<readonly import("./code-review-run-service").WebCodeReviewRunRecordV1[]>;
294
+ /** v0.3.29 — one review run record with digest re-verification. */
295
+ reviewRunGet(runId: string, context: WebContextGuardV1): Promise<import("./code-review-run-service").WebCodeReviewRunRecordV1>;
296
+ /** v0.3.29 — cancels a running review to release the single in-flight slot. */
297
+ reviewRunCancel(runId: string, context: WebContextGuardV1): {
298
+ readonly cancelled: boolean;
299
+ };
284
300
  listTerminals(context: WebContextGuardV1): readonly import("./terminal-manager").WebTerminalMetadataV1[];
285
301
  createSession(name?: string, context?: WebContextGuardV1): Promise<WebSessionSummaryV1>;
286
302
  activateSession(sessionId: string, context?: WebContextGuardV1): Promise<WebCommandResultV1>;