@4fu/arbor-workspace-ovnfs 0.0.0-stage → 2026.1107.0
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/LICENSE +21 -0
- package/README.md +195 -2
- package/cli.d.ts +114 -0
- package/cli.js +89 -0
- package/groups.d.ts +33 -0
- package/groups.js +211 -0
- package/hooks/commit-msg +48 -0
- package/index.d.ts +39 -0
- package/index.js +52 -0
- package/ovnfs.d.ts +99 -0
- package/ovnfs.js +301 -0
- package/package.json +45 -4
- package/section.d.ts +13 -0
- package/section.js +38 -0
- package/skills/ovnfs/SKILL.md +82 -0
- package/text.d.ts +16 -0
- package/text.js +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4fuu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,196 @@
|
|
|
1
|
-
#
|
|
1
|
+
# arbor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
arbor is a framework for an agent without sessions: many context windows run
|
|
4
|
+
at once over one shared record, so work moves in parallel, replies come fast,
|
|
5
|
+
and every window can find everything that was ever done.
|
|
6
|
+
|
|
7
|
+
A deployment composes the pieces it wants and registers plugins for the domains
|
|
8
|
+
it serves. The framework guarantees what an agent cannot guarantee for itself -
|
|
9
|
+
the record, isolation, exactly-once effects, the checks, dispatch - and leaves
|
|
10
|
+
everything that takes judgment, such as git, merges and conflicts, to the agent.
|
|
11
|
+
|
|
12
|
+
## Core ideas
|
|
13
|
+
|
|
14
|
+
- **No sessions to manage.** Work runs in disposable context windows. Any
|
|
15
|
+
number run at the same time; none of them owns anything, and a window that
|
|
16
|
+
ends loses nothing. The model looks up what it needs instead of carrying it.
|
|
17
|
+
- **One record, shared live.** Every input, tool call, result, send and note
|
|
18
|
+
revision goes into one journal and is visible to every window as
|
|
19
|
+
soon as it is written. Records never merge and are never lost.
|
|
20
|
+
- **Dispatch by a judge.** A judge (Jev by default, replaceable) decides which
|
|
21
|
+
live window takes a new input, or opens a new one. There is no router session
|
|
22
|
+
and no main line.
|
|
23
|
+
- **Current without locks.** Windows run in parallel. New events on a
|
|
24
|
+
conversation a window is in are steered into it as they arrive; only a note
|
|
25
|
+
write is checked against the revision the window read.
|
|
26
|
+
- **Mechanisms in the framework, policy in the agent.** The framework
|
|
27
|
+
guarantees what an agent cannot guarantee for itself - the record, isolation,
|
|
28
|
+
exactly-once effects, the checks, dispatch. Everything that takes judgment,
|
|
29
|
+
such as git, merges and conflicts, is the agent's.
|
|
30
|
+
- **A shared workspace with per-window checkouts.** The default workspace is
|
|
31
|
+
ovnfs: one shared folder every window reads, and a window opens its own
|
|
32
|
+
copy-on-write checkout of the project it needs, works there and merges back
|
|
33
|
+
with `apply`. It falls back to a plain directory per window when ovnfs is not
|
|
34
|
+
installed. It is not a sandbox: ovnfs isolates files, not processes.
|
|
35
|
+
- **A self-changing environment.** The model adds and changes skills, MCP
|
|
36
|
+
servers and resident services itself; a `reload` validates the change and
|
|
37
|
+
broadcasts it to every live window as a delta of names and descriptions.
|
|
38
|
+
- **Minimal limits.** The framework sets no policy. Permissions, privacy
|
|
39
|
+
review, budgets and approvals belong to deployments built on it, through
|
|
40
|
+
hooks.
|
|
41
|
+
- **Direct tools plus code mode.** Decisions are direct tools; everything else
|
|
42
|
+
is a module called from `code_exec`. There is no CLI.
|
|
43
|
+
- **Every module is replaceable.** The agent backend (pi by default), the
|
|
44
|
+
journal, notes, workspaces, the judge, channels and the rest are interfaces
|
|
45
|
+
([docs/architecture.md](docs/architecture.md#interfaces)).
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Node 24 or newer. Every package is published separately, so a deployment
|
|
50
|
+
installs the core and the defaults it wants:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
|
|
54
|
+
@4fu/arbor-environment-dir @4fu/arbor-supervisor-process @4fu/arbor-agent-pi \
|
|
55
|
+
@4fu/arbor-workspace-dir
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`@4fu/arbor-plugin-http` is optional; it is the example plugin below.
|
|
59
|
+
`@4fu/arbor-segmenter-jieba` is an optional peer of the lexical package, and
|
|
60
|
+
`@4fu/arbor-search-zvec` an optional peer of the journal: absent either one, the
|
|
61
|
+
journal searches lexically and nothing else changes.
|
|
62
|
+
|
|
63
|
+
## A minimal host
|
|
64
|
+
|
|
65
|
+
The smallest host needs a journal and a backend. Adding the defaults and one
|
|
66
|
+
plugin is what a deployment usually starts from:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { mkdtemp } from "node:fs/promises";
|
|
70
|
+
import { tmpdir } from "node:os";
|
|
71
|
+
import { join } from "node:path";
|
|
72
|
+
import { Arbor } from "@4fu/arbor-core";
|
|
73
|
+
import { DirectoryEnvironment } from "@4fu/arbor-environment-dir";
|
|
74
|
+
import { SqliteJournal } from "@4fu/arbor-journal-sqlite";
|
|
75
|
+
import { SqliteNoteStore } from "@4fu/arbor-notes-sqlite";
|
|
76
|
+
import { ProcessSupervisor } from "@4fu/arbor-supervisor-process";
|
|
77
|
+
import { DirectoryWorkspace } from "@4fu/arbor-workspace-dir";
|
|
78
|
+
import { PiAgentBackend } from "@4fu/arbor-agent-pi";
|
|
79
|
+
import { HttpChannel, httpPlugin } from "@4fu/arbor-plugin-http";
|
|
80
|
+
|
|
81
|
+
const root = await mkdtemp(join(tmpdir(), "arbor-"));
|
|
82
|
+
|
|
83
|
+
// Every module below is a default implementation of an interface. Replace any
|
|
84
|
+
// one by passing your own; the host does not change.
|
|
85
|
+
const journal = new SqliteJournal({ path: join(root, "journal.sqlite") });
|
|
86
|
+
const notes = new SqliteNoteStore({ path: join(root, "notes.sqlite"), journal });
|
|
87
|
+
const supervisor = new ProcessSupervisor({ root: join(root, "services") });
|
|
88
|
+
const environment = new DirectoryEnvironment({ root: join(root, "env"), supervisor });
|
|
89
|
+
const workspace = new DirectoryWorkspace({ root: join(root, "workspaces") });
|
|
90
|
+
|
|
91
|
+
// A plugin adds; it never patches the core. This one is a chat channel: a
|
|
92
|
+
// webhook in, a window's sends out, and a page that drives the whole host.
|
|
93
|
+
let arbor;
|
|
94
|
+
const channel = new HttpChannel({ host: "127.0.0.1", port: 8787, board: () => arbor.boardLines() });
|
|
95
|
+
arbor = new Arbor({
|
|
96
|
+
journal,
|
|
97
|
+
notes,
|
|
98
|
+
backend: new PiAgentBackend(),
|
|
99
|
+
workspace,
|
|
100
|
+
environment,
|
|
101
|
+
supervisor,
|
|
102
|
+
plugins: [httpPlugin({ channel })],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await arbor.start();
|
|
106
|
+
await channel.listen();
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`arbor.open(task)` dispatches one task, and the journal is where everything that
|
|
110
|
+
happened can be read back ([docs/journal.md](docs/journal.md)). The workspace a
|
|
111
|
+
window gets, and how a continuation keeps working in it, are in
|
|
112
|
+
[docs/workspaces.md](docs/workspaces.md).
|
|
113
|
+
|
|
114
|
+
## Packages
|
|
115
|
+
|
|
116
|
+
| Package | What it is |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| [`@4fu/arbor-interfaces`](packages/interfaces) | every replaceable interface, plus the core's own shapes. Types only. |
|
|
119
|
+
| [`@4fu/arbor-errors`](packages/errors) | the error shape every refusal uses: the rule and the next action |
|
|
120
|
+
| [`@4fu/arbor-prompt`](packages/prompt) | typed blocks, the one deterministic renderer and the replaceable text table |
|
|
121
|
+
| [`@4fu/arbor-tools`](packages/tools) | the entry points: direct tools, code mode modules, the host API |
|
|
122
|
+
| [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
|
|
123
|
+
| [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
|
|
124
|
+
| [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
125
|
+
| [`@4fu/arbor-lexical`](packages/lexical) | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
|
|
126
|
+
| [`@4fu/arbor-search-zvec`](packages/search-zvec) | the optional semantic channel: event text embedded and searched by meaning with zvec |
|
|
127
|
+
| [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the fallback `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
|
|
128
|
+
| [`@4fu/arbor-workspace-ovnfs`](packages/workspace-ovnfs) | the default `Workspace` when ovnfs is installed: one shared folder, per-window copy-on-write checkouts the model opens and applies back |
|
|
129
|
+
| [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
130
|
+
| [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
131
|
+
| [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
132
|
+
| [`@4fu/arbor-agent-pi`](packages/agent-pi) | the default `AgentBackend`: pi-agent-core in process |
|
|
133
|
+
| [`@4fu/arbor-plugin-http`](packages/plugin-http) | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
|
|
134
|
+
| [`@4fu/arbor-core`](packages/core) | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects and schedules |
|
|
135
|
+
|
|
136
|
+
### Defaults are examples
|
|
137
|
+
|
|
138
|
+
Every default above is a reference implementation of its interface, written to
|
|
139
|
+
be read and replaced. A real deployment is expected to swap out the ones that do
|
|
140
|
+
not fit: the SQLite journal and notes, the directory workspace, the process
|
|
141
|
+
supervisor, the environment directory, the HTTP test plugin and the Jev judge
|
|
142
|
+
are all examples of what an interface can be satisfied by, not commitments.
|
|
143
|
+
What a deployment keeps is its own choice, and the interfaces are the contract
|
|
144
|
+
([docs/architecture.md](docs/architecture.md#interfaces)).
|
|
145
|
+
|
|
146
|
+
Each package's own documentation holds the limits of that default, so a
|
|
147
|
+
deployment can see what it would be taking on before it adopts one.
|
|
148
|
+
|
|
149
|
+
## Documents
|
|
150
|
+
|
|
151
|
+
| Page | Contents |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| [docs/overview.md](docs/overview.md) | goals, concepts, principles, routes considered |
|
|
154
|
+
| [docs/architecture.md](docs/architecture.md) | layers, interfaces, stack, models, plugins, hooks, data placement |
|
|
155
|
+
| [docs/journal.md](docs/journal.md) | events and their order, targets, watermarks, external ids, refs, tracing, media, redaction |
|
|
156
|
+
| [docs/windows.md](docs/windows.md) | window lifecycle, prompt layout, reminders, continuation |
|
|
157
|
+
| [docs/dispatch.md](docs/dispatch.md) | task sources, judge decisions, insertion timing, backpressure, scheduled tasks |
|
|
158
|
+
| [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
|
|
159
|
+
| [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
|
|
160
|
+
| [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
|
|
161
|
+
| [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
|
|
162
|
+
| [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
|
|
163
|
+
| [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
|
|
164
|
+
| [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
|
|
165
|
+
|
|
166
|
+
## Repository layout
|
|
167
|
+
|
|
168
|
+
`pnpm check` typechecks and tests every package on its TypeScript sources.
|
|
169
|
+
`pnpm build` emits the `dist` publishing needs, and `pnpm pack` shows what each
|
|
170
|
+
tarball would contain ([docs/release.md](docs/release.md)).
|
|
171
|
+
|
|
172
|
+
| Path | Layer |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `packages/interfaces` | the interfaces layer: every replaceable interface, plus the core's own shapes. Types only. |
|
|
175
|
+
| `packages/errors` | the error shape every refusal uses: the rule and the next action |
|
|
176
|
+
| `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
|
|
177
|
+
| `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
178
|
+
| `packages/lexical` | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
|
|
179
|
+
| `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
|
|
180
|
+
| `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
181
|
+
| `packages/workspace-dir` | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place, and the lookup of another window's directory it contributes to the catalog |
|
|
182
|
+
| `packages/environment-dir` | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
183
|
+
| `packages/supervisor-process` | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
184
|
+
| `packages/plugin-http` | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
|
|
185
|
+
| `packages/prompt` | typed blocks, the one deterministic renderer and the replaceable text table |
|
|
186
|
+
| `packages/agent-pi` | the default `AgentBackend`: pi-agent-core in process |
|
|
187
|
+
| `packages/tools` | the entry points: direct tools, code mode modules, the host API |
|
|
188
|
+
| `packages/core` | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects, schedules and the snapshot epochs |
|
|
189
|
+
|
|
190
|
+
`mise.toml` pins the toolchain - Node and pnpm - installed under
|
|
191
|
+
`~/.local/share/mise`, and `.agents/setup` prepares a machine from it. A machine
|
|
192
|
+
without mise is expected to carry Node 24 or newer and pnpm 12 already.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT, see [LICENSE](LICENSE).
|
package/cli.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin wrapper over the `ovnfs` binary (docs/workspaces.md).
|
|
3
|
+
*
|
|
4
|
+
* Every operation is one `ovnfs ctl OP 'JSON'` call, which sends the admin
|
|
5
|
+
* request and prints the raw reply as JSON on stdout. A reply is parsed whether
|
|
6
|
+
* or not the process exits zero: `apply` reports conflicts in its reply, and a
|
|
7
|
+
* daemon error answers with a non-zero exit and empty stdout. The daemon is
|
|
8
|
+
* started once, by `start`, before anything else is asked of it.
|
|
9
|
+
*
|
|
10
|
+
* The binary is pinned at v0.1.1; `version` reads what is installed so the host
|
|
11
|
+
* can warn on a mismatch without failing.
|
|
12
|
+
*/
|
|
13
|
+
/** The running daemon, from `ovnfs --json start`. */
|
|
14
|
+
export interface OvnfsStatus {
|
|
15
|
+
version: number;
|
|
16
|
+
pid: number;
|
|
17
|
+
proto: string;
|
|
18
|
+
port: number;
|
|
19
|
+
mount: string;
|
|
20
|
+
state: string;
|
|
21
|
+
socket: string;
|
|
22
|
+
checkouts: number;
|
|
23
|
+
}
|
|
24
|
+
/** One checkout, as `ovnfs ctl ls` lists it. */
|
|
25
|
+
export interface OvnfsCheckoutInfo {
|
|
26
|
+
name: string;
|
|
27
|
+
path: string;
|
|
28
|
+
source: string;
|
|
29
|
+
base: string;
|
|
30
|
+
git: boolean;
|
|
31
|
+
dirty: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** `ovnfs ctl ls` reply. */
|
|
34
|
+
export interface OvnfsLsReply {
|
|
35
|
+
checkouts: OvnfsCheckoutInfo[];
|
|
36
|
+
snapshots: {
|
|
37
|
+
id: string;
|
|
38
|
+
checkout: string;
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
/** `ovnfs ctl new` arguments. */
|
|
42
|
+
export interface OvnfsNewArgs {
|
|
43
|
+
dir?: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
}
|
|
46
|
+
/** `ovnfs ctl new` reply. */
|
|
47
|
+
export interface OvnfsNewReply {
|
|
48
|
+
checkout: OvnfsCheckoutInfo;
|
|
49
|
+
warnings: string[];
|
|
50
|
+
}
|
|
51
|
+
/** One changed path, as `diff` and `apply` report it. */
|
|
52
|
+
export interface OvnfsChange {
|
|
53
|
+
path: string;
|
|
54
|
+
status: string;
|
|
55
|
+
}
|
|
56
|
+
/** `ovnfs ctl diff` reply. */
|
|
57
|
+
export interface OvnfsDiffReply {
|
|
58
|
+
from: string;
|
|
59
|
+
changes: OvnfsChange[];
|
|
60
|
+
patch?: string;
|
|
61
|
+
}
|
|
62
|
+
/** `ovnfs ctl apply` arguments. */
|
|
63
|
+
export interface OvnfsApplyArgs {
|
|
64
|
+
checkout: string;
|
|
65
|
+
branch?: string;
|
|
66
|
+
dry_run?: boolean;
|
|
67
|
+
}
|
|
68
|
+
/** One path apply could not settle. */
|
|
69
|
+
export interface OvnfsConflict {
|
|
70
|
+
path: string;
|
|
71
|
+
reason: string;
|
|
72
|
+
markers: boolean;
|
|
73
|
+
}
|
|
74
|
+
/** `ovnfs ctl apply` reply. */
|
|
75
|
+
export interface OvnfsApplyReply {
|
|
76
|
+
snapshot: {
|
|
77
|
+
id: string;
|
|
78
|
+
checkout: string;
|
|
79
|
+
};
|
|
80
|
+
source: string;
|
|
81
|
+
applied: OvnfsChange[];
|
|
82
|
+
merged: string[];
|
|
83
|
+
conflicts: OvnfsConflict[];
|
|
84
|
+
branch?: string;
|
|
85
|
+
commit?: string;
|
|
86
|
+
}
|
|
87
|
+
export declare class OvnfsCli {
|
|
88
|
+
private readonly bin;
|
|
89
|
+
private readonly state;
|
|
90
|
+
private readonly mount;
|
|
91
|
+
constructor(bin: string, state: string, mount: string);
|
|
92
|
+
/** The build this binary reports, for the pinned-version check. */
|
|
93
|
+
version(): Promise<string>;
|
|
94
|
+
/** Make sure the daemon runs and answer its status. */
|
|
95
|
+
start(): Promise<OvnfsStatus>;
|
|
96
|
+
/** Every live checkout, with its dirty flag. */
|
|
97
|
+
ls(): Promise<OvnfsLsReply>;
|
|
98
|
+
/** Open a checkout on a folder. */
|
|
99
|
+
open(args: OvnfsNewArgs): Promise<OvnfsNewReply>;
|
|
100
|
+
/** A checkout's changed source paths since its base. */
|
|
101
|
+
diff(checkout: string): Promise<OvnfsDiffReply>;
|
|
102
|
+
/** Merge a checkout's changes back into its folder. */
|
|
103
|
+
apply(args: OvnfsApplyArgs): Promise<OvnfsApplyReply>;
|
|
104
|
+
/** Remove a checkout; its snapshots stay. */
|
|
105
|
+
rm(checkout: string): Promise<void>;
|
|
106
|
+
private global;
|
|
107
|
+
private text;
|
|
108
|
+
/**
|
|
109
|
+
* One operation that answers with JSON. A reply is read from stdout whether
|
|
110
|
+
* or not the process exited zero, because `ctl` prints the reply and reports
|
|
111
|
+
* a daemon error on stderr with a non-zero exit and no stdout.
|
|
112
|
+
*/
|
|
113
|
+
private json;
|
|
114
|
+
}
|
package/cli.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin wrapper over the `ovnfs` binary (docs/workspaces.md).
|
|
3
|
+
*
|
|
4
|
+
* Every operation is one `ovnfs ctl OP 'JSON'` call, which sends the admin
|
|
5
|
+
* request and prints the raw reply as JSON on stdout. A reply is parsed whether
|
|
6
|
+
* or not the process exits zero: `apply` reports conflicts in its reply, and a
|
|
7
|
+
* daemon error answers with a non-zero exit and empty stdout. The daemon is
|
|
8
|
+
* started once, by `start`, before anything else is asked of it.
|
|
9
|
+
*
|
|
10
|
+
* The binary is pinned at v0.1.1; `version` reads what is installed so the host
|
|
11
|
+
* can warn on a mismatch without failing.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
/** How much one reply may hold before the wrapper refuses it. */
|
|
15
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
16
|
+
export class OvnfsCli {
|
|
17
|
+
bin;
|
|
18
|
+
state;
|
|
19
|
+
mount;
|
|
20
|
+
constructor(bin, state, mount) {
|
|
21
|
+
this.bin = bin;
|
|
22
|
+
this.state = state;
|
|
23
|
+
this.mount = mount;
|
|
24
|
+
}
|
|
25
|
+
/** The build this binary reports, for the pinned-version check. */
|
|
26
|
+
version() {
|
|
27
|
+
return this.text(["version"]);
|
|
28
|
+
}
|
|
29
|
+
/** Make sure the daemon runs and answer its status. */
|
|
30
|
+
start() {
|
|
31
|
+
return this.json(["--json", "start"]);
|
|
32
|
+
}
|
|
33
|
+
/** Every live checkout, with its dirty flag. */
|
|
34
|
+
ls() {
|
|
35
|
+
return this.json(["ctl", "ls", "{}"]);
|
|
36
|
+
}
|
|
37
|
+
/** Open a checkout on a folder. */
|
|
38
|
+
open(args) {
|
|
39
|
+
return this.json(["ctl", "new", JSON.stringify(args)]);
|
|
40
|
+
}
|
|
41
|
+
/** A checkout's changed source paths since its base. */
|
|
42
|
+
diff(checkout) {
|
|
43
|
+
return this.json(["ctl", "diff", JSON.stringify({ checkout })]);
|
|
44
|
+
}
|
|
45
|
+
/** Merge a checkout's changes back into its folder. */
|
|
46
|
+
apply(args) {
|
|
47
|
+
return this.json(["ctl", "apply", JSON.stringify(args)]);
|
|
48
|
+
}
|
|
49
|
+
/** Remove a checkout; its snapshots stay. */
|
|
50
|
+
rm(checkout) {
|
|
51
|
+
return this.json(["ctl", "rm", JSON.stringify({ checkout })]).then(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
global(args) {
|
|
54
|
+
return ["--state", this.state, "--mount", this.mount, ...args];
|
|
55
|
+
}
|
|
56
|
+
text(args) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
execFile(this.bin, this.global(args), { maxBuffer: MAX_BUFFER }, (error, stdout, stderr) => {
|
|
59
|
+
if (error !== null) {
|
|
60
|
+
reject(new Error((stderr || error.message).trim()));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
resolve(stdout.trim());
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One operation that answers with JSON. A reply is read from stdout whether
|
|
69
|
+
* or not the process exited zero, because `ctl` prints the reply and reports
|
|
70
|
+
* a daemon error on stderr with a non-zero exit and no stdout.
|
|
71
|
+
*/
|
|
72
|
+
json(args) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
execFile(this.bin, this.global(args), { maxBuffer: MAX_BUFFER }, (error, stdout, stderr) => {
|
|
75
|
+
const body = stdout.trim();
|
|
76
|
+
if (body === "") {
|
|
77
|
+
reject(new Error((stderr || error?.message || "ovnfs: no reply").trim()));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
resolve(JSON.parse(body));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
reject(new Error(`ovnfs: reply was not JSON: ${body.slice(0, 200)}`));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
package/groups.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module functions the ovnfs workspace contributes (docs/workspaces.md).
|
|
3
|
+
*
|
|
4
|
+
* `checkout`, `list`, `diff`, `apply`, `rm` and `path` give the model the ovnfs
|
|
5
|
+
* method without the CLI: open a checkout of a project subfolder, see its
|
|
6
|
+
* changes, merge them back, remove it. The plain CLI works just as well - a
|
|
7
|
+
* command run in `bash` reaches the same daemon - and a checkout opened or
|
|
8
|
+
* applied through here is recorded in the journal, linked to the window.
|
|
9
|
+
*
|
|
10
|
+
* Every result and refusal is one text-table entry naming the rule and the next
|
|
11
|
+
* action. These functions live in the `workspace` module beside the core's
|
|
12
|
+
* `grep`, `find` and `ls`.
|
|
13
|
+
*/
|
|
14
|
+
import type { CallContext, ModuleSpec, TextTable, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
15
|
+
import type { OvnfsWorkspace } from "./ovnfs.ts";
|
|
16
|
+
export declare class OvnfsWorkspaceGroup implements ToolGroup {
|
|
17
|
+
readonly id = "workspace.ovnfs";
|
|
18
|
+
private readonly workspace;
|
|
19
|
+
private readonly table;
|
|
20
|
+
constructor(workspace: OvnfsWorkspace, table: TextTable);
|
|
21
|
+
tools(): readonly [];
|
|
22
|
+
modules(): readonly ModuleSpec[];
|
|
23
|
+
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
24
|
+
private checkout;
|
|
25
|
+
private list;
|
|
26
|
+
private diff;
|
|
27
|
+
private apply;
|
|
28
|
+
private rm;
|
|
29
|
+
private path;
|
|
30
|
+
private ok;
|
|
31
|
+
private refusal;
|
|
32
|
+
private notOwned;
|
|
33
|
+
}
|
package/groups.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module functions the ovnfs workspace contributes (docs/workspaces.md).
|
|
3
|
+
*
|
|
4
|
+
* `checkout`, `list`, `diff`, `apply`, `rm` and `path` give the model the ovnfs
|
|
5
|
+
* method without the CLI: open a checkout of a project subfolder, see its
|
|
6
|
+
* changes, merge them back, remove it. The plain CLI works just as well - a
|
|
7
|
+
* command run in `bash` reaches the same daemon - and a checkout opened or
|
|
8
|
+
* applied through here is recorded in the journal, linked to the window.
|
|
9
|
+
*
|
|
10
|
+
* Every result and refusal is one text-table entry naming the rule and the next
|
|
11
|
+
* action. These functions live in the `workspace` module beside the core's
|
|
12
|
+
* `grep`, `find` and `ls`.
|
|
13
|
+
*/
|
|
14
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
15
|
+
export class OvnfsWorkspaceGroup {
|
|
16
|
+
id = "workspace.ovnfs";
|
|
17
|
+
workspace;
|
|
18
|
+
table;
|
|
19
|
+
constructor(workspace, table) {
|
|
20
|
+
this.workspace = workspace;
|
|
21
|
+
this.table = table;
|
|
22
|
+
}
|
|
23
|
+
tools() {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
modules() {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
name: "workspace",
|
|
30
|
+
description: textOf(this.table, "module.workspace"),
|
|
31
|
+
functions: [
|
|
32
|
+
{
|
|
33
|
+
name: "workspace.checkout",
|
|
34
|
+
signature: "workspace.checkout(name, dir?)",
|
|
35
|
+
description: textOf(this.table, "function.workspace.checkout"),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "workspace.list",
|
|
39
|
+
signature: "workspace.list(window?)",
|
|
40
|
+
description: textOf(this.table, "function.workspace.list"),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "workspace.diff",
|
|
44
|
+
signature: "workspace.diff(name)",
|
|
45
|
+
description: textOf(this.table, "function.workspace.diff"),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "workspace.apply",
|
|
49
|
+
signature: "workspace.apply(name, branch?)",
|
|
50
|
+
description: textOf(this.table, "function.workspace.apply"),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "workspace.rm",
|
|
54
|
+
signature: "workspace.rm(name)",
|
|
55
|
+
description: textOf(this.table, "function.workspace.rm"),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "workspace.path",
|
|
59
|
+
signature: "workspace.path(window)",
|
|
60
|
+
description: textOf(this.table, "function.workspace.path"),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
async call(request, context) {
|
|
67
|
+
switch (request.name) {
|
|
68
|
+
case "workspace.checkout":
|
|
69
|
+
return this.checkout(request, context);
|
|
70
|
+
case "workspace.list":
|
|
71
|
+
return this.list(request, context);
|
|
72
|
+
case "workspace.diff":
|
|
73
|
+
return this.diff(request);
|
|
74
|
+
case "workspace.apply":
|
|
75
|
+
return this.apply(request, context);
|
|
76
|
+
case "workspace.rm":
|
|
77
|
+
return this.rm(request);
|
|
78
|
+
case "workspace.path":
|
|
79
|
+
return this.path(request);
|
|
80
|
+
default:
|
|
81
|
+
return this.notOwned(request);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async checkout(request, context) {
|
|
85
|
+
const name = stringArg(request.args, "name");
|
|
86
|
+
// ovnfs accepts only lowercase letters, digits, ".", "_" and "-" in a name.
|
|
87
|
+
if (!name.startsWith(`${context.window}-`) || !/^[a-z0-9._-]+$/.test(name)) {
|
|
88
|
+
return this.refusal(request, "refused.workspace.name", { name });
|
|
89
|
+
}
|
|
90
|
+
const dir = stringArg(request.args, "dir");
|
|
91
|
+
try {
|
|
92
|
+
const path = await this.workspace.openCheckout(context.window, name, dir);
|
|
93
|
+
return this.ok(request, textOf(this.table, "result.workspace.checkout", { name, path }));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return this.refusal(request, "refused.workspace.cli", { message: messageOf(error) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async list(request, context) {
|
|
100
|
+
const window = windowArg(request.args.window) ?? context.window;
|
|
101
|
+
const checkouts = await this.workspace.checkouts(window);
|
|
102
|
+
if (checkouts.length === 0) {
|
|
103
|
+
return this.ok(request, textOf(this.table, "result.workspace.list.empty", { window }));
|
|
104
|
+
}
|
|
105
|
+
const lines = checkouts
|
|
106
|
+
.map((one) => textOf(this.table, "result.workspace.list.line", {
|
|
107
|
+
name: one.name,
|
|
108
|
+
dirty: one.dirty ? textOf(this.table, "result.workspace.list.dirty") : "",
|
|
109
|
+
path: one.path,
|
|
110
|
+
}))
|
|
111
|
+
.join("\n");
|
|
112
|
+
return this.ok(request, textOf(this.table, "result.workspace.list", { window, lines }));
|
|
113
|
+
}
|
|
114
|
+
async diff(request) {
|
|
115
|
+
const name = stringArg(request.args, "name");
|
|
116
|
+
try {
|
|
117
|
+
const reply = await this.workspace.diffCheckout(name);
|
|
118
|
+
if (reply.changes.length === 0) {
|
|
119
|
+
return this.ok(request, textOf(this.table, "result.workspace.diff.empty", { name }));
|
|
120
|
+
}
|
|
121
|
+
const changes = reply.changes.map((change) => `${change.status} ${change.path}`).join("\n");
|
|
122
|
+
return this.ok(request, textOf(this.table, "result.workspace.diff", { name, changes }));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return this.refusal(request, "refused.workspace.cli", { message: messageOf(error) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async apply(request, context) {
|
|
129
|
+
const name = stringArg(request.args, "name");
|
|
130
|
+
const branch = stringArg(request.args, "branch") || undefined;
|
|
131
|
+
try {
|
|
132
|
+
const reply = await this.workspace.applyCheckout(context.window, name, branch);
|
|
133
|
+
if (reply.conflicts.length > 0) {
|
|
134
|
+
const conflicts = reply.conflicts
|
|
135
|
+
.map((conflict) => `${conflict.path}(${conflict.reason})`)
|
|
136
|
+
.join("\n");
|
|
137
|
+
return this.ok(request, textOf(this.table, "result.workspace.conflict", { name, conflicts }));
|
|
138
|
+
}
|
|
139
|
+
if (branch !== undefined) {
|
|
140
|
+
return this.ok(request, textOf(this.table, "result.workspace.apply.branch", { name, branch, commit: reply.commit ?? "" }));
|
|
141
|
+
}
|
|
142
|
+
return this.ok(request, textOf(this.table, "result.workspace.apply", {
|
|
143
|
+
name,
|
|
144
|
+
applied: reply.applied.length,
|
|
145
|
+
merged: reply.merged.length,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return this.refusal(request, "refused.workspace.cli", { message: messageOf(error) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async rm(request) {
|
|
153
|
+
const name = stringArg(request.args, "name");
|
|
154
|
+
try {
|
|
155
|
+
await this.workspace.removeCheckout(name);
|
|
156
|
+
return this.ok(request, textOf(this.table, "result.workspace.rm", { name }));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return this.refusal(request, "refused.workspace.cli", { message: messageOf(error) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async path(request) {
|
|
163
|
+
const window = windowArg(request.args.window) ?? "c0";
|
|
164
|
+
const checkouts = await this.workspace.checkouts(window);
|
|
165
|
+
if (checkouts.length === 0) {
|
|
166
|
+
return this.ok(request, textOf(this.table, "result.workspace.path.empty", { window }));
|
|
167
|
+
}
|
|
168
|
+
const lines = checkouts.map((one) => `${one.name} ${one.path}`).join("\n");
|
|
169
|
+
return this.ok(request, textOf(this.table, "result.workspace.path", { window, lines }));
|
|
170
|
+
}
|
|
171
|
+
ok(request, content) {
|
|
172
|
+
return { callId: request.callId, ok: true, content };
|
|
173
|
+
}
|
|
174
|
+
refusal(request, key, args) {
|
|
175
|
+
return {
|
|
176
|
+
callId: request.callId,
|
|
177
|
+
ok: false,
|
|
178
|
+
content: "",
|
|
179
|
+
error: {
|
|
180
|
+
rule: key,
|
|
181
|
+
message: textOf(this.table, key, args, key),
|
|
182
|
+
nextAction: textOf(this.table, key.replace("refused.", "next."), args) || undefined,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
notOwned(request) {
|
|
187
|
+
return {
|
|
188
|
+
callId: request.callId,
|
|
189
|
+
ok: false,
|
|
190
|
+
content: "",
|
|
191
|
+
error: {
|
|
192
|
+
rule: "module.function.unknown",
|
|
193
|
+
message: textOf(this.table, "refused.unknown_function", {
|
|
194
|
+
module: "workspace",
|
|
195
|
+
name: request.name,
|
|
196
|
+
}),
|
|
197
|
+
nextAction: textOf(this.table, "next.unknown_function"),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function stringArg(args, name) {
|
|
203
|
+
const value = args[name];
|
|
204
|
+
return typeof value === "string" ? value : "";
|
|
205
|
+
}
|
|
206
|
+
function windowArg(value) {
|
|
207
|
+
return typeof value === "string" && /^c\d+$/.test(value) ? value : undefined;
|
|
208
|
+
}
|
|
209
|
+
function messageOf(error) {
|
|
210
|
+
return error instanceof Error ? error.message : String(error);
|
|
211
|
+
}
|