@orion-agents/orion-code 0.3.20 → 0.3.21

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,12 +22,30 @@ which is **not** a pass.
22
22
 
23
23
  ## [Unreleased]
24
24
 
25
- ## [0.3.20]
25
+ ## [0.3.21] — UNRELEASED
26
26
 
27
- > **Status: candidate.** Open. Repairs the Web E2E suite that has been red since
28
- > v0.3.11 (the dock's tabs became rail buttons) independently of the Git panel
29
- > work, and fixes the product defects the repair exposed. Not merged, tagged or
30
- > published.
27
+ > **Status: candidate.** Open. Adds user-authorized local project-root discovery to
28
+ > `LOCAL WORKSPACE` (bounded scan, explicit inspect → confirm → activate reuse, no
29
+ > Home/network-volume scanning, no silent registration) and corrects the v0.3.20
30
+ > release-facts documentation (`docs/plan/evidence/v0.3.20-e2e/release-receipt-v0.3.20.md`).
31
+ > Not merged, tagged or published.
32
+
33
+ ### Added (planned)
34
+
35
+ - Saved project roots registry (`WorkspaceRootsRegistryV1`) and a bounded, cancellable
36
+ discovery service with depth/candidate/time budgets and partial-result semantics.
37
+
38
+ ### Fixed (planned)
39
+
40
+ - README / README.zh-CN / CHANGELOG release-state drift for v0.3.20, plus a release-check
41
+ guard that fails when a README describes a tagged version as unpublished.
42
+
43
+ ## [0.3.20] — 2026-10-07
44
+
45
+ > **Status: published.** Released as `@orion-agents/orion-code@0.3.20` from
46
+ > `refs/tags/v0.3.20`, and merged to `main` as `e658932` (#261). Repairs the Web E2E
47
+ > suite that has been red since v0.3.11 (the dock's tabs became rail buttons)
48
+ > independently of the Git panel work, and fixes the product defects the repair exposed.
31
49
  >
32
50
  > ### Fixed (product)
33
51
  >
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  Local-first, goal-driven coding agent for the terminal and browser.
4
4
 
5
- > v0.3.20 candidate — open. The v0.3.19 round found that the Web E2E suite has been red
6
- > since v0.3.11 (the dock's tabs became rail buttons), independently of the Git panel work;
7
- > repairing those specs is this cycle's intended content.
8
- > 0.3.19 is published to npm and tagged `v0.3.19`; 0.3.20 is not.
5
+ > v0.3.21 candidate — open. Adds user-authorized local project-root discovery to
6
+ > `LOCAL WORKSPACE` (bounded scan, explicit inspect → confirm → activate reuse). The previous
7
+ > release 0.3.20 is on npm (registry `latest`) and tagged `v0.3.20` (`e658932`, #261) —
8
+ > release facts: `docs/plan/evidence/v0.3.20-e2e/release-receipt-v0.3.20.md`.
9
9
 
10
10
  [中文说明](README.zh-CN.md) ·
11
11
  [v0.3.7 plan](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
@@ -170,7 +170,7 @@ current development environments. Node 20 is upstream EOL and is no longer a v0.
170
170
  After the immutable `0.3.5` npm receipt exists:
171
171
 
172
172
  ```bash
173
- npm install -g @orion-agents/orion-code@0.3.20
173
+ npm install -g @orion-agents/orion-code@0.3.21
174
174
  orion --version
175
175
  orion doctor
176
176
  ```
package/README.zh-CN.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  面向终端与浏览器、本地优先的目标驱动 Coding Agent。
4
4
 
5
- > v0.3.20 候选版本:进行中。v0.3.19 那轮验证发现 Web E2E 套件自 v0.3.11(停靠栏 tab 改为
6
- > rail 按钮)起就与产品脱节,与 Git 工作栏的工作无关;修好这些 spec 是本周期的目标。
7
- > 0.3.19 已发布 npm 并打 tag `v0.3.19`,0.3.20 尚未发布。
5
+ > v0.3.21 候选版本:进行中。为 `LOCAL WORKSPACE` 增加用户显式授权的项目根目录发现(有界
6
+ > 扫描、复用 inspect → confirm → activate)。上一版 0.3.20 已发布 npm(registry `latest`)
7
+ > 并打 tag `v0.3.20`(`e658932`,#261)——发布事实:`docs/plan/evidence/v0.3.20-e2e/release-receipt-v0.3.20.md`。
8
8
 
9
9
  [English](README.md) ·
10
10
  [v0.3.7 方案](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
@@ -101,7 +101,7 @@ Current。Node 20 已结束上游维护,不再属于 v0.3 Runtime 合同。
101
101
  当 npm 已存在不可变的 `0.3.4` 发布凭据后:
102
102
 
103
103
  ```bash
104
- npm install -g @orion-agents/orion-code@0.3.20
104
+ npm install -g @orion-agents/orion-code@0.3.21
105
105
  orion --version
106
106
  orion doctor
107
107
  ```
@@ -29,7 +29,7 @@ export declare const WEB_E2E_WEB37_SCENARIOS_V1: readonly ["WEB37-P0-01", "WEB37
29
29
  * v0.3.17 — LOCAL WORKSPACE open path. The picker is driven by a script file
30
30
  * (`ORION_CODE_WEB_PICKER_FIXTURE`) so CI never opens a real Finder dialog.
31
31
  */
32
- export declare const WEB_E2E_WEB38_SCENARIOS_V1: readonly ["WEB38-P0-01", "WEB38-P0-02", "WEB38-P0-03", "WEB38-P0-04", "WEB38-P0-05"];
32
+ export declare const WEB_E2E_WEB38_SCENARIOS_V1: readonly ["WEB38-P0-01", "WEB38-P0-02", "WEB38-P0-03", "WEB38-P0-04", "WEB38-P0-05", "WEB38-P0-06"];
33
33
  /**
34
34
  * v0.3.17 — Git right-hand work panel (`docs/plan/v0.3.17-plan.md`).
35
35
  *
@@ -149,6 +149,7 @@ exports.WEB_E2E_WEB38_SCENARIOS_V1 = Object.freeze([
149
149
  'WEB38-P0-03',
150
150
  'WEB38-P0-04',
151
151
  'WEB38-P0-05',
152
+ 'WEB38-P0-06',
152
153
  ]);
153
154
  /**
154
155
  * v0.3.17 — Git right-hand work panel (`docs/plan/v0.3.17-plan.md`).
@@ -0,0 +1,46 @@
1
+ /** Saved roots are user-authorized scan boundaries, not a project catalog. */
2
+ export declare const MAX_WORKSPACE_ROOTS = 12;
3
+ export interface WorkspaceRootEntryV1 {
4
+ readonly id: string;
5
+ readonly canonicalPath: string;
6
+ readonly label: string;
7
+ readonly addedAt: string;
8
+ readonly lastScannedAt?: string;
9
+ }
10
+ export interface WorkspaceRootsRegistryOptions {
11
+ readonly storagePath?: string;
12
+ readonly now?: () => Date;
13
+ readonly createId?: () => string;
14
+ }
15
+ export declare class WorkspaceRootsRegistryError extends Error {
16
+ readonly code: 'workspace_roots_invalid' | 'workspace_roots_capacity' | 'workspace_root_unavailable';
17
+ constructor(message: string, code: 'workspace_roots_invalid' | 'workspace_roots_capacity' | 'workspace_root_unavailable');
18
+ }
19
+ /**
20
+ * v0.3.21 — durable registry of user-authorized project-root directories.
21
+ *
22
+ * A root is a scan boundary the user explicitly granted through the native
23
+ * picker (or an explicit migration path); it is a different entity from a
24
+ * workspace and lives in its own versioned file so discovery can never be
25
+ * confused with `WorkspaceRegistryV1` (opened workspaces). Adding a root never
26
+ * registers a workspace, activates a Context, or touches the session catalog.
27
+ */
28
+ export declare class WorkspaceRootsRegistryV1 {
29
+ private readonly storagePath;
30
+ private readonly now;
31
+ private readonly createId;
32
+ constructor(options?: WorkspaceRootsRegistryOptions);
33
+ list(): readonly WorkspaceRootEntryV1[];
34
+ find(id: string): WorkspaceRootEntryV1 | undefined;
35
+ /** Canonicalize, dedupe by path, cap the count, and persist atomically. */
36
+ add(path: string): WorkspaceRootEntryV1;
37
+ remove(id: string): boolean;
38
+ /** Record that a discovery scan completed for this root (success or partial). */
39
+ markScanned(id: string, scannedAt?: string): WorkspaceRootEntryV1 | undefined;
40
+ private readDocument;
41
+ private writeDocument;
42
+ private ensureStorageDirectory;
43
+ }
44
+ /** Re-exported for tests: a root must be an existing, readable directory. */
45
+ export declare function canonicalWorkspaceRoot(path: string): string;
46
+ //# sourceMappingURL=workspace-roots-registry.d.ts.map
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WorkspaceRootsRegistryV1 = exports.WorkspaceRootsRegistryError = exports.MAX_WORKSPACE_ROOTS = void 0;
4
+ exports.canonicalWorkspaceRoot = canonicalWorkspaceRoot;
5
+ const crypto_1 = require("crypto");
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const paths_1 = require("../product/paths");
9
+ const atomic_write_1 = require("./atomic-write");
10
+ const file_lock_1 = require("./file-lock");
11
+ const workspace_registry_1 = require("./workspace-registry");
12
+ /** Translate the shared directory error into this registry's own code. */
13
+ function canonicalRootPath(path) {
14
+ try {
15
+ return (0, workspace_registry_1.canonicalWorkspaceDirectory)(path);
16
+ }
17
+ catch (error) {
18
+ if (error instanceof workspace_registry_1.WorkspaceRegistryError) {
19
+ throw new WorkspaceRootsRegistryError('Workspace root directory is unavailable.', 'workspace_root_unavailable');
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ const WORKSPACE_ROOTS_SCHEMA_VERSION = 1;
25
+ const DEFAULT_ROOTS_FILENAME = 'workspace-roots.v1.json';
26
+ /** Saved roots are user-authorized scan boundaries, not a project catalog. */
27
+ exports.MAX_WORKSPACE_ROOTS = 12;
28
+ class WorkspaceRootsRegistryError extends Error {
29
+ constructor(message, code) {
30
+ super(message);
31
+ this.code = code;
32
+ this.name = 'WorkspaceRootsRegistryError';
33
+ }
34
+ }
35
+ exports.WorkspaceRootsRegistryError = WorkspaceRootsRegistryError;
36
+ /**
37
+ * v0.3.21 — durable registry of user-authorized project-root directories.
38
+ *
39
+ * A root is a scan boundary the user explicitly granted through the native
40
+ * picker (or an explicit migration path); it is a different entity from a
41
+ * workspace and lives in its own versioned file so discovery can never be
42
+ * confused with `WorkspaceRegistryV1` (opened workspaces). Adding a root never
43
+ * registers a workspace, activates a Context, or touches the session catalog.
44
+ */
45
+ class WorkspaceRootsRegistryV1 {
46
+ constructor(options = {}) {
47
+ this.storagePath = options.storagePath ?? (0, path_1.join)((0, paths_1.getConfigHome)(), DEFAULT_ROOTS_FILENAME);
48
+ this.now = options.now ?? (() => new Date());
49
+ this.createId = options.createId ?? crypto_1.randomUUID;
50
+ }
51
+ list() {
52
+ return freezeEntries(this.readDocument().entries);
53
+ }
54
+ find(id) {
55
+ return this.readDocument().entries.find(entry => entry.id === id);
56
+ }
57
+ /** Canonicalize, dedupe by path, cap the count, and persist atomically. */
58
+ add(path) {
59
+ const canonicalPath = canonicalRootPath(path);
60
+ this.ensureStorageDirectory();
61
+ return (0, file_lock_1.withFileLockSync)(this.storagePath, () => {
62
+ const document = this.readDocument();
63
+ const existing = document.entries.find(entry => entry.canonicalPath === canonicalPath);
64
+ if (existing)
65
+ return existing;
66
+ if (document.entries.length >= exports.MAX_WORKSPACE_ROOTS) {
67
+ throw new WorkspaceRootsRegistryError(`Workspace roots registry cannot contain more than ${exports.MAX_WORKSPACE_ROOTS} entries.`, 'workspace_roots_capacity');
68
+ }
69
+ const now = this.now().toISOString();
70
+ const entry = Object.freeze({
71
+ id: this.createId(),
72
+ canonicalPath,
73
+ label: workspaceRootLabel(canonicalPath),
74
+ addedAt: now,
75
+ });
76
+ this.writeDocument([...document.entries, entry]);
77
+ return entry;
78
+ });
79
+ }
80
+ remove(id) {
81
+ this.ensureStorageDirectory();
82
+ return (0, file_lock_1.withFileLockSync)(this.storagePath, () => {
83
+ const document = this.readDocument();
84
+ const entries = document.entries.filter(entry => entry.id !== id);
85
+ if (entries.length === document.entries.length)
86
+ return false;
87
+ this.writeDocument(entries);
88
+ return true;
89
+ });
90
+ }
91
+ /** Record that a discovery scan completed for this root (success or partial). */
92
+ markScanned(id, scannedAt) {
93
+ this.ensureStorageDirectory();
94
+ const at = (scannedAt ?? this.now().toISOString()).trim();
95
+ return (0, file_lock_1.withFileLockSync)(this.storagePath, () => {
96
+ const document = this.readDocument();
97
+ const target = document.entries.find(entry => entry.id === id);
98
+ if (!target)
99
+ return undefined;
100
+ const updated = Object.freeze({
101
+ ...target,
102
+ lastScannedAt: Number.isFinite(Date.parse(at)) ? new Date(at).toISOString() : at,
103
+ });
104
+ this.writeDocument(document.entries.map(entry => (entry.id === id ? updated : entry)));
105
+ return updated;
106
+ });
107
+ }
108
+ readDocument() {
109
+ if (!(0, fs_1.existsSync)(this.storagePath)) {
110
+ return Object.freeze({ schemaVersion: WORKSPACE_ROOTS_SCHEMA_VERSION, entries: [] });
111
+ }
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse((0, fs_1.readFileSync)(this.storagePath, 'utf8'));
115
+ }
116
+ catch {
117
+ throw new WorkspaceRootsRegistryError('Workspace roots registry is not valid JSON.', 'workspace_roots_invalid');
118
+ }
119
+ return parseDocument(parsed);
120
+ }
121
+ writeDocument(entries) {
122
+ this.ensureStorageDirectory();
123
+ const normalized = [...entries]
124
+ .sort((left, right) => Date.parse(right.addedAt) - Date.parse(left.addedAt) ||
125
+ left.label.localeCompare(right.label) ||
126
+ left.id.localeCompare(right.id))
127
+ .map(entry => ({ ...entry }));
128
+ (0, atomic_write_1.atomicWriteFileSync)(this.storagePath, `${JSON.stringify({ schemaVersion: WORKSPACE_ROOTS_SCHEMA_VERSION, entries: normalized }, null, 2)}\n`, { mode: 0o600, fsync: true });
129
+ }
130
+ ensureStorageDirectory() {
131
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(this.storagePath), { recursive: true, mode: 0o700 });
132
+ }
133
+ }
134
+ exports.WorkspaceRootsRegistryV1 = WorkspaceRootsRegistryV1;
135
+ function parseDocument(value) {
136
+ if (!value || typeof value !== 'object' || Array.isArray(value))
137
+ return invalidDocument();
138
+ const row = value;
139
+ if (row.schemaVersion !== WORKSPACE_ROOTS_SCHEMA_VERSION || !Array.isArray(row.entries)) {
140
+ return invalidDocument();
141
+ }
142
+ if (row.entries.length > exports.MAX_WORKSPACE_ROOTS)
143
+ return invalidDocument();
144
+ const entries = row.entries.map(parseEntry);
145
+ if (new Set(entries.map(entry => entry.id)).size !== entries.length ||
146
+ new Set(entries.map(entry => entry.canonicalPath)).size !== entries.length) {
147
+ return invalidDocument();
148
+ }
149
+ return Object.freeze({
150
+ schemaVersion: WORKSPACE_ROOTS_SCHEMA_VERSION,
151
+ entries: freezeEntries(entries),
152
+ });
153
+ }
154
+ function parseEntry(value) {
155
+ if (!value || typeof value !== 'object' || Array.isArray(value))
156
+ return invalidDocument();
157
+ const row = value;
158
+ if (typeof row.id !== 'string' ||
159
+ !row.id.trim() ||
160
+ row.id.length > 128 ||
161
+ typeof row.canonicalPath !== 'string' ||
162
+ !row.canonicalPath.trim() ||
163
+ typeof row.label !== 'string' ||
164
+ !row.label.trim() ||
165
+ typeof row.addedAt !== 'string' ||
166
+ !Number.isFinite(Date.parse(row.addedAt)) ||
167
+ (row.lastScannedAt !== undefined &&
168
+ (typeof row.lastScannedAt !== 'string' || !Number.isFinite(Date.parse(row.lastScannedAt))))) {
169
+ return invalidDocument();
170
+ }
171
+ return Object.freeze({
172
+ id: row.id,
173
+ canonicalPath: row.canonicalPath,
174
+ label: row.label,
175
+ addedAt: row.addedAt,
176
+ ...(row.lastScannedAt !== undefined ? { lastScannedAt: row.lastScannedAt } : {}),
177
+ });
178
+ }
179
+ function invalidDocument() {
180
+ throw new WorkspaceRootsRegistryError('Workspace roots registry document is invalid.', 'workspace_roots_invalid');
181
+ }
182
+ function freezeEntries(entries) {
183
+ return Object.freeze([...entries]
184
+ .sort((left, right) => Date.parse(right.addedAt) - Date.parse(left.addedAt) ||
185
+ left.label.localeCompare(right.label) ||
186
+ left.id.localeCompare(right.id))
187
+ .map(entry => Object.freeze({ ...entry })));
188
+ }
189
+ function workspaceRootLabel(path) {
190
+ const normalized = path.replace(/[\\/]+$/u, '');
191
+ return normalized.split(/[\\/]/u).filter(Boolean).at(-1) ?? normalized;
192
+ }
193
+ /** Re-exported for tests: a root must be an existing, readable directory. */
194
+ function canonicalWorkspaceRoot(path) {
195
+ const canonical = (0, workspace_registry_1.canonicalWorkspaceDirectory)(path);
196
+ (0, fs_1.statSync)(canonical);
197
+ return canonical;
198
+ }
199
+ //# sourceMappingURL=workspace-roots-registry.js.map
@@ -419,6 +419,42 @@ export interface WebWorkspaceInspectRequestV1 {
419
419
  readonly path: string;
420
420
  readonly source?: WebWorkspaceCandidateSourceV1;
421
421
  }
422
+ export type WebWorkspaceRootDiscoveryHintV1 = 'git' | 'manifest';
423
+ export interface WebWorkspaceRootEntryV1 {
424
+ readonly id: string;
425
+ readonly canonicalPath: string;
426
+ readonly label: string;
427
+ readonly addedAt: string;
428
+ readonly lastScannedAt?: string;
429
+ }
430
+ export interface WebWorkspaceRootDiscoveryStatusV1 {
431
+ readonly status: 'ready' | 'partial';
432
+ readonly candidateCount: number;
433
+ readonly durationMs: number;
434
+ readonly errorCode?: string;
435
+ readonly cached: boolean;
436
+ }
437
+ export interface WebWorkspaceRootViewV1 extends WebWorkspaceRootEntryV1 {
438
+ /** The cached outcome of the most recent scan, when one is still fresh. */
439
+ readonly discovery: WebWorkspaceRootDiscoveryStatusV1 | null;
440
+ }
441
+ export interface WebWorkspaceRootCandidateV1 {
442
+ readonly rootId: string;
443
+ readonly relativePath: string;
444
+ readonly label: string;
445
+ readonly hint: WebWorkspaceRootDiscoveryHintV1;
446
+ readonly status: 'ready' | 'partial';
447
+ }
448
+ export interface WebWorkspaceRootDiscoverOutcomeV1 {
449
+ readonly requestId?: string;
450
+ readonly rootId: string;
451
+ readonly status: 'ready' | 'partial';
452
+ readonly candidates: readonly WebWorkspaceRootCandidateV1[];
453
+ readonly errorCode?: string;
454
+ readonly durationMs: number;
455
+ readonly scannedEntries: number;
456
+ readonly lastScannedAt?: string;
457
+ }
422
458
  export type WebWorkspaceInvalidationReasonV1 = 'context-change' | 'filesystem-change' | 'terminal-command' | 'tool-finished';
423
459
  export type WebToolDetailSummaryV1 = ToolDetailSummary;
424
460
  export type WebToolDetailPageV1 = ToolDetailPage;
@@ -300,6 +300,51 @@ async function handleRequest(context) {
300
300
  sendJson(response, 200, { requestId, candidate });
301
301
  return;
302
302
  }
303
+ // v0.3.21 — saved project roots: read-only list. Roots are scan boundaries,
304
+ // not workspaces; listing them changes nothing.
305
+ if (method === 'GET' && path === '/workspaces/roots') {
306
+ sendJson(response, 200, { roots: context.workbench.listWorkspaceRoots() });
307
+ return;
308
+ }
309
+ // v0.3.21 — add a root through the native picker's confirmed path. A local
310
+ // configuration mutation: transport-guarded, naturally idempotent (roots
311
+ // dedupe by canonical path), and never a Context mutation.
312
+ if (method === 'POST' && path === '/workspaces/roots/add') {
313
+ assertMutation(request, context.nonce, context.origin);
314
+ const body = requireRecord(await readJson(request), 'Workspace root request');
315
+ assertOnlyKeys(body, ['requestId', 'path']);
316
+ const requestId = requireUuid(body.requestId, 'requestId');
317
+ const rootPath = requireText(body.path, 'path', 4096);
318
+ const root = await context.workbench.addWorkspaceRoot(rootPath, requestId);
319
+ sendJson(response, 200, { requestId, root });
320
+ return;
321
+ }
322
+ // v0.3.21 — remove a root. Cancels any in-flight scan for it first; the
323
+ // workspaces registry is untouched.
324
+ if (method === 'POST' && path === '/workspaces/roots/remove') {
325
+ assertMutation(request, context.nonce, context.origin);
326
+ const body = requireRecord(await readJson(request), 'Workspace root request');
327
+ assertOnlyKeys(body, ['requestId', 'rootId']);
328
+ const requestId = requireUuid(body.requestId, 'requestId');
329
+ const rootId = requireText(body.rootId, 'rootId', 128);
330
+ const removed = context.workbench.removeWorkspaceRoot(rootId);
331
+ sendJson(response, 200, { requestId, removed });
332
+ return;
333
+ }
334
+ // v0.3.21 — bounded discovery inside one saved root. Read-only, abortable,
335
+ // host-wide single-flight; the newest request aborts the previous scan. A
336
+ // candidate is a hint: the client still runs inspect → confirm → activate.
337
+ if (method === 'POST' && path === '/workspaces/roots/discover') {
338
+ assertMutation(request, context.nonce, context.origin);
339
+ const body = requireRecord(await readJson(request), 'Workspace discovery request');
340
+ assertOnlyKeys(body, ['requestId', 'rootId', 'refresh']);
341
+ const requestId = requireUuid(body.requestId, 'requestId');
342
+ const rootId = requireText(body.rootId, 'rootId', 128);
343
+ const refresh = body.refresh === undefined ? true : body.refresh === true;
344
+ const outcome = await context.workbench.discoverWorkspaceRoot(rootId, requestId, { refresh });
345
+ sendJson(response, 200, outcome);
346
+ return;
347
+ }
303
348
  if (method === 'GET' && path === '/sessions') {
304
349
  const contextGuard = requireContextGuardQuery(url);
305
350
  sendJson(response, 200, collectionPage(url, 'sessions', context.workbench.listSessions(contextGuard), session => session.id));
@@ -6,8 +6,10 @@ import { WorkspaceRegistryV1 } from '../services/workspace-registry';
6
6
  import { WebEventHub } from './event-hub';
7
7
  import { FileReadServiceV1 } from './file-read-service';
8
8
  import { GitReadModelServiceV1 } from './git-read-model-service';
9
- import { type WebComposerActionResultV1, type WebComposerActionV1, type WebComposerControlStateV1, type WebBootstrapV1, type WebCommandResultV1, type WebContextActivateRequestV1, type WebContextGuardV1, type WebMcpServerSummaryV1, type WebPageV1, type WebSessionSnapshotV1, type WebSessionSummaryV1, type WebSettingsMutationResultV1, type WebSettingsSnapshotV1, type WebSettingsUpdateRequestV1, type WebSkillSummaryV1, type WebToolDetailPageV1, type WebToolDetailSummaryV1, type WebWorkspaceSummaryV1, type WebWorkspaceProjectSummaryV1, type WebWorkspaceCandidateSourceV1, type WebWorkspaceCandidateV1 } from './protocol';
9
+ import { type WebComposerActionResultV1, type WebComposerActionV1, type WebComposerControlStateV1, type WebBootstrapV1, type WebCommandResultV1, type WebContextActivateRequestV1, type WebContextGuardV1, type WebMcpServerSummaryV1, type WebPageV1, type WebSessionSnapshotV1, type WebSessionSummaryV1, type WebSettingsMutationResultV1, type WebSettingsSnapshotV1, type WebSettingsUpdateRequestV1, type WebSkillSummaryV1, type WebToolDetailPageV1, type WebToolDetailSummaryV1, type WebWorkspaceSummaryV1, type WebWorkspaceProjectSummaryV1, type WebWorkspaceCandidateSourceV1, type WebWorkspaceCandidateV1, type WebWorkspaceRootViewV1, type WebWorkspaceRootDiscoverOutcomeV1 } from './protocol';
10
10
  import { type NativeDirectoryPickResult, type NativeDirectoryPicker } from './native-directory-picker';
11
+ import { WorkspaceDiscoveryTelemetryV1 } from './workspace-discovery';
12
+ import { WorkspaceRootsRegistryV1 } from '../services/workspace-roots-registry';
11
13
  import { type WebSessionRuntimeSummaryV1 } from './session-runtime-registry';
12
14
  import { TerminalManagerV1 } from './terminal-manager';
13
15
  export { WebWorkbenchError } from './errors';
@@ -17,6 +19,10 @@ export interface WebWorkbenchControllerOptions {
17
19
  readonly createRuntime?: (cwd: string) => Promise<OrionCodeUiRuntime>;
18
20
  readonly createSessionRuntime?: (cwd: string) => Promise<OrionCodeUiRuntime>;
19
21
  readonly workspaceRegistry?: WorkspaceRegistryV1;
22
+ /** v0.3.21 — user-authorized scan boundaries; injectable for tests. */
23
+ readonly workspaceRootsRegistry?: WorkspaceRootsRegistryV1;
24
+ /** v0.3.21 — injectable discovery telemetry for tests. */
25
+ readonly discoveryTelemetry?: WorkspaceDiscoveryTelemetryV1;
20
26
  /** v0.3.16 — Host native directory picker; injectable for tests. */
21
27
  readonly nativeDirectoryPicker?: NativeDirectoryPicker;
22
28
  }
@@ -44,6 +50,14 @@ export declare class WebWorkbenchController {
44
50
  private pickerInFlight;
45
51
  /** v0.3.17 — bounded, path-free open-path traces exposed through diagnostics(). */
46
52
  private readonly openTelemetry;
53
+ private readonly workspaceRootsRegistry;
54
+ private readonly discoveryTelemetry;
55
+ private readonly discoveryService;
56
+ /**
57
+ * v0.3.21 — global discovery single-flight: at most one scan runs per Host;
58
+ * a new discover request aborts the previous one (scan_cancelled) and wins.
59
+ */
60
+ private readonly discoveryInflight;
47
61
  readonly terminalManager: TerminalManagerV1;
48
62
  private readonly mutationResults;
49
63
  private readonly sessionViews;
@@ -111,6 +125,13 @@ export declare class WebWorkbenchController {
111
125
  * file contents, or touches the session catalog beyond a warm-cache peek.
112
126
  */
113
127
  inspectWorkspaceFast(path: string, source?: WebWorkspaceCandidateSourceV1, requestId?: string): Promise<WebWorkspaceCandidateV1>;
128
+ listWorkspaceRoots(): readonly WebWorkspaceRootViewV1[];
129
+ addWorkspaceRoot(path: string, requestId?: string): Promise<WebWorkspaceRootViewV1>;
130
+ removeWorkspaceRoot(rootId: string): boolean;
131
+ discoverWorkspaceRoot(rootId: string, requestId?: string, options?: {
132
+ readonly refresh?: boolean;
133
+ }): Promise<WebWorkspaceRootDiscoverOutcomeV1>;
134
+ private toDiscoverOutcome;
114
135
  listWorkspaceSessions(workspaceId: string, context?: WebContextGuardV1): readonly WebSessionSummaryV1[];
115
136
  /** Archived sessions of a Workspace (v0.3.7), newest archive first. */
116
137
  listArchivedWorkspaceSessions(workspaceId: string, context?: WebContextGuardV1): readonly WebSessionSummaryV1[];
@@ -34,6 +34,8 @@ const workspace_mutation_arbiter_1 = require("./workspace-mutation-arbiter");
34
34
  const protocol_1 = require("./protocol");
35
35
  const native_directory_picker_1 = require("./native-directory-picker");
36
36
  const workspace_open_telemetry_1 = require("./workspace-open-telemetry");
37
+ const workspace_discovery_1 = require("./workspace-discovery");
38
+ const workspace_roots_registry_1 = require("../services/workspace-roots-registry");
37
39
  const review_service_1 = require("./review-service");
38
40
  const session_runtime_registry_1 = require("./session-runtime-registry");
39
41
  const terminal_manager_1 = require("./terminal-manager");
@@ -58,6 +60,11 @@ class WebWorkbenchController {
58
60
  this.pickerInFlight = false;
59
61
  /** v0.3.17 — bounded, path-free open-path traces exposed through diagnostics(). */
60
62
  this.openTelemetry = new workspace_open_telemetry_1.WorkspaceOpenTelemetryV1();
63
+ /**
64
+ * v0.3.21 — global discovery single-flight: at most one scan runs per Host;
65
+ * a new discover request aborts the previous one (scan_cancelled) and wins.
66
+ */
67
+ this.discoveryInflight = new Map();
61
68
  this.mutationResults = new Map();
62
69
  this.sessionViews = new Map();
63
70
  this.sessionViewBytes = 0;
@@ -93,6 +100,11 @@ class WebWorkbenchController {
93
100
  onStateChanged: state => this.emitWorkspaceMutationState(state),
94
101
  });
95
102
  this.workspaceRegistry = options.workspaceRegistry ?? new workspace_registry_1.WorkspaceRegistryV1();
103
+ this.workspaceRootsRegistry = options.workspaceRootsRegistry ?? new workspace_roots_registry_1.WorkspaceRootsRegistryV1();
104
+ this.discoveryTelemetry = options.discoveryTelemetry ?? new workspace_discovery_1.WorkspaceDiscoveryTelemetryV1();
105
+ this.discoveryService = new workspace_discovery_1.WorkspaceDiscoveryServiceV1({
106
+ telemetry: this.discoveryTelemetry,
107
+ });
96
108
  this.directoryPicker = options.nativeDirectoryPicker ?? (0, native_directory_picker_1.createNativeDirectoryPicker)();
97
109
  this.terminalManager = new terminal_manager_1.TerminalManagerV1({
98
110
  resolveWorkspace: workspaceId => this.workspaceRegistry.find(workspaceId)?.canonicalPath,
@@ -317,6 +329,98 @@ class WebWorkbenchController {
317
329
  }
318
330
  return candidate;
319
331
  }
332
+ // ---------------------------------------------------------------------
333
+ // v0.3.21 — user-authorized project roots and bounded discovery.
334
+ //
335
+ // Roots are scan boundaries, not workspaces: nothing here registers a
336
+ // workspace, activates a Context, installs a Runtime, or reads file
337
+ // contents. A discovered candidate is a hint; the client must still run the
338
+ // existing POST /workspaces/inspect → confirm → activate flow.
339
+ // ---------------------------------------------------------------------
340
+ listWorkspaceRoots() {
341
+ return this.workspaceRootsRegistry.list().map(entry => {
342
+ const cached = this.discoveryService.cached(entry.id);
343
+ return Object.freeze({
344
+ ...entry,
345
+ discovery: cached
346
+ ? Object.freeze({
347
+ status: cached.status,
348
+ candidateCount: cached.candidates.length,
349
+ durationMs: cached.durationMs,
350
+ ...(cached.errorCode ? { errorCode: cached.errorCode } : {}),
351
+ cached: true,
352
+ })
353
+ : null,
354
+ });
355
+ });
356
+ }
357
+ async addWorkspaceRoot(path, requestId) {
358
+ if (this.closed) {
359
+ throw new errors_1.WebWorkbenchError(503, 'The local Web Host is shutting down.', 'host_closed');
360
+ }
361
+ const entry = this.workspaceRootsRegistry.add(path);
362
+ void requestId;
363
+ const view = this.listWorkspaceRoots().find(root => root.id === entry.id);
364
+ if (!view)
365
+ throw new errors_1.WebWorkbenchError(500, 'Workspace root vanished after add.', 'scan_failed');
366
+ return view;
367
+ }
368
+ removeWorkspaceRoot(rootId) {
369
+ // Cancelling first: a removed root must never receive a late scan result.
370
+ const inflight = this.discoveryInflight.get(rootId);
371
+ if (inflight) {
372
+ inflight.controller.abort();
373
+ this.discoveryInflight.delete(rootId);
374
+ }
375
+ this.discoveryService.invalidate(rootId);
376
+ return this.workspaceRootsRegistry.remove(rootId);
377
+ }
378
+ async discoverWorkspaceRoot(rootId, requestId, options = {}) {
379
+ if (this.closed) {
380
+ throw new errors_1.WebWorkbenchError(503, 'The local Web Host is shutting down.', 'host_closed');
381
+ }
382
+ const entry = this.workspaceRootsRegistry.find(rootId);
383
+ if (!entry) {
384
+ throw new errors_1.WebWorkbenchError(404, 'Workspace root is not registered.', 'root_missing');
385
+ }
386
+ // Global single-flight: the newest discover request wins; the previous
387
+ // scan is aborted and reports scan_cancelled.
388
+ for (const [id, inflight] of this.discoveryInflight) {
389
+ inflight.controller.abort();
390
+ this.discoveryInflight.delete(id);
391
+ }
392
+ if (!options.refresh) {
393
+ const cached = this.discoveryService.cached(rootId);
394
+ if (cached)
395
+ return this.toDiscoverOutcome(cached, entry.lastScannedAt, requestId);
396
+ }
397
+ const controller = new AbortController();
398
+ const promise = this.discoveryService.discover({ root: entry, signal: controller.signal });
399
+ this.discoveryInflight.set(rootId, { controller, promise });
400
+ let outcome;
401
+ try {
402
+ outcome = await promise;
403
+ }
404
+ finally {
405
+ const current = this.discoveryInflight.get(rootId);
406
+ if (current && current.promise === promise)
407
+ this.discoveryInflight.delete(rootId);
408
+ }
409
+ const scanned = this.workspaceRootsRegistry.markScanned(rootId);
410
+ return this.toDiscoverOutcome(outcome, scanned?.lastScannedAt, requestId);
411
+ }
412
+ toDiscoverOutcome(outcome, lastScannedAt, requestId) {
413
+ return Object.freeze({
414
+ ...(requestId ? { requestId } : {}),
415
+ rootId: outcome.rootId,
416
+ status: outcome.status,
417
+ candidates: outcome.candidates,
418
+ ...(outcome.errorCode ? { errorCode: outcome.errorCode } : {}),
419
+ durationMs: outcome.durationMs,
420
+ scannedEntries: outcome.scannedEntries,
421
+ ...(lastScannedAt ? { lastScannedAt } : {}),
422
+ });
423
+ }
320
424
  listWorkspaceSessions(workspaceId, context) {
321
425
  if (context)
322
426
  this.assertContextGuard(context);
@@ -1766,6 +1870,9 @@ class WebWorkbenchController {
1766
1870
  workspaceKernel: this.runtimeValue.workspaceRuntimeKernel?.diagnostics() ?? null,
1767
1871
  // v0.3.17 — bounded, path-free open-path traces (picker / inspect / activate).
1768
1872
  workspaceOpen: this.openTelemetry.snapshot(),
1873
+ // v0.3.21 — bounded, path-free discovery traces (scan duration, counts,
1874
+ // error codes). Never a root path, a label, a marker name, or content.
1875
+ workspaceDiscovery: this.discoveryTelemetry.snapshot(),
1769
1876
  session: {
1770
1877
  ...this.sessionActivity,
1771
1878
  actors: this.sessionRegistry.stats(),