@saadnvd1/agent-os 0.3.851 → 0.3.859
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/app/api/leads/adopt/route.ts +35 -0
- package/app/api/leads/proposals/route.ts +52 -0
- package/app/api/leads/route.test.ts +95 -0
- package/app/api/leads/route.ts +60 -0
- package/app/api/sessions/[id]/fresh/route.ts +2 -1
- package/app/api/sessions/[id]/route.ts +9 -1
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
- package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
- package/components/Analyst/pair.ts +2 -1
- package/components/Chat/ChatPanel.tsx +1 -0
- package/components/Chat/Forks.tsx +24 -2
- package/components/Orchestrator/AskCard.tsx +6 -0
- package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
- package/components/Pane/index.tsx +5 -7
- package/components/Schedules/ScheduleForm.tsx +2 -1
- package/components/SessionList/RowSubtitle.tsx +2 -1
- package/components/SessionList/SessionRow.tsx +2 -1
- package/components/SessionList/useDoneAction.ts +8 -2
- package/components/SessionList/useSidebarData.ts +8 -3
- package/content/whats-new.json +24 -0
- package/data/done/index.ts +7 -1
- package/hooks/useNeedsYou.ts +10 -4
- package/hooks/useOrchestratorHeader.ts +2 -1
- package/lib/analyst/createLink.ts +3 -1
- package/lib/away/report.ts +2 -1
- package/lib/bus/format.ts +4 -1
- package/lib/bus/index.ts +9 -2
- package/lib/bus/read-scope.ts +4 -3
- package/lib/bus/resolve.ts +4 -1
- package/lib/bus/role-alias.ts +2 -1
- package/lib/bus/targets.ts +8 -3
- package/lib/bus/waits/tick.ts +2 -1
- package/lib/chat/compact-context.ts +2 -1
- package/lib/chat/mention-roots.ts +2 -1
- package/lib/chat/runner.ts +3 -2
- package/lib/chat/settings.ts +3 -2
- package/lib/chat/worker/extras.ts +4 -1
- package/lib/chat/worker/host.ts +6 -5
- package/lib/db/migrations/20261102T1944_leads.ts +142 -0
- package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +10 -2
- package/lib/done/archive.ts +3 -2
- package/lib/done/auto-archive.ts +2 -1
- package/lib/done/bulk.ts +4 -3
- package/lib/done/index.ts +3 -2
- package/lib/done/plan.ts +5 -2
- package/lib/experiments/registry.ts +11 -0
- package/lib/experiments/view.ts +3 -1
- package/lib/fork/brief.ts +2 -2
- package/lib/fork/extras.ts +4 -2
- package/lib/fork/guard.ts +4 -2
- package/lib/fork/scratch.ts +102 -12
- package/lib/fork/settle.ts +31 -7
- package/lib/fork/start.ts +5 -4
- package/lib/fork/tick.ts +19 -0
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +10 -0
- package/lib/fork/worktree.ts +7 -3
- package/lib/health/env/sendFixNotes.ts +3 -2
- package/lib/hosts/peer-actions.ts +2 -1
- package/lib/hosts/peer-sessions.ts +3 -1
- package/lib/leads/actor.ts +16 -0
- package/lib/leads/adopt.ts +150 -0
- package/lib/leads/area.ts +77 -0
- package/lib/leads/brief-common.ts +122 -0
- package/lib/leads/brief.ts +155 -0
- package/lib/leads/charters.ts +99 -0
- package/lib/leads/deliver.ts +24 -0
- package/lib/leads/digest.ts +166 -0
- package/lib/leads/direction.ts +202 -0
- package/lib/leads/home.ts +196 -0
- package/lib/leads/index.ts +8 -0
- package/lib/leads/memos.ts +70 -0
- package/lib/leads/messages.ts +112 -0
- package/lib/leads/profiles.ts +83 -0
- package/lib/leads/roles.ts +114 -0
- package/lib/leads/settings.ts +55 -0
- package/lib/leads/templates.ts +51 -0
- package/lib/leads/tools.ts +159 -0
- package/lib/leads/types.ts +88 -0
- package/lib/leads/usage.ts +46 -0
- package/lib/learning-cards/input.ts +2 -1
- package/lib/live/drive.ts +2 -1
- package/lib/models/launch.ts +2 -1
- package/lib/models/session-role.ts +2 -1
- package/lib/needs-you.ts +12 -6
- package/lib/notify/index.ts +2 -1
- package/lib/orchestrator/act.ts +3 -1
- package/lib/orchestrator/ask-tool.ts +10 -0
- package/lib/orchestrator/ask-view.ts +2 -0
- package/lib/orchestrator/asks.ts +48 -8
- package/lib/orchestrator/brief.ts +32 -67
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/facts.ts +2 -1
- package/lib/orchestrator/full-access.ts +2 -1
- package/lib/orchestrator/home.ts +8 -7
- package/lib/orchestrator/mcp-servers.ts +7 -6
- package/lib/orchestrator/notes.ts +15 -4
- package/lib/orchestrator/overview.ts +3 -1
- package/lib/orchestrator/recipe-tools.ts +2 -1
- package/lib/orchestrator/reminders.ts +2 -1
- package/lib/orchestrator/tools.ts +43 -13
- package/lib/orchestrator/worker.ts +45 -13
- package/lib/orchestrator/write-guard.ts +2 -2
- package/lib/projects.ts +3 -2
- package/lib/resurrect/find.ts +2 -1
- package/lib/resurrect/index.ts +3 -2
- package/lib/schedules/index.ts +3 -2
- package/lib/schedules/start.ts +2 -1
- package/lib/schedules/store.ts +3 -2
- package/lib/session-audit/sources.ts +4 -3
- package/lib/session-groups/workspace.ts +2 -1
- package/lib/sessions/launch.ts +2 -1
- package/lib/sidebar/machines.ts +2 -1
- package/lib/sidebar/row-actions.ts +2 -1
- package/lib/sidebar/row-badge.ts +3 -1
- package/lib/sidebar/row-menu.ts +6 -3
- package/lib/sidebar/shelves.ts +3 -3
- package/lib/status/collect.ts +4 -3
- package/lib/tasks/held-replies.ts +2 -1
- package/lib/tasks/move-targets.ts +2 -1
- package/lib/tasks/pause.ts +2 -1
- package/lib/tasks/starter.ts +3 -2
- package/lib/workspaces.ts +4 -3
- package/lib/worktree-sweep/findSettled.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { experimentOn } from "@/lib/experiments";
|
|
3
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
+
import { getWorkspace } from "@/lib/workspaces";
|
|
5
|
+
import { adoptLead } from "@/lib/leads/adopt";
|
|
6
|
+
|
|
7
|
+
// Adopt an existing chat as one of a workspace's leads. It keeps its id,
|
|
8
|
+
// its chat and its conversation; see lib/leads/adopt.
|
|
9
|
+
export async function POST(request: NextRequest) {
|
|
10
|
+
const gate = requireApprover(request);
|
|
11
|
+
if (!gate.ok) return gate.response;
|
|
12
|
+
if (!experimentOn("leads"))
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: "Leads is off (Settings → Experiments)" },
|
|
15
|
+
{ status: 409 }
|
|
16
|
+
);
|
|
17
|
+
const body = await request.json().catch(() => ({}));
|
|
18
|
+
const { sessionId, workspaceId, kind } = body as Record<string, unknown>;
|
|
19
|
+
if (typeof sessionId !== "string" || typeof kind !== "string")
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: "Give sessionId, workspaceId and kind" },
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
if (typeof workspaceId !== "string" || !getWorkspace(workspaceId))
|
|
25
|
+
return NextResponse.json({ error: "Unknown workspace" }, { status: 404 });
|
|
26
|
+
try {
|
|
27
|
+
const session = await adoptLead({ sessionId, workspaceId, kind });
|
|
28
|
+
return NextResponse.json({ session });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
32
|
+
{ status: 400 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
3
|
+
import { getWorkspace } from "@/lib/workspaces";
|
|
4
|
+
import { isLeadKind } from "@/lib/leads/roles";
|
|
5
|
+
import { openProposals } from "@/lib/leads/direction";
|
|
6
|
+
import { confirmPins } from "@/lib/leads/adopt";
|
|
7
|
+
|
|
8
|
+
const bad = (error: string, status = 400) =>
|
|
9
|
+
NextResponse.json({ error }, { status });
|
|
10
|
+
|
|
11
|
+
// A lead's pins waiting for the owner.
|
|
12
|
+
export async function GET(request: NextRequest) {
|
|
13
|
+
const q = request.nextUrl.searchParams;
|
|
14
|
+
const workspaceId = q.get("workspaceId") ?? "";
|
|
15
|
+
const kind = q.get("kind") ?? "";
|
|
16
|
+
if (!getWorkspace(workspaceId)) return bad("Unknown workspace", 404);
|
|
17
|
+
if (!isLeadKind(kind)) return bad("Unknown lead");
|
|
18
|
+
return NextResponse.json({ proposals: openProposals(workspaceId, kind) });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The owner's one tap: the confirmed proposals become pinned direction, the
|
|
22
|
+
// rest are dropped, and with switchOver the lead starts fresh on its memo
|
|
23
|
+
// and direction (an adopted lead's switch).
|
|
24
|
+
export async function POST(request: NextRequest) {
|
|
25
|
+
const gate = requireApprover(request);
|
|
26
|
+
if (!gate.ok) return gate.response;
|
|
27
|
+
const body = (await request.json().catch(() => ({}))) as Record<
|
|
28
|
+
string,
|
|
29
|
+
unknown
|
|
30
|
+
>;
|
|
31
|
+
const { workspaceId, kind, confirm, switchOver } = body;
|
|
32
|
+
if (typeof workspaceId !== "string" || !getWorkspace(workspaceId))
|
|
33
|
+
return bad("Unknown workspace", 404);
|
|
34
|
+
if (!isLeadKind(kind)) return bad("Unknown lead");
|
|
35
|
+
if (
|
|
36
|
+
!Array.isArray(confirm) ||
|
|
37
|
+
!confirm.every((n) => Number.isInteger(n) && n > 0)
|
|
38
|
+
)
|
|
39
|
+
return bad("confirm is a list of proposal ids");
|
|
40
|
+
try {
|
|
41
|
+
return NextResponse.json(
|
|
42
|
+
await confirmPins({
|
|
43
|
+
workspaceId,
|
|
44
|
+
kind,
|
|
45
|
+
confirm,
|
|
46
|
+
switchOver: switchOver === true,
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return bad(error instanceof Error ? error.message : String(error));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { setExperiment } from "@/lib/experiments";
|
|
5
|
+
import { TRUST_HEADER } from "@/lib/security/auth";
|
|
6
|
+
import { seedWorkspace } from "@/lib/orchestrator/testing";
|
|
7
|
+
import { GET, POST } from "./route";
|
|
8
|
+
import * as adopt from "./adopt/route";
|
|
9
|
+
import * as proposals from "./proposals/route";
|
|
10
|
+
|
|
11
|
+
const post = (
|
|
12
|
+
handler: (r: NextRequest) => Promise<Response>,
|
|
13
|
+
path: string,
|
|
14
|
+
body: unknown,
|
|
15
|
+
trust: string | null = "loopback"
|
|
16
|
+
) =>
|
|
17
|
+
handler(
|
|
18
|
+
new NextRequest(`http://localhost/api/leads${path}`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
headers: trust ? { [TRUST_HEADER]: trust } : {},
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
db.prepare(`DELETE FROM settings WHERE key LIKE 'experiments.%'`).run();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("/api/leads", () => {
|
|
30
|
+
it("refuses while Leads is off, and without an approver", async () => {
|
|
31
|
+
const { workspace } = seedWorkspace();
|
|
32
|
+
const body = { workspaceId: workspace.id, kinds: ["marketing"] };
|
|
33
|
+
expect((await post(POST, "", body)).status).toBe(409);
|
|
34
|
+
expect(
|
|
35
|
+
(
|
|
36
|
+
await post(adopt.POST, "/adopt", {
|
|
37
|
+
...body,
|
|
38
|
+
sessionId: "x",
|
|
39
|
+
kind: "marketing",
|
|
40
|
+
})
|
|
41
|
+
).status
|
|
42
|
+
).toBe(409);
|
|
43
|
+
setExperiment("leads", true);
|
|
44
|
+
expect((await post(POST, "", body, null)).status).toBe(403);
|
|
45
|
+
expect((await post(proposals.POST, "/proposals", body, null)).status).toBe(
|
|
46
|
+
403
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("adds leads and lists them", async () => {
|
|
51
|
+
setExperiment("leads", true);
|
|
52
|
+
const { workspace } = seedWorkspace();
|
|
53
|
+
const res = await post(POST, "", {
|
|
54
|
+
workspaceId: workspace.id,
|
|
55
|
+
kinds: ["marketing"],
|
|
56
|
+
});
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
expect(
|
|
59
|
+
(await res.json()).leads.map((l: { kind: string }) => l.kind)
|
|
60
|
+
).toEqual(["tech", "marketing"]);
|
|
61
|
+
const list = await (
|
|
62
|
+
await GET(new NextRequest("http://localhost/api/leads"))
|
|
63
|
+
).json();
|
|
64
|
+
expect(list.enabled).toBe(true);
|
|
65
|
+
expect(
|
|
66
|
+
list.workspaces.map((w: { workspaceId: string }) => w.workspaceId)
|
|
67
|
+
).toContain(workspace.id);
|
|
68
|
+
const bad = await post(POST, "", {
|
|
69
|
+
workspaceId: workspace.id,
|
|
70
|
+
kinds: ["No Way"],
|
|
71
|
+
});
|
|
72
|
+
expect(bad.status).toBe(400);
|
|
73
|
+
expect(
|
|
74
|
+
(await post(POST, "", { workspaceId: "nope", kinds: [] })).status
|
|
75
|
+
).toBe(404);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("lists a lead's open proposals and refuses an unknown lead's", async () => {
|
|
79
|
+
setExperiment("leads", true);
|
|
80
|
+
const { workspace } = seedWorkspace();
|
|
81
|
+
await post(POST, "", { workspaceId: workspace.id, kinds: ["product"] });
|
|
82
|
+
const res = await proposals.GET(
|
|
83
|
+
new NextRequest(
|
|
84
|
+
`http://localhost/api/leads/proposals?workspaceId=${workspace.id}&kind=product`
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
expect((await res.json()).proposals).toEqual([]);
|
|
88
|
+
const none = await post(proposals.POST, "/proposals", {
|
|
89
|
+
workspaceId: workspace.id,
|
|
90
|
+
kind: "design",
|
|
91
|
+
confirm: [],
|
|
92
|
+
});
|
|
93
|
+
expect(none.status).toBe(400);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { experimentOn } from "@/lib/experiments";
|
|
3
|
+
import { requireApprover } from "@/lib/security/approver";
|
|
4
|
+
import { getWorkspace } from "@/lib/workspaces";
|
|
5
|
+
import { sessionById } from "@/lib/bus/targets";
|
|
6
|
+
import { sessionWorkspace } from "@/lib/schedules/store";
|
|
7
|
+
import { workspaceLeads, workspacesWithLeads } from "@/lib/leads/settings";
|
|
8
|
+
import { addLeads, leadViews } from "@/lib/leads/home";
|
|
9
|
+
|
|
10
|
+
const view = (workspaceId: string) => {
|
|
11
|
+
const w = workspaceLeads(workspaceId)!;
|
|
12
|
+
return {
|
|
13
|
+
workspaceId,
|
|
14
|
+
workspace: getWorkspace(workspaceId)?.name ?? "?",
|
|
15
|
+
chiefAutonomy: w.chiefAutonomy,
|
|
16
|
+
leads: leadViews(workspaceId),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// The workspaces with leads, and each one's leads. With `from` (a session),
|
|
21
|
+
// only that session's workspace, unless `all`.
|
|
22
|
+
export async function GET(request: NextRequest) {
|
|
23
|
+
const q = request.nextUrl.searchParams;
|
|
24
|
+
const from = sessionById(q.get("from"));
|
|
25
|
+
const own = from && !q.has("all") ? sessionWorkspace(from) : null;
|
|
26
|
+
const workspaces = workspacesWithLeads()
|
|
27
|
+
.filter((w) => !own || w.workspaceId === own)
|
|
28
|
+
.map((w) => view(w.workspaceId));
|
|
29
|
+
return NextResponse.json({
|
|
30
|
+
enabled: experimentOn("leads"),
|
|
31
|
+
workspace: own,
|
|
32
|
+
workspaces,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "Add leads" on a workspace: its Tech lead (its orchestrator) and the
|
|
37
|
+
// leads picked. Safe to repeat.
|
|
38
|
+
export async function POST(request: NextRequest) {
|
|
39
|
+
const gate = requireApprover(request);
|
|
40
|
+
if (!gate.ok) return gate.response;
|
|
41
|
+
if (!experimentOn("leads"))
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: "Leads is off (Settings → Experiments)" },
|
|
44
|
+
{ status: 409 }
|
|
45
|
+
);
|
|
46
|
+
const body = await request.json().catch(() => ({}));
|
|
47
|
+
const workspaceId =
|
|
48
|
+
typeof body.workspaceId === "string" ? body.workspaceId : "";
|
|
49
|
+
if (!getWorkspace(workspaceId))
|
|
50
|
+
return NextResponse.json({ error: "Unknown workspace" }, { status: 404 });
|
|
51
|
+
try {
|
|
52
|
+
addLeads(workspaceId, body.kinds ?? []);
|
|
53
|
+
return NextResponse.json(view(workspaceId));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
57
|
+
{ status: 400 }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStandingRole } from "@/lib/leads/roles";
|
|
1
2
|
import { NextResponse } from "next/server";
|
|
2
3
|
import { queries, db, type Session } from "@/lib/db";
|
|
3
4
|
import { FreshRefusal, freshStartChat } from "@/lib/chat/runner";
|
|
@@ -14,7 +15,7 @@ export async function POST(
|
|
|
14
15
|
const session = queries.getSession(db).get(id) as Session | undefined;
|
|
15
16
|
if (!session)
|
|
16
17
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
|
17
|
-
if (session.role
|
|
18
|
+
if (!isStandingRole(session.role) && !session.task_brief)
|
|
18
19
|
return NextResponse.json(
|
|
19
20
|
{ error: "Only an orchestrator or a task can start fresh" },
|
|
20
21
|
{ status: 400 }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isStandingRole } from "@/lib/leads/roles";
|
|
2
|
+
import { removeFork } from "@/lib/fork/scratch";
|
|
1
3
|
import { removeSessionAttachments } from "@/lib/attachments/store";
|
|
2
4
|
import { NextRequest, NextResponse } from "next/server";
|
|
3
5
|
import { deletionRefusal } from "@/lib/orchestrator/home";
|
|
@@ -208,7 +210,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
let clearedGroup: string | null = null;
|
|
211
|
-
if (typeof body.projectId === "string" && !existing.role) {
|
|
213
|
+
if (typeof body.projectId === "string" && !isStandingRole(existing.role)) {
|
|
212
214
|
if (!getProject(body.projectId))
|
|
213
215
|
return NextResponse.json(
|
|
214
216
|
{ error: "Project not found" },
|
|
@@ -307,6 +309,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
307
309
|
}, `cleanup-worktree-${id}`);
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
// A fork child's own folders go with it: deleting it is the ask.
|
|
313
|
+
runInBackground(async () => {
|
|
314
|
+
await agentEnded;
|
|
315
|
+
await removeFork(id);
|
|
316
|
+
}, `cleanup-fork-${id}`);
|
|
317
|
+
|
|
310
318
|
// Also cleanup worker worktrees in background
|
|
311
319
|
if (workers.length > 0) {
|
|
312
320
|
for (const worker of workers) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStandingRole } from "@/lib/leads/roles";
|
|
1
2
|
import { NextResponse } from "next/server";
|
|
2
3
|
import { getDb, queries, type Session } from "@/lib/db";
|
|
3
4
|
import { getManagedSessionPattern } from "@/lib/providers/registry";
|
|
@@ -36,7 +37,7 @@ export async function POST() {
|
|
|
36
37
|
// Delete every session from the database but the workspaces'
|
|
37
38
|
// orchestrators, which are never swept.
|
|
38
39
|
const dbSessions = (queries.getAllSessions(db).all() as Session[]).filter(
|
|
39
|
-
(s) => s.role
|
|
40
|
+
(s) => !isStandingRole(s.role)
|
|
40
41
|
);
|
|
41
42
|
for (const session of dbSessions) {
|
|
42
43
|
try {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { seedWorkspace } from "@/lib/orchestrator/testing";
|
|
4
|
+
import { TOKEN_HEADER } from "@/lib/orchestrator/tools";
|
|
5
|
+
import { addLeads, getLead, standingToken } from "@/lib/leads/home";
|
|
6
|
+
import { latestMemo } from "@/lib/leads/memos";
|
|
7
|
+
import { POST } from "./route";
|
|
8
|
+
|
|
9
|
+
const call = (w: string, token: string | null, tool: string, args = {}) =>
|
|
10
|
+
POST(
|
|
11
|
+
new NextRequest(`http://localhost/api/workspaces/${w}/orchestrator/tools`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
body: JSON.stringify({ tool, args }),
|
|
14
|
+
headers: token ? { [TOKEN_HEADER]: token } : {},
|
|
15
|
+
}),
|
|
16
|
+
{ params: Promise.resolve({ id: w }) }
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("the standing sessions' tools", () => {
|
|
20
|
+
it("knows the caller by its token and keeps Tech's tools Tech's", async () => {
|
|
21
|
+
const { workspace } = seedWorkspace();
|
|
22
|
+
const w = workspace.id;
|
|
23
|
+
const [tech] = addLeads(w, ["marketing"]);
|
|
24
|
+
const marketing = getLead(w, "marketing")!;
|
|
25
|
+
const mine = standingToken(marketing.id);
|
|
26
|
+
expect((await call(w, null, "sessions")).status).toBe(403);
|
|
27
|
+
expect((await call(w, "wrong", "sessions")).status).toBe(403);
|
|
28
|
+
expect((await call(w, mine, "sessions")).status).toBe(200);
|
|
29
|
+
expect((await call(w, mine, "sign_off", { task: "x" })).status).toBe(403);
|
|
30
|
+
expect((await call(w, mine, "nonsense")).status).toBe(400);
|
|
31
|
+
const memo = await call(w, mine, "update_memo", { text: "Mine" });
|
|
32
|
+
expect(memo.status).toBe(200);
|
|
33
|
+
expect(latestMemo(w, "marketing")?.text).toBe("Mine");
|
|
34
|
+
// Tech's token isn't refused sign_off as not its own.
|
|
35
|
+
const techCall = await call(w, standingToken(tech.id), "sign_off", {
|
|
36
|
+
task: "x",
|
|
37
|
+
});
|
|
38
|
+
expect(techCall.status).toBe(400);
|
|
39
|
+
// Another workspace's token is no one here.
|
|
40
|
+
const other = seedWorkspace().workspace.id;
|
|
41
|
+
expect((await call(other, mine, "sessions")).status).toBe(403);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { isToolName, runTool } from "@/lib/orchestrator/serve";
|
|
3
|
-
import { isOrchestratorToken } from "@/lib/orchestrator/home";
|
|
4
2
|
import { TOKEN_HEADER } from "@/lib/orchestrator/tools";
|
|
3
|
+
import { standingByToken } from "@/lib/leads/home";
|
|
4
|
+
import { NotYours, runStandingTool, UnknownTool } from "@/lib/leads/tools";
|
|
5
5
|
|
|
6
6
|
type RouteParams = { params: Promise<{ id: string }> };
|
|
7
7
|
|
|
8
8
|
const LEGACY_ASK = "ask_saad";
|
|
9
9
|
|
|
10
|
-
// The orchestrator chat's tools call here from
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// The orchestrator chat's tools, and its leads', call here from their
|
|
11
|
+
// workers: plain text back. Each standing session's worker holds its own
|
|
12
|
+
// secret, which says who is calling: other local sessions are trusted by
|
|
13
|
+
// the network, not to act as the orchestrator or a lead.
|
|
13
14
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
14
15
|
const { id } = await params;
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const caller = standingByToken(id, request.headers.get(TOKEN_HEADER));
|
|
17
|
+
if (!caller) return new NextResponse("Not the orchestrator", { status: 403 });
|
|
17
18
|
const body = await request.json().catch(() => ({}));
|
|
18
19
|
const { args } = body;
|
|
19
20
|
// A worker started before the rename still calls ask_saad.
|
|
20
21
|
const tool = body.tool === LEGACY_ASK ? "ask_owner" : body.tool;
|
|
21
|
-
if (!isToolName(tool))
|
|
22
|
-
return NextResponse.json({ error: "Unknown tool" }, { status: 400 });
|
|
23
22
|
try {
|
|
24
|
-
const text = await
|
|
23
|
+
const text = await runStandingTool(id, caller, tool, args ?? {});
|
|
25
24
|
return new NextResponse(text, {
|
|
26
25
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
27
26
|
});
|
|
28
27
|
} catch (error) {
|
|
28
|
+
if (error instanceof UnknownTool)
|
|
29
|
+
return NextResponse.json({ error: "Unknown tool" }, { status: 400 });
|
|
29
30
|
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
-
return new NextResponse(message, {
|
|
31
|
+
return new NextResponse(message, {
|
|
32
|
+
status: error instanceof NotYours ? 403 : 400,
|
|
33
|
+
});
|
|
31
34
|
}
|
|
32
35
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { isStandingRole } from "@/lib/leads/roles";
|
|
3
4
|
import { useCallback } from "react";
|
|
4
5
|
import { toast } from "sonner";
|
|
5
6
|
import { usePanes } from "@/contexts/PaneContext";
|
|
@@ -72,7 +73,7 @@ export function useCanAddAnalyst(session: Session | null | undefined) {
|
|
|
72
73
|
const paired = useSessionAnalyst(chat?.id).data;
|
|
73
74
|
return (
|
|
74
75
|
!!chat &&
|
|
75
|
-
!chat.role &&
|
|
76
|
+
!isStandingRole(chat.role) &&
|
|
76
77
|
!chat.archived_at &&
|
|
77
78
|
(chat.host_id || "local") === "local" &&
|
|
78
79
|
chat.agent_role !== "analyst" &&
|
|
@@ -649,6 +649,7 @@ export function ChatPanel({
|
|
|
649
649
|
{orchestratorOf && (
|
|
650
650
|
<NeedsYouStrip
|
|
651
651
|
workspaceId={orchestratorOf}
|
|
652
|
+
sessionId={sessionId}
|
|
652
653
|
chatWaiting={state === "waiting"}
|
|
653
654
|
onGo={(item) => {
|
|
654
655
|
if (item.kind === "ask") needsYouFocusActions.ask(item.ask.id);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { GitFork, Square } from "lucide-react";
|
|
4
|
+
import { CircleCheck, GitFork, Square } from "lucide-react";
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { useExperiment } from "@/data/experiments";
|
|
7
7
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
14
14
|
import { Button } from "@/components/ui/button";
|
|
15
15
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
16
|
+
import { useDoneAction } from "@/components/SessionList/useDoneAction";
|
|
16
17
|
import { SessionAnchor } from "./SessionAnchor";
|
|
17
18
|
|
|
18
19
|
type Tone = "amber" | "muted" | "green" | "primary" | "red";
|
|
@@ -73,12 +74,14 @@ export const runningNote = (c: ChildView, now = Date.now()): string | null => {
|
|
|
73
74
|
function Row({
|
|
74
75
|
c,
|
|
75
76
|
onStop,
|
|
77
|
+
onDone,
|
|
76
78
|
stopping,
|
|
77
79
|
again,
|
|
78
80
|
}: {
|
|
79
81
|
c: ChildView;
|
|
80
82
|
again: ReturnType<typeof useRunAgain>;
|
|
81
83
|
onStop: () => void;
|
|
84
|
+
onDone: () => void;
|
|
82
85
|
stopping: boolean;
|
|
83
86
|
}) {
|
|
84
87
|
const { text, tone } = stateLabel(c);
|
|
@@ -144,6 +147,16 @@ function Row({
|
|
|
144
147
|
<Square aria-hidden className="size-4" />
|
|
145
148
|
</Button>
|
|
146
149
|
)}
|
|
150
|
+
<Button
|
|
151
|
+
size="sm"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
aria-label={`Mark fork ${c.n} done`}
|
|
154
|
+
data-tip="Done"
|
|
155
|
+
className="size-11 shrink-0"
|
|
156
|
+
onClick={onDone}
|
|
157
|
+
>
|
|
158
|
+
<CircleCheck aria-hidden className="size-4" />
|
|
159
|
+
</Button>
|
|
147
160
|
</li>
|
|
148
161
|
);
|
|
149
162
|
}
|
|
@@ -154,10 +167,11 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
|
154
167
|
const { data } = useForkChildren(sessionId, on);
|
|
155
168
|
const cancel = useCancelForks(sessionId);
|
|
156
169
|
const again = useRunAgain(sessionId);
|
|
170
|
+
const markDone = useDoneAction();
|
|
157
171
|
const [collapsed, setCollapsed] = useState(false);
|
|
158
172
|
if (!on || !data) return null;
|
|
159
173
|
if (data.asChild) return <ForkOfBanner info={data.asChild} />;
|
|
160
|
-
const
|
|
174
|
+
const children = data.children.filter((c) => !c.done);
|
|
161
175
|
if (!children.length) return null;
|
|
162
176
|
const open = children.filter((c) => c.state !== "settled").length;
|
|
163
177
|
const stop = (ref?: string) =>
|
|
@@ -206,6 +220,14 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
|
206
220
|
stopping={cancel.isPending}
|
|
207
221
|
again={again}
|
|
208
222
|
onStop={() => stop(String(c.n))}
|
|
223
|
+
onDone={() =>
|
|
224
|
+
markDone({
|
|
225
|
+
id: c.id,
|
|
226
|
+
name: c.label,
|
|
227
|
+
task_status: null,
|
|
228
|
+
pr_status: null,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
209
231
|
/>
|
|
210
232
|
))}
|
|
211
233
|
</ul>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { TECH } from "@/lib/leads/types";
|
|
3
4
|
import { useState, useSyncExternalStore } from "react";
|
|
4
5
|
import { Check, CornerDownLeft, ExternalLink, X } from "lucide-react";
|
|
5
6
|
import { Button } from "@/components/ui/button";
|
|
@@ -84,6 +85,11 @@ export function AskCard({
|
|
|
84
85
|
{kind.label}
|
|
85
86
|
</span>
|
|
86
87
|
</div>
|
|
88
|
+
{ask.leadKind !== TECH && (
|
|
89
|
+
<p className="text-muted-foreground text-[11px] capitalize">
|
|
90
|
+
{ask.leadKind} lead
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
87
93
|
{ask.why && (
|
|
88
94
|
<p
|
|
89
95
|
className={cn(
|
|
@@ -40,11 +40,16 @@ export interface NeedsYouActions {
|
|
|
40
40
|
// it is resolved. Dismissing an item only hides it; an ask stays open.
|
|
41
41
|
export const NeedsYouStrip = memo(function NeedsYouStrip({
|
|
42
42
|
workspaceId,
|
|
43
|
+
sessionId,
|
|
43
44
|
chatWaiting,
|
|
44
45
|
onGo,
|
|
45
46
|
onReply,
|
|
46
|
-
}: NeedsYouActions & {
|
|
47
|
-
|
|
47
|
+
}: NeedsYouActions & {
|
|
48
|
+
workspaceId: string;
|
|
49
|
+
sessionId: string;
|
|
50
|
+
chatWaiting: boolean;
|
|
51
|
+
}) {
|
|
52
|
+
const items = useNeedsYou(workspaceId, sessionId, chatWaiting);
|
|
48
53
|
const dismiss = useDismissNeedsYou(workspaceId);
|
|
49
54
|
const cards = useApplyCard();
|
|
50
55
|
// The learning card opened in place.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { isStandingRole, isTechRole } from "@/lib/leads/roles";
|
|
3
4
|
import { hasPrompt } from "@/lib/sessions/has-prompt";
|
|
4
5
|
import { useTaskLogQuery } from "@/data/session-logs";
|
|
5
6
|
import { SetupCard } from "@/components/Chat/SetupCard";
|
|
@@ -183,7 +184,7 @@ export const Pane = memo(function Pane({
|
|
|
183
184
|
);
|
|
184
185
|
const showGit = showGitToggle(isLocalSession, isGitRepo);
|
|
185
186
|
const showGitDrawer =
|
|
186
|
-
gitDrawerOpen && showGit && session?.role
|
|
187
|
+
gitDrawerOpen && showGit && !isStandingRole(session?.role);
|
|
187
188
|
// The folder turned out not to be a repo: don't leave its git view open.
|
|
188
189
|
useEffect(() => {
|
|
189
190
|
if (isGitRepo !== false) return;
|
|
@@ -339,14 +340,11 @@ export const Pane = memo(function Pane({
|
|
|
339
340
|
<ChatPanel
|
|
340
341
|
sessionId={chat.id}
|
|
341
342
|
sessionName={chat.name}
|
|
342
|
-
accessLocked={chat.role
|
|
343
|
+
accessLocked={isStandingRole(chat.role)}
|
|
343
344
|
freshStart={
|
|
344
|
-
(chat.role
|
|
345
|
-
!chat.archived_at
|
|
346
|
-
}
|
|
347
|
-
orchestratorOf={
|
|
348
|
-
chat.role === "orchestrator" ? chat.workspace_id : null
|
|
345
|
+
(isStandingRole(chat.role) || !!chat.has_brief) && !chat.archived_at
|
|
349
346
|
}
|
|
347
|
+
orchestratorOf={isTechRole(chat.role) ? chat.workspace_id : null}
|
|
350
348
|
active={isActive && isFocused}
|
|
351
349
|
/>
|
|
352
350
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { isTechRole } from "@/lib/leads/roles";
|
|
3
4
|
import { useEffect, useMemo, useState } from "react";
|
|
4
5
|
import { Button } from "@/components/ui/button";
|
|
5
6
|
import { Input } from "@/components/ui/input";
|
|
@@ -226,7 +227,7 @@ export function ScheduleForm({
|
|
|
226
227
|
.filter(
|
|
227
228
|
(s) =>
|
|
228
229
|
!s.archived_at &&
|
|
229
|
-
s.role
|
|
230
|
+
!isTechRole(s.role) &&
|
|
230
231
|
(!s.task_status || s.task_status === "running") &&
|
|
231
232
|
(s.workspace_id ??
|
|
232
233
|
(s.project_id ? projectWs.get(s.project_id) : null)) === workspace
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { isTechRole } from "@/lib/leads/roles";
|
|
3
4
|
import { Repeat, Layers, Pause, Hourglass } from "lucide-react";
|
|
4
5
|
import { HostBadge } from "@/components/Hosts";
|
|
5
6
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
@@ -35,7 +36,7 @@ export function RowSubtitle({
|
|
|
35
36
|
const ctx = useRowContext();
|
|
36
37
|
// What its processes use, on a busy session: "3.1 cores · 4.2 GB".
|
|
37
38
|
const usage = useSessionUsage(session.id);
|
|
38
|
-
if (session.role
|
|
39
|
+
if (isTechRole(session.role)) {
|
|
39
40
|
const ws = session.workspace_id ?? "";
|
|
40
41
|
const overview = ctx.orchestrators.find((o) => o.workspaceId === ws);
|
|
41
42
|
const parts = headerParts({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { isTechRole } from "@/lib/leads/roles";
|
|
3
4
|
import { AGENT_ROLE_DEFS, isAgentRole } from "@/lib/models/agent-roles";
|
|
4
5
|
import { isFork } from "@/lib/sessions/fork";
|
|
5
6
|
import { memo, useState } from "react";
|
|
@@ -173,7 +174,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
173
174
|
}
|
|
174
175
|
/>
|
|
175
176
|
</RowShell>
|
|
176
|
-
{session.role
|
|
177
|
+
{isTechRole(session.role) && row.need && (
|
|
177
178
|
<OrchestratorAsks workspaceId={session.workspace_id} />
|
|
178
179
|
)}
|
|
179
180
|
{(row.workers.length > 0 || !!row.quick?.length) && (
|
|
@@ -9,12 +9,18 @@ import {
|
|
|
9
9
|
} from "@/data/done";
|
|
10
10
|
import { useSettleSession } from "@/data/sessions/shelf";
|
|
11
11
|
|
|
12
|
+
export type DoneTarget = Pick<
|
|
13
|
+
Session,
|
|
14
|
+
"id" | "name" | "task_status" | "pr_status"
|
|
15
|
+
> &
|
|
16
|
+
Partial<Pick<Session, "settled_at">>;
|
|
17
|
+
|
|
12
18
|
// Done from a session's menu: an open PR merges through the gates, after an
|
|
13
19
|
// in-app confirmation; a failing gate comes back as an error toast naming it.
|
|
14
20
|
export function useDoneAction() {
|
|
15
21
|
const done = useDoneSession();
|
|
16
22
|
const settle = useSettleSession();
|
|
17
|
-
const run = (session:
|
|
23
|
+
const run = (session: DoneTarget, merges: boolean) => {
|
|
18
24
|
const id = toast.loading(
|
|
19
25
|
merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
|
|
20
26
|
);
|
|
@@ -38,7 +44,7 @@ export function useDoneAction() {
|
|
|
38
44
|
onError: (e) => toast.error(e.message, { id, duration: 12000 }),
|
|
39
45
|
});
|
|
40
46
|
};
|
|
41
|
-
return (session:
|
|
47
|
+
return (session: DoneTarget) => {
|
|
42
48
|
const merges =
|
|
43
49
|
session.task_status === "running" && session.pr_status === "open";
|
|
44
50
|
if (!merges) return run(session, false);
|