@timqi/pier 0.0.17 → 0.0.19
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/dist/agent/events.js +16 -4
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +28 -2
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/core/hub.js +14 -4
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +64 -0
- package/dist/db.js +44 -0
- package/dist/main.js +1 -0
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +14 -1
- package/dist/tasks/definitions.js +3 -1
- package/dist/tasks/messages.js +1 -2
- package/dist/tasks/service.js +5 -0
- package/dist/tasks/store.js +44 -12
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
- package/dist/web/public/assets/code-DjWvqL94.js +1 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-D8CLFi_l.js} +1 -1
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-yNocr797.css} +1 -1
- package/dist/web/public/assets/index-yNocr797.css.br +0 -0
- package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +104 -23
- package/dist/web/terminal.js +2 -7
- package/package.json +1 -1
- package/skills/pier-slack/SKILL.md +4 -16
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/core/reply.js
CHANGED
|
@@ -216,3 +216,67 @@ export function splitReply(rawMarkdown, meta) {
|
|
|
216
216
|
return { text: markdown, suggestions: [], meta, silence };
|
|
217
217
|
return { text: markdown.slice(0, m.index).trimEnd(), suggestions, meta, silence };
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The body of a block that is still being written, for a surface rendering it
|
|
221
|
+
* before the turn ends: everything `splitReply` repairs, minus the next-step
|
|
222
|
+
* block. That block is only a next-step block at the very *end* of a turn, so
|
|
223
|
+
* text a later paragraph has already closed behind keeps it as body — which is
|
|
224
|
+
* what the final render will do with it too.
|
|
225
|
+
*/
|
|
226
|
+
export const streamBody = (markdown) => cjkFriendly(markdown.replace(SILENT, "").trim());
|
|
227
|
+
/** Lines a blank line does not necessarily separate: a list item, a quote and
|
|
228
|
+
* an indented continuation all survive one — a loose list is still one list.
|
|
229
|
+
* Deliberately over-matching (`*` and `-` also catch emphasis and a rule):
|
|
230
|
+
* claiming one boundary too few costs nothing but a repaint. */
|
|
231
|
+
const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
|
|
232
|
+
/**
|
|
233
|
+
* How much of `markdown` after `from` can no longer change as more text
|
|
234
|
+
* arrives: the offset just past the last blank line that closes a block, or
|
|
235
|
+
* `from` if there is none.
|
|
236
|
+
*
|
|
237
|
+
* Streaming markdown is the caller. Re-parsing the whole reply on every
|
|
238
|
+
* repaint costs O(N²) over a turn, so the web chat renders each closed prefix
|
|
239
|
+
* once and keeps its DOM. A boundary is therefore only claimed where parsing
|
|
240
|
+
* the two sides separately renders the same as parsing them together: never
|
|
241
|
+
* inside a backtick or tilde fence, inside a block stripped on completion, or
|
|
242
|
+
* between two lines a blank line leaves in the same list or quote. Fence runs
|
|
243
|
+
* keep their length as in `channels/chunk.ts`: a ```` fence is not closed by
|
|
244
|
+
* the ``` it quotes.
|
|
245
|
+
*/
|
|
246
|
+
export function stableBlockEnd(markdown, from = 0) {
|
|
247
|
+
// The last line is still growing, so it decides nothing — not the fence
|
|
248
|
+
// state, and not whether the blank line above it ended a block.
|
|
249
|
+
const end = markdown.lastIndexOf("\n") + 1;
|
|
250
|
+
/** Marker run of the fence currently open; empty = closed. */
|
|
251
|
+
let open = "";
|
|
252
|
+
let silent = false;
|
|
253
|
+
let cut = from;
|
|
254
|
+
/** Start of the block after a blank line, waiting for its first line. */
|
|
255
|
+
let pending = -1;
|
|
256
|
+
let prev = "";
|
|
257
|
+
for (let at = from; at < end;) {
|
|
258
|
+
const nl = markdown.indexOf("\n", at);
|
|
259
|
+
const line = markdown.slice(at, nl);
|
|
260
|
+
at = nl + 1;
|
|
261
|
+
const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
262
|
+
if (!open && !silent && !line.trim()) {
|
|
263
|
+
if (pending < 0 && prev)
|
|
264
|
+
pending = at;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!open && !silent && pending >= 0 && !(CONTINUES.test(prev) && CONTINUES.test(line)))
|
|
268
|
+
cut = pending;
|
|
269
|
+
const run = fence?.[1] ?? "";
|
|
270
|
+
if (!open && run && !silent)
|
|
271
|
+
open = run;
|
|
272
|
+
else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
|
|
273
|
+
open = "";
|
|
274
|
+
if (!open && (!run || silent)) {
|
|
275
|
+
for (const tag of line.matchAll(/<(\/?)silent>/gi))
|
|
276
|
+
silent = !tag[1];
|
|
277
|
+
}
|
|
278
|
+
pending = -1;
|
|
279
|
+
prev = line;
|
|
280
|
+
}
|
|
281
|
+
return cut;
|
|
282
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -288,6 +288,27 @@ const MIGRATIONS = [
|
|
|
288
288
|
created_at INTEGER NOT NULL,
|
|
289
289
|
session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
|
|
290
290
|
);
|
|
291
|
+
`,
|
|
292
|
+
// 15 — the scheduler's tick stops reading the rows it cannot deliver.
|
|
293
|
+
`
|
|
294
|
+
-- Once a second, tasks/ asks for the runs whose callback is still owed and
|
|
295
|
+
-- the messages whose injection has not landed. Both are a handful of rows
|
|
296
|
+
-- filtered on one low-cardinality column, and without an index both are a
|
|
297
|
+
-- full scan of a table that only grows — the sweep got slower with every
|
|
298
|
+
-- run that finished cleanly and can never match again.
|
|
299
|
+
CREATE INDEX task_runs_callback_state ON task_runs(callback_state);
|
|
300
|
+
CREATE INDEX task_messages_state ON task_messages(state);
|
|
301
|
+
`,
|
|
302
|
+
// 16 — the same tick stops parsing every task document to find none due.
|
|
303
|
+
`
|
|
304
|
+
-- When a task is next due is the one field the scheduler asks about once a
|
|
305
|
+
-- second, and it lived only inside the JSON: answering meant reading and
|
|
306
|
+
-- parsing every definition, due or not. A generated column keeps the JSON
|
|
307
|
+
-- as the only record while giving the query planner a value it can index; a
|
|
308
|
+
-- disabled or archived task has no next run, so NULLs stay out of the index.
|
|
309
|
+
ALTER TABLE tasks ADD COLUMN next_run_at INTEGER
|
|
310
|
+
GENERATED ALWAYS AS (json_extract(json, '$.nextRunAt')) VIRTUAL;
|
|
311
|
+
CREATE INDEX tasks_due ON tasks(next_run_at) WHERE next_run_at IS NOT NULL;
|
|
291
312
|
`,
|
|
292
313
|
];
|
|
293
314
|
/**
|
|
@@ -310,6 +331,24 @@ export function transact(db, work) {
|
|
|
310
331
|
throw err;
|
|
311
332
|
}
|
|
312
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Prepared statements, memoized by their SQL. `prepare()` compiles, and the
|
|
336
|
+
* callers here hand it the same handful of strings forever — twice per
|
|
337
|
+
* authenticated request, once a second per scheduler sweep. A `StatementSync`
|
|
338
|
+
* is reusable with different bound parameters, so one per SQL string per
|
|
339
|
+
* connection is the whole cache. Bound to the connection because a statement
|
|
340
|
+
* belongs to the database that compiled it; SQL built per call does not
|
|
341
|
+
* belong in here, since the cache would then grow without a bound.
|
|
342
|
+
*/
|
|
343
|
+
export function statements(db) {
|
|
344
|
+
const cache = new Map();
|
|
345
|
+
return (sql) => {
|
|
346
|
+
let stmt = cache.get(sql);
|
|
347
|
+
if (!stmt)
|
|
348
|
+
cache.set(sql, (stmt = db.prepare(sql)));
|
|
349
|
+
return stmt;
|
|
350
|
+
};
|
|
351
|
+
}
|
|
313
352
|
let shared;
|
|
314
353
|
/**
|
|
315
354
|
* The process's one connection, opened and migrated on first use. Every store
|
|
@@ -350,6 +389,11 @@ export function openDb(path, migrations = MIGRATIONS) {
|
|
|
350
389
|
// file, and SQLite refuses to change it inside one.
|
|
351
390
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
352
391
|
db.exec("PRAGMA journal_mode = WAL");
|
|
392
|
+
// WAL's default leaves every commit waiting on an fsync, and DatabaseSync is
|
|
393
|
+
// synchronous — that wait is the event loop's. NORMAL still survives a
|
|
394
|
+
// process crash; only a power loss can cost the last transactions, which for
|
|
395
|
+
// routing state and task bookkeeping is a fair trade for not blocking.
|
|
396
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
353
397
|
// Off by default in SQLite, and a declared relationship nothing enforces is
|
|
354
398
|
// a comment. Set before migrate(): it is a per-connection switch and a no-op
|
|
355
399
|
// inside a transaction. Nothing older declares a key, so this changes the
|
package/dist/main.js
CHANGED
|
@@ -345,6 +345,7 @@ app.route("/", createServer({
|
|
|
345
345
|
onUnlocked: () => void startChannels(),
|
|
346
346
|
reload: () => reloadInstance(true),
|
|
347
347
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
348
|
+
activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
|
|
348
349
|
channelOf: (id) => conversations.channelOf(id),
|
|
349
350
|
}));
|
|
350
351
|
const port = Number(process.env.PORT ?? 3141);
|
package/dist/secrets.js
CHANGED
|
@@ -119,6 +119,15 @@ export class Secrets {
|
|
|
119
119
|
this.#file = next;
|
|
120
120
|
log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* What vt says about itself. A refused or impossible unlock is almost never
|
|
124
|
+
* Pier's fault — missing binary, no agent listening, config pointing
|
|
125
|
+
* elsewhere — and `lockedReason` only carries the last error. Read-only, so
|
|
126
|
+
* it is safe to run while locked; vt's own report is the repair instruction.
|
|
127
|
+
*/
|
|
128
|
+
doctor() {
|
|
129
|
+
return this.vt.doctor();
|
|
130
|
+
}
|
|
122
131
|
/** First boot: random KEK and DEK, file mode. */
|
|
123
132
|
#create() {
|
|
124
133
|
const kek = randomBytes(KEY_BYTES);
|
|
@@ -172,20 +181,23 @@ export const vtCli = {
|
|
|
172
181
|
throw new Error("vt create printed no vt:// record");
|
|
173
182
|
return record;
|
|
174
183
|
},
|
|
184
|
+
// Bounded: doctor probes an agent socket and a worker over the network, and
|
|
185
|
+
// a hung probe would leave the Console waiting on a diagnosis forever.
|
|
186
|
+
doctor: () => run("vt", ["doctor"], undefined, 15_000),
|
|
175
187
|
};
|
|
176
|
-
function run(cmd, args, stdin) {
|
|
188
|
+
function run(cmd, args, stdin, timeoutMs) {
|
|
177
189
|
return new Promise((resolvePromise, reject) => {
|
|
178
|
-
const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
190
|
+
const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs });
|
|
179
191
|
let out = "";
|
|
180
192
|
let err = "";
|
|
181
193
|
child.stdout.on("data", (d) => (out += d.toString()));
|
|
182
194
|
child.stderr.on("data", (d) => (err += d.toString()));
|
|
183
195
|
child.on("error", reject);
|
|
184
|
-
child.on("close", (code) => {
|
|
196
|
+
child.on("close", (code, signal) => {
|
|
185
197
|
if (code === 0)
|
|
186
|
-
resolvePromise(out.trim());
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
return resolvePromise(out.trim());
|
|
199
|
+
const how = signal ? `timed out after ${timeoutMs}ms (${signal})` : `exited ${code}`;
|
|
200
|
+
reject(new Error(`${cmd} ${args[0]} ${how}: ${err.trim() || out.trim()}`));
|
|
189
201
|
});
|
|
190
202
|
child.stdin.on("error", reject); // EPIPE if vt exits before reading
|
|
191
203
|
if (stdin !== undefined)
|
package/dist/tasks/agent.js
CHANGED
|
@@ -74,9 +74,17 @@ export class AgentTaskRunner {
|
|
|
74
74
|
run.context.renderedPrompt = prompt;
|
|
75
75
|
this.store.saveRun(run);
|
|
76
76
|
let text = "";
|
|
77
|
+
// Not only what the turn said but how it ended: a provider outage ends
|
|
78
|
+
// it with an empty reply, and a run that settles on that reports "no
|
|
79
|
+
// reply" — the one wording for a turn that *chose* to say nothing, so
|
|
80
|
+
// the outage arrives at the caller looking like an answer (§5b). Pi has
|
|
81
|
+
// already retried by the time this lands; the run only reports.
|
|
82
|
+
let failure;
|
|
77
83
|
const unsubscribe = session.subscribe((event) => {
|
|
78
|
-
if (event.type === "turn-end")
|
|
84
|
+
if (event.type === "turn-end") {
|
|
79
85
|
text = event.text;
|
|
86
|
+
failure = event.error;
|
|
87
|
+
}
|
|
80
88
|
});
|
|
81
89
|
// The race below also settles this attempt if Pi ignores the abort:
|
|
82
90
|
// a hung turn must not hold one of the 4 slots (and its waiters)
|
|
@@ -106,6 +114,11 @@ export class AgentTaskRunner {
|
|
|
106
114
|
await Promise.race([turn, abortedTurn]);
|
|
107
115
|
if (signal.aborted)
|
|
108
116
|
throw new Error("cancelled");
|
|
117
|
+
// Before the fallback below: on a reused session that reads the
|
|
118
|
+
// *previous* turn's answer back out of history and reports it as
|
|
119
|
+
// this run's result.
|
|
120
|
+
if (failure)
|
|
121
|
+
throw new Error(failure);
|
|
109
122
|
if (!text) {
|
|
110
123
|
const history = await session.history();
|
|
111
124
|
text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
|
|
@@ -228,7 +228,9 @@ export class TaskDefinitions {
|
|
|
228
228
|
}
|
|
229
229
|
claimDue(now) {
|
|
230
230
|
const due = [];
|
|
231
|
-
|
|
231
|
+
// The store narrows to the tasks with a next run at or before `now`; the
|
|
232
|
+
// JSON is still the record, so its own fields decide.
|
|
233
|
+
for (const task of this.store.listDueTasks(now)) {
|
|
232
234
|
if (!task.enabled || task.archived || task.nextRunAt === null || task.nextRunAt > now)
|
|
233
235
|
continue;
|
|
234
236
|
task.nextRunAt = nextRunAt(task.trigger, now);
|
package/dist/tasks/messages.js
CHANGED
|
@@ -84,8 +84,7 @@ export class TaskMessenger {
|
|
|
84
84
|
* `inject` dedupes on the recipient transcript, so a retry cannot double
|
|
85
85
|
* deliver. Controls aimed at a finished run are dead and expire here. */
|
|
86
86
|
retryUndelivered(now = Date.now()) {
|
|
87
|
-
for (const message of this.store.listUndeliveredMessages()) {
|
|
88
|
-
const run = this.store.getRun(message.runId);
|
|
87
|
+
for (const { message, run } of this.store.listUndeliveredMessages()) {
|
|
89
88
|
if (!run)
|
|
90
89
|
continue;
|
|
91
90
|
// Expiring a dead control is not a retry, so it ignores the backoff.
|
package/dist/tasks/service.js
CHANGED
|
@@ -198,6 +198,11 @@ export class TaskService {
|
|
|
198
198
|
.reverse()
|
|
199
199
|
.map((run) => this.backgroundRun(run));
|
|
200
200
|
}
|
|
201
|
+
/** The same runs `backgroundRuns` reports as in flight, counted per session
|
|
202
|
+
* in one query — a list needs the number, not the runs. */
|
|
203
|
+
activeBackgroundRunCounts() {
|
|
204
|
+
return this.store.countActiveBackgroundRunsBySession();
|
|
205
|
+
}
|
|
201
206
|
run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
|
|
202
207
|
this.refusePaused(parentRunId);
|
|
203
208
|
const task = this.get(taskId);
|
package/dist/tasks/store.js
CHANGED
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
// runs, groups and messages are rows here, read and written through one
|
|
3
3
|
// connection db.ts opened. A store owns its queries, never its own tables or
|
|
4
4
|
// its own handle — the schema is db.ts's migration list.
|
|
5
|
-
import { pierDb } from "../db.js";
|
|
5
|
+
import { pierDb, statements } from "../db.js";
|
|
6
6
|
const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
|
|
7
7
|
export class TaskStore {
|
|
8
|
-
|
|
8
|
+
/** Every query below is a fixed string, so each is compiled once. */
|
|
9
|
+
sql;
|
|
9
10
|
constructor(db = pierDb()) {
|
|
10
|
-
this.
|
|
11
|
+
this.sql = statements(db);
|
|
11
12
|
}
|
|
12
13
|
// Every table is one JSON column plus query columns; these two are the only
|
|
13
14
|
// readers, and the one seam where a future schema change normalizes old rows
|
|
14
15
|
// (pre-v1 databases are refused outright in db.ts).
|
|
15
16
|
#one(sql, ...params) {
|
|
16
|
-
const row = this.
|
|
17
|
+
const row = this.sql(sql).get(...params);
|
|
17
18
|
return row ? JSON.parse(row.json) : undefined;
|
|
18
19
|
}
|
|
19
20
|
#many(sql, ...params) {
|
|
20
|
-
return this.
|
|
21
|
+
return this.sql(sql).all(...params)
|
|
21
22
|
.map((row) => JSON.parse(row.json));
|
|
22
23
|
}
|
|
23
24
|
listTasks() {
|
|
@@ -27,11 +28,17 @@ export class TaskStore {
|
|
|
27
28
|
return this.#one("SELECT json FROM tasks WHERE id = ?", id);
|
|
28
29
|
}
|
|
29
30
|
saveTask(task) {
|
|
30
|
-
this.
|
|
31
|
+
this.sql(`
|
|
31
32
|
INSERT INTO tasks(id, updated_at, json) VALUES (?, ?, ?)
|
|
32
33
|
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
|
|
33
34
|
`).run(task.id, task.updatedAt, JSON.stringify(task));
|
|
34
35
|
}
|
|
36
|
+
/** The tick's whole question, asked of the index rather than of every
|
|
37
|
+
* document: a task with no next run (disabled, archived, manual) is not in
|
|
38
|
+
* it, so an idle second reads no row. */
|
|
39
|
+
listDueTasks(now) {
|
|
40
|
+
return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
|
|
41
|
+
}
|
|
35
42
|
listRuns(taskId, limit = 50, offset = 0) {
|
|
36
43
|
return this.#many(`
|
|
37
44
|
SELECT json FROM task_runs WHERE task_id = ?
|
|
@@ -42,7 +49,7 @@ export class TaskStore {
|
|
|
42
49
|
return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
|
|
43
50
|
}
|
|
44
51
|
saveRun(run) {
|
|
45
|
-
this.
|
|
52
|
+
this.sql(`
|
|
46
53
|
INSERT INTO task_runs(id, task_id, queued_at, state, callback_state, json)
|
|
47
54
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
48
55
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -60,7 +67,7 @@ export class TaskStore {
|
|
|
60
67
|
`, rootRunId, clamp(limit, 500));
|
|
61
68
|
}
|
|
62
69
|
countActiveRuns() {
|
|
63
|
-
const row = this.
|
|
70
|
+
const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
|
|
64
71
|
return row.n;
|
|
65
72
|
}
|
|
66
73
|
findActiveRun(taskId) {
|
|
@@ -77,6 +84,20 @@ export class TaskStore {
|
|
|
77
84
|
ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
|
|
78
85
|
`, sessionId);
|
|
79
86
|
}
|
|
87
|
+
/** How many background runs each session has in flight, for a list that
|
|
88
|
+
* draws one dot per row: the state column narrows the scan to the handful
|
|
89
|
+
* of live runs, and no row's JSON is parsed. */
|
|
90
|
+
countActiveBackgroundRunsBySession() {
|
|
91
|
+
const rows = this.sql(`
|
|
92
|
+
SELECT json_extract(json, '$.invokedBySessionId') AS session_id, COUNT(*) AS n
|
|
93
|
+
FROM task_runs
|
|
94
|
+
WHERE state IN ('queued', 'running')
|
|
95
|
+
AND json_extract(json, '$.background') = 1
|
|
96
|
+
AND json_extract(json, '$.invokedBySessionId') IS NOT NULL
|
|
97
|
+
GROUP BY session_id
|
|
98
|
+
`).all();
|
|
99
|
+
return new Map(rows.map((row) => [row.session_id, row.n]));
|
|
100
|
+
}
|
|
80
101
|
listRunsForSession(sessionId, limit = 50) {
|
|
81
102
|
return this.#many(`
|
|
82
103
|
SELECT json FROM task_runs
|
|
@@ -85,7 +106,7 @@ export class TaskStore {
|
|
|
85
106
|
`, sessionId, clamp(limit, 200));
|
|
86
107
|
}
|
|
87
108
|
saveGroup(group) {
|
|
88
|
-
this.
|
|
109
|
+
this.sql(`
|
|
89
110
|
INSERT INTO task_groups(id, created_at, callback_state, finished_at, json)
|
|
90
111
|
VALUES (?, ?, ?, ?, ?)
|
|
91
112
|
ON CONFLICT(id) DO UPDATE SET
|
|
@@ -107,7 +128,7 @@ export class TaskStore {
|
|
|
107
128
|
`, now);
|
|
108
129
|
}
|
|
109
130
|
saveMessage(message) {
|
|
110
|
-
this.
|
|
131
|
+
this.sql(`
|
|
111
132
|
INSERT INTO task_messages(id, run_id, state, created_at, json)
|
|
112
133
|
VALUES (?, ?, ?, ?, ?)
|
|
113
134
|
ON CONFLICT(id) DO UPDATE SET state=excluded.state, json=excluded.json
|
|
@@ -122,9 +143,20 @@ export class TaskStore {
|
|
|
122
143
|
listRecentMessages(since) {
|
|
123
144
|
return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
|
|
124
145
|
}
|
|
125
|
-
/** Messages whose injection never landed
|
|
146
|
+
/** Messages whose injection never landed, each beside the run it belongs to:
|
|
147
|
+
* the delivery sweep needs both, and fetching the run per message made one
|
|
148
|
+
* sweep cost a query per undelivered message. A message whose run is gone
|
|
149
|
+
* comes back with `run: undefined` — the sweep drops it. */
|
|
126
150
|
listUndeliveredMessages() {
|
|
127
|
-
|
|
151
|
+
const rows = this.sql(`
|
|
152
|
+
SELECT m.json AS json, r.json AS run_json
|
|
153
|
+
FROM task_messages m LEFT JOIN task_runs r ON r.id = m.run_id
|
|
154
|
+
WHERE m.state IN ('pending', 'failed') ORDER BY m.created_at
|
|
155
|
+
`).all();
|
|
156
|
+
return rows.map((row) => ({
|
|
157
|
+
message: JSON.parse(row.json),
|
|
158
|
+
run: row.run_json === null ? undefined : JSON.parse(row.run_json),
|
|
159
|
+
}));
|
|
128
160
|
}
|
|
129
161
|
/** Decisions are excluded: they have no timeout and stay answerable across
|
|
130
162
|
* restarts — a reply to a terminal run resumes it. */
|
package/dist/tools.js
CHANGED
|
@@ -188,6 +188,10 @@ export function prependPath(env = process.env, bin = toolsBin()) {
|
|
|
188
188
|
mkdirSync(bin, { recursive: true }); // a PATH entry that does not exist is a shell's problem
|
|
189
189
|
env.PATH = current ? `${bin}${delimiter}${current}` : bin;
|
|
190
190
|
}
|
|
191
|
+
/** How long `ubix list --json` stays usable for `status()`: long enough that
|
|
192
|
+
* one Console page open spawns it once, short enough that an install done
|
|
193
|
+
* outside Pier shows up while the operator is still looking at the page. */
|
|
194
|
+
const LIST_TTL_MS = 3_000;
|
|
191
195
|
/** `stale` is heartbeat age, never how long the work has taken; `wait` is what
|
|
192
196
|
* a waiter gives a live holder before giving up with a reason. */
|
|
193
197
|
const LOCK_TIMING = { heartbeatMs: 5_000, staleMs: 30_000, waitMs: 20 * 60_000, pollMs: 200 };
|
|
@@ -606,7 +610,15 @@ export class ManagedTools {
|
|
|
606
610
|
this.#lock ??= new SyncLock(this.#db());
|
|
607
611
|
return this.#lock.run(async (fence) => {
|
|
608
612
|
const { tools, customTools } = read();
|
|
609
|
-
|
|
613
|
+
// This run is the only thing here that changes what `list` answers, so
|
|
614
|
+
// the memo `status()` reads is dropped on both sides of it.
|
|
615
|
+
this.#listed = undefined;
|
|
616
|
+
try {
|
|
617
|
+
return await this.#converge(tools, customTools, fence);
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
this.#listed = undefined;
|
|
621
|
+
}
|
|
610
622
|
});
|
|
611
623
|
}
|
|
612
624
|
async #converge(enabled, custom, fence) {
|
|
@@ -698,7 +710,7 @@ export class ManagedTools {
|
|
|
698
710
|
return base;
|
|
699
711
|
let states;
|
|
700
712
|
try {
|
|
701
|
-
states = await this.#
|
|
713
|
+
states = await this.#listedTools();
|
|
702
714
|
}
|
|
703
715
|
catch (err) {
|
|
704
716
|
// The page says why it cannot answer rather than answering wrongly: a
|
|
@@ -733,6 +745,24 @@ export class ManagedTools {
|
|
|
733
745
|
});
|
|
734
746
|
});
|
|
735
747
|
}
|
|
748
|
+
/** What `ubix list --json` last said, retained for LIST_TTL_MS. It is a
|
|
749
|
+
* subprocess, and the Console asks for the catalog on every settings read —
|
|
750
|
+
* one page open is several, each of which spawned its own ubix. A failed
|
|
751
|
+
* read is not retained: it is not an answer to hand the next caller for
|
|
752
|
+
* three seconds. */
|
|
753
|
+
#listed;
|
|
754
|
+
#listedTools() {
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
if (this.#listed && now - this.#listed.at < LIST_TTL_MS)
|
|
757
|
+
return this.#listed.states;
|
|
758
|
+
const states = this.#states(this.ubixPath, this.#env(), ["list", "--json"]);
|
|
759
|
+
void states.catch(() => {
|
|
760
|
+
if (this.#listed?.states === states)
|
|
761
|
+
this.#listed = undefined;
|
|
762
|
+
});
|
|
763
|
+
this.#listed = { at: now, states };
|
|
764
|
+
return states;
|
|
765
|
+
}
|
|
736
766
|
/** Pier's ubix config and state, never the operator's. `UBIX_CONFIG_DIR` /
|
|
737
767
|
* `UBIX_DATA_DIR` name the directories that hold config.toml / state.toml
|
|
738
768
|
* directly — not XDG parents, which every child ubix spawns (uv, fnm,
|
package/dist/web/auth.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { createHash, randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
|
|
21
21
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
22
22
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
23
|
-
import { pierDb, transact } from "../db.js";
|
|
23
|
+
import { pierDb, statements, transact } from "../db.js";
|
|
24
24
|
import { logger } from "../log.js";
|
|
25
25
|
const log = logger("auth");
|
|
26
26
|
const COOKIE = "pier_session";
|
|
@@ -67,9 +67,13 @@ function generatePassword() {
|
|
|
67
67
|
*/
|
|
68
68
|
export class AuthStore {
|
|
69
69
|
#db;
|
|
70
|
+
/** Compiled once each: `check()` runs two of these on every request that
|
|
71
|
+
* carries a cookie, which is every request the workbench makes. */
|
|
72
|
+
#sql;
|
|
70
73
|
#revokeListeners = new Set();
|
|
71
74
|
constructor(db = pierDb(), print = (m) => log.info(m)) {
|
|
72
75
|
this.#db = db;
|
|
76
|
+
this.#sql = statements(db);
|
|
73
77
|
let row = this.#row();
|
|
74
78
|
if (!row) {
|
|
75
79
|
const password = generatePassword();
|
|
@@ -81,8 +85,7 @@ export class AuthStore {
|
|
|
81
85
|
// in one transaction: half of this leaves a new password and live old
|
|
82
86
|
// cookies, which is the state recovery exists to end.
|
|
83
87
|
transact(this.#db, () => {
|
|
84
|
-
this.#
|
|
85
|
-
.prepare("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
|
|
88
|
+
this.#sql("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
|
|
86
89
|
.run(salt, hash(password, salt), Date.now());
|
|
87
90
|
this.#dropSessions();
|
|
88
91
|
});
|
|
@@ -96,14 +99,13 @@ export class AuthStore {
|
|
|
96
99
|
this.sweep();
|
|
97
100
|
}
|
|
98
101
|
#row() {
|
|
99
|
-
return this.#
|
|
100
|
-
.prepare("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
|
|
102
|
+
return this.#sql("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
|
|
101
103
|
.get();
|
|
102
104
|
}
|
|
103
105
|
/** Every signed-in browser at once. Private: the callers that mean it also
|
|
104
106
|
* have to tell the listeners, and `revoke(ALL)` is that pair in public. */
|
|
105
107
|
#dropSessions() {
|
|
106
|
-
this.#
|
|
108
|
+
this.#sql("DELETE FROM web_sessions").run();
|
|
107
109
|
}
|
|
108
110
|
/** Sessions nobody may use any more, deleted rather than merely refused: a
|
|
109
111
|
* row is what a push subscription hangs off, so "expired" has to become
|
|
@@ -111,8 +113,7 @@ export class AuthStore {
|
|
|
111
113
|
* boot and whenever somebody signs in — the two moments the process has a
|
|
112
114
|
* reason to look at this table at all. */
|
|
113
115
|
sweep() {
|
|
114
|
-
const swept = this.#
|
|
115
|
-
.prepare("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
|
|
116
|
+
const swept = this.#sql("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
|
|
116
117
|
.all(Date.now() - TTL_MS);
|
|
117
118
|
for (const row of swept)
|
|
118
119
|
this.#revoked(row.id);
|
|
@@ -135,8 +136,7 @@ export class AuthStore {
|
|
|
135
136
|
// Credential and sessions change together or not at all — a crash between
|
|
136
137
|
// the two writes is exactly the state "everyone signs in again" denies.
|
|
137
138
|
transact(this.#db, () => {
|
|
138
|
-
this.#
|
|
139
|
-
.prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
139
|
+
this.#sql("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
140
140
|
.run(salt, hash(password, salt), Date.now());
|
|
141
141
|
this.#dropSessions();
|
|
142
142
|
});
|
|
@@ -150,10 +150,8 @@ export class AuthStore {
|
|
|
150
150
|
// name, and the 256-bit token is the only part that has to resist guessing.
|
|
151
151
|
const id = randomBytes(9).toString("base64url");
|
|
152
152
|
const token = randomBytes(32).toString("base64url");
|
|
153
|
-
this.#
|
|
154
|
-
|
|
155
|
-
" VALUES (?, ?, ?, ?, ?, ?)")
|
|
156
|
-
.run(id, digest(token), now, now, ip, agent.slice(0, 200));
|
|
153
|
+
this.#sql("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
|
|
154
|
+
" VALUES (?, ?, ?, ?, ?, ?)").run(id, digest(token), now, now, ip, agent.slice(0, 200));
|
|
157
155
|
return `${id}.${token}`;
|
|
158
156
|
}
|
|
159
157
|
/**
|
|
@@ -166,9 +164,7 @@ export class AuthStore {
|
|
|
166
164
|
const [id, token] = (cookie ?? "").split(".");
|
|
167
165
|
if (!id || !token)
|
|
168
166
|
return undefined;
|
|
169
|
-
const row = this.#
|
|
170
|
-
.prepare("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?")
|
|
171
|
-
.get(id);
|
|
167
|
+
const row = this.#sql("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?").get(id);
|
|
172
168
|
const now = Date.now();
|
|
173
169
|
// One clock: last use is the deadline, so there is no second column that
|
|
174
170
|
// can disagree with it about when this session ends. An expired row is
|
|
@@ -184,7 +180,7 @@ export class AuthStore {
|
|
|
184
180
|
return undefined;
|
|
185
181
|
if (now - row.seenAt < TOUCH_MS)
|
|
186
182
|
return { id, renewed: false };
|
|
187
|
-
this.#
|
|
183
|
+
this.#sql("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
|
|
188
184
|
return { id, renewed: true };
|
|
189
185
|
}
|
|
190
186
|
/** Sign out one browser, or every one of them (`ALL`). Listeners hear the
|
|
@@ -193,16 +189,14 @@ export class AuthStore {
|
|
|
193
189
|
if (id === ALL)
|
|
194
190
|
this.#dropSessions();
|
|
195
191
|
else
|
|
196
|
-
this.#
|
|
192
|
+
this.#sql("DELETE FROM web_sessions WHERE id = ?").run(id);
|
|
197
193
|
this.#revoked(id);
|
|
198
194
|
}
|
|
199
195
|
/** Signed-in browsers, most recently seen first. Never the token — the list
|
|
200
196
|
* is shown to whoever is signed in, and it is not a set of credentials. */
|
|
201
197
|
list() {
|
|
202
|
-
return this.#
|
|
203
|
-
|
|
204
|
-
" FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC")
|
|
205
|
-
.all(Date.now() - TTL_MS);
|
|
198
|
+
return this.#sql("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
|
|
199
|
+
" FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC").all(Date.now() - TTL_MS);
|
|
206
200
|
}
|
|
207
201
|
/** A long-lived authenticated surface closes itself when a cookie is
|
|
208
202
|
* revoked. The store and listeners share the process lifetime. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The agent files a scope is configured by — Pi's own config, skills and
|
|
2
|
+
// extensions — read and written through the ConfigStore, never as paths. A
|
|
3
|
+
// scope is "global" or a project cwd Pi already knows, which is why this is
|
|
4
|
+
// the one filesystem-shaped surface that does not go through web/fs.ts: it
|
|
5
|
+
// never takes a path from the browser at all.
|
|
6
|
+
import { guarded } from "./route.js";
|
|
7
|
+
export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
|
|
8
|
+
// Scope comes from the client as "global" or a project cwd; only cwds Pi
|
|
9
|
+
// already knows (the session list) are accepted — never an arbitrary path.
|
|
10
|
+
const parseScope = async (raw) => {
|
|
11
|
+
if (!raw || raw === "global")
|
|
12
|
+
return { kind: "global" };
|
|
13
|
+
const known = await factory.list();
|
|
14
|
+
return known.some((s) => s.cwd === raw) ? { kind: "project", cwd: raw } : null;
|
|
15
|
+
};
|
|
16
|
+
app.get("/api/config", async (c) => {
|
|
17
|
+
c.header("cache-control", "no-store");
|
|
18
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
19
|
+
if (!scope)
|
|
20
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
21
|
+
return c.json({
|
|
22
|
+
// Where this scope's files live on disk — the UI labels "Global" with it.
|
|
23
|
+
dir: scope.kind === "global" ? config.globalDir : scope.cwd,
|
|
24
|
+
files: await config.listFiles(scope),
|
|
25
|
+
resources: await config.listResources(scope),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
|
|
29
|
+
c.header("cache-control", "no-store");
|
|
30
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
31
|
+
if (!scope)
|
|
32
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
33
|
+
return c.json({ content: await config.readFile(scope, c.req.param("name")) });
|
|
34
|
+
});
|
|
35
|
+
guarded(app, "PUT", "/api/config/files/:name", 400, async (c) => {
|
|
36
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
37
|
+
if (!scope)
|
|
38
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
39
|
+
const body = await c.req.json().catch(() => null);
|
|
40
|
+
if (typeof body?.content !== "string" || typeof body?.expected !== "string") {
|
|
41
|
+
return c.json({ error: "content and expected content required" }, 400);
|
|
42
|
+
}
|
|
43
|
+
const name = c.req.param("name");
|
|
44
|
+
await config.writeFile(scope, name, body.content, body.expected);
|
|
45
|
+
onConfigWritten?.();
|
|
46
|
+
return c.json({ ok: true, content: await config.readFile(scope, name) });
|
|
47
|
+
});
|
|
48
|
+
// Resource names may contain slashes — query params, not path params.
|
|
49
|
+
guarded(app, "GET", "/api/config/resource", 400, async (c) => {
|
|
50
|
+
c.header("cache-control", "no-store");
|
|
51
|
+
const scope = await parseScope(c.req.query("scope"));
|
|
52
|
+
if (!scope)
|
|
53
|
+
return c.json({ error: "unknown scope" }, 400);
|
|
54
|
+
const kind = c.req.query("kind");
|
|
55
|
+
const name = c.req.query("name");
|
|
56
|
+
if ((kind !== "extensions" && kind !== "skills") || !name) {
|
|
57
|
+
return c.json({ error: "kind and name required" }, 400);
|
|
58
|
+
}
|
|
59
|
+
return c.json({ content: await config.readResource(scope, kind, name) });
|
|
60
|
+
});
|
|
61
|
+
}
|