agent-relay-server 0.82.0 → 0.83.1
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/docs/openapi.json +367 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-BTBQr8uG.js +3 -0
- package/public/assets/MessageBubble-BTBQr8uG.js.map +1 -0
- package/public/assets/{PlanGraphPanel-r3aVT4uc.js → PlanGraphPanel-CoWJJVyD.js} +2 -2
- package/public/assets/{PlanGraphPanel-r3aVT4uc.js.map → PlanGraphPanel-CoWJJVyD.js.map} +1 -1
- package/public/assets/activity-XqRFu_9T.js +2 -0
- package/public/assets/{activity-DtMjjws8.js.map → activity-XqRFu_9T.js.map} +1 -1
- package/public/assets/{agent-profiles-eRlq98tp.js → agent-profiles-RFCLAgnC.js} +2 -2
- package/public/assets/{agent-profiles-eRlq98tp.js.map → agent-profiles-RFCLAgnC.js.map} +1 -1
- package/public/assets/{agents-DmbjRTA7.js → agents-BWmmWj4Q.js} +2 -2
- package/public/assets/{agents-DmbjRTA7.js.map → agents-BWmmWj4Q.js.map} +1 -1
- package/public/assets/{analytics-BW7ik_Ws.js → analytics-2H2z2UNS.js} +3 -3
- package/public/assets/{analytics-BW7ik_Ws.js.map → analytics-2H2z2UNS.js.map} +1 -1
- package/public/assets/{automation-CxwGXWC7.js → automation-DRtOENs0.js} +2 -2
- package/public/assets/{automation-CxwGXWC7.js.map → automation-DRtOENs0.js.map} +1 -1
- package/public/assets/{badge-BwaoBnNO.js → badge-CK6ejiOh.js} +2 -2
- package/public/assets/{badge-BwaoBnNO.js.map → badge-CK6ejiOh.js.map} +1 -1
- package/public/assets/branch-state-badge-DxwNcWr8.js +2 -0
- package/public/assets/{branch-state-badge-CvdKPTlV.js.map → branch-state-badge-DxwNcWr8.js.map} +1 -1
- package/public/assets/{button-CFIklTDX.js → button-BAwKuRIq.js} +2 -2
- package/public/assets/{button-CFIklTDX.js.map → button-BAwKuRIq.js.map} +1 -1
- package/public/assets/{card-Bi94xEmr.js → card-DDxxo-Fo.js} +2 -2
- package/public/assets/{card-Bi94xEmr.js.map → card-DDxxo-Fo.js.map} +1 -1
- package/public/assets/{channels-D8UY3baa.js → channels-Sh_jdB7y.js} +2 -2
- package/public/assets/{channels-D8UY3baa.js.map → channels-Sh_jdB7y.js.map} +1 -1
- package/public/assets/chat-gfUxbTus.js +2 -0
- package/public/assets/{chat-CCPukbI9.js.map → chat-gfUxbTus.js.map} +1 -1
- package/public/assets/{connectors-W2MslhQ2.js → connectors-C3C4i4Gz.js} +2 -2
- package/public/assets/{connectors-W2MslhQ2.js.map → connectors-C3C4i4Gz.js.map} +1 -1
- package/public/assets/{copy-button-A0pXLXBy.js → copy-button-D3B_s7J7.js} +2 -2
- package/public/assets/{copy-button-A0pXLXBy.js.map → copy-button-D3B_s7J7.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{dist--NYpzm4f.js → dist-CAFUEpK2.js} +2 -2
- package/public/assets/{dist--NYpzm4f.js.map → dist-CAFUEpK2.js.map} +1 -1
- package/public/assets/dist-CIbvPDQV.js +2 -0
- package/public/assets/{dist-D3jA_LvQ.js.map → dist-CIbvPDQV.js.map} +1 -1
- package/public/assets/{dist-DY3KCMIN.js → dist-CL2npVDe.js} +2 -2
- package/public/assets/{dist-DY3KCMIN.js.map → dist-CL2npVDe.js.map} +1 -1
- package/public/assets/{dist-DVorlUcA.js → dist-CnRv0y2x.js} +2 -2
- package/public/assets/{dist-DVorlUcA.js.map → dist-CnRv0y2x.js.map} +1 -1
- package/public/assets/dist-CoRABSO3.js +2 -0
- package/public/assets/{dist-rLCXIl7x.js.map → dist-CoRABSO3.js.map} +1 -1
- package/public/assets/{dist-yzKYLD10.js → dist-DLsSqk6h.js} +2 -2
- package/public/assets/{dist-yzKYLD10.js.map → dist-DLsSqk6h.js.map} +1 -1
- package/public/assets/{dist-DhEz6YNy.js → dist-Dmiv1tSO.js} +2 -2
- package/public/assets/{dist-DhEz6YNy.js.map → dist-Dmiv1tSO.js.map} +1 -1
- package/public/assets/dist-DyqEeyBI.js +2 -0
- package/public/assets/{dist-HTxIFS7t.js.map → dist-DyqEeyBI.js.map} +1 -1
- package/public/assets/{dist-_EunAAFX.js → dist-ERYvDMdt.js} +2 -2
- package/public/assets/{dist-_EunAAFX.js.map → dist-ERYvDMdt.js.map} +1 -1
- package/public/assets/{dist-C4VoXCT-.js → dist-YQVQrtnc.js} +2 -2
- package/public/assets/{dist-C4VoXCT-.js.map → dist-YQVQrtnc.js.map} +1 -1
- package/public/assets/{dist-BODiHbq1.js → dist-YjP8Gd1C.js} +2 -2
- package/public/assets/{dist-BODiHbq1.js.map → dist-YjP8Gd1C.js.map} +1 -1
- package/public/assets/{es2015-BDZowz0r.js → es2015-Yto-4EBw.js} +2 -2
- package/public/assets/{es2015-BDZowz0r.js.map → es2015-Yto-4EBw.js.map} +1 -1
- package/public/assets/{formatted-body-impl-tIjgf9Kr.js → formatted-body-impl-DpNHGXXQ.js} +2 -2
- package/public/assets/{formatted-body-impl-tIjgf9Kr.js.map → formatted-body-impl-DpNHGXXQ.js.map} +1 -1
- package/public/assets/index-D8TVUdlC.js +22 -0
- package/public/assets/index-D8TVUdlC.js.map +1 -0
- package/public/assets/index-DgZLDL0K.css +2 -0
- package/public/assets/{input-CD-ZcZIA.js → input-CLW2gDUM.js} +2 -2
- package/public/assets/{input-CD-ZcZIA.js.map → input-CLW2gDUM.js.map} +1 -1
- package/public/assets/{insights-B93PGmKq.js → insights-DPaJ5Hme.js} +2 -2
- package/public/assets/{insights-B93PGmKq.js.map → insights-DPaJ5Hme.js.map} +1 -1
- package/public/assets/{integrations-BfWMAznM.js → integrations-PqWMrPJa.js} +2 -2
- package/public/assets/{integrations-BfWMAznM.js.map → integrations-PqWMrPJa.js.map} +1 -1
- package/public/assets/{lib-BqL6WCDu.js → lib-DdCM7GfN.js} +2 -2
- package/public/assets/{lib-BqL6WCDu.js.map → lib-DdCM7GfN.js.map} +1 -1
- package/public/assets/lucide-react-CsPXvXMC.js +9 -0
- package/public/assets/{lucide-react-D78N6SwK.js.map → lucide-react-CsPXvXMC.js.map} +1 -1
- package/public/assets/{maintenance-CgRASTD0.js → maintenance-DsszLb92.js} +2 -2
- package/public/assets/{maintenance-CgRASTD0.js.map → maintenance-DsszLb92.js.map} +1 -1
- package/public/assets/{managed-agents-B1AdrEv4.js → managed-agents-DqWPxFE0.js} +2 -2
- package/public/assets/{managed-agents-B1AdrEv4.js.map → managed-agents-DqWPxFE0.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-D2zEt-i-.js → markdown-preview-impl-CWR4ILHK.js} +2 -2
- package/public/assets/{markdown-preview-impl-D2zEt-i-.js.map → markdown-preview-impl-CWR4ILHK.js.map} +1 -1
- package/public/assets/memory-C2s9bJ38.js +2 -0
- package/public/assets/{memory-nUBcij_9.js.map → memory-C2s9bJ38.js.map} +1 -1
- package/public/assets/{messages-BNDs53bW.js → messages-DHRJNAvy.js} +2 -2
- package/public/assets/{messages-BNDs53bW.js.map → messages-DHRJNAvy.js.map} +1 -1
- package/public/assets/{orchestrators-BouoI90a.js → orchestrators-BGNNllJc.js} +2 -2
- package/public/assets/{orchestrators-BouoI90a.js.map → orchestrators-BGNNllJc.js.map} +1 -1
- package/public/assets/{overview-C8_hAGpC.js → overview-C1vZfyhW.js} +2 -2
- package/public/assets/{overview-C8_hAGpC.js.map → overview-C1vZfyhW.js.map} +1 -1
- package/public/assets/{pairs-BcCOB1oO.js → pairs-C1uzo8x0.js} +2 -2
- package/public/assets/{pairs-BcCOB1oO.js.map → pairs-C1uzo8x0.js.map} +1 -1
- package/public/assets/{projects-MZ6CNrYO.js → projects-B2-8Eu0e.js} +2 -2
- package/public/assets/{projects-MZ6CNrYO.js.map → projects-B2-8Eu0e.js.map} +1 -1
- package/public/assets/{react-dom-CU0WCHqq.js → react-dom-CEl58oxP.js} +2 -2
- package/public/assets/{react-dom-CU0WCHqq.js.map → react-dom-CEl58oxP.js.map} +1 -1
- package/public/assets/{security-Bh8BGpZR.js → security-XhAAP6q3.js} +2 -2
- package/public/assets/{security-Bh8BGpZR.js.map → security-XhAAP6q3.js.map} +1 -1
- package/public/assets/{select-Drdvachg.js → select-DEGUFEjx.js} +2 -2
- package/public/assets/{select-Drdvachg.js.map → select-DEGUFEjx.js.map} +1 -1
- package/public/assets/settings-DTbVUdlZ.js +4 -0
- package/public/assets/{settings-IrNBT0hy.js.map → settings-DTbVUdlZ.js.map} +1 -1
- package/public/assets/{store-CqFZmc-B.js → store-DYaQAXRv.js} +5 -5
- package/public/assets/{store-CqFZmc-B.js.map → store-DYaQAXRv.js.map} +1 -1
- package/public/assets/{tasks-heEgx7vT.js → tasks-zwGo7hon.js} +2 -2
- package/public/assets/{tasks-heEgx7vT.js.map → tasks-zwGo7hon.js.map} +1 -1
- package/public/assets/{teams-BjETr-_W.js → teams-Brc5D8ox.js} +2 -2
- package/public/assets/{teams-BjETr-_W.js.map → teams-Brc5D8ox.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BDqmbWB3.js → terminal-viewer-impl-C6yUAL8G.js} +3 -3
- package/public/assets/{terminal-viewer-impl-BDqmbWB3.js.map → terminal-viewer-impl-C6yUAL8G.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-CichJ8Jn.js → use-keyboard-viewport-DABeIMEJ.js} +2 -2
- package/public/assets/{use-keyboard-viewport-CichJ8Jn.js.map → use-keyboard-viewport-DABeIMEJ.js.map} +1 -1
- package/public/assets/user-avatar-Bw1R011j.js +2 -0
- package/public/assets/user-avatar-Bw1R011j.js.map +1 -0
- package/public/assets/{work-queue-rgQs4Wis.js → work-queue-ge4R7keo.js} +2 -2
- package/public/assets/{work-queue-rgQs4Wis.js.map → work-queue-ge4R7keo.js.map} +1 -1
- package/public/assets/workspaces-BhblC-Nd.js +3 -0
- package/public/assets/{workspaces-hIjZicaF.js.map → workspaces-BhblC-Nd.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/agent-drive-arbiter.ts +112 -0
- package/src/db/artifacts.ts +2 -3
- package/src/db/drive-leases.ts +165 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +6 -0
- package/src/db/users.ts +26 -0
- package/src/maintenance/jobs.ts +11 -0
- package/src/routes/_shared.ts +1 -1
- package/src/routes/agent-sessions.ts +97 -1
- package/src/routes/artifacts.ts +7 -3
- package/src/routes/index.ts +13 -2
- package/src/routes/users.ts +152 -6
- package/src/security.ts +2 -0
- package/src/services/user-profile.ts +69 -0
- package/src/upgrade-install-verify.ts +160 -0
- package/src/upgrade.ts +40 -11
- package/public/assets/MessageBubble-ckJ2VVgq.js +0 -3
- package/public/assets/MessageBubble-ckJ2VVgq.js.map +0 -1
- package/public/assets/activity-DtMjjws8.js +0 -2
- package/public/assets/branch-state-badge-CvdKPTlV.js +0 -2
- package/public/assets/chat-CCPukbI9.js +0 -2
- package/public/assets/dist-D3jA_LvQ.js +0 -2
- package/public/assets/dist-HTxIFS7t.js +0 -2
- package/public/assets/dist-rLCXIl7x.js +0 -2
- package/public/assets/index-BfQFX8td.js +0 -22
- package/public/assets/index-BfQFX8td.js.map +0 -1
- package/public/assets/index-D8vc380x.css +0 -2
- package/public/assets/lucide-react-D78N6SwK.js +0 -9
- package/public/assets/memory-nUBcij_9.js +0 -2
- package/public/assets/settings-IrNBT0hy.js +0 -4
- package/public/assets/workspaces-hIjZicaF.js +0 -3
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Concurrency arbitration — "two humans driving one agent safely" (#388 users wave 5, #394).
|
|
2
|
+
//
|
|
3
|
+
// THE single decision home for "may this user drive this agent right now?". It composes three
|
|
4
|
+
// things the routes must never re-derive piecemeal:
|
|
5
|
+
// 1. the single-vs-multi-user switch (`isMultiUserWorld`) — single-user is a COMPLETE no-op,
|
|
6
|
+
// 2. the per-agent drive lease (`src/db/drive-leases.ts`) — the soft-lock / "X is driving",
|
|
7
|
+
// 3. role (`resolveViewer`) — an admin overrides a held wheel.
|
|
8
|
+
//
|
|
9
|
+
// The drive routes (prompt submit, interrupt, plan-approve/answer) and the explicit handoff
|
|
10
|
+
// endpoint both call `arbitrateDrive`, so the lock can never be enforced in one seam and skipped
|
|
11
|
+
// in another.
|
|
12
|
+
//
|
|
13
|
+
// Back-compat (load-bearing): with a single user `arbitrateDrive` returns `allowed` WITHOUT
|
|
14
|
+
// touching the lease table — the solo operator always holds the wheel, prompt/interrupt/approve
|
|
15
|
+
// flow exactly as before, and no expiry can ever strand them out of their own agent. Arbitration
|
|
16
|
+
// only engages once a second user exists.
|
|
17
|
+
import { canonicalHumanAgentId, userSinkId } from "agent-relay-sdk";
|
|
18
|
+
import { isMultiUserWorld } from "./db/agent-ownership.ts";
|
|
19
|
+
import { resolveViewer } from "./agent-authz.ts";
|
|
20
|
+
import { getUser } from "./db/users.ts";
|
|
21
|
+
import {
|
|
22
|
+
acquireDriveLease,
|
|
23
|
+
getDriveLease,
|
|
24
|
+
releaseDriveLease,
|
|
25
|
+
takeOverDriveLease,
|
|
26
|
+
type DriveLeaseRecord,
|
|
27
|
+
} from "./db/drive-leases.ts";
|
|
28
|
+
|
|
29
|
+
/** Who holds (or held) the wheel, resolved for human-readable "X is driving" messaging. */
|
|
30
|
+
interface DriveHolder {
|
|
31
|
+
/** Relational `users.id`. */
|
|
32
|
+
userId: string;
|
|
33
|
+
/** Canonical human message address (`user` / `user:<id>`). */
|
|
34
|
+
address: string;
|
|
35
|
+
/** Friendly name when the user row resolves. */
|
|
36
|
+
displayName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The outcome of an arbitration. `mode` records WHY it was allowed so the route can audit a steal
|
|
41
|
+
* distinctly from an ordinary acquire:
|
|
42
|
+
* - `solo` — single-user world, no lease written (back-compat no-op).
|
|
43
|
+
* - `holder` — caller already held the wheel; lease refreshed.
|
|
44
|
+
* - `acquired` — wheel was free; caller took it.
|
|
45
|
+
* - `override` — caller is an admin who stole a live lease from another driver.
|
|
46
|
+
*/
|
|
47
|
+
export type DriveArbitration =
|
|
48
|
+
| { allowed: true; mode: "solo" | "holder" | "acquired" | "override"; lease: DriveLeaseRecord | null; previousHolder?: DriveHolder }
|
|
49
|
+
| { allowed: false; heldBy: DriveHolder; lease: DriveLeaseRecord };
|
|
50
|
+
|
|
51
|
+
/** Resolve a `users.id` into a displayable drive holder. */
|
|
52
|
+
function driveHolder(userId: string): DriveHolder {
|
|
53
|
+
const user = getUser(userId);
|
|
54
|
+
const holder: DriveHolder = { userId, address: canonicalHumanAgentId(userSinkId(userId)) };
|
|
55
|
+
const name = user?.displayName ?? user?.handle;
|
|
56
|
+
if (name) holder.displayName = name;
|
|
57
|
+
return holder;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A short "X is driving this agent" phrase for the 409 body / UI. */
|
|
61
|
+
export function drivingMessage(holder: DriveHolder): string {
|
|
62
|
+
return `${holder.displayName ?? holder.address} is driving this agent`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decide whether `userId` may drive `agentId` now, taking/refreshing the lease as a side effect on
|
|
67
|
+
* success. An admin always wins (stealing a live lease → `override`); a non-admin wins only when
|
|
68
|
+
* the wheel is free or already theirs, otherwise it is `denied` with who holds it.
|
|
69
|
+
*
|
|
70
|
+
* Callers pass an already-`drive`-authorized user (the #392 gate runs first); arbitration is the
|
|
71
|
+
* second, concurrency layer on top.
|
|
72
|
+
*/
|
|
73
|
+
export function arbitrateDrive(userId: string, agentId: string, opts: { now?: number } = {}): DriveArbitration {
|
|
74
|
+
// Single-user world → no-op. No lease row is written, so a vanished/expired lease can NEVER
|
|
75
|
+
// strand the solo user out of their own agent (the load-bearing back-compat invariant).
|
|
76
|
+
if (!isMultiUserWorld()) return { allowed: true, mode: "solo", lease: null };
|
|
77
|
+
|
|
78
|
+
const now = opts.now;
|
|
79
|
+
const before = getDriveLease(agentId, now);
|
|
80
|
+
const acq = acquireDriveLease({ agentId, userId, now });
|
|
81
|
+
if (acq.ok) {
|
|
82
|
+
return { allowed: true, mode: before?.holderUserId === userId ? "holder" : "acquired", lease: acq.lease };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Blocked by another live holder. An admin overrides and steals the wheel; everyone else waits.
|
|
86
|
+
if (resolveViewer(userId)?.role === "admin") {
|
|
87
|
+
const { lease, previousHolderUserId } = takeOverDriveLease({ agentId, userId, now });
|
|
88
|
+
const result: DriveArbitration = { allowed: true, mode: "override", lease };
|
|
89
|
+
if (previousHolderUserId) result.previousHolder = driveHolder(previousHolderUserId);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
return { allowed: false, heldBy: driveHolder(acq.lease.holderUserId), lease: acq.lease };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Explicit release of the wheel ("let go"). A holder releases their own lease; an admin may force
|
|
97
|
+
* a reset (no `userId` guard). Single-user is a no-op. Returns the holder that was released, if any.
|
|
98
|
+
*/
|
|
99
|
+
export function releaseDrive(userId: string, agentId: string, opts: { force?: boolean } = {}): { released: boolean; previousHolder?: DriveHolder } {
|
|
100
|
+
if (!isMultiUserWorld()) return { released: false };
|
|
101
|
+
const current = getDriveLease(agentId);
|
|
102
|
+
const isAdmin = resolveViewer(userId)?.role === "admin";
|
|
103
|
+
const released = releaseDriveLease(opts.force && isAdmin ? { agentId } : { agentId, userId });
|
|
104
|
+
return released && current ? { released, previousHolder: driveHolder(current.holderUserId) } : { released };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Current driver of an agent (null = free, or single-user world where it is implicitly self). */
|
|
108
|
+
export function currentDriver(agentId: string, opts: { now?: number } = {}): DriveHolder | null {
|
|
109
|
+
if (!isMultiUserWorld()) return null;
|
|
110
|
+
const lease = getDriveLease(agentId, opts.now);
|
|
111
|
+
return lease ? driveHolder(lease.holderUserId) : null;
|
|
112
|
+
}
|
package/src/db/artifacts.ts
CHANGED
|
@@ -81,10 +81,10 @@ import type {
|
|
|
81
81
|
|
|
82
82
|
// --- Artifacts ---
|
|
83
83
|
|
|
84
|
-
export const VALID_ARTIFACT_KINDS = new Set<ArtifactKind>(["image", "audio", "video", "document", "archive", "other"]);
|
|
84
|
+
export const VALID_ARTIFACT_KINDS = new Set<ArtifactKind>(["avatar", "image", "audio", "video", "document", "archive", "other"]);
|
|
85
85
|
const VALID_ARTIFACT_VISIBILITIES = new Set<ArtifactVisibility>(["private", "project", "org"]);
|
|
86
86
|
export const VALID_ARTIFACT_SENSITIVITIES = new Set<ArtifactSensitivity>(["public", "normal", "sensitive", "secret"]);
|
|
87
|
-
const VALID_ARTIFACT_LINK_ENTITY_TYPES = new Set<ArtifactLink["entityType"]>(["message", "task", "recipeRun", "recipeStep", "channelEvent"]);
|
|
87
|
+
const VALID_ARTIFACT_LINK_ENTITY_TYPES = new Set<ArtifactLink["entityType"]>(["message", "task", "recipeRun", "recipeStep", "channelEvent", "user"]);
|
|
88
88
|
export const VALID_ARTIFACT_ROLES = new Set<NonNullable<ArtifactLink["role"]>>(["media", "patch", "report", "log", "output", "input"]);
|
|
89
89
|
|
|
90
90
|
export function artifactId(): string {
|
|
@@ -339,4 +339,3 @@ export function linkAttachmentRefs(entityType: ArtifactLink["entityType"], entit
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
// --- Messages ---
|
|
342
|
-
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Agent drive lease — the soft-lock behind "X is driving this agent" (#388 users wave 5, #394).
|
|
2
|
+
//
|
|
3
|
+
// More than one human can now target the same agent (#392 ownership lets two operators share a
|
|
4
|
+
// worker). This is the per-agent, single-writer hold that keeps two drivers from corrupting one
|
|
5
|
+
// turn: prompt submit, interrupt, and plan-approve/answer all funnel through it so only the lease
|
|
6
|
+
// HOLDER (or an admin override) acts. It is a *leased* hold — refreshed on every drive action and
|
|
7
|
+
// expiring after `CLAIM_LEASE_MS` of silence — so a vanished driver never locks the wheel forever
|
|
8
|
+
// (the same vanish-safety the message claim lease gives, reused here rather than re-invented).
|
|
9
|
+
//
|
|
10
|
+
// This module owns PERSISTENCE only. The arbitration decision (single-user no-op, holder/refresh,
|
|
11
|
+
// admin override, blocked) lives in `src/agent-drive-arbiter.ts`, the one home both the drive
|
|
12
|
+
// routes and the explicit handoff endpoint call.
|
|
13
|
+
//
|
|
14
|
+
// Back-compat: the arbiter never even calls acquire while the relay is single-user, so this table
|
|
15
|
+
// stays empty and the solo operator can never be stranded out of their own agent by an expiry.
|
|
16
|
+
import { CLAIM_LEASE_MS } from "../config";
|
|
17
|
+
import { getDb } from "./connection.ts";
|
|
18
|
+
|
|
19
|
+
export interface DriveLeaseRecord {
|
|
20
|
+
agentId: string;
|
|
21
|
+
/** The relational `users.id` currently holding the wheel. */
|
|
22
|
+
holderUserId: string;
|
|
23
|
+
acquiredAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DriveLeaseRow {
|
|
29
|
+
agent_id: string;
|
|
30
|
+
holder_user_id: string;
|
|
31
|
+
acquired_at: number;
|
|
32
|
+
updated_at: number;
|
|
33
|
+
expires_at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rowToDriveLease(row: DriveLeaseRow): DriveLeaseRecord {
|
|
37
|
+
return {
|
|
38
|
+
agentId: row.agent_id,
|
|
39
|
+
holderUserId: row.holder_user_id,
|
|
40
|
+
acquiredAt: row.acquired_at,
|
|
41
|
+
updatedAt: row.updated_at,
|
|
42
|
+
expiresAt: row.expires_at,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Single DDL home for the `agent_drive_leases` table (#394). Called from applyMigrations after the
|
|
48
|
+
* `agents` table exists. ON DELETE CASCADE drops the lease when its agent is removed, so a hold
|
|
49
|
+
* never outlives the agent (no need to release on shutdown).
|
|
50
|
+
*/
|
|
51
|
+
export function ensureDriveLeaseSchema(): void {
|
|
52
|
+
getDb().run(`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS agent_drive_leases (
|
|
54
|
+
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
55
|
+
holder_user_id TEXT NOT NULL,
|
|
56
|
+
acquired_at INTEGER NOT NULL,
|
|
57
|
+
updated_at INTEGER NOT NULL,
|
|
58
|
+
expires_at INTEGER NOT NULL
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The LIVE lease for an agent, or null. Expired rows read as null (lazy expiry — a driver who
|
|
64
|
+
* went silent past the lease has already let go of the wheel, the next acquire overwrites). */
|
|
65
|
+
export function getDriveLease(agentId: string, now: number = Date.now()): DriveLeaseRecord | null {
|
|
66
|
+
const row = getDb()
|
|
67
|
+
.query("SELECT * FROM agent_drive_leases WHERE agent_id = ? AND expires_at > ?")
|
|
68
|
+
.get(agentId, now) as DriveLeaseRow | null;
|
|
69
|
+
return row ? rowToDriveLease(row) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** All live drive leases (for status/observability). */
|
|
73
|
+
export function listDriveLeases(now: number = Date.now()): DriveLeaseRecord[] {
|
|
74
|
+
return (getDb()
|
|
75
|
+
.query("SELECT * FROM agent_drive_leases WHERE expires_at > ? ORDER BY acquired_at DESC")
|
|
76
|
+
.all(now) as DriveLeaseRow[]).map(rowToDriveLease);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Atomically take or refresh the wheel. Wins iff the wheel is free/expired OR already held by this
|
|
81
|
+
* same user (a refresh, preserving the original `acquired_at`). Loses to a live lease held by
|
|
82
|
+
* someone else — returned so the caller can say who is driving. Serialized in a transaction so two
|
|
83
|
+
* concurrent first-drivers can't both win (the concurrent-drive race).
|
|
84
|
+
*/
|
|
85
|
+
export function acquireDriveLease(input: {
|
|
86
|
+
agentId: string;
|
|
87
|
+
userId: string;
|
|
88
|
+
ttlMs?: number;
|
|
89
|
+
now?: number;
|
|
90
|
+
}): { ok: true; lease: DriveLeaseRecord } | { ok: false; lease: DriveLeaseRecord } {
|
|
91
|
+
return getDb().transaction(() => {
|
|
92
|
+
const now = input.now ?? Date.now();
|
|
93
|
+
const existing = getDriveLease(input.agentId, now);
|
|
94
|
+
if (existing && existing.holderUserId !== input.userId) return { ok: false as const, lease: existing };
|
|
95
|
+
return { ok: true as const, lease: writeLease(input.agentId, input.userId, existing, now, input.ttlMs) };
|
|
96
|
+
})();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Force-grab the wheel regardless of the current holder — the admin override / explicit take-over.
|
|
101
|
+
* Always succeeds; returns the displaced holder (if any) so the caller can make the handoff
|
|
102
|
+
* visible. A self take-over is just a refresh (keeps `acquired_at`).
|
|
103
|
+
*/
|
|
104
|
+
export function takeOverDriveLease(input: {
|
|
105
|
+
agentId: string;
|
|
106
|
+
userId: string;
|
|
107
|
+
ttlMs?: number;
|
|
108
|
+
now?: number;
|
|
109
|
+
}): { lease: DriveLeaseRecord; previousHolderUserId?: string } {
|
|
110
|
+
return getDb().transaction(() => {
|
|
111
|
+
const now = input.now ?? Date.now();
|
|
112
|
+
const existing = getDriveLease(input.agentId, now);
|
|
113
|
+
const previousHolderUserId = existing && existing.holderUserId !== input.userId ? existing.holderUserId : undefined;
|
|
114
|
+
const lease = writeLease(input.agentId, input.userId, existing, now, input.ttlMs);
|
|
115
|
+
return previousHolderUserId ? { lease, previousHolderUserId } : { lease };
|
|
116
|
+
})();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Shared upsert. A same-holder write refreshes the lease while preserving the first `acquired_at`. */
|
|
120
|
+
function writeLease(
|
|
121
|
+
agentId: string,
|
|
122
|
+
userId: string,
|
|
123
|
+
existing: DriveLeaseRecord | null,
|
|
124
|
+
now: number,
|
|
125
|
+
ttlMs?: number,
|
|
126
|
+
): DriveLeaseRecord {
|
|
127
|
+
const acquiredAt = existing?.holderUserId === userId ? existing.acquiredAt : now;
|
|
128
|
+
const expiresAt = now + (ttlMs ?? CLAIM_LEASE_MS);
|
|
129
|
+
getDb()
|
|
130
|
+
.query(
|
|
131
|
+
`INSERT INTO agent_drive_leases (agent_id, holder_user_id, acquired_at, updated_at, expires_at)
|
|
132
|
+
VALUES (?, ?, ?, ?, ?)
|
|
133
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
134
|
+
holder_user_id = excluded.holder_user_id,
|
|
135
|
+
acquired_at = excluded.acquired_at,
|
|
136
|
+
updated_at = excluded.updated_at,
|
|
137
|
+
expires_at = excluded.expires_at`,
|
|
138
|
+
)
|
|
139
|
+
.run(agentId, userId, acquiredAt, now, expiresAt);
|
|
140
|
+
return getDriveLease(agentId, now)!;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Explicit release ("let go of the wheel"). Guarded by `userId` so a stale release can't drop a
|
|
145
|
+
* newer holder's lease — only the current holder clears their own. Pass no `userId` to force-clear
|
|
146
|
+
* (admin reset). Returns whether a row was removed.
|
|
147
|
+
*/
|
|
148
|
+
export function releaseDriveLease(opts: { agentId: string; userId?: string }): boolean {
|
|
149
|
+
if (opts.userId) {
|
|
150
|
+
return getDb()
|
|
151
|
+
.query("DELETE FROM agent_drive_leases WHERE agent_id = ? AND holder_user_id = ?")
|
|
152
|
+
.run(opts.agentId, opts.userId).changes > 0;
|
|
153
|
+
}
|
|
154
|
+
return getDb().query("DELETE FROM agent_drive_leases WHERE agent_id = ?").run(opts.agentId).changes > 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Sweep expired leases (maintenance tidy-up; correctness already comes from lazy expiry). */
|
|
158
|
+
export function releaseExpiredDriveLeases(now: number = Date.now()): string[] {
|
|
159
|
+
const expired = getDb()
|
|
160
|
+
.query("SELECT agent_id FROM agent_drive_leases WHERE expires_at <= ?")
|
|
161
|
+
.all(now) as Array<{ agent_id: string }>;
|
|
162
|
+
if (!expired.length) return [];
|
|
163
|
+
getDb().query("DELETE FROM agent_drive_leases WHERE expires_at <= ?").run(now);
|
|
164
|
+
return expired.map((r) => r.agent_id);
|
|
165
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./message-reads.ts";
|
|
|
18
18
|
export * from "./inbox.ts";
|
|
19
19
|
export * from "./users.ts";
|
|
20
20
|
export * from "./agent-ownership.ts";
|
|
21
|
+
export * from "./drive-leases.ts";
|
|
21
22
|
export * from "./activity.ts";
|
|
22
23
|
export * from "./stats.ts";
|
|
23
24
|
export * from "./orchestrators.ts";
|
package/src/db/migrations.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { matchAgents } from "../agent-ref";
|
|
|
17
17
|
import { getDb } from "./connection.ts";
|
|
18
18
|
import { ensureUsersSchema, seedDefaultUser } from "./users.ts";
|
|
19
19
|
import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-ownership.ts";
|
|
20
|
+
import { ensureDriveLeaseSchema } from "./drive-leases.ts";
|
|
20
21
|
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
21
22
|
import { normalizeReactionEmoji } from "./mappers.ts";
|
|
22
23
|
import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
|
|
@@ -804,6 +805,11 @@ export function applyMigrations(): void {
|
|
|
804
805
|
// agents are in, so they can be excluded from ownership.
|
|
805
806
|
ensureAgentOwnershipSchema();
|
|
806
807
|
|
|
808
|
+
// Per-agent drive lease — the "X is driving this agent" soft-lock (#394). FK-references agents,
|
|
809
|
+
// so it ensures after the agents table exists. Empty in a single-user world (the arbiter never
|
|
810
|
+
// writes it), so it adds nothing to the legacy flow.
|
|
811
|
+
ensureDriveLeaseSchema();
|
|
812
|
+
|
|
807
813
|
// Built-in agents — registered unconditionally so sends from these ids
|
|
808
814
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
809
815
|
// meta.builtin (or by id for "user").
|
package/src/db/users.ts
CHANGED
|
@@ -51,6 +51,7 @@ function rowToUser(row: UserRow): User {
|
|
|
51
51
|
status: row.status as UserStatus,
|
|
52
52
|
tenantId: row.tenant_id,
|
|
53
53
|
avatarArtifactId: row.avatar_artifact_id,
|
|
54
|
+
avatarUrl: row.avatar_artifact_id ? `/users/${encodeURIComponent(row.id)}/avatar` : null,
|
|
54
55
|
createdAt: row.created_at,
|
|
55
56
|
updatedAt: row.updated_at,
|
|
56
57
|
};
|
|
@@ -98,6 +99,31 @@ export function createUser(input: CreateUserInput): User {
|
|
|
98
99
|
return created;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
export function updateUserProfile(input: {
|
|
103
|
+
id: string;
|
|
104
|
+
displayName?: string;
|
|
105
|
+
avatarArtifactId?: string | null;
|
|
106
|
+
}): User {
|
|
107
|
+
const current = getUser(input.id);
|
|
108
|
+
if (!current) throw new ValidationError("user not found");
|
|
109
|
+
const updates: string[] = [];
|
|
110
|
+
const params: Array<string | number | null> = [];
|
|
111
|
+
if (input.displayName !== undefined) {
|
|
112
|
+
if (!input.displayName.trim()) throw new ValidationError("displayName required");
|
|
113
|
+
updates.push("display_name = ?");
|
|
114
|
+
params.push(input.displayName.trim());
|
|
115
|
+
}
|
|
116
|
+
if (input.avatarArtifactId !== undefined) {
|
|
117
|
+
updates.push("avatar_artifact_id = ?");
|
|
118
|
+
params.push(input.avatarArtifactId);
|
|
119
|
+
}
|
|
120
|
+
if (!updates.length) return current;
|
|
121
|
+
updates.push("updated_at = ?");
|
|
122
|
+
params.push(Date.now(), input.id);
|
|
123
|
+
getDb().query(`UPDATE users SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
124
|
+
return getUser(input.id)!;
|
|
125
|
+
}
|
|
126
|
+
|
|
101
127
|
/**
|
|
102
128
|
* Seed the admin-level default user mapped to the existing single human (#393 P0).
|
|
103
129
|
* Idempotent (ON CONFLICT DO NOTHING) so it runs safely on every boot/migration — the
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
reapStaleAgents,
|
|
14
14
|
reapStaleOrchestrators,
|
|
15
15
|
releaseExpiredClaims,
|
|
16
|
+
releaseExpiredDriveLeases,
|
|
16
17
|
releaseOrphanedTasks,
|
|
17
18
|
runDbMaintenance,
|
|
18
19
|
sweepArtifacts,
|
|
@@ -87,6 +88,16 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
87
88
|
},
|
|
88
89
|
},
|
|
89
90
|
MERGE_PAUSE_MAINTENANCE_JOB,
|
|
91
|
+
{
|
|
92
|
+
id: "drive-lease-expiration",
|
|
93
|
+
title: "Drive lease expiration",
|
|
94
|
+
description: "Release expired \"X is driving this agent\" soft-locks (#394).",
|
|
95
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
96
|
+
runOnStart: true,
|
|
97
|
+
handler() {
|
|
98
|
+
return { releasedDriveLeaseAgentIds: releaseExpiredDriveLeases() };
|
|
99
|
+
},
|
|
100
|
+
},
|
|
90
101
|
{
|
|
91
102
|
id: "task-orphan-release",
|
|
92
103
|
title: "Orphaned task release",
|
package/src/routes/_shared.ts
CHANGED
|
@@ -205,7 +205,7 @@ export const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact
|
|
|
205
205
|
|
|
206
206
|
export const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
|
|
207
207
|
|
|
208
|
-
export const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
|
|
208
|
+
export const VALID_ARTIFACT_KINDS = ["avatar", "image", "audio", "video", "document", "archive", "other"] as const;
|
|
209
209
|
|
|
210
210
|
export const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
211
211
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: agent-sessions.
|
|
2
2
|
import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
|
|
3
|
-
import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
|
|
3
|
+
import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
|
|
4
|
+
import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
|
|
4
5
|
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
|
|
5
6
|
import { announcePresence, viewersForAgent } from "../dashboard-presence";
|
|
6
7
|
import { buildManagedSpawnParams } from "../managed-policy";
|
|
@@ -19,6 +20,12 @@ import { relayToken } from "../config";
|
|
|
19
20
|
|
|
20
21
|
const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
22
|
|
|
23
|
+
// 409 when a second human tries to drive an agent another user is already driving (#394). The
|
|
24
|
+
// blocked driver learns who holds the wheel so the UI can show "X is driving" + offer "request".
|
|
25
|
+
function driveBlockedResponse(arb: Extract<DriveArbitration, { allowed: false }>): Response {
|
|
26
|
+
return json({ error: drivingMessage(arb.heldBy), code: "agent-being-driven", heldBy: arb.heldBy }, 409);
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
function metaStringArray(meta: Record<string, unknown> | undefined, key: string): string[] {
|
|
23
30
|
const value = meta?.[key];
|
|
24
31
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
|
@@ -214,6 +221,13 @@ export const postAgentAction: Handler = async (req, params) => {
|
|
|
214
221
|
const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
|
|
215
222
|
if (driveDenied) return driveDenied;
|
|
216
223
|
if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
|
|
224
|
+
// #394: interrupt is a turn-drive action — only the lease holder (or an admin override) may cut
|
|
225
|
+
// the active turn. Lifecycle actions (restart/shutdown/compact/clearContext/resume) stay on the
|
|
226
|
+
// #392 authz gate only; they manage the agent rather than driving its turn.
|
|
227
|
+
if (action === "interrupt") {
|
|
228
|
+
const arb = arbitrateDrive(requestUserId(req), agent.id);
|
|
229
|
+
if (!arb.allowed) return driveBlockedResponse(arb);
|
|
230
|
+
}
|
|
217
231
|
|
|
218
232
|
// Shutdown converges on the shutdownAgent service (epic #342, #347): one #221
|
|
219
233
|
// parent→child gate + canonical orchestrator resolution + payload, shared with the
|
|
@@ -319,6 +333,11 @@ export const postAgentPrompt: Handler = async (req, params) => {
|
|
|
319
333
|
// #392: only a user authorized to drive this agent may prompt it (fail-open single-user).
|
|
320
334
|
const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
|
|
321
335
|
if (driveDenied) return driveDenied;
|
|
336
|
+
// #394: submitting a prompt takes/refreshes the drive lease. A second human driving the same
|
|
337
|
+
// agent is blocked here (single-user is a no-op), so two operators can't double-submit into one
|
|
338
|
+
// turn. The holder's own re-submit just refreshes the lease — busy-state handling is unchanged.
|
|
339
|
+
const arb = arbitrateDrive(requestUserId(req), agent.id);
|
|
340
|
+
if (!arb.allowed) return driveBlockedResponse(arb);
|
|
322
341
|
if (agent.status === "offline") return error("agent is offline", 422);
|
|
323
342
|
const denied = authorizeRoute(req, {
|
|
324
343
|
scope: "message:send",
|
|
@@ -388,6 +407,10 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
|
|
|
388
407
|
// #392: approving/denying a permission is driving the agent (fail-open single-user).
|
|
389
408
|
const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
|
|
390
409
|
if (driveDenied) return driveDenied;
|
|
410
|
+
// #394: a plan-approve / AskUserQuestion answer drives the turn — only the lease holder (or an
|
|
411
|
+
// admin override) may answer, so two humans can't race competing decisions into one prompt.
|
|
412
|
+
const arb = arbitrateDrive(requestUserId(req), agent.id);
|
|
413
|
+
if (!arb.allowed) return driveBlockedResponse(arb);
|
|
391
414
|
if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
|
|
392
415
|
const pendingApproval = isRecord(agent.meta?.providerState) && isRecord(agent.meta.providerState.pendingApproval)
|
|
393
416
|
? agent.meta.providerState.pendingApproval
|
|
@@ -470,6 +493,79 @@ export const getAgentPresence: Handler = (_req, params) => {
|
|
|
470
493
|
return json({ agentId: agent.id, viewers: viewersForAgent(agent.id) });
|
|
471
494
|
};
|
|
472
495
|
|
|
496
|
+
// Drive lease status (#394): who currently holds the wheel on this agent. `driver: null` means the
|
|
497
|
+
// wheel is free, or the relay is single-user (the lone operator implicitly always holds it).
|
|
498
|
+
export const getAgentDrive: Handler = (req, params) => {
|
|
499
|
+
const agent = getAgent(params.id!);
|
|
500
|
+
if (!agent) return error("agent not found", 404);
|
|
501
|
+
if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
|
|
502
|
+
const driver = currentDriver(agent.id);
|
|
503
|
+
const me = requestUserId(req);
|
|
504
|
+
return json({ agentId: agent.id, driver, isHolder: driver ? driver.userId === me : true });
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// Explicit, visible handoff of the wheel (#394): `takeover` (take/refresh the lease — admins steal
|
|
508
|
+
// a held one, others succeed only when it is free), `release` (let go), `request` (ask the current
|
|
509
|
+
// holder to release). Single-user is a no-op throughout, so the solo operator is never affected.
|
|
510
|
+
export const postAgentDrive: Handler = async (req, params) => {
|
|
511
|
+
const parsed = await parseBody<unknown>(req);
|
|
512
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
513
|
+
try {
|
|
514
|
+
if (!isRecord(parsed.body)) return error("action required");
|
|
515
|
+
const action = optionalEnum(parsed.body.action, "action", ["takeover", "release", "request"] as const);
|
|
516
|
+
if (!action) return error("action required");
|
|
517
|
+
const agent = getAgent(params.id!);
|
|
518
|
+
if (!agent) return error("agent not found", 404);
|
|
519
|
+
// Driving the wheel requires the same #392 drive authorization the prompt/interrupt routes use.
|
|
520
|
+
const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
|
|
521
|
+
if (driveDenied) return driveDenied;
|
|
522
|
+
const userId = requestUserId(req);
|
|
523
|
+
|
|
524
|
+
if (action === "release") {
|
|
525
|
+
const result = releaseDrive(userId, agent.id);
|
|
526
|
+
auditDriveEvent(req, agent.id, "release", { released: result.released });
|
|
527
|
+
return json({ ok: true, action, released: result.released, driver: currentDriver(agent.id) }, 200);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (action === "request") {
|
|
531
|
+
const holder = currentDriver(agent.id);
|
|
532
|
+
auditDriveEvent(req, agent.id, "request", { heldBy: holder?.address });
|
|
533
|
+
return json({ ok: true, action, requested: true, driver: holder }, 202);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// takeover
|
|
537
|
+
const arb = arbitrateDrive(userId, agent.id);
|
|
538
|
+
if (!arb.allowed) return driveBlockedResponse(arb);
|
|
539
|
+
auditDriveEvent(req, agent.id, arb.mode === "override" ? "override" : "takeover", {
|
|
540
|
+
...(arb.previousHolder ? { from: arb.previousHolder.address } : {}),
|
|
541
|
+
});
|
|
542
|
+
return json({ ok: true, action, mode: arb.mode, driver: currentDriver(agent.id) }, 200);
|
|
543
|
+
} catch (e) {
|
|
544
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
545
|
+
throw e;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
function auditDriveEvent(req: Request, agentId: string, kind: "takeover" | "override" | "release" | "request", extra: Record<string, unknown>): void {
|
|
550
|
+
const titles: Record<typeof kind, string> = {
|
|
551
|
+
takeover: "Agent wheel taken",
|
|
552
|
+
override: "Agent wheel overridden (admin)",
|
|
553
|
+
release: "Agent wheel released",
|
|
554
|
+
request: "Agent wheel requested",
|
|
555
|
+
};
|
|
556
|
+
auditEvent({
|
|
557
|
+
clientId: `server-agent-${agentId}-drive-${kind}-${Date.now()}`,
|
|
558
|
+
kind: "state",
|
|
559
|
+
title: titles[kind],
|
|
560
|
+
body: kind,
|
|
561
|
+
meta: agentId,
|
|
562
|
+
icon: kind === "release" ? "ti-steering-wheel-off" : "ti-steering-wheel",
|
|
563
|
+
view: "chat",
|
|
564
|
+
agentId,
|
|
565
|
+
metadata: { drive: kind, requestedByUser: requestUserAddress(req), ...extra, ...authAuditMetadata(req) },
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
473
569
|
export const postAgentTerminalSession: Handler = async (req, params) => {
|
|
474
570
|
try {
|
|
475
571
|
const agent = getAgent(params.id!);
|
package/src/routes/artifacts.ts
CHANGED
|
@@ -18,7 +18,7 @@ function artifactActor(req: Request): string {
|
|
|
18
18
|
return "server";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
|
|
21
|
+
export function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
|
|
22
22
|
const lowerName = filename?.toLowerCase() ?? "";
|
|
23
23
|
if (mediaType.startsWith("image/")) return "image";
|
|
24
24
|
if (mediaType.startsWith("audio/")) return "audio";
|
|
@@ -32,10 +32,14 @@ function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
|
|
|
32
32
|
return "other";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string): string {
|
|
35
|
+
export function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string): string {
|
|
36
36
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
37
37
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
|
|
38
38
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
|
|
39
|
+
if (
|
|
40
|
+
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
41
|
+
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
|
42
|
+
) return "image/webp";
|
|
39
43
|
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return "application/pdf";
|
|
40
44
|
if (bytes[0] === 0x50 && bytes[1] === 0x4b) return "application/zip";
|
|
41
45
|
const lower = filename?.toLowerCase() ?? "";
|
|
@@ -52,7 +56,7 @@ function safeDispositionFilename(filename: string | undefined): string {
|
|
|
52
56
|
return cleaned || "artifact";
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
function artifactContentDisposition(filename: string | undefined, mediaType: string): string {
|
|
59
|
+
export function artifactContentDisposition(filename: string | undefined, mediaType: string): string {
|
|
56
60
|
const disposition = mediaType.startsWith("image/") && mediaType !== "image/svg+xml" ? "inline" : "attachment";
|
|
57
61
|
return `${disposition}; filename="${safeDispositionFilename(filename)}"`;
|
|
58
62
|
}
|
package/src/routes/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
|
|
2
2
|
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
3
3
|
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
4
|
-
import { deleteAgentTerminalSession, getAgentPresence, postAgentAction, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
|
|
5
|
-
import { getUsers } from "./users";
|
|
4
|
+
import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
|
|
5
|
+
import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
|
|
6
6
|
import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
|
|
7
7
|
import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
|
|
8
8
|
import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
|
|
@@ -87,6 +87,15 @@ const routes: Route[] = [
|
|
|
87
87
|
route("DELETE", "/api/memories/:id", deleteMemoryRoute),
|
|
88
88
|
|
|
89
89
|
route("GET", "/api/users", getUsers),
|
|
90
|
+
route("GET", "/api/users/me", getMe),
|
|
91
|
+
route("PATCH", "/api/users/me", patchMe),
|
|
92
|
+
route("POST", "/api/users/me/avatar", postMyAvatar),
|
|
93
|
+
route("DELETE", "/api/users/me/avatar", deleteMyAvatar),
|
|
94
|
+
route("GET", "/api/users/:id/avatar", getUserAvatar),
|
|
95
|
+
route("HEAD", "/api/users/:id/avatar", headUserAvatar),
|
|
96
|
+
route("PATCH", "/api/users/:id", patchUserById),
|
|
97
|
+
route("POST", "/api/users/:id/avatar", postUserAvatarById),
|
|
98
|
+
route("DELETE", "/api/users/:id/avatar", deleteUserAvatarById),
|
|
90
99
|
|
|
91
100
|
route("POST", "/api/agents", postAgent),
|
|
92
101
|
route("POST", "/api/agents/spawn", postAgentSpawn),
|
|
@@ -116,6 +125,8 @@ const routes: Route[] = [
|
|
|
116
125
|
route("POST", "/api/agents/:id/permission-decision", postAgentPermissionDecision),
|
|
117
126
|
route("GET", "/api/agents/:id/presence", getAgentPresence),
|
|
118
127
|
route("POST", "/api/agents/:id/presence", postAgentPresence),
|
|
128
|
+
route("GET", "/api/agents/:id/drive", getAgentDrive),
|
|
129
|
+
route("POST", "/api/agents/:id/drive", postAgentDrive),
|
|
119
130
|
route("POST", "/api/agents/:id/terminal-session", postAgentTerminalSession),
|
|
120
131
|
route("DELETE", "/api/agents/:id/terminal-session/:session", deleteAgentTerminalSession),
|
|
121
132
|
route("DELETE", "/api/agents/:id", deleteAgentById),
|