@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
package/lib/peer/rpc-server.ts
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* Refusals before decryption are plain and fixed (`bad envelope`, `bad
|
|
16
16
|
* signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
|
|
17
17
|
* and a bad signature all read `bad signature`, so a stranger can't tell
|
|
18
|
-
* which machines are linked here; the audit keeps the difference.
|
|
18
|
+
* which machines are linked here; the audit keeps the difference. The one
|
|
19
|
+
* exception is a machine unlinked here, proven by the key it was pinned
|
|
20
|
+
* with: it gets 403 `unknown peer`, signed (unlinked-proof.ts), so it can
|
|
21
|
+
* show that it was unlinked. Everything
|
|
19
22
|
* after is inside the envelope,
|
|
20
23
|
* always HTTP 200, so whoever carries the traffic never learns the route or
|
|
21
24
|
* its status.
|
|
@@ -45,7 +48,14 @@ import {
|
|
|
45
48
|
type BoxKey,
|
|
46
49
|
type Identity,
|
|
47
50
|
} from "./keys";
|
|
48
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
hasActivePin,
|
|
53
|
+
hasRecentUnlinked,
|
|
54
|
+
pinForMachine,
|
|
55
|
+
unlinkedMachine,
|
|
56
|
+
type PeerPin,
|
|
57
|
+
} from "./links";
|
|
58
|
+
import { UNLINKED_PROOF_HEADER, signUnlinked } from "./unlinked-proof";
|
|
49
59
|
import { sqliteNonceStore } from "./nonce-store";
|
|
50
60
|
import { createReplayGuard, type ReplayGuard } from "./replay";
|
|
51
61
|
import {
|
|
@@ -75,7 +85,11 @@ export interface RpcDeps {
|
|
|
75
85
|
identity: () => Identity | null;
|
|
76
86
|
boxKey: () => BoxKey;
|
|
77
87
|
pinForMachine: (machineId: string) => PeerPin | null;
|
|
78
|
-
/**
|
|
88
|
+
/** A machine unlinked here lately: the key it was pinned with. */
|
|
89
|
+
unlinked?: (
|
|
90
|
+
machineId: string
|
|
91
|
+
) => { signPub: string; hostId: string | null } | null;
|
|
92
|
+
/** False while no link is active or recently unlinked: the route is closed to everyone. */
|
|
79
93
|
anyActive: () => boolean;
|
|
80
94
|
replay: ReplayGuard;
|
|
81
95
|
dispatch: (d: Dispatch) => Promise<PeerResponse>;
|
|
@@ -125,6 +139,10 @@ export async function handlePeerRpc(
|
|
|
125
139
|
const self = deps.identity();
|
|
126
140
|
let fromMachine: string | null = null;
|
|
127
141
|
let pin: PeerPin | null = null;
|
|
142
|
+
// An unlinked sender whose header its old key proved, and its nonce.
|
|
143
|
+
let unlinkedHost: string | null = null;
|
|
144
|
+
let viaUnlinked = false;
|
|
145
|
+
let provenUnlinked: string | null = null;
|
|
128
146
|
const audit = (
|
|
129
147
|
outcome: Outcome,
|
|
130
148
|
status: number,
|
|
@@ -133,7 +151,7 @@ export async function handlePeerRpc(
|
|
|
133
151
|
bytes = 0
|
|
134
152
|
) =>
|
|
135
153
|
deps.audit({
|
|
136
|
-
hostId: pin?.hostId ??
|
|
154
|
+
hostId: pin?.hostId ?? unlinkedHost,
|
|
137
155
|
machineId: fromMachine,
|
|
138
156
|
direction: "in",
|
|
139
157
|
method,
|
|
@@ -144,6 +162,19 @@ export async function handlePeerRpc(
|
|
|
144
162
|
outcome,
|
|
145
163
|
});
|
|
146
164
|
const refuse = (refusal: Refusal): null => {
|
|
165
|
+
if (refusal === "unknown_peer" && provenUnlinked && self && fromMachine) {
|
|
166
|
+
audit("unknown_peer", 403);
|
|
167
|
+
res.setHeader(
|
|
168
|
+
UNLINKED_PROOF_HEADER,
|
|
169
|
+
signUnlinked(self.signKey, {
|
|
170
|
+
from: self.machineId,
|
|
171
|
+
to: fromMachine,
|
|
172
|
+
nonce: provenUnlinked,
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
plain(res, 403, "unknown peer");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
147
178
|
const [status, text, outcome] = REFUSED[refusal];
|
|
148
179
|
audit(outcome, status);
|
|
149
180
|
plain(res, status, text);
|
|
@@ -161,6 +192,18 @@ export async function handlePeerRpc(
|
|
|
161
192
|
: found.state !== "active"
|
|
162
193
|
? "suspended"
|
|
163
194
|
: null;
|
|
195
|
+
if (!found) {
|
|
196
|
+
const gone = deps.unlinked?.(machineId);
|
|
197
|
+
if (gone)
|
|
198
|
+
try {
|
|
199
|
+
const key = signPublic(gone.signPub);
|
|
200
|
+
unlinkedHost = gone.hostId;
|
|
201
|
+
viaUnlinked = true;
|
|
202
|
+
return key;
|
|
203
|
+
} catch {
|
|
204
|
+
// Unreadable: a decoy, like any stranger.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
164
207
|
if (!found || hidden) return DECOY_KEY;
|
|
165
208
|
try {
|
|
166
209
|
return signPublic(found.signPub);
|
|
@@ -177,6 +220,13 @@ export async function handlePeerRpc(
|
|
|
177
220
|
return null;
|
|
178
221
|
};
|
|
179
222
|
|
|
223
|
+
// The one nonce the header claimed: the whole envelope must carry it.
|
|
224
|
+
let claimed: string | null = null;
|
|
225
|
+
const spent: ReplayGuard = {
|
|
226
|
+
check: (peer, nonce) =>
|
|
227
|
+
claimed !== null && claimed === `${peer}:${nonce}` ? "ok" : "replay",
|
|
228
|
+
};
|
|
229
|
+
|
|
180
230
|
if (!deps.anyActive()) return plain(res, 404, "not found");
|
|
181
231
|
const ticket = deps.budget.open(sourceOf(req));
|
|
182
232
|
if (!ticket) return void refuse("busy");
|
|
@@ -193,6 +243,21 @@ export async function handlePeerRpc(
|
|
|
193
243
|
return { refusal: "bad_sig" };
|
|
194
244
|
const now = deps.now?.() ?? Date.now();
|
|
195
245
|
if (Math.abs(now - header.ts) > SKEW_MS) return { refusal: "stale" };
|
|
246
|
+
// The nonce is spent the moment its header is proven, so a header
|
|
247
|
+
// seen on the wire can't be sent again with junk bodies to hold
|
|
248
|
+
// the sender's share while the first is still arriving.
|
|
249
|
+
const fresh = deps.replay.check(
|
|
250
|
+
header.from,
|
|
251
|
+
header.nonce,
|
|
252
|
+
header.ts,
|
|
253
|
+
now
|
|
254
|
+
);
|
|
255
|
+
if (fresh !== "ok") return { refusal: fresh };
|
|
256
|
+
if (viaUnlinked) {
|
|
257
|
+
provenUnlinked = header.nonce;
|
|
258
|
+
return { refusal: "unknown_peer" };
|
|
259
|
+
}
|
|
260
|
+
claimed = `${header.from}:${header.nonce}`;
|
|
196
261
|
return { peer: header.from };
|
|
197
262
|
},
|
|
198
263
|
ticket,
|
|
@@ -209,7 +274,7 @@ export async function handlePeerRpc(
|
|
|
209
274
|
self: self.machineId,
|
|
210
275
|
boxKey: deps.boxKey().privateKey,
|
|
211
276
|
signKeyFor,
|
|
212
|
-
replay:
|
|
277
|
+
replay: spent,
|
|
213
278
|
now: deps.now?.(),
|
|
214
279
|
});
|
|
215
280
|
};
|
|
@@ -342,7 +407,8 @@ export function rpcDeps(port: number): RpcDeps {
|
|
|
342
407
|
identity: () => currentIdentity(),
|
|
343
408
|
boxKey: () => (box ??= loadBoxKey()),
|
|
344
409
|
pinForMachine,
|
|
345
|
-
|
|
410
|
+
unlinked: (machineId) => unlinkedMachine(machineId),
|
|
411
|
+
anyActive: () => hasActivePin() || hasRecentUnlinked(),
|
|
346
412
|
replay: createReplayGuard({ store: sqliteNonceStore() }),
|
|
347
413
|
dispatch: loopbackDispatch(port),
|
|
348
414
|
audit: recordPeerCall,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unlinking a machine linked through Connect, from either side. Here: the
|
|
3
|
+
* pin goes at once (links.ts dropPin), then the other machine is told with a
|
|
4
|
+
* signed POST /api/peer/unlink, best effort. There: the same call drops its
|
|
5
|
+
* pin and its Machines says "Unlinked by <this machine>". If that call never
|
|
6
|
+
* arrives, the other machine's next call here is answered with a signed
|
|
7
|
+
* "unknown peer" (rpc-server.ts), which does the same.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { deleteHost } from "../hosts";
|
|
11
|
+
import { hostLink, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
import { peerCall } from "./client";
|
|
13
|
+
import { dropPin, pinForHost } from "./links";
|
|
14
|
+
|
|
15
|
+
export const UNLINKED_HERE = "Unlinked here";
|
|
16
|
+
|
|
17
|
+
export interface UnlinkDeps {
|
|
18
|
+
tell: (link: HostLink) => Promise<unknown>;
|
|
19
|
+
remove: (hostId: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const realDeps: UnlinkDeps = {
|
|
23
|
+
tell: (link) =>
|
|
24
|
+
peerCall(link, "/api/peer/unlink", { body: {}, timeout: 8_000 }),
|
|
25
|
+
remove: deleteHost,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface UnlinkResult {
|
|
29
|
+
name: string;
|
|
30
|
+
/** The other machine confirmed it dropped its side. */
|
|
31
|
+
told: boolean;
|
|
32
|
+
/** The machine left Machines here too (it held no sessions or projects). */
|
|
33
|
+
removed: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A person here unlinks it. Throws when it isn't linked through Connect. */
|
|
37
|
+
export async function unlinkPeer(
|
|
38
|
+
hostId: string,
|
|
39
|
+
deps: UnlinkDeps = realDeps
|
|
40
|
+
): Promise<UnlinkResult> {
|
|
41
|
+
const link = hostLink(hostId);
|
|
42
|
+
if (!link?.connect || !pinForHost(hostId))
|
|
43
|
+
throw new Error("That machine isn't linked through Connect.");
|
|
44
|
+
dropPin(hostId, { byPeer: false, reason: UNLINKED_HERE });
|
|
45
|
+
let told = false;
|
|
46
|
+
// A suspended link is refused there too; nothing would arrive.
|
|
47
|
+
if (link.connect.state === "active")
|
|
48
|
+
try {
|
|
49
|
+
await deps.tell(link);
|
|
50
|
+
told = true;
|
|
51
|
+
} catch {
|
|
52
|
+
// Best effort: its next call here is refused and says why.
|
|
53
|
+
}
|
|
54
|
+
let removed = false;
|
|
55
|
+
try {
|
|
56
|
+
deps.remove(hostId);
|
|
57
|
+
removed = true;
|
|
58
|
+
} catch {
|
|
59
|
+
// It still holds sessions or projects; it stays, unlinked.
|
|
60
|
+
}
|
|
61
|
+
return { name: link.hostName, told, removed };
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refusal that is signed: "unknown peer", sent to a machine that was
|
|
3
|
+
* unlinked here, bound to its own request's nonce. Only the key it had pinned
|
|
4
|
+
* for this machine verifies it, so whoever carries the traffic can't forge an
|
|
5
|
+
* unlink, and a replayed one never matches a new request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto, { type KeyObject } from "crypto";
|
|
9
|
+
|
|
10
|
+
export const UNLINKED_PROOF_HEADER = "x-agentos-peer-unlinked";
|
|
11
|
+
const DOMAIN = "agentos-peer-unlinked-v1";
|
|
12
|
+
|
|
13
|
+
interface Bound {
|
|
14
|
+
/** The machine refusing: the one that unlinked. */
|
|
15
|
+
from: string;
|
|
16
|
+
/** The caller, which it no longer knows. */
|
|
17
|
+
to: string;
|
|
18
|
+
/** The caller's request nonce, base64url. */
|
|
19
|
+
nonce: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const message = (o: Bound) =>
|
|
23
|
+
Buffer.from(`${DOMAIN}|${o.from}|${o.to}|${o.nonce}`);
|
|
24
|
+
|
|
25
|
+
export const signUnlinked = (signKey: KeyObject, o: Bound): string =>
|
|
26
|
+
crypto.sign(null, message(o), signKey).toString("base64url");
|
|
27
|
+
|
|
28
|
+
export function verifyUnlinked(
|
|
29
|
+
signPub: KeyObject,
|
|
30
|
+
o: Bound,
|
|
31
|
+
sig: string | null
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!sig || !/^[A-Za-z0-9_-]{86}$/.test(sig)) return false;
|
|
34
|
+
try {
|
|
35
|
+
return crypto.verify(
|
|
36
|
+
null,
|
|
37
|
+
message(o),
|
|
38
|
+
signPub,
|
|
39
|
+
Buffer.from(sig, "base64url")
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/lib/projects.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type DevServerType,
|
|
20
20
|
} from "./db";
|
|
21
21
|
import { resolveModelForAgent } from "./model-catalog";
|
|
22
|
+
import { clearForeignGroup } from "./session-groups/assignSessions";
|
|
22
23
|
import type { AgentType } from "./providers";
|
|
23
24
|
|
|
24
25
|
const execAsync = promisify(exec);
|
|
@@ -312,7 +313,7 @@ export function getProjectSessions(projectId: string): Session[] {
|
|
|
312
313
|
export function moveSessionToProject(
|
|
313
314
|
sessionId: string,
|
|
314
315
|
projectId: string
|
|
315
|
-
):
|
|
316
|
+
): { clearedGroup: string | null } {
|
|
316
317
|
const to = getProject(projectId);
|
|
317
318
|
const from = db
|
|
318
319
|
.prepare(
|
|
@@ -321,7 +322,7 @@ export function moveSessionToProject(
|
|
|
321
322
|
)
|
|
322
323
|
.get(sessionId) as
|
|
323
324
|
{ workspace_id: string | null; own: string | null } | undefined;
|
|
324
|
-
db.transaction(() => {
|
|
325
|
+
return db.transaction(() => {
|
|
325
326
|
queries.updateSessionProject(db).run(projectId, sessionId);
|
|
326
327
|
// A project's session follows the project; one left in none keeps the
|
|
327
328
|
// workspace it was in.
|
|
@@ -333,6 +334,8 @@ export function moveSessionToProject(
|
|
|
333
334
|
: (from?.workspace_id ?? from?.own ?? null),
|
|
334
335
|
sessionId
|
|
335
336
|
);
|
|
337
|
+
// A group belongs to the workspace the session just left.
|
|
338
|
+
return { clearedGroup: clearForeignGroup(sessionId) };
|
|
336
339
|
})();
|
|
337
340
|
}
|
|
338
341
|
|
package/lib/schedules/when.ts
CHANGED
|
@@ -26,7 +26,7 @@ const UNIT_MS: Record<string, number> = {
|
|
|
26
26
|
d: 86_400_000,
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function duration(text: string): number | null {
|
|
29
|
+
export function duration(text: string): number | null {
|
|
30
30
|
let total = 0;
|
|
31
31
|
const rest = text.replace(
|
|
32
32
|
/(\d+(?:\.\d+)?|an?)\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/g,
|
package/lib/security/auth.ts
CHANGED
|
@@ -18,6 +18,9 @@ export const DEVICE_COOKIE = "aos_device";
|
|
|
18
18
|
export const DEVICE_HEADER = "x-agentos-device";
|
|
19
19
|
export const TRUST_HEADER = "x-agentos-trust";
|
|
20
20
|
export const REMOTE_HEADER = "x-agentos-remote";
|
|
21
|
+
// Who sent it, for per-sender limits: the visitor's address on a Connect
|
|
22
|
+
// request (lib/peer/rpc-intake sourceOf), an IPv6 address by its /64.
|
|
23
|
+
export const SOURCE_HEADER = "x-agentos-source";
|
|
21
24
|
// The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
|
|
22
25
|
// and the linked machine it verified. Never trusted from anywhere else.
|
|
23
26
|
export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
|
|
@@ -81,12 +84,17 @@ const FORWARDING = new Set([
|
|
|
81
84
|
const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
|
|
82
85
|
|
|
83
86
|
// Reached without a token: the pairing page, what it needs to render, the
|
|
84
|
-
// claim it posts,
|
|
85
|
-
//
|
|
87
|
+
// claim it posts, a linked machine's rpc (its envelope is the proof) and
|
|
88
|
+
// the pairing hellos (signed by a key the account lists). Exact paths, plus
|
|
89
|
+
// a few static asset folders whose paths may only use plain characters (no
|
|
90
|
+
// traversal, no encoding).
|
|
86
91
|
const PUBLIC_EXACT = new Set([
|
|
87
92
|
"/pair",
|
|
88
93
|
"/api/pair/claim",
|
|
89
94
|
"/api/peer/rpc",
|
|
95
|
+
"/api/peer/hello",
|
|
96
|
+
"/api/peer/hello/reveal",
|
|
97
|
+
"/api/peer/hello/status",
|
|
90
98
|
"/manifest.webmanifest",
|
|
91
99
|
"/manifest.json",
|
|
92
100
|
"/favicon.ico",
|
package/lib/security/demo.ts
CHANGED
|
@@ -49,23 +49,30 @@ export function assertDemoSandbox(env: Env = process.env, home = os.homedir()) {
|
|
|
49
49
|
// "projects/[id]"): Next serves the folder, so an id must never match them.
|
|
50
50
|
// The test walks app/api and fails when one is missing here.
|
|
51
51
|
export const STATIC_SIBLINGS = [
|
|
52
|
+
"account",
|
|
52
53
|
"archived",
|
|
53
54
|
"arrived",
|
|
54
55
|
"availability",
|
|
55
56
|
"browse",
|
|
57
|
+
"check",
|
|
56
58
|
"clone",
|
|
57
59
|
"connect",
|
|
58
60
|
"detect",
|
|
59
61
|
"done-idle",
|
|
60
62
|
"draft",
|
|
61
63
|
"ended",
|
|
64
|
+
"exchange",
|
|
62
65
|
"held-replies",
|
|
63
66
|
"import",
|
|
64
67
|
"init",
|
|
68
|
+
"group",
|
|
65
69
|
"init-script",
|
|
66
70
|
"me",
|
|
67
71
|
"items",
|
|
72
|
+
"link",
|
|
68
73
|
"network",
|
|
74
|
+
"order",
|
|
75
|
+
"pending",
|
|
69
76
|
"pdf",
|
|
70
77
|
"preview",
|
|
71
78
|
"queue",
|
package/lib/security/gate.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DEVICE_HEADER,
|
|
15
15
|
TRUST_HEADER,
|
|
16
16
|
REMOTE_HEADER,
|
|
17
|
+
SOURCE_HEADER,
|
|
17
18
|
PEER_HEADER,
|
|
18
19
|
PEER_INTERNAL_HEADER,
|
|
19
20
|
type AuthPolicy,
|
|
@@ -25,6 +26,7 @@ import { tailscaleAddresses } from "./net";
|
|
|
25
26
|
import { networkSetting } from "./network-settings";
|
|
26
27
|
import { pairLocation } from "../session-url";
|
|
27
28
|
import { peerSecret } from "../peer/internal";
|
|
29
|
+
import { sourceOf } from "../peer/rpc-intake";
|
|
28
30
|
|
|
29
31
|
// Interface addresses change rarely; don't re-read them on every request.
|
|
30
32
|
let tailnetCache = { at: 0, ips: [] as string[] };
|
|
@@ -63,6 +65,7 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
|
63
65
|
delete req.headers[PEER_HEADER];
|
|
64
66
|
const tunnel = isTunnelSocket(req.socket);
|
|
65
67
|
req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
|
|
68
|
+
req.headers[SOURCE_HEADER] = sourceOf(req);
|
|
66
69
|
let result: AuthResult;
|
|
67
70
|
try {
|
|
68
71
|
result = authorize(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { liveGroup } from "./store";
|
|
3
|
+
import { sessionWorkspaceId } from "./workspace";
|
|
4
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
5
|
+
|
|
6
|
+
// The one place that checks the invariant: a session joins only a group of
|
|
7
|
+
// its own workspace. Membership is a view choice, so it leaves updated_at
|
|
8
|
+
// alone, like pinning.
|
|
9
|
+
export function checkMember(sessionId: string, group: SessionGroup): void {
|
|
10
|
+
const workspaceId = sessionWorkspaceId(sessionId);
|
|
11
|
+
if (workspaceId === undefined)
|
|
12
|
+
throw new GroupError("not-found", "Session not found");
|
|
13
|
+
if ((workspaceId ?? null) !== (group.workspace_id ?? null))
|
|
14
|
+
throw new GroupError(
|
|
15
|
+
"workspace",
|
|
16
|
+
`"${group.name}" is a group of another workspace`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Files sessions into a group, or out of any (groupId null). All or none.
|
|
21
|
+
export function assignSessions(
|
|
22
|
+
sessionIds: readonly string[],
|
|
23
|
+
groupId: string | null
|
|
24
|
+
): { moved: number } {
|
|
25
|
+
const ids = [...new Set(sessionIds)];
|
|
26
|
+
const group = groupId === null ? null : liveGroup(groupId);
|
|
27
|
+
const db = getDb();
|
|
28
|
+
return db.transaction(() => {
|
|
29
|
+
let moved = 0;
|
|
30
|
+
for (const id of ids) {
|
|
31
|
+
if (group) checkMember(id, group);
|
|
32
|
+
else if (sessionWorkspaceId(id) === undefined)
|
|
33
|
+
throw new GroupError("not-found", "Session not found");
|
|
34
|
+
moved += db
|
|
35
|
+
.prepare(
|
|
36
|
+
`UPDATE sessions SET session_group_id = ?
|
|
37
|
+
WHERE id = ? AND session_group_id IS NOT ?`
|
|
38
|
+
)
|
|
39
|
+
.run(group?.id ?? null, id, group?.id ?? null).changes;
|
|
40
|
+
}
|
|
41
|
+
return { moved };
|
|
42
|
+
})();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// For a session that is starting: its group may have been deleted, or its
|
|
46
|
+
// workspace changed, since it was asked for. Starting must not fail over a
|
|
47
|
+
// view choice, so it just starts ungrouped.
|
|
48
|
+
export function fileInGroup(sessionId: string, groupId: string): void {
|
|
49
|
+
try {
|
|
50
|
+
assignSessions([sessionId], groupId);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (!(err instanceof GroupError)) throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A new session starts in the group of the one it came from (a worker from its
|
|
57
|
+
// conductor, a fork or a fresh start from its source), when that group is
|
|
58
|
+
// still live and in the new session's workspace.
|
|
59
|
+
export function inheritGroup(fromSessionId: string, toSessionId: string): void {
|
|
60
|
+
const row = getDb()
|
|
61
|
+
.prepare(`SELECT session_group_id AS id FROM sessions WHERE id = ?`)
|
|
62
|
+
.get(fromSessionId) as { id: string | null } | undefined;
|
|
63
|
+
if (row?.id) fileInGroup(toSessionId, row.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Leaving a workspace leaves its groups (a project move into another one).
|
|
67
|
+
export function clearForeignGroup(sessionId: string): string | null {
|
|
68
|
+
const row = getDb()
|
|
69
|
+
.prepare(
|
|
70
|
+
`SELECT g.id AS id, g.name AS name, g.workspace_id AS ws
|
|
71
|
+
FROM sessions s JOIN session_groups g ON g.id = s.session_group_id
|
|
72
|
+
WHERE s.id = ?`
|
|
73
|
+
)
|
|
74
|
+
.get(sessionId) as
|
|
75
|
+
{ id: string; name: string; ws: string | null } | undefined;
|
|
76
|
+
if (!row || (sessionWorkspaceId(sessionId) ?? null) === row.ws) return null;
|
|
77
|
+
getDb()
|
|
78
|
+
.prepare(`UPDATE sessions SET session_group_id = NULL WHERE id = ?`)
|
|
79
|
+
.run(sessionId);
|
|
80
|
+
return row.name;
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { resolveReadable, readerScope } from "@/lib/bus/read-scope";
|
|
3
|
+
import { assignSessions } from "./assignSessions";
|
|
4
|
+
import { createGroup } from "./createGroup";
|
|
5
|
+
import { listGroups } from "./store";
|
|
6
|
+
import { updateGroup } from "./updateGroup";
|
|
7
|
+
import { resolveGroup } from "./resolveGroup";
|
|
8
|
+
import { GroupError } from "./types";
|
|
9
|
+
|
|
10
|
+
export type GroupCommand =
|
|
11
|
+
| { action: "list" }
|
|
12
|
+
| { action: "new"; name: string }
|
|
13
|
+
| { action: "rename"; group: string; name: string }
|
|
14
|
+
| { action: "add"; group: string; sessions: string[] }
|
|
15
|
+
| { action: "rm"; sessions: string[] };
|
|
16
|
+
|
|
17
|
+
// `aos group ...`: the caller's own workspace's groups, resolved the way
|
|
18
|
+
// `aos read` resolves sessions, so it reaches nothing outside that scope.
|
|
19
|
+
export function groupCommand(callerId: string, cmd: GroupCommand): string {
|
|
20
|
+
const scope = readerScope(callerId);
|
|
21
|
+
const ws = scope.workspaceId;
|
|
22
|
+
if (cmd.action === "list") return describe(ws, scope.label);
|
|
23
|
+
if (cmd.action === "new") {
|
|
24
|
+
const g = createGroup(ws, cmd.name);
|
|
25
|
+
return `created group "${g.name}" in your ${scope.label}`;
|
|
26
|
+
}
|
|
27
|
+
if (cmd.action === "rename") {
|
|
28
|
+
const from = resolveGroup(ws, cmd.group);
|
|
29
|
+
return `renamed "${from.name}" to "${updateGroup(from.id, { name: cmd.name }).name}"`;
|
|
30
|
+
}
|
|
31
|
+
if (!cmd.sessions.length)
|
|
32
|
+
throw new GroupError("invalid", "Name at least one session");
|
|
33
|
+
const target = cmd.action === "add" ? resolveGroup(ws, cmd.group) : null;
|
|
34
|
+
const ids = cmd.sessions.map((ref) => {
|
|
35
|
+
if (ref === "me") return callerId;
|
|
36
|
+
const found = resolveReadable(callerId, ref);
|
|
37
|
+
if (!found.ok) throw new GroupError("not-found", found.error);
|
|
38
|
+
return found.session.id;
|
|
39
|
+
});
|
|
40
|
+
assignSessions(ids, target?.id ?? null);
|
|
41
|
+
return target
|
|
42
|
+
? `put ${ids.length} session${ids.length === 1 ? "" : "s"} in "${target.name}"`
|
|
43
|
+
: `took ${ids.length} session${ids.length === 1 ? "" : "s"} out of their group`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describe(workspaceId: string | null, label: string): string {
|
|
47
|
+
const groups = listGroups(workspaceId);
|
|
48
|
+
if (!groups.length) return `no groups in your ${label}`;
|
|
49
|
+
const counts = new Map(
|
|
50
|
+
(
|
|
51
|
+
getDb()
|
|
52
|
+
.prepare(
|
|
53
|
+
`SELECT session_group_id AS id, COUNT(*) AS n FROM sessions
|
|
54
|
+
WHERE session_group_id IS NOT NULL AND archived_at IS NULL
|
|
55
|
+
GROUP BY session_group_id`
|
|
56
|
+
)
|
|
57
|
+
.all() as { id: string; n: number }[]
|
|
58
|
+
).map((r) => [r.id, r.n])
|
|
59
|
+
);
|
|
60
|
+
return groups.map((g) => `${g.name} · ${counts.get(g.id) ?? 0}`).join("\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { cleanName, findGroup, groupByName } from "./store";
|
|
4
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
5
|
+
|
|
6
|
+
// A new group goes to the bottom of its workspace's order. The name is
|
|
7
|
+
// unique within the workspace, ignoring case.
|
|
8
|
+
export function createGroup(
|
|
9
|
+
workspaceId: string | null,
|
|
10
|
+
name: unknown
|
|
11
|
+
): SessionGroup {
|
|
12
|
+
const clean = cleanName(name);
|
|
13
|
+
const db = getDb();
|
|
14
|
+
if (
|
|
15
|
+
workspaceId &&
|
|
16
|
+
!db.prepare(`SELECT 1 FROM workspaces WHERE id = ?`).get(workspaceId)
|
|
17
|
+
)
|
|
18
|
+
throw new GroupError("not-found", "Workspace not found");
|
|
19
|
+
if (groupByName(workspaceId, clean))
|
|
20
|
+
throw new GroupError("duplicate", `There is already a group "${clean}"`);
|
|
21
|
+
const id = randomUUID();
|
|
22
|
+
db.prepare(
|
|
23
|
+
`INSERT INTO session_groups (id, workspace_id, name, sort_order)
|
|
24
|
+
VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order) + 1, 0)
|
|
25
|
+
FROM session_groups WHERE COALESCE(workspace_id, '') = COALESCE(?, '')))`
|
|
26
|
+
).run(id, workspaceId, clean, workspaceId);
|
|
27
|
+
return findGroup(id)!;
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export {
|
|
3
|
+
groupNames,
|
|
4
|
+
listGroups,
|
|
5
|
+
findGroup,
|
|
6
|
+
groupByName,
|
|
7
|
+
liveGroup,
|
|
8
|
+
} from "./store";
|
|
9
|
+
export { createGroup } from "./createGroup";
|
|
10
|
+
export { updateGroup } from "./updateGroup";
|
|
11
|
+
export { reorderGroups } from "./reorderGroups";
|
|
12
|
+
export { deleteGroup, restoreGroup } from "./removeGroup";
|
|
13
|
+
export {
|
|
14
|
+
assignSessions,
|
|
15
|
+
checkMember,
|
|
16
|
+
clearForeignGroup,
|
|
17
|
+
fileInGroup,
|
|
18
|
+
inheritGroup,
|
|
19
|
+
} from "./assignSessions";
|
|
20
|
+
export { resolveGroup } from "./resolveGroup";
|
|
21
|
+
export { sessionWorkspaceId } from "./workspace";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { findGroup, groupByName, liveGroup } from "./store";
|
|
3
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
4
|
+
|
|
5
|
+
// Soft delete: members keep their session_group_id and the sidebar ignores a
|
|
6
|
+
// deleted group, so they fall back to the status shelves and Undo (restore)
|
|
7
|
+
// puts them back. Nothing is hard-deleted.
|
|
8
|
+
export function deleteGroup(id: string): SessionGroup {
|
|
9
|
+
const group = liveGroup(id);
|
|
10
|
+
getDb()
|
|
11
|
+
.prepare(
|
|
12
|
+
`UPDATE session_groups SET deleted_at = datetime('now'),
|
|
13
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
14
|
+
)
|
|
15
|
+
.run(id);
|
|
16
|
+
return group;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function restoreGroup(id: string): SessionGroup {
|
|
20
|
+
const group = findGroup(id);
|
|
21
|
+
if (!group) throw new GroupError("not-found", "Group not found");
|
|
22
|
+
if (!group.deleted) return group;
|
|
23
|
+
// A new group took the name meanwhile.
|
|
24
|
+
if (groupByName(group.workspace_id, group.name))
|
|
25
|
+
throw new GroupError(
|
|
26
|
+
"duplicate",
|
|
27
|
+
`There is already a group "${group.name}"`
|
|
28
|
+
);
|
|
29
|
+
getDb()
|
|
30
|
+
.prepare(
|
|
31
|
+
`UPDATE session_groups SET deleted_at = NULL,
|
|
32
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
33
|
+
)
|
|
34
|
+
.run(id);
|
|
35
|
+
return liveGroup(id);
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { listGroups } from "./store";
|
|
3
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
4
|
+
|
|
5
|
+
// Sets a workspace's group order. `ids` may name only some of them (a move
|
|
6
|
+
// up or down sends the whole list; a stale list from another device leaves
|
|
7
|
+
// newer groups at the end), but never one from another workspace.
|
|
8
|
+
export function reorderGroups(
|
|
9
|
+
workspaceId: string | null,
|
|
10
|
+
ids: unknown
|
|
11
|
+
): SessionGroup[] {
|
|
12
|
+
if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string"))
|
|
13
|
+
throw new GroupError("invalid", "ids is a list of group ids");
|
|
14
|
+
const current = listGroups(workspaceId);
|
|
15
|
+
const known = new Set(current.map((g) => g.id));
|
|
16
|
+
const wanted = [...new Set(ids as string[])];
|
|
17
|
+
const stranger = wanted.find((i) => !known.has(i));
|
|
18
|
+
if (stranger)
|
|
19
|
+
throw new GroupError("workspace", "A group is not in this workspace");
|
|
20
|
+
const order = [
|
|
21
|
+
...wanted,
|
|
22
|
+
...current.map((g) => g.id).filter((i) => !wanted.includes(i)),
|
|
23
|
+
];
|
|
24
|
+
const db = getDb();
|
|
25
|
+
const set = db.prepare(
|
|
26
|
+
`UPDATE session_groups SET sort_order = ?, updated_at = datetime('now')
|
|
27
|
+
WHERE id = ? AND sort_order != ?`
|
|
28
|
+
);
|
|
29
|
+
db.transaction(() => order.forEach((id, i) => set.run(i, id, i)))();
|
|
30
|
+
return listGroups(workspaceId);
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { groupByName, listGroups } from "./store";
|
|
2
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
3
|
+
|
|
4
|
+
// A group named by a caller (`aos --group`, an orchestrator tool): it must
|
|
5
|
+
// exist in that workspace, and the error lists the ones that do.
|
|
6
|
+
export function resolveGroup(
|
|
7
|
+
workspaceId: string | null,
|
|
8
|
+
name: string
|
|
9
|
+
): SessionGroup {
|
|
10
|
+
const found = groupByName(workspaceId, name);
|
|
11
|
+
if (found) return found;
|
|
12
|
+
const names = listGroups(workspaceId).map((g) => `"${g.name}"`);
|
|
13
|
+
throw new GroupError(
|
|
14
|
+
"not-found",
|
|
15
|
+
names.length
|
|
16
|
+
? `No group "${name.trim()}" in this workspace. Groups: ${names.join(", ")}`
|
|
17
|
+
: `No group "${name.trim()}": this workspace has no groups`
|
|
18
|
+
);
|
|
19
|
+
}
|