@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,73 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
claimsShared,
|
|
5
|
+
claimsView,
|
|
6
|
+
liveClaims,
|
|
7
|
+
registerOutside,
|
|
8
|
+
} from "@/lib/claims";
|
|
9
|
+
import { outsideBody } from "@/lib/claims/types";
|
|
10
|
+
import { repoKey } from "@/lib/git-remote";
|
|
11
|
+
import { getDb, queries, type Session } from "@/lib/db";
|
|
12
|
+
import { getProject } from "@/lib/projects";
|
|
13
|
+
import { expandHome } from "@/lib/tasks/session";
|
|
14
|
+
|
|
15
|
+
export const dynamic = "force-dynamic";
|
|
16
|
+
|
|
17
|
+
const query = z.object({
|
|
18
|
+
session: z.string().max(100).optional(),
|
|
19
|
+
project: z.string().max(100).optional(),
|
|
20
|
+
repo: z.string().max(300).optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// GET /api/claims - what live sessions, here and on linked machines, are
|
|
24
|
+
// working on, with the pairs that overlap and when each machine was last
|
|
25
|
+
// heard from. `session` or `project` narrows it to that repository.
|
|
26
|
+
export async function GET(request: NextRequest) {
|
|
27
|
+
const q = query.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
|
28
|
+
if (!q.success)
|
|
29
|
+
return NextResponse.json({ error: "Bad query" }, { status: 400 });
|
|
30
|
+
let repo = q.data.repo?.toLowerCase();
|
|
31
|
+
if (q.data.session) {
|
|
32
|
+
const s = queries.getSession(getDb()).get(q.data.session) as
|
|
33
|
+
Session | undefined;
|
|
34
|
+
if (!s)
|
|
35
|
+
return NextResponse.json({ error: "No such session" }, { status: 404 });
|
|
36
|
+
repo =
|
|
37
|
+
(await repoKey(expandHome(s.worktree_path || s.working_directory))) ??
|
|
38
|
+
undefined;
|
|
39
|
+
if (!repo) return NextResponse.json(shape([]));
|
|
40
|
+
}
|
|
41
|
+
if (q.data.project) {
|
|
42
|
+
const p = getProject(q.data.project);
|
|
43
|
+
if (!p)
|
|
44
|
+
return NextResponse.json({ error: "No such project" }, { status: 404 });
|
|
45
|
+
repo = (await repoKey(expandHome(p.working_directory))) ?? undefined;
|
|
46
|
+
if (!repo) return NextResponse.json(shape([]));
|
|
47
|
+
}
|
|
48
|
+
return NextResponse.json(shape(liveClaims({ repo })));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// POST /api/claims - a session AgentOS didn't start registers or renews its
|
|
52
|
+
// claim (`aos claim start|touch`). The CLI read git in its own folder and
|
|
53
|
+
// sends the remote URL, branch and paths; nothing is run here.
|
|
54
|
+
export async function POST(request: NextRequest) {
|
|
55
|
+
const body = outsideBody.safeParse(await request.json().catch(() => null));
|
|
56
|
+
if (!body.success)
|
|
57
|
+
return NextResponse.json({ error: "Bad claim" }, { status: 400 });
|
|
58
|
+
if (!claimsShared())
|
|
59
|
+
return NextResponse.json({ error: "Claims are off" }, { status: 409 });
|
|
60
|
+
const result = registerOutside(body.data);
|
|
61
|
+
if (!result)
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{ error: "No repository remote, or that id belongs to another claim" },
|
|
64
|
+
{ status: 422 }
|
|
65
|
+
);
|
|
66
|
+
const claims = liveClaims({ repo: result.claim.repo });
|
|
67
|
+
const overlaps = shape(claims).overlaps.filter(
|
|
68
|
+
(o) => o.a === result.claim.id || o.b === result.claim.id
|
|
69
|
+
);
|
|
70
|
+
return NextResponse.json({ ...shape(claims), id: result.claim.id, overlaps });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const shape = (claims: ReturnType<typeof liveClaims>) => claimsView(claims);
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { deleteHost } from "@/lib/hosts";
|
|
3
|
+
import { pinForHost } from "@/lib/peer/links";
|
|
3
4
|
|
|
4
5
|
export async function DELETE(
|
|
5
6
|
_request: NextRequest,
|
|
6
7
|
{ params }: { params: Promise<{ id: string }> }
|
|
7
8
|
) {
|
|
8
9
|
try {
|
|
9
|
-
|
|
10
|
+
const id = (await params).id;
|
|
11
|
+
// Removing it here would leave the other machine linked to this one.
|
|
12
|
+
if (pinForHost(id))
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: "It's linked through Connect: unlink it first." },
|
|
15
|
+
{ status: 409 }
|
|
16
|
+
);
|
|
17
|
+
deleteHost(id);
|
|
10
18
|
return NextResponse.json({ success: true });
|
|
11
19
|
} catch (error) {
|
|
12
20
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { helloRoute } from "@/lib/peer/hello-route";
|
|
2
|
+
import { receiveReveal } from "@/lib/peer/pairing";
|
|
3
|
+
|
|
4
|
+
// POST /api/peer/hello/reveal - public: the asking machine reveals the nonce
|
|
5
|
+
// its hello committed to.
|
|
6
|
+
export const POST = helloRoute((body) => receiveReveal(body));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { helloRoute } from "@/lib/peer/hello-route";
|
|
2
|
+
import { receiveHello } from "@/lib/peer/pairing";
|
|
3
|
+
import { SOURCE_HEADER } from "@/lib/security/auth";
|
|
4
|
+
|
|
5
|
+
// POST /api/peer/hello - public: another machine on this Connect account
|
|
6
|
+
// asks to link (lib/peer/pairing.ts).
|
|
7
|
+
export const POST = helloRoute((body, request) =>
|
|
8
|
+
receiveHello(body, undefined, request.headers.get(SOURCE_HEADER) ?? "unknown")
|
|
9
|
+
);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { helloRoute } from "@/lib/peer/hello-route";
|
|
2
|
+
import { receiveStatus } from "@/lib/peer/pairing";
|
|
3
|
+
|
|
4
|
+
// POST /api/peer/hello/status - public: the asking machine learns, signed,
|
|
5
|
+
// whether the code was typed here.
|
|
6
|
+
export const POST = helloRoute((body) => receiveStatus(body));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { PEER_HEADER, TRUST_HEADER } from "@/lib/security/auth";
|
|
3
|
+
import { getHost } from "@/lib/hosts";
|
|
4
|
+
import { unlinkedByPeer } from "@/lib/peer/links";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
// POST /api/peer/unlink - a linked machine unlinked this one. Reached only
|
|
9
|
+
// through its signed envelope (lib/peer/rpc-server.ts), which the gate turns
|
|
10
|
+
// into `peer` trust naming the host it's pinned to; nothing else may call it.
|
|
11
|
+
export function POST(request: NextRequest) {
|
|
12
|
+
const hostId = request.headers.get(PEER_HEADER);
|
|
13
|
+
if (request.headers.get(TRUST_HEADER) !== "peer" || !hostId)
|
|
14
|
+
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
|
15
|
+
const name = getHost(hostId)?.name ?? "the other machine";
|
|
16
|
+
return NextResponse.json({ unlinked: unlinkedByPeer(hostId, name) });
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { peerAudit } from "@/lib/peer/audit";
|
|
4
|
+
|
|
5
|
+
// GET /api/peers/:hostId/audit - every call to and from that linked machine,
|
|
6
|
+
// newest first: Machines → Activity.
|
|
7
|
+
export async function GET(
|
|
8
|
+
request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ hostId: string }> }
|
|
10
|
+
) {
|
|
11
|
+
const gate = requireApprover(request);
|
|
12
|
+
if (!gate.ok) return gate.response;
|
|
13
|
+
return NextResponse.json({ rows: peerAudit((await params).hostId) });
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { unlinkPeer } from "@/lib/peer/unlink";
|
|
4
|
+
|
|
5
|
+
// DELETE /api/peers/:hostId - unlink a machine linked through Connect: its
|
|
6
|
+
// pin goes here at once, and it's told to drop its side. Takes the same
|
|
7
|
+
// trust as linking.
|
|
8
|
+
export async function DELETE(
|
|
9
|
+
request: NextRequest,
|
|
10
|
+
{ params }: { params: Promise<{ hostId: string }> }
|
|
11
|
+
) {
|
|
12
|
+
const gate = requireApprover(request);
|
|
13
|
+
if (!gate.ok) return gate.response;
|
|
14
|
+
try {
|
|
15
|
+
return NextResponse.json(await unlinkPeer((await params).hostId));
|
|
16
|
+
} catch (err) {
|
|
17
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
18
|
+
return NextResponse.json({ error }, { status: 404 });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { accountMachines } from "@/lib/peer/machines";
|
|
3
|
+
|
|
4
|
+
// GET /api/peers/account[?refresh=1] - the other machines on this Connect
|
|
5
|
+
// account, whether each is linked here, and whether the plan allows linking.
|
|
6
|
+
export async function GET(request: NextRequest) {
|
|
7
|
+
const refresh = request.nextUrl.searchParams.get("refresh") === "1";
|
|
8
|
+
return NextResponse.json(await accountMachines(refresh));
|
|
9
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { mintDevice } from "@/lib/security/devices";
|
|
4
|
+
import { isPublicPath } from "@/lib/security/auth";
|
|
5
|
+
|
|
6
|
+
const accept = await import("./pending/[id]/accept/route");
|
|
7
|
+
const decline = await import("./pending/[id]/decline/route");
|
|
8
|
+
const link = await import("./link/route");
|
|
9
|
+
const cancel = await import("./link/[id]/route");
|
|
10
|
+
const confirm = await import("./link/[id]/confirm/route");
|
|
11
|
+
const hello = await import("../peer/hello/route");
|
|
12
|
+
const unlink = await import("./[hostId]/route");
|
|
13
|
+
const audit = await import("./[hostId]/audit/route");
|
|
14
|
+
const hostDelete = await import("../hosts/[id]/route");
|
|
15
|
+
|
|
16
|
+
const plain = mintDevice("phone").device.id;
|
|
17
|
+
const approver = mintDevice("tablet", null, true).device.id;
|
|
18
|
+
|
|
19
|
+
const req = (
|
|
20
|
+
path: string,
|
|
21
|
+
o: { trust?: string; device?: string; body?: string; method?: string } = {}
|
|
22
|
+
) =>
|
|
23
|
+
new NextRequest(`http://localhost${path}`, {
|
|
24
|
+
method: o.method ?? "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
...(o.trust && { "x-agentos-trust": o.trust }),
|
|
27
|
+
...(o.device && { "x-agentos-device": o.device }),
|
|
28
|
+
"content-type": "application/json",
|
|
29
|
+
},
|
|
30
|
+
body:
|
|
31
|
+
o.method === "GET"
|
|
32
|
+
? undefined
|
|
33
|
+
: (o.body ?? JSON.stringify({ code: "123456" })),
|
|
34
|
+
});
|
|
35
|
+
const params = { params: Promise.resolve({ id: "nope" }) };
|
|
36
|
+
|
|
37
|
+
describe("linking routes", () => {
|
|
38
|
+
it("take a code only from this machine, the tailnet or a Can approve device", async () => {
|
|
39
|
+
for (const who of [
|
|
40
|
+
{},
|
|
41
|
+
{ trust: "device", device: plain },
|
|
42
|
+
{ trust: "device", device: "unknown" },
|
|
43
|
+
{ trust: "peer" },
|
|
44
|
+
{ trust: "open" },
|
|
45
|
+
]) {
|
|
46
|
+
const r = await accept.POST(
|
|
47
|
+
req("/api/peers/pending/nope/accept", who),
|
|
48
|
+
params
|
|
49
|
+
);
|
|
50
|
+
expect(r.status).toBe(403);
|
|
51
|
+
expect((await decline.POST(req("/x", who), params)).status).toBe(403);
|
|
52
|
+
expect((await confirm.POST(req("/x", who), params)).status).toBe(403);
|
|
53
|
+
expect((await link.POST(req("/x", { ...who, body: "{}" }))).status).toBe(
|
|
54
|
+
403
|
|
55
|
+
);
|
|
56
|
+
expect(
|
|
57
|
+
(
|
|
58
|
+
await cancel.DELETE(
|
|
59
|
+
req("/x", { ...who, method: "DELETE", body: "" }),
|
|
60
|
+
params
|
|
61
|
+
)
|
|
62
|
+
).status
|
|
63
|
+
).toBe(403);
|
|
64
|
+
}
|
|
65
|
+
// Past the gate: there is no such request.
|
|
66
|
+
for (const who of [
|
|
67
|
+
{ trust: "loopback" },
|
|
68
|
+
{ trust: "tailnet" },
|
|
69
|
+
{ trust: "device", device: approver },
|
|
70
|
+
])
|
|
71
|
+
expect(
|
|
72
|
+
(await accept.POST(req("/api/peers/pending/nope/accept", who), params))
|
|
73
|
+
.status
|
|
74
|
+
).toBe(404);
|
|
75
|
+
expect(
|
|
76
|
+
(await confirm.POST(req("/x", { trust: "loopback" }), params)).status
|
|
77
|
+
).toBe(404);
|
|
78
|
+
expect(
|
|
79
|
+
(await link.POST(req("/x", { trust: "loopback", body: "{}" }))).status
|
|
80
|
+
).toBe(400);
|
|
81
|
+
for (const body of ['{"code":482913}', "{", "{}"])
|
|
82
|
+
for (const route of [accept, confirm])
|
|
83
|
+
expect(
|
|
84
|
+
(await route.POST(req("/x", { trust: "loopback", body }), params))
|
|
85
|
+
.status
|
|
86
|
+
).toBe(400);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps a hello at 4 KiB and refuses junk", async () => {
|
|
90
|
+
const big = await hello.POST(
|
|
91
|
+
req("/api/peer/hello", { body: "x".repeat(5000) })
|
|
92
|
+
);
|
|
93
|
+
expect(big.status).toBe(413);
|
|
94
|
+
expect(
|
|
95
|
+
(await hello.POST(req("/api/peer/hello", { body: "{" }))).status
|
|
96
|
+
).toBe(400);
|
|
97
|
+
expect(big.headers.get("cache-control")).toBe("no-store");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("are public only for the three hello paths", () => {
|
|
101
|
+
for (const p of [
|
|
102
|
+
"/api/peer/hello",
|
|
103
|
+
"/api/peer/hello/reveal",
|
|
104
|
+
"/api/peer/hello/status",
|
|
105
|
+
])
|
|
106
|
+
expect(isPublicPath(p)).toBe(true);
|
|
107
|
+
for (const p of [
|
|
108
|
+
"/api/peer/hello/x",
|
|
109
|
+
"/api/peer/hello/../../pair/start",
|
|
110
|
+
"/api/peers/pending",
|
|
111
|
+
"/api/peers/account",
|
|
112
|
+
"/api/peers/link",
|
|
113
|
+
"/api/peers/link/x/confirm",
|
|
114
|
+
"/api/peers/h1",
|
|
115
|
+
"/api/peers/h1/audit",
|
|
116
|
+
"/api/peer/unlink",
|
|
117
|
+
])
|
|
118
|
+
expect(isPublicPath(p)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("unlink and Activity take the same trust as linking", async () => {
|
|
122
|
+
const hostParams = { params: Promise.resolve({ hostId: "nope" }) };
|
|
123
|
+
for (const who of [
|
|
124
|
+
{},
|
|
125
|
+
{ trust: "device", device: plain },
|
|
126
|
+
{ trust: "peer" },
|
|
127
|
+
{ trust: "open" },
|
|
128
|
+
]) {
|
|
129
|
+
const del = req("/api/peers/nope", {
|
|
130
|
+
...who,
|
|
131
|
+
method: "DELETE",
|
|
132
|
+
body: "",
|
|
133
|
+
});
|
|
134
|
+
expect((await unlink.DELETE(del, hostParams)).status).toBe(403);
|
|
135
|
+
const get = req("/api/peers/nope/audit", { ...who, method: "GET" });
|
|
136
|
+
expect((await audit.GET(get, hostParams)).status).toBe(403);
|
|
137
|
+
}
|
|
138
|
+
const asApprover = { trust: "device", device: approver };
|
|
139
|
+
expect(
|
|
140
|
+
(
|
|
141
|
+
await unlink.DELETE(
|
|
142
|
+
req("/x", { ...asApprover, method: "DELETE", body: "" }),
|
|
143
|
+
hostParams
|
|
144
|
+
)
|
|
145
|
+
).status
|
|
146
|
+
).toBe(404);
|
|
147
|
+
const rows = await audit.GET(
|
|
148
|
+
req("/x", { ...asApprover, method: "GET" }),
|
|
149
|
+
hostParams
|
|
150
|
+
);
|
|
151
|
+
expect(await rows.json()).toEqual({ rows: [] });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("won't remove a Connect-linked machine without unlinking it", async () => {
|
|
155
|
+
const { createConnectHost } = await import("@/lib/hosts");
|
|
156
|
+
const { savePin, pinForHost } = await import("@/lib/peer/links");
|
|
157
|
+
const id = createConnectHost("Linked").id;
|
|
158
|
+
savePin({
|
|
159
|
+
hostId: id,
|
|
160
|
+
machineId: "f0f0f0f0",
|
|
161
|
+
hostname: "m-0000000000000000.runagentos.com",
|
|
162
|
+
signPub: "x",
|
|
163
|
+
boxPub: "y",
|
|
164
|
+
});
|
|
165
|
+
const res = await hostDelete.DELETE(
|
|
166
|
+
req("/x", { method: "DELETE", body: "" }),
|
|
167
|
+
{
|
|
168
|
+
params: Promise.resolve({ id }),
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
expect(res.status).toBe(409);
|
|
172
|
+
expect(pinForHost(id)).not.toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
+
import { confirmLink } from "@/lib/peer/pairing-out";
|
|
5
|
+
|
|
6
|
+
const bodySchema = z.object({ code: z.string().max(32) });
|
|
7
|
+
|
|
8
|
+
// POST /api/peers/link/:id/confirm {code} - the code the other machine shows
|
|
9
|
+
// once it took ours, typed here: only then is it pinned. Same gate as
|
|
10
|
+
// starting the link.
|
|
11
|
+
export async function POST(
|
|
12
|
+
request: NextRequest,
|
|
13
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
14
|
+
) {
|
|
15
|
+
const gate = requireApprover(request);
|
|
16
|
+
if (!gate.ok) return gate.response;
|
|
17
|
+
const body = bodySchema.safeParse(await request.json().catch(() => null));
|
|
18
|
+
if (!body.success)
|
|
19
|
+
return NextResponse.json({ error: "code required" }, { status: 400 });
|
|
20
|
+
const result = confirmLink((await params).id, body.data.code);
|
|
21
|
+
if (!result.ok)
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: result.error, attemptsLeft: result.attemptsLeft },
|
|
24
|
+
{ status: result.status }
|
|
25
|
+
);
|
|
26
|
+
return NextResponse.json({ link: result.link });
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { cancelLink, outgoingLink } from "@/lib/peer/pairing-out";
|
|
4
|
+
|
|
5
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
6
|
+
|
|
7
|
+
// GET /api/peers/link/:id - a link this machine started: its code and state.
|
|
8
|
+
export async function GET(_request: NextRequest, { params }: Ctx) {
|
|
9
|
+
const link = outgoingLink((await params).id);
|
|
10
|
+
if (!link)
|
|
11
|
+
return NextResponse.json({ error: "No such request" }, { status: 404 });
|
|
12
|
+
return NextResponse.json({ link });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// DELETE /api/peers/link/:id - stop waiting for it.
|
|
16
|
+
export async function DELETE(request: NextRequest, { params }: Ctx) {
|
|
17
|
+
const gate = requireApprover(request);
|
|
18
|
+
if (!gate.ok) return gate.response;
|
|
19
|
+
return NextResponse.json({ cancelled: cancelLink((await params).id) });
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
+
import { LinkError, startLink } from "@/lib/peer/pairing-out";
|
|
5
|
+
|
|
6
|
+
const bodySchema = z.object({ machineId: z.string().min(1).max(64) });
|
|
7
|
+
|
|
8
|
+
// POST /api/peers/link {machineId} - ask an account machine to link. Once
|
|
9
|
+
// linked it can run code here too, so only a trusted place may start it.
|
|
10
|
+
export async function POST(request: NextRequest) {
|
|
11
|
+
const gate = requireApprover(request);
|
|
12
|
+
if (!gate.ok) return gate.response;
|
|
13
|
+
const body = bodySchema.safeParse(await request.json().catch(() => null));
|
|
14
|
+
if (!body.success)
|
|
15
|
+
return NextResponse.json({ error: "machineId required" }, { status: 400 });
|
|
16
|
+
try {
|
|
17
|
+
return NextResponse.json({ link: await startLink(body.data.machineId) });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const status = error instanceof LinkError ? error.status : 500;
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
return NextResponse.json({ error: message }, { status });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
+
import { acceptRequest } from "@/lib/peer/pairing";
|
|
5
|
+
|
|
6
|
+
const bodySchema = z.object({ code: z.string().max(32) });
|
|
7
|
+
|
|
8
|
+
// POST /api/peers/pending/:id/accept {code} - the code the other machine
|
|
9
|
+
// shows, typed here. Linking lets that machine run code here, so it takes
|
|
10
|
+
// this machine, the tailnet or a device with Can approve.
|
|
11
|
+
export async function POST(
|
|
12
|
+
request: NextRequest,
|
|
13
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
14
|
+
) {
|
|
15
|
+
const gate = requireApprover(request);
|
|
16
|
+
if (!gate.ok) return gate.response;
|
|
17
|
+
const body = bodySchema.safeParse(await request.json().catch(() => null));
|
|
18
|
+
if (!body.success)
|
|
19
|
+
return NextResponse.json({ error: "code required" }, { status: 400 });
|
|
20
|
+
const result = await acceptRequest((await params).id, body.data.code);
|
|
21
|
+
if (!result.ok)
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: result.error, attemptsLeft: result.attemptsLeft },
|
|
24
|
+
{ status: result.status }
|
|
25
|
+
);
|
|
26
|
+
return NextResponse.json({
|
|
27
|
+
hostId: result.hostId,
|
|
28
|
+
name: result.name,
|
|
29
|
+
confirmCode: result.confirmCode,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { declineRequest } from "@/lib/peer/pairing";
|
|
4
|
+
|
|
5
|
+
// POST /api/peers/pending/:id/decline
|
|
6
|
+
export async function POST(
|
|
7
|
+
request: NextRequest,
|
|
8
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
9
|
+
) {
|
|
10
|
+
const gate = requireApprover(request);
|
|
11
|
+
if (!gate.ok) return gate.response;
|
|
12
|
+
return NextResponse.json({ declined: declineRequest((await params).id) });
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { mintDevice } from "@/lib/security/devices";
|
|
4
|
+
|
|
5
|
+
const seen: unknown[] = [];
|
|
6
|
+
vi.mock("@/lib/peer/pairing", () => ({
|
|
7
|
+
pendingRequests: (_now: number, o: unknown) => {
|
|
8
|
+
seen.push(o);
|
|
9
|
+
return [];
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
const { GET } = await import("./route");
|
|
13
|
+
|
|
14
|
+
const get = (headers: Record<string, string>) =>
|
|
15
|
+
GET(new NextRequest("http://localhost/api/peers/pending", { headers }));
|
|
16
|
+
|
|
17
|
+
describe("GET /api/peers/pending", () => {
|
|
18
|
+
it("hands out the second code only to a caller that could accept", async () => {
|
|
19
|
+
const plain = mintDevice("phone").device.id;
|
|
20
|
+
const approver = mintDevice("tablet", null, true).device.id;
|
|
21
|
+
for (const h of [
|
|
22
|
+
{} as Record<string, string>,
|
|
23
|
+
{ "x-agentos-trust": "device", "x-agentos-device": plain },
|
|
24
|
+
{ "x-agentos-trust": "peer" },
|
|
25
|
+
])
|
|
26
|
+
expect((await get(h)).status).toBe(200);
|
|
27
|
+
await get({ "x-agentos-trust": "loopback" });
|
|
28
|
+
await get({ "x-agentos-trust": "device", "x-agentos-device": approver });
|
|
29
|
+
expect(seen).toEqual([
|
|
30
|
+
{ withCodes: false },
|
|
31
|
+
{ withCodes: false },
|
|
32
|
+
{ withCodes: false },
|
|
33
|
+
{ withCodes: true },
|
|
34
|
+
{ withCodes: true },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { approverOf } from "@/lib/security/approver";
|
|
3
|
+
import { pendingRequests } from "@/lib/peer/pairing";
|
|
4
|
+
|
|
5
|
+
// GET /api/peers/pending - machines asking to link here, waiting for the
|
|
6
|
+
// code, and accepted ones whose second code is still to be typed there. That
|
|
7
|
+
// code goes only to a caller that could have accepted.
|
|
8
|
+
export function GET(request: NextRequest) {
|
|
9
|
+
return NextResponse.json({
|
|
10
|
+
requests: pendingRequests(Date.now(), {
|
|
11
|
+
withCodes: approverOf(request.headers) !== null,
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { restoreGroup } from "@/lib/session-groups";
|
|
2
|
+
import { run } from "@/lib/session-groups/route";
|
|
3
|
+
|
|
4
|
+
// POST /api/session-groups/:id/restore - undo a delete.
|
|
5
|
+
export async function POST(
|
|
6
|
+
_request: Request,
|
|
7
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
8
|
+
) {
|
|
9
|
+
const { id } = await params;
|
|
10
|
+
return run(() => ({ sessionGroup: restoreGroup(id) }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { deleteGroup, updateGroup } from "@/lib/session-groups";
|
|
3
|
+
import { groupRoute, run } from "@/lib/session-groups/route";
|
|
4
|
+
|
|
5
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
6
|
+
|
|
7
|
+
const patch = z.object({
|
|
8
|
+
name: z.string().optional(),
|
|
9
|
+
collapsed: z.boolean().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// PATCH /api/session-groups/:id { name?, collapsed? } - rename or fold.
|
|
13
|
+
export async function PATCH(request: Request, { params }: Ctx) {
|
|
14
|
+
const { id } = await params;
|
|
15
|
+
return groupRoute(request, patch, (b) => ({
|
|
16
|
+
sessionGroup: updateGroup(id, b),
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// DELETE /api/session-groups/:id - soft delete; its sessions fall back to the
|
|
21
|
+
// status shelves and restore brings them back.
|
|
22
|
+
export async function DELETE(_request: Request, { params }: Ctx) {
|
|
23
|
+
const { id } = await params;
|
|
24
|
+
return run(() => ({ sessionGroup: deleteGroup(id) }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { reorderGroups } from "@/lib/session-groups";
|
|
3
|
+
import { groupRoute } from "@/lib/session-groups/route";
|
|
4
|
+
|
|
5
|
+
const order = z.object({
|
|
6
|
+
workspaceId: z.string().nullable(),
|
|
7
|
+
ids: z.array(z.string()),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// POST /api/session-groups/order { workspaceId, ids } - a workspace's order.
|
|
11
|
+
export async function POST(request: Request) {
|
|
12
|
+
return groupRoute(request, order, (b) => ({
|
|
13
|
+
sessionGroups: reorderGroups(b.workspaceId, b.ids),
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createGroup, listGroups } from "@/lib/session-groups";
|
|
3
|
+
import { groupRoute, run } from "@/lib/session-groups/route";
|
|
4
|
+
|
|
5
|
+
const create = z.object({
|
|
6
|
+
name: z.string({ error: "A group needs a name" }),
|
|
7
|
+
workspaceId: z.string().nullable(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// GET /api/session-groups - every live group.
|
|
11
|
+
export async function GET() {
|
|
12
|
+
return run(() => ({ sessionGroups: listGroups() }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// POST /api/session-groups { name, workspaceId } - create one at the bottom
|
|
16
|
+
// of its workspace's order.
|
|
17
|
+
export async function POST(request: Request) {
|
|
18
|
+
return groupRoute(request, create, (b) => ({
|
|
19
|
+
sessionGroup: createGroup(b.workspaceId, b.name),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inheritGroup } from "@/lib/session-groups/assignSessions";
|
|
1
2
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
3
|
import { randomUUID } from "crypto";
|
|
3
4
|
import { getDb, queries, type Session, type Message } from "@/lib/db";
|
|
@@ -63,6 +64,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
|
63
64
|
parent.host_id
|
|
64
65
|
);
|
|
65
66
|
|
|
67
|
+
inheritGroup(parentId, newId);
|
|
68
|
+
|
|
66
69
|
// NOTE: We do NOT copy claude_session_id here.
|
|
67
70
|
// When the forked session is first attached, it will use --fork-session flag
|
|
68
71
|
// with the parent's claude_session_id to create a new branched conversation.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { assignSessions } from "@/lib/session-groups";
|
|
3
|
+
import { groupRoute } from "@/lib/session-groups/route";
|
|
4
|
+
|
|
5
|
+
const body = z.object({ groupId: z.string().nullable() });
|
|
6
|
+
|
|
7
|
+
// POST /api/sessions/:id/group { groupId | null } - join a group of the
|
|
8
|
+
// session's workspace, or leave.
|
|
9
|
+
export async function POST(
|
|
10
|
+
request: Request,
|
|
11
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
12
|
+
) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
return groupRoute(request, body, (b) => ({
|
|
15
|
+
ok: true,
|
|
16
|
+
...assignSessions([id], b.groupId),
|
|
17
|
+
}));
|
|
18
|
+
}
|