@saadnvd1/agent-os 0.3.700 → 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/spawn/route.ts +12 -1
- 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/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/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- 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 +15 -14
- package/app/page.tsx +5 -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 +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- 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/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/keys.ts +1 -0
- package/data/peers/queries.ts +31 -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/spawn.ts +4 -0
- package/lib/bus/index.ts +8 -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/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/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 +16 -8
- package/lib/doctor/checks.ts +64 -1
- 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 +6 -1
- package/lib/orchestrator/facts.ts +5 -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 +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/security/demo.ts +7 -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/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 +1 -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 +3 -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 +3 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
package/lib/security/demo.ts
CHANGED
|
@@ -49,23 +49,30 @@ export function assertDemoSandbox(env: Env = process.env, home = os.homedir()) {
|
|
|
49
49
|
// "projects/[id]"): Next serves the folder, so an id must never match them.
|
|
50
50
|
// The test walks app/api and fails when one is missing here.
|
|
51
51
|
export const STATIC_SIBLINGS = [
|
|
52
|
+
"account",
|
|
52
53
|
"archived",
|
|
53
54
|
"arrived",
|
|
54
55
|
"availability",
|
|
55
56
|
"browse",
|
|
57
|
+
"check",
|
|
56
58
|
"clone",
|
|
57
59
|
"connect",
|
|
58
60
|
"detect",
|
|
59
61
|
"done-idle",
|
|
60
62
|
"draft",
|
|
61
63
|
"ended",
|
|
64
|
+
"exchange",
|
|
62
65
|
"held-replies",
|
|
63
66
|
"import",
|
|
64
67
|
"init",
|
|
68
|
+
"group",
|
|
65
69
|
"init-script",
|
|
66
70
|
"me",
|
|
67
71
|
"items",
|
|
72
|
+
"link",
|
|
68
73
|
"network",
|
|
74
|
+
"order",
|
|
75
|
+
"pending",
|
|
69
76
|
"pdf",
|
|
70
77
|
"preview",
|
|
71
78
|
"queue",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { liveGroup } from "./store";
|
|
3
|
+
import { sessionWorkspaceId } from "./workspace";
|
|
4
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
5
|
+
|
|
6
|
+
// The one place that checks the invariant: a session joins only a group of
|
|
7
|
+
// its own workspace. Membership is a view choice, so it leaves updated_at
|
|
8
|
+
// alone, like pinning.
|
|
9
|
+
export function checkMember(sessionId: string, group: SessionGroup): void {
|
|
10
|
+
const workspaceId = sessionWorkspaceId(sessionId);
|
|
11
|
+
if (workspaceId === undefined)
|
|
12
|
+
throw new GroupError("not-found", "Session not found");
|
|
13
|
+
if ((workspaceId ?? null) !== (group.workspace_id ?? null))
|
|
14
|
+
throw new GroupError(
|
|
15
|
+
"workspace",
|
|
16
|
+
`"${group.name}" is a group of another workspace`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Files sessions into a group, or out of any (groupId null). All or none.
|
|
21
|
+
export function assignSessions(
|
|
22
|
+
sessionIds: readonly string[],
|
|
23
|
+
groupId: string | null
|
|
24
|
+
): { moved: number } {
|
|
25
|
+
const ids = [...new Set(sessionIds)];
|
|
26
|
+
const group = groupId === null ? null : liveGroup(groupId);
|
|
27
|
+
const db = getDb();
|
|
28
|
+
return db.transaction(() => {
|
|
29
|
+
let moved = 0;
|
|
30
|
+
for (const id of ids) {
|
|
31
|
+
if (group) checkMember(id, group);
|
|
32
|
+
else if (sessionWorkspaceId(id) === undefined)
|
|
33
|
+
throw new GroupError("not-found", "Session not found");
|
|
34
|
+
moved += db
|
|
35
|
+
.prepare(
|
|
36
|
+
`UPDATE sessions SET session_group_id = ?
|
|
37
|
+
WHERE id = ? AND session_group_id IS NOT ?`
|
|
38
|
+
)
|
|
39
|
+
.run(group?.id ?? null, id, group?.id ?? null).changes;
|
|
40
|
+
}
|
|
41
|
+
return { moved };
|
|
42
|
+
})();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// For a session that is starting: its group may have been deleted, or its
|
|
46
|
+
// workspace changed, since it was asked for. Starting must not fail over a
|
|
47
|
+
// view choice, so it just starts ungrouped.
|
|
48
|
+
export function fileInGroup(sessionId: string, groupId: string): void {
|
|
49
|
+
try {
|
|
50
|
+
assignSessions([sessionId], groupId);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (!(err instanceof GroupError)) throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A new session starts in the group of the one it came from (a worker from its
|
|
57
|
+
// conductor, a fork or a fresh start from its source), when that group is
|
|
58
|
+
// still live and in the new session's workspace.
|
|
59
|
+
export function inheritGroup(fromSessionId: string, toSessionId: string): void {
|
|
60
|
+
const row = getDb()
|
|
61
|
+
.prepare(`SELECT session_group_id AS id FROM sessions WHERE id = ?`)
|
|
62
|
+
.get(fromSessionId) as { id: string | null } | undefined;
|
|
63
|
+
if (row?.id) fileInGroup(toSessionId, row.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Leaving a workspace leaves its groups (a project move into another one).
|
|
67
|
+
export function clearForeignGroup(sessionId: string): string | null {
|
|
68
|
+
const row = getDb()
|
|
69
|
+
.prepare(
|
|
70
|
+
`SELECT g.id AS id, g.name AS name, g.workspace_id AS ws
|
|
71
|
+
FROM sessions s JOIN session_groups g ON g.id = s.session_group_id
|
|
72
|
+
WHERE s.id = ?`
|
|
73
|
+
)
|
|
74
|
+
.get(sessionId) as
|
|
75
|
+
{ id: string; name: string; ws: string | null } | undefined;
|
|
76
|
+
if (!row || (sessionWorkspaceId(sessionId) ?? null) === row.ws) return null;
|
|
77
|
+
getDb()
|
|
78
|
+
.prepare(`UPDATE sessions SET session_group_id = NULL WHERE id = ?`)
|
|
79
|
+
.run(sessionId);
|
|
80
|
+
return row.name;
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { resolveReadable, readerScope } from "@/lib/bus/read-scope";
|
|
3
|
+
import { assignSessions } from "./assignSessions";
|
|
4
|
+
import { createGroup } from "./createGroup";
|
|
5
|
+
import { listGroups } from "./store";
|
|
6
|
+
import { updateGroup } from "./updateGroup";
|
|
7
|
+
import { resolveGroup } from "./resolveGroup";
|
|
8
|
+
import { GroupError } from "./types";
|
|
9
|
+
|
|
10
|
+
export type GroupCommand =
|
|
11
|
+
| { action: "list" }
|
|
12
|
+
| { action: "new"; name: string }
|
|
13
|
+
| { action: "rename"; group: string; name: string }
|
|
14
|
+
| { action: "add"; group: string; sessions: string[] }
|
|
15
|
+
| { action: "rm"; sessions: string[] };
|
|
16
|
+
|
|
17
|
+
// `aos group ...`: the caller's own workspace's groups, resolved the way
|
|
18
|
+
// `aos read` resolves sessions, so it reaches nothing outside that scope.
|
|
19
|
+
export function groupCommand(callerId: string, cmd: GroupCommand): string {
|
|
20
|
+
const scope = readerScope(callerId);
|
|
21
|
+
const ws = scope.workspaceId;
|
|
22
|
+
if (cmd.action === "list") return describe(ws, scope.label);
|
|
23
|
+
if (cmd.action === "new") {
|
|
24
|
+
const g = createGroup(ws, cmd.name);
|
|
25
|
+
return `created group "${g.name}" in your ${scope.label}`;
|
|
26
|
+
}
|
|
27
|
+
if (cmd.action === "rename") {
|
|
28
|
+
const from = resolveGroup(ws, cmd.group);
|
|
29
|
+
return `renamed "${from.name}" to "${updateGroup(from.id, { name: cmd.name }).name}"`;
|
|
30
|
+
}
|
|
31
|
+
if (!cmd.sessions.length)
|
|
32
|
+
throw new GroupError("invalid", "Name at least one session");
|
|
33
|
+
const target = cmd.action === "add" ? resolveGroup(ws, cmd.group) : null;
|
|
34
|
+
const ids = cmd.sessions.map((ref) => {
|
|
35
|
+
if (ref === "me") return callerId;
|
|
36
|
+
const found = resolveReadable(callerId, ref);
|
|
37
|
+
if (!found.ok) throw new GroupError("not-found", found.error);
|
|
38
|
+
return found.session.id;
|
|
39
|
+
});
|
|
40
|
+
assignSessions(ids, target?.id ?? null);
|
|
41
|
+
return target
|
|
42
|
+
? `put ${ids.length} session${ids.length === 1 ? "" : "s"} in "${target.name}"`
|
|
43
|
+
: `took ${ids.length} session${ids.length === 1 ? "" : "s"} out of their group`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describe(workspaceId: string | null, label: string): string {
|
|
47
|
+
const groups = listGroups(workspaceId);
|
|
48
|
+
if (!groups.length) return `no groups in your ${label}`;
|
|
49
|
+
const counts = new Map(
|
|
50
|
+
(
|
|
51
|
+
getDb()
|
|
52
|
+
.prepare(
|
|
53
|
+
`SELECT session_group_id AS id, COUNT(*) AS n FROM sessions
|
|
54
|
+
WHERE session_group_id IS NOT NULL AND archived_at IS NULL
|
|
55
|
+
GROUP BY session_group_id`
|
|
56
|
+
)
|
|
57
|
+
.all() as { id: string; n: number }[]
|
|
58
|
+
).map((r) => [r.id, r.n])
|
|
59
|
+
);
|
|
60
|
+
return groups.map((g) => `${g.name} · ${counts.get(g.id) ?? 0}`).join("\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { cleanName, findGroup, groupByName } from "./store";
|
|
4
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
5
|
+
|
|
6
|
+
// A new group goes to the bottom of its workspace's order. The name is
|
|
7
|
+
// unique within the workspace, ignoring case.
|
|
8
|
+
export function createGroup(
|
|
9
|
+
workspaceId: string | null,
|
|
10
|
+
name: unknown
|
|
11
|
+
): SessionGroup {
|
|
12
|
+
const clean = cleanName(name);
|
|
13
|
+
const db = getDb();
|
|
14
|
+
if (
|
|
15
|
+
workspaceId &&
|
|
16
|
+
!db.prepare(`SELECT 1 FROM workspaces WHERE id = ?`).get(workspaceId)
|
|
17
|
+
)
|
|
18
|
+
throw new GroupError("not-found", "Workspace not found");
|
|
19
|
+
if (groupByName(workspaceId, clean))
|
|
20
|
+
throw new GroupError("duplicate", `There is already a group "${clean}"`);
|
|
21
|
+
const id = randomUUID();
|
|
22
|
+
db.prepare(
|
|
23
|
+
`INSERT INTO session_groups (id, workspace_id, name, sort_order)
|
|
24
|
+
VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order) + 1, 0)
|
|
25
|
+
FROM session_groups WHERE COALESCE(workspace_id, '') = COALESCE(?, '')))`
|
|
26
|
+
).run(id, workspaceId, clean, workspaceId);
|
|
27
|
+
return findGroup(id)!;
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export {
|
|
3
|
+
groupNames,
|
|
4
|
+
listGroups,
|
|
5
|
+
findGroup,
|
|
6
|
+
groupByName,
|
|
7
|
+
liveGroup,
|
|
8
|
+
} from "./store";
|
|
9
|
+
export { createGroup } from "./createGroup";
|
|
10
|
+
export { updateGroup } from "./updateGroup";
|
|
11
|
+
export { reorderGroups } from "./reorderGroups";
|
|
12
|
+
export { deleteGroup, restoreGroup } from "./removeGroup";
|
|
13
|
+
export {
|
|
14
|
+
assignSessions,
|
|
15
|
+
checkMember,
|
|
16
|
+
clearForeignGroup,
|
|
17
|
+
fileInGroup,
|
|
18
|
+
inheritGroup,
|
|
19
|
+
} from "./assignSessions";
|
|
20
|
+
export { resolveGroup } from "./resolveGroup";
|
|
21
|
+
export { sessionWorkspaceId } from "./workspace";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { findGroup, groupByName, liveGroup } from "./store";
|
|
3
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
4
|
+
|
|
5
|
+
// Soft delete: members keep their session_group_id and the sidebar ignores a
|
|
6
|
+
// deleted group, so they fall back to the status shelves and Undo (restore)
|
|
7
|
+
// puts them back. Nothing is hard-deleted.
|
|
8
|
+
export function deleteGroup(id: string): SessionGroup {
|
|
9
|
+
const group = liveGroup(id);
|
|
10
|
+
getDb()
|
|
11
|
+
.prepare(
|
|
12
|
+
`UPDATE session_groups SET deleted_at = datetime('now'),
|
|
13
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
14
|
+
)
|
|
15
|
+
.run(id);
|
|
16
|
+
return group;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function restoreGroup(id: string): SessionGroup {
|
|
20
|
+
const group = findGroup(id);
|
|
21
|
+
if (!group) throw new GroupError("not-found", "Group not found");
|
|
22
|
+
if (!group.deleted) return group;
|
|
23
|
+
// A new group took the name meanwhile.
|
|
24
|
+
if (groupByName(group.workspace_id, group.name))
|
|
25
|
+
throw new GroupError(
|
|
26
|
+
"duplicate",
|
|
27
|
+
`There is already a group "${group.name}"`
|
|
28
|
+
);
|
|
29
|
+
getDb()
|
|
30
|
+
.prepare(
|
|
31
|
+
`UPDATE session_groups SET deleted_at = NULL,
|
|
32
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
33
|
+
)
|
|
34
|
+
.run(id);
|
|
35
|
+
return liveGroup(id);
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { listGroups } from "./store";
|
|
3
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
4
|
+
|
|
5
|
+
// Sets a workspace's group order. `ids` may name only some of them (a move
|
|
6
|
+
// up or down sends the whole list; a stale list from another device leaves
|
|
7
|
+
// newer groups at the end), but never one from another workspace.
|
|
8
|
+
export function reorderGroups(
|
|
9
|
+
workspaceId: string | null,
|
|
10
|
+
ids: unknown
|
|
11
|
+
): SessionGroup[] {
|
|
12
|
+
if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string"))
|
|
13
|
+
throw new GroupError("invalid", "ids is a list of group ids");
|
|
14
|
+
const current = listGroups(workspaceId);
|
|
15
|
+
const known = new Set(current.map((g) => g.id));
|
|
16
|
+
const wanted = [...new Set(ids as string[])];
|
|
17
|
+
const stranger = wanted.find((i) => !known.has(i));
|
|
18
|
+
if (stranger)
|
|
19
|
+
throw new GroupError("workspace", "A group is not in this workspace");
|
|
20
|
+
const order = [
|
|
21
|
+
...wanted,
|
|
22
|
+
...current.map((g) => g.id).filter((i) => !wanted.includes(i)),
|
|
23
|
+
];
|
|
24
|
+
const db = getDb();
|
|
25
|
+
const set = db.prepare(
|
|
26
|
+
`UPDATE session_groups SET sort_order = ?, updated_at = datetime('now')
|
|
27
|
+
WHERE id = ? AND sort_order != ?`
|
|
28
|
+
);
|
|
29
|
+
db.transaction(() => order.forEach((id, i) => set.run(i, id, i)))();
|
|
30
|
+
return listGroups(workspaceId);
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { groupByName, listGroups } from "./store";
|
|
2
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
3
|
+
|
|
4
|
+
// A group named by a caller (`aos --group`, an orchestrator tool): it must
|
|
5
|
+
// exist in that workspace, and the error lists the ones that do.
|
|
6
|
+
export function resolveGroup(
|
|
7
|
+
workspaceId: string | null,
|
|
8
|
+
name: string
|
|
9
|
+
): SessionGroup {
|
|
10
|
+
const found = groupByName(workspaceId, name);
|
|
11
|
+
if (found) return found;
|
|
12
|
+
const names = listGroups(workspaceId).map((g) => `"${g.name}"`);
|
|
13
|
+
throw new GroupError(
|
|
14
|
+
"not-found",
|
|
15
|
+
names.length
|
|
16
|
+
? `No group "${name.trim()}" in this workspace. Groups: ${names.join(", ")}`
|
|
17
|
+
: `No group "${name.trim()}": this workspace has no groups`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import { GROUP_STATUS, GroupError } from "./types";
|
|
4
|
+
|
|
5
|
+
// Runs a group action for a route: a body that fails its schema is a 400, a
|
|
6
|
+
// GroupError becomes its status code.
|
|
7
|
+
export async function groupRoute<S extends z.ZodType>(
|
|
8
|
+
request: Request,
|
|
9
|
+
schema: S,
|
|
10
|
+
act: (body: z.infer<S>) => unknown
|
|
11
|
+
): Promise<NextResponse> {
|
|
12
|
+
const parsed = schema.safeParse(await request.json().catch(() => null));
|
|
13
|
+
if (!parsed.success)
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: parsed.error.issues[0]?.message ?? "Bad request" },
|
|
16
|
+
{ status: 400 }
|
|
17
|
+
);
|
|
18
|
+
return run(() => act(parsed.data));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function run(act: () => unknown): NextResponse {
|
|
22
|
+
try {
|
|
23
|
+
return NextResponse.json(act());
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err instanceof GroupError)
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: err.message, code: err.code },
|
|
28
|
+
{ status: GROUP_STATUS[err.code] }
|
|
29
|
+
);
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { GroupError, NAME_MAX, type SessionGroup } from "./types";
|
|
3
|
+
|
|
4
|
+
interface GroupRow extends Omit<SessionGroup, "collapsed"> {
|
|
5
|
+
collapsed: number;
|
|
6
|
+
deleted_at: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const COLUMNS = `id, workspace_id, name, sort_order, collapsed, created_at,
|
|
10
|
+
updated_at, deleted_at`;
|
|
11
|
+
|
|
12
|
+
const toGroup = ({ deleted_at: _, ...row }: GroupRow): SessionGroup => ({
|
|
13
|
+
...row,
|
|
14
|
+
collapsed: !!row.collapsed,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function cleanName(name: unknown): string {
|
|
18
|
+
const trimmed =
|
|
19
|
+
typeof name === "string" ? name.trim().replace(/\s+/g, " ") : "";
|
|
20
|
+
if (!trimmed) throw new GroupError("invalid", "A group needs a name");
|
|
21
|
+
if (trimmed.length > NAME_MAX)
|
|
22
|
+
throw new GroupError(
|
|
23
|
+
"invalid",
|
|
24
|
+
`A group name is at most ${NAME_MAX} characters`
|
|
25
|
+
);
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Live groups (not deleted), in the order to show them within a workspace.
|
|
30
|
+
export function listGroups(workspaceId?: string | null): SessionGroup[] {
|
|
31
|
+
const rows = (
|
|
32
|
+
workspaceId === undefined
|
|
33
|
+
? getDb()
|
|
34
|
+
.prepare(
|
|
35
|
+
`SELECT ${COLUMNS} FROM session_groups WHERE deleted_at IS NULL
|
|
36
|
+
ORDER BY sort_order, created_at`
|
|
37
|
+
)
|
|
38
|
+
.all()
|
|
39
|
+
: getDb()
|
|
40
|
+
.prepare(
|
|
41
|
+
`SELECT ${COLUMNS} FROM session_groups WHERE deleted_at IS NULL
|
|
42
|
+
AND COALESCE(workspace_id, '') = COALESCE(?, '')
|
|
43
|
+
ORDER BY sort_order, created_at`
|
|
44
|
+
)
|
|
45
|
+
.all(workspaceId)
|
|
46
|
+
) as GroupRow[];
|
|
47
|
+
return rows.map(toGroup);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Live groups' names by id, for labelling.
|
|
51
|
+
export function groupNames(): Map<string, string> {
|
|
52
|
+
return new Map(listGroups().map((g) => [g.id, g.name]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Any group by id, deleted or not, with whether it is deleted.
|
|
56
|
+
export function findGroup(
|
|
57
|
+
id: string
|
|
58
|
+
): (SessionGroup & { deleted: boolean }) | null {
|
|
59
|
+
const row = getDb()
|
|
60
|
+
.prepare(`SELECT ${COLUMNS} FROM session_groups WHERE id = ?`)
|
|
61
|
+
.get(id) as GroupRow | undefined;
|
|
62
|
+
return row ? { ...toGroup(row), deleted: !!row.deleted_at } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A live group by id, or the error a caller should surface.
|
|
66
|
+
export function liveGroup(id: unknown): SessionGroup {
|
|
67
|
+
const group = typeof id === "string" ? findGroup(id) : null;
|
|
68
|
+
if (!group) throw new GroupError("not-found", "Group not found");
|
|
69
|
+
if (group.deleted) throw new GroupError("deleted", "That group was deleted");
|
|
70
|
+
return group;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function groupByName(
|
|
74
|
+
workspaceId: string | null,
|
|
75
|
+
name: string
|
|
76
|
+
): SessionGroup | null {
|
|
77
|
+
const row = getDb()
|
|
78
|
+
.prepare(
|
|
79
|
+
`SELECT ${COLUMNS} FROM session_groups
|
|
80
|
+
WHERE deleted_at IS NULL AND lower(name) = lower(?)
|
|
81
|
+
AND COALESCE(workspace_id, '') = COALESCE(?, '')`
|
|
82
|
+
)
|
|
83
|
+
.get(name.trim(), workspaceId) as GroupRow | undefined;
|
|
84
|
+
return row ? toGroup(row) : null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type { SessionGroup } from "@/lib/sidebar/shelves";
|
|
2
|
+
|
|
3
|
+
export type GroupErrorCode =
|
|
4
|
+
"not-found" | "duplicate" | "workspace" | "invalid" | "deleted";
|
|
5
|
+
|
|
6
|
+
// Thrown for anything the caller got wrong; routes map the code to a status.
|
|
7
|
+
export class GroupError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
readonly code: GroupErrorCode,
|
|
10
|
+
message: string
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "GroupError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const GROUP_STATUS: Record<GroupErrorCode, number> = {
|
|
18
|
+
"not-found": 404,
|
|
19
|
+
duplicate: 409,
|
|
20
|
+
workspace: 409,
|
|
21
|
+
invalid: 400,
|
|
22
|
+
deleted: 409,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const NAME_MAX = 60;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import { cleanName, groupByName, liveGroup } from "./store";
|
|
3
|
+
import { GroupError, type SessionGroup } from "./types";
|
|
4
|
+
|
|
5
|
+
// Rename and fold. Folding is stored here so every device agrees.
|
|
6
|
+
export function updateGroup(
|
|
7
|
+
id: string,
|
|
8
|
+
patch: { name?: unknown; collapsed?: unknown }
|
|
9
|
+
): SessionGroup {
|
|
10
|
+
const group = liveGroup(id);
|
|
11
|
+
const sets: string[] = [];
|
|
12
|
+
const args: (string | number)[] = [];
|
|
13
|
+
if (patch.name !== undefined) {
|
|
14
|
+
const clean = cleanName(patch.name);
|
|
15
|
+
const clash = groupByName(group.workspace_id, clean);
|
|
16
|
+
if (clash && clash.id !== id)
|
|
17
|
+
throw new GroupError("duplicate", `There is already a group "${clean}"`);
|
|
18
|
+
sets.push("name = ?");
|
|
19
|
+
args.push(clean);
|
|
20
|
+
}
|
|
21
|
+
if (patch.collapsed !== undefined) {
|
|
22
|
+
if (typeof patch.collapsed !== "boolean")
|
|
23
|
+
throw new GroupError("invalid", "collapsed must be a boolean");
|
|
24
|
+
sets.push("collapsed = ?");
|
|
25
|
+
args.push(patch.collapsed ? 1 : 0);
|
|
26
|
+
}
|
|
27
|
+
if (sets.length)
|
|
28
|
+
getDb()
|
|
29
|
+
.prepare(
|
|
30
|
+
`UPDATE session_groups SET ${sets.join(", ")},
|
|
31
|
+
updated_at = datetime('now') WHERE id = ?`
|
|
32
|
+
)
|
|
33
|
+
.run(...args, id);
|
|
34
|
+
return liveGroup(id);
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
|
|
3
|
+
// The workspace a session belongs to: its project's, else the one kept on the
|
|
4
|
+
// session (an orchestrator, or a scratch session in no project). Same rule as
|
|
5
|
+
// inWorkspace in lib/sidebar/shelves, read from the database.
|
|
6
|
+
export function sessionWorkspaceId(
|
|
7
|
+
sessionId: string
|
|
8
|
+
): string | null | undefined {
|
|
9
|
+
const row = getDb()
|
|
10
|
+
.prepare(
|
|
11
|
+
`SELECT s.role AS role, s.workspace_id AS own, p.workspace_id AS byProject
|
|
12
|
+
FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
|
|
13
|
+
WHERE s.id = ?`
|
|
14
|
+
)
|
|
15
|
+
.get(sessionId) as
|
|
16
|
+
| { role: string | null; own: string | null; byProject: string | null }
|
|
17
|
+
| undefined;
|
|
18
|
+
if (!row) return undefined;
|
|
19
|
+
if (row.role === "orchestrator") return row.own;
|
|
20
|
+
return row.byProject ?? row.own;
|
|
21
|
+
}
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* then (for a worktree) the setup in the background, then the first message.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { fileInGroup } from "../session-groups/assignSessions";
|
|
6
7
|
import { agentosHome } from "../agentos-home";
|
|
7
8
|
import type { StartVia } from "../tasks/starter";
|
|
8
9
|
import fs from "fs";
|
|
@@ -82,8 +83,9 @@ export interface LaunchInput {
|
|
|
82
83
|
// The workspace selected when it was started. A session in a project
|
|
83
84
|
// follows the project; one in none keeps this (see inWorkspace).
|
|
84
85
|
workspaceId?: string | null;
|
|
85
|
-
groupPath?: string;
|
|
86
86
|
systemPrompt?: string | null;
|
|
87
|
+
// The user's group it starts in (lib/session-groups).
|
|
88
|
+
groupId?: string;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
export interface Launched {
|
|
@@ -317,7 +319,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
317
319
|
input.parentSessionId ?? null,
|
|
318
320
|
model,
|
|
319
321
|
input.systemPrompt ?? null,
|
|
320
|
-
|
|
322
|
+
"sessions",
|
|
321
323
|
agentType,
|
|
322
324
|
input.autoApprove || input.access === "full" ? 1 : 0,
|
|
323
325
|
project?.id ?? "uncategorized",
|
|
@@ -335,6 +337,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
335
337
|
id
|
|
336
338
|
);
|
|
337
339
|
setHome(id, home);
|
|
340
|
+
if (input.groupId) fileInGroup(id, input.groupId);
|
|
338
341
|
if (interactive) setSessionModel(id, interactive);
|
|
339
342
|
if (input.access)
|
|
340
343
|
db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
|
package/lib/sidebar/rollup.ts
CHANGED
package/lib/sidebar/row-menu.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type RowMenuItem =
|
|
|
21
21
|
| "new-conversation"
|
|
22
22
|
| "move-machine"
|
|
23
23
|
| "move-project"
|
|
24
|
+
| "move-group"
|
|
24
25
|
| "check-ins"
|
|
25
26
|
| "save-recipe"
|
|
26
27
|
| "run-again"
|
|
@@ -133,6 +134,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
|
|
|
133
134
|
...group("move", [
|
|
134
135
|
input.machineTargets > 0 && "move-machine",
|
|
135
136
|
agentRow && input.otherProjects > 0 && live && "move-project",
|
|
137
|
+
agentRow && "move-group",
|
|
136
138
|
]),
|
|
137
139
|
...group("automate", [
|
|
138
140
|
input.hasWorkspace &&
|