@edgehero/pi-dispatch 5.0.0 → 5.2.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 +1 -0
- package/package.json +4 -3
- package/src/cancel-cli.mjs +102 -14
- package/src/cancel-state.mjs +9 -3
- package/src/cli.mjs +8 -0
- package/src/connection.mjs +7 -0
- package/src/console-audit.mjs +1 -1
- package/src/console-bootstrap.mjs +211 -45
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -0
- package/src/doctor-bundles.mjs +6 -2
- package/src/doctor-model-proposals.mjs +4 -2
- package/src/doctor.mjs +223 -30
- package/src/envelope.mjs +5 -3
- package/src/mcp-config.mjs +3 -1
- package/src/models-json.mjs +8 -1
- package/src/pause-windows.mjs +19 -3
- package/src/setup-host-entry.mjs +4 -1
- package/src/up.mjs +6 -4
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ with at least one commit, and `run` refuses any other folder before it queues an
|
|
|
43
43
|
|---|---|
|
|
44
44
|
| `init` | write `.env` and the config files into this folder (never overwrites) |
|
|
45
45
|
| `up` | one pass that asks before each container action: image, Valkey, egress proxy, `init`, `doctor` |
|
|
46
|
+
| `console` | get into the web console: starts its service if it stopped, then opens your browser signed in |
|
|
46
47
|
| `doctor [--fix] [--live] [--openshell] [--secrets] [--no-publish]` | check the whole setup; `--live` reads the isolation guarantees back from real containers; each run publishes its checks (paths, URLs and secret values cut out) for the console and the panel, unless `--no-publish` |
|
|
47
48
|
| `worker` | run jobs from the queue |
|
|
48
49
|
| `run <folder> --task "..."` | queue one job against a local folder |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch worker and CLI: go from one AI coding agent to many in the background, running the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
|
|
6
6
|
"keywords": [
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"./git-hardening": "./src/git-hardening.mjs",
|
|
55
55
|
"./http-body": "./src/http-body.mjs",
|
|
56
56
|
"./console-tokens": "./src/console-tokens.mjs",
|
|
57
|
+
"./console-devices": "./src/console-devices.mjs",
|
|
57
58
|
"./console-probe": "./src/console-probe.mjs",
|
|
58
59
|
"./console-audit": "./src/console-audit.mjs",
|
|
59
60
|
"./console-answer": "./src/console-answer.mjs",
|
|
@@ -207,8 +208,8 @@
|
|
|
207
208
|
"start": "node src/cli.mjs worker"
|
|
208
209
|
},
|
|
209
210
|
"dependencies": {
|
|
210
|
-
"@earendil-works/pi-ai": "1.5.
|
|
211
|
-
"@earendil-works/pi-tui": "1.5.
|
|
211
|
+
"@earendil-works/pi-ai": "1.5.2",
|
|
212
|
+
"@earendil-works/pi-tui": "1.5.2",
|
|
212
213
|
"@octokit/auth-app": "8.2.0",
|
|
213
214
|
"@octokit/rest": "22.0.1",
|
|
214
215
|
"bullmq": "5.80.4",
|
package/src/cancel-cli.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* job's attempt count.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
|
|
27
|
+
import { CANCEL_REQ_TTL_MS, cancelAckKey, cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
|
|
28
28
|
|
|
29
29
|
/** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
|
|
30
30
|
const FLEET_READ_TIMEOUT_MS = 2_000;
|
|
@@ -42,11 +42,43 @@ export function boundedText(text) {
|
|
|
42
42
|
return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Whether `error` is Valkey's own refusal (a reply), as against no answer at all: the two are different sentences. */
|
|
46
|
+
function refusedByValkey(error) {
|
|
47
|
+
return error?.name === "ReplyError";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What went wrong, as the start of a line: Valkey refused a command (it answered), or it did not answer. */
|
|
51
|
+
function valkeyFault(error, shown) {
|
|
52
|
+
const command = typeof error?.command?.name === "string" ? error.command.name.toUpperCase() : "a command";
|
|
53
|
+
return refusedByValkey(error) ? `Valkey at ${shown} refused ${boundedText(command)}: ${boundedText(error?.message)}` : `Valkey at ${shown} stopped answering (${boundedText(error?.message)})`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The job's state, read under the same bound as every other command here: `{ state }`, or `{ unread }` with why it could
|
|
58
|
+
* not be read. Issue #846: a read that failed was `"unknown"`, which is also what BullMQ says of a job that left its
|
|
59
|
+
* queue, so a job still running was reported as gone "and nothing was changed".
|
|
60
|
+
*/
|
|
61
|
+
async function boundedState(job, timeoutMs) {
|
|
62
|
+
let timer;
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([
|
|
65
|
+
Promise.resolve(job.getState()).then((state) => ({ state })),
|
|
66
|
+
new Promise((resolve) => {
|
|
67
|
+
timer = setTimeout(() => resolve({ unread: `no answer within ${Math.round(timeoutMs / 1000)}s` }), timeoutMs);
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { unread: error?.message ?? String(error) };
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
/**
|
|
46
78
|
* Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
|
|
47
79
|
* so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
|
|
48
80
|
*/
|
|
49
|
-
export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now } = {}) {
|
|
81
|
+
export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now, commandTimeoutMs = null, closeTimeoutMs = 1000 } = {}) {
|
|
50
82
|
const fail = (message) => {
|
|
51
83
|
errWrite(`error: ${message}\n`);
|
|
52
84
|
return 1;
|
|
@@ -55,7 +87,9 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
55
87
|
return fail("a job id is required: pi-dispatch cancel <jobId> (ids are in `pi-dispatch status`, the panel, and the run log)");
|
|
56
88
|
}
|
|
57
89
|
// Lazy imports, the pause verb's shape: a mistyped id must not load bullmq before it can be refused.
|
|
58
|
-
const { parseConnection, makeRedisClient } = await import("./connection.mjs");
|
|
90
|
+
const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS } = await import("./connection.mjs");
|
|
91
|
+
// The bound on each command and on the state reads: the console's, unless a test asks for another.
|
|
92
|
+
const bound = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : VALKEY_COMMAND_TIMEOUT_MS;
|
|
59
93
|
const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
|
|
60
94
|
const { readLiveHosts } = await import("./host-registry.mjs");
|
|
61
95
|
const parseConn = parseConnectionFn ?? parseConnection;
|
|
@@ -83,10 +117,14 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
83
117
|
});
|
|
84
118
|
const refused = await refusalOf(url);
|
|
85
119
|
if (refused) return fail(refused);
|
|
86
|
-
|
|
120
|
+
// BOUNDED (issue #846): each command waits at most VALKEY_COMMAND_TIMEOUT_MS for its answer, and one that timed out is
|
|
121
|
+
// never sent again. The unbounded client this was waited for as long as Valkey was gone: killed during the ack poll it
|
|
122
|
+
// printed nothing until the operator gave up, and on a partition its DEL never reached the catch below.
|
|
123
|
+
const probe = mkRedis(url, { commandTimeoutMs: bound, disconnectTimeoutMs: 0 });
|
|
87
124
|
probe.on?.("error", () => {});
|
|
88
125
|
const queues = [];
|
|
89
126
|
let requested = false;
|
|
127
|
+
let requestedAt = null;
|
|
90
128
|
try {
|
|
91
129
|
// The job could sit on the shared queue or on any host's own (issue #57): find it the way pause
|
|
92
130
|
// spans them, and fail OPEN but LOUDLY on a degraded registry read, exactly as the kill switch does.
|
|
@@ -138,6 +176,12 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
138
176
|
return 0;
|
|
139
177
|
}
|
|
140
178
|
const state = queued.state;
|
|
179
|
+
// Issue #846: `removeQueuedJob` reads a state it could not read as "unknown" (the console's sequence shares it), which
|
|
180
|
+
// BullMQ also says of a job that left its queue. Asked again under the bound, so a read that failed says so.
|
|
181
|
+
if (state === "unknown") {
|
|
182
|
+
const again = await boundedState(job, bound);
|
|
183
|
+
if (again.unread !== undefined) return fail(`could not read the state of ${jobId} (${boundedText(again.unread)}), so whether it is running is not known; nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
|
|
184
|
+
}
|
|
141
185
|
// The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
|
|
142
186
|
// the state is re-read and an active job falls through to the active path rather than failing an operator whose
|
|
143
187
|
// job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
|
|
@@ -147,7 +191,8 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
147
191
|
|
|
148
192
|
if (state === "active") {
|
|
149
193
|
requested = true;
|
|
150
|
-
|
|
194
|
+
requestedAt = now();
|
|
195
|
+
const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, now, onWritten: () => (requestedAt = now()), ...(sleep ? { sleep } : {}) });
|
|
151
196
|
if (res.ack !== undefined) {
|
|
152
197
|
write(`cancel accepted by ${res.ack === "" ? "the worker" : boundedText(res.ack)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
|
|
153
198
|
return 0;
|
|
@@ -155,8 +200,11 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
155
200
|
// Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
|
|
156
201
|
// lands in that moment is never acknowledged and the job is back in the queue. Re-read, so the words are true.
|
|
157
202
|
// Gate round 3: by the state, since a job that finished or left the queue cannot be removed by a second cancel.
|
|
158
|
-
const
|
|
203
|
+
const read = await boundedState(job, bound);
|
|
159
204
|
const waited = `no worker acknowledged within ${Math.round(ackTimeoutMs / 1000)}s`;
|
|
205
|
+
// Issue #846: a state that could not be read is said as that, never as BullMQ's "unknown" (a job that left).
|
|
206
|
+
if (read.unread !== undefined) return fail(`${waited}, and the job's state could not be read (${boundedText(read.unread)}), so whether it still runs is not known. The cancel request was withdrawn, so nothing was changed: run \`pi-dispatch status\`, and \`pi-dispatch cancel ${jobId}\` again if it still runs`);
|
|
207
|
+
const after = read.state;
|
|
160
208
|
if (after === "waiting" || after === "delayed" || after === "prioritized" || after === "paused") return fail(`${waited}: the job went back to the queue before its worker read the cancel (it is now ${after}), and nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again to remove it`);
|
|
161
209
|
if (after === "completed" || after === "failed") return fail(`${waited}: the job finished (${after}) before its worker read the cancel, and nothing was changed`);
|
|
162
210
|
if (after !== "active") return fail(`${waited}: the job is no longer in the queue (its state reads ${after}), and nothing was changed`);
|
|
@@ -170,18 +218,58 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
170
218
|
// del would sit in ioredis's retry queue and hold this process open long past the error message.
|
|
171
219
|
// The bound timer is CLEARED when the del wins the race (discoverHostQueues' own rule): cli.mjs
|
|
172
220
|
// sets exitCode rather than exiting, so a stray pending timer is a second of pure hang.
|
|
221
|
+
// What the withdrawing DEL ANSWERED decides the sentence (issue #846, the second adversary round): 1, it was taken back;
|
|
222
|
+
// 0, it was already gone, because its worker read it (and may have acknowledged) or it expired; no answer, not known.
|
|
223
|
+
// Reading "answered" as "withdrawn" said "nothing was changed" of a cancel its worker had taken and acted on, when a
|
|
224
|
+
// poll was lost to a Valkey that died and came back within the bound.
|
|
225
|
+
const within = (promise, ms) => {
|
|
226
|
+
let timer;
|
|
227
|
+
return Promise.race([Promise.resolve(promise).then((value) => ({ value }), (error) => ({ lost: true, error })), new Promise((r) => (timer = setTimeout(() => r({ lost: true }), ms)))]).finally(() => clearTimeout(timer));
|
|
228
|
+
};
|
|
229
|
+
let withdrawal = null;
|
|
230
|
+
let acked = null;
|
|
173
231
|
if (requested) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
clearTimeout(bound);
|
|
232
|
+
withdrawal = await within(probe.del?.(cancelReqKey(jobId)), 1000);
|
|
233
|
+
if (withdrawal.value !== undefined && Number(withdrawal.value) === 0) {
|
|
234
|
+
const ack = await within(probe.get?.(cancelAckKey(jobId)), 1000);
|
|
235
|
+
if (typeof ack.value === "string") acked = ack.value;
|
|
236
|
+
}
|
|
180
237
|
}
|
|
181
|
-
|
|
238
|
+
if (acked !== null) {
|
|
239
|
+
write(`cancel accepted by ${acked === "" ? "the worker" : boundedText(acked)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
if (error?.valkeyRefused) return fail(error.message);
|
|
243
|
+
const shown = (await import("./connection.mjs")).urlShown(url);
|
|
244
|
+
// Issue #846: after the request went out, what is said is what became of it. The old line said only "could not reach
|
|
245
|
+
// Valkey ... is it running?", also when Valkey answered with a refusal, and never that a request may still stand.
|
|
246
|
+
if (requested && error?.valkeyMayStillRun) {
|
|
247
|
+
const removed = withdrawal?.value !== undefined ? Number(withdrawal.value) : null;
|
|
248
|
+
if (removed !== null && removed > 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written. The request was withdrawn again, so nothing was changed, unless its worker read it in that moment: run \`pi-dispatch status\` to see whether ${jobId} still runs`);
|
|
249
|
+
if (removed === 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and the request was gone when the CLI went to withdraw it: its worker may have read it, or it expired, and no acknowledgment was found. Run \`pi-dispatch status\` to see whether ${jobId} still runs`);
|
|
250
|
+
const until = new Date((requestedAt ?? now()) + CANCEL_REQ_TTL_MS).toISOString();
|
|
251
|
+
const then = refusedByValkey(error) || refusedByValkey(withdrawal?.error) ? "run `pi-dispatch status` to see whether it did" : "run `pi-dispatch status` once Valkey answers to see whether it did";
|
|
252
|
+
return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and its withdrawal was not answered, so it may stand until ${until}, ${Math.round(CANCEL_REQ_TTL_MS / 1000)}s after it was written: a worker that reads it before then stops the job. ${then.charAt(0).toUpperCase()}${then.slice(1)}`);
|
|
253
|
+
}
|
|
254
|
+
if (refusedByValkey(error)) return fail(`${valkeyFault(error, shown)}; nothing was changed`);
|
|
255
|
+
return fail(`could not reach Valkey at ${shown}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
|
|
182
256
|
} finally {
|
|
183
257
|
probe.disconnect?.();
|
|
184
|
-
|
|
258
|
+
// BOUNDED (issue #846): `close()` waits for the answer to QUIT, so on a Valkey that stopped answering the verb could hold
|
|
259
|
+
// its process after its last line. Past the bound the queue is dropped (`disconnect`, no QUIT; ioredis may still take
|
|
260
|
+
// its own disconnect timeout to let the socket go), which nothing here needs an answer to.
|
|
261
|
+
await Promise.all(queues.map((q) => closeWithin(q, closeTimeoutMs)));
|
|
185
262
|
}
|
|
186
263
|
}
|
|
187
264
|
|
|
265
|
+
/** Close `queue`, or drop its connection when the close is not answered within `ms`. Never throws. */
|
|
266
|
+
async function closeWithin(queue, ms) {
|
|
267
|
+
let timer;
|
|
268
|
+
const closed = await Promise.race([
|
|
269
|
+
Promise.resolve(queue.close()).then(() => true, () => true),
|
|
270
|
+
new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms))),
|
|
271
|
+
]);
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
if (!closed) await Promise.resolve(queue.disconnect?.()).catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
|
package/src/cancel-state.mjs
CHANGED
|
@@ -86,12 +86,18 @@ export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
|
|
|
86
86
|
* already own a could-not-reach-Valkey message, and swallowing here would turn an unreachable Valkey into
|
|
87
87
|
* a lying "no worker acknowledged".
|
|
88
88
|
*
|
|
89
|
-
* `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
|
|
89
|
+
* `pollMs`/`sleep` are injectable so tests never wait on a wall clock. The wait ends at `ackTimeoutMs` by whichever runs
|
|
90
|
+
* out first: the sleeps it took, or the time `now` says has passed (issue #846). Counting sleeps alone, a GET that waited
|
|
91
|
+
* out a Valkey restart in the client's queue was not counted at all, so a 10 s wait could last as long as the outage did.
|
|
90
92
|
*/
|
|
91
|
-
export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
93
|
+
export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onWritten = null }) {
|
|
92
94
|
const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
|
|
93
95
|
const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
|
|
94
96
|
await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
|
|
97
|
+
// When the request's TTL started, for a caller that names until when it may stand (the CLI, issue #846), and the start
|
|
98
|
+
// of the wait for its acknowledgment, so a slow SET is not counted against it.
|
|
99
|
+
onWritten?.();
|
|
100
|
+
const deadline = now() + ackTimeoutMs;
|
|
95
101
|
for (let waited = 0; ; waited += pollMs) {
|
|
96
102
|
let host;
|
|
97
103
|
try {
|
|
@@ -102,7 +108,7 @@ export async function requestCancel({ redis, jobId, interventionOrigin = "human"
|
|
|
102
108
|
throw err;
|
|
103
109
|
}
|
|
104
110
|
if (host !== null && host !== undefined) return { ack: host };
|
|
105
|
-
if (waited + pollMs > ackTimeoutMs) break;
|
|
111
|
+
if (waited + pollMs > ackTimeoutMs || now() >= deadline) break;
|
|
106
112
|
await sleep(pollMs);
|
|
107
113
|
}
|
|
108
114
|
// Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
|
package/src/cli.mjs
CHANGED
|
@@ -27,7 +27,10 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
|
|
|
27
27
|
--secrets resolves each project secret bundle here and prints its variable names, never values;
|
|
28
28
|
every run publishes its checks to Valkey for the console and the panel (labels with paths
|
|
29
29
|
and secret values redacted, kept 7 days), and --no-publish skips that
|
|
30
|
+
pi-dispatch console [--name NAME] [--next /setup] [--port N] [--deployment DIR]
|
|
31
|
+
get into the web console: starts its service if it stopped, then opens your browser signed in
|
|
30
32
|
pi-dispatch up [--console] [--yes] [--adopt-volume] [--restart-proxy] [--refresh-proxy-rules]
|
|
33
|
+
with --console also: [--ask-each] [--no-open] [--name NAME]
|
|
31
34
|
one consented pass: pull+tag the job image, start Valkey, init, doctor
|
|
32
35
|
pi-dispatch setup github mint GitHub App credentials in one browser click (App Manifest flow);
|
|
33
36
|
every write shown first and individually consented — no --yes here
|
|
@@ -161,6 +164,11 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
|
|
|
161
164
|
return runUp(argv.slice(1), { env });
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
if (cmd === "console") {
|
|
168
|
+
const { runConsoleOpen } = await import("./console-open.mjs");
|
|
169
|
+
return runConsoleOpen(argv.slice(1), { env });
|
|
170
|
+
}
|
|
171
|
+
|
|
164
172
|
if (cmd === "setup") {
|
|
165
173
|
// Subcommand shape (`setup <forge>`) so future forges can land beside github without a new
|
|
166
174
|
// top-level verb; an unknown or missing target prints guidance rather than guessing.
|
package/src/connection.mjs
CHANGED
|
@@ -250,6 +250,13 @@ class BoundedRedis extends Redis {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* How long one command of a short-lived client may wait for its answer (`commandTimeoutMs`), in the offline queue or on a
|
|
255
|
+
* Valkey that stopped answering: the console's own clients wait as long, and `pi-dispatch cancel` (issue #846) takes it
|
|
256
|
+
* from here, so a cancel against a Valkey that went away ends with a sentence rather than waiting as long as it is gone.
|
|
257
|
+
*/
|
|
258
|
+
export const VALKEY_COMMAND_TIMEOUT_MS = 5_000;
|
|
259
|
+
|
|
253
260
|
/**
|
|
254
261
|
* A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
|
|
255
262
|
* `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
|
package/src/console-audit.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { escapeControls } from "./projects.mjs";
|
|
|
39
39
|
|
|
40
40
|
export const CONSOLE_AUDIT_DIR = "console-audit";
|
|
41
41
|
export const CONSOLE_AUDIT_KEY = "console:audit";
|
|
42
|
-
export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started"]);
|
|
42
|
+
export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started", "login-device", "device-remembered", "device-ended"]);
|
|
43
43
|
export const CONSOLE_AUDIT_OUTCOMES = Object.freeze(["ok", "refused", "failed"]);
|
|
44
44
|
// A string field's cap, in code points, AFTER its controls are written out: a row stays small enough for the bounded tail
|
|
45
45
|
// read below whatever a caller hands in (a Host header, a refusal reason), and no field can bury the others.
|