@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.
- package/README.md +41 -1
- package/app/api/away/route.ts +56 -0
- package/app/api/away/seen/route.ts +12 -0
- package/app/api/health/route.ts +9 -0
- package/app/page.tsx +2 -0
- package/components/Away/AwayReport.tsx +16 -0
- package/components/Away/AwayReportDialog.tsx +193 -0
- package/components/Away/AwayToggle.tsx +91 -0
- package/components/Away/time.test.ts +20 -0
- package/components/Away/time.ts +32 -0
- package/components/Away/useAwayCommands.ts +43 -0
- package/components/Chat/Messages.tsx +1 -0
- package/components/CommandPalette/useAppCommands.ts +2 -0
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- package/lib/away/brief.ts +23 -0
- package/lib/away/deploy.ts +173 -0
- package/lib/away/report.ts +398 -0
- package/lib/away/run.ts +445 -0
- package/lib/away/state.ts +201 -0
- package/lib/away/sweep.ts +216 -0
- package/lib/away/view.ts +51 -0
- package/lib/chat/events.ts +1 -1
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +69 -0
- package/lib/db/types.ts +4 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/watcher.ts +6 -0
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/package.json +1 -1
package/lib/away/run.ts
ADDED
|
@@ -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
|
+
}
|