@orion-agents/orion-code 0.3.11 → 0.3.14
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 +94 -2
- package/README.md +8 -5
- package/README.zh-CN.md +5 -4
- package/dist/cli.js +14 -1
- package/dist/web/context-text.d.ts +17 -0
- package/dist/web/context-text.js +40 -0
- package/dist/web/file-read-service.d.ts +35 -0
- package/dist/web/file-read-service.js +158 -1
- package/dist/web/file-write-service.d.ts +16 -0
- package/dist/web/file-write-service.js +103 -0
- package/dist/web/git-read-model-service.d.ts +14 -0
- package/dist/web/git-read-model-service.js +64 -0
- package/dist/web/host-daemon.d.ts +9 -0
- package/dist/web/host-daemon.js +45 -1
- package/dist/web/protocol.d.ts +9 -2
- package/dist/web/protocol.js +26 -0
- package/dist/web/review-service.d.ts +2 -19
- package/dist/web/review-service.js +4 -39
- package/dist/web/server.js +111 -0
- package/dist/web/workbench-controller.d.ts +47 -0
- package/dist/web/workbench-controller.js +93 -13
- package/dist/web-client/assets/{DiffViewer-BrE5tfZc.js → DiffViewer-BrHqudpK.js} +1 -1
- package/dist/web-client/assets/FilesPanel-BcFY6L1a.js +1 -0
- package/dist/web-client/assets/GitPanel-B6E5IEXm.js +1 -0
- package/dist/web-client/assets/ResourceSplitLayout-fPYE9JKJ.js +1 -0
- package/dist/web-client/assets/ReviewPanel-5ecTVmhT.js +1 -0
- package/dist/web-client/assets/{TerminalPanel-CMLU7j08.js → TerminalPanel-IkCttxgw.js} +1 -1
- package/dist/web-client/assets/index-DgVNIqmE.js +20 -0
- package/dist/web-client/assets/index-R6E589Fc.css +1 -0
- package/dist/web-client/index.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/web-client/assets/FilesPanel-ZwlVgxJi.js +0 -2
- package/dist/web-client/assets/GitPanel-Cnz52VRO.js +0 -1
- package/dist/web-client/assets/ReviewPanel-D_xxkVG_.js +0 -1
- package/dist/web-client/assets/index-BlFUf8dG.js +0 -17
- package/dist/web-client/assets/index-CUG6w669.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -22,9 +22,101 @@ which is **not** a pass.
|
|
|
22
22
|
|
|
23
23
|
## [Unreleased]
|
|
24
24
|
|
|
25
|
-
## [0.3.
|
|
25
|
+
## [0.3.14] — CANDIDATE
|
|
26
26
|
|
|
27
|
-
> **Status: candidate.**
|
|
27
|
+
> **Status: candidate.** Editable Files panel (guarded `POST /files/write` with
|
|
28
|
+
> revision CAS) and removal of the legacy view-only controls. Not merged,
|
|
29
|
+
> tagged or published.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- File editing in the Files panel: content view gains a view/edit toggle; the
|
|
34
|
+
edit mode is a controlled monospace textarea with dirty tracking, explicit
|
|
35
|
+
save (guarded `POST /files/write`), cancel/Esc with an unsaved-changes
|
|
36
|
+
confirmation, and a 409 conflict path that offers an explicit reload.
|
|
37
|
+
- `POST /files/write`: workspace-fenced (registry + symlink escape checks),
|
|
38
|
+
sensitive-path block, 512 KiB text-only payload limit, on-disk stat
|
|
39
|
+
fingerprint CAS (`file_revision_conflict`), user-gesture header
|
|
40
|
+
(`file-mutation-v1`), idempotent `file.write` mutation ledger entry, and
|
|
41
|
+
one-shot snapshot invalidation so Git/Review decorations refresh after a
|
|
42
|
+
save.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- Resource refreshes keep the inspected file: `ReviewPanel` no longer drops the
|
|
47
|
+
open diff on a manual refresh, a `refreshEpoch` invalidation or a Git revision
|
|
48
|
+
conflict recovery; `FilesPanel` keeps the open file on refresh (still reset
|
|
49
|
+
when the workspace itself changes), so the editor no longer closes mid-edit.
|
|
50
|
+
- The review summary counters are localised (已暂存 / 未暂存 / 未跟踪 / 冲突)
|
|
51
|
+
instead of raw English labels.
|
|
52
|
+
- Binary detection tolerates an 8 KiB sample that ends inside one UTF-8 code
|
|
53
|
+
point, so CJK files are no longer rejected as binary on read or save.
|
|
54
|
+
|
|
55
|
+
### Removed
|
|
56
|
+
|
|
57
|
+
- The read-only file toolbar: 「跳转」(line jump), 「复制」 and 「自动换行」
|
|
58
|
+
controls, the line-number gutter spans and the wrap-only styles.
|
|
59
|
+
- The Review panel's 「验证证据」 module: the evidence list,
|
|
60
|
+
`WebReviewVerificationV1`, `ReviewServiceV1.verificationPage`, the
|
|
61
|
+
`GET /review/verification` route and the snapshot's `verification` /
|
|
62
|
+
`verificationTruncated` fields. The review overview is now composed only from
|
|
63
|
+
Git facts; durable tool receipts remain available to the runtime.
|
|
64
|
+
|
|
65
|
+
## [0.3.13] — CANDIDATE
|
|
66
|
+
|
|
67
|
+
> **Status: candidate.** Development baseline after v0.3.12 (version bump only;
|
|
68
|
+
> no functional changes yet). Not merged, tagged or published.
|
|
69
|
+
|
|
70
|
+
## [0.3.12] — NPM-PUBLISHED
|
|
71
|
+
|
|
72
|
+
> **Status: npm-published.** `@orion-agents/orion-code@0.3.12` is available
|
|
73
|
+
> through the npm registry (latest tag). Published 2026-09-07 via PR #252;
|
|
74
|
+
> git tag/release may lag. Wide Right Workspace + repository-truth + guarded
|
|
75
|
+
> Git mutations (v0.3.12-plan) plus resource split layout, conversation
|
|
76
|
+
> history navigator and the unified brand mark (v0.3.13-plan v3/v4/v5).
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- Wide Right Workspace logic layer: five-panel registry, per-workspace layout
|
|
81
|
+
preferences (schema v4 with per-panel navigator widths), pure dock/rail/drawer
|
|
82
|
+
geometry with 320px conversation minimum and 360/560/960/max snap points.
|
|
83
|
+
- Repository snapshot store shared by Git/Review/Files decorations
|
|
84
|
+
(coalescing, one-shot invalidation, revision-stale detection).
|
|
85
|
+
- Progressive Review verification paging (GET /review/verification).
|
|
86
|
+
- Guarded Git stage/unstage/commit (POST /git/\*) with host-resolved file ids,
|
|
87
|
+
repository-revision CAS and an explicit user-gesture header.
|
|
88
|
+
- Bounded workspace search (GET /files/search; name/content scopes).
|
|
89
|
+
- Context V2 file_range references (revision-bound, line-extracted, budgeted).
|
|
90
|
+
- **Resource split layout (v0.3.13-plan-v4):** Files/Git/Review share one
|
|
91
|
+
content | splitter | navigator shell (`ResourceSplitLayout`) with explicit
|
|
92
|
+
named grid areas (no more sparse grid auto-placement gaps). Git now shows its
|
|
93
|
+
diff on the left, matching Files/Review. Each panel has an inner draggable
|
|
94
|
+
separator measured against the split root — never the workbench shell — so
|
|
95
|
+
dragging never moves the outer Right Workspace; per-workspace + per-panel
|
|
96
|
+
navigator widths are persisted (300px default, 160–420px drag range,
|
|
97
|
+
double-click/keyboard reset), and <=620px containers stack navigator-over-
|
|
98
|
+
content and hide the handle.
|
|
99
|
+
- **Conversation history navigator (v0.3.13-plan-v5):** a left-inside rail on
|
|
100
|
+
the loaded transcript derived from the unified order timeline. A pure bucket
|
|
101
|
+
model (<=48–64 stable buckets, user/assistant/errors always visible) feeds
|
|
102
|
+
decorative ticks plus one focusable `role="slider"` (Arrow/Page/Home/End,
|
|
103
|
+
reduced-motion aware). An IntersectionObserver + rAF-throttled viewport
|
|
104
|
+
marker tracks the reading position; pointer scrub uses capture and cancels
|
|
105
|
+
pinned auto-follow; the top entry drives the existing paginated
|
|
106
|
+
load-earlier. Container-query degradation: full rail >760px, compact
|
|
107
|
+
641–760px, hidden <=640px with no horizontal overflow.
|
|
108
|
+
- **Unified Orion brand mark (v0.3.13-plan-v5 §9):** one decorative
|
|
109
|
+
`OrionBrandMark` inline SVG (20×20 artboard, three bars, theme-token
|
|
110
|
+
gradients with forced-colors fallback) replaces the duplicated
|
|
111
|
+
`.brand-mark`/`.boot-mark` span CSS in the project navigator and boot
|
|
112
|
+
screen; the mark stays aria-hidden and non-interactive.
|
|
113
|
+
- E2E: rail-panel scenario ids on the allowlist; real-conversation smoke spec;
|
|
114
|
+
Files/Review/Git split-layout, history-rail and brand visual specs.
|
|
115
|
+
|
|
116
|
+
## [0.3.11] — NPM-PUBLISHED
|
|
117
|
+
|
|
118
|
+
> **Status: npm-published.** `@orion-agents/orion-code@0.3.11` is available through the npm
|
|
119
|
+
> registry (latest tag). Published 2026-09-05; git tag/release may lag.
|
|
28
120
|
|
|
29
121
|
### Added
|
|
30
122
|
|
package/README.md
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Local-first, goal-driven coding agent for the terminal and browser.
|
|
4
4
|
|
|
5
|
-
> v0.3.
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
5
|
+
> v0.3.12 candidate — a Wide Right Workspace on top of the vertical rail: pure
|
|
6
|
+
> dock/rail/drawer geometry (detail dockable to 80vw while the conversation
|
|
7
|
+
> keeps a 320px minimum), per-workspace layout preferences, a shared repository
|
|
8
|
+
> snapshot store, progressive Review verification, guarded Git stage/unstage/
|
|
9
|
+
> commit, bounded workspace search and revision-bound file_range Context
|
|
10
|
+
> references. Candidate source is not an npm publication, Git tag, or
|
|
11
|
+
> merge-ready release.
|
|
9
12
|
|
|
10
13
|
[中文说明](README.zh-CN.md) ·
|
|
11
14
|
[v0.3.7 plan](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
|
|
@@ -170,7 +173,7 @@ current development environments. Node 20 is upstream EOL and is no longer a v0.
|
|
|
170
173
|
After the immutable `0.3.5` npm receipt exists:
|
|
171
174
|
|
|
172
175
|
```bash
|
|
173
|
-
npm install -g @orion-agents/orion-code@0.3.
|
|
176
|
+
npm install -g @orion-agents/orion-code@0.3.12
|
|
174
177
|
orion --version
|
|
175
178
|
orion doctor
|
|
176
179
|
```
|
package/README.zh-CN.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
面向终端与浏览器、本地优先的目标驱动 Coding Agent。
|
|
4
4
|
|
|
5
|
-
> v0.3.
|
|
6
|
-
>
|
|
7
|
-
>
|
|
5
|
+
> v0.3.12 候选版本:在竖状 rail 之上的**大尺寸右侧工作区**——纯 dock/rail/drawer 几何
|
|
6
|
+
> (详情面可拉宽至 80vw,对话保留 320px 最小宽度)、按 workspace 的布局偏好、共享仓库快照
|
|
7
|
+
> store、Review 验证证据渐进分页、受控 Git stage/unstage/commit、受限工作区搜索与带版本
|
|
8
|
+
> 约束的 file_range Context 引用。Candidate 源码不代表已创建 npm 发布、Git tag 或达到可合并状态。
|
|
8
9
|
|
|
9
10
|
[English](README.md) ·
|
|
10
11
|
[v0.3.7 方案](docs/plan/v0.3.7-left-rail-improvement-plan.md) ·
|
|
@@ -101,7 +102,7 @@ Current。Node 20 已结束上游维护,不再属于 v0.3 Runtime 合同。
|
|
|
101
102
|
当 npm 已存在不可变的 `0.3.4` 发布凭据后:
|
|
102
103
|
|
|
103
104
|
```bash
|
|
104
|
-
npm install -g @orion-agents/orion-code@0.3.
|
|
105
|
+
npm install -g @orion-agents/orion-code@0.3.12
|
|
105
106
|
orion --version
|
|
106
107
|
orion doctor
|
|
107
108
|
```
|
package/dist/cli.js
CHANGED
|
@@ -80,8 +80,21 @@ async function manageBackgroundHost(command, args) {
|
|
|
80
80
|
if (status.state === 'running') {
|
|
81
81
|
console.log(`orion web is running: ${status.pidfile.url} (pid ${status.pidfile.pid})`);
|
|
82
82
|
console.log(`workspace ${status.pidfile.workspace} · started ${new Date(status.pidfile.startedAt).toISOString()}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// No pidfile-owned host, or a stale one. A live listener on the port means
|
|
86
|
+
// an externally managed host (e.g. a launchd LaunchAgent) is serving it —
|
|
87
|
+
// report that instead of a confusing 'not running'.
|
|
88
|
+
if (await (0, host_daemon_1.isHostPortTaken)(options.port)) {
|
|
89
|
+
console.log(`Port ${options.port} is being served by an externally managed Orion host ` +
|
|
90
|
+
`(e.g. a launchd LaunchAgent such as ai.orion-code.web). It is not owned ` +
|
|
91
|
+
`by \`orion web status/stop\`; inspect it with \`launchctl list | grep orion\`.`);
|
|
92
|
+
if (status.state === 'stale') {
|
|
93
|
+
console.log(`Note: the pidfile for the former pid ${status.pid} is stale and can be removed safely.`);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
83
96
|
}
|
|
84
|
-
|
|
97
|
+
if (status.state === 'stale') {
|
|
85
98
|
console.log(`orion web is not running (stale pidfile for pid ${status.pid} on port ${status.port}).`);
|
|
86
99
|
}
|
|
87
100
|
else {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.3.12 — Context text helpers shared by the controller when resolving
|
|
3
|
+
* version-bound references (file_range etc.). Pure and unit-tested.
|
|
4
|
+
*/
|
|
5
|
+
export interface ExtractedLines {
|
|
6
|
+
readonly text: string;
|
|
7
|
+
/** True when the requested range reached the end of the file before endLine. */
|
|
8
|
+
readonly clampedAtEnd: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Extract a 1-based inclusive line range. Never throws for out-of-range input. */
|
|
11
|
+
export declare function extractLineRange(content: string, startLine: number, endLine: number): ExtractedLines;
|
|
12
|
+
/** Soft per-reference byte budget for line content sent to the model context. */
|
|
13
|
+
export declare function enforceContextBudget(text: string, maxBytes: number): {
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly truncated: boolean;
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=context-text.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* v0.3.12 — Context text helpers shared by the controller when resolving
|
|
4
|
+
* version-bound references (file_range etc.). Pure and unit-tested.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.extractLineRange = extractLineRange;
|
|
8
|
+
exports.enforceContextBudget = enforceContextBudget;
|
|
9
|
+
/** Extract a 1-based inclusive line range. Never throws for out-of-range input. */
|
|
10
|
+
function extractLineRange(content, startLine, endLine) {
|
|
11
|
+
const start = Math.max(1, Math.trunc(startLine));
|
|
12
|
+
const end = Math.max(start, Math.trunc(endLine));
|
|
13
|
+
const lines = content.split('\n');
|
|
14
|
+
if (start > lines.length) {
|
|
15
|
+
return Object.freeze({ text: '', clampedAtEnd: true });
|
|
16
|
+
}
|
|
17
|
+
const last = Math.min(end, lines.length);
|
|
18
|
+
const slice = lines.slice(start - 1, last);
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
text: slice.join('\n'),
|
|
21
|
+
clampedAtEnd: end > lines.length,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Soft per-reference byte budget for line content sent to the model context. */
|
|
25
|
+
function enforceContextBudget(text, maxBytes) {
|
|
26
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) {
|
|
27
|
+
return Object.freeze({ text, truncated: false });
|
|
28
|
+
}
|
|
29
|
+
let low = 0;
|
|
30
|
+
let high = text.length;
|
|
31
|
+
while (low < high) {
|
|
32
|
+
const mid = (low + high + 1) >>> 1;
|
|
33
|
+
if (Buffer.byteLength(text.slice(0, mid), 'utf8') <= maxBytes)
|
|
34
|
+
low = mid;
|
|
35
|
+
else
|
|
36
|
+
high = mid - 1;
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze({ text: text.slice(0, low), truncated: true });
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=context-text.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type BigIntStats } from 'fs';
|
|
1
2
|
export type WebFileKindV1 = 'file' | 'directory' | 'symlink';
|
|
2
3
|
export interface WebFileNodeV1 {
|
|
3
4
|
readonly id: string;
|
|
@@ -45,17 +46,49 @@ export declare class FileReadServiceV1 {
|
|
|
45
46
|
constructor(workspace: string);
|
|
46
47
|
get rootId(): string;
|
|
47
48
|
performanceCounters(): WebFileReadPerformanceCountersV1;
|
|
49
|
+
/** v0.3.12 S3 — resolve a remembered file id back to its workspace-relative path. */
|
|
50
|
+
pathForFileId(fileId: string): string;
|
|
48
51
|
identifyRelativePath(relativePath: string): string;
|
|
49
52
|
list(input?: {
|
|
50
53
|
readonly parentId?: string;
|
|
51
54
|
readonly cursor?: string;
|
|
52
55
|
readonly pageSize?: number;
|
|
53
56
|
}): WebFileTreePageV1;
|
|
57
|
+
/**
|
|
58
|
+
* v0.3.12 S4 — bounded workspace search. Name scope walks the tree (skipping
|
|
59
|
+
* VCS/dependency/ignored names, hidden directories, out-of-root symlinks and
|
|
60
|
+
* sensitive paths); content scope additionally matches inside the first
|
|
61
|
+
* 64 KiB of each text file and skips binary payloads. Results cap at
|
|
62
|
+
* `limit`; the scan stops early once the cap is reached or the file budget
|
|
63
|
+
* is exhausted.
|
|
64
|
+
*/
|
|
65
|
+
search(input: {
|
|
66
|
+
readonly query: string;
|
|
67
|
+
readonly scope: 'name' | 'content';
|
|
68
|
+
readonly limit?: number;
|
|
69
|
+
}): {
|
|
70
|
+
readonly items: ReadonlyArray<{
|
|
71
|
+
readonly id: string;
|
|
72
|
+
readonly name: string;
|
|
73
|
+
readonly path: string;
|
|
74
|
+
}>;
|
|
75
|
+
readonly truncated: boolean;
|
|
76
|
+
};
|
|
77
|
+
private fileContainsQuery;
|
|
54
78
|
readContent(input: {
|
|
55
79
|
readonly fileId: string;
|
|
56
80
|
readonly cursor?: string;
|
|
57
81
|
readonly limitBytes?: number;
|
|
58
82
|
}): WebFileContentPageV1;
|
|
83
|
+
/** v0.3.14 — write-path preflight for an existing in-root regular text file. */
|
|
84
|
+
writePreflight(fileId: string): {
|
|
85
|
+
readonly canonicalPath: string;
|
|
86
|
+
readonly relativePath: string;
|
|
87
|
+
readonly revision: string;
|
|
88
|
+
readonly sizeBytes: number;
|
|
89
|
+
};
|
|
90
|
+
/** v0.3.14 — post-write revision fingerprint for the response. */
|
|
91
|
+
revisionOf(canonicalPath: string): string;
|
|
59
92
|
private projectNode;
|
|
60
93
|
private resolveNode;
|
|
61
94
|
private resolveRelative;
|
|
@@ -63,4 +96,6 @@ export declare class FileReadServiceV1 {
|
|
|
63
96
|
private encodeCursor;
|
|
64
97
|
private decodeCursor;
|
|
65
98
|
}
|
|
99
|
+
export declare function fingerprintStat(stat: BigIntStats): string;
|
|
100
|
+
export declare function isBinary(buffer: Buffer): boolean;
|
|
66
101
|
//# sourceMappingURL=file-read-service.d.ts.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FileReadServiceV1 = void 0;
|
|
4
|
+
exports.fingerprintStat = fingerprintStat;
|
|
5
|
+
exports.isBinary = isBinary;
|
|
4
6
|
const crypto_1 = require("crypto");
|
|
5
7
|
const fs_1 = require("fs");
|
|
6
8
|
const path_1 = require("path");
|
|
@@ -41,6 +43,14 @@ class FileReadServiceV1 {
|
|
|
41
43
|
performanceCounters() {
|
|
42
44
|
return Object.freeze({ ...this.performance });
|
|
43
45
|
}
|
|
46
|
+
/** v0.3.12 S3 — resolve a remembered file id back to its workspace-relative path. */
|
|
47
|
+
pathForFileId(fileId) {
|
|
48
|
+
const relativePath = this.pathById.get(fileId);
|
|
49
|
+
if (relativePath === undefined) {
|
|
50
|
+
throw new errors_1.WebWorkbenchError(404, 'File node was not found.', 'file_not_found');
|
|
51
|
+
}
|
|
52
|
+
return relativePath;
|
|
53
|
+
}
|
|
44
54
|
identifyRelativePath(relativePath) {
|
|
45
55
|
const normalized = normalizeRelativePath(relativePath);
|
|
46
56
|
this.resolveRelative(normalized);
|
|
@@ -107,6 +117,132 @@ class FileReadServiceV1 {
|
|
|
107
117
|
: null,
|
|
108
118
|
});
|
|
109
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* v0.3.12 S4 — bounded workspace search. Name scope walks the tree (skipping
|
|
122
|
+
* VCS/dependency/ignored names, hidden directories, out-of-root symlinks and
|
|
123
|
+
* sensitive paths); content scope additionally matches inside the first
|
|
124
|
+
* 64 KiB of each text file and skips binary payloads. Results cap at
|
|
125
|
+
* `limit`; the scan stops early once the cap is reached or the file budget
|
|
126
|
+
* is exhausted.
|
|
127
|
+
*/
|
|
128
|
+
search(input) {
|
|
129
|
+
const query = input.query.trim().slice(0, 200).toLowerCase();
|
|
130
|
+
if (!query) {
|
|
131
|
+
throw new errors_1.WebWorkbenchError(400, 'A non-empty search query is required.', 'file_search_query_required');
|
|
132
|
+
}
|
|
133
|
+
const limit = boundedPageSize(input.limit ?? 50);
|
|
134
|
+
const items = [];
|
|
135
|
+
const stack = [{ dir: this.root, rel: '' }];
|
|
136
|
+
let visitedDirectories = 0;
|
|
137
|
+
let visitedFiles = 0;
|
|
138
|
+
let truncated = false;
|
|
139
|
+
const fileBudget = 2500;
|
|
140
|
+
while (stack.length > 0 && visitedFiles < fileBudget) {
|
|
141
|
+
const { dir, rel } = stack.pop();
|
|
142
|
+
visitedDirectories += 1;
|
|
143
|
+
if (visitedDirectories > 4000) {
|
|
144
|
+
truncated = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
let directory;
|
|
148
|
+
try {
|
|
149
|
+
directory = (0, fs_1.opendirSync)(dir);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const entries = [];
|
|
155
|
+
try {
|
|
156
|
+
for (;;) {
|
|
157
|
+
const entry = directory.readSync();
|
|
158
|
+
if (!entry)
|
|
159
|
+
break;
|
|
160
|
+
if (IGNORED_NAMES.has(entry.name) ||
|
|
161
|
+
(entry.name.startsWith('.') && entry.isDirectory())) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (entry.isDirectory())
|
|
165
|
+
entries.push({ name: entry.name, kind: 'directory' });
|
|
166
|
+
else if (entry.isFile())
|
|
167
|
+
entries.push({ name: entry.name, kind: 'file' });
|
|
168
|
+
else if (entry.isSymbolicLink())
|
|
169
|
+
entries.push({ name: entry.name, kind: 'link' });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
directory.closeSync();
|
|
174
|
+
}
|
|
175
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (items.length >= limit) {
|
|
178
|
+
truncated = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
182
|
+
const absolute = (0, path_1.resolve)(dir, entry.name);
|
|
183
|
+
if (entry.kind === 'directory') {
|
|
184
|
+
stack.push({ dir: absolute, rel: childRel });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (entry.kind === 'link') {
|
|
188
|
+
try {
|
|
189
|
+
const canonical = (0, fs_1.realpathSync)(absolute);
|
|
190
|
+
if (!isWithinRoot(canonical, this.root))
|
|
191
|
+
continue;
|
|
192
|
+
if ((0, fs_1.statSync)(canonical).isDirectory())
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
visitedFiles += 1;
|
|
200
|
+
if ((0, redaction_1.isSensitiveFilePath)(childRel))
|
|
201
|
+
continue;
|
|
202
|
+
const nameMatches = entry.name.toLowerCase().includes(query);
|
|
203
|
+
if (input.scope === 'name') {
|
|
204
|
+
if (nameMatches) {
|
|
205
|
+
items.push({ id: this.remember(childRel), name: entry.name, path: childRel });
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
let contentMatches = false;
|
|
210
|
+
if (!nameMatches) {
|
|
211
|
+
contentMatches = this.fileContainsQuery(absolute, query);
|
|
212
|
+
}
|
|
213
|
+
if (nameMatches || contentMatches) {
|
|
214
|
+
items.push({ id: this.remember(childRel), name: entry.name, path: childRel });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return Object.freeze({
|
|
219
|
+
items: Object.freeze(items.map(item => Object.freeze(item))),
|
|
220
|
+
truncated: truncated || visitedFiles >= fileBudget,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
fileContainsQuery(absolutePath, query) {
|
|
224
|
+
try {
|
|
225
|
+
const descriptor = (0, fs_1.openSync)(absolutePath, fs_1.constants.O_RDONLY);
|
|
226
|
+
try {
|
|
227
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
228
|
+
const bytesRead = (0, fs_1.readSync)(descriptor, buffer, 0, buffer.length, 0);
|
|
229
|
+
if (bytesRead === 0)
|
|
230
|
+
return false;
|
|
231
|
+
if (buffer.subarray(0, bytesRead).includes(0))
|
|
232
|
+
return false;
|
|
233
|
+
return new util_1.TextDecoder('utf-8')
|
|
234
|
+
.decode(buffer.subarray(0, bytesRead))
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
.includes(query);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
(0, fs_1.closeSync)(descriptor);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
110
246
|
readContent(input) {
|
|
111
247
|
this.performance.readOperations += 1;
|
|
112
248
|
const limitBytes = boundedContentBytes(input.limitBytes ?? DEFAULT_CONTENT_BYTES);
|
|
@@ -135,7 +271,7 @@ class FileReadServiceV1 {
|
|
|
135
271
|
if (sample.length > 0) {
|
|
136
272
|
this.performance.bytesRead += (0, fs_1.readSync)(descriptor, sample, 0, sample.length, 0);
|
|
137
273
|
}
|
|
138
|
-
const binary = isBinary(sample);
|
|
274
|
+
const binary = isBinary(validUtf8Prefix(sample));
|
|
139
275
|
if (binary) {
|
|
140
276
|
return Object.freeze({
|
|
141
277
|
fileId: input.fileId,
|
|
@@ -199,6 +335,27 @@ class FileReadServiceV1 {
|
|
|
199
335
|
(0, fs_1.closeSync)(descriptor);
|
|
200
336
|
}
|
|
201
337
|
}
|
|
338
|
+
/** v0.3.14 — write-path preflight for an existing in-root regular text file. */
|
|
339
|
+
writePreflight(fileId) {
|
|
340
|
+
this.performance.readOperations += 1;
|
|
341
|
+
const node = this.resolveNode(fileId);
|
|
342
|
+
if (!node.stat.isFile()) {
|
|
343
|
+
throw new errors_1.WebWorkbenchError(409, 'File node is not a regular file.', 'file_not_regular');
|
|
344
|
+
}
|
|
345
|
+
if (isSensitiveResolvedPath(this.root, node.relativePath, node.canonicalPath)) {
|
|
346
|
+
throw new errors_1.WebWorkbenchError(403, 'Sensitive files are not writable in the Web Workbench.', 'sensitive_file_blocked');
|
|
347
|
+
}
|
|
348
|
+
return Object.freeze({
|
|
349
|
+
canonicalPath: node.canonicalPath,
|
|
350
|
+
relativePath: node.relativePath,
|
|
351
|
+
revision: fingerprintStat(node.stat),
|
|
352
|
+
sizeBytes: Number(node.stat.size),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/** v0.3.14 — post-write revision fingerprint for the response. */
|
|
356
|
+
revisionOf(canonicalPath) {
|
|
357
|
+
return fingerprintStat((0, fs_1.statSync)(canonicalPath, { bigint: true }));
|
|
358
|
+
}
|
|
202
359
|
projectNode(relativePath) {
|
|
203
360
|
const lexicalPath = (0, path_1.resolve)(this.root, relativePath);
|
|
204
361
|
const lexicalStat = (0, fs_1.lstatSync)(lexicalPath, { bigint: true });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FileReadServiceV1 } from './file-read-service';
|
|
2
|
+
export interface WebFileWriteResultV1 {
|
|
3
|
+
readonly fileId: string;
|
|
4
|
+
readonly revision: string;
|
|
5
|
+
readonly sizeBytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class FileWriteServiceV1 {
|
|
8
|
+
private readonly fileService;
|
|
9
|
+
constructor(fileService: FileReadServiceV1);
|
|
10
|
+
writeContent(input: {
|
|
11
|
+
readonly fileId: string;
|
|
12
|
+
readonly content: string;
|
|
13
|
+
readonly expectedRevision: string;
|
|
14
|
+
}): WebFileWriteResultV1;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=file-write-service.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FileWriteServiceV1 = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
const file_read_service_1 = require("./file-read-service");
|
|
7
|
+
const MAX_WRITE_BYTES = 512 * 1024;
|
|
8
|
+
const WRITE_SAMPLE_BYTES = 8192;
|
|
9
|
+
/**
|
|
10
|
+
* v0.3.14 — bounded, CAS-guarded writes for an existing in-root text file.
|
|
11
|
+
*
|
|
12
|
+
* Safety stack mirrors the read path: the file id resolves through the read
|
|
13
|
+
* service's fenced registry (workspace escape, symlink escape and sensitive
|
|
14
|
+
* paths fail closed there), the payload must be valid UTF-8 text without NULs
|
|
15
|
+
* within 512 KiB, and the write commits only when the on-disk revision still
|
|
16
|
+
* matches the caller's `expectedRevision` (compare-and-swap against the same
|
|
17
|
+
* stat fingerprint the read path returns).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* A bounded sample can end inside one UTF-8 code point (very likely for CJK
|
|
21
|
+
* text at an 8 KiB boundary); drop the trailing partial byte before judging the
|
|
22
|
+
* file as binary, mirroring the read path's `validUtf8Prefix`.
|
|
23
|
+
*/
|
|
24
|
+
function completeUtf8Tail(buffer) {
|
|
25
|
+
for (let trim = 0; trim <= Math.min(3, Math.max(0, buffer.length - 1)); trim += 1) {
|
|
26
|
+
const candidate = buffer.subarray(0, buffer.length - trim);
|
|
27
|
+
if (!(0, file_read_service_1.isBinary)(candidate))
|
|
28
|
+
return candidate;
|
|
29
|
+
}
|
|
30
|
+
return buffer;
|
|
31
|
+
}
|
|
32
|
+
class FileWriteServiceV1 {
|
|
33
|
+
constructor(fileService) {
|
|
34
|
+
this.fileService = fileService;
|
|
35
|
+
}
|
|
36
|
+
writeContent(input) {
|
|
37
|
+
const content = input.content;
|
|
38
|
+
if (typeof content !== 'string') {
|
|
39
|
+
throw new errors_1.WebWorkbenchError(400, 'File content must be a string.', 'file_content_invalid');
|
|
40
|
+
}
|
|
41
|
+
if (content.includes('\0')) {
|
|
42
|
+
throw new errors_1.WebWorkbenchError(415, 'Files containing NUL bytes are not editable in the Web Workbench.', 'file_binary');
|
|
43
|
+
}
|
|
44
|
+
const byteLength = Buffer.byteLength(content, 'utf8');
|
|
45
|
+
if (byteLength > MAX_WRITE_BYTES) {
|
|
46
|
+
throw new errors_1.WebWorkbenchError(413, 'Edited file content exceeds the 512 KiB Web editor limit.', 'file_too_large');
|
|
47
|
+
}
|
|
48
|
+
if (!/^[0-9a-f]{64}$/u.test(input.expectedRevision)) {
|
|
49
|
+
throw new errors_1.WebWorkbenchError(400, 'expectedRevision is invalid.', 'file_revision_invalid');
|
|
50
|
+
}
|
|
51
|
+
const preflight = this.fileService.writePreflight(input.fileId);
|
|
52
|
+
if (preflight.sizeBytes > MAX_WRITE_BYTES) {
|
|
53
|
+
throw new errors_1.WebWorkbenchError(413, 'Target file exceeds the 512 KiB Web editor limit.', 'file_too_large');
|
|
54
|
+
}
|
|
55
|
+
const payload = Buffer.from(content, 'utf8');
|
|
56
|
+
// Phase 1 (read descriptor): CAS + binary re-check. An O_WRONLY descriptor
|
|
57
|
+
// cannot readSync, so the preflight opens its own read handle first.
|
|
58
|
+
const readDescriptor = (0, fs_1.openSync)(preflight.canonicalPath, fs_1.constants.O_RDONLY);
|
|
59
|
+
let beforeSize;
|
|
60
|
+
try {
|
|
61
|
+
const before = (0, fs_1.fstatSync)(readDescriptor, { bigint: true });
|
|
62
|
+
beforeSize = before.size;
|
|
63
|
+
const sample = Buffer.alloc(Math.min(WRITE_SAMPLE_BYTES, Number(before.size)));
|
|
64
|
+
if (sample.length > 0) {
|
|
65
|
+
(0, fs_1.readSync)(readDescriptor, sample, 0, sample.length, 0);
|
|
66
|
+
if ((0, file_read_service_1.isBinary)(completeUtf8Tail(sample))) {
|
|
67
|
+
throw new errors_1.WebWorkbenchError(415, 'Target file is not UTF-8 text.', 'file_binary');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if ((0, file_read_service_1.fingerprintStat)(before) !== input.expectedRevision) {
|
|
71
|
+
throw new errors_1.WebWorkbenchError(409, 'The file changed on disk before the save could run; reload it first.', 'file_revision_conflict');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
(0, fs_1.closeSync)(readDescriptor);
|
|
76
|
+
}
|
|
77
|
+
// Phase 2 (write descriptor): CAS is re-checked by the kernel fingerprint
|
|
78
|
+
// window being as short as possible between the two handles.
|
|
79
|
+
const writeDescriptor = (0, fs_1.openSync)(preflight.canonicalPath, fs_1.constants.O_WRONLY);
|
|
80
|
+
try {
|
|
81
|
+
const before = (0, fs_1.fstatSync)(writeDescriptor, { bigint: true });
|
|
82
|
+
if ((0, file_read_service_1.fingerprintStat)(before) !== input.expectedRevision) {
|
|
83
|
+
throw new errors_1.WebWorkbenchError(409, 'The file changed on disk before the save could run; reload it first.', 'file_revision_conflict');
|
|
84
|
+
}
|
|
85
|
+
(0, fs_1.writeSync)(writeDescriptor, payload, 0, payload.length, 0);
|
|
86
|
+
// Shrink keeps the file exactly as long as the payload.
|
|
87
|
+
if (beforeSize > BigInt(payload.length)) {
|
|
88
|
+
(0, fs_1.truncateSync)(preflight.canonicalPath, payload.length);
|
|
89
|
+
}
|
|
90
|
+
const after = (0, fs_1.statSync)(preflight.canonicalPath, { bigint: true });
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
fileId: input.fileId,
|
|
93
|
+
revision: (0, file_read_service_1.fingerprintStat)(after),
|
|
94
|
+
sizeBytes: Number(after.size),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
(0, fs_1.closeSync)(writeDescriptor);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.FileWriteServiceV1 = FileWriteServiceV1;
|
|
103
|
+
//# sourceMappingURL=file-write-service.js.map
|
|
@@ -77,6 +77,20 @@ export declare class GitReadModelServiceV1 {
|
|
|
77
77
|
private capture;
|
|
78
78
|
private projectFile;
|
|
79
79
|
private rememberPath;
|
|
80
|
+
/** v0.3.12 S3 — guarded stage: paths are host-resolved file ids only. */
|
|
81
|
+
stagePaths(paths: readonly string[]): Promise<{
|
|
82
|
+
readonly repositoryRevision: string;
|
|
83
|
+
}>;
|
|
84
|
+
unstagePaths(paths: readonly string[]): Promise<{
|
|
85
|
+
readonly repositoryRevision: string;
|
|
86
|
+
}>;
|
|
87
|
+
commit(message: string): Promise<{
|
|
88
|
+
readonly repositoryRevision: string;
|
|
89
|
+
readonly commitSha: string;
|
|
90
|
+
}>;
|
|
91
|
+
private mutate;
|
|
92
|
+
private requireRepositoryRoot;
|
|
93
|
+
private revisionAfterMutation;
|
|
80
94
|
private runGit;
|
|
81
95
|
private tryGit;
|
|
82
96
|
private encodeCursor;
|