@saadnvd1/agent-os 0.3.700 → 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.
- package/README.md +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +15 -14
- package/app/page.tsx +5 -0
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftPanel.tsx +40 -2
- package/components/Hosts/AccountMachines.tsx +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +17 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +4 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +10 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/index.ts +8 -0
- package/lib/claims/collectLocal.ts +121 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +1 -0
- package/lib/db/types.ts +16 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +6 -1
- package/lib/orchestrator/facts.ts +5 -0
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/launch.ts +5 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +5 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +1 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +3 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +3 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- 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("|");
|
package/lib/db/changes.ts
CHANGED
|
@@ -45,6 +45,10 @@ export const TABLES_WATCHED_CHAT_BOOKMARKS = ["chat_bookmarks"] as const;
|
|
|
45
45
|
|
|
46
46
|
export const TABLES_WATCHED_LEARNING_CARDS = ["learning_cards"] as const;
|
|
47
47
|
|
|
48
|
+
export const TABLES_WATCHED_SESSION_GROUPS = ["session_groups"] as const;
|
|
49
|
+
|
|
50
|
+
export const TABLES_WATCHED_WORK_CLAIMS = ["work_claims"] as const;
|
|
51
|
+
|
|
48
52
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
49
53
|
export const WATCHED_TABLES: readonly string[] = [
|
|
50
54
|
...TABLES_WATCHED_45,
|
|
@@ -55,6 +59,8 @@ export const WATCHED_TABLES: readonly string[] = [
|
|
|
55
59
|
...TABLES_WATCHED_80,
|
|
56
60
|
...TABLES_WATCHED_CHAT_BOOKMARKS,
|
|
57
61
|
...TABLES_WATCHED_LEARNING_CARDS,
|
|
62
|
+
...TABLES_WATCHED_SESSION_GROUPS,
|
|
63
|
+
...TABLES_WATCHED_WORK_CLAIMS,
|
|
58
64
|
];
|
|
59
65
|
|
|
60
66
|
export function installChangeTriggers(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1400_work_claims",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// What each live session, here or on a linked machine, is working on:
|
|
7
|
+
// repo, branch, goal and the paths of its diff. Never code. Times are
|
|
8
|
+
// this machine's clock in ms; they cross the wire as durations.
|
|
9
|
+
db.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS work_claims (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
origin TEXT NOT NULL,
|
|
13
|
+
origin_name TEXT NOT NULL,
|
|
14
|
+
via_machine TEXT,
|
|
15
|
+
hops INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
source TEXT NOT NULL,
|
|
17
|
+
session_id TEXT,
|
|
18
|
+
session_name TEXT,
|
|
19
|
+
repo TEXT NOT NULL,
|
|
20
|
+
branch TEXT,
|
|
21
|
+
base TEXT,
|
|
22
|
+
goal TEXT NOT NULL,
|
|
23
|
+
files TEXT NOT NULL DEFAULT '[]',
|
|
24
|
+
files_more INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
pr_url TEXT,
|
|
26
|
+
started_at INTEGER NOT NULL,
|
|
27
|
+
expires_at INTEGER NOT NULL,
|
|
28
|
+
released TEXT,
|
|
29
|
+
updated_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_work_claims_repo ON work_claims (repo, expires_at);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_work_claims_session ON work_claims (session_id);
|
|
33
|
+
`);
|
|
34
|
+
},
|
|
35
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1515_peer_unlinked",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A machine unlinked here, from either side (lib/peer/unlink.ts): its pin
|
|
7
|
+
// is gone, and its signing key is kept for 30 days so its next call can
|
|
8
|
+
// be told, provably, that it's no longer linked, and so Machines can say
|
|
9
|
+
// who unlinked it. Never trusted for anything else.
|
|
10
|
+
db.exec(`CREATE TABLE IF NOT EXISTS peer_unlinked (
|
|
11
|
+
machine_id TEXT PRIMARY KEY,
|
|
12
|
+
host_id TEXT,
|
|
13
|
+
sign_pub TEXT NOT NULL,
|
|
14
|
+
by_peer INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
reason TEXT NOT NULL,
|
|
16
|
+
at INTEGER NOT NULL
|
|
17
|
+
)`);
|
|
18
|
+
db.exec(
|
|
19
|
+
`CREATE INDEX IF NOT EXISTS idx_peer_unlinked_host ON peer_unlinked(host_id)`
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import { cleanMachineName } from "../../peer/name";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
key: "20261020T1700_clean_connect_names",
|
|
6
|
+
up: (db) => {
|
|
7
|
+
// Connect machines linked before names were cleaned in full (lib/peer/name.ts)
|
|
8
|
+
// keep the name the service gave them; clean those once.
|
|
9
|
+
const rows = db
|
|
10
|
+
.prepare(`SELECT id, name FROM hosts WHERE kind = 'connect'`)
|
|
11
|
+
.all() as { id: string; name: string }[];
|
|
12
|
+
const set = db.prepare(`UPDATE hosts SET name = ? WHERE id = ?`);
|
|
13
|
+
for (const r of rows) {
|
|
14
|
+
const clean = cleanMachineName(r.name);
|
|
15
|
+
if (clean !== r.name) set.run(clean, r.id);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
} satisfies Migration;
|