pi-onlyne 1.1.1 → 1.1.2
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/README.md +9 -7
- package/README.zh.md +7 -4
- package/package.json +1 -1
- package/src/index.ts +4 -4
- package/src/socket.mjs +54 -0
- package/src/socket.test.mjs +79 -0
package/README.md
CHANGED
|
@@ -215,10 +215,10 @@ workspace, `relay.toml` in a manual installation.
|
|
|
215
215
|
|
|
216
216
|
```toml
|
|
217
217
|
relay_required = ["writer"] # these roles must have received a handoff
|
|
218
|
-
relay_required_count = 2 #
|
|
218
|
+
relay_required_count = 2 # legacy alias of relay_count: this many distinct downstream roles
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
-
`relay_required` wins when both keys are present.
|
|
221
|
+
`relay_count` is the canonical count key. `relay_required_count` is its legacy alias, the spelling `relay.toml` itself uses. `relay_required` wins when both the list and the count keys are present.
|
|
222
222
|
|
|
223
223
|
The policy belongs in the spec, not in the vendor directory. `onlyne generate --force`
|
|
224
224
|
rewrites the copy this package is vendored into and takes a hand-written `relay.toml`
|
|
@@ -228,8 +228,8 @@ every session process it spawns:
|
|
|
228
228
|
```toml
|
|
229
229
|
[[client]]
|
|
230
230
|
role = "planner"
|
|
231
|
+
relay_count = 2 # this many distinct downstream roles
|
|
231
232
|
relay_required = ["writer"] # these roles must have received a handoff
|
|
232
|
-
relay_count = 2 # ... or this many distinct downstream roles
|
|
233
233
|
```
|
|
234
234
|
|
|
235
235
|
The sources rank `environment > relay.toml > none`: `ONLYNE_RELAY_REQUIRED` (the list,
|
|
@@ -329,7 +329,7 @@ the shipped client.
|
|
|
329
329
|
| `ONLYNE_ROLE` | yes | the mount role |
|
|
330
330
|
| `ONLYNE_SESSION_ID` | yes | mounted session id; `session_id` equals `task_id` in the shipped client |
|
|
331
331
|
| `ONLYNE_TASK_ID` | yes | the task this process serves; drives `session_register` and the initial `ready` |
|
|
332
|
-
| `ONLYNE_SOCKET` | no |
|
|
332
|
+
| `ONLYNE_SOCKET` | no | the socket the client serves for this workspace, injected into every session process it spawns; with the variable unset the plugin reads the marker `<cwd>/.onlyne/run/socket` for the path the daemon published, and falls back to `<cwd>/.onlyne/run/s` |
|
|
333
333
|
| `ONLYNE_RELAY_REQUIRED` | no | the role's spec `relay_required`, comma-joined: the guard's list mode (§5) |
|
|
334
334
|
| `ONLYNE_RELAY_COUNT` | no | the role's spec `relay_count`: the guard's count mode, which decides only when the list is empty (§5) |
|
|
335
335
|
| `ORCA_PANE_KEY` | no | where this process runs (`<tab_id>:<leaf_id>`), reported on every heartbeat as `observed.host.orca.pane_key`; unset outside an Orca pane, which is why the field is then absent |
|
|
@@ -339,9 +339,10 @@ the shipped client.
|
|
|
339
339
|
Constants worth knowing: the plugin heartbeats every 10 s (`heartbeat_timeout_ms` is 30 s),
|
|
340
340
|
allows 5 s for `hello` and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.
|
|
341
341
|
|
|
342
|
-
The plugin reads
|
|
342
|
+
The plugin reads three files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1),
|
|
343
343
|
`relay.toml` next to its `package.json` (the relay policy's fallback, read only when the
|
|
344
|
-
client injected none, §5)
|
|
344
|
+
client injected none, §5), and `<cwd>/.onlyne/run/socket` (the marker naming the socket
|
|
345
|
+
path the client's daemon bound, read when the environment carried none, §8).
|
|
345
346
|
|
|
346
347
|
## 8. Troubleshooting
|
|
347
348
|
|
|
@@ -349,6 +350,7 @@ client injected none, §5).
|
|
|
349
350
|
| --- | --- | --- |
|
|
350
351
|
| `[pi-onlyne] session …` never appears | one of the three env vars is missing, or `enabled` is false | `env \| grep ONLYNE_`; `cat .pi/onlyne.json` |
|
|
351
352
|
| `socket error: connect ENOENT …/.onlyne/run/s` | no `onlyne-client run` for this workspace | start the client, or `onlyne-client status` |
|
|
353
|
+
| `socket error: connect EINVAL …/.onlyne/run/s` on a deep workspace | macOS gives `sun_path` 104 bytes, so a socket path past 103 is refused; a generated role workspace nests three levels under its server root and a long root carries the canonical spelling over the bound. The client serves such a workspace from a short path under the temporary directory and publishes it in `<workspace>/.onlyne/run/socket` | `onlyne-client status` for the line `onlyne: client running … socket <path>`, which names the served path, plus the client log line carrying `socket = <path>`; `cat <workspace>/.onlyne/run/socket` holds that same path, and the plugin dials it when the environment injected nothing |
|
|
352
354
|
| `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
|
|
353
355
|
| `ready refused: internal: unknown session for …` | the plugin mounted and reported for a task the client never staged (normal when pi is started by hand outside a task) | start pi under the client, not by hand |
|
|
354
356
|
| `assign` never arrives | the client's `session_command` did not spawn pi, or `inject` was dropped | the client log for the spawn line; `/onlyne status` for the capability set |
|
|
@@ -368,7 +370,7 @@ client injected none, §5).
|
|
|
368
370
|
|
|
369
371
|
```bash
|
|
370
372
|
cd plugins/onlyne-agent-pi
|
|
371
|
-
node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
|
|
373
|
+
node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard, socket path
|
|
372
374
|
```
|
|
373
375
|
|
|
374
376
|
`src/agent.live.test.mjs` skips itself unless `target/debug/onlyne-client` and
|
package/README.zh.md
CHANGED
|
@@ -284,7 +284,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
284
284
|
| `ONLYNE_ROLE` | 是 | 挂载的 role |
|
|
285
285
|
| `ONLYNE_SESSION_ID` | 是 | 挂载的 session id;当前 client 中 session_id 等于 task_id |
|
|
286
286
|
| `ONLYNE_TASK_ID` | 是 | 本进程服务的任务;驱动 `session_register` 与首条 `ready` |
|
|
287
|
-
| `ONLYNE_SOCKET` | 否 |
|
|
287
|
+
| `ONLYNE_SOCKET` | 否 | client 为该工作区实际服务的 socket 路径;凡 client 拉起的会话进程都会带上。变量未设置时,插件读标记文件 `<cwd>/.onlyne/run/socket`,取守护进程发布的那个路径,随后落到 `<cwd>/.onlyne/run/s` |
|
|
288
288
|
| `ONLYNE_RELAY_REQUIRED` | 否 | 该 role 在 spec 里的 `relay_required`,逗号分隔:守卫的名单模式(§5) |
|
|
289
289
|
| `ONLYNE_RELAY_COUNT` | 否 | 该 role 在 spec 里的 `relay_count`:守卫的 count 模式,只在名单为空时起作用(§5) |
|
|
290
290
|
| `ORCA_PANE_KEY` | 否 | 本进程跑在哪(`<tab_id>:<leaf_id>`),每个 heartbeat 以 `observed.host.orca.pane_key` 上报;不在 Orca pane 里时未设置,这也是该字段缺席的原因 |
|
|
@@ -294,8 +294,10 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
294
294
|
值得记住的常量:插件每 10 秒发一次心跳(`heartbeat_timeout_ms` 是 30 秒),`hello` 最多等
|
|
295
295
|
5 秒,单次请求超时 30 秒,重连按 1/2/4/8/16/30 秒阶梯退避。
|
|
296
296
|
|
|
297
|
-
|
|
298
|
-
`relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5
|
|
297
|
+
插件自己读三个文件:`<cwd>/.pi/onlyne.json`(开关,§1)、`package.json` 旁边的
|
|
298
|
+
`relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5)、
|
|
299
|
+
`<cwd>/.onlyne/run/socket`(标记文件,写明 client 守护进程绑定的 socket 路径,只在环境里
|
|
300
|
+
没带路径时才读,§8)。
|
|
299
301
|
|
|
300
302
|
## 8. 故障排查
|
|
301
303
|
|
|
@@ -303,6 +305,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
303
305
|
| --- | --- | --- |
|
|
304
306
|
| 看不到 `[pi-onlyne] session …` | 三个环境变量缺一,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
|
|
305
307
|
| `socket error: connect ENOENT …/.onlyne/run/s` | 该工作区没有 `onlyne-client run` | 起 client,或 `onlyne-client status` |
|
|
308
|
+
| 深层工作区里 `socket error: connect EINVAL …/.onlyne/run/s` | macOS 的 `sun_path` 只有 104 字节,超过 103 的 socket 路径会被内核拒绝;生成的 role 工作区在 server root 下再套三层,root 一长,规范写法就越过这个上界。client 面对这种工作区会把 socket 放到临时目录下的短路径上服务,并把选中的路径发布进 `<workspace>/.onlyne/run/socket` | 看 `onlyne-client status` 打印的 `onlyne: client running … socket <路径>`,那一行点出实际服务的路径,再看 client 日志里带 `socket = <路径>` 的那行;`cat <workspace>/.onlyne/run/socket` 得到同一个路径——环境里没带变量时,插件拨的就是它 |
|
|
306
309
|
| 反复 `reconnecting in 4000ms` | client 已停或 socket 被替换 | `onlyne --server-root … roles` |
|
|
307
310
|
| `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
|
|
308
311
|
| `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
|
|
@@ -322,7 +325,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
322
325
|
|
|
323
326
|
```bash
|
|
324
327
|
cd plugins/onlyne-agent-pi
|
|
325
|
-
node --test src/*.test.mjs # 帧编解码、协议词汇、agent
|
|
328
|
+
node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫、socket 路径
|
|
326
329
|
```
|
|
327
330
|
|
|
328
331
|
`src/agent.live.test.mjs` 只在 `target/debug/onlyne-client` 与 `onlyne-server` 存在时运行。
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { Type } from "typebox";
|
|
|
20
20
|
import { OnlyneAgent } from "./agent.mjs";
|
|
21
21
|
import { loadConfig, sessionIdentity } from "./config.mjs";
|
|
22
22
|
import { loadRelay, relayEnabled } from "./relay.mjs";
|
|
23
|
+
import { resolveSocketPath } from "./socket.mjs";
|
|
23
24
|
import { createSurface } from "./pi-surface.mjs";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -32,9 +33,6 @@ declare const process: {
|
|
|
32
33
|
stderr: { write(chunk: string): void };
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
/** Socket every role workspace serves; `crates/onlyne-client/src/adapter_socket.rs`. */
|
|
36
|
-
const SOCKET_RELATIVE_PATH = ".onlyne/run/s";
|
|
37
|
-
|
|
38
36
|
/** One image part handed to the pi message surface. */
|
|
39
37
|
interface ImagePartInput {
|
|
40
38
|
mime: string;
|
|
@@ -235,7 +233,9 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
235
233
|
log(`disabled by ${config.path}`);
|
|
236
234
|
return;
|
|
237
235
|
}
|
|
238
|
-
|
|
236
|
+
// Environment first (the client injects the path it serves), then the
|
|
237
|
+
// marker the daemon publishes, then the canonical `run/s` (socket.mjs).
|
|
238
|
+
const socketPath = resolveSocketPath(env, ctx.cwd);
|
|
239
239
|
// The guard's policy comes from the spec through the client's environment;
|
|
240
240
|
// a hand-written `relay.toml` beside the package is the fallback a manual
|
|
241
241
|
// installation still has (relay.mjs).
|
package/src/socket.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The adapter socket a pi session dials.
|
|
2
|
+
//
|
|
3
|
+
// macOS gives `sun_path` 104 bytes, so the kernel refuses a unix socket path
|
|
4
|
+
// past 103 (`UNIX_SOCKET_PATH_MAX` in `crates/onlyne-layout/src/lib.rs`). A
|
|
5
|
+
// generated role workspace nests three levels below its server root
|
|
6
|
+
// (`<root>/.onlyne/ws/<topology>/<role>/.onlyne/run/s`), so a deep root carries
|
|
7
|
+
// the canonical spelling past that bound. The client answers by serving such a
|
|
8
|
+
// workspace from a short path under the temporary directory
|
|
9
|
+
// (`<temp>/onlyne-<16hex>/s`) and publishing the choice it bound in the marker
|
|
10
|
+
// file `<workspace>/.onlyne/run/socket`, one line holding the absolute served
|
|
11
|
+
// path. `onlyne-layout::SocketEndpoint::publish` writes that file; every reader
|
|
12
|
+
// in the product reaches one live socket through it.
|
|
13
|
+
//
|
|
14
|
+
// So the plugin has three answers in order, cheapest first: the path the client
|
|
15
|
+
// injected when it spawned this process (`ONLYNE_SOCKET`), the path the running
|
|
16
|
+
// daemon published in the marker, and the canonical spelling. The third one is
|
|
17
|
+
// a complete answer for every workspace short enough to serve from `run/s`,
|
|
18
|
+
// because `bind_socket` writes the marker at every start and such a tree
|
|
19
|
+
// publishes `run/s` in it; the two readings give one path. A marker that is
|
|
20
|
+
// missing, unreadable, or blank holds no published override, so resolution
|
|
21
|
+
// falls through silently. That keeps a hand-started pi in a workspace with a
|
|
22
|
+
// running daemon on the right socket with no environment at all.
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { isAbsolute, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
/** The canonical socket leaf every role workspace names; `SOCKET_FILE_NAME` in `crates/onlyne-layout/src/lib.rs`. */
|
|
28
|
+
export const SOCKET_RELATIVE_PATH = join(".onlyne", "run", "s");
|
|
29
|
+
|
|
30
|
+
/** Marker beside it naming the path the daemon actually serves. */
|
|
31
|
+
export const SOCKET_MARKER_RELATIVE_PATH = join(".onlyne", "run", "socket");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The socket path to dial for the workspace at `cwd`.
|
|
35
|
+
*
|
|
36
|
+
* @param {Record<string, string | undefined>} env the process environment
|
|
37
|
+
* @param {string} cwd the pi working directory, the role workspace itself
|
|
38
|
+
* @param {{ readFile?: (path: string) => string }} [options]
|
|
39
|
+
* @returns {string} an absolute path: the injected one, the published one, or `run/s`
|
|
40
|
+
*/
|
|
41
|
+
export function resolveSocketPath(env, cwd, options = {}) {
|
|
42
|
+
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
43
|
+
const injected = typeof env.ONLYNE_SOCKET === "string" ? env.ONLYNE_SOCKET.trim() : "";
|
|
44
|
+
if (injected) return injected;
|
|
45
|
+
const marker = join(cwd, SOCKET_MARKER_RELATIVE_PATH);
|
|
46
|
+
let published = "";
|
|
47
|
+
try {
|
|
48
|
+
published = readFile(marker).trim();
|
|
49
|
+
} catch {
|
|
50
|
+
published = "";
|
|
51
|
+
}
|
|
52
|
+
if (published && isAbsolute(published)) return published;
|
|
53
|
+
return join(cwd, SOCKET_RELATIVE_PATH);
|
|
54
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Which socket path a pi session dials.
|
|
2
|
+
//
|
|
3
|
+
// The three answers have to stay in one order: what the client injected, what
|
|
4
|
+
// the daemon published in `<run>/socket`, and the canonical `<run>/s`. A
|
|
5
|
+
// workspace deep enough that the canonical spelling passes macOS' 103-byte
|
|
6
|
+
// `sun_path` bound is served from a short path under the temporary directory,
|
|
7
|
+
// and the marker is the only place inside the tree that names it.
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterEach, test } from "node:test";
|
|
14
|
+
|
|
15
|
+
import { SOCKET_MARKER_RELATIVE_PATH, SOCKET_RELATIVE_PATH, resolveSocketPath } from "./socket.mjs";
|
|
16
|
+
|
|
17
|
+
const cleanups = [];
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
while (cleanups.length > 0) cleanups.pop()();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** One temp workspace whose `run/` directory exists, optionally with a marker body. */
|
|
23
|
+
function workspace(markerBody) {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
|
|
25
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
26
|
+
mkdirSync(join(dir, ".onlyne", "run"), { recursive: true });
|
|
27
|
+
if (markerBody !== undefined) writeFileSync(join(dir, SOCKET_MARKER_RELATIVE_PATH), markerBody);
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The short path a daemon serves an over-long workspace from. */
|
|
32
|
+
const SERVED = join(tmpdir(), "onlyne-0123456789abcdef", "s");
|
|
33
|
+
|
|
34
|
+
test("the injected environment variable decides first", () => {
|
|
35
|
+
const dir = workspace(`${SERVED}\n`);
|
|
36
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: SERVED }, dir), SERVED);
|
|
37
|
+
// Any other published answer loses to the environment.
|
|
38
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: join(dir, SOCKET_RELATIVE_PATH) }, dir), join(dir, SOCKET_RELATIVE_PATH));
|
|
39
|
+
// A value wrapped in space names the same socket.
|
|
40
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: ` ${SERVED} ` }, dir), SERVED);
|
|
41
|
+
|
|
42
|
+
// A variable holding nothing answers nothing, so the tree decides.
|
|
43
|
+
const bare = workspace();
|
|
44
|
+
assert.equal(resolveSocketPath({ ONLYNE_SOCKET: " " }, bare), join(bare, SOCKET_RELATIVE_PATH));
|
|
45
|
+
assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a published marker moves the session onto the served path", () => {
|
|
49
|
+
const dir = workspace(`${SERVED}\n`);
|
|
50
|
+
const resolved = resolveSocketPath({}, dir);
|
|
51
|
+
assert.equal(resolved, SERVED);
|
|
52
|
+
// The moved path lives outside the workspace, which is the whole point of the
|
|
53
|
+
// marker: the tree's own `run/s` would be refused by the kernel here.
|
|
54
|
+
assert.ok(!resolved.startsWith(dir));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a workspace with no marker keeps the canonical spelling", () => {
|
|
58
|
+
const dir = workspace();
|
|
59
|
+
assert.equal(resolveSocketPath({}, dir), join(dir, ".onlyne", "run", "s"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a marker that is empty, relative, or unreadable falls through silently", () => {
|
|
63
|
+
const empty = workspace("");
|
|
64
|
+
assert.equal(resolveSocketPath({}, empty), join(empty, SOCKET_RELATIVE_PATH));
|
|
65
|
+
|
|
66
|
+
const relative = workspace("run/s");
|
|
67
|
+
assert.equal(resolveSocketPath({}, relative), join(relative, SOCKET_RELATIVE_PATH));
|
|
68
|
+
|
|
69
|
+
// A `socket` leaf holding a directory makes the read itself fail.
|
|
70
|
+
const unreadable = workspace();
|
|
71
|
+
rmSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH), { force: true });
|
|
72
|
+
mkdirSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH));
|
|
73
|
+
assert.equal(resolveSocketPath({}, unreadable), join(unreadable, SOCKET_RELATIVE_PATH));
|
|
74
|
+
|
|
75
|
+
// A workspace with no `run/` at all still names one canonical path.
|
|
76
|
+
const bare = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
|
|
77
|
+
cleanups.push(() => rmSync(bare, { recursive: true, force: true }));
|
|
78
|
+
assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
|
|
79
|
+
});
|