@saadnvd1/agent-os 0.3.513 → 0.3.516

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.
@@ -3,13 +3,24 @@
3
3
  * the Connect service for a fresh connector token, which goes to the child
4
4
  * in TUNNEL_TOKEN only: never argv, never a file, never a log line. The
5
5
  * child exits → it's started again with backoff; stop() ends it.
6
+ *
7
+ * cloudflared must never outlive AgentOS: it would go on publishing
8
+ * whatever next holds the tunnel port. So it runs in a process group of its
9
+ * own under a small shell that kills it the moment AgentOS goes, however it
10
+ * goes (SIGKILL and out-of-memory included), and a copy a crash left behind
11
+ * anyway is killed before the next one starts.
6
12
  */
7
13
 
8
- import { execFile, spawn as nodeSpawn } from "child_process";
14
+ import {
15
+ execFile,
16
+ spawn as nodeSpawn,
17
+ type ChildProcess,
18
+ type SpawnOptions,
19
+ } from "child_process";
9
20
  import fs from "fs";
10
21
  import path from "path";
11
22
  import readline from "readline";
12
- import type { Readable } from "stream";
23
+ import type { Readable, Writable } from "stream";
13
24
  import { ApiError, clean } from "./control-api";
14
25
 
15
26
  export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
@@ -21,6 +32,7 @@ export interface Prepared {
21
32
 
22
33
  interface Child {
23
34
  pid?: number;
35
+ stdin?: Writable | null;
24
36
  stdout: Readable | null;
25
37
  stderr: Readable | null;
26
38
  kill(signal?: NodeJS.Signals): boolean;
@@ -53,7 +65,48 @@ const DELAYS = {
53
65
  // cloudflared can be chatty while the network flaps.
54
66
  const LOG_LINES_PER_MINUTE = 20;
55
67
 
56
- /** A cloudflared left running by an AgentOS that died without stopping it. */
68
+ // $1 is cloudflared, the rest its arguments. Its stdin is a pipe only
69
+ // AgentOS holds the other end of: when AgentOS ends, the read sees EOF and
70
+ // cloudflared is killed outright (a SIGTERM would let it drain, still
71
+ // forwarding, for half a minute). The shell exits with cloudflared's code.
72
+ const WATCHDOG = `exec 3<&0 </dev/null
73
+ "$@" 3<&- &
74
+ child=$!
75
+ trap 'kill -TERM "$child" 2>/dev/null' TERM INT HUP
76
+ { read -r _ <&3; kill -KILL "$child" 2>/dev/null; } &
77
+ watch=$!
78
+ exec 3<&-
79
+ while :; do
80
+ wait "$child"; code=$?
81
+ kill -0 "$child" 2>/dev/null || break
82
+ done
83
+ kill "$watch" 2>/dev/null
84
+ exit "$code"`;
85
+
86
+ /** cloudflared under the watchdog, its group killed as one. */
87
+ export const spawnTunnel: Spawn = (bin, args, opts) => {
88
+ if (process.platform === "win32")
89
+ return (nodeSpawn as unknown as Spawn)(bin, args, opts);
90
+ const proc: ChildProcess = nodeSpawn(
91
+ "/bin/sh",
92
+ ["-c", WATCHDOG, "agentos-cloudflared", bin, ...args],
93
+ {
94
+ env: opts.env,
95
+ stdio: ["pipe", "pipe", "pipe"],
96
+ detached: true,
97
+ } as SpawnOptions
98
+ );
99
+ proc.stdin?.on("error", () => {});
100
+ return Object.assign(proc, {
101
+ kill: (signal: NodeJS.Signals = "SIGTERM") => {
102
+ try {
103
+ if (proc.pid) return process.kill(-proc.pid, signal);
104
+ } catch {}
105
+ return proc.kill(signal);
106
+ },
107
+ }) as unknown as Child;
108
+ };
109
+
57
110
  const commandOf = (pid: number) =>
58
111
  new Promise<string>((resolve) =>
59
112
  execFile(
@@ -64,25 +117,60 @@ const commandOf = (pid: number) =>
64
117
  )
65
118
  );
66
119
 
67
- async function stopStale(pidFile: string, binDir: string) {
120
+ const signal = (pid: number, sig: NodeJS.Signals | 0) => {
121
+ try {
122
+ process.kill(pid, sig);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+
129
+ export interface ReapDeps {
130
+ commandOf?: (pid: number) => Promise<string>;
131
+ /** process.kill: false when there was nothing to signal. */
132
+ kill?: (pid: number, sig: NodeJS.Signals | 0) => boolean;
133
+ waitMs?: number;
134
+ }
135
+
136
+ /**
137
+ * Kills a cloudflared an AgentOS that died left running, named by the pid
138
+ * file, and only if that pid is still one of ours (pids get reused). Call
139
+ * it only while holding connect.lock: then no live AgentOS owns it.
140
+ */
141
+ export async function reapTunnel(
142
+ dir: string,
143
+ binDir = path.join(dir, "bin"),
144
+ deps: ReapDeps = {}
145
+ ) {
146
+ const pidFile = path.join(dir, "cloudflared.pid");
147
+ const kill = deps.kill ?? signal;
68
148
  let pid = 0;
69
149
  try {
70
150
  pid = Number(fs.readFileSync(pidFile, "utf8")) || 0;
71
151
  } catch {
72
- return;
152
+ return false;
73
153
  }
154
+ let reaped = false;
74
155
  if (pid > 1) {
75
- const command = await commandOf(pid);
76
- try {
77
- if (command.startsWith(binDir + path.sep)) process.kill(pid, "SIGTERM");
78
- } catch {}
156
+ const command = await (deps.commandOf ?? commandOf)(pid);
157
+ if (command.includes(binDir + path.sep)) {
158
+ // Its group (the watchdog and cloudflared), or a bare cloudflared an
159
+ // older AgentOS started outside one.
160
+ reaped = kill(-pid, "SIGKILL");
161
+ reaped = kill(pid, "SIGKILL") || reaped;
162
+ const end = Date.now() + (deps.waitMs ?? 2000);
163
+ while (kill(pid, 0) && Date.now() < end)
164
+ await new Promise((r) => setTimeout(r, 25));
165
+ }
79
166
  }
80
167
  fs.rmSync(pidFile, { force: true });
168
+ return reaped;
81
169
  }
82
170
 
83
171
  export function superviseTunnel(opts: SuperviseOptions) {
84
172
  const log = opts.log ?? (() => {});
85
- const spawn = opts.spawn ?? (nodeSpawn as unknown as Spawn);
173
+ const spawn = opts.spawn ?? spawnTunnel;
86
174
  const delays = opts.delays ?? DELAYS;
87
175
  const pidFile = path.join(opts.dir, "cloudflared.pid");
88
176
  const home = path.join(opts.dir, "cloudflared-home");
@@ -145,7 +233,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
145
233
 
146
234
  const run = async ({ bin, token }: Prepared) => {
147
235
  const redact = (line: string) => clean(line.split(token).join("[token]"));
148
- await stopStale(pidFile, path.dirname(bin));
236
+ await reapTunnel(opts.dir, path.dirname(bin));
149
237
  if (stopped) return;
150
238
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
151
239
  // Its own config and home, so no config.yml elsewhere on the machine
@@ -256,8 +344,18 @@ export function superviseTunnel(opts: SuperviseOptions) {
256
344
  proc.on("exit", (code) => end(`cloudflared exited (${code ?? "signal"})`));
257
345
  };
258
346
 
347
+ // A stop that never ran (the process exiting, or a signal nobody else
348
+ // handles) still takes cloudflared's whole group down first.
259
349
  const killNow = () => child?.kill("SIGTERM");
350
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
351
+ const onSignal = (sig: NodeJS.Signals) => {
352
+ killNow();
353
+ for (const s of SIGNALS) process.removeListener(s, onSignal);
354
+ // Nobody else handles it: end the way the signal would have.
355
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
356
+ };
260
357
  process.once("exit", killNow);
358
+ for (const s of SIGNALS) process.on(s, onSignal);
261
359
  tick();
262
360
 
263
361
  return {
@@ -268,6 +366,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
268
366
  stopped = true;
269
367
  state = "stopped";
270
368
  process.removeListener("exit", killNow);
369
+ for (const s of SIGNALS) process.removeListener(s, onSignal);
271
370
  if (timer) clearTimeout(timer);
272
371
  const proc = child;
273
372
  if (!proc) return;
@@ -1630,6 +1630,34 @@ const migrations: Migration[] = [
1630
1630
  );
1631
1631
  },
1632
1632
  },
1633
+ {
1634
+ id: 71,
1635
+ name: "devices_push",
1636
+ up: (db) => {
1637
+ // Push belongs to a paired device, so revoking the device clears it.
1638
+ // push_categories is a JSON array; NULL means the defaults.
1639
+ // Each column checked, so a run cut off part way can be run again.
1640
+ const have = new Set(
1641
+ (
1642
+ db.prepare(`PRAGMA table_info(devices)`).all() as { name: string }[]
1643
+ ).map((c) => c.name)
1644
+ );
1645
+ for (const col of [
1646
+ "push_token",
1647
+ "push_platform",
1648
+ "push_environment",
1649
+ "push_categories",
1650
+ "push_updated_at",
1651
+ "push_last_error",
1652
+ ])
1653
+ if (!have.has(col))
1654
+ db.exec(`ALTER TABLE devices ADD COLUMN ${col} TEXT`);
1655
+ db.exec(
1656
+ `CREATE INDEX IF NOT EXISTS idx_devices_push_token ON devices(push_token)
1657
+ WHERE push_token IS NOT NULL`
1658
+ );
1659
+ },
1660
+ },
1633
1661
  ];
1634
1662
 
1635
1663
  // `upTo`: stop after this id (tests that start from an older database).
@@ -9,6 +9,7 @@ import fs from "fs";
9
9
  import type Database from "better-sqlite3";
10
10
  import { notifyStatusChanged } from "./status/hub";
11
11
  import { resetDemoChat } from "./chat/demo";
12
+ import { markAllStatusDirty } from "./status/dirty";
12
13
 
13
14
  export const RESEED_MS = 10 * 60 * 1000;
14
15
 
@@ -92,6 +93,7 @@ export function startDemoReseed(
92
93
  try {
93
94
  restoreDemo(db, file);
94
95
  resetDemoChat();
96
+ markAllStatusDirty();
95
97
  notifyStatusChanged();
96
98
  onRestored();
97
99
  } catch (error) {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The category registry: one entry per kind of event, holding everything
3
+ * about it (priority, whether it's on by default, the copy, the deep link).
4
+ * Adding an event is one entry here. Pure, so the mobile app can sync it.
5
+ */
6
+
7
+ import {
8
+ PUSH_CATEGORIES,
9
+ PUSH_TITLE,
10
+ type PushCategory,
11
+ type PushEnvelope,
12
+ type PushEnvelopeV1,
13
+ type PushEvent,
14
+ type PushLevel,
15
+ } from "./push-types";
16
+ import { safeId, safeKey } from "./redact";
17
+
18
+ export interface CategoryDef {
19
+ level: PushLevel;
20
+ defaultOn: boolean;
21
+ /** Settings label. */
22
+ label: string;
23
+ /** The one word or phrase shown as the body. Fixed text, no event data. */
24
+ word: string;
25
+ /** App path for an id, or the fallback when the id is missing or unsafe. */
26
+ link: (id: string | null) => string;
27
+ }
28
+
29
+ /** Where a tap goes when nothing more specific is known. */
30
+ export const FALLBACK_LINK = "/needs";
31
+
32
+ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
33
+ asks: {
34
+ level: "time-sensitive",
35
+ defaultOn: true,
36
+ label: "Asks",
37
+ word: "Needs your call",
38
+ link: (id) => (id ? `/orchestrator?workspaceId=${id}` : FALLBACK_LINK),
39
+ },
40
+ "needs-you": {
41
+ level: "active",
42
+ defaultOn: true,
43
+ label: "Waiting for you",
44
+ word: "Needs you",
45
+ link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
46
+ },
47
+ review: {
48
+ level: "active",
49
+ defaultOn: true,
50
+ label: "Ready for review",
51
+ word: "Ready for review",
52
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
53
+ },
54
+ ci: {
55
+ level: "active",
56
+ defaultOn: true,
57
+ label: "CI and review problems",
58
+ word: "Update",
59
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
60
+ },
61
+ merged: {
62
+ level: "passive",
63
+ defaultOn: false,
64
+ label: "Merged",
65
+ word: "Update",
66
+ link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
67
+ },
68
+ agents: {
69
+ level: "active",
70
+ defaultOn: true,
71
+ label: "Messages from agents",
72
+ word: "Update",
73
+ link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
74
+ },
75
+ digest: {
76
+ level: "passive",
77
+ defaultOn: true,
78
+ label: "While you're away",
79
+ word: "Updates",
80
+ link: () => FALLBACK_LINK,
81
+ },
82
+ system: {
83
+ level: "active",
84
+ defaultOn: true,
85
+ label: "Machine alerts",
86
+ word: "Update",
87
+ link: () => "/machines",
88
+ },
89
+ };
90
+
91
+ export const isPushCategory = (c: unknown): c is PushCategory =>
92
+ typeof c === "string" && (PUSH_CATEGORIES as readonly string[]).includes(c);
93
+
94
+ export function defaultCategories(): PushCategory[] {
95
+ return PUSH_CATEGORIES.filter((c) => CATEGORIES[c].defaultOn);
96
+ }
97
+
98
+ /**
99
+ * The categories a device receives: its saved list, or the defaults when it
100
+ * has none. Unknown names are dropped so an old row survives a removed
101
+ * category.
102
+ */
103
+ export function enabledCategories(
104
+ saved: readonly unknown[] | null | undefined
105
+ ): PushCategory[] {
106
+ return saved
107
+ ? PUSH_CATEGORIES.filter((c) => saved.includes(c))
108
+ : defaultCategories();
109
+ }
110
+
111
+ /**
112
+ * The v1 payload for an event. Built only from the registry and a vetted id
113
+ * and key, so it carries no event text whatever the caller passes.
114
+ */
115
+ export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
116
+ const def = CATEGORIES[event.category];
117
+ const id = safeId(event.id);
118
+ return {
119
+ v: 1,
120
+ c: event.category,
121
+ t: PUSH_TITLE,
122
+ b: def.word,
123
+ l: def.link(id),
124
+ k: safeKey(event.key) ?? `${event.category}:${id ?? "-"}`,
125
+ p: def.level,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * The app side: read what arrived. An unknown version or a malformed body
131
+ * gives the generic fallback, so a newer server never breaks an older app.
132
+ */
133
+ export function parsePushEnvelope(raw: unknown): PushEnvelope {
134
+ const o = (raw && typeof raw === "object" ? raw : {}) as Record<
135
+ string,
136
+ unknown
137
+ >;
138
+ const category = isPushCategory(o.c) ? o.c : "system";
139
+ if (o.v === 1 || o.v === 2) {
140
+ const link =
141
+ typeof o.l === "string" && isAppLink(o.l)
142
+ ? o.l
143
+ : CATEGORIES[category].link(null);
144
+ const base = {
145
+ c: category,
146
+ t: PUSH_TITLE,
147
+ b: typeof o.b === "string" ? o.b.slice(0, 80) : CATEGORIES[category].word,
148
+ l: link,
149
+ k: safeKey(o.k) ?? `${category}:-`,
150
+ p: CATEGORIES[category].level,
151
+ } as const;
152
+ // A v2 body is read as its generic v1 alert until this app can decrypt.
153
+ return { ...base, v: 1 };
154
+ }
155
+ return buildPushPayload({ category });
156
+ }
157
+
158
+ const APP_PATHS = [
159
+ /^\/needs$/,
160
+ /^\/machines$/,
161
+ /^\/session\/[A-Za-z0-9_-]{1,64}$/,
162
+ /^\/task\/[A-Za-z0-9_-]{1,64}$/,
163
+ /^\/orchestrator\?workspaceId=[A-Za-z0-9_-]{1,64}$/,
164
+ ];
165
+
166
+ /** True for a link the registry could have produced. */
167
+ export function isAppLink(link: string): boolean {
168
+ return APP_PATHS.some((re) => re.test(link));
169
+ }
170
+
171
+ /**
172
+ * True only for a payload the registry could have built: fixed title, the
173
+ * category's own word, a known link shape, a plain key. A sender checks this
174
+ * before it sends, so nothing else can leave the machine.
175
+ */
176
+ export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
177
+ if (!p || typeof p !== "object") return false;
178
+ const o = p as Record<string, unknown>;
179
+ if (o.v !== 1 || !isPushCategory(o.c)) return false;
180
+ const def = CATEGORIES[o.c];
181
+ return (
182
+ Object.keys(o).sort().join() === "b,c,k,l,p,t,v" &&
183
+ o.t === PUSH_TITLE &&
184
+ o.b === def.word &&
185
+ o.p === def.level &&
186
+ typeof o.l === "string" &&
187
+ isAppLink(o.l) &&
188
+ safeKey(o.k) === o.k
189
+ );
190
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Push notification types. Pure: no imports from the app, so the mobile app
3
+ * can sync this file as is.
4
+ *
5
+ * The payload is a versioned envelope. v1 is generic and content-free (a fixed
6
+ * title, a category word, a deep link). v2 is reserved for encrypted rich
7
+ * text; an app that doesn't know a version shows the outer alert and opens
8
+ * the fallback link, so v2 can ship without breaking older apps.
9
+ */
10
+
11
+ export const PUSH_CATEGORIES = [
12
+ "asks",
13
+ "needs-you",
14
+ "review",
15
+ "ci",
16
+ "merged",
17
+ "agents",
18
+ "digest",
19
+ "system",
20
+ ] as const;
21
+ export type PushCategory = (typeof PUSH_CATEGORIES)[number];
22
+
23
+ /** Maps onto the APNs interruption level. */
24
+ export type PushLevel = "time-sensitive" | "active" | "passive";
25
+
26
+ export const PUSH_PLATFORMS = ["ios", "android"] as const;
27
+ export type PushPlatform = (typeof PUSH_PLATFORMS)[number];
28
+
29
+ export const PUSH_ENVIRONMENTS = ["production", "sandbox"] as const;
30
+ export type PushEnvironment = (typeof PUSH_ENVIRONMENTS)[number];
31
+
32
+ export const PUSH_TITLE = "AgentOS";
33
+
34
+ /** Plain, content-free alert. */
35
+ export interface PushEnvelopeV1 {
36
+ v: 1;
37
+ /** Category, so the app can group and filter. */
38
+ c: PushCategory;
39
+ /** Fixed title. */
40
+ t: typeof PUSH_TITLE;
41
+ /** Category word from the registry, never event text. */
42
+ b: string;
43
+ /** Deep link, an app path like `/session/abc`. */
44
+ l: string;
45
+ /** Replaces an earlier alert with the same key. Opaque. */
46
+ k: string;
47
+ /** Interruption level. */
48
+ p: PushLevel;
49
+ }
50
+
51
+ /** Reserved: encrypted rich text. Not produced yet. */
52
+ export interface PushEnvelopeV2 {
53
+ v: 2;
54
+ c: PushCategory;
55
+ t: typeof PUSH_TITLE;
56
+ /** Generic fallback body shown if the app can't decrypt. */
57
+ b: string;
58
+ l: string;
59
+ k: string;
60
+ p: PushLevel;
61
+ /** Device id the key belongs to. */
62
+ d: string;
63
+ /** Base64url nonce and ciphertext. */
64
+ n: string;
65
+ x: string;
66
+ }
67
+
68
+ export type PushEnvelope = PushEnvelopeV1 | PushEnvelopeV2;
69
+
70
+ /** What a sender needs to say about an event. Ids only, never text. */
71
+ export interface PushEvent {
72
+ category: PushCategory;
73
+ /** Session, task or workspace id the link opens, when the category has one. */
74
+ id?: string | null;
75
+ /** Dedupe key, e.g. `ask:<id>` or `review:<task>:<sha>`. */
76
+ key?: string | null;
77
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Keeps free text and secret-looking strings out of push payloads. Pure, so
3
+ * the mobile app can sync it.
4
+ */
5
+
6
+ const MAX_ID = 64;
7
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
8
+
9
+ /** Hex or base64 runs this long are treated as keys. */
10
+ const LONG_RUN = /[A-Za-z0-9+/_=-]{24,}/g;
11
+ const SECRET_PATTERNS: RegExp[] = [
12
+ /\baosd_[A-Za-z0-9_-]+/g,
13
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
14
+ /\bgh[pousr]_[A-Za-z0-9]{8,}/g,
15
+ /\bAKIA[0-9A-Z]{8,}/g,
16
+ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g,
17
+ ];
18
+
19
+ /** True when the string looks like a token, key or JWT. */
20
+ export function looksSecret(text: string): boolean {
21
+ return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
22
+ re.lastIndex = 0;
23
+ return re.test(text);
24
+ });
25
+ }
26
+
27
+ /**
28
+ * First line only, collapsed whitespace, no backticks, secrets replaced by
29
+ * `…`, clipped. For the rich text of a later envelope version.
30
+ */
31
+ export function scrubText(text: string | null | undefined, max = 120): string {
32
+ let s = (text ?? "").split(/\r?\n/).find((l) => l.trim()) ?? "";
33
+ s = s.replace(/`+/g, "");
34
+ for (const re of SECRET_PATTERNS) s = s.replace(re, "…");
35
+ s = s.replace(LONG_RUN, "…");
36
+ s = s.replace(/\s+/g, " ").trim();
37
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s;
38
+ }
39
+
40
+ /** An id fit for a link or key: short and plain. Null if it isn't. */
41
+ export function safeId(id: unknown): string | null {
42
+ if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
43
+ return null;
44
+ if (!SAFE_ID.test(id)) return null;
45
+ return looksSecret(id) ? null : id;
46
+ }
47
+
48
+ /**
49
+ * A collapse key: opaque and bounded. Anything with characters outside the
50
+ * plain set, or secret-looking, is dropped rather than cleaned.
51
+ */
52
+ export function safeKey(key: unknown): string | null {
53
+ if (typeof key !== "string" || key.length === 0 || key.length > 128)
54
+ return null;
55
+ if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
56
+ return looksSecret(key) ? null : key;
57
+ }
@@ -26,6 +26,7 @@ import { isPaused } from "./pause";
26
26
  import { review } from "./review";
27
27
  import { judge, mergeJudged } from "./signoff";
28
28
  import { workspaceTask } from "./targets";
29
+ import { markStatusDirty } from "../status/dirty";
29
30
 
30
31
  // Judged again at least this often, for what the fingerprint can't see.
31
32
  export const ARMED_RECHECK_MS = 5 * 60 * 1000;
@@ -38,6 +39,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
38
39
  merge_armed_seen = NULL WHERE id = ?`
39
40
  ).run(sessionId);
40
41
  looked.delete(sessionId);
42
+ markStatusDirty(sessionId);
41
43
  notifyStatusChanged();
42
44
  }
43
45
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { db } from "../db";
8
8
  import { notifyStatusChanged } from "../status/hub";
9
+ import { markAllStatusDirty, markStatusDirty } from "../status/dirty";
9
10
 
10
11
  // ci: when the check count on a commit last changed, for settling.
11
12
  export type CheckKind = "review" | "scope" | "ci";
@@ -58,6 +59,7 @@ export function putCheck(row: {
58
59
  row.status,
59
60
  row.detail ?? null
60
61
  );
62
+ markStatusDirty(row.sessionId);
61
63
  notifyStatusChanged();
62
64
  return getCheck(row.sessionId, row.sha, row.kind)!;
63
65
  }
@@ -68,4 +70,5 @@ export function clearStaleRunning(maxAgeMinutes = 30): void {
68
70
  `UPDATE orchestrator_checks SET status = 'error', detail = 'Interrupted: run it again'
69
71
  WHERE status = 'running' AND created_at < datetime('now', ?)`
70
72
  ).run(`-${maxAgeMinutes} minutes`);
73
+ markAllStatusDirty();
71
74
  }
@@ -20,6 +20,7 @@ import { storedPR } from "../tasks/session";
20
20
  import { commitTime, repoOf } from "./repo";
21
21
  import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
22
22
  import { repoHasCI } from "./repo-ci";
23
+ import { markStatusDirty } from "../status/dirty";
23
24
 
24
25
  export type FactStatus = "running" | "waiting" | "idle" | "dead";
25
26
 
@@ -211,7 +212,10 @@ export async function settleIn(
211
212
  let at = committed.get(pr.head);
212
213
  if (at === undefined) {
213
214
  at = await commitTime(repoOf(s), pr.head).catch(() => 0);
214
- if (at) committed.set(pr.head, at);
215
+ if (at) {
216
+ committed.set(pr.head, at);
217
+ markStatusDirty(s.id);
218
+ }
215
219
  }
216
220
  return ciSettleIn({
217
221
  workspaceId,
@@ -18,6 +18,7 @@ import type { CheckRow } from "./checks";
18
18
  import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
19
19
  import type { RepoCI } from "./repo-ci";
20
20
  import { untrusted } from "./untrusted";
21
+ import { markStatusDirty } from "../status/dirty";
21
22
 
22
23
  export type GateName =
23
24
  | "ci"
@@ -244,6 +245,7 @@ function countOnce(
244
245
  last_reason = excluded.last_reason,
245
246
  last_sha = excluded.last_sha`
246
247
  ).run(sessionId, gate, workspaceId, reason, sha, fresh ? 1 : 0);
248
+ markStatusDirty(sessionId);
247
249
  return failureOf(sessionId, gate)!.count;
248
250
  }
249
251
 
@@ -261,6 +263,7 @@ export function markEscalated(sessionId: string, gate: string): void {
261
263
  db.prepare(
262
264
  `UPDATE orchestrator_gate_failures SET escalated_at = datetime('now') WHERE session_id = ? AND gate = ?`
263
265
  ).run(sessionId, gate);
266
+ markStatusDirty(sessionId);
264
267
  }
265
268
 
266
269
  export function escalations(sessionId: string): FailureRow[] {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { db } from "../db";
10
+ import { markAllStatusDirty } from "../status/dirty";
10
11
 
11
12
  const KEY = "orchestrator.merge_approvals";
12
13
 
@@ -26,4 +27,5 @@ export function setMergeApprovals(on: boolean): void {
26
27
  `INSERT INTO settings (key, value) VALUES (?, ?)
27
28
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`
28
29
  ).run(KEY, on ? "1" : "0");
30
+ markAllStatusDirty();
29
31
  }
@@ -59,6 +59,7 @@ export const STATIC_SIBLINGS = [
59
59
  "import",
60
60
  "init",
61
61
  "init-script",
62
+ "me",
62
63
  "items",
63
64
  "network",
64
65
  "queue",
@@ -0,0 +1,12 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { DEVICE_HEADER } from "./auth";
3
+
4
+ /**
5
+ * The calling device, from the gate's header (never the body). Null when the
6
+ * request wasn't made with a device token, e.g. from this machine's browser.
7
+ */
8
+ export const callingDevice = (request: NextRequest): string | null =>
9
+ request.headers.get(DEVICE_HEADER);
10
+
11
+ export const needsDevice = () =>
12
+ NextResponse.json({ error: "Needs a paired device token." }, { status: 403 });