@saadnvd1/agent-os 0.3.700 → 0.3.723
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 +2 -0
- package/app/api/agent-roles/route.test.ts +67 -0
- package/app/api/agent-roles/route.ts +61 -0
- package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
- package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
- package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
- package/app/api/analyst/[id]/review/route.ts +22 -0
- package/app/api/analyst/[id]/route.ts +44 -0
- package/app/api/analyst/route.ts +10 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +34 -2
- 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/models/availability/route.ts +9 -0
- package/app/api/models/choices/route.ts +9 -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/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]/analyst/route.ts +11 -0
- package/app/api/sessions/[id]/crew/route.ts +38 -0
- package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
- package/app/api/sessions/[id]/export/route.test.ts +125 -0
- package/app/api/sessions/[id]/export/route.ts +34 -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]/model/route.ts +49 -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 -14
- package/app/page.tsx +10 -0
- package/components/Chat/Messages.tsx +104 -10
- 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/DraftChips.tsx +6 -1
- package/components/Draft/DraftPanel.tsx +90 -3
- package/components/Draft/ModelUnavailableDialog.tsx +78 -0
- package/components/Draft/RoleChips.tsx +110 -0
- package/components/Export/ExportActions.tsx +136 -0
- package/components/Export/ExportSheet.tsx +217 -0
- package/components/Export/RangePicker.tsx +144 -0
- package/components/Export/Seg.tsx +38 -0
- package/components/Export/TurnSelectBar.tsx +45 -0
- 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/Models/AgentRoleCard.tsx +114 -0
- package/components/Models/AgentRolesSection.tsx +132 -0
- package/components/Models/ModelBadges.tsx +48 -0
- package/components/Models/ModelsSection.tsx +4 -2
- package/components/Models/RoleModelSelect.tsx +87 -0
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +12 -0
- package/components/Pane/PaneMenu.tsx +10 -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 +19 -7
- package/components/SessionList/SessionRowMenu.tsx +23 -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/Sessions/RolePill.tsx +196 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Share/ShareDialog.tsx +13 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/analyst/index.ts +3 -0
- package/data/analyst/keys.ts +6 -0
- package/data/analyst/mutations.ts +116 -0
- package/data/analyst/queries.ts +35 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/exports/index.ts +2 -0
- package/data/exports/keys.ts +3 -0
- package/data/exports/queries.ts +51 -0
- package/data/models/index.ts +98 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +9 -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 +36 -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 +1 -0
- package/lib/agents/spawn.ts +4 -0
- package/lib/analyst/__fixtures__/items.ts +57 -0
- package/lib/analyst/__fixtures__/mocks.ts +49 -0
- package/lib/analyst/brief.ts +17 -0
- package/lib/analyst/buildDigest.ts +204 -0
- package/lib/analyst/createLink.ts +98 -0
- package/lib/analyst/endLink.ts +31 -0
- package/lib/analyst/forward.ts +109 -0
- package/lib/analyst/index.ts +16 -0
- package/lib/analyst/linkView.ts +38 -0
- package/lib/analyst/onTurnEnd.ts +119 -0
- package/lib/analyst/parseReview.ts +41 -0
- package/lib/analyst/pushDigest.ts +143 -0
- package/lib/analyst/resume.ts +35 -0
- package/lib/analyst/route.ts +50 -0
- package/lib/analyst/signals.ts +121 -0
- package/lib/analyst/store.ts +303 -0
- package/lib/analyst/types.ts +140 -0
- package/lib/bus/condense.ts +5 -1
- package/lib/bus/index.ts +13 -0
- package/lib/bus/read-scope.ts +12 -0
- package/lib/bus/role-alias.ts +70 -0
- package/lib/bus/targets.ts +3 -0
- package/lib/chat/drivers/opencode-mapper.ts +55 -1
- package/lib/chat/drivers/pi-approvals.ts +7 -4
- package/lib/chat/drivers/pi-mapper.ts +38 -0
- package/lib/chat/drivers/pi-text-tools.ts +104 -0
- package/lib/chat/drivers/pi.ts +8 -3
- package/lib/chat/drivers/turn-items.ts +36 -11
- package/lib/chat/drivers/turn-outcome.ts +108 -0
- package/lib/chat/events.ts +8 -0
- package/lib/chat/registry.ts +13 -0
- package/lib/chat/store.ts +19 -0
- package/lib/chat/turns.ts +1 -0
- package/lib/chat/worker/extras.ts +4 -2
- package/lib/claims/collectLocal.ts +123 -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 +12 -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/migrations/20261021T1400_session_agent_roles.ts +20 -0
- package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +3 -0
- package/lib/db/types.ts +19 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- package/lib/drafts.ts +4 -0
- package/lib/export/build.ts +470 -0
- package/lib/export/compact.ts +156 -0
- package/lib/export/estimate.ts +83 -0
- package/lib/export/fixtures.ts +45 -0
- package/lib/export/html.tsx +195 -0
- package/lib/export/json.ts +39 -0
- package/lib/export/load.ts +64 -0
- package/lib/export/markdown.ts +127 -0
- package/lib/export/request.ts +36 -0
- package/lib/export/service.ts +142 -0
- package/lib/export/summary.ts +86 -0
- package/lib/export/turns.ts +63 -0
- package/lib/export/view.ts +173 -0
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/models/agent-binding.ts +102 -0
- package/lib/models/agent-roles-view.ts +157 -0
- package/lib/models/agent-roles.ts +87 -0
- package/lib/models/availability-refs.ts +17 -0
- package/lib/models/availability.ts +91 -0
- package/lib/models/catalog.ts +156 -0
- package/lib/models/config.ts +27 -0
- package/lib/models/locality.ts +33 -0
- package/lib/models/providers.ts +11 -0
- package/lib/models/resolve.ts +50 -12
- package/lib/models/roles.ts +12 -0
- package/lib/needs-you.ts +1 -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 +12 -2
- package/lib/orchestrator/read.ts +5 -1
- 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/untrusted.ts +5 -0
- 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 +6 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-artifacts/agentos-files.ts +94 -0
- package/lib/session-artifacts/collect.ts +17 -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/crew.ts +72 -0
- package/lib/sessions/launch.ts +54 -11
- package/lib/settings-url.ts +1 -0
- package/lib/share/markdown.ts +2 -2
- package/lib/share/redact.ts +16 -10
- package/lib/share/render.tsx +1 -1
- package/lib/share/snapshot.ts +2 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +8 -1
- 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 +11 -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-deps/caches.ts +3 -2
- package/lib/worktree-deps/clone.ts +3 -1
- package/lib/worktree-deps/paths.ts +26 -0
- 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 +12 -1
- package/stores/claimsUi.ts +22 -0
- package/stores/exportUi.ts +87 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
package/lib/peer/rpc-server.ts
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* Refusals before decryption are plain and fixed (`bad envelope`, `bad
|
|
16
16
|
* signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
|
|
17
17
|
* and a bad signature all read `bad signature`, so a stranger can't tell
|
|
18
|
-
* which machines are linked here; the audit keeps the difference.
|
|
18
|
+
* which machines are linked here; the audit keeps the difference. The one
|
|
19
|
+
* exception is a machine unlinked here, proven by the key it was pinned
|
|
20
|
+
* with: it gets 403 `unknown peer`, signed (unlinked-proof.ts), so it can
|
|
21
|
+
* show that it was unlinked. Everything
|
|
19
22
|
* after is inside the envelope,
|
|
20
23
|
* always HTTP 200, so whoever carries the traffic never learns the route or
|
|
21
24
|
* its status.
|
|
@@ -45,7 +48,14 @@ import {
|
|
|
45
48
|
type BoxKey,
|
|
46
49
|
type Identity,
|
|
47
50
|
} from "./keys";
|
|
48
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
hasActivePin,
|
|
53
|
+
hasRecentUnlinked,
|
|
54
|
+
pinForMachine,
|
|
55
|
+
unlinkedMachine,
|
|
56
|
+
type PeerPin,
|
|
57
|
+
} from "./links";
|
|
58
|
+
import { UNLINKED_PROOF_HEADER, signUnlinked } from "./unlinked-proof";
|
|
49
59
|
import { sqliteNonceStore } from "./nonce-store";
|
|
50
60
|
import { createReplayGuard, type ReplayGuard } from "./replay";
|
|
51
61
|
import {
|
|
@@ -75,7 +85,11 @@ export interface RpcDeps {
|
|
|
75
85
|
identity: () => Identity | null;
|
|
76
86
|
boxKey: () => BoxKey;
|
|
77
87
|
pinForMachine: (machineId: string) => PeerPin | null;
|
|
78
|
-
/**
|
|
88
|
+
/** A machine unlinked here lately: the key it was pinned with. */
|
|
89
|
+
unlinked?: (
|
|
90
|
+
machineId: string
|
|
91
|
+
) => { signPub: string; hostId: string | null } | null;
|
|
92
|
+
/** False while no link is active or recently unlinked: the route is closed to everyone. */
|
|
79
93
|
anyActive: () => boolean;
|
|
80
94
|
replay: ReplayGuard;
|
|
81
95
|
dispatch: (d: Dispatch) => Promise<PeerResponse>;
|
|
@@ -125,6 +139,10 @@ export async function handlePeerRpc(
|
|
|
125
139
|
const self = deps.identity();
|
|
126
140
|
let fromMachine: string | null = null;
|
|
127
141
|
let pin: PeerPin | null = null;
|
|
142
|
+
// An unlinked sender whose header its old key proved, and its nonce.
|
|
143
|
+
let unlinkedHost: string | null = null;
|
|
144
|
+
let viaUnlinked = false;
|
|
145
|
+
let provenUnlinked: string | null = null;
|
|
128
146
|
const audit = (
|
|
129
147
|
outcome: Outcome,
|
|
130
148
|
status: number,
|
|
@@ -133,7 +151,7 @@ export async function handlePeerRpc(
|
|
|
133
151
|
bytes = 0
|
|
134
152
|
) =>
|
|
135
153
|
deps.audit({
|
|
136
|
-
hostId: pin?.hostId ??
|
|
154
|
+
hostId: pin?.hostId ?? unlinkedHost,
|
|
137
155
|
machineId: fromMachine,
|
|
138
156
|
direction: "in",
|
|
139
157
|
method,
|
|
@@ -144,6 +162,19 @@ export async function handlePeerRpc(
|
|
|
144
162
|
outcome,
|
|
145
163
|
});
|
|
146
164
|
const refuse = (refusal: Refusal): null => {
|
|
165
|
+
if (refusal === "unknown_peer" && provenUnlinked && self && fromMachine) {
|
|
166
|
+
audit("unknown_peer", 403);
|
|
167
|
+
res.setHeader(
|
|
168
|
+
UNLINKED_PROOF_HEADER,
|
|
169
|
+
signUnlinked(self.signKey, {
|
|
170
|
+
from: self.machineId,
|
|
171
|
+
to: fromMachine,
|
|
172
|
+
nonce: provenUnlinked,
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
plain(res, 403, "unknown peer");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
147
178
|
const [status, text, outcome] = REFUSED[refusal];
|
|
148
179
|
audit(outcome, status);
|
|
149
180
|
plain(res, status, text);
|
|
@@ -161,6 +192,18 @@ export async function handlePeerRpc(
|
|
|
161
192
|
: found.state !== "active"
|
|
162
193
|
? "suspended"
|
|
163
194
|
: null;
|
|
195
|
+
if (!found) {
|
|
196
|
+
const gone = deps.unlinked?.(machineId);
|
|
197
|
+
if (gone)
|
|
198
|
+
try {
|
|
199
|
+
const key = signPublic(gone.signPub);
|
|
200
|
+
unlinkedHost = gone.hostId;
|
|
201
|
+
viaUnlinked = true;
|
|
202
|
+
return key;
|
|
203
|
+
} catch {
|
|
204
|
+
// Unreadable: a decoy, like any stranger.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
164
207
|
if (!found || hidden) return DECOY_KEY;
|
|
165
208
|
try {
|
|
166
209
|
return signPublic(found.signPub);
|
|
@@ -210,6 +253,10 @@ export async function handlePeerRpc(
|
|
|
210
253
|
now
|
|
211
254
|
);
|
|
212
255
|
if (fresh !== "ok") return { refusal: fresh };
|
|
256
|
+
if (viaUnlinked) {
|
|
257
|
+
provenUnlinked = header.nonce;
|
|
258
|
+
return { refusal: "unknown_peer" };
|
|
259
|
+
}
|
|
213
260
|
claimed = `${header.from}:${header.nonce}`;
|
|
214
261
|
return { peer: header.from };
|
|
215
262
|
},
|
|
@@ -360,7 +407,8 @@ export function rpcDeps(port: number): RpcDeps {
|
|
|
360
407
|
identity: () => currentIdentity(),
|
|
361
408
|
boxKey: () => (box ??= loadBoxKey()),
|
|
362
409
|
pinForMachine,
|
|
363
|
-
|
|
410
|
+
unlinked: (machineId) => unlinkedMachine(machineId),
|
|
411
|
+
anyActive: () => hasActivePin() || hasRecentUnlinked(),
|
|
364
412
|
replay: createReplayGuard({ store: sqliteNonceStore() }),
|
|
365
413
|
dispatch: loopbackDispatch(port),
|
|
366
414
|
audit: recordPeerCall,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unlinking a machine linked through Connect, from either side. Here: the
|
|
3
|
+
* pin goes at once (links.ts dropPin), then the other machine is told with a
|
|
4
|
+
* signed POST /api/peer/unlink, best effort. There: the same call drops its
|
|
5
|
+
* pin and its Machines says "Unlinked by <this machine>". If that call never
|
|
6
|
+
* arrives, the other machine's next call here is answered with a signed
|
|
7
|
+
* "unknown peer" (rpc-server.ts), which does the same.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { deleteHost } from "../hosts";
|
|
11
|
+
import { hostLink, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
import { peerCall } from "./client";
|
|
13
|
+
import { dropPin, pinForHost } from "./links";
|
|
14
|
+
|
|
15
|
+
export const UNLINKED_HERE = "Unlinked here";
|
|
16
|
+
|
|
17
|
+
export interface UnlinkDeps {
|
|
18
|
+
tell: (link: HostLink) => Promise<unknown>;
|
|
19
|
+
remove: (hostId: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const realDeps: UnlinkDeps = {
|
|
23
|
+
tell: (link) =>
|
|
24
|
+
peerCall(link, "/api/peer/unlink", { body: {}, timeout: 8_000 }),
|
|
25
|
+
remove: deleteHost,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface UnlinkResult {
|
|
29
|
+
name: string;
|
|
30
|
+
/** The other machine confirmed it dropped its side. */
|
|
31
|
+
told: boolean;
|
|
32
|
+
/** The machine left Machines here too (it held no sessions or projects). */
|
|
33
|
+
removed: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A person here unlinks it. Throws when it isn't linked through Connect. */
|
|
37
|
+
export async function unlinkPeer(
|
|
38
|
+
hostId: string,
|
|
39
|
+
deps: UnlinkDeps = realDeps
|
|
40
|
+
): Promise<UnlinkResult> {
|
|
41
|
+
const link = hostLink(hostId);
|
|
42
|
+
if (!link?.connect || !pinForHost(hostId))
|
|
43
|
+
throw new Error("That machine isn't linked through Connect.");
|
|
44
|
+
dropPin(hostId, { byPeer: false, reason: UNLINKED_HERE });
|
|
45
|
+
let told = false;
|
|
46
|
+
// A suspended link is refused there too; nothing would arrive.
|
|
47
|
+
if (link.connect.state === "active")
|
|
48
|
+
try {
|
|
49
|
+
await deps.tell(link);
|
|
50
|
+
told = true;
|
|
51
|
+
} catch {
|
|
52
|
+
// Best effort: its next call here is refused and says why.
|
|
53
|
+
}
|
|
54
|
+
let removed = false;
|
|
55
|
+
try {
|
|
56
|
+
deps.remove(hostId);
|
|
57
|
+
removed = true;
|
|
58
|
+
} catch {
|
|
59
|
+
// It still holds sessions or projects; it stays, unlinked.
|
|
60
|
+
}
|
|
61
|
+
return { name: link.hostName, told, removed };
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refusal that is signed: "unknown peer", sent to a machine that was
|
|
3
|
+
* unlinked here, bound to its own request's nonce. Only the key it had pinned
|
|
4
|
+
* for this machine verifies it, so whoever carries the traffic can't forge an
|
|
5
|
+
* unlink, and a replayed one never matches a new request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto, { type KeyObject } from "crypto";
|
|
9
|
+
|
|
10
|
+
export const UNLINKED_PROOF_HEADER = "x-agentos-peer-unlinked";
|
|
11
|
+
const DOMAIN = "agentos-peer-unlinked-v1";
|
|
12
|
+
|
|
13
|
+
interface Bound {
|
|
14
|
+
/** The machine refusing: the one that unlinked. */
|
|
15
|
+
from: string;
|
|
16
|
+
/** The caller, which it no longer knows. */
|
|
17
|
+
to: string;
|
|
18
|
+
/** The caller's request nonce, base64url. */
|
|
19
|
+
nonce: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const message = (o: Bound) =>
|
|
23
|
+
Buffer.from(`${DOMAIN}|${o.from}|${o.to}|${o.nonce}`);
|
|
24
|
+
|
|
25
|
+
export const signUnlinked = (signKey: KeyObject, o: Bound): string =>
|
|
26
|
+
crypto.sign(null, message(o), signKey).toString("base64url");
|
|
27
|
+
|
|
28
|
+
export function verifyUnlinked(
|
|
29
|
+
signPub: KeyObject,
|
|
30
|
+
o: Bound,
|
|
31
|
+
sig: string | null
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!sig || !/^[A-Za-z0-9_-]{86}$/.test(sig)) return false;
|
|
34
|
+
try {
|
|
35
|
+
return crypto.verify(
|
|
36
|
+
null,
|
|
37
|
+
message(o),
|
|
38
|
+
signPub,
|
|
39
|
+
Buffer.from(sig, "base64url")
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/lib/projects.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type DevServerType,
|
|
20
20
|
} from "./db";
|
|
21
21
|
import { resolveModelForAgent } from "./model-catalog";
|
|
22
|
+
import { clearForeignGroup } from "./session-groups/assignSessions";
|
|
22
23
|
import type { AgentType } from "./providers";
|
|
23
24
|
|
|
24
25
|
const execAsync = promisify(exec);
|
|
@@ -293,6 +294,7 @@ export function deleteProject(id: string): boolean {
|
|
|
293
294
|
|
|
294
295
|
// Its merge settings override (lib/tasks/merge-policy.ts)
|
|
295
296
|
db.prepare(`DELETE FROM settings WHERE key = ?`).run(`merge.project.${id}`);
|
|
297
|
+
db.prepare(`DELETE FROM settings WHERE key = ?`).run(`models.project.${id}`);
|
|
296
298
|
|
|
297
299
|
// Delete project
|
|
298
300
|
queries.deleteProject(db).run(id);
|
|
@@ -312,7 +314,7 @@ export function getProjectSessions(projectId: string): Session[] {
|
|
|
312
314
|
export function moveSessionToProject(
|
|
313
315
|
sessionId: string,
|
|
314
316
|
projectId: string
|
|
315
|
-
):
|
|
317
|
+
): { clearedGroup: string | null } {
|
|
316
318
|
const to = getProject(projectId);
|
|
317
319
|
const from = db
|
|
318
320
|
.prepare(
|
|
@@ -321,7 +323,7 @@ export function moveSessionToProject(
|
|
|
321
323
|
)
|
|
322
324
|
.get(sessionId) as
|
|
323
325
|
{ workspace_id: string | null; own: string | null } | undefined;
|
|
324
|
-
db.transaction(() => {
|
|
326
|
+
return db.transaction(() => {
|
|
325
327
|
queries.updateSessionProject(db).run(projectId, sessionId);
|
|
326
328
|
// A project's session follows the project; one left in none keeps the
|
|
327
329
|
// workspace it was in.
|
|
@@ -333,6 +335,8 @@ export function moveSessionToProject(
|
|
|
333
335
|
: (from?.workspace_id ?? from?.own ?? null),
|
|
334
336
|
sessionId
|
|
335
337
|
);
|
|
338
|
+
// A group belongs to the workspace the session just left.
|
|
339
|
+
return { clearedGroup: clearForeignGroup(sessionId) };
|
|
336
340
|
})();
|
|
337
341
|
}
|
|
338
342
|
|
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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a task saves under .agentos/mockups and .agentos/screenshots. Git
|
|
3
|
+
* ignores that folder (its .gitignore is `*`) and a Bash write isn't a
|
|
4
|
+
* file-writing tool call, so neither other source lists them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { isSecretPath } from "../secret-paths";
|
|
10
|
+
import { SCREENSHOT_DIR } from "../tasks/ui-brief";
|
|
11
|
+
import { kindForPath, type Candidate } from "./derive";
|
|
12
|
+
|
|
13
|
+
export const MOCKUP_DIR = ".agentos/mockups";
|
|
14
|
+
export const AGENTOS_DIRS = [MOCKUP_DIR, SCREENSHOT_DIR];
|
|
15
|
+
const MAX_DEPTH = 3;
|
|
16
|
+
const MAX_LISTED = 200;
|
|
17
|
+
|
|
18
|
+
const isAgentosFile = (rel: string) =>
|
|
19
|
+
AGENTOS_DIRS.some((d) => rel.startsWith(`${d}/`));
|
|
20
|
+
|
|
21
|
+
function candidate(
|
|
22
|
+
worktree: string,
|
|
23
|
+
rel: string,
|
|
24
|
+
at: number
|
|
25
|
+
): Candidate | null {
|
|
26
|
+
const file = path.join(worktree, rel);
|
|
27
|
+
if (isSecretPath(file)) return null;
|
|
28
|
+
return {
|
|
29
|
+
key: `file:${file}`,
|
|
30
|
+
kind: kindForPath(file),
|
|
31
|
+
name: path.basename(file),
|
|
32
|
+
at,
|
|
33
|
+
by: "shell",
|
|
34
|
+
source: { type: "file", path: file },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function walk(root: string, rel: string, depth: number, out: string[]): void {
|
|
39
|
+
if (out.length >= MAX_LISTED) return;
|
|
40
|
+
let names: fs.Dirent[];
|
|
41
|
+
try {
|
|
42
|
+
names = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const d of names) {
|
|
47
|
+
const child = `${rel}/${d.name}`;
|
|
48
|
+
// Symlinks are skipped: they could point out of the worktree.
|
|
49
|
+
if (d.isFile()) out.push(child);
|
|
50
|
+
else if (d.isDirectory() && depth < MAX_DEPTH)
|
|
51
|
+
walk(root, child, depth + 1, out);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function agentosFiles(worktree: string): { rel: string; at: number }[] {
|
|
56
|
+
const rels: string[] = [];
|
|
57
|
+
for (const dir of AGENTOS_DIRS) walk(worktree, dir, 1, rels);
|
|
58
|
+
return rels.slice(0, MAX_LISTED).flatMap((rel) => {
|
|
59
|
+
try {
|
|
60
|
+
return [{ rel, at: fs.statSync(path.join(worktree, rel)).mtimeMs }];
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function fromAgentosDirs(
|
|
68
|
+
worktree: string,
|
|
69
|
+
known: Set<string>
|
|
70
|
+
): Candidate[] {
|
|
71
|
+
return agentosFiles(worktree).flatMap(({ rel, at }) => {
|
|
72
|
+
const c = candidate(worktree, rel, at);
|
|
73
|
+
return c && !known.has(c.key) ? [c] : [];
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// After the worktree is gone: the copies that were kept.
|
|
78
|
+
export function fromKept(
|
|
79
|
+
worktree: string,
|
|
80
|
+
kept: string[],
|
|
81
|
+
at: number,
|
|
82
|
+
known: Set<string>
|
|
83
|
+
): Candidate[] {
|
|
84
|
+
return kept.filter(isAgentosFile).flatMap((rel) => {
|
|
85
|
+
const c = candidate(worktree, rel, at);
|
|
86
|
+
return c && !known.has(c.key) ? [c] : [];
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Changes when a file is added, removed or rewritten there.
|
|
91
|
+
export function agentosSignature(worktree: string): string {
|
|
92
|
+
const files = agentosFiles(worktree);
|
|
93
|
+
return `${files.length}:${files.reduce((m, f) => Math.max(m, f.at), 0)}`;
|
|
94
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type ItemRow,
|
|
33
33
|
type RunRow,
|
|
34
34
|
} from "./derive";
|
|
35
|
+
import { agentosSignature, fromAgentosDirs, fromKept } from "./agentos-files";
|
|
35
36
|
import { keepFiles, readManifest, relInside, sessionKeptDir } from "./keep";
|
|
36
37
|
|
|
37
38
|
export const MAX_FILES = 500;
|
|
@@ -160,6 +161,16 @@ async function build(session: Session): Promise<Entry[]> {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
const manifest = worktree && !worktreeThere ? readManifest(session.id) : null;
|
|
164
|
+
if (worktree) {
|
|
165
|
+
const known = new Set(candidates.map((c) => c.key));
|
|
166
|
+
candidates.push(
|
|
167
|
+
...(worktreeThere
|
|
168
|
+
? fromAgentosDirs(worktree, known)
|
|
169
|
+
: manifest
|
|
170
|
+
? fromKept(worktree, manifest.kept, sqliteMs(manifest.at), known)
|
|
171
|
+
: [])
|
|
172
|
+
);
|
|
173
|
+
}
|
|
163
174
|
if (worktree) {
|
|
164
175
|
const planFile = path.join(worktree, PLAN_FILE);
|
|
165
176
|
const named = candidates.some((c) => c.key === `file:${planFile}`);
|
|
@@ -255,6 +266,9 @@ interface Cached {
|
|
|
255
266
|
}
|
|
256
267
|
const cache = new Map<string, Cached>();
|
|
257
268
|
|
|
269
|
+
const isLocalWorktree = (s: Session) =>
|
|
270
|
+
!!s.worktree_path && (!s.host_id || s.host_id === "local");
|
|
271
|
+
|
|
258
272
|
function signature(session: Session): string {
|
|
259
273
|
const row = db
|
|
260
274
|
.prepare(`SELECT max(seq) AS seq FROM chat_items WHERE session_id = ?`)
|
|
@@ -265,6 +279,9 @@ function signature(session: Session): string {
|
|
|
265
279
|
session.pr_number,
|
|
266
280
|
session.worktree_path,
|
|
267
281
|
session.updated_at,
|
|
282
|
+
isLocalWorktree(session)
|
|
283
|
+
? agentosSignature(expand(session.worktree_path!))
|
|
284
|
+
: "",
|
|
268
285
|
].join("|");
|
|
269
286
|
}
|
|
270
287
|
|
|
@@ -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
|
+
}
|