@timqi/pier 0.0.16 → 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 +105 -0
- package/dist/main.js +1 -0
- package/dist/secrets.js +18 -6
- package/dist/settings.js +2 -11
- 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 +42 -18
- package/dist/web/auth.js +212 -68
- 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-C4ivXTBE.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-2E9_cwpg.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/push.js +30 -7
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +104 -23
- package/dist/web/terminal.js +21 -16
- package/docs/deploy.md +12 -3
- 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-DVUvzNK1.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
|
@@ -253,8 +253,102 @@ const MIGRATIONS = [
|
|
|
253
253
|
token TEXT NOT NULL,
|
|
254
254
|
heartbeat_at INTEGER NOT NULL
|
|
255
255
|
);
|
|
256
|
+
`,
|
|
257
|
+
// 13 — a signed-in browser can be signed out on its own (web/auth.ts).
|
|
258
|
+
`
|
|
259
|
+
-- One row per signed-in browser. The cookie carries "<id>.<token>" and only
|
|
260
|
+
-- the token's SHA-256 is stored, so a copy of this database cannot be turned
|
|
261
|
+
-- into a session — and deleting a row is what revocation is. seen_at is the
|
|
262
|
+
-- whole lifetime: the session ends one TTL after it, so there is no second
|
|
263
|
+
-- column that can disagree about when.
|
|
264
|
+
CREATE TABLE web_sessions (
|
|
265
|
+
id TEXT PRIMARY KEY,
|
|
266
|
+
token_hash TEXT NOT NULL,
|
|
267
|
+
created_at INTEGER NOT NULL,
|
|
268
|
+
seen_at INTEGER NOT NULL,
|
|
269
|
+
ip TEXT NOT NULL,
|
|
270
|
+
agent TEXT NOT NULL
|
|
271
|
+
);
|
|
272
|
+
`,
|
|
273
|
+
// 14 — signing a browser out also stops notifying it (web/push.ts).
|
|
274
|
+
`
|
|
275
|
+
-- A subscription belongs to the web session that made it, and dies with it:
|
|
276
|
+
-- the cascade is the rule, so no code has to remember to run it — revoking a
|
|
277
|
+
-- session, changing the password and recovering it all reach here for free.
|
|
278
|
+
-- Rebuilt rather than altered because a foreign key cannot be added to an
|
|
279
|
+
-- existing table; nothing is carried over, since migration 13 invalidated
|
|
280
|
+
-- every cookie and each of these rows belongs to a browser that is now
|
|
281
|
+
-- signed out. A browser re-subscribes on its next load.
|
|
282
|
+
DROP TABLE push_subscriptions;
|
|
283
|
+
CREATE TABLE push_subscriptions (
|
|
284
|
+
endpoint TEXT PRIMARY KEY,
|
|
285
|
+
p256dh TEXT NOT NULL,
|
|
286
|
+
auth TEXT NOT NULL,
|
|
287
|
+
label TEXT NOT NULL,
|
|
288
|
+
created_at INTEGER NOT NULL,
|
|
289
|
+
session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
|
|
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;
|
|
256
312
|
`,
|
|
257
313
|
];
|
|
314
|
+
/**
|
|
315
|
+
* Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
|
|
316
|
+
* competes with another Pier process on the same file: taking the write lock
|
|
317
|
+
* up front turns a race into a wait, where deferred would turn it into
|
|
318
|
+
* SQLITE_BUSY halfway through. The rollback is the reason this is shared —
|
|
319
|
+
* three modules had written the same seven lines, and a `catch` that forgets
|
|
320
|
+
* to roll back leaves the connection in a transaction forever.
|
|
321
|
+
*/
|
|
322
|
+
export function transact(db, work) {
|
|
323
|
+
db.exec("BEGIN IMMEDIATE");
|
|
324
|
+
try {
|
|
325
|
+
const result = work();
|
|
326
|
+
db.exec("COMMIT");
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
db.exec("ROLLBACK");
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
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
|
+
}
|
|
258
352
|
let shared;
|
|
259
353
|
/**
|
|
260
354
|
* The process's one connection, opened and migrated on first use. Every store
|
|
@@ -295,6 +389,17 @@ export function openDb(path, migrations = MIGRATIONS) {
|
|
|
295
389
|
// file, and SQLite refuses to change it inside one.
|
|
296
390
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
297
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");
|
|
397
|
+
// Off by default in SQLite, and a declared relationship nothing enforces is
|
|
398
|
+
// a comment. Set before migrate(): it is a per-connection switch and a no-op
|
|
399
|
+
// inside a transaction. Nothing older declares a key, so this changes the
|
|
400
|
+
// behaviour of exactly one table — push_subscriptions, whose rows must not
|
|
401
|
+
// outlive the session that made them.
|
|
402
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
298
403
|
migrate(db, path, migrations);
|
|
299
404
|
if (path !== ":memory:")
|
|
300
405
|
restrict(path);
|
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/settings.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// too" — the agent is told the URL in its system prompt (core/reply.ts), and
|
|
8
8
|
// nothing outside this process ever opened that file.
|
|
9
9
|
import { isThinkingLevel } from "./core/types.js";
|
|
10
|
-
import { pierDb } from "./db.js";
|
|
10
|
+
import { pierDb, transact } from "./db.js";
|
|
11
11
|
import { logger } from "./log.js";
|
|
12
12
|
// The one place the custom-tool vocabulary lives (names, ubix sources, the
|
|
13
13
|
// names Pier already owns). Imported rather than copied: a second validator
|
|
@@ -201,16 +201,7 @@ export class SettingsStore {
|
|
|
201
201
|
* declared and invisible.
|
|
202
202
|
*/
|
|
203
203
|
transact(work) {
|
|
204
|
-
this.#db
|
|
205
|
-
try {
|
|
206
|
-
const result = work();
|
|
207
|
-
this.#db.exec("COMMIT");
|
|
208
|
-
return result;
|
|
209
|
-
}
|
|
210
|
-
catch (err) {
|
|
211
|
-
this.#db.exec("ROLLBACK");
|
|
212
|
-
throw err;
|
|
213
|
-
}
|
|
204
|
+
return transact(this.#db, work);
|
|
214
205
|
}
|
|
215
206
|
#set(key, value) {
|
|
216
207
|
this.#db.prepare(`
|
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
|
@@ -16,7 +16,7 @@ import { execFile } from "node:child_process";
|
|
|
16
16
|
import { createHash, randomUUID } from "node:crypto";
|
|
17
17
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { delimiter, join } from "node:path";
|
|
19
|
-
import { pierDb } from "./db.js";
|
|
19
|
+
import { pierDb, transact } from "./db.js";
|
|
20
20
|
import { logger } from "./log.js";
|
|
21
21
|
import { pierPath, resolveAgentDir } from "./paths.js";
|
|
22
22
|
const log = logger("tools");
|
|
@@ -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 };
|
|
@@ -253,21 +257,15 @@ export class SyncLock {
|
|
|
253
257
|
* in one immediate transaction, so two waiters cannot both win. */
|
|
254
258
|
#acquire(token) {
|
|
255
259
|
const now = Date.now();
|
|
256
|
-
this.#db
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return taken.changes === 1;
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
this.#db.exec("ROLLBACK");
|
|
269
|
-
throw err;
|
|
270
|
-
}
|
|
260
|
+
const { stale, taken } = transact(this.#db, () => ({
|
|
261
|
+
stale: this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
|
|
262
|
+
.run(now - this.#timing.staleMs),
|
|
263
|
+
taken: this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
|
|
264
|
+
.run(token, now),
|
|
265
|
+
}));
|
|
266
|
+
if (stale.changes && taken.changes)
|
|
267
|
+
log.warn("took over a tools sync lock whose holder stopped beating");
|
|
268
|
+
return taken.changes === 1;
|
|
271
269
|
}
|
|
272
270
|
/** Still ours? The authority is the row, asked now — not the heartbeat's own
|
|
273
271
|
* bookkeeping, which a stopped process does not get to run either. */
|
|
@@ -612,7 +610,15 @@ export class ManagedTools {
|
|
|
612
610
|
this.#lock ??= new SyncLock(this.#db());
|
|
613
611
|
return this.#lock.run(async (fence) => {
|
|
614
612
|
const { tools, customTools } = read();
|
|
615
|
-
|
|
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
|
+
}
|
|
616
622
|
});
|
|
617
623
|
}
|
|
618
624
|
async #converge(enabled, custom, fence) {
|
|
@@ -704,7 +710,7 @@ export class ManagedTools {
|
|
|
704
710
|
return base;
|
|
705
711
|
let states;
|
|
706
712
|
try {
|
|
707
|
-
states = await this.#
|
|
713
|
+
states = await this.#listedTools();
|
|
708
714
|
}
|
|
709
715
|
catch (err) {
|
|
710
716
|
// The page says why it cannot answer rather than answering wrongly: a
|
|
@@ -739,6 +745,24 @@ export class ManagedTools {
|
|
|
739
745
|
});
|
|
740
746
|
});
|
|
741
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
|
+
}
|
|
742
766
|
/** Pier's ubix config and state, never the operator's. `UBIX_CONFIG_DIR` /
|
|
743
767
|
* `UBIX_DATA_DIR` name the directories that hold config.toml / state.toml
|
|
744
768
|
* directly — not XDG parents, which every child ubix spawns (uv, fnm,
|