@saadnvd1/agent-os 0.3.460 → 0.3.461

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.
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Away mode, in motion: turning it on and off, telling each workspace's
3
+ * orchestrator, the sweep every SWEEP_EVERY_MS while it's on, the batched
4
+ * phone notification, and the report when it ends. Driven by the
5
+ * orchestrator watcher's tick (lib/orchestrator/watcher.ts).
6
+ */
7
+
8
+ import path from "path";
9
+ import { db, type Session } from "../db";
10
+ import { getWorkspace } from "../workspaces";
11
+ import { queueEvent } from "../orchestrator/events";
12
+ import { listOrchestrators } from "../orchestrator/home";
13
+ import { addNote } from "../orchestrator/notes";
14
+ import { isPaused } from "../orchestrator/pause";
15
+ import { sessionFacts } from "../orchestrator/facts";
16
+ import { getCheck } from "../orchestrator/checks";
17
+ import { holdingEscalations } from "../orchestrator/gates";
18
+ import { review } from "../orchestrator/review";
19
+ import { repoOf } from "../orchestrator/repo";
20
+ import { clip } from "../orchestrator/digest";
21
+ import { sendMessage } from "../bus";
22
+ import { notifyPhone } from "../notify";
23
+ import { getProject } from "../projects";
24
+ import { loadProjectConfig } from "../project-config";
25
+ import { run } from "../tasks/gh";
26
+ import { expandHome } from "../tasks/session";
27
+ import { auditId, auditsRoot, writeAtomic } from "../session-audit/store";
28
+ import {
29
+ activeAway,
30
+ claimSweep,
31
+ endAway,
32
+ fromSql,
33
+ isExpired,
34
+ saveReport,
35
+ startAway,
36
+ toSql,
37
+ untilText,
38
+ type AwayRow,
39
+ } from "./state";
40
+ import {
41
+ needsGitCheck,
42
+ pickCandidates,
43
+ SWEEP_EVERY_MS,
44
+ type Candidate,
45
+ } from "./sweep";
46
+ import { checkDeploy } from "./deploy";
47
+ import { buildAwayReport, phoneSummary, reportMarkdown } from "./report";
48
+ import { awayOnLine } from "./brief";
49
+
50
+ const PHONE_SOURCE = "away";
51
+ const PHONE_LINES = 8;
52
+ const GIT_AT_ONCE = 4;
53
+
54
+ // Every workspace with an orchestrator, by id.
55
+ function orchestrated(): { workspaceId: string; orchestratorId: string }[] {
56
+ return listOrchestrators()
57
+ .filter((o) => o.workspace_id && getWorkspace(o.workspace_id))
58
+ .map((o) => ({ workspaceId: o.workspace_id!, orchestratorId: o.id }));
59
+ }
60
+
61
+ function tellOrchestrators(key: string, line: string, now: number): void {
62
+ for (const { workspaceId } of orchestrated())
63
+ queueEvent(workspaceId, key, null, line, now);
64
+ }
65
+
66
+ // In Settings > Audits under "away", named for when it ended (local time)
67
+ // and its period, so another database's reports never overwrite it.
68
+ export const reportFile = (row: AwayRow) =>
69
+ path.join(
70
+ auditsRoot(),
71
+ "away",
72
+ `${auditId(new Date(fromSql(row.ended_at ?? row.started_at)))}-${row.id}.md`
73
+ );
74
+
75
+ export function turnAwayOn(
76
+ input: { setBy: string; expiresAt: number | null },
77
+ now = Date.now()
78
+ ): AwayRow {
79
+ const { row, started } = startAway(input, now);
80
+ // Sent again when the end moves, so the orchestrator has the new one.
81
+ tellOrchestrators(`away:${row.id}`, awayOnLine(row, now), now);
82
+ if (started)
83
+ for (const { workspaceId } of orchestrated())
84
+ addNote(
85
+ workspaceId,
86
+ `Saad turned Away on, ${untilText(row, now)}.`,
87
+ "away"
88
+ );
89
+ return row;
90
+ }
91
+
92
+ // Ends Away, builds the report, keeps it, and tells the orchestrators and
93
+ // his phone. Null when it was already off.
94
+ export function turnAwayOff(
95
+ by: "saad" | "expired",
96
+ now = Date.now()
97
+ ): AwayRow | null {
98
+ // Ended and its report kept together: a throw or a crash between them
99
+ // can't leave a period over with no report. A throw keeps Away on.
100
+ const done = db.transaction(() => {
101
+ const row = endAway(by, now);
102
+ if (!row) return null;
103
+ const report = buildAwayReport(row, deployConfigured);
104
+ saveReport(row.id, report);
105
+ return { ended: row, report };
106
+ })();
107
+ if (!done) return null;
108
+ const { ended, report } = done;
109
+ try {
110
+ writeAtomic(reportFile(ended), reportMarkdown(report));
111
+ } catch (error) {
112
+ console.error("[away] couldn't write the report file:", error);
113
+ }
114
+ const line =
115
+ by === "expired"
116
+ ? "Away mode ran out: Saad may be back. Go back to reacting to events; don't drive interactive sessions."
117
+ : "Saad is back: Away mode is off. Go back to reacting to events.";
118
+ tellOrchestrators(`back:${ended.id}`, line, now);
119
+ for (const { workspaceId } of orchestrated())
120
+ addNote(
121
+ workspaceId,
122
+ by === "expired" ? "Away ran out." : "Saad turned Away off.",
123
+ "away"
124
+ );
125
+ // What came up since the last sweep's batch (a held `aos notify` is in
126
+ // no report), then the summary. A second later, so the window takes
127
+ // everything up to now.
128
+ notifyBatch(ended, now + 1000);
129
+ notifyPhone(PHONE_SOURCE, phoneSummary(report));
130
+ return { ...ended, report: JSON.stringify(report) };
131
+ }
132
+
133
+ function deployConfigured(projectId: string): boolean {
134
+ const project = getProject(projectId);
135
+ return (
136
+ !!project &&
137
+ !!loadProjectConfig(expandHome(project.working_directory)).config.deploy
138
+ );
139
+ }
140
+
141
+ // Claimed before it's acted on: a crash loses one nudge rather than
142
+ // sending it twice. Refused once Away is off or the workspace is paused,
143
+ // so a sweep under way stops acting the moment either changes.
144
+ function claim(
145
+ away: AwayRow,
146
+ workspaceId: string,
147
+ subject: string,
148
+ kind: string,
149
+ key: string,
150
+ detail: string | null = null
151
+ ): boolean {
152
+ if (isPaused(workspaceId)) return false;
153
+ return (
154
+ db
155
+ .prepare(
156
+ `INSERT OR IGNORE INTO away_actions (away_id, workspace_id, subject, kind, key, detail)
157
+ SELECT ?, ?, ?, ?, ?, ?
158
+ WHERE EXISTS (SELECT 1 FROM away_periods WHERE id = ? AND ended_at IS NULL)`
159
+ )
160
+ .run(away.id, workspaceId, subject, kind, key, detail, away.id)
161
+ .changes === 1
162
+ );
163
+ }
164
+
165
+ function actedKeys(awayId: number, workspaceId: string): Set<string> {
166
+ return new Set(
167
+ (
168
+ db
169
+ .prepare(
170
+ `SELECT key FROM away_actions WHERE away_id = ? AND workspace_id = ?`
171
+ )
172
+ .all(awayId, workspaceId) as { key: string }[]
173
+ ).map((r) => r.key)
174
+ );
175
+ }
176
+
177
+ function nudgeCounts(awayId: number, workspaceId: string): Map<string, number> {
178
+ const rows = db
179
+ .prepare(
180
+ `SELECT subject, COUNT(*) AS n FROM away_actions
181
+ WHERE away_id = ? AND workspace_id = ? AND kind = 'nudge' GROUP BY subject`
182
+ )
183
+ .all(awayId, workspaceId) as { subject: string; n: number }[];
184
+ return new Map(rows.map((r) => [r.subject, r.n]));
185
+ }
186
+
187
+ const sessionRow = (id: string) =>
188
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
189
+ | Session
190
+ | undefined;
191
+
192
+ async function dirtySessions(ids: string[]): Promise<Set<string>> {
193
+ const dirty = new Set<string>();
194
+ // A few at a time: a burst of spawns is what loads the machine.
195
+ for (let i = 0; i < ids.length; i += GIT_AT_ONCE)
196
+ await Promise.all(
197
+ ids.slice(i, i + GIT_AT_ONCE).map(async (id) => {
198
+ const s = sessionRow(id);
199
+ // A linked machine's worktree isn't on this disk.
200
+ if (!s || (s.host_id && s.host_id !== "local")) return;
201
+ const dir = expandHome(s.worktree_path || s.working_directory);
202
+ const out = await run(
203
+ "git",
204
+ ["status", "--porcelain", "--untracked-files=normal"],
205
+ dir
206
+ ).catch(() => "");
207
+ if (out.trim()) dirty.add(id);
208
+ })
209
+ );
210
+ return dirty;
211
+ }
212
+
213
+ async function act(
214
+ away: AwayRow,
215
+ workspaceId: string,
216
+ orchestratorId: string,
217
+ c: Candidate
218
+ ): Promise<void> {
219
+ if (!claim(away, workspaceId, c.sessionId, c.kind, c.key)) return;
220
+ try {
221
+ if (c.kind === "review") await review(workspaceId, c.sessionId);
222
+ else
223
+ await sendMessage({
224
+ fromId: orchestratorId,
225
+ to: c.sessionId,
226
+ body: c.message!,
227
+ });
228
+ addNote(workspaceId, `Away sweep: ${c.log}`, "away");
229
+ } catch (error) {
230
+ const why = error instanceof Error ? error.message : String(error);
231
+ addNote(
232
+ workspaceId,
233
+ `Away sweep: ${c.log} It didn't go through: ${clip(why, 200)}`,
234
+ "away"
235
+ );
236
+ }
237
+ }
238
+
239
+ const SHA = /^[0-9a-f]{40}$/;
240
+
241
+ async function mergeCommit(repo: string, pr: number): Promise<string | null> {
242
+ const out = await run(
243
+ "gh",
244
+ [
245
+ "pr",
246
+ "view",
247
+ String(pr),
248
+ "--json",
249
+ "mergeCommit",
250
+ "--jq",
251
+ ".mergeCommit.oid",
252
+ ],
253
+ repo
254
+ ).catch(() => "");
255
+ const sha = out.trim();
256
+ return SHA.test(sha) ? sha : null;
257
+ }
258
+
259
+ // Each task merged while away, until its deploy is decided.
260
+ async function checkDeploys(
261
+ away: AwayRow,
262
+ workspaceId: string,
263
+ now: number
264
+ ): Promise<void> {
265
+ const merged = db
266
+ .prepare(
267
+ `SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
268
+ WHERE p.workspace_id = ? AND s.task_status = 'merged' AND s.merged_at >= ?
269
+ AND NOT EXISTS (SELECT 1 FROM away_actions a
270
+ WHERE a.away_id = ? AND a.key = 'deploy:' || s.id)`
271
+ )
272
+ .all(workspaceId, away.started_at, away.id) as Session[];
273
+ for (const s of merged) {
274
+ let repo: string;
275
+ try {
276
+ repo = repoOf(s);
277
+ } catch {
278
+ continue;
279
+ }
280
+ const config = loadProjectConfig(repo).config.deploy;
281
+ const verdict = await checkDeploy({
282
+ repo,
283
+ config,
284
+ mergeSha:
285
+ config && s.pr_number ? await mergeCommit(repo, s.pr_number) : null,
286
+ mergedAt: s.merged_at ? fromSql(s.merged_at) : now,
287
+ now,
288
+ });
289
+ if (verdict.state === "pending") continue;
290
+ const kind =
291
+ verdict.state === "ok"
292
+ ? "deploy_ok"
293
+ : verdict.state === "failed"
294
+ ? "deploy_failed"
295
+ : "deploy_unchecked";
296
+ if (!claim(away, workspaceId, s.id, kind, `deploy:${s.id}`, verdict.detail))
297
+ continue;
298
+ if (verdict.state === "unchecked") continue;
299
+ const line = `task ${s.name}: PR #${s.pr_number ?? "?"} merged; ${verdict.state === "ok" ? verdict.detail : `deploy failed: ${verdict.detail}`}`;
300
+ addNote(workspaceId, `Away sweep: ${line}`, "away");
301
+ if (verdict.state === "failed")
302
+ queueEvent(workspaceId, `deploy:${s.id}`, s.id, line, now);
303
+ }
304
+ }
305
+
306
+ export async function sweepWorkspace(
307
+ away: AwayRow,
308
+ workspaceId: string,
309
+ orchestratorId: string,
310
+ now = Date.now()
311
+ ): Promise<void> {
312
+ // Paused means it acts on nothing, the sweep included.
313
+ if (isPaused(workspaceId)) return;
314
+ const facts = await sessionFacts(workspaceId);
315
+ const acted = actedKeys(away.id, workspaceId);
316
+ const dirty = await dirtySessions(needsGitCheck(facts, now, acted));
317
+ const openOn = new Set(
318
+ (
319
+ db
320
+ .prepare(
321
+ `SELECT subject FROM orchestrator_asks WHERE workspace_id = ? AND status = 'open' AND subject LIKE 'task:%'`
322
+ )
323
+ .all(workspaceId) as { subject: string }[]
324
+ ).map((r) => r.subject.slice(5))
325
+ );
326
+ const held = new Set(
327
+ facts
328
+ .filter((f) => openOn.has(f.id) || holdingEscalations(f.id).length)
329
+ .map((f) => f.id)
330
+ );
331
+ const { act: todo, firstSeen } = pickCandidates({
332
+ now,
333
+ facts,
334
+ dirty,
335
+ held,
336
+ acted,
337
+ nudges: nudgeCounts(away.id, workspaceId),
338
+ reviewOf: (id, sha) => {
339
+ const c = getCheck(id, sha, "review");
340
+ return c ? { status: c.status, at: fromSql(c.created_at) } : null;
341
+ },
342
+ });
343
+ for (const key of firstSeen)
344
+ claim(away, workspaceId, key.split(":")[1], "seen", key);
345
+ for (const c of todo) await act(away, workspaceId, orchestratorId, c);
346
+ await checkDeploys(away, workspaceId, now);
347
+ }
348
+
349
+ // One phone message for what came up since the last: new asks across
350
+ // every workspace, and deploys that failed.
351
+ export function notifyBatch(away: AwayRow, now = Date.now()): string | null {
352
+ // [since, until): a row made in the second of this batch goes in the
353
+ // next one, never in none.
354
+ const since = away.asks_notified_at ?? away.started_at;
355
+ const until = toSql(now);
356
+ const claimed = db
357
+ .prepare(
358
+ `UPDATE away_periods SET asks_notified_at = ? WHERE id = ? AND asks_notified_at IS ?`
359
+ )
360
+ .run(toSql(now), away.id, away.asks_notified_at).changes;
361
+ if (!claimed) return null;
362
+ const asks = db
363
+ .prepare(
364
+ `SELECT a.kind, a.title, w.name AS workspace FROM orchestrator_asks a
365
+ JOIN workspaces w ON w.id = a.workspace_id
366
+ WHERE a.status = 'open' AND a.created_at >= ? AND a.created_at < ? ORDER BY a.id`
367
+ )
368
+ .all(since, until) as { kind: string; title: string; workspace: string }[];
369
+ // Failed deploys, and what orchestrators sent with `aos notify` while
370
+ // he was away (held for this batch).
371
+ const held = db
372
+ .prepare(
373
+ `SELECT a.kind, a.detail, w.name AS workspace FROM away_actions a
374
+ JOIN workspaces w ON w.id = a.workspace_id
375
+ WHERE a.away_id = ? AND a.kind IN ('deploy_failed', 'notify')
376
+ AND a.created_at >= ? AND a.created_at < ?
377
+ ORDER BY a.id`
378
+ )
379
+ .all(away.id, since, until) as {
380
+ kind: string;
381
+ detail: string | null;
382
+ workspace: string;
383
+ }[];
384
+ const deploys = held.filter((h) => h.kind === "deploy_failed");
385
+ const notes = held.filter((h) => h.kind === "notify");
386
+ if (!asks.length && !held.length) return null;
387
+ const lines = [
388
+ ...deploys.map(
389
+ (d) => `- ${d.workspace}: deploy failed: ${clip(d.detail ?? "", 120)}`
390
+ ),
391
+ ...asks.map((a) => `- ${a.workspace}: ${a.kind}: ${clip(a.title, 100)}`),
392
+ ...notes.map((n) => `- ${n.workspace}: ${clip(n.detail ?? "", 160)}`),
393
+ ];
394
+ const count = (n: number, one: string, many: string) =>
395
+ n ? [`${n} ${n === 1 ? one : many}`] : [];
396
+ const parts = [
397
+ ...count(asks.length, "new ask", "new asks"),
398
+ ...count(deploys.length, "failed deploy", "failed deploys"),
399
+ ...count(
400
+ notes.length,
401
+ "note from an orchestrator",
402
+ "notes from orchestrators"
403
+ ),
404
+ ];
405
+ const head = `While you're away: ${parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0]}`;
406
+ const shown = lines.slice(0, PHONE_LINES);
407
+ const more = lines.length - shown.length;
408
+ const text = [head, ...shown, ...(more ? [`- and ${more} more`] : [])].join(
409
+ "\n"
410
+ );
411
+ notifyPhone(PHONE_SOURCE, text);
412
+ return text;
413
+ }
414
+
415
+ let sweeping = false;
416
+
417
+ // Every watcher tick: cheap unless a sweep is due. Ends Away when its time
418
+ // runs out; otherwise sweeps every SWEEP_EVERY_MS, in the background so
419
+ // event delivery never waits on it.
420
+ export function awayTick(now = Date.now()): void {
421
+ const away = activeAway();
422
+ if (!away) return;
423
+ if (isExpired(away, now)) {
424
+ turnAwayOff("expired", now);
425
+ return;
426
+ }
427
+ if (sweeping || !claimSweep(away, SWEEP_EVERY_MS, now)) return;
428
+ sweeping = true;
429
+ void (async () => {
430
+ for (const { workspaceId, orchestratorId } of orchestrated()) {
431
+ try {
432
+ await sweepWorkspace(away, workspaceId, orchestratorId, now);
433
+ } catch (error) {
434
+ console.error(`[away] sweep of ${workspaceId}:`, error);
435
+ }
436
+ }
437
+ // Ended meanwhile: the report's summary says what's waiting.
438
+ const still = activeAway();
439
+ if (still?.id === away.id) notifyBatch(still, Date.now());
440
+ })()
441
+ .catch((error) => console.error("[away] sweep:", error))
442
+ .finally(() => {
443
+ sweeping = false;
444
+ });
445
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Away mode's state: one row per time Saad stepped away, with who set it,
3
+ * when, and when it runs out (null: until he's back). At most one is open.
4
+ * Times are SQLite's UTC "YYYY-MM-DD HH:MM:SS", like the tables the report
5
+ * reads, so they compare as text.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { SWEEP_EVERY_MS } from "./sweep";
10
+
11
+ export interface AwayRow {
12
+ id: number;
13
+ set_by: string;
14
+ started_at: string;
15
+ expires_at: string | null;
16
+ ended_at: string | null;
17
+ // "saad" when he turned it off, "expired" when its time ran out.
18
+ ended_by: string | null;
19
+ last_sweep_at: string | null;
20
+ asks_notified_at: string | null;
21
+ // The "While you were away" report (./report), JSON, built as it ended.
22
+ report: string | null;
23
+ report_seen_at: string | null;
24
+ }
25
+
26
+ // The longest Away can be set for ahead; "until I'm back" has no end.
27
+ export const MAX_AWAY_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ export const toSql = (ms: number) =>
30
+ new Date(ms).toISOString().replace("T", " ").slice(0, 19);
31
+ export const fromSql = (t: string) => Date.parse(`${t.replace(" ", "T")}Z`);
32
+
33
+ export function activeAway(): AwayRow | null {
34
+ return (
35
+ (db
36
+ .prepare(
37
+ `SELECT * FROM away_periods WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1`
38
+ )
39
+ .get() as AwayRow | undefined) ?? null
40
+ );
41
+ }
42
+
43
+ export function getAway(id: number): AwayRow | null {
44
+ return (
45
+ (db.prepare(`SELECT * FROM away_periods WHERE id = ?`).get(id) as
46
+ | AwayRow
47
+ | undefined) ?? null
48
+ );
49
+ }
50
+
51
+ export const isExpired = (row: AwayRow, now = Date.now()) =>
52
+ !!row.expires_at && fromSql(row.expires_at) <= now;
53
+
54
+ // Turns Away on, or moves the end of the one already on. Returns the row
55
+ // and whether it was newly started.
56
+ export function startAway(
57
+ input: { setBy: string; expiresAt: number | null },
58
+ now = Date.now()
59
+ ): { row: AwayRow; started: boolean } {
60
+ if (input.expiresAt !== null) {
61
+ if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now)
62
+ throw new Error("Away has to end in the future");
63
+ if (input.expiresAt - now > MAX_AWAY_MS)
64
+ throw new Error("Away can be set for at most a week ahead");
65
+ }
66
+ const expires = input.expiresAt === null ? null : toSql(input.expiresAt);
67
+ return db.transaction(() => {
68
+ const open = activeAway();
69
+ if (open) {
70
+ db.prepare(`UPDATE away_periods SET expires_at = ? WHERE id = ?`).run(
71
+ expires,
72
+ open.id
73
+ );
74
+ return { row: getAway(open.id)!, started: false };
75
+ }
76
+ const { lastInsertRowid } = db
77
+ .prepare(
78
+ `INSERT INTO away_periods (set_by, started_at, expires_at) VALUES (?, ?, ?)`
79
+ )
80
+ .run(input.setBy, toSql(now), expires);
81
+ return { row: getAway(Number(lastInsertRowid))!, started: true };
82
+ })();
83
+ }
84
+
85
+ // Ends the open period, once: a second caller (the timer and Saad at the
86
+ // same moment) gets null. The end is its expiry when that came first.
87
+ export function endAway(
88
+ by: "saad" | "expired",
89
+ now = Date.now()
90
+ ): AwayRow | null {
91
+ const open = activeAway();
92
+ if (!open) return null;
93
+ const end =
94
+ open.expires_at && fromSql(open.expires_at) < now
95
+ ? open.expires_at
96
+ : toSql(now);
97
+ const changed = db
98
+ .prepare(
99
+ `UPDATE away_periods SET ended_at = ?, ended_by = ? WHERE id = ? AND ended_at IS NULL`
100
+ )
101
+ .run(end, by, open.id).changes;
102
+ return changed ? getAway(open.id) : null;
103
+ }
104
+
105
+ export function saveReport(id: number, report: unknown): void {
106
+ db.prepare(`UPDATE away_periods SET report = ? WHERE id = ?`).run(
107
+ JSON.stringify(report),
108
+ id
109
+ );
110
+ }
111
+
112
+ // The newest ended period's report, until Saad dismisses it. An older one
113
+ // he never dismissed is in Audits, not shown again.
114
+ export function unseenReport(): AwayRow | null {
115
+ const row = db
116
+ .prepare(
117
+ `SELECT * FROM away_periods WHERE ended_at IS NOT NULL AND report IS NOT NULL
118
+ ORDER BY id DESC LIMIT 1`
119
+ )
120
+ .get() as AwayRow | undefined;
121
+ return row && !row.report_seen_at ? row : null;
122
+ }
123
+
124
+ export function markReportSeen(id: number, now = Date.now()): boolean {
125
+ return (
126
+ db
127
+ .prepare(
128
+ `UPDATE away_periods SET report_seen_at = ? WHERE id = ? AND report IS NOT NULL AND report_seen_at IS NULL`
129
+ )
130
+ .run(toSql(now), id).changes === 1
131
+ );
132
+ }
133
+
134
+ // Claims a sweep: true for the one caller that moved last_sweep_at.
135
+ export function claimSweep(
136
+ row: AwayRow,
137
+ everyMs: number,
138
+ now = Date.now()
139
+ ): boolean {
140
+ if (row.last_sweep_at && now - fromSql(row.last_sweep_at) < everyMs)
141
+ return false;
142
+ return (
143
+ db
144
+ .prepare(
145
+ `UPDATE away_periods SET last_sweep_at = ? WHERE id = ? AND ended_at IS NULL
146
+ AND last_sweep_at IS ?`
147
+ )
148
+ .run(toSql(now), row.id, row.last_sweep_at).changes === 1
149
+ );
150
+ }
151
+
152
+ // "4:30 PM", or "Sat 4:30 PM" when it isn't today, in the server's zone.
153
+ export function clockTime(ms: number, now = Date.now()): string {
154
+ const d = new Date(ms);
155
+ const time = d.toLocaleTimeString("en-US", {
156
+ hour: "numeric",
157
+ minute: "2-digit",
158
+ });
159
+ return d.toDateString() === new Date(now).toDateString()
160
+ ? time
161
+ : `${d.toLocaleDateString("en-US", { weekday: "short" })} ${time}`;
162
+ }
163
+
164
+ export function untilText(row: AwayRow, now = Date.now()): string {
165
+ return row.expires_at
166
+ ? `until ${clockTime(fromSql(row.expires_at), now)}`
167
+ : "until he's back";
168
+ }
169
+
170
+ // While Away is on, an orchestrator's `aos notify` waits for the sweep's
171
+ // one batched message rather than buzzing per item. True when held.
172
+ export function holdForBatch(
173
+ workspaceId: string,
174
+ sessionId: string,
175
+ text: string,
176
+ now = Date.now()
177
+ ): boolean {
178
+ const open = activeAway();
179
+ if (!open) return false;
180
+ return (
181
+ db
182
+ .prepare(
183
+ `INSERT OR IGNORE INTO away_actions (away_id, workspace_id, subject, kind, key, detail, created_at)
184
+ VALUES (?, ?, ?, 'notify', ?, ?, ?)`
185
+ )
186
+ .run(
187
+ open.id,
188
+ workspaceId,
189
+ sessionId,
190
+ `notify:${sessionId}:${now}:${text.length}`,
191
+ text,
192
+ toSql(now)
193
+ ).changes === 1
194
+ );
195
+ }
196
+
197
+ // Until the next sweep sends the batch, as near as can be told.
198
+ export function msToNextSweep(now = Date.now()): number {
199
+ const last = activeAway()?.last_sweep_at;
200
+ return last ? Math.max(0, fromSql(last) + SWEEP_EVERY_MS - now) : 0;
201
+ }