@timqi/pier 0.0.1 → 0.0.2

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.
Files changed (67) hide show
  1. package/README.md +76 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
@@ -13,7 +13,7 @@
13
13
  * into the factory). Both halves of the feature live in this file: the syntax
14
14
  * the agent is told to emit, and the parser that reads it back.
15
15
  */
16
- export const REPLY_SURFACE_PROMPT = `## Pier chat surface
16
+ const REPLY_SURFACE_PROMPT = `## Pier chat surface
17
17
 
18
18
  Your replies render in a chat UI (web and IM). Three optional markdown
19
19
  conventions:
@@ -25,15 +25,19 @@ conventions:
25
25
  - **Attachments** — link a file you produced by absolute \`file://\` URL:
26
26
  \`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
27
27
  other files as a download card; only files inside the session's working
28
- directory are readable.
28
+ directory or Pier's inbox are readable. The same convention runs inbound: a
29
+ user message ending in \`[name](file:///…)\` lines is carrying files the
30
+ sender attached, already saved to disk — read one only when it matters to
31
+ the task; every read puts its content in your context for good.
29
32
  - **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
30
33
  remains no message is sent. In a group chat you are handed every message,
31
34
  including humans talking to each other: stay silent rather than acknowledge
32
35
  what was not addressed to you.
33
36
 
34
37
  A message may start with \`[name<id> time]\` — the sender, added by Pier, not
35
- typed by them. It appears only when the speaker or the day changes, so the last
36
- one still applies. Use that \`id\` to mention someone; never ask for their own.
38
+ typed by them. It appears only on a change new speaker, a ~10-minute gap, a
39
+ new day so the last one still applies; a gap alone shows as time only, like
40
+ \`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
37
41
  `;
38
42
  /**
39
43
  * The contract above plus the two facts about *this* deployment that an agent
@@ -124,6 +128,17 @@ export function cjkFriendly(markdown) {
124
128
  });
125
129
  return out.replace(/\uE010(\d+)\uE011/g, (_m, i) => stash[Number(i)] ?? "");
126
130
  }
131
+ /**
132
+ * The one wording for a turn that said nothing, wherever it is shown — the
133
+ * chat surfaces and the task result path. The reason arrives pre-escaped
134
+ * because each surface escapes for its own markup; the label itself contains
135
+ * nothing any of them escape.
136
+ */
137
+ export const quietLabel = (silence) => silence ? `stayed silent — ${silence}` : "no reply";
138
+ /** One policy for "did this turn actually reply": options count as a reply —
139
+ * the buttons are the answer, so a turn that is only its options is not
140
+ * "nothing". Every surface decides through here. */
141
+ export const isSilentReply = (reply) => !reply.text.trim() && reply.suggestions.length === 0;
127
142
  /** How a reasoning level is spelled wherever a human reads it. */
128
143
  export const thinkingLabel = (level) => level === "xhigh" ? "Extra high" : level[0].toUpperCase() + level.slice(1);
129
144
  /** 1200 → "1.2K", 12_000 → "12K" — absolute token counts read badly inline. */
@@ -159,7 +174,6 @@ export const formatTurnMeta = (meta) => `${formatDuration(meta.durationMs)} · $
159
174
  const BLOCK = /(?:^|\n)[ \t]*-{3,}[ \t]*\r?\n((?:[ \t]*\[[^\]\r\n]+\][ \t]*(?:[||][ \t]*)?)+)\s*$/;
160
175
  const TOKEN = /\[([^\]\r\n]+)\]/g;
161
176
  const MAX_SUGGESTIONS = 5;
162
- /** Split an assistant turn's markdown into renderable text + next-step labels. */
163
177
  /**
164
178
  * A deliberate non-answer. In a group thread the agent is handed every message,
165
179
  * and most of them are two humans talking; a bot that replies to each one is
@@ -183,6 +197,7 @@ export function silentReason(markdown) {
183
197
  .filter(Boolean);
184
198
  return reasons.length ? reasons.join(" · ") : undefined;
185
199
  }
200
+ /** Split an assistant turn's markdown into renderable text + next-step labels. */
186
201
  export function splitReply(rawMarkdown, meta) {
187
202
  // Every surface that renders this goes through here, and every CommonMark
188
203
  // parser has some version of the CJK emphasis hole — so the repair belongs
@@ -1,11 +1,17 @@
1
- // Conversation → session routing plus event wiring. In-memory for v1;
2
- // persistence arrives with task storage (docs/plans/bootstrap.md step 4).
1
+ // Conversation → session routing plus event wiring. In-memory on purpose:
2
+ // the durable chat session map lives in channels/conversations.ts.
3
3
  import { logger } from "../log.js";
4
4
  import { EventHub } from "./hub.js";
5
5
  import { SenderPrefix, withPrefix } from "./identity.js";
6
6
  import { decide } from "./queue.js";
7
7
  import { splitReply } from "./reply.js";
8
8
  const log = logger("core");
9
+ /** How long a session may sit idle in memory before it is let go. Generous on
10
+ * purpose: eviction is a memory measure, and re-opening one costs a Pi
11
+ * resume plus the transcript being read back. */
12
+ const IDLE_TTL_MS = 30 * 60_000;
13
+ /** Sweep interval. Nothing here is urgent, so it is coarse. */
14
+ const SWEEP_MS = 5 * 60_000;
9
15
  /** An error goes into a chat window, so it is trimmed to something readable. */
10
16
  const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
11
17
  function keyOf(key) {
@@ -16,9 +22,16 @@ export class Router {
16
22
  resolve;
17
23
  byKey = new Map();
18
24
  bySession = new Map();
25
+ /** Resolves in flight, so two surfaces asking at once share one session
26
+ * object instead of opening a second Pi runtime on the same transcript.
27
+ * Web and task keys collapse to the session id they both name. */
28
+ opening = new Map();
19
29
  channels = new Map();
20
30
  /** Who each session last heard from, so a header costs tokens only on news. */
21
31
  senders = new SenderPrefix();
32
+ /** Set once by a graceful restart (src/drain.ts); never unset — the process
33
+ * exits when the drain ends. */
34
+ draining = false;
22
35
  constructor(hub,
23
36
  /** Create or resume the session owning a conversation (wired in main.ts). */
24
37
  resolve) {
@@ -55,6 +68,61 @@ export class Router {
55
68
  });
56
69
  });
57
70
  }
71
+ /**
72
+ * Report something that happened *to* a session rather than in it: a task
73
+ * result that could not be delivered, say. Its conversation is told when one
74
+ * is attached — an agent that was promised an answer and a human watching the
75
+ * same thread learn it is not coming from the same place they were waiting.
76
+ * Otherwise the hub carries it for the web timeline.
77
+ */
78
+ reportTo(sessionId, message) {
79
+ const key = this.conversationOf(sessionId);
80
+ if (key)
81
+ this.report(sessionId, key, message);
82
+ else
83
+ this.hub.emit(sessionId, { type: "error", message });
84
+ }
85
+ /**
86
+ * Let go of every session that has been idle too long, so a process serving
87
+ * IM threads and task runs for weeks does not hold one live Pi runtime per
88
+ * conversation it ever saw. Only the in-memory attachment goes: the durable
89
+ * conversation → session mapping stays, so the next message resumes the very
90
+ * same transcript (channels/conversations.ts).
91
+ *
92
+ * Skipped for anything that would notice: a streaming turn, and a session
93
+ * someone is still watching over SSE.
94
+ */
95
+ async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now()) {
96
+ let evicted = 0;
97
+ for (const [id, attached] of [...this.bySession]) {
98
+ if (attached.session.state === "streaming")
99
+ continue;
100
+ if (this.hub.hasSubscribers(id))
101
+ continue;
102
+ if (now - attached.activeAt < ttlMs)
103
+ continue;
104
+ this.bySession.delete(id);
105
+ this.forgetKeys(attached.session);
106
+ attached.unsubscribe();
107
+ this.senders.forget(id);
108
+ this.hub.dropReplay(id);
109
+ evicted += 1;
110
+ log.info(`evicted idle session ${id} (${keyOf(attached.key)})`);
111
+ // Best-effort: a runtime that will not shut down must not keep the
112
+ // sweeper from releasing the rest.
113
+ await attached.session.dispose().catch((err) => log.error(`disposing session ${id} failed`, err));
114
+ }
115
+ return evicted;
116
+ }
117
+ /** Run evictIdle on a timer. Returns the stop function (main.ts owns it). */
118
+ startIdleEviction() {
119
+ // Unref'd: a sweep pending is never a reason for the process to stay up.
120
+ const timer = setInterval(() => {
121
+ void this.evictIdle().catch((err) => log.error("idle sweep failed", err));
122
+ }, SWEEP_MS);
123
+ timer.unref();
124
+ return () => clearInterval(timer);
125
+ }
58
126
  stateOf(sessionId) {
59
127
  return this.bySession.get(sessionId)?.session.state;
60
128
  }
@@ -74,21 +142,43 @@ export class Router {
74
142
  conversationOf(sessionId) {
75
143
  return this.bySession.get(sessionId)?.key;
76
144
  }
145
+ /** Every key that points at this session object. One session can be reached
146
+ * under more than one — `web:<id>` and `task:<id>` name the same session —
147
+ * and a key left behind hands out a session that is no longer live. */
148
+ forgetKeys(session) {
149
+ for (const [key, held] of this.byKey)
150
+ if (held === session)
151
+ this.byKey.delete(key);
152
+ }
77
153
  /** Attach an existing session to a conversation and wire its events. */
78
154
  attach(key, session) {
79
- this.byKey.set(keyOf(key), session);
80
155
  const existing = this.bySession.get(session.id);
81
- if (existing?.session === session)
156
+ if (existing?.session === session) {
157
+ this.byKey.set(keyOf(key), session);
158
+ existing.activeAt = Date.now();
82
159
  return;
83
- this.bySession.set(session.id, { session, key, stateSince: Date.now() });
160
+ }
161
+ if (existing) {
162
+ // Two live objects on one transcript: both would write it and both would
163
+ // answer the chat. Single-flight `ensure` closes the race that makes
164
+ // this, so reaching here is a bug worth seeing — the replaced one is
165
+ // silenced and unreachable, rather than left answering under aliases
166
+ // nobody knows are stale.
167
+ log.warn(`session ${session.id} replaced while attached to ${keyOf(existing.key)}`);
168
+ existing.unsubscribe();
169
+ this.forgetKeys(existing.session);
170
+ }
171
+ this.byKey.set(keyOf(key), session);
84
172
  log.info(`attached ${keyOf(key)} → session ${session.id}`);
85
- session.subscribe((payload) => {
173
+ const unsubscribe = session.subscribe((payload) => {
86
174
  this.hub.emit(session.id, payload);
87
175
  // Run state is workspace-visible: every client's session list shows it.
88
176
  if (payload.type === "state") {
89
177
  const attached = this.bySession.get(session.id);
178
+ // Every turn passes through here, so this is also where a session
179
+ // proves to the sweeper that it is still in use.
90
180
  if (attached)
91
- attached.stateSince = Date.now();
181
+ attached.stateSince = attached.activeAt = Date.now();
92
182
  this.hub.emitWorkspace({
93
183
  type: "session-state",
94
184
  sessionId: session.id,
@@ -132,10 +222,43 @@ export class Router {
132
222
  }
133
223
  }
134
224
  });
225
+ this.bySession.set(session.id, {
226
+ session,
227
+ key,
228
+ stateSince: Date.now(),
229
+ activeAt: Date.now(),
230
+ unsubscribe,
231
+ });
135
232
  }
136
233
  async abort(sessionId) {
137
234
  await this.bySession.get(sessionId)?.session.abort();
138
235
  }
236
+ /** Refuse new work from every surface; in-flight turns keep running. */
237
+ beginDrain() {
238
+ this.draining = true;
239
+ }
240
+ /** For surfaces that mutate state before dispatching (the web's edit and
241
+ * queue-deliver routes): ask first, so a refused dispatch cannot cost a
242
+ * rewound transcript or a cleared queue. */
243
+ isDraining() {
244
+ return this.draining;
245
+ }
246
+ /** The drain gate, throwing. Told to the chat directly (5b): an adapter's
247
+ * dispatch catch only logs, and the web caller gets the throw. */
248
+ refuseDraining(key) {
249
+ const message = "Pier is restarting — this message was not taken; send it again in a moment.";
250
+ this.channels.get(key.channelId)
251
+ ?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
252
+ .catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
253
+ throw new Error(message);
254
+ }
255
+ /** Attached sessions still mid-turn — what the drain waits on, and what its
256
+ * deadline snapshots into the ledger. */
257
+ busy() {
258
+ return [...this.bySession.values()]
259
+ .filter((attached) => attached.session.state === "streaming")
260
+ .map((attached) => ({ session: attached.session, key: attached.key }));
261
+ }
139
262
  /**
140
263
  * The session already attached to a conversation, if any. Never creates one:
141
264
  * a channel's stop or settings command must not be what opens a session.
@@ -157,31 +280,80 @@ export class Router {
157
280
  this.byKey.set(keyOf(key), session);
158
281
  }
159
282
  if (!session) {
283
+ // Aliases share one lock: web:<id> and task:<id> must not each open one.
284
+ const lock = key.channelId === "web" || key.channelId === "task"
285
+ ? `session:${key.conversationId}`
286
+ : keyOf(key);
287
+ const inflight = this.opening.get(lock);
288
+ // A second caller rides the first one's resolve — which attaches before
289
+ // this continuation runs, having awaited it first — and registers its own
290
+ // key against the session that came back.
291
+ if (inflight) {
292
+ session = await inflight;
293
+ this.byKey.set(keyOf(key), session);
294
+ return this.reached(session);
295
+ }
160
296
  try {
161
- session = await this.resolve(key);
297
+ // Inside the try: a resolver that throws synchronously is the same
298
+ // failure as one that rejects, and reports the same way.
299
+ const opening = this.resolve(key);
300
+ this.opening.set(lock, opening);
301
+ session = await opening;
162
302
  }
163
303
  catch (err) {
164
- // The one failure with nowhere to report itself: there is no session id
165
- // to emit under yet, and every caller only sees a rejected promise.
166
- log.error(`could not open a session for ${keyOf(key)}`, err);
304
+ this.unopened(key, err);
167
305
  throw err;
168
306
  }
307
+ finally {
308
+ this.opening.delete(lock);
309
+ }
169
310
  this.attach(key, session);
170
311
  }
312
+ return this.reached(session);
313
+ }
314
+ /** A session that would not open has no event stream of its own to report on
315
+ * — unless its id is what we were asked for, which is what a web or task key
316
+ * is. Otherwise the chat that is waiting is told directly. Callers still get
317
+ * the rejection; this is only so the waiting side is not left with nothing. */
318
+ unopened(key, err) {
319
+ log.error(`could not open a session for ${keyOf(key)}`, err);
320
+ const message = truncate(`could not open a session: ${String(err)}`);
321
+ // A web or task key names the session that would not open, so its own
322
+ // stream is where the waiting surface is looking; an IM key names a chat.
323
+ if (key.channelId === "web" || key.channelId === "task") {
324
+ this.reportTo(key.conversationId, message);
325
+ return;
326
+ }
327
+ this.channels.get(key.channelId)
328
+ ?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
329
+ .catch((e) => log.error(`could not report it to ${key.channelId}`, e));
330
+ }
331
+ /** Reached for, so not idle — every surface that uses a session comes
332
+ * through `ensure`, including the ones that only read it. */
333
+ reached(session) {
334
+ const attached = this.bySession.get(session.id);
335
+ if (attached)
336
+ attached.activeAt = Date.now();
171
337
  return session;
172
338
  }
173
339
  async dispatch(msg) {
340
+ // Before ensure — a drain must not be what opens a session …
341
+ if (this.draining)
342
+ this.refuseDraining(msg.key);
174
343
  const session = await this.ensure(msg.key);
344
+ // … and after — a dispatch that was inside a slow ensure when the gate
345
+ // closed must not start the turn the drain just declared finished with.
346
+ if (this.draining)
347
+ this.refuseDraining(msg.key);
175
348
  const { action, text } = decide(msg, session.state);
176
349
  // A group chat is many people talking into one session; without a speaker
177
350
  // line the agent cannot tell them apart or mention anyone back. Emitted
178
351
  // only when the speaker or the clock says something new.
179
352
  const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
180
- log.debug(`${action} ${keyOf(msg.key)} → session ${session.id}` +
181
- ` (${String(prompt.length)} chars, ${String(msg.images?.length ?? 0)} images)`);
353
+ log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
182
354
  // Turn outcomes flow through the event stream; a rejected call surfaces
183
355
  // there too, never as a thrown exception across the seam.
184
- session[action](prompt, msg.images).catch((err) => {
356
+ session[action](prompt).catch((err) => {
185
357
  this.report(session.id, msg.key, String(err));
186
358
  });
187
359
  return { sessionId: session.id };
@@ -5,3 +5,56 @@
5
5
  * drift, and boundary validators use isThinkingLevel instead of their own copy. */
6
6
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
7
7
  export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
8
+ // Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
9
+ // non-Pi backend is bound to them by this seam.
10
+ export const PROVIDER_APIS = [
11
+ "openai-completions",
12
+ "openai-responses",
13
+ "anthropic-messages",
14
+ "google-generative-ai",
15
+ ];
16
+ export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
17
+ /** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
18
+ * write and web/ pre-checks them at its HTTP boundary, and neither may import
19
+ * the other. Throws the message the surface shows. */
20
+ export function validateProviderSetup(input) {
21
+ if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
22
+ throw new Error("invalid provider id");
23
+ }
24
+ if (input.endpoint) {
25
+ if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
26
+ throw new Error("invalid endpoint");
27
+ }
28
+ validateEndpoint(input.endpoint);
29
+ }
30
+ if (input.kind === "builtin")
31
+ return;
32
+ if (!input.endpoint)
33
+ throw new Error("custom provider endpoint required");
34
+ if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
35
+ throw new Error("invalid provider name");
36
+ }
37
+ if (!isProviderApi(input.api))
38
+ throw new Error("unsupported provider API");
39
+ if (!input.models.length || input.models.length > 100)
40
+ throw new Error("1-100 models required");
41
+ const ids = input.models.map((model) => model.id);
42
+ if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
43
+ throw new Error("model ids must be non-empty, trimmed and unique");
44
+ }
45
+ }
46
+ export function validateEndpoint(endpoint) {
47
+ let url;
48
+ try {
49
+ url = new URL(endpoint);
50
+ }
51
+ catch {
52
+ throw new Error("endpoint must be an http(s) URL");
53
+ }
54
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
55
+ throw new Error("endpoint must be an http(s) URL");
56
+ }
57
+ if (url.username || url.password || url.search || url.hash) {
58
+ throw new Error("endpoint must not contain credentials, query or fragment");
59
+ }
60
+ }
package/dist/db.js CHANGED
@@ -126,6 +126,18 @@ const MIGRATIONS = [
126
126
  key TEXT PRIMARY KEY,
127
127
  value TEXT NOT NULL
128
128
  );
129
+ `,
130
+ // 3 — what a restart's drain deadline cut off, told to the chat at next boot.
131
+ `
132
+ -- Written only when a graceful restart aborts a still-running turn; the next
133
+ -- boot delivers each row and clears it once delivered. Owned by drain.ts.
134
+ CREATE TABLE restart_ledger (
135
+ id INTEGER PRIMARY KEY,
136
+ channel_id TEXT NOT NULL,
137
+ conversation_id TEXT NOT NULL,
138
+ note TEXT NOT NULL,
139
+ created_at INTEGER NOT NULL
140
+ );
129
141
  `,
130
142
  ];
131
143
  let shared;
@@ -134,6 +146,17 @@ let shared;
134
146
  * defaults to it; a test passes `openDb(":memory:")` instead.
135
147
  */
136
148
  export const pierDb = () => (shared ??= openDb(PIER_DB));
149
+ /** A release-level restore point, taken while the service is stopped even when
150
+ * the release has no schema migration. The previous complete copy stays put if
151
+ * writing its replacement fails. */
152
+ export function backupDb(path = PIER_DB) {
153
+ if (!existsSync(path))
154
+ return undefined;
155
+ const bak = `${path}.release.bak`;
156
+ copyDatabase(path, bak);
157
+ log.info(`pre-update backup: ${bak}`);
158
+ return bak;
159
+ }
137
160
  /** Open a database, bring it to the current schema, and lock down its files.
138
161
  * `migrations` is injectable only so tests can exercise an upgrade — there is
139
162
  * exactly one real list. */
@@ -141,10 +164,11 @@ export function openDb(path, migrations = MIGRATIONS) {
141
164
  if (path !== ":memory:")
142
165
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
143
166
  const db = new DatabaseSync(path);
144
- // Outside the transaction below: journal_mode is a property of the file, and
145
- // SQLite refuses to change it inside one.
146
- db.exec("PRAGMA journal_mode = WAL");
167
+ // Timeout first: two processes booting together contend on the WAL switch
168
+ // itself. Outside the transaction below: journal_mode is a property of the
169
+ // file, and SQLite refuses to change it inside one.
147
170
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
171
+ db.exec("PRAGMA journal_mode = WAL");
148
172
  migrate(db, path, migrations);
149
173
  if (path !== ":memory:")
150
174
  restrict(path);
@@ -175,8 +199,6 @@ function migrate(db, path, migrations) {
175
199
  }
176
200
  if (at === target)
177
201
  return;
178
- if (at > 0 && path !== ":memory:")
179
- snapshot(db, path, at);
180
202
  // One transaction for the statements *and* the version number: a crash
181
203
  // between them would leave a database whose version describes a schema it
182
204
  // does not have, which is worse than a crash.
@@ -188,9 +210,24 @@ function migrate(db, path, migrations) {
188
210
  const { user_version: locked } = db.prepare("PRAGMA user_version").get();
189
211
  if (locked >= target) {
190
212
  db.exec("ROLLBACK");
213
+ if (locked > target) {
214
+ throw new Error(`${path} advanced to schema ${locked} while this Pier was waiting; it speaks ${target}`);
215
+ }
191
216
  log.info(`schema already at ${locked}, migrated by another process`);
192
217
  return;
193
218
  }
219
+ // Keep the write lock while a second, read-only connection copies the last
220
+ // committed state. That connection may VACUUM while this one holds a RESERVED
221
+ // lock; other Pier starts wait here instead of racing on the shared .tmp.
222
+ if (locked > 0 && path !== ":memory:") {
223
+ try {
224
+ snapshot(path, locked);
225
+ }
226
+ catch (err) {
227
+ db.exec("ROLLBACK");
228
+ throw err;
229
+ }
230
+ }
194
231
  let step = locked;
195
232
  try {
196
233
  for (; step < target; step++)
@@ -221,14 +258,23 @@ function migrate(db, path, migrations) {
221
258
  * old snapshot nor a complete new one. A rename is atomic: the `.bak` name only
222
259
  * ever refers to a finished copy.
223
260
  */
224
- function snapshot(db, path, at) {
261
+ function snapshot(path, at) {
225
262
  const bak = `${path}.v${at}.bak`;
263
+ copyDatabase(path, bak);
264
+ log.info(`pre-migration backup: ${bak}`);
265
+ }
266
+ function copyDatabase(path, bak) {
226
267
  const tmp = `${bak}.tmp`;
227
268
  rmSync(tmp, { force: true }); // a previous crash may have left one
228
- db.exec(`VACUUM INTO '${tmp.replaceAll("'", "''")}'`);
269
+ const source = new DatabaseSync(path, { readOnly: true });
270
+ try {
271
+ source.exec(`VACUUM INTO '${tmp.replaceAll("'", "''")}'`);
272
+ }
273
+ finally {
274
+ source.close();
275
+ }
229
276
  chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
230
277
  renameSync(tmp, bak);
231
- log.info(`pre-migration backup: ${bak}`);
232
278
  }
233
279
  /** Snapshots beside the database, newest schema first. */
234
280
  function backups(path) {
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
+ }