pi-onlyne 1.2.1 → 2.0.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/README.md +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -395
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/config.test.mjs +0 -8
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +110 -30
- package/src/pi-surface.test.mjs +232 -0
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -69
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The other question a background extension makes necessary: is work this
|
|
2
|
+
// session dispatched through the `Agent` tool still running where the agent loop
|
|
3
|
+
// cannot see it?
|
|
4
|
+
//
|
|
5
|
+
// The subagents extension is not one of the `pi-background-tasks` family, and
|
|
6
|
+
// its `Agent` call is not one of those tools. A field test on one session showed
|
|
7
|
+
// all five `Agent` calls issued in a single assistant message, each returning at
|
|
8
|
+
// once with `Agent started in background` and an output file under
|
|
9
|
+
// `/tmp/pi-subagents-<uid>/` — so the tool hands back a handle and the child
|
|
10
|
+
// carries on, and no `tool_execution_start` / `tool_execution_end` pair brackets
|
|
11
|
+
// it. The tool is not pi's own either: the string does not appear in pi's dist,
|
|
12
|
+
// and pi's extension API exposes no subagent or background-task query at all,
|
|
13
|
+
// only `getActiveTools` / `getAllTools` / `setActiveTools`. So there is no event
|
|
14
|
+
// to subscribe to and no frame to ask for; what the extension leaves behind is
|
|
15
|
+
// an on-disk registry at `~/.pi/subagents/missions/<uuid>.json`, and this probe
|
|
16
|
+
// reads that.
|
|
17
|
+
//
|
|
18
|
+
// Every failure mode is inert, exactly as in `background-work.mjs`: no
|
|
19
|
+
// directory, an unreadable file, a missing field, malformed JSON, or a read that
|
|
20
|
+
// throws all read as "no subagent work known" and leave the plugin's own
|
|
21
|
+
// judgement untouched. A probe that throws is worse than one that answers
|
|
22
|
+
// nothing.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mission statuses that mean the work is over.
|
|
30
|
+
*
|
|
31
|
+
* The live spelling was never observed. The machine this was measured on had
|
|
32
|
+
* four missions, all of them terminal (`completed`, `failed`, `cancelled` for
|
|
33
|
+
* missions; `completed`, `failed` for `workflowChildren`; `stopped`, `complete`,
|
|
34
|
+
* `failed` for `runs[].status`), so the running value is unenumerated. The safe
|
|
35
|
+
* direction is therefore "anything not on this list is live": guessing the other
|
|
36
|
+
* way would read a genuinely running session as idle, which is the exact bug
|
|
37
|
+
* this probe exists to fix. The cost of being wrong this way is a session that
|
|
38
|
+
* waits a little longer than it had to — a new terminal spelling delays the
|
|
39
|
+
* phase, it does not lose work.
|
|
40
|
+
*/
|
|
41
|
+
export const TERMINAL_MISSION_STATUSES = Object.freeze([
|
|
42
|
+
"cancelled",
|
|
43
|
+
"complete",
|
|
44
|
+
"completed",
|
|
45
|
+
"failed",
|
|
46
|
+
"stopped",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How many mission files one call will open. The registry is one JSON object per
|
|
51
|
+
* mission and grows without bound over a machine's life, so an uncapped scan
|
|
52
|
+
* would make the probe cost a function of history while the caller only ever
|
|
53
|
+
* cares about the present. The cap trades that for a bounded read: a session
|
|
54
|
+
* whose live work sits past the cap is reported as not running, the same fail-safe
|
|
55
|
+
* direction as every other answer here, and the newest files are read first
|
|
56
|
+
* because those are the ones just dispatched.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_MAX_MISSION_FILES = 200;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How many bytes one mission file may be before it is skipped. A mission
|
|
62
|
+
* transcript, not a mission, is the large artefact; anything past this is not
|
|
63
|
+
* the status record this probe reads.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_MAX_MISSION_BYTES = 256 * 1024;
|
|
66
|
+
|
|
67
|
+
/** @param {unknown} status */
|
|
68
|
+
export function isTerminalMissionStatus(status) {
|
|
69
|
+
return typeof status === "string" && TERMINAL_MISSION_STATUSES.includes(status);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} mission
|
|
74
|
+
* @param {string | null} sessionId this session's id, or `null` when unknown
|
|
75
|
+
*/
|
|
76
|
+
export function isLiveMission(mission, sessionId = null) {
|
|
77
|
+
if (!mission || typeof mission !== "object") return false;
|
|
78
|
+
// A mission with no status is not live: absence of evidence is absence of
|
|
79
|
+
// work, the same direction `background-work.mjs` fails safe in.
|
|
80
|
+
if (typeof mission.status !== "string" || !mission.status) return false;
|
|
81
|
+
if (isTerminalMissionStatus(mission.status)) return false;
|
|
82
|
+
const owner = mission.ownerSessionId;
|
|
83
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
84
|
+
// Scope by owner so the answer is "is *this* session's work still running",
|
|
85
|
+
// not "is anything on this machine running".
|
|
86
|
+
return typeof owner === "string" && owner === sessionId;
|
|
87
|
+
}
|
|
88
|
+
// The caller cannot name its own session, so a mission naming no session still
|
|
89
|
+
// counts: watching a silent subset would be worse than watching too much.
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Newest first, so a capped scan spends its budget on the missions just dispatched. */
|
|
94
|
+
function byNewestFirst(left, right) {
|
|
95
|
+
const a = right.mtimeMs ?? 0;
|
|
96
|
+
const b = left.mtimeMs ?? 0;
|
|
97
|
+
if (a !== b) return a - b;
|
|
98
|
+
return String(right.name).localeCompare(String(left.name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @returns {string[]} the `.json` mission files, newest first and already capped. */
|
|
102
|
+
function missionFiles(directory, maxFiles) {
|
|
103
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
104
|
+
return entries
|
|
105
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
106
|
+
.map((entry) => {
|
|
107
|
+
let mtimeMs = 0;
|
|
108
|
+
try {
|
|
109
|
+
mtimeMs = fs.statSync(path.join(directory, entry.name)).mtimeMs;
|
|
110
|
+
} catch {
|
|
111
|
+
/* an entry that will not stat sorts as oldest and may be cut by the cap */
|
|
112
|
+
}
|
|
113
|
+
return { name: entry.name, mtimeMs };
|
|
114
|
+
})
|
|
115
|
+
.sort(byNewestFirst)
|
|
116
|
+
.slice(0, maxFiles)
|
|
117
|
+
.map((entry) => path.join(directory, entry.name));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readMission(file, maxBytes) {
|
|
121
|
+
if (fs.statSync(file).size > maxBytes) return null;
|
|
122
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One live-mission question, asked of the on-disk registry and answered by
|
|
127
|
+
* whatever the subagents extension left there. Inert by construction: a throw
|
|
128
|
+
* anywhere inside resolves to `false`.
|
|
129
|
+
*
|
|
130
|
+
* @param {{
|
|
131
|
+
* homeDir?: string,
|
|
132
|
+
* getSessionId?: () => string | null,
|
|
133
|
+
* log?: (line: string) => void,
|
|
134
|
+
* maxFiles?: number,
|
|
135
|
+
* maxBytes?: number,
|
|
136
|
+
* }} options
|
|
137
|
+
*/
|
|
138
|
+
export function createSubagentProbe({
|
|
139
|
+
homeDir = os.homedir(),
|
|
140
|
+
getSessionId = null,
|
|
141
|
+
log = () => {},
|
|
142
|
+
maxFiles = DEFAULT_MAX_MISSION_FILES,
|
|
143
|
+
maxBytes = DEFAULT_MAX_MISSION_BYTES,
|
|
144
|
+
} = {}) {
|
|
145
|
+
const warned = new Set();
|
|
146
|
+
|
|
147
|
+
const warnOnce = (reason) => {
|
|
148
|
+
if (warned.has(reason)) return;
|
|
149
|
+
warned.add(reason);
|
|
150
|
+
log(`subagent work: ${reason}`);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const sessionId = () => {
|
|
154
|
+
if (typeof getSessionId !== "function") return null;
|
|
155
|
+
return getSessionId() ?? null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @returns {Promise<boolean>}
|
|
160
|
+
*/
|
|
161
|
+
function running() {
|
|
162
|
+
try {
|
|
163
|
+
const directory = path.join(homeDir, ".pi", "subagents", "missions");
|
|
164
|
+
if (!fs.existsSync(directory)) {
|
|
165
|
+
// No registry at all means the extension has never run here; that is a
|
|
166
|
+
// steady state rather than a fault, so it is recorded once.
|
|
167
|
+
warnOnce("no subagent registry in this home");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
for (const file of missionFiles(directory, maxFiles)) {
|
|
171
|
+
let mission = null;
|
|
172
|
+
try {
|
|
173
|
+
mission = readMission(file, maxBytes);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
warnOnce(`mission unreadable: ${error.message}`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (mission === null) {
|
|
179
|
+
warnOnce("mission record too large to be a status record");
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (isLiveMission(mission, sessionId())) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
warnOnce(`registry unreadable: ${error.message}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { running };
|
|
192
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { createSubagentProbe } from "./background-subagents.mjs";
|
|
8
|
+
|
|
9
|
+
const SESSION = "s-ours";
|
|
10
|
+
const OTHER = "s-theirs";
|
|
11
|
+
|
|
12
|
+
/** A throwaway home with a missions registry, as the extension leaves it. */
|
|
13
|
+
function homeWith(files) {
|
|
14
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "onlyne-subagents-"));
|
|
15
|
+
const missions = path.join(homeDir, ".pi", "subagents", "missions");
|
|
16
|
+
fs.mkdirSync(missions, { recursive: true });
|
|
17
|
+
for (const [name, body] of Object.entries(files)) {
|
|
18
|
+
fs.writeFileSync(path.join(missions, name), typeof body === "string" ? body : JSON.stringify(body));
|
|
19
|
+
}
|
|
20
|
+
return { homeDir, missions };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function probeFor(homeDir, sessionId = SESSION) {
|
|
24
|
+
const logs = [];
|
|
25
|
+
const probe = createSubagentProbe({
|
|
26
|
+
homeDir,
|
|
27
|
+
getSessionId: () => sessionId,
|
|
28
|
+
log: (line) => logs.push(line),
|
|
29
|
+
});
|
|
30
|
+
return { logs, probe };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("a registry of terminal missions reads as not running", async () => {
|
|
34
|
+
const { homeDir } = homeWith({
|
|
35
|
+
"a.json": { id: "a", status: "completed", ownerSessionId: SESSION },
|
|
36
|
+
"b.json": { id: "b", status: "failed", ownerSessionId: SESSION },
|
|
37
|
+
"c.json": { id: "c", status: "cancelled", ownerSessionId: SESSION },
|
|
38
|
+
});
|
|
39
|
+
const { probe } = probeFor(homeDir);
|
|
40
|
+
|
|
41
|
+
assert.equal(await probe.running(), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a mission whose status is not terminal reads as running", async () => {
|
|
45
|
+
// The live spelling was never observed, so any unrecognised value is treated as
|
|
46
|
+
// live; that is the safe direction, and this is what it looks like in a test.
|
|
47
|
+
const { homeDir } = homeWith({
|
|
48
|
+
"a.json": { id: "a", status: "running", ownerSessionId: SESSION },
|
|
49
|
+
"b.json": { id: "b", status: "something-new", ownerSessionId: SESSION },
|
|
50
|
+
});
|
|
51
|
+
const { probe } = probeFor(homeDir);
|
|
52
|
+
|
|
53
|
+
assert.equal(await probe.running(), true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a mission owned by this session reads as running and another session's does not", async () => {
|
|
57
|
+
const theirs = homeWith({ "a.json": { id: "a", status: "running", ownerSessionId: OTHER } });
|
|
58
|
+
const { probe: other } = probeFor(theirs.homeDir, SESSION);
|
|
59
|
+
assert.equal(await other.running(), false, "another session's live mission is not this session's work");
|
|
60
|
+
|
|
61
|
+
const ours = homeWith({ "a.json": { id: "a", status: "running", ownerSessionId: SESSION } });
|
|
62
|
+
const { probe: mine } = probeFor(ours.homeDir, SESSION);
|
|
63
|
+
assert.equal(await mine.running(), true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a caller that cannot name its own session counts a mission naming none", async () => {
|
|
67
|
+
const { homeDir } = homeWith({ "a.json": { id: "a", status: "running" } });
|
|
68
|
+
const { probe } = probeFor(homeDir, null);
|
|
69
|
+
|
|
70
|
+
assert.equal(await probe.running(), true, "watching a silent subset is worse than watching too much");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a missing registry reads as not running and does not throw", async () => {
|
|
74
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "onlyne-subagents-empty-"));
|
|
75
|
+
const { logs, probe } = probeFor(homeDir);
|
|
76
|
+
|
|
77
|
+
assert.equal(await probe.running(), false);
|
|
78
|
+
assert.equal(logs.length, 1, "the missing registry is said once");
|
|
79
|
+
assert.equal(await probe.running(), false);
|
|
80
|
+
assert.equal(logs.length, 1, "and not repeated on the next call");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a missions path that is a file rather than a directory reads as not running", async () => {
|
|
84
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "onlyne-subagents-file-"));
|
|
85
|
+
const missions = path.join(homeDir, ".pi", "subagents");
|
|
86
|
+
fs.mkdirSync(missions, { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(missions, "missions"), "not a directory");
|
|
88
|
+
const { probe } = probeFor(homeDir);
|
|
89
|
+
|
|
90
|
+
assert.equal(await probe.running(), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("an unreadable mission file reads as not running and does not throw", async () => {
|
|
94
|
+
const { homeDir, missions } = homeWith({ "a.json": { id: "a", status: "completed" } });
|
|
95
|
+
fs.writeFileSync(path.join(missions, "b.json"), "{ not json");
|
|
96
|
+
fs.mkdirSync(path.join(missions, "c.json")); // a directory where a file is expected
|
|
97
|
+
fs.chmodSync(missions, 0o000);
|
|
98
|
+
const { probe } = probeFor(homeDir);
|
|
99
|
+
try {
|
|
100
|
+
assert.equal(await probe.running(), false);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.chmodSync(missions, 0o755);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a mission with no status reads as not running", async () => {
|
|
107
|
+
const { homeDir } = homeWith({
|
|
108
|
+
"a.json": { id: "a", ownerSessionId: SESSION },
|
|
109
|
+
"b.json": { id: "b", status: null, ownerSessionId: SESSION },
|
|
110
|
+
"c.json": { id: "c", status: 42, ownerSessionId: SESSION },
|
|
111
|
+
});
|
|
112
|
+
const { probe } = probeFor(homeDir);
|
|
113
|
+
|
|
114
|
+
assert.equal(await probe.running(), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("malformed JSON in a live-looking registry still answers", async () => {
|
|
118
|
+
const { homeDir, missions } = homeWith({ "a.json": "<<<not json>>>" });
|
|
119
|
+
fs.writeFileSync(path.join(missions, "b.json"), JSON.stringify({ status: "running", ownerSessionId: SESSION }));
|
|
120
|
+
const { probe } = probeFor(homeDir);
|
|
121
|
+
|
|
122
|
+
assert.equal(await probe.running(), true, "one bad file does not hide a live mission");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a mission larger than the byte bound is skipped, and the rest of the registry still answers", async () => {
|
|
126
|
+
const { homeDir, missions } = homeWith({
|
|
127
|
+
"a.json": { id: "a", status: "completed", ownerSessionId: SESSION, summary: "x".repeat(4096) },
|
|
128
|
+
});
|
|
129
|
+
fs.writeFileSync(path.join(missions, "huge.json"), JSON.stringify({
|
|
130
|
+
status: "running",
|
|
131
|
+
ownerSessionId: SESSION,
|
|
132
|
+
transcript: "x".repeat(300 * 1024),
|
|
133
|
+
}));
|
|
134
|
+
const { logs, probe } = probeFor(homeDir);
|
|
135
|
+
|
|
136
|
+
assert.equal(await probe.running(), false);
|
|
137
|
+
assert.equal(logs.some((line) => line.includes("too large")), true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a capped read spends its budget on the newest missions", async () => {
|
|
141
|
+
// The cap is asserted by which file the probe read, so each case puts the live
|
|
142
|
+
// mission on one side of the cap and the terminal one on the other.
|
|
143
|
+
const registry = {
|
|
144
|
+
"old.json": { id: "old", status: "running", ownerSessionId: SESSION },
|
|
145
|
+
"new.json": { id: "new", status: "completed", ownerSessionId: SESSION },
|
|
146
|
+
};
|
|
147
|
+
const { homeDir, missions } = homeWith(registry);
|
|
148
|
+
const old = new Date(Date.now() - 60_000);
|
|
149
|
+
fs.utimesSync(path.join(missions, "old.json"), old, old);
|
|
150
|
+
const capped = createSubagentProbe({ homeDir, getSessionId: () => SESSION, maxFiles: 1 });
|
|
151
|
+
|
|
152
|
+
// Only the newer file is inside the cap, and it is terminal.
|
|
153
|
+
assert.equal(await capped.running(), false);
|
|
154
|
+
|
|
155
|
+
// The same cap, the same two files, with the newer one now live: the answer
|
|
156
|
+
// flips only because the newer file is the one that got read.
|
|
157
|
+
fs.writeFileSync(
|
|
158
|
+
path.join(missions, "new.json"),
|
|
159
|
+
JSON.stringify({ id: "new", status: "running", ownerSessionId: SESSION }),
|
|
160
|
+
);
|
|
161
|
+
assert.equal(await createSubagentProbe({
|
|
162
|
+
homeDir,
|
|
163
|
+
getSessionId: () => SESSION,
|
|
164
|
+
maxFiles: 1,
|
|
165
|
+
}).running(), true);
|
|
166
|
+
});
|
package/src/config.mjs
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// generate-time template advice), so the only consumer is this extension. A
|
|
7
7
|
// malformed or missing file falls back to the defaults and reports a warning
|
|
8
8
|
// instead of disabling the session: the extension's own `enabled` key is the one
|
|
9
|
-
// deliberate off switch.
|
|
10
|
-
// say — keeps the one default it names and leaves the rest of the file alone.
|
|
9
|
+
// deliberate off switch.
|
|
11
10
|
|
|
12
11
|
import { readFileSync } from "node:fs";
|
|
13
12
|
import { join } from "node:path";
|
|
@@ -15,18 +14,10 @@ import { join } from "node:path";
|
|
|
15
14
|
/** Where the switch file lives, relative to the pi working directory. */
|
|
16
15
|
export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
* How many idle reminders one task may collect before the ladder fails it
|
|
20
|
-
* (`agent.mjs` `settleNow`): two, so the third idle without a completion is the
|
|
21
|
-
* failure.
|
|
22
|
-
*/
|
|
23
|
-
export const DEFAULT_IDLE_REMINDERS = 2;
|
|
24
|
-
|
|
25
|
-
/** Defaults: on, connecting as soon as a session starts, and the idle bound. */
|
|
17
|
+
/** Defaults: on, and connecting as soon as a session starts. */
|
|
26
18
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
27
19
|
enabled: true,
|
|
28
20
|
autoStart: true,
|
|
29
|
-
idleReminders: DEFAULT_IDLE_REMINDERS,
|
|
30
21
|
});
|
|
31
22
|
|
|
32
23
|
/**
|
|
@@ -34,7 +25,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
34
25
|
*
|
|
35
26
|
* @param {string} cwd
|
|
36
27
|
* @param {{ readFile?: (path: string) => string }} [options]
|
|
37
|
-
* @returns {{ enabled: boolean, autoStart: boolean,
|
|
28
|
+
* @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
|
|
38
29
|
*/
|
|
39
30
|
export function loadConfig(cwd, options = {}) {
|
|
40
31
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
@@ -60,21 +51,11 @@ export function loadConfig(cwd, options = {}) {
|
|
|
60
51
|
return { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
|
|
61
52
|
}
|
|
62
53
|
const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
|
|
63
|
-
// The bound is a count, so only a non-negative integer is a value: a string,
|
|
64
|
-
// a fraction or a negative would either count nothing or count forever.
|
|
65
|
-
// Zero is a value — it says the first idle without a completion is already
|
|
66
|
-
// the failure — and it is the operator's call to make.
|
|
67
|
-
const idleReminders = parsed.idleReminders;
|
|
68
|
-
const usable = Number.isInteger(idleReminders) && idleReminders >= 0;
|
|
69
54
|
return {
|
|
70
55
|
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
|
|
71
56
|
autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
|
|
72
|
-
idleReminders: usable ? idleReminders : DEFAULT_CONFIG.idleReminders,
|
|
73
57
|
path,
|
|
74
|
-
warning:
|
|
75
|
-
idleReminders !== undefined && !usable
|
|
76
|
-
? `${path} idleReminders must be a non-negative integer; using ${DEFAULT_CONFIG.idleReminders}`
|
|
77
|
-
: null,
|
|
58
|
+
warning: null,
|
|
78
59
|
present: true,
|
|
79
60
|
};
|
|
80
61
|
}
|
package/src/config.test.mjs
CHANGED
|
@@ -8,8 +8,6 @@ import assert from "node:assert/strict";
|
|
|
8
8
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
11
|
import { afterEach, test } from "node:test";
|
|
14
12
|
|
|
15
13
|
import { CONFIG_RELATIVE_PATH, DEFAULT_CONFIG, loadConfig, sessionIdentity } from "./config.mjs";
|
|
@@ -80,9 +78,3 @@ test("the session identity gate needs all three variables", () => {
|
|
|
80
78
|
assert.equal(sessionIdentity({}), null);
|
|
81
79
|
assert.equal(sessionIdentity({ ONLYNE_ROLE: "", ONLYNE_SESSION_ID: "s", ONLYNE_TASK_ID: "t" }), null);
|
|
82
80
|
});
|
|
83
|
-
|
|
84
|
-
test("the shipped switch file documents only the keys the plugin reads", () => {
|
|
85
|
-
const path = fileURLToPath(new URL("../onlyne.json.example", import.meta.url));
|
|
86
|
-
const example = JSON.parse(readFileSync(path, "utf8"));
|
|
87
|
-
assert.deepEqual(example, { enabled: true, watch: { autoStart: true } });
|
|
88
|
-
});
|