mtech-ai-local-runner 0.1.47 → 0.1.48
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 +94 -2
- package/dist/src/asks/commands.js +100 -0
- package/dist/src/asks/doctor.js +106 -0
- package/dist/src/asks/hook-client.js +236 -0
- package/dist/src/asks/hub-protocol.js +97 -0
- package/dist/src/asks/hub.js +202 -0
- package/dist/src/asks/inbox.js +347 -0
- package/dist/src/asks/integrations.js +433 -0
- package/dist/src/asks/messenger.js +135 -0
- package/dist/src/asks/notifier.js +73 -0
- package/dist/src/asks/routing.js +239 -0
- package/dist/src/asks/runtime.js +198 -0
- package/dist/src/asks/shim.js +78 -0
- package/dist/src/asks/store.js +210 -0
- package/dist/src/asks/templates.js +44 -0
- package/dist/src/asks/types.js +64 -0
- package/dist/src/cli.js +90 -31
- package/dist/src/daemon/runtime.js +14 -3
- package/dist/src/gateway-client.js +18 -3
- package/dist/src/registration/local-config.js +7 -1
- package/dist/src/safety/local-edit-content.js +14 -0
- package/dist/src/safety/local-tool-policy.js +3 -0
- package/dist/src/safety/local-tool-server.js +2 -2
- package/dist/src/safety/local-tool-worker.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { chmod, lstat, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { HUB_SOCKET, HUB_TIMEOUT_MS, MAX_LINE_BYTES, MAX_SOCKET_PATH_BYTES, encodeLine, parseHubRequest, } from "./hub-protocol.js";
|
|
5
|
+
/*
|
|
6
|
+
* The daemon's local hub (Work Item e1c08b33, spec section 8.2). A Unix socket, mode 0600 in the 0700 state
|
|
7
|
+
* root, so only this user can reach it. It answers one request per connection and keeps a Claude Stop watcher's
|
|
8
|
+
* connection open until the handler wakes or releases it. It holds no ask state itself: the handler does.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The hook client gives up after HUB_TIMEOUT_MS and prints nothing. A request that has waited this long, a little less
|
|
12
|
+
* to leave room for the write, can no longer be answered in time: the hub changes nothing for it.
|
|
13
|
+
*/
|
|
14
|
+
export const HUB_ANSWER_WINDOW_MS = HUB_TIMEOUT_MS - 100;
|
|
15
|
+
export function hubSocketPath(stateRoot) {
|
|
16
|
+
return join(stateRoot, HUB_SOCKET);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* True when nothing more can be sent to, or read from, the client. The client never half-closes its side, so an
|
|
20
|
+
* ended read side means it left. The socket's own state is read because its 'close' event can come several turns of
|
|
21
|
+
* the event loop later.
|
|
22
|
+
*/
|
|
23
|
+
function hasLeft(socket) {
|
|
24
|
+
return socket.destroyed || !socket.writable || socket.readableEnded;
|
|
25
|
+
}
|
|
26
|
+
function waitedTooLong(arrivedAt) {
|
|
27
|
+
return performance.now() - arrivedAt > HUB_ANSWER_WINDOW_MS;
|
|
28
|
+
}
|
|
29
|
+
/** The client of an event request whose line arrived at `arrivedAt` (performance.now()). */
|
|
30
|
+
export function clientFor(socket, arrivedAt) {
|
|
31
|
+
return { get gone() { return hasLeft(socket) || waitedTooLong(arrivedAt); } };
|
|
32
|
+
}
|
|
33
|
+
/** The watcher for one watch connection. `answered` is true once anything was sent on it. */
|
|
34
|
+
export function watcherFor(socket, arrivedAt = performance.now()) {
|
|
35
|
+
let closeEvent = false;
|
|
36
|
+
let answered = false;
|
|
37
|
+
const listeners = [];
|
|
38
|
+
socket.once("close", () => {
|
|
39
|
+
closeEvent = true;
|
|
40
|
+
for (const listener of listeners.splice(0))
|
|
41
|
+
listener();
|
|
42
|
+
});
|
|
43
|
+
const closed = () => closeEvent || hasLeft(socket);
|
|
44
|
+
const send = (message, last) => {
|
|
45
|
+
if (closed())
|
|
46
|
+
return false;
|
|
47
|
+
socket.write(encodeLine(message));
|
|
48
|
+
answered = true;
|
|
49
|
+
if (last)
|
|
50
|
+
socket.end();
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
get closed() { return closed(); },
|
|
55
|
+
// Once something was sent the client has its answer; only a watch that is still unanswered can be too late.
|
|
56
|
+
get gone() { return closed() || (!answered && waitedTooLong(arrivedAt)); },
|
|
57
|
+
get answered() { return answered; },
|
|
58
|
+
hold: () => send({ v: 1, type: "watching" }, false),
|
|
59
|
+
wake: text => send({ v: 1, type: "wake", text }, true),
|
|
60
|
+
release: reason => { send({ v: 1, type: "release", reason }, true); },
|
|
61
|
+
onClose: listener => { if (closeEvent)
|
|
62
|
+
listener();
|
|
63
|
+
else
|
|
64
|
+
listeners.push(listener); },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** A socket left behind by a daemon that died is removed; anything else at that path is never touched. */
|
|
68
|
+
async function removeStaleSocket(path) {
|
|
69
|
+
try {
|
|
70
|
+
if (!(await lstat(path)).isSocket())
|
|
71
|
+
throw new Error("ASK_HUB_PATH_NOT_A_SOCKET");
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === "ENOENT")
|
|
75
|
+
return;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
await rm(path, { force: true });
|
|
79
|
+
}
|
|
80
|
+
export class AskHub {
|
|
81
|
+
options;
|
|
82
|
+
server = null;
|
|
83
|
+
sockets = new Set();
|
|
84
|
+
constructor(options) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
}
|
|
87
|
+
get listening() {
|
|
88
|
+
return this.server !== null;
|
|
89
|
+
}
|
|
90
|
+
async start() {
|
|
91
|
+
// A second listener would remove the live socket as if it were stale.
|
|
92
|
+
if (this.server !== null)
|
|
93
|
+
throw new Error("ASK_HUB_ALREADY_LISTENING");
|
|
94
|
+
const path = this.options.socketPath;
|
|
95
|
+
if (Buffer.byteLength(path) > MAX_SOCKET_PATH_BYTES)
|
|
96
|
+
throw new Error("ASK_HUB_SOCKET_PATH_TOO_LONG");
|
|
97
|
+
const directory = dirname(path);
|
|
98
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
99
|
+
await chmod(directory, 0o700);
|
|
100
|
+
await removeStaleSocket(path);
|
|
101
|
+
const server = createServer(socket => this.connection(socket));
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
server.once("error", reject);
|
|
104
|
+
server.listen(path, () => { server.off("error", reject); resolve(); });
|
|
105
|
+
});
|
|
106
|
+
// A listening server can still emit an error (accepting with no file descriptors left, say); without a listener Node
|
|
107
|
+
// would throw it and take the daemon down. Only its code is logged.
|
|
108
|
+
server.on("error", error => this.logServerError(error));
|
|
109
|
+
try {
|
|
110
|
+
// listen() creates the socket with the umask's mode (0755); only this user may connect.
|
|
111
|
+
await chmod(path, 0o600);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// Never leave a socket listening that is not private, nor a connection that was accepted on it.
|
|
115
|
+
for (const socket of this.sockets)
|
|
116
|
+
socket.destroy();
|
|
117
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
118
|
+
await rm(path, { force: true });
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
this.server = server;
|
|
122
|
+
}
|
|
123
|
+
async stop() {
|
|
124
|
+
const server = this.server;
|
|
125
|
+
this.server = null;
|
|
126
|
+
if (server === null)
|
|
127
|
+
return;
|
|
128
|
+
for (const socket of this.sockets)
|
|
129
|
+
socket.destroy();
|
|
130
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
131
|
+
await rm(this.options.socketPath, { force: true });
|
|
132
|
+
}
|
|
133
|
+
connection(socket) {
|
|
134
|
+
this.sockets.add(socket);
|
|
135
|
+
socket.once("close", () => this.sockets.delete(socket));
|
|
136
|
+
socket.on("error", () => undefined);
|
|
137
|
+
socket.setEncoding("utf8");
|
|
138
|
+
// A client that never sends a full line is dropped; a watch request clears the timer and stays open.
|
|
139
|
+
socket.setTimeout(this.options.requestTimeoutMs ?? 2000, () => socket.destroy());
|
|
140
|
+
let buffer = "";
|
|
141
|
+
const onData = (chunk) => {
|
|
142
|
+
buffer += chunk;
|
|
143
|
+
const newline = buffer.indexOf("\n");
|
|
144
|
+
// A line over the limit is dropped without an answer, whether or not its end has arrived yet.
|
|
145
|
+
if (Buffer.byteLength(newline < 0 ? buffer : buffer.slice(0, newline)) > MAX_LINE_BYTES) {
|
|
146
|
+
socket.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (newline < 0)
|
|
150
|
+
return;
|
|
151
|
+
const arrivedAt = performance.now();
|
|
152
|
+
socket.off("data", onData);
|
|
153
|
+
// Keep reading, so the client leaving is seen while its request waits.
|
|
154
|
+
socket.resume();
|
|
155
|
+
socket.setTimeout(0);
|
|
156
|
+
void this.dispatch(buffer.slice(0, newline), socket, arrivedAt);
|
|
157
|
+
};
|
|
158
|
+
socket.on("data", onData);
|
|
159
|
+
}
|
|
160
|
+
async dispatch(line, socket, arrivedAt) {
|
|
161
|
+
const reply = (response) => { if (!hasLeft(socket))
|
|
162
|
+
socket.end(encodeLine(response)); };
|
|
163
|
+
const request = parseHubRequest(line);
|
|
164
|
+
if (request === null) {
|
|
165
|
+
reply({ v: 1, ok: false, code: "invalid_request" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
if (request.op === "status") {
|
|
170
|
+
reply({ v: 1, ok: true, status: this.options.handler.status() });
|
|
171
|
+
}
|
|
172
|
+
else if (request.op === "event") {
|
|
173
|
+
reply({ v: 1, ok: true, text: await this.options.handler.event(request, clientFor(socket, arrivedAt)) });
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const watcher = watcherFor(socket, arrivedAt);
|
|
177
|
+
await this.options.handler.watch(request, watcher);
|
|
178
|
+
// The handler sent nothing: it refused a client that had gone. Do not leave a connection open that nobody answers.
|
|
179
|
+
if (!watcher.answered)
|
|
180
|
+
socket.destroy();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
this.logFailure(request.op, error);
|
|
185
|
+
reply({ v: 1, ok: false, code: "unavailable" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
logServerError(error) {
|
|
189
|
+
const code = error?.code;
|
|
190
|
+
try {
|
|
191
|
+
this.options.log?.({ event: "ask_hub_server_error", code: typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN" });
|
|
192
|
+
}
|
|
193
|
+
catch { /* Logging never stops the hub. */ }
|
|
194
|
+
}
|
|
195
|
+
logFailure(op, error) {
|
|
196
|
+
const code = error?.code;
|
|
197
|
+
try {
|
|
198
|
+
this.options.log?.({ event: "ask_hub_handler_failed", op, error: typeof code === "string" ? code : error instanceof Error ? error.name : "UNKNOWN" });
|
|
199
|
+
}
|
|
200
|
+
catch { /* Logging never stops the hub. */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
|
|
2
|
+
import { addDeliveries, closeDeliveries, confirmNotified, confirmWake, isLive, planRoutes, promptSubmitted, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
|
|
3
|
+
import { droppableDeliveryId, parseAskDelivery } from "./types.js";
|
|
4
|
+
import { renderOutput } from "./templates.js";
|
|
5
|
+
/** The most reports one flush sends, so a misbehaving Gateway can never keep it busy. */
|
|
6
|
+
const MAX_REPORTS_PER_FLUSH = 200;
|
|
7
|
+
/** How many ended delivery ids are remembered, so one offered again is never stored or shown again. */
|
|
8
|
+
const MAX_ENDED = 1000;
|
|
9
|
+
const sameReport = (a, b) => a !== undefined && a.event === b.event && a.via === b.via && a.app === b.app;
|
|
10
|
+
/** The Gateway refused the report itself (not this runner's delivery, or the switch is off): retrying cannot help. */
|
|
11
|
+
const refused = (error) => error?.code === "GATEWAY_TOOL_REJECTED";
|
|
12
|
+
export class AskInbox {
|
|
13
|
+
deps;
|
|
14
|
+
state = emptyAskState();
|
|
15
|
+
watchers = new Map();
|
|
16
|
+
queue = Promise.resolve();
|
|
17
|
+
/** Notifications still running, so idle() can wait for them. */
|
|
18
|
+
notifications = new Set();
|
|
19
|
+
/** Deliveries this runner could not use, to be reported dropped. Not stored: the Gateway offers them again. */
|
|
20
|
+
drops = new Set();
|
|
21
|
+
/** Deliveries that ended here: closed, fallen back, or refused. Offered again, they are ignored. */
|
|
22
|
+
ended = new Set();
|
|
23
|
+
constructor(deps) {
|
|
24
|
+
this.deps = deps;
|
|
25
|
+
}
|
|
26
|
+
now() {
|
|
27
|
+
return (this.deps.now ?? Date.now)();
|
|
28
|
+
}
|
|
29
|
+
log(event) {
|
|
30
|
+
try {
|
|
31
|
+
this.deps.log?.(event);
|
|
32
|
+
}
|
|
33
|
+
catch { /* Logging never stops delivery. */ }
|
|
34
|
+
}
|
|
35
|
+
/** Runs one change at a time, in call order. */
|
|
36
|
+
exclusive(change) {
|
|
37
|
+
const result = this.queue.then(change);
|
|
38
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reads the stored state. A missing file or content that is not JSON is an empty state (the store decides). Any other
|
|
43
|
+
* error propagates: the caller must run without asks, because the next save would overwrite a file it could not read.
|
|
44
|
+
*/
|
|
45
|
+
async load() {
|
|
46
|
+
await this.exclusive(async () => {
|
|
47
|
+
this.state = await this.deps.store.load();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Resolves when every queued change is saved and every notification has finished. For shutdown, before the lock is released. */
|
|
51
|
+
async idle() {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const queue = this.queue;
|
|
54
|
+
await Promise.all([queue, ...this.notifications]);
|
|
55
|
+
if (queue === this.queue && this.notifications.size === 0)
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
status() {
|
|
60
|
+
const now = this.now();
|
|
61
|
+
return {
|
|
62
|
+
deliveries: Object.keys(this.state.items).length,
|
|
63
|
+
live_sessions: Object.values(this.state.sessions).filter(session => isLive(session, now)).length,
|
|
64
|
+
watchers: this.watchers.size,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The answer resolves as soon as the change is made, before the save; the queue is held until the save is done. A
|
|
69
|
+
* client that is gone gets nothing and changes nothing: marking an ask shown for a hook that cannot print it would
|
|
70
|
+
* count it as seen later although nobody saw it.
|
|
71
|
+
*/
|
|
72
|
+
event(request, client) {
|
|
73
|
+
return new Promise((answer, fail) => {
|
|
74
|
+
this.exclusive(async () => {
|
|
75
|
+
if (client.gone) {
|
|
76
|
+
answer(null);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const context = { app: request.app, sessionId: request.session_id, cwd: request.cwd, now: this.now() };
|
|
80
|
+
let text = null;
|
|
81
|
+
if (request.event === "session-start")
|
|
82
|
+
text = renderOutput(sessionStarted(this.state, context), { sessionId: request.session_id });
|
|
83
|
+
else if (request.event === "prompt")
|
|
84
|
+
text = renderOutput(promptSubmitted(this.state, context));
|
|
85
|
+
else if (request.event === "stop")
|
|
86
|
+
text = renderOutput(turnEnded(this.state, context, { stopHookActive: request.stop_hook_active === true }));
|
|
87
|
+
else {
|
|
88
|
+
sessionEnded(this.state, context);
|
|
89
|
+
this.release(sessionKey(request.app, request.session_id), "session_ended");
|
|
90
|
+
}
|
|
91
|
+
const notify = this.route();
|
|
92
|
+
answer(text);
|
|
93
|
+
await this.commit(notify);
|
|
94
|
+
}).catch(fail);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A Claude Stop watcher. A Claude turn end is always a watch, so registering one is the session's turn end: it runs
|
|
99
|
+
* turnEnded(), which shows what is already pinned to the session (via stop) and marks the session idle. Without that
|
|
100
|
+
* the session would still count as in a turn, and the wake branch of the routing rules could never be reached.
|
|
101
|
+
* What is not pinned makes the watcher wait, replacing any older watcher of the same session.
|
|
102
|
+
*/
|
|
103
|
+
watch(request, watcher) {
|
|
104
|
+
return this.exclusive(async () => {
|
|
105
|
+
// The client went away, or gave up, while this waited in the queue. Showing it anything would lose the text, and it must not replace an older watcher.
|
|
106
|
+
if (watcher.gone)
|
|
107
|
+
return;
|
|
108
|
+
const key = sessionKey("claude", request.session_id);
|
|
109
|
+
this.release(key, "replaced");
|
|
110
|
+
// Claude's own loop guard is the hourly wake limit; stop_hook_active belongs to Stop decisions, not to a rewake.
|
|
111
|
+
const text = renderOutput(turnEnded(this.state, { app: "claude", sessionId: request.session_id, cwd: request.cwd, now: this.now() }, { stopHookActive: false }));
|
|
112
|
+
if (text !== null) {
|
|
113
|
+
watcher.wake(text);
|
|
114
|
+
}
|
|
115
|
+
else if (watcher.hold()) {
|
|
116
|
+
this.watchers.set(key, watcher);
|
|
117
|
+
watcher.onClose(() => { if (this.watchers.get(key) === watcher)
|
|
118
|
+
this.watchers.delete(key); });
|
|
119
|
+
}
|
|
120
|
+
await this.settle();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Deliveries from the session-open wait. A valid one is stored, then reported received (by flush). One that fails
|
|
125
|
+
* validation, or cannot be stored, is reported dropped, so the Gateway sends today's Slack message at once.
|
|
126
|
+
*/
|
|
127
|
+
receive(entries) {
|
|
128
|
+
return this.exclusive(async () => {
|
|
129
|
+
const deliveries = [];
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const delivery = parseAskDelivery(entry);
|
|
132
|
+
if (delivery !== null && this.ended.has(delivery.deliveryId))
|
|
133
|
+
continue;
|
|
134
|
+
if (delivery !== null) {
|
|
135
|
+
deliveries.push(delivery);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const deliveryId = droppableDeliveryId(entry);
|
|
139
|
+
if (deliveryId !== null && this.state.items[deliveryId] === undefined)
|
|
140
|
+
this.drops.add(deliveryId);
|
|
141
|
+
}
|
|
142
|
+
const { added } = addDeliveries(this.state, deliveries, this.now());
|
|
143
|
+
if (added.length > 0) {
|
|
144
|
+
if (await this.persist()) {
|
|
145
|
+
// Stored now: a dropped report still queued from an earlier failed save is stale.
|
|
146
|
+
for (const item of added)
|
|
147
|
+
this.drops.delete(item.deliveryId);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
for (const item of added) {
|
|
151
|
+
delete this.state.items[item.deliveryId];
|
|
152
|
+
this.drops.add(item.deliveryId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await this.settle();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Deliveries the Gateway closed after this runner received them: answered, decided, expired or fallen back. */
|
|
160
|
+
close(deliveryIds) {
|
|
161
|
+
return this.exclusive(async () => {
|
|
162
|
+
const closed = closeDeliveries(this.state, deliveryIds);
|
|
163
|
+
for (const deliveryId of closed)
|
|
164
|
+
this.end(deliveryId);
|
|
165
|
+
if (closed.length > 0)
|
|
166
|
+
await this.persist();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Asks are off for this runner (the wait result has no ask keys): every stored delivery ends, with no report, and the
|
|
171
|
+
* dropped reports not sent yet are dropped too, since the Gateway would refuse them. Waiting watchers stay.
|
|
172
|
+
*/
|
|
173
|
+
endAll() {
|
|
174
|
+
return this.exclusive(async () => {
|
|
175
|
+
const ended = Object.keys(this.state.items);
|
|
176
|
+
for (const deliveryId of ended)
|
|
177
|
+
this.end(deliveryId);
|
|
178
|
+
this.drops.clear();
|
|
179
|
+
if (ended.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
this.log({ event: "ask_deliveries_ended", reason: "asks_off", count: ended.length });
|
|
182
|
+
await this.persist();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** Forgets a delivery for good: it is dropped from the store and never stored or shown again. */
|
|
186
|
+
end(deliveryId) {
|
|
187
|
+
delete this.state.items[deliveryId];
|
|
188
|
+
this.ended.add(deliveryId);
|
|
189
|
+
if (this.ended.size > MAX_ENDED)
|
|
190
|
+
this.ended.delete(this.ended.values().next().value);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Sends queued reports in order: drops first, then each delivery's outbox. A transport or server failure stops
|
|
194
|
+
* the flush and leaves the report queued for the next one. A report the Gateway refuses, or a result that says
|
|
195
|
+
* the delivery fell back or closed, ends the delivery here.
|
|
196
|
+
*/
|
|
197
|
+
async flush(send, signal) {
|
|
198
|
+
// What to send is picked inside the queue, after any change in progress has been saved: receive() holds the queue
|
|
199
|
+
// until its save is done, so a received report never reaches the Gateway before its delivery is on disk.
|
|
200
|
+
for (const deliveryId of await this.exclusive(async () => [...this.drops])) {
|
|
201
|
+
if (signal.aborted)
|
|
202
|
+
return;
|
|
203
|
+
if (!this.drops.has(deliveryId))
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
await send(deliveryId, { event: "dropped" }, signal);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (!refused(error)) {
|
|
210
|
+
this.reportFailed(error, signal);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
this.drops.delete(deliveryId);
|
|
215
|
+
}
|
|
216
|
+
for (let sent = 0; sent < MAX_REPORTS_PER_FLUSH && !signal.aborted; sent += 1) {
|
|
217
|
+
const next = await this.exclusive(async () => {
|
|
218
|
+
const item = Object.values(this.state.items).find(candidate => candidate.outbox.length > 0);
|
|
219
|
+
return item === undefined ? undefined : { deliveryId: item.deliveryId, report: item.outbox[0] };
|
|
220
|
+
});
|
|
221
|
+
if (next === undefined)
|
|
222
|
+
return;
|
|
223
|
+
const report = next.report;
|
|
224
|
+
let result;
|
|
225
|
+
try {
|
|
226
|
+
result = await send(next.deliveryId, report, signal);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (!refused(error)) {
|
|
230
|
+
this.reportFailed(error, signal);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await this.exclusive(async () => { this.end(next.deliveryId); await this.persist(); });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const status = result?.delivery?.status;
|
|
237
|
+
await this.exclusive(async () => {
|
|
238
|
+
const current = this.state.items[next.deliveryId];
|
|
239
|
+
if (current === undefined)
|
|
240
|
+
return;
|
|
241
|
+
if (sameReport(current.outbox[0], report))
|
|
242
|
+
current.outbox.shift();
|
|
243
|
+
// The Gateway already sent Slack, or the ask is answered: drop it and never show it again.
|
|
244
|
+
if (status === "fallen_back" || status === "closed")
|
|
245
|
+
this.end(next.deliveryId);
|
|
246
|
+
await this.persist();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** A report that did not get through stays queued. A report the shutdown aborted is no failure, so it is not logged. */
|
|
251
|
+
reportFailed(error, signal) {
|
|
252
|
+
if (!signal.aborted)
|
|
253
|
+
this.log({ event: "ask_report_failed", code: errorCode(error) });
|
|
254
|
+
}
|
|
255
|
+
/** Tells every waiting watcher to exit 0, for daemon shutdown. */
|
|
256
|
+
releaseAll(reason) {
|
|
257
|
+
for (const key of [...this.watchers.keys()])
|
|
258
|
+
this.release(key, reason);
|
|
259
|
+
}
|
|
260
|
+
release(key, reason) {
|
|
261
|
+
const watcher = this.watchers.get(key);
|
|
262
|
+
if (watcher === undefined)
|
|
263
|
+
return;
|
|
264
|
+
this.watchers.delete(key);
|
|
265
|
+
watcher.release(reason);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Prunes, re-routes, wakes watchers and claims notifications; returns the deliveries to notify. Synchronous, so the
|
|
269
|
+
* plan, the wake and its confirmation can never interleave with another change. Must run inside exclusive().
|
|
270
|
+
*/
|
|
271
|
+
route() {
|
|
272
|
+
const now = this.now();
|
|
273
|
+
pruneAskState(this.state, now);
|
|
274
|
+
const watching = new Set([...this.watchers].filter(([, watcher]) => !watcher.closed).map(([key]) => key));
|
|
275
|
+
const plan = planRoutes(this.state, now, watching);
|
|
276
|
+
for (const { sessionKey: key, output } of plan.wakes) {
|
|
277
|
+
const watcher = this.watchers.get(key);
|
|
278
|
+
const text = renderOutput(output);
|
|
279
|
+
if (watcher === undefined || text === null || !watcher.wake(text))
|
|
280
|
+
continue;
|
|
281
|
+
this.watchers.delete(key);
|
|
282
|
+
confirmWake(this.state, key, output, now);
|
|
283
|
+
}
|
|
284
|
+
// Claimed before the notification runs, so a delivery is never notified twice.
|
|
285
|
+
for (const item of plan.notify)
|
|
286
|
+
confirmNotified(this.state, item.deliveryId, now, false);
|
|
287
|
+
return plan.notify.map(item => item.deliveryId);
|
|
288
|
+
}
|
|
289
|
+
/** Saves, then starts the notifications and the change callback. Whoever waits for an answer has it by now. Inside exclusive(). */
|
|
290
|
+
async commit(notify) {
|
|
291
|
+
await this.persist();
|
|
292
|
+
if (notify.length > 0)
|
|
293
|
+
this.startNotifying(notify);
|
|
294
|
+
try {
|
|
295
|
+
this.deps.onChange?.();
|
|
296
|
+
}
|
|
297
|
+
catch { /* A failed flush trigger is retried after the next wait. */ }
|
|
298
|
+
}
|
|
299
|
+
/** route() and commit() together, for a change that has no answer to hand over first. */
|
|
300
|
+
async settle() {
|
|
301
|
+
await this.commit(this.route());
|
|
302
|
+
}
|
|
303
|
+
async persist() {
|
|
304
|
+
try {
|
|
305
|
+
await this.deps.store.save(this.state);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
this.log({ event: "ask_store_write_failed", code: error.code ?? "UNKNOWN" });
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
startNotifying(deliveryIds) {
|
|
314
|
+
const running = this.notifyEach(deliveryIds)
|
|
315
|
+
.catch(error => this.log({ event: "ask_notification_failed", error: error instanceof Error ? error.name : "UNKNOWN" }))
|
|
316
|
+
.then(() => { this.notifications.delete(running); });
|
|
317
|
+
this.notifications.add(running);
|
|
318
|
+
}
|
|
319
|
+
async notifyEach(deliveryIds) {
|
|
320
|
+
for (const deliveryId of deliveryIds) {
|
|
321
|
+
const item = this.state.items[deliveryId];
|
|
322
|
+
if (item === undefined || this.deps.notify === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
let shown = false;
|
|
325
|
+
try {
|
|
326
|
+
shown = await this.deps.notify(item);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
shown = false;
|
|
330
|
+
}
|
|
331
|
+
if (!shown)
|
|
332
|
+
continue;
|
|
333
|
+
await this.exclusive(async () => {
|
|
334
|
+
confirmNotified(this.state, deliveryId, this.now(), true);
|
|
335
|
+
await this.persist();
|
|
336
|
+
try {
|
|
337
|
+
this.deps.onChange?.();
|
|
338
|
+
}
|
|
339
|
+
catch { /* Retried after the next wait. */ }
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function errorCode(error) {
|
|
345
|
+
const code = error?.code;
|
|
346
|
+
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN";
|
|
347
|
+
}
|