@timqi/pier 0.0.1 → 0.0.3
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 +87 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack-tool.js +28 -3
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +92 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/drain.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// A graceful restart: refuse new work, let running turns finish, and write
|
|
2
|
+
// down what the deadline had to cut off so the next boot can tell the chats.
|
|
3
|
+
//
|
|
4
|
+
// The trigger is SIGUSR2 (main.ts); systemd's `Restart=always` is the "start
|
|
5
|
+
// again" half. SIGTERM stays the fast path systemd expects — this file is only
|
|
6
|
+
// the slow one. Nothing here is persisted for its own sake: everything durable
|
|
7
|
+
// (transcripts, the chat → session map, task runs) already survives a restart,
|
|
8
|
+
// so the ledger below holds only the one thing that would otherwise vanish
|
|
9
|
+
// silently — turns and queued messages the deadline aborted (§5b).
|
|
10
|
+
import { logger } from "./log.js";
|
|
11
|
+
const log = logger("drain");
|
|
12
|
+
/** How long running turns may take before they are aborted. Generous: a turn
|
|
13
|
+
* can be a subagent fan-out, and an abort still persists the partial work. */
|
|
14
|
+
export const DRAIN_DEADLINE_MS = 5 * 60_000;
|
|
15
|
+
const POLL_MS = 1_000;
|
|
16
|
+
/** Shared cleanup window after the deadline. All sessions use the same clock,
|
|
17
|
+
* so N hung seams still cost at most this long rather than N times as long. */
|
|
18
|
+
const CLEANUP_BOUND_MS = 10_000;
|
|
19
|
+
/** What the dying process owes the chats, held for the next one to deliver. */
|
|
20
|
+
export class RestartLedger {
|
|
21
|
+
db;
|
|
22
|
+
constructor(db) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
}
|
|
25
|
+
record(entry) {
|
|
26
|
+
this.db.prepare("INSERT INTO restart_ledger (channel_id, conversation_id, note, created_at) VALUES (?, ?, ?, ?)").run(entry.channelId, entry.conversationId, entry.note, Date.now());
|
|
27
|
+
}
|
|
28
|
+
list() {
|
|
29
|
+
const rows = this.db.prepare("SELECT id, channel_id, conversation_id, note FROM restart_ledger ORDER BY id").all();
|
|
30
|
+
return rows.map((row) => ({
|
|
31
|
+
id: row.id,
|
|
32
|
+
channelId: row.channel_id,
|
|
33
|
+
conversationId: row.conversation_id,
|
|
34
|
+
note: row.note,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
remove(id) {
|
|
38
|
+
this.db.prepare("DELETE FROM restart_ledger WHERE id = ?").run(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve when the process may exit: every turn settled and every task run
|
|
43
|
+
* terminal, or the deadline reached and the stragglers aborted into the
|
|
44
|
+
* ledger. The caller (main.ts) owns what happens next — the ordinary shutdown,
|
|
45
|
+
* minus aborting task runs: the boot-time interrupted marking is the recovery
|
|
46
|
+
* path (tasks/service.ts start()), not a teardown race against dying channels.
|
|
47
|
+
*/
|
|
48
|
+
export async function drainForRestart(deps, deadlineMs = DRAIN_DEADLINE_MS, pollMs = POLL_MS, cleanupBoundMs = CLEANUP_BOUND_MS) {
|
|
49
|
+
const { router, tasks, ledger } = deps;
|
|
50
|
+
router.beginDrain();
|
|
51
|
+
tasks.pause();
|
|
52
|
+
const deadline = Date.now() + deadlineMs;
|
|
53
|
+
let lastReport = "";
|
|
54
|
+
for (;;) {
|
|
55
|
+
// Sleep first: a prompt accepted just before the gate closed may not have
|
|
56
|
+
// flipped its session to streaming yet, and exiting on that blink would
|
|
57
|
+
// cut off the very turn the drain exists to protect.
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
59
|
+
const busy = router.busy();
|
|
60
|
+
const runs = tasks.activeRunCount();
|
|
61
|
+
if (busy.length === 0 && runs === 0) {
|
|
62
|
+
log.info("drained — nothing running");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (Date.now() >= deadline) {
|
|
66
|
+
log.warn(`drain deadline after ${String(Math.round(deadlineMs / 1000))}s — aborting ${String(busy.length)} turn(s); ` +
|
|
67
|
+
`${String(runs)} task run(s) will be marked interrupted at boot`);
|
|
68
|
+
const cleanupDeadline = Date.now() + cleanupBoundMs;
|
|
69
|
+
await Promise.all(busy.map(({ session, key }) => abortToLedger(session, key, ledger, cleanupDeadline)));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const report = `draining: ${String(busy.length)} turn(s), ${String(runs)} active task run(s)`;
|
|
73
|
+
if (report !== lastReport)
|
|
74
|
+
log.info((lastReport = report));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** A seam call the deadline cannot wait on forever: a hang or a rejection is
|
|
78
|
+
* logged and answered with the fallback, and cleanup moves on. */
|
|
79
|
+
async function bounded(work, ms, what, fallback) {
|
|
80
|
+
let timer;
|
|
81
|
+
const timeout = new Promise((resolve) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
log.error(`${what} did not answer within ${String(ms)}ms`);
|
|
84
|
+
resolve(fallback);
|
|
85
|
+
}, ms);
|
|
86
|
+
timer.unref();
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([
|
|
90
|
+
work.catch((err) => {
|
|
91
|
+
log.error(`${what} failed`, err);
|
|
92
|
+
return fallback;
|
|
93
|
+
}),
|
|
94
|
+
timeout,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (timer)
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Write the chat's entry, then abort the turn. The ledger comes first so a
|
|
103
|
+
* hung abort cannot cost the note; the abort persists the partial transcript;
|
|
104
|
+
* the pending queue would just vanish, so its texts ride along. */
|
|
105
|
+
async function abortToLedger(session, key, ledger, cleanupDeadline) {
|
|
106
|
+
const remaining = () => Math.max(0, cleanupDeadline - Date.now());
|
|
107
|
+
const queued = await bounded(session.pendingQueue(), remaining(), `queue snapshot of session ${session.id}`, { steering: [], followUp: [] });
|
|
108
|
+
const pending = [...queued.steering, ...queued.followUp];
|
|
109
|
+
// A web or task key has no chat to write to: the transcript shows the
|
|
110
|
+
// aborted turn, and a task run's interruption is reported by its callback
|
|
111
|
+
// recovery. Only a dropped queue would be invisible there, so it is at
|
|
112
|
+
// least logged.
|
|
113
|
+
if (key.channelId === "web" || key.channelId === "task") {
|
|
114
|
+
if (pending.length) {
|
|
115
|
+
log.warn(`session ${session.id}: ${String(pending.length)} queued message(s) dropped by the restart`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const note = [
|
|
120
|
+
"Pier restarted before this turn finished — the last message may be unanswered.",
|
|
121
|
+
...(pending.length ? ["Queued and not delivered:", ...pending.map((text) => `> ${text}`)] : []),
|
|
122
|
+
].join("\n");
|
|
123
|
+
ledger.record({ channelId: key.channelId, conversationId: key.conversationId, note });
|
|
124
|
+
}
|
|
125
|
+
await bounded(session.abort(), remaining(), `abort of session ${session.id}`, undefined);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Deliver what a previous process wrote on its way out. Runs at boot once the
|
|
129
|
+
* adapters are up, and again on a Console unlock. Each entry is removed only
|
|
130
|
+
* after confirmed delivery. A missing adapter or a thrown notification keeps
|
|
131
|
+
* the debt for the next start: a duplicate apology is preferable to silence.
|
|
132
|
+
*/
|
|
133
|
+
export async function deliverLedger(ledger, notify) {
|
|
134
|
+
for (const entry of ledger.list()) {
|
|
135
|
+
const target = `${entry.channelId}:${entry.conversationId}`;
|
|
136
|
+
const delivered = await notify(entry).catch((err) => {
|
|
137
|
+
log.error(`restart note to ${target} failed`, err);
|
|
138
|
+
return null;
|
|
139
|
+
});
|
|
140
|
+
if (delivered === true)
|
|
141
|
+
ledger.remove(entry.id);
|
|
142
|
+
else if (delivered === false)
|
|
143
|
+
log.warn(`restart note waiting — ${target} is not running: ${entry.note}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -18,6 +18,7 @@ import { handleSlackTool, slackToolSpec } from "./channels/slack-tool.js";
|
|
|
18
18
|
import { parseConversation as parseSlackConversation } from "./channels/slack.js";
|
|
19
19
|
import { EventHub } from "./core/hub.js";
|
|
20
20
|
import { pierDb } from "./db.js";
|
|
21
|
+
import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
|
|
21
22
|
import { surfacePrompt } from "./core/reply.js";
|
|
22
23
|
import { Router } from "./core/router.js";
|
|
23
24
|
import { logger } from "./log.js";
|
|
@@ -27,7 +28,9 @@ import { TaskStore } from "./tasks/store.js";
|
|
|
27
28
|
import { taskToolSpec } from "./tasks/tool.js";
|
|
28
29
|
import { PIER_HOME, pierPath } from "./paths.js";
|
|
29
30
|
import { Secrets } from "./secrets.js";
|
|
31
|
+
import { startUpdate, unitPath, updaterProblem } from "./service.js";
|
|
30
32
|
import { SettingsStore } from "./settings.js";
|
|
33
|
+
import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
31
34
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
32
35
|
import { SessionStateStore } from "./web/session-state.js";
|
|
33
36
|
import { createServer } from "./web/server.js";
|
|
@@ -64,6 +67,7 @@ let channelStore;
|
|
|
64
67
|
// Shared by the adapter and the tool: a display name is looked up once per
|
|
65
68
|
// process, not once per message and again per transcript.
|
|
66
69
|
const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
|
|
70
|
+
const piConfig = new PiConfigStore();
|
|
67
71
|
const factory = new PiAgentFactory([
|
|
68
72
|
taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
|
|
69
73
|
slackToolSpec((params, callerSessionId) => handleSlackTool({
|
|
@@ -95,7 +99,9 @@ const factory = new PiAgentFactory([
|
|
|
95
99
|
[fileURLToPath(new URL("../skills", import.meta.url))],
|
|
96
100
|
// Provider credentials live sealed in pier.db; a leftover auth.json is
|
|
97
101
|
// imported on first use and renamed to auth.json.imported.
|
|
98
|
-
new CredentialStore(db, secrets)
|
|
102
|
+
new CredentialStore(db, secrets), piConfig,
|
|
103
|
+
// Operator pins ride ahead of the curated catalog in every model picker.
|
|
104
|
+
() => settings.get().modelMenu);
|
|
99
105
|
const hub = new EventHub();
|
|
100
106
|
const router = new Router(hub, (key) => {
|
|
101
107
|
// Web conversation ids ARE session ids; an IM conversation id is a chat or a
|
|
@@ -106,7 +112,12 @@ const router = new Router(hub, (key) => {
|
|
|
106
112
|
}
|
|
107
113
|
return resolveIm(key);
|
|
108
114
|
});
|
|
109
|
-
|
|
115
|
+
// An attached session holds a live Pi runtime and its transcript, and nothing
|
|
116
|
+
// else ever lets one go: without this, one per conversation ever answered.
|
|
117
|
+
const stopEviction = router.startIdleEviction();
|
|
118
|
+
tasks = new TaskService(new TaskStore(db), factory, router, hub, {
|
|
119
|
+
modelMenu: () => settings.get().modelMenu,
|
|
120
|
+
});
|
|
110
121
|
tasks.start();
|
|
111
122
|
channelStore = new ChannelStore(db, secrets);
|
|
112
123
|
const control = createControl({ router, factory, conversations, store: channelStore });
|
|
@@ -115,7 +126,110 @@ resolveIm = resolveConversation(conversations, factory, control.launchFor, (mess
|
|
|
115
126
|
// Channels connect only once tokens are readable. A refused unlock (vt denial,
|
|
116
127
|
// corrupt master.key) must not take the web surface down — it is where the
|
|
117
128
|
// operator goes to repair — but it is named loudly, not served as silence.
|
|
118
|
-
|
|
129
|
+
// Once they are up, the chats a previous restart cut off are told (drain.ts) —
|
|
130
|
+
// on this path and on a later Console unlock alike, because a note held back
|
|
131
|
+
// by locked secrets must not wait for yet another restart.
|
|
132
|
+
const restartLedger = new RestartLedger(db);
|
|
133
|
+
const startChannels = async () => {
|
|
134
|
+
await channels.reload();
|
|
135
|
+
await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
136
|
+
.catch((err) => log.error("restart-note delivery failed", err));
|
|
137
|
+
};
|
|
138
|
+
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
139
|
+
// Replacing Pier is systemd's job, not this process's: the oneshot unit stops
|
|
140
|
+
// the service, snapshots the database, installs and starts it again. Without
|
|
141
|
+
// that unit there is nothing to hand the work to, and the Console says so
|
|
142
|
+
// instead of offering a button that cannot work.
|
|
143
|
+
const updates = new UpdateCheck();
|
|
144
|
+
// Asked once at boot, not lazily on the first page load: a restart is exactly
|
|
145
|
+
// when "am I current?" is worth knowing, and it puts the answer in the journal
|
|
146
|
+
// of a Pier nobody has a browser open on.
|
|
147
|
+
void updates.refresh();
|
|
148
|
+
/**
|
|
149
|
+
* Hand over, but not onto a running turn. The updater's first act is
|
|
150
|
+
* `systemctl stop`, i.e. a SIGTERM, which is the *fast* teardown — so anything
|
|
151
|
+
* that started since the idle check would be killed with no note anywhere. The
|
|
152
|
+
* gate closes first and the drain waits, exactly as `pier restart` does,
|
|
153
|
+
* ledger included; only then is the install handed over. A handover that never
|
|
154
|
+
* starts reopens the gate, because a Pier that silently refuses every message
|
|
155
|
+
* forever is worse than the race it was avoiding.
|
|
156
|
+
*/
|
|
157
|
+
// Shared restart state. The updater's handover, the SIGUSR2 drain (below) and
|
|
158
|
+
// the final teardown must see each other: without this, two paths drain the
|
|
159
|
+
// same Pier at once, and a failure on one reopens the gate the other still
|
|
160
|
+
// needs shut.
|
|
161
|
+
let handingOver = false;
|
|
162
|
+
let draining = false;
|
|
163
|
+
let shuttingDown = false;
|
|
164
|
+
const takeWorkAgain = (why) => {
|
|
165
|
+
handingOver = false;
|
|
166
|
+
log.error(`${why} — taking work again`);
|
|
167
|
+
// Not ours to reopen: a SIGUSR2 restart or the teardown owns the gate now,
|
|
168
|
+
// and reopening it would hand new work to a process that is exiting.
|
|
169
|
+
if (draining || shuttingDown)
|
|
170
|
+
return;
|
|
171
|
+
router.endDrain();
|
|
172
|
+
tasks.unpause();
|
|
173
|
+
// The drain may have deadline-aborted turns into the ledger. Without the
|
|
174
|
+
// restart that was supposed to follow, that debt would wait for one days
|
|
175
|
+
// away (§5b) — so the chats are told now, by the process that cut them off.
|
|
176
|
+
void deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
177
|
+
.catch((err) => log.error("restart-note delivery failed", err));
|
|
178
|
+
};
|
|
179
|
+
/** How long the handover has to actually stop us. `systemctl start --no-block`
|
|
180
|
+
* returns when the job is *queued*, so "started" is not proof of anything;
|
|
181
|
+
* the real outcome is a SIGTERM a second or two later. */
|
|
182
|
+
const HANDOVER_GRACE_MS = 60_000;
|
|
183
|
+
const handOverToUpdater = async () => {
|
|
184
|
+
// One handover at a time, and never on top of a restart: the Console button,
|
|
185
|
+
// the auto-update tick and SIGUSR2 would otherwise drain the same Pier
|
|
186
|
+
// twice, each believing the gate is its own to reopen on failure.
|
|
187
|
+
if (handingOver || draining || shuttingDown)
|
|
188
|
+
return "busy";
|
|
189
|
+
handingOver = true;
|
|
190
|
+
await drainForRestart({ router, tasks, ledger: restartLedger });
|
|
191
|
+
const started = startUpdate({ say: (message) => log.info(message) });
|
|
192
|
+
if (started !== "started") {
|
|
193
|
+
takeWorkAgain(`update not started (${started})`);
|
|
194
|
+
return started;
|
|
195
|
+
}
|
|
196
|
+
// The gate is closed and nothing in this process will open it again, so a
|
|
197
|
+
// handover that queues and then goes nowhere — npm failed, the unit was
|
|
198
|
+
// masked, the job sat behind another — would leave Pier alive and refusing
|
|
199
|
+
// every message with no way back. Unref'd: this must not be what keeps the
|
|
200
|
+
// process up while systemd is trying to stop it.
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
takeWorkAgain(`still running ${String(HANDOVER_GRACE_MS / 1000)}s after handing over — pier-update.service never stopped Pier` +
|
|
203
|
+
` (check: journalctl --user -u pier-update.service -e)`);
|
|
204
|
+
}, HANDOVER_GRACE_MS).unref();
|
|
205
|
+
return started;
|
|
206
|
+
};
|
|
207
|
+
const updater = process.platform === "linux" && existsSync(unitPath())
|
|
208
|
+
? { apply: handOverToUpdater, problem: () => updaterProblem() }
|
|
209
|
+
: null;
|
|
210
|
+
// Unattended only when the operator asked for it *and* nothing is running.
|
|
211
|
+
if (updater) {
|
|
212
|
+
const problem = updaterProblem();
|
|
213
|
+
// Loudly, at boot: this is the one moment the operator is looking, and the
|
|
214
|
+
// alternative is a restart that fails months from now.
|
|
215
|
+
if (problem)
|
|
216
|
+
log.warn(`the updater cannot run: ${problem}`);
|
|
217
|
+
startAutoUpdate(updates, {
|
|
218
|
+
enabled: () => settings.get().autoUpdate,
|
|
219
|
+
idle: () => router.busy().length === 0 && tasks.activeRunCount() === 0,
|
|
220
|
+
apply: async () => {
|
|
221
|
+
// Re-checked here, not only at boot: a version manager can remove the
|
|
222
|
+
// recorded Node months into an uptime, and draining for a handover that
|
|
223
|
+
// cannot happen would take the whole instance down with it.
|
|
224
|
+
const now = updaterProblem();
|
|
225
|
+
if (now) {
|
|
226
|
+
log.error(`auto-update skipped: ${now}`);
|
|
227
|
+
return "not-installed";
|
|
228
|
+
}
|
|
229
|
+
return handOverToUpdater();
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
119
233
|
// Composition happens here so web/ and tasks/ never import each other.
|
|
120
234
|
const app = new Hono();
|
|
121
235
|
// A route that threw would otherwise answer 500 and leave no trace anywhere:
|
|
@@ -129,8 +243,8 @@ app.onError((err, c) => {
|
|
|
129
243
|
// so a surface added later is covered without knowing this exists. Built
|
|
130
244
|
// before the listener: a first run generates and prints its password here.
|
|
131
245
|
const auth = new AuthStore(db);
|
|
132
|
-
registerAuthRoutes(app, auth);
|
|
133
246
|
app.use("*", requireAuth(auth));
|
|
247
|
+
registerAuthRoutes(app, auth);
|
|
134
248
|
registerTaskRoutes(app, tasks, { factory, router });
|
|
135
249
|
registerChannelRoutes(app, channelStore, channels);
|
|
136
250
|
registerBoardRoutes(app);
|
|
@@ -139,11 +253,14 @@ app.route("/", createServer({
|
|
|
139
253
|
router,
|
|
140
254
|
hub,
|
|
141
255
|
sessions: new SessionStateStore(db),
|
|
142
|
-
config:
|
|
256
|
+
config: piConfig,
|
|
257
|
+
providers: factory,
|
|
143
258
|
settings,
|
|
144
259
|
secrets,
|
|
260
|
+
updates,
|
|
261
|
+
updater,
|
|
145
262
|
// Unlocked from the Console: start the channels boot held back.
|
|
146
|
-
onUnlocked: () => void
|
|
263
|
+
onUnlocked: () => void startChannels(),
|
|
147
264
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
148
265
|
}));
|
|
149
266
|
const port = Number(process.env.PORT ?? 3141);
|
|
@@ -164,20 +281,65 @@ process.on("uncaughtException", (err) => {
|
|
|
164
281
|
process.on("unhandledRejection", (reason) => {
|
|
165
282
|
log.error("unhandled rejection", reason);
|
|
166
283
|
});
|
|
284
|
+
const shutdown = (stopTasks = true) => {
|
|
285
|
+
// Once: SIGTERM can land while a drain is finishing, and two teardowns
|
|
286
|
+
// racing each other close the same sockets twice.
|
|
287
|
+
if (shuttingDown)
|
|
288
|
+
return;
|
|
289
|
+
shuttingDown = true;
|
|
290
|
+
// Best-effort, and bounded: a socket an adapter cannot close must not turn
|
|
291
|
+
// `systemctl restart` into a 90-second wait for SIGKILL.
|
|
292
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
293
|
+
stopEviction();
|
|
294
|
+
// The drain path leaves task runs alone: aborting them here would record
|
|
295
|
+
// them cancelled and race their callbacks against dying channels, when the
|
|
296
|
+
// boot-time interrupted marking is the recovery that was promised.
|
|
297
|
+
if (stopTasks)
|
|
298
|
+
tasks.stop();
|
|
299
|
+
void channels.stop().finally(() => {
|
|
300
|
+
server.close(() => process.exit(0));
|
|
301
|
+
// Every workbench tab holds an SSE stream open, so `close()` alone would
|
|
302
|
+
// always wait out the timer above. (`in` because the served type is a
|
|
303
|
+
// union with HTTP/2, which has no such method — and no such problem.)
|
|
304
|
+
if ("closeAllConnections" in server)
|
|
305
|
+
server.closeAllConnections();
|
|
306
|
+
});
|
|
307
|
+
};
|
|
167
308
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
168
309
|
process.once(signal, () => {
|
|
169
310
|
log.info(`${signal} received, shutting down`);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
311
|
+
shutdown();
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// The slow restart (`pier restart`): refuse new work, let running turns finish
|
|
315
|
+
// — bounded by the drain deadline — then exit for `Restart=always` to bring the
|
|
316
|
+
// next process up. SIGTERM above stays the fast path systemd expects. `on`,
|
|
317
|
+
// not `once`: a second SIGUSR2 with no handler would fall back to Node's
|
|
318
|
+
// default and kill the drain it meant to hurry.
|
|
319
|
+
process.on("SIGUSR2", () => {
|
|
320
|
+
if (draining) {
|
|
321
|
+
log.info("SIGUSR2 received again — already draining");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
draining = true;
|
|
325
|
+
log.info("SIGUSR2 received, draining for restart");
|
|
326
|
+
void drainForRestart({ router, tasks, ledger: restartLedger })
|
|
327
|
+
.catch((err) => log.error("drain failed — shutting down anyway", err))
|
|
328
|
+
.then(() => shutdown(false));
|
|
329
|
+
});
|
|
330
|
+
// Reload without a restart (`pier reload`): adapters re-read their config, and
|
|
331
|
+
// idle sessions are let go so the next message re-opens them with the current
|
|
332
|
+
// skills, extensions and prompts — all applied at attach, none stored in a
|
|
333
|
+
// transcript. Streaming or watched sessions pick the change up at their next
|
|
334
|
+
// natural eviction. Only under systemd (the CLI signals through systemctl):
|
|
335
|
+
// a foreground `pier` keeps SIGHUP's default, dying with its terminal instead
|
|
336
|
+
// of surviving as an orphan that holds the port.
|
|
337
|
+
if (process.env.INVOCATION_ID) {
|
|
338
|
+
process.on("SIGHUP", () => {
|
|
339
|
+
log.info("SIGHUP received, reloading channels and recycling idle sessions");
|
|
340
|
+
void channels.reload();
|
|
341
|
+
void router.evictIdle(0)
|
|
342
|
+
.then((n) => log.info(`recycled ${String(n)} idle session(s)`))
|
|
343
|
+
.catch((err) => log.error("session recycle failed", err));
|
|
182
344
|
});
|
|
183
345
|
}
|
package/dist/secrets.js
CHANGED
|
@@ -26,7 +26,6 @@ export class Secrets {
|
|
|
26
26
|
path;
|
|
27
27
|
vt;
|
|
28
28
|
#dek;
|
|
29
|
-
#kek;
|
|
30
29
|
#file;
|
|
31
30
|
/** Why decrypt is refused right now — "" once unlocked. */
|
|
32
31
|
#lockedReason = "unlock() has not run";
|
|
@@ -58,7 +57,12 @@ export class Secrets {
|
|
|
58
57
|
try {
|
|
59
58
|
raw = readFileSync(this.path, "utf8");
|
|
60
59
|
}
|
|
61
|
-
catch {
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Only a missing file means first boot. Any other read error (EACCES,
|
|
62
|
+
// EISDIR…) must not fall through to #create(), which would rename a
|
|
63
|
+
// fresh key over the existing one and destroy every sealed credential.
|
|
64
|
+
if (err.code !== "ENOENT")
|
|
65
|
+
throw err;
|
|
62
66
|
this.#file = this.#create();
|
|
63
67
|
log.info(`created ${this.path} (file mode)`);
|
|
64
68
|
raw = readFileSync(this.path, "utf8");
|
|
@@ -66,12 +70,12 @@ export class Secrets {
|
|
|
66
70
|
const file = JSON.parse(raw);
|
|
67
71
|
if (!file.kek || !file.dek || !file.dekId)
|
|
68
72
|
throw new Error(`${this.path} is malformed`);
|
|
69
|
-
|
|
73
|
+
const kek = file.kek.startsWith("vt://")
|
|
70
74
|
? Buffer.from(await this.vt.read(file.kek), "base64")
|
|
71
75
|
: Buffer.from(file.kek, "base64");
|
|
72
|
-
if (
|
|
76
|
+
if (kek.length !== KEY_BYTES)
|
|
73
77
|
throw new Error(`${this.path} KEK is not ${KEY_BYTES} bytes`);
|
|
74
|
-
this.#dek = open(
|
|
78
|
+
this.#dek = open(kek, file.dek, `kek:${file.dekId}`);
|
|
75
79
|
this.#file = file;
|
|
76
80
|
this.#lockedReason = "";
|
|
77
81
|
log.info(`secrets unlocked (${this.mode} mode, dek ${file.dekId})`);
|
|
@@ -112,7 +116,6 @@ export class Secrets {
|
|
|
112
116
|
throw new Error("vt create did not return a vt:// record");
|
|
113
117
|
}
|
|
114
118
|
this.#write(next);
|
|
115
|
-
this.#kek = kek;
|
|
116
119
|
this.#file = next;
|
|
117
120
|
log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
|
|
118
121
|
}
|
|
@@ -184,6 +187,7 @@ function run(cmd, args, stdin) {
|
|
|
184
187
|
else
|
|
185
188
|
reject(new Error(`${cmd} ${args[0]} exited ${code}: ${err.trim() || out.trim()}`));
|
|
186
189
|
});
|
|
190
|
+
child.stdin.on("error", reject); // EPIPE if vt exits before reading
|
|
187
191
|
if (stdin !== undefined)
|
|
188
192
|
child.stdin.write(stdin);
|
|
189
193
|
child.stdin.end();
|