@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/README.md
CHANGED
|
@@ -31,6 +31,7 @@ and check on all of it from your phone.
|
|
|
31
31
|
- **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working, In review and Done as it changes. [More](docs/guides/sessions.md)
|
|
32
32
|
- **A health watcher.** Zero-token checks on the orchestrator's tick catch a red machine load, a held queue, a quiet background job, a restart loop, slow CI, a PR armed for hours and a piece started twice. Each trip wakes one short, tool-less Sonnet call that judges it and posts one line to the orchestrator; only a thing you alone can clear reaches your phone. Settings → Health lists what is open, the history, the notes on what normal looks like and the thresholds. Report only: it never acts. [More](docs/reference/health.md)
|
|
33
33
|
- **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
|
|
34
|
+
- **Session groups.** Named shelves inside a workspace, under Pinned, for sessions that belong together; a group stays put while its sessions change state. [More](docs/guides/sessions.md#groups)
|
|
34
35
|
- **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
|
|
35
36
|
- **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
|
|
36
37
|
- **Experiments.** Settings → Experiments lists features still being tried, each off until you turn it on, with what it has cost and how often its suggestions were taken. The first is Learning cards: when a session ends it can offer to save the work as a recipe or keep a lesson where it belongs. [More](docs/decisions/2026-10-13-experiments.md)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { groupCommand } from "@/lib/session-groups/cli";
|
|
4
|
+
import { GROUP_STATUS, GroupError } from "@/lib/session-groups";
|
|
5
|
+
|
|
6
|
+
const refs = z.array(z.string().min(1)).min(1);
|
|
7
|
+
const body = z.object({
|
|
8
|
+
from: z.string().min(1, "aos group runs from inside an AgentOS session"),
|
|
9
|
+
command: z.discriminatedUnion("action", [
|
|
10
|
+
z.object({ action: z.literal("list") }),
|
|
11
|
+
z.object({ action: z.literal("new"), name: z.string() }),
|
|
12
|
+
z.object({
|
|
13
|
+
action: z.literal("rename"),
|
|
14
|
+
group: z.string(),
|
|
15
|
+
name: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
z.object({
|
|
18
|
+
action: z.literal("add"),
|
|
19
|
+
group: z.string(),
|
|
20
|
+
sessions: refs,
|
|
21
|
+
}),
|
|
22
|
+
z.object({ action: z.literal("rm"), sessions: refs }),
|
|
23
|
+
]),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// POST /api/bus/groups { from, command } - `aos group …`, scoped to the
|
|
27
|
+
// asking session's workspace like `aos read`.
|
|
28
|
+
export async function POST(request: NextRequest) {
|
|
29
|
+
const parsed = body.safeParse(await request.json().catch(() => null));
|
|
30
|
+
if (!parsed.success)
|
|
31
|
+
return NextResponse.json(
|
|
32
|
+
{ error: parsed.error.issues[0]?.message ?? "Bad request" },
|
|
33
|
+
{ status: 400 }
|
|
34
|
+
);
|
|
35
|
+
try {
|
|
36
|
+
return NextResponse.json({
|
|
37
|
+
summary: groupCommand(parsed.data.from, parsed.data.command),
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
const status = error instanceof GroupError ? GROUP_STATUS[error.code] : 409;
|
|
42
|
+
return NextResponse.json({ error: message }, { status });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { sendMessage } from "@/lib/bus";
|
|
3
|
+
import { parseTimeout } from "@/lib/bus/waits/register";
|
|
3
4
|
|
|
4
5
|
// from: the sending session's id, or null when the user sends from the UI.
|
|
5
6
|
// The message is stored either way; delivery says whether it reached the
|
|
6
7
|
// recipient ("delivered", "queued" behind a busy turn, or "failed" + why).
|
|
7
8
|
export async function POST(request: NextRequest) {
|
|
8
9
|
try {
|
|
9
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
from = null,
|
|
12
|
+
to,
|
|
13
|
+
body,
|
|
14
|
+
await: waits = false,
|
|
15
|
+
timeout,
|
|
16
|
+
replyTo,
|
|
17
|
+
} = await request.json();
|
|
10
18
|
const sent = await sendMessage({
|
|
11
19
|
fromId: from,
|
|
12
20
|
to: String(to ?? ""),
|
|
13
21
|
body: String(body ?? ""),
|
|
22
|
+
replyTo: replyTo == null ? undefined : Number(replyTo),
|
|
23
|
+
awaitMs: waits ? parseTimeout(timeout) : undefined,
|
|
14
24
|
});
|
|
15
25
|
return NextResponse.json(sent, { status: 201 });
|
|
16
26
|
} catch (error) {
|
|
@@ -4,6 +4,7 @@ import { createTask } from "@/lib/tasks";
|
|
|
4
4
|
import { startOrQueue, stampPriority } from "@/lib/tasks/queue";
|
|
5
5
|
import { isPriority } from "@/lib/tasks/schedule-types";
|
|
6
6
|
import { hostIdNamed } from "@/lib/hosts";
|
|
7
|
+
import { resolveGroup } from "@/lib/session-groups";
|
|
7
8
|
import { startView } from "@/lib/sessions/launch";
|
|
8
9
|
import { callerSession, starterOf } from "@/lib/tasks/starter";
|
|
9
10
|
import { callingDevice } from "@/lib/security/device-me";
|
|
@@ -33,6 +34,7 @@ export async function POST(request: NextRequest) {
|
|
|
33
34
|
from,
|
|
34
35
|
cwd,
|
|
35
36
|
piece,
|
|
37
|
+
group,
|
|
36
38
|
} = await request.json();
|
|
37
39
|
// Whoever called: a session (by `from`, else the worktree it runs in) or a person.
|
|
38
40
|
const starter = starterOf(
|
|
@@ -53,8 +55,15 @@ export async function POST(request: NextRequest) {
|
|
|
53
55
|
throw new Error('machine is "mac" or "any"');
|
|
54
56
|
if (piece !== undefined && mode !== "task")
|
|
55
57
|
throw new Error("--piece works for tasks only");
|
|
58
|
+
const target = findProject(String(project ?? ""));
|
|
59
|
+
if (group !== undefined && (typeof group !== "string" || !group.trim()))
|
|
60
|
+
throw new Error("--group needs a group name");
|
|
61
|
+
const groupId =
|
|
62
|
+
typeof group === "string"
|
|
63
|
+
? resolveGroup(target.workspace_id ?? null, group).id
|
|
64
|
+
: undefined;
|
|
56
65
|
if (mode === "task") {
|
|
57
|
-
const projectId =
|
|
66
|
+
const projectId = target.id;
|
|
58
67
|
// A task whose plan has pieces waiting here must say which it builds.
|
|
59
68
|
const waiting = startedBy ? waitingPiecesFor(startedBy, projectId) : [];
|
|
60
69
|
let pieceId: number | undefined;
|
|
@@ -73,6 +82,7 @@ export async function POST(request: NextRequest) {
|
|
|
73
82
|
model,
|
|
74
83
|
hostId: on ? hostIdNamed(String(on)) : undefined,
|
|
75
84
|
view: startView(view),
|
|
85
|
+
groupId,
|
|
76
86
|
planOnly: planOnly === true ? true : undefined,
|
|
77
87
|
};
|
|
78
88
|
const out = await startOrQueue(
|
|
@@ -100,6 +110,7 @@ export async function POST(request: NextRequest) {
|
|
|
100
110
|
model,
|
|
101
111
|
startedBy,
|
|
102
112
|
parentSessionId: starter.parentSessionId,
|
|
113
|
+
groupId,
|
|
103
114
|
});
|
|
104
115
|
return NextResponse.json({ session }, { status: 201 });
|
|
105
116
|
} catch (error) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { registerWait } from "@/lib/bus/waits/register";
|
|
3
|
+
|
|
4
|
+
// `aos wait <session> --until idle|merged|reply`: records the wait and
|
|
5
|
+
// returns; the sweep wakes the sender when it holds (lib/bus/waits/tick).
|
|
6
|
+
export async function POST(request: NextRequest) {
|
|
7
|
+
try {
|
|
8
|
+
const { from, to, until, timeout } = await request.json();
|
|
9
|
+
if (typeof from !== "string" || !from)
|
|
10
|
+
throw new Error("aos wait runs from inside an AgentOS session");
|
|
11
|
+
const result = await registerWait({
|
|
12
|
+
fromId: from,
|
|
13
|
+
to: String(to ?? ""),
|
|
14
|
+
until: String(until ?? ""),
|
|
15
|
+
timeout: timeout == null ? null : String(timeout),
|
|
16
|
+
});
|
|
17
|
+
return NextResponse.json(result, { status: 201 });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { endOutside } from "@/lib/claims";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
// DELETE /api/claims/:id - an outside session ends its own claim. A task's
|
|
7
|
+
// or another machine's claim can't be ended this way.
|
|
8
|
+
export async function DELETE(
|
|
9
|
+
_request: Request,
|
|
10
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
11
|
+
) {
|
|
12
|
+
const { id } = await params;
|
|
13
|
+
const result = endOutside(id);
|
|
14
|
+
if (result === "forbidden")
|
|
15
|
+
return NextResponse.json(
|
|
16
|
+
{ error: "Only an outside session's claim can be ended here" },
|
|
17
|
+
{ status: 403 }
|
|
18
|
+
);
|
|
19
|
+
return NextResponse.json({ ok: true, ended: result === "ended" });
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { checkStart } from "@/lib/claims/startCheck";
|
|
4
|
+
import { startWarning } from "@/lib/claims/startWarning";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
const body = z.object({
|
|
9
|
+
projectId: z.string().min(1).max(100),
|
|
10
|
+
name: z.string().max(200).optional(),
|
|
11
|
+
prompt: z.string().max(100_000),
|
|
12
|
+
branch: z.string().max(250).nullable().optional(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// POST /api/claims/check - before a person starts a task: who else is on the
|
|
16
|
+
// same work. Reads claims and the repo's file list; never blocks a start.
|
|
17
|
+
export async function POST(request: NextRequest) {
|
|
18
|
+
const parsed = body.safeParse(await request.json().catch(() => null));
|
|
19
|
+
if (!parsed.success)
|
|
20
|
+
return NextResponse.json({ error: "Bad check" }, { status: 400 });
|
|
21
|
+
const { overlaps } = await checkStart(parsed.data);
|
|
22
|
+
return NextResponse.json({ overlaps: overlaps.map(startWarning) });
|
|
23
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { NextRequest } from "next/server";
|
|
6
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { tempDir } from "@/lib/test-temp";
|
|
8
|
+
import { seedWorkspace } from "@/lib/orchestrator/testing";
|
|
9
|
+
|
|
10
|
+
const { db } = await import("@/lib/db");
|
|
11
|
+
const { machineId } = await import("@/lib/claims/machine");
|
|
12
|
+
const { upsertRemote } = await import("@/lib/claims/store");
|
|
13
|
+
const { putSetting } = await import("@/lib/settings-store");
|
|
14
|
+
const { POST } = await import("./check/route");
|
|
15
|
+
|
|
16
|
+
const FILES = ["lib/auth/session.ts", "lib/auth/index.ts", "lib/auth/token.ts"];
|
|
17
|
+
const REPO = "git.example.test/team/widgets";
|
|
18
|
+
|
|
19
|
+
let dir: string;
|
|
20
|
+
let projectId: string;
|
|
21
|
+
|
|
22
|
+
const sh = (cwd: string, ...args: string[]) =>
|
|
23
|
+
execFileSync("git", args, { cwd, stdio: "pipe" });
|
|
24
|
+
|
|
25
|
+
const check = (body: unknown) =>
|
|
26
|
+
POST(
|
|
27
|
+
new NextRequest("http://localhost/api/claims/check", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
vi.spyOn(os, "homedir").mockReturnValue(tempDir("aos-claim-check-"));
|
|
35
|
+
dir = tempDir("claim-check-repo-");
|
|
36
|
+
sh(dir, "init", "-q", "-b", "main");
|
|
37
|
+
sh(dir, "config", "user.email", "t@example.test");
|
|
38
|
+
sh(dir, "config", "user.name", "t");
|
|
39
|
+
sh(dir, "remote", "add", "origin", "git@git.example.test:team/widgets.git");
|
|
40
|
+
for (const f of FILES) {
|
|
41
|
+
fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true });
|
|
42
|
+
fs.writeFileSync(path.join(dir, f), "x");
|
|
43
|
+
}
|
|
44
|
+
sh(dir, "add", "-A");
|
|
45
|
+
sh(dir, "commit", "-q", "-m", "init");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
db.prepare(`DELETE FROM work_claims`).run();
|
|
50
|
+
putSetting("claims.share", "on");
|
|
51
|
+
const t = seedWorkspace();
|
|
52
|
+
projectId = t.app.id;
|
|
53
|
+
db.prepare(`UPDATE projects SET working_directory = ? WHERE id = ?`).run(
|
|
54
|
+
dir,
|
|
55
|
+
projectId
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const remote = (files: string[]) =>
|
|
60
|
+
upsertRemote(machineId(), "peer-1", {
|
|
61
|
+
id: "claim-box-1",
|
|
62
|
+
origin: "box-machine",
|
|
63
|
+
origin_name: "box",
|
|
64
|
+
hops: 0,
|
|
65
|
+
source: "session",
|
|
66
|
+
session_name: "Rework login sessions",
|
|
67
|
+
repo: REPO,
|
|
68
|
+
branch: "feature/login",
|
|
69
|
+
base: "main",
|
|
70
|
+
goal: "Rework login sessions",
|
|
71
|
+
files,
|
|
72
|
+
files_more: 0,
|
|
73
|
+
pr_url: null,
|
|
74
|
+
ttl_s: 600,
|
|
75
|
+
age_s: 60,
|
|
76
|
+
released: null,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("POST /api/claims/check", () => {
|
|
80
|
+
it("refuses a body that isn't a check", async () => {
|
|
81
|
+
expect((await check("nope")).status).toBe(400);
|
|
82
|
+
expect((await check({ projectId })).status).toBe(400);
|
|
83
|
+
expect((await check({ projectId, prompt: 5 })).status).toBe(400);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("says nothing when no one else is on the repo", async () => {
|
|
87
|
+
const res = await check({ projectId, prompt: "Tidy auth" });
|
|
88
|
+
expect(await res.json()).toEqual({ overlaps: [] });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns the overlap with the other claim, strong first", async () => {
|
|
92
|
+
remote(FILES);
|
|
93
|
+
const res = await check({
|
|
94
|
+
projectId,
|
|
95
|
+
name: "Tidy auth",
|
|
96
|
+
prompt: `Touch ${FILES.join(" ")}`,
|
|
97
|
+
});
|
|
98
|
+
const { overlaps } = await res.json();
|
|
99
|
+
expect(overlaps).toHaveLength(1);
|
|
100
|
+
expect(overlaps[0]).toMatchObject({
|
|
101
|
+
strength: "strong",
|
|
102
|
+
claim: { id: "claim-box-1", machine: "box", branch: "feature/login" },
|
|
103
|
+
});
|
|
104
|
+
expect(overlaps[0].shared).toEqual([...FILES].sort());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns no overlaps when sharing is off", async () => {
|
|
108
|
+
remote(FILES);
|
|
109
|
+
putSetting("claims.share", "off");
|
|
110
|
+
const res = await check({ projectId, prompt: `Touch ${FILES.join(" ")}` });
|
|
111
|
+
expect(await res.json()).toEqual({ overlaps: [] });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { acceptExchange, answerExchange } from "@/lib/claims/exchange";
|
|
3
|
+
import { claimsShared } from "@/lib/claims/machine";
|
|
4
|
+
import { rememberPeer } from "@/lib/claims/peers";
|
|
5
|
+
import { exchangeBody } from "@/lib/claims/types";
|
|
6
|
+
import { pinForHost } from "@/lib/peer/links";
|
|
7
|
+
import { PEER_HEADER, TRUST_HEADER } from "@/lib/security/auth";
|
|
8
|
+
import { readCapped } from "@/lib/security/read-capped";
|
|
9
|
+
|
|
10
|
+
export const dynamic = "force-dynamic";
|
|
11
|
+
|
|
12
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
// POST /api/claims/exchange - a linked machine sends its claims and gets
|
|
15
|
+
// ours back. Paths and one-line goals only; the device-token gate applies.
|
|
16
|
+
export async function POST(request: NextRequest) {
|
|
17
|
+
// A Connect peer arrives as `peer` trust naming its pin. The RPC layer has
|
|
18
|
+
// already refused suspended and unknown ones; a pin suspended since is
|
|
19
|
+
// refused here too.
|
|
20
|
+
let viaHost: string | null = null;
|
|
21
|
+
if (request.headers.get(TRUST_HEADER) === "peer") {
|
|
22
|
+
const hostId = request.headers.get(PEER_HEADER);
|
|
23
|
+
if (!hostId || pinForHost(hostId)?.state !== "active")
|
|
24
|
+
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
|
25
|
+
viaHost = hostId;
|
|
26
|
+
}
|
|
27
|
+
const raw = await readCapped(request, MAX_BYTES);
|
|
28
|
+
if (raw === null)
|
|
29
|
+
return NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
30
|
+
let json: unknown;
|
|
31
|
+
try {
|
|
32
|
+
json = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return NextResponse.json({ error: "Not JSON" }, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
const body = exchangeBody.safeParse(json);
|
|
37
|
+
if (!body.success)
|
|
38
|
+
return NextResponse.json({ error: "Bad claims" }, { status: 400 });
|
|
39
|
+
if (viaHost && claimsShared()) rememberPeer(viaHost, body.data.machine.id);
|
|
40
|
+
acceptExchange(body.data, viaHost);
|
|
41
|
+
return NextResponse.json(answerExchange(body.data));
|
|
42
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { machineId } from "@/lib/claims/machine";
|
|
5
|
+
import { putSetting } from "@/lib/settings-store";
|
|
6
|
+
import { upsertLocal } from "@/lib/claims/store";
|
|
7
|
+
import { POST } from "./route";
|
|
8
|
+
import { DELETE } from "./[id]/route";
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
db.prepare(`DELETE FROM work_claims`).run();
|
|
12
|
+
putSetting("claims.share", null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const post = (body: unknown) =>
|
|
16
|
+
POST(
|
|
17
|
+
new NextRequest("http://localhost/api/claims", {
|
|
18
|
+
method: "POST",
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
const del = (id: string) =>
|
|
23
|
+
DELETE(new Request("http://localhost"), { params: Promise.resolve({ id }) });
|
|
24
|
+
|
|
25
|
+
const ok = {
|
|
26
|
+
remote: "https://github.com/acme/widgets.git",
|
|
27
|
+
branch: "feature/search",
|
|
28
|
+
goal: "Rework search",
|
|
29
|
+
files: ["src/search.ts"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("POST /api/claims", () => {
|
|
33
|
+
it("registers, then reports an overlap with a task's claim", async () => {
|
|
34
|
+
upsertLocal(
|
|
35
|
+
{ id: machineId(), name: "here" },
|
|
36
|
+
{
|
|
37
|
+
sessionId: "t1",
|
|
38
|
+
sessionName: "Task",
|
|
39
|
+
repo: "github.com/acme/widgets",
|
|
40
|
+
branch: "task/search",
|
|
41
|
+
base: "main",
|
|
42
|
+
goal: "Search work",
|
|
43
|
+
files: ["src/search.ts"],
|
|
44
|
+
filesMore: 0,
|
|
45
|
+
prUrl: null,
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
const res = await post(ok);
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
expect(data.id).toMatch(/^o-/);
|
|
52
|
+
expect(data.overlaps[0].strength).toBe("strong");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects a bad body, a non-remote and a custom id", async () => {
|
|
56
|
+
expect((await post({ remote: 5 })).status).toBe(400);
|
|
57
|
+
expect((await post({ ...ok, id: "s-abc" })).status).toBe(400);
|
|
58
|
+
expect((await post({ ...ok, remote: "/srv/x" })).status).toBe(422);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("is off when claims are", async () => {
|
|
62
|
+
putSetting("claims.share", "off");
|
|
63
|
+
expect((await post(ok)).status).toBe(409);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("DELETE /api/claims/:id", () => {
|
|
68
|
+
it("ends an outside claim but not a task's", async () => {
|
|
69
|
+
upsertLocal(
|
|
70
|
+
{ id: machineId(), name: "here" },
|
|
71
|
+
{
|
|
72
|
+
sessionId: "t1",
|
|
73
|
+
sessionName: "Task",
|
|
74
|
+
repo: "github.com/acme/widgets",
|
|
75
|
+
branch: "x",
|
|
76
|
+
base: "main",
|
|
77
|
+
goal: "Task",
|
|
78
|
+
files: [],
|
|
79
|
+
filesMore: 0,
|
|
80
|
+
prUrl: null,
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
expect((await del("s-t1")).status).toBe(403);
|
|
84
|
+
const { id } = await (await post(ok)).json();
|
|
85
|
+
expect((await del(id)).status).toBe(200);
|
|
86
|
+
expect((await del("o-00000000-0000-0000-0000-000000000000")).status).toBe(
|
|
87
|
+
200
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { machineId } from "@/lib/claims/machine";
|
|
5
|
+
import { createConnectHost } from "@/lib/hosts";
|
|
6
|
+
import { putSetting } from "@/lib/settings-store";
|
|
7
|
+
import { savePin, suspendPin } from "@/lib/peer/links";
|
|
8
|
+
import { upsertLocal } from "@/lib/claims/store";
|
|
9
|
+
import { GET } from "./route";
|
|
10
|
+
import { POST } from "./exchange/route";
|
|
11
|
+
|
|
12
|
+
const post = (body: unknown) =>
|
|
13
|
+
POST(
|
|
14
|
+
new NextRequest("http://localhost/api/claims/exchange", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
db.prepare(`DELETE FROM work_claims`).run();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const claim = {
|
|
25
|
+
id: "r1",
|
|
26
|
+
origin: "peer-1",
|
|
27
|
+
origin_name: "box",
|
|
28
|
+
goal: "Add oauth",
|
|
29
|
+
repo: "github.com/acme/widgets",
|
|
30
|
+
branch: "feature/oauth",
|
|
31
|
+
files: ["a.ts"],
|
|
32
|
+
ttl_s: 300,
|
|
33
|
+
age_s: 5,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("POST /api/claims/exchange", () => {
|
|
37
|
+
it("stores a peer's claims and answers with ours", async () => {
|
|
38
|
+
upsertLocal(
|
|
39
|
+
{ id: machineId(), name: "here" },
|
|
40
|
+
{
|
|
41
|
+
sessionId: "s",
|
|
42
|
+
sessionName: "Mine",
|
|
43
|
+
repo: "github.com/acme/widgets",
|
|
44
|
+
branch: "feature/mine",
|
|
45
|
+
base: "main",
|
|
46
|
+
goal: "Mine",
|
|
47
|
+
files: [],
|
|
48
|
+
filesMore: 0,
|
|
49
|
+
prUrl: null,
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
const res = await post({
|
|
53
|
+
machine: { id: "peer-1", name: "box" },
|
|
54
|
+
claims: [claim],
|
|
55
|
+
});
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
const body = await res.json();
|
|
58
|
+
expect(body.machine.id).toBe(machineId());
|
|
59
|
+
expect(body.claims.map((c: { id: string }) => c.id)).toEqual(["s-s"]);
|
|
60
|
+
const list = await (
|
|
61
|
+
await GET(new NextRequest("http://localhost/api/claims"))
|
|
62
|
+
).json();
|
|
63
|
+
expect(list.claims.map((c: { id: string }) => c.id).sort()).toEqual([
|
|
64
|
+
"r1",
|
|
65
|
+
"s-s",
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses bodies that are not claims", async () => {
|
|
70
|
+
expect((await post("{nope")).status).toBe(400);
|
|
71
|
+
expect(
|
|
72
|
+
(await post({ machine: { id: "p", name: "n" }, claims: [{ id: "x" }] }))
|
|
73
|
+
.status
|
|
74
|
+
).toBe(400);
|
|
75
|
+
expect(
|
|
76
|
+
(
|
|
77
|
+
await post({
|
|
78
|
+
machine: { id: "p", name: "n" },
|
|
79
|
+
claims: [{ ...claim, ttl_s: -5 }],
|
|
80
|
+
})
|
|
81
|
+
).status
|
|
82
|
+
).toBe(400);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("POST /api/claims/exchange as a Connect peer", () => {
|
|
87
|
+
const asPeer = (hostId: string | null) =>
|
|
88
|
+
POST(
|
|
89
|
+
new NextRequest("http://localhost/api/claims/exchange", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"x-agentos-trust": "peer",
|
|
93
|
+
...(hostId && { "x-agentos-peer": hostId }),
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
machine: { id: "peer-1", name: "box" },
|
|
97
|
+
claims: [claim],
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
it("refuses a host with no active pin and stores nothing", async () => {
|
|
103
|
+
expect((await asPeer("no-such-host")).status).toBe(403);
|
|
104
|
+
expect((await asPeer(null)).status).toBe(403);
|
|
105
|
+
expect(db.prepare(`SELECT 1 FROM work_claims`).all()).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("refuses a suspended pin, and an active one keeps its via_host", async () => {
|
|
109
|
+
const host = createConnectHost(`Spark-${Date.now()}`).id;
|
|
110
|
+
savePin({
|
|
111
|
+
hostId: host,
|
|
112
|
+
machineId: "b2b2b2b2",
|
|
113
|
+
hostname: "m-0123456789abcdef.runagentos.com",
|
|
114
|
+
signPub: "s",
|
|
115
|
+
boxPub: "b",
|
|
116
|
+
});
|
|
117
|
+
expect((await asPeer(host)).status).toBe(200);
|
|
118
|
+
const via = () =>
|
|
119
|
+
(
|
|
120
|
+
db.prepare(`SELECT via_host FROM work_claims WHERE id='r1'`).get() as {
|
|
121
|
+
via_host: string | null;
|
|
122
|
+
}
|
|
123
|
+
).via_host;
|
|
124
|
+
expect(via()).toBe(host);
|
|
125
|
+
// A token caller re-sending it, with more life, must not clear the pin.
|
|
126
|
+
await post({
|
|
127
|
+
machine: { id: "peer-1", name: "box" },
|
|
128
|
+
claims: [{ ...claim, ttl_s: 600 }],
|
|
129
|
+
});
|
|
130
|
+
expect(via()).toBe(host);
|
|
131
|
+
suspendPin(host, "revoked");
|
|
132
|
+
db.prepare(`DELETE FROM work_claims`).run();
|
|
133
|
+
expect((await asPeer(host)).status).toBe(403);
|
|
134
|
+
expect(db.prepare(`SELECT 1 FROM work_claims`).all()).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("writes nothing when sharing is off", async () => {
|
|
138
|
+
const host = createConnectHost(`Studio-${Date.now()}`).id;
|
|
139
|
+
savePin({
|
|
140
|
+
hostId: host,
|
|
141
|
+
machineId: "c3c3c3c3",
|
|
142
|
+
hostname: "m-fedcba9876543210.runagentos.com",
|
|
143
|
+
signPub: "s",
|
|
144
|
+
boxPub: "b",
|
|
145
|
+
});
|
|
146
|
+
db.prepare(`DELETE FROM claim_peers`).run();
|
|
147
|
+
putSetting("claims.share", "off");
|
|
148
|
+
try {
|
|
149
|
+
expect((await asPeer(host)).status).toBe(200);
|
|
150
|
+
} finally {
|
|
151
|
+
putSetting("claims.share", "on");
|
|
152
|
+
}
|
|
153
|
+
expect(db.prepare(`SELECT 1 FROM claim_peers`).all()).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("GET /api/claims", () => {
|
|
158
|
+
it("rejects a bad query and an unknown session", async () => {
|
|
159
|
+
const bad = await GET(
|
|
160
|
+
new NextRequest(`http://localhost/api/claims?repo=${"x".repeat(400)}`)
|
|
161
|
+
);
|
|
162
|
+
expect(bad.status).toBe(400);
|
|
163
|
+
const none = await GET(
|
|
164
|
+
new NextRequest("http://localhost/api/claims?session=missing")
|
|
165
|
+
);
|
|
166
|
+
expect(none.status).toBe(404);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("narrows to a repo and lists overlaps", async () => {
|
|
170
|
+
await post({
|
|
171
|
+
machine: { id: "peer-1", name: "box" },
|
|
172
|
+
claims: [
|
|
173
|
+
claim,
|
|
174
|
+
{
|
|
175
|
+
...claim,
|
|
176
|
+
id: "r2",
|
|
177
|
+
origin: "peer-2",
|
|
178
|
+
origin_name: "pc",
|
|
179
|
+
repo: "github.com/acme/other",
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
const body = await (
|
|
184
|
+
await GET(
|
|
185
|
+
new NextRequest(
|
|
186
|
+
"http://localhost/api/claims?repo=github.com/acme/widgets"
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
).json();
|
|
190
|
+
expect(body.claims.map((c: { id: string }) => c.id)).toEqual(["r1"]);
|
|
191
|
+
expect(body.overlaps).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
});
|