@saadnvd1/agent-os 0.3.698 → 0.3.715

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 (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,366 @@
1
+ import { randomUUID } from "crypto";
2
+ import { getDb } from "../db";
3
+ import {
4
+ CLAIM_TTL_MS,
5
+ OUTSIDE_TTL_MS,
6
+ asSource,
7
+ MAX_FILES,
8
+ MAX_HOPS,
9
+ REMOTE_TTL_CAP_MS,
10
+ TOMBSTONE_MS,
11
+ safePath,
12
+ type Claim,
13
+ type ClaimWire,
14
+ type Released,
15
+ } from "./types";
16
+
17
+ interface Row {
18
+ id: string;
19
+ origin: string;
20
+ origin_name: string;
21
+ via_machine: string | null;
22
+ hops: number;
23
+ source: string;
24
+ session_id: string | null;
25
+ session_name: string | null;
26
+ repo: string;
27
+ branch: string | null;
28
+ base: string | null;
29
+ goal: string;
30
+ files: string;
31
+ files_more: number;
32
+ pr_url: string | null;
33
+ started_at: number;
34
+ expires_at: number;
35
+ released: string | null;
36
+ updated_at: number;
37
+ }
38
+
39
+ // Another machine's text: no control characters, one line, short.
40
+ export const cleanLine = (s: unknown, max: number) =>
41
+ String(s ?? "")
42
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
43
+ .replace(/\s+/g, " ")
44
+ .trim()
45
+ .slice(0, max);
46
+
47
+ function parseFiles(raw: string): string[] {
48
+ try {
49
+ const v = JSON.parse(raw);
50
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ const fromRow = (r: Row): Claim => ({
57
+ id: r.id,
58
+ origin: r.origin,
59
+ originName: r.origin_name,
60
+ viaMachine: r.via_machine,
61
+ hops: r.hops,
62
+ source: asSource(r.source),
63
+ sessionId: r.session_id,
64
+ sessionName: r.session_name,
65
+ repo: r.repo,
66
+ branch: r.branch,
67
+ base: r.base,
68
+ goal: r.goal,
69
+ files: parseFiles(r.files),
70
+ filesMore: r.files_more,
71
+ prUrl: r.pr_url,
72
+ startedAt: r.started_at,
73
+ expiresAt: r.expires_at,
74
+ released: (r.released as Released | null) ?? null,
75
+ updatedAt: r.updated_at,
76
+ });
77
+
78
+ const SELECT = `SELECT * FROM work_claims`;
79
+
80
+ export interface LocalClaim {
81
+ sessionId: string;
82
+ sessionName: string;
83
+ repo: string;
84
+ branch: string | null;
85
+ base: string | null;
86
+ goal: string;
87
+ files: string[];
88
+ filesMore: number;
89
+ prUrl: string | null;
90
+ }
91
+
92
+ export const localClaimId = (sessionId: string) => `s-${sessionId}`;
93
+
94
+ // Renews the claim for a local session; its start stays when it was first seen.
95
+ export function upsertLocal(
96
+ origin: { id: string; name: string },
97
+ c: LocalClaim,
98
+ now = Date.now()
99
+ ): void {
100
+ const id = localClaimId(c.sessionId);
101
+ const files = c.files.filter(safePath).slice(0, MAX_FILES);
102
+ getDb()
103
+ .prepare(
104
+ `INSERT INTO work_claims (id, origin, origin_name, hops, source, session_id,
105
+ session_name, repo, branch, base, goal, files, files_more, pr_url,
106
+ started_at, expires_at, released, updated_at)
107
+ VALUES (@id, @origin, @originName, 0, 'session', @sessionId, @sessionName,
108
+ @repo, @branch, @base, @goal, @files, @filesMore, @prUrl,
109
+ @now, @expires, NULL, @now)
110
+ ON CONFLICT(id) DO UPDATE SET origin_name = @originName,
111
+ session_name = @sessionName, repo = @repo, branch = @branch, base = @base,
112
+ goal = @goal, files = @files, files_more = @filesMore, pr_url = @prUrl,
113
+ expires_at = @expires, released = NULL, updated_at = @now`
114
+ )
115
+ .run({
116
+ id,
117
+ origin: origin.id,
118
+ originName: cleanLine(origin.name, 60),
119
+ sessionId: c.sessionId,
120
+ sessionName: cleanLine(c.sessionName, 120),
121
+ repo: c.repo,
122
+ branch: c.branch,
123
+ base: c.base,
124
+ goal: cleanLine(c.goal, 120) || "(unnamed)",
125
+ files: JSON.stringify(files),
126
+ filesMore: c.filesMore + Math.max(0, c.files.length - files.length),
127
+ prUrl: c.prUrl,
128
+ now,
129
+ expires: now + CLAIM_TTL_MS,
130
+ });
131
+ }
132
+
133
+ // Ends a local claim, kept as a tombstone so the release reaches the peers.
134
+ export function release(
135
+ sessionId: string,
136
+ why: Released,
137
+ now = Date.now()
138
+ ): void {
139
+ getDb()
140
+ .prepare(
141
+ `UPDATE work_claims SET released = ?, expires_at = ?, updated_at = ?
142
+ WHERE id = ? AND (released IS NULL OR released != ?)`
143
+ )
144
+ .run(why, now + TOMBSTONE_MS, now, localClaimId(sessionId), why);
145
+ }
146
+
147
+ export interface OutsideClaim {
148
+ id: string;
149
+ sessionName: string | null;
150
+ repo: string;
151
+ branch: string | null;
152
+ base: string | null;
153
+ goal: string;
154
+ files: string[];
155
+ }
156
+
157
+ export const newOutsideId = () => `o-${randomUUID()}`;
158
+
159
+ // Registers or renews a claim for a session AgentOS didn't start. It lives
160
+ // OUTSIDE_TTL_MS from its last touch. False when the id is another claim's.
161
+ export function upsertOutside(
162
+ origin: { id: string; name: string },
163
+ c: OutsideClaim,
164
+ now = Date.now()
165
+ ): boolean {
166
+ const db = getDb();
167
+ const have = db
168
+ .prepare(`SELECT origin, source FROM work_claims WHERE id = ?`)
169
+ .get(c.id) as { origin: string; source: string } | undefined;
170
+ if (have && (have.origin !== origin.id || have.source !== "outside"))
171
+ return false;
172
+ const files = c.files.map((f) => f.trim()).filter(safePath);
173
+ const kept = files.slice(0, MAX_FILES);
174
+ db.prepare(
175
+ `INSERT INTO work_claims (id, origin, origin_name, hops, source, session_id,
176
+ session_name, repo, branch, base, goal, files, files_more, pr_url,
177
+ started_at, expires_at, released, updated_at)
178
+ VALUES (@id, @origin, @originName, 0, 'outside', NULL, @sessionName,
179
+ @repo, @branch, @base, @goal, @files, @filesMore, NULL,
180
+ @now, @expires, NULL, @now)
181
+ ON CONFLICT(id) DO UPDATE SET origin_name = @originName,
182
+ session_name = @sessionName, repo = @repo, branch = @branch, base = @base,
183
+ goal = @goal, files = @files, files_more = @filesMore,
184
+ expires_at = @expires, released = NULL, updated_at = @now`
185
+ ).run({
186
+ id: c.id,
187
+ origin: origin.id,
188
+ originName: cleanLine(origin.name, 60),
189
+ sessionName: cleanLine(c.sessionName, 120) || null,
190
+ repo: c.repo,
191
+ branch: cleanLine(c.branch, 250) || null,
192
+ base: cleanLine(c.base, 250) || null,
193
+ goal: cleanLine(c.goal, 120) || "(unnamed)",
194
+ files: JSON.stringify(kept),
195
+ filesMore: Math.max(0, files.length - kept.length),
196
+ now,
197
+ expires: now + OUTSIDE_TTL_MS,
198
+ });
199
+ return true;
200
+ }
201
+
202
+ // Ends an outside claim of this machine; a task's or a peer's is refused.
203
+ export function releaseOutside(
204
+ self: string,
205
+ id: string,
206
+ now = Date.now()
207
+ ): "ended" | "missing" | "forbidden" {
208
+ const row = getDb()
209
+ .prepare(`SELECT origin, source FROM work_claims WHERE id = ?`)
210
+ .get(id) as { origin: string; source: string } | undefined;
211
+ if (!row) return "missing";
212
+ if (row.origin !== self || row.source !== "outside") return "forbidden";
213
+ getDb()
214
+ .prepare(
215
+ `UPDATE work_claims SET released = 'done', expires_at = ?, updated_at = ?
216
+ WHERE id = ? AND released IS NULL`
217
+ )
218
+ .run(now + TOMBSTONE_MS, now, id);
219
+ return "ended";
220
+ }
221
+
222
+ // Takes one claim a peer sent. Returns whether it was stored. The row with
223
+ // the most life wins, unless a nearer copy (fewer hops) says something newer.
224
+ export function upsertRemote(
225
+ self: string,
226
+ sender: string,
227
+ w: ClaimWire,
228
+ now = Date.now(),
229
+ viaHost: string | null = null
230
+ ): boolean {
231
+ if (w.origin === self || w.hops > MAX_HOPS) return false;
232
+ const files = w.files.map((f) => f.trim()).filter(safePath);
233
+ const expires = now + Math.min(w.ttl_s * 1000, REMOTE_TTL_CAP_MS);
234
+ if (expires <= now) return false;
235
+ const db = getDb();
236
+ const have = db.prepare(`${SELECT} WHERE id = ?`).get(w.id) as
237
+ Row | undefined;
238
+ if (have) {
239
+ if (have.origin === self) return false;
240
+ if (have.released && !w.released && have.expires_at >= expires)
241
+ return false;
242
+ if (expires <= have.expires_at && w.hops >= have.hops) return false;
243
+ }
244
+ db.prepare(
245
+ `INSERT INTO work_claims (id, origin, origin_name, via_machine, via_host, hops, source,
246
+ session_id, session_name, repo, branch, base, goal, files, files_more,
247
+ pr_url, started_at, expires_at, released, updated_at)
248
+ VALUES (@id, @origin, @originName, @via, @viaHost, @hops, @source, NULL, @sessionName,
249
+ @repo, @branch, @base, @goal, @files, @filesMore, @prUrl, @started,
250
+ @expires, @released, @now)
251
+ ON CONFLICT(id) DO UPDATE SET origin_name = @originName, via_machine = @via, via_host = COALESCE(@viaHost, via_host),
252
+ hops = @hops, session_name = @sessionName, repo = @repo, branch = @branch,
253
+ base = @base, goal = @goal, files = @files, files_more = @filesMore,
254
+ pr_url = @prUrl, started_at = MIN(started_at, @started),
255
+ expires_at = @expires, released = @released, updated_at = @now`
256
+ ).run({
257
+ id: w.id,
258
+ origin: w.origin,
259
+ originName: cleanLine(w.origin_name, 60) || "machine",
260
+ via: sender,
261
+ viaHost,
262
+ hops: w.hops,
263
+ source: asSource(w.source),
264
+ sessionName: cleanLine(w.session_name, 120) || null,
265
+ repo: cleanLine(w.repo, 300).toLowerCase(),
266
+ branch: cleanLine(w.branch, 250) || null,
267
+ base: cleanLine(w.base, 250) || null,
268
+ goal: cleanLine(w.goal, 120) || "(unnamed)",
269
+ files: JSON.stringify(files),
270
+ filesMore: w.files_more + Math.max(0, w.files.length - files.length),
271
+ prUrl: /^https?:\/\//.test(w.pr_url ?? "")
272
+ ? cleanLine(w.pr_url, 300)
273
+ : null,
274
+ started: now - Math.floor(w.age_s * 1000),
275
+ expires,
276
+ released: w.released ?? null,
277
+ now,
278
+ });
279
+ return true;
280
+ }
281
+
282
+ export function purgeExpired(now = Date.now()): number {
283
+ return getDb()
284
+ .prepare(`DELETE FROM work_claims WHERE expires_at <= ?`)
285
+ .run(now).changes;
286
+ }
287
+
288
+ export function liveClaims(
289
+ opts: { repo?: string; now?: number } = {}
290
+ ): Claim[] {
291
+ const rows = getDb()
292
+ .prepare(
293
+ `${SELECT} WHERE released IS NULL AND expires_at > @now
294
+ ${opts.repo ? "AND repo = @repo" : ""} ORDER BY started_at`
295
+ )
296
+ .all({
297
+ now: opts.now ?? Date.now(),
298
+ ...(opts.repo && { repo: opts.repo }),
299
+ }) as Row[];
300
+ return rows.map(fromRow);
301
+ }
302
+
303
+ // One claim by id while it is alive: not released, not expired. A wait on a
304
+ // claim is over when this is null.
305
+ export function liveClaim(id: string, now = Date.now()): Claim | null {
306
+ const r = getDb()
307
+ .prepare(`${SELECT} WHERE id = ? AND released IS NULL AND expires_at > ?`)
308
+ .get(id, now) as Row | undefined;
309
+ return r ? fromRow(r) : null;
310
+ }
311
+
312
+ // Local claims whose session is no longer worth claiming for.
313
+ export function localLiveSessionIds(): string[] {
314
+ return (
315
+ getDb()
316
+ .prepare(
317
+ `SELECT session_id FROM work_claims
318
+ WHERE session_id IS NOT NULL AND released IS NULL`
319
+ )
320
+ .all() as { session_id: string }[]
321
+ ).map((r) => r.session_id);
322
+ }
323
+
324
+ // What goes to a peer: everything still alive or just released, except what
325
+ // the peer itself told us and what is too many hops from its source.
326
+ export function wireFor(
327
+ self: string,
328
+ peer: { machineId?: string | null } = {},
329
+ now = Date.now()
330
+ ): ClaimWire[] {
331
+ const rows = getDb()
332
+ .prepare(`${SELECT} WHERE expires_at > ?`)
333
+ .all(now) as Row[];
334
+ return rows
335
+ .filter(
336
+ (r) =>
337
+ r.hops < MAX_HOPS &&
338
+ (!peer.machineId ||
339
+ (r.origin !== peer.machineId && r.via_machine !== peer.machineId))
340
+ )
341
+ .map((r) => ({
342
+ id: r.id,
343
+ origin: r.origin,
344
+ origin_name: r.origin_name,
345
+ hops: r.origin === self ? 0 : r.hops + 1,
346
+ source: asSource(r.source),
347
+ session_name: r.session_name,
348
+ repo: r.repo,
349
+ branch: r.branch,
350
+ base: r.base,
351
+ goal: r.goal,
352
+ files: parseFiles(r.files).slice(0, MAX_FILES),
353
+ files_more: r.files_more,
354
+ pr_url: r.pr_url,
355
+ ttl_s: Math.max(0, Math.floor((r.expires_at - now) / 1000)),
356
+ age_s: Math.max(0, Math.floor((now - r.started_at) / 1000)),
357
+ released: (r.released as Released | null) ?? null,
358
+ }));
359
+ }
360
+
361
+ // One claim by id even when released: a tombstone says how it ended.
362
+ export function claimById(id: string): Claim | null {
363
+ const r = getDb().prepare(`${SELECT} WHERE id = ?`).get(id) as
364
+ Row | undefined;
365
+ return r ? fromRow(r) : null;
366
+ }
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+
3
+ export const CLAIMS_SYNC_MS = 60_000;
4
+ // A local session's claim, renewed every sync while the session lives.
5
+ export const CLAIM_TTL_MS = 10 * 60_000;
6
+ // An outside session's claim, renewed by its hook or `aos claim touch`.
7
+ export const OUTSIDE_TTL_MS = 30 * 60_000;
8
+ // The most life a peer can give a claim here.
9
+ export const REMOTE_TTL_CAP_MS = 15 * 60_000;
10
+ export const TOMBSTONE_MS = 15 * 60_000;
11
+ export const MAX_WIRE_CLAIMS = 500;
12
+ export const MAX_FILES = 300;
13
+ export const MAX_HOPS = 2;
14
+
15
+ export const CLAIM_SOURCES = ["session", "outside"] as const;
16
+ export type ClaimSource = (typeof CLAIM_SOURCES)[number];
17
+ export const asSource = (s: unknown): ClaimSource =>
18
+ s === "outside" ? "outside" : "session";
19
+ export type Released = "done" | "merged" | "abandoned";
20
+
21
+ export interface Claim {
22
+ id: string;
23
+ origin: string;
24
+ originName: string;
25
+ viaMachine: string | null;
26
+ hops: number;
27
+ source: ClaimSource;
28
+ sessionId: string | null;
29
+ sessionName: string | null;
30
+ repo: string;
31
+ branch: string | null;
32
+ base: string | null;
33
+ goal: string;
34
+ files: string[];
35
+ filesMore: number;
36
+ prUrl: string | null;
37
+ startedAt: number;
38
+ expiresAt: number;
39
+ released: Released | null;
40
+ updatedAt: number;
41
+ }
42
+
43
+ // A path in a diff: relative, inside the repo, short.
44
+ export const safePath = (p: string) =>
45
+ p.length > 0 &&
46
+ p.length <= 300 &&
47
+ !p.startsWith("/") &&
48
+ !/^[a-zA-Z]:[\\/]/.test(p) &&
49
+ !p.includes("\0") &&
50
+ !p.split(/[\\/]/).includes("..");
51
+
52
+ const text = (max: number) => z.string().max(max * 4);
53
+
54
+ export const claimWire = z.object({
55
+ id: z.string().min(1).max(100),
56
+ origin: z.string().min(1).max(100),
57
+ origin_name: text(60),
58
+ hops: z.number().int().min(0).max(10).default(0),
59
+ source: z.string().max(20).default("session"),
60
+ session_name: text(120).nullable().optional(),
61
+ repo: z.string().min(3).max(300),
62
+ branch: z.string().max(250).nullable().optional(),
63
+ base: z.string().max(250).nullable().optional(),
64
+ goal: text(120),
65
+ files: z.array(z.string().max(300)).max(MAX_FILES),
66
+ files_more: z.number().int().min(0).max(1_000_000).default(0),
67
+ pr_url: z.string().max(300).nullable().optional(),
68
+ ttl_s: z.number().min(0).max(86_400),
69
+ age_s: z.number().min(0).max(31_536_000),
70
+ released: z.enum(["done", "merged", "abandoned"]).nullable().optional(),
71
+ });
72
+ export type ClaimWire = z.infer<typeof claimWire>;
73
+
74
+ export const exchangeBody = z.object({
75
+ machine: z.object({ id: z.string().min(1).max(100), name: text(60) }),
76
+ claims: z.array(claimWire).max(MAX_WIRE_CLAIMS),
77
+ });
78
+ export type ExchangeBody = z.infer<typeof exchangeBody>;
79
+
80
+ export interface Overlap {
81
+ a: Claim;
82
+ b: Claim;
83
+ kinds: ("branch" | "files" | "goal")[];
84
+ shared: string[];
85
+ strength: "strong" | "weak";
86
+ }
87
+
88
+ // What an outside session posts: its folder's remote URL, branch and the
89
+ // paths of its diff. Never file contents.
90
+ export const outsideBody = z.object({
91
+ id: z
92
+ .string()
93
+ .regex(/^o-[0-9a-f-]{36}$/)
94
+ .optional(),
95
+ remote: z.string().min(3).max(500),
96
+ branch: z.string().max(250).nullable().optional(),
97
+ base: z.string().max(250).nullable().optional(),
98
+ goal: text(120).optional(),
99
+ name: text(120).optional(),
100
+ files: z.array(z.string().max(300)).max(5000).default([]),
101
+ });
102
+ export type OutsideBody = z.infer<typeof outsideBody>;
@@ -0,0 +1,80 @@
1
+ // The claims as the UI and the mobile app read them: plain JSON, with the
2
+ // pairs that overlap and when each machine was last heard from.
3
+
4
+ import { getDb } from "../db";
5
+ import { findOverlaps } from "./overlap";
6
+ import { machineId, machineName } from "./machine";
7
+ import type { Claim, Overlap } from "./types";
8
+
9
+ export interface MachineView {
10
+ id: string;
11
+ name: string;
12
+ self: boolean;
13
+ // Epoch ms of the last claim or exchange from it; null for this machine.
14
+ lastHeardAt: number | null;
15
+ }
16
+
17
+ export interface OverlapView {
18
+ a: string;
19
+ b: string;
20
+ kinds: Overlap["kinds"];
21
+ shared: string[];
22
+ strength: Overlap["strength"];
23
+ }
24
+
25
+ export interface ClaimsView {
26
+ // The server's clock, so ages don't depend on the browser's.
27
+ now: number;
28
+ machine: { id: string; name: string };
29
+ machines: MachineView[];
30
+ claims: Claim[];
31
+ overlaps: OverlapView[];
32
+ }
33
+
34
+ export const overlapView = (o: Overlap): OverlapView => ({
35
+ a: o.a.id,
36
+ b: o.b.id,
37
+ kinds: o.kinds,
38
+ shared: o.shared,
39
+ strength: o.strength,
40
+ });
41
+
42
+ function peerSeen(): Map<string, number> {
43
+ const rows = getDb()
44
+ .prepare(
45
+ `SELECT machine_id, MAX(seen_at) AS seen FROM claim_peers GROUP BY machine_id`
46
+ )
47
+ .all() as { machine_id: string; seen: number }[];
48
+ return new Map(rows.map((r) => [r.machine_id, r.seen]));
49
+ }
50
+
51
+ export function machinesOf(claims: Claim[]): MachineView[] {
52
+ const self = machineId();
53
+ const seen = peerSeen();
54
+ const byId = new Map<string, MachineView>();
55
+ for (const c of claims) {
56
+ if (c.origin === self) continue;
57
+ const have = byId.get(c.origin);
58
+ const heard = Math.max(seen.get(c.origin) ?? 0, c.updatedAt);
59
+ byId.set(c.origin, {
60
+ id: c.origin,
61
+ name: c.originName,
62
+ self: false,
63
+ lastHeardAt: Math.max(have?.lastHeardAt ?? 0, heard),
64
+ });
65
+ }
66
+ return [
67
+ { id: self, name: machineName(), self: true, lastHeardAt: null },
68
+ ...[...byId.values()].sort((a, b) => a.name.localeCompare(b.name)),
69
+ ];
70
+ }
71
+
72
+ export function claimsView(claims: Claim[]): ClaimsView {
73
+ return {
74
+ now: Date.now(),
75
+ machine: { id: machineId(), name: machineName() },
76
+ machines: machinesOf(claims),
77
+ claims,
78
+ overlaps: findOverlaps(claims).map(overlapView),
79
+ };
80
+ }
@@ -0,0 +1,24 @@
1
+ // Which of two overlapping claims keeps going. Both servers must reach the
2
+ // same answer without talking, so only fields both see are used.
3
+
4
+ import type { Claim } from "./types";
5
+
6
+ // Started times this close count as a tie: age_s crosses the wire in seconds.
7
+ export const TIE_WINDOW_MS = 2 * 60_000;
8
+
9
+ type Rank = Pick<Claim, "id" | "prUrl" | "startedAt">;
10
+
11
+ // The claim that keeps going. An open PR beats none, then the earlier start,
12
+ // then the lexically smaller id. Symmetric: winner(a, b) is winner(b, a).
13
+ export function winner<T extends Rank>(a: T, b: T): T {
14
+ if (!!a.prUrl !== !!b.prUrl) return a.prUrl ? a : b;
15
+ if (Math.abs(a.startedAt - b.startedAt) > TIE_WINDOW_MS)
16
+ return a.startedAt < b.startedAt ? a : b;
17
+ return a.id < b.id ? a : b;
18
+ }
19
+
20
+ export const whoYields = <T extends Rank>(a: T, b: T): T =>
21
+ winner(a, b) === a ? b : a;
22
+
23
+ export const pairKey = (a: Pick<Claim, "id">, b: Pick<Claim, "id">) =>
24
+ [a.id, b.id].sort().join("|");
@@ -214,17 +214,123 @@ export async function requestTunnel(
214
214
  return { hostname: hostnameFrom(r), token: tokenFrom(r) };
215
215
  }
216
216
 
217
+ /** What the account's plan allows; absent from an older Connect service. */
218
+ export interface Entitlements {
219
+ plan: string;
220
+ linkMachines: boolean;
221
+ machineLimit: number;
222
+ }
223
+
224
+ export interface Beat {
225
+ enabled: boolean;
226
+ entitlements?: Entitlements;
227
+ /** Changes whenever the list `peers` would give changes. */
228
+ peersEtag?: string;
229
+ }
230
+
231
+ const ETAG = /^[A-Za-z0-9_-]{1,128}$/;
232
+
233
+ export function entitlementsFrom(raw: unknown): Entitlements | undefined {
234
+ const e = (raw ?? null) as Record<string, unknown> | null;
235
+ if (!e || typeof e !== "object" || typeof e.link_machines !== "boolean")
236
+ return undefined;
237
+ return {
238
+ plan: typeof e.plan === "string" ? clean(e.plan, 32) : "unknown",
239
+ linkMachines: e.link_machines,
240
+ machineLimit:
241
+ typeof e.machine_limit === "number" &&
242
+ Number.isSafeInteger(e.machine_limit)
243
+ ? e.machine_limit
244
+ : 0,
245
+ };
246
+ }
247
+
248
+ const etagFrom = (raw: unknown) =>
249
+ typeof raw === "string" && ETAG.test(raw) ? raw : undefined;
250
+
217
251
  export async function heartbeat(
218
252
  config: ConnectConfig,
219
253
  privateKey: string,
220
254
  version: string
221
- ) {
255
+ ): Promise<Beat> {
222
256
  const res = await signed(config, privateKey)(
223
257
  "POST",
224
258
  `/api/v1/machines/${config.machineId}/heartbeat`,
225
259
  { version }
226
260
  );
227
- return (await res.json()) as { enabled: boolean };
261
+ const r = ((await res.json().catch(() => null)) ?? {}) as Record<
262
+ string,
263
+ unknown
264
+ >;
265
+ return {
266
+ enabled: r.enabled !== false,
267
+ entitlements: entitlementsFrom(r.entitlements),
268
+ peersEtag: etagFrom(r.peers_etag),
269
+ };
270
+ }
271
+
272
+ /** Another machine on this account, as the Connect service introduces it. */
273
+ export interface ListedMachine {
274
+ id: string;
275
+ name: string;
276
+ hostname: string;
277
+ /** Its Ed25519 key as the service holds it: SPKI PEM. */
278
+ publicKey: string;
279
+ lastSeenAt: string | null;
280
+ version: string | null;
281
+ }
282
+
283
+ export interface PeerList {
284
+ entitlements?: Entitlements;
285
+ peersEtag?: string;
286
+ peers: ListedMachine[];
287
+ }
288
+
289
+ const MAX_PEERS = 100;
290
+
291
+ /**
292
+ * The account's other machines this one may link to. Only an introduction:
293
+ * anything malformed is dropped here, and the keys still have to be
294
+ * confirmed by a person on both machines before either trusts the other.
295
+ */
296
+ export async function listPeers(
297
+ config: ConnectConfig,
298
+ privateKey: string
299
+ ): Promise<PeerList> {
300
+ const res = await signed(config, privateKey)(
301
+ "GET",
302
+ `/api/v1/machines/${config.machineId}/peers`
303
+ );
304
+ const r = (await res.json().catch(() => null)) as Record<
305
+ string,
306
+ unknown
307
+ > | null;
308
+ if (!r || !Array.isArray(r.peers))
309
+ throw new Error("the Connect service sent no machine list");
310
+ const str = (v: unknown, max: number) =>
311
+ typeof v === "string" && v.length <= max ? v : null;
312
+ const peers: ListedMachine[] = [];
313
+ for (const raw of r.peers.slice(0, MAX_PEERS)) {
314
+ const m = (raw ?? {}) as Record<string, unknown>;
315
+ const id = str(m.id, 8);
316
+ const hostname = str(m.hostname, 253);
317
+ const publicKey = str(m.public_key, 1024);
318
+ if (!id || !MACHINE_ID.test(id) || !hostname || !publicKey) continue;
319
+ if (id === config.machineId) continue;
320
+ peers.push({
321
+ id,
322
+ name: clean(str(m.name, 500) ?? id, 80),
323
+ hostname,
324
+ publicKey,
325
+ lastSeenAt: str(m.last_seen_at, 64),
326
+ version: str(m.agent_os_version, 64),
327
+ });
328
+ }
329
+ return {
330
+ entitlements: entitlementsFrom(r.entitlements),
331
+ peersEtag: etagFrom(r.peers_etag),
332
+ peers,
333
+ };
228
334
  }
229
335
 
230
336
  export interface PushRequestBody {