@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.
- package/README.md +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/bus/wait/route.ts +22 -0
- 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/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- 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/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +174 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +14 -0
- 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 +31 -13
- package/app/page.tsx +7 -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 +194 -0
- package/components/Hosts/HostRow.tsx +195 -45
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +246 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +18 -2
- 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/Welcome/ConnectBlock.tsx +1 -1
- 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/index.ts +2 -0
- package/data/peers/keys.ts +7 -0
- package/data/peers/queries.ts +172 -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/brief.ts +3 -1
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +121 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -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/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -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/20261020T1600_bus_waits.ts +35 -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 +20 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- 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 +19 -1
- package/lib/orchestrator/facts.ts +22 -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 +270 -0
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +26 -1
- package/lib/peer/code.ts +3 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/link-cli.ts +248 -0
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +84 -0
- package/lib/peer/name.ts +32 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +432 -0
- package/lib/peer/pin.ts +62 -0
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +72 -6
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/demo.ts +7 -0
- package/lib/security/gate.ts +3 -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/end.ts +3 -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 +3 -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 +5 -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 +6 -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("|");
|
|
@@ -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
|
-
|
|
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 {
|