@huynle/hub 0.7.4 → 0.8.0
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/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// CON-06 approval tiers (contract "Approval tiers (CON-06)"): the tiers slot of the Concierge modules (index.ts).
|
|
2
|
+
//
|
|
3
|
+
// | Tier | When | How |
|
|
4
|
+
// | auto | inside the @@concierge grant (the acting engine allows) | it acts; audit as: "concierge" (audit.ts) |
|
|
5
|
+
// | confirm | escalations whose riskTier is confirm; stop/deny proposals | tap, or voice/text in the Concierge panel |
|
|
6
|
+
// | tap | riskTier tap; prompt/answer proposals | console cookie only, never voice |
|
|
7
|
+
//
|
|
8
|
+
// Routes (Concierge-only authority: the user's CURRENT Concierge session through its act token; every other agent
|
|
9
|
+
// credential, app tokens included, is 404; the user is 403 where the route is the Concierge's own):
|
|
10
|
+
//
|
|
11
|
+
// POST /api/agent/concierge/proposals {verb, session, input?, why?, requestID?|formID?} → 201 {decision} (proposals.ts)
|
|
12
|
+
// POST /api/agent/concierge/arm {decisionID | null} the Concierge → {armed | null}
|
|
13
|
+
// GET /api/agent/concierge/arm user; the Concierge → {armed | null}
|
|
14
|
+
// GET /api/agent/concierge/sessions/:addr/forms/:form user; the Concierge → {form: PendingForm} (CON-13)
|
|
15
|
+
//
|
|
16
|
+
// (CON-13) The form read is a pending form's SCHEMA only (title, field keys, types, option values and labels,
|
|
17
|
+
// required, limits; acting.ts form()): never the session's other text. It is checked like the answer verb short of
|
|
18
|
+
// the grant (kill switch, tenancy, the Concierge guard, pending, self-tree), so the Concierge can answer or propose
|
|
19
|
+
// an answer that fits; an answer that does not is 400 with the field's problem (form-check.ts).
|
|
20
|
+
//
|
|
21
|
+
// Arming marks one OPEN CONFIRM Decision of the user as the one under discussion (one per user, replaced by the
|
|
22
|
+
// next, ARM_TTL_MS; tap → 400 invalid; not open → 410 gone; unknown → 404). It is durable (arm.ts) and goes to the
|
|
23
|
+
// console as SSE {type: "concierge", armed}. An answered/closed/expired Decision is disarmed (decisionChanged). When
|
|
24
|
+
// an arm lapses (ARM_TTL_MS) the pod that armed it says so: SSE {type: "concierge", armed: null} (lapse watch below).
|
|
25
|
+
// The board marks the live armed Decision (`armedID`, ConciergeBoardDecision.armed).
|
|
26
|
+
//
|
|
27
|
+
// via (checkVia, Decisions' via hook): a voice/text answer is admitted only when the Decision is confirm tier, open
|
|
28
|
+
// and armed by the CURRENT Concierge session, and the answering device is the user's and has presence on that
|
|
29
|
+
// session (within PRESENCE_WINDOW_MS; the registry's presence, set by POST /api/sessions/:id/presence). Anything
|
|
30
|
+
// else (and a failed read) → 403 "Answer this decision with a tap". The model never answers: the route is the
|
|
31
|
+
// console's (devices only), and matching speech to an option happens on the device.
|
|
32
|
+
import type { ConciergeArmed } from "../../../packages/protocol/src/concierge"
|
|
33
|
+
import type { Counters, StateStore } from "../backend/types"
|
|
34
|
+
import type { Acting } from "../agent/acting"
|
|
35
|
+
import { type AgentActor, canActAsSession, type UserActor } from "../agent/actor"
|
|
36
|
+
import type { AgentAudit } from "../agent/audit"
|
|
37
|
+
import { boundedJson } from "../agent/body"
|
|
38
|
+
import { type ActOutcome, type Decision, type Decisions, TAP_ONLY } from "../agent/decisions"
|
|
39
|
+
import type { Party } from "../agent/envelope"
|
|
40
|
+
import { AgentError } from "../agent/errors"
|
|
41
|
+
import { RouteIdempotency } from "../agent/idempotency"
|
|
42
|
+
import type { Message } from "../agent/messages"
|
|
43
|
+
import type { AgentRoute } from "../agent/routes"
|
|
44
|
+
import { ArmStore, type ArmedDecision } from "./arm"
|
|
45
|
+
import type { ConciergePorts, ConciergeTiers } from "./index"
|
|
46
|
+
import { parseProposal, Proposals } from "./proposals"
|
|
47
|
+
|
|
48
|
+
const PATH = "/api/agent/concierge"
|
|
49
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
50
|
+
const IDEMPOTENCY_COLLECTION = "concierge-proposal-keys"
|
|
51
|
+
/** How often a live arm's lapse watch looks at the clock (it reads the store only once the arm is due). */
|
|
52
|
+
const LAPSE_POLL_MS = 1_000
|
|
53
|
+
|
|
54
|
+
/** What the tiers need from the hub besides the Concierge ports (http.ts fills it). */
|
|
55
|
+
export interface TierPorts {
|
|
56
|
+
readonly state: StateStore
|
|
57
|
+
readonly counters: Pick<Counters, "incr" | "get">
|
|
58
|
+
readonly decisions: Pick<Decisions, "ask" | "list" | "get">
|
|
59
|
+
readonly acting: Pick<Acting, "preflight" | "stop" | "deny" | "prompt" | "answer" | "form">
|
|
60
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
61
|
+
readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
|
|
62
|
+
readonly published?: (userID: string, message: Message) => void
|
|
63
|
+
readonly describe?: (userID: string, actor: AgentActor) => Party
|
|
64
|
+
/** Whether the device reported the session visible within the presence window (any pod). */
|
|
65
|
+
readonly present: (deviceID: string, sessionID: string) => boolean
|
|
66
|
+
/** The device's owner (undefined: no such device). */
|
|
67
|
+
readonly deviceOwner: (deviceID: string) => string | undefined
|
|
68
|
+
/** The console's live event {type: "concierge", armed}. Never throws through. */
|
|
69
|
+
readonly armed: (userID: string, armed: ArmedView | null) => void
|
|
70
|
+
readonly now?: () => number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What clients see of the armed Decision. */
|
|
74
|
+
export type ArmedView = ConciergeArmed
|
|
75
|
+
|
|
76
|
+
const view = (armed: ArmedDecision | undefined): ArmedView | null => (armed ? { decisionID: armed.decisionID, at: armed.at, expiresAt: armed.expiresAt } : null)
|
|
77
|
+
const notFound = () => new AgentError("not_found", "Not found")
|
|
78
|
+
const tapOnly = () => new AgentError("forbidden", TAP_ONLY, { hint: "Tap an option" })
|
|
79
|
+
|
|
80
|
+
/** The tiers module for the Concierge ports; without `ports.tiers` it is the no-op (no routes). */
|
|
81
|
+
export function conciergeTiers(ports: ConciergePorts): ConciergeTiers {
|
|
82
|
+
const hub = ports.tiers
|
|
83
|
+
if (!hub) return { routes: () => [] }
|
|
84
|
+
const { identity } = ports
|
|
85
|
+
const arms = new ArmStore({ state: hub.state, ...(hub.now ? { now: hub.now } : {}) })
|
|
86
|
+
const proposals = new Proposals({
|
|
87
|
+
decisions: hub.decisions,
|
|
88
|
+
acting: hub.acting,
|
|
89
|
+
counters: hub.counters,
|
|
90
|
+
...(hub.audit ? { audit: hub.audit } : {}),
|
|
91
|
+
...(hub.protects ? { protects: hub.protects } : {}),
|
|
92
|
+
...(hub.published ? { published: hub.published } : {}),
|
|
93
|
+
...(hub.describe ? { describe: hub.describe } : {}),
|
|
94
|
+
})
|
|
95
|
+
const idempotency = new RouteIdempotency({ state: hub.state, collection: IDEMPOTENCY_COLLECTION, ...(hub.now ? { now: hub.now } : {}) })
|
|
96
|
+
const tell = (userID: string, armed: ArmedView | null) => {
|
|
97
|
+
try {
|
|
98
|
+
hub.armed(userID, armed)
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
console.error(`[hub] concierge armed event failed: ${(cause as Error).message}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const clock = hub.now ?? Date.now
|
|
104
|
+
|
|
105
|
+
// Lapse watch: one timer per user for the arm this pod made. It checks the clock every LAPSE_POLL_MS and, once the
|
|
106
|
+
// arm is due, reads the record: replaced by a newer arm (another pod's, or this pod's own watch) → nothing; still
|
|
107
|
+
// live (clock skew) → keep watching; gone or expired → SSE armed: null (harmless if a disarm already said so).
|
|
108
|
+
const lapses = new Map<string, ReturnType<typeof setTimeout>>()
|
|
109
|
+
const unwatch = (userID: string) => {
|
|
110
|
+
const timer = lapses.get(userID)
|
|
111
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
112
|
+
lapses.delete(userID)
|
|
113
|
+
}
|
|
114
|
+
const watchLapse = (userID: string, armed: ArmedDecision) => {
|
|
115
|
+
unwatch(userID)
|
|
116
|
+
const schedule = () => {
|
|
117
|
+
const timer = setTimeout(check, Math.min(LAPSE_POLL_MS, Math.max(0, armed.expiresAt - clock()) + 5))
|
|
118
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
119
|
+
lapses.set(userID, timer)
|
|
120
|
+
}
|
|
121
|
+
const check = () => {
|
|
122
|
+
if (clock() < armed.expiresAt) return schedule()
|
|
123
|
+
lapses.delete(userID)
|
|
124
|
+
void arms
|
|
125
|
+
.current(userID)
|
|
126
|
+
.then((current) => {
|
|
127
|
+
if (current && (current.at !== armed.at || current.decisionID !== armed.decisionID)) return
|
|
128
|
+
if (current) {
|
|
129
|
+
if (!lapses.has(userID)) schedule()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
tell(userID, null)
|
|
133
|
+
})
|
|
134
|
+
.catch((cause: unknown) => console.error(`[hub] concierge arm lapse check failed: ${(cause as Error).message}`))
|
|
135
|
+
}
|
|
136
|
+
schedule()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The user's current Concierge session through its act token; else 404 (the user: 403 when `userForbidden`). */
|
|
140
|
+
const conciergeOnly = async (actor: AgentActor, userID: string, userForbidden?: string): Promise<string> => {
|
|
141
|
+
if (actor.kind === "user") {
|
|
142
|
+
if (userForbidden) throw new AgentError("forbidden", userForbidden)
|
|
143
|
+
return ""
|
|
144
|
+
}
|
|
145
|
+
if (actor.kind === "session" && canActAsSession(actor) && actor.userID === userID) {
|
|
146
|
+
const { sessionID } = await identity.current(userID)
|
|
147
|
+
if (sessionID !== undefined && sessionID === actor.sessionID) return sessionID
|
|
148
|
+
}
|
|
149
|
+
throw notFound()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The armed Decision as of now: open, and armed by the current Concierge session. */
|
|
153
|
+
const live = async (userID: string): Promise<ArmedDecision | undefined> => {
|
|
154
|
+
const armed = await arms.current(userID)
|
|
155
|
+
if (!armed) return undefined
|
|
156
|
+
const [{ sessionID }, decision] = await Promise.all([identity.current(userID), hub.decisions.get(userID, armed.decisionID)])
|
|
157
|
+
return sessionID === armed.sessionID && decision?.state === "open" && decision.tier === "confirm" ? armed : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const routes: AgentRoute[] = [
|
|
161
|
+
{
|
|
162
|
+
method: "POST",
|
|
163
|
+
path: `${PATH}/proposals`,
|
|
164
|
+
allow: { session: true, app: true },
|
|
165
|
+
handle: async ({ actor, request, user }) => {
|
|
166
|
+
const sessionID = await conciergeOnly(actor, user.id, "The Concierge proposes; act on the session yourself")
|
|
167
|
+
const body = await boundedJson(request)
|
|
168
|
+
// Keys are per Concierge session: a relaunched Concierge never gets an earlier session's result.
|
|
169
|
+
return idempotency.run(user.id, `POST /concierge/proposals ${sessionID}`, request.headers.get("idempotency-key"), body, async () => {
|
|
170
|
+
const decision = await proposals.propose(user.id, actor, parseProposal(body))
|
|
171
|
+
return { status: 201, body: { decision } }
|
|
172
|
+
})
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
method: "GET",
|
|
177
|
+
path: `${PATH}/sessions/:addr/forms/:form`,
|
|
178
|
+
allow: { session: true, app: true },
|
|
179
|
+
handle: async ({ actor, params, user }) => {
|
|
180
|
+
await conciergeOnly(actor, user.id)
|
|
181
|
+
return Response.json({ form: await hub.acting.form({ actor, userID: user.id }, params.addr!, params.form!) }, { headers: NO_STORE })
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
method: "GET",
|
|
186
|
+
path: `${PATH}/arm`,
|
|
187
|
+
allow: { session: true, app: true },
|
|
188
|
+
handle: async ({ actor, user }) => {
|
|
189
|
+
await conciergeOnly(actor, user.id)
|
|
190
|
+
return Response.json({ armed: view(await live(user.id)) }, { headers: NO_STORE })
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
method: "POST",
|
|
195
|
+
path: `${PATH}/arm`,
|
|
196
|
+
allow: { session: true, app: true },
|
|
197
|
+
handle: async ({ actor, request, user }) => {
|
|
198
|
+
const sessionID = await conciergeOnly(actor, user.id, "Only the Concierge arms a Decision")
|
|
199
|
+
const body = await boundedJson(request)
|
|
200
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
201
|
+
const fields = body as Record<string, unknown>
|
|
202
|
+
for (const key of Object.keys(fields)) if (key !== "decisionID") throw new AgentError("invalid", `Unknown field ${key}`)
|
|
203
|
+
const id = fields.decisionID
|
|
204
|
+
if (id === null) {
|
|
205
|
+
unwatch(user.id)
|
|
206
|
+
if (await arms.disarm(user.id)) tell(user.id, null)
|
|
207
|
+
return Response.json({ armed: null }, { headers: NO_STORE })
|
|
208
|
+
}
|
|
209
|
+
if (typeof id !== "string" || !id || id.length > 64) throw new AgentError("invalid", "decisionID is a Decision ID (or null to disarm)")
|
|
210
|
+
const decision = await hub.decisions.get(user.id, id)
|
|
211
|
+
if (!decision) throw new AgentError("not_found", "No such decision")
|
|
212
|
+
if (decision.state !== "open") throw new AgentError("gone", "The decision is already answered or expired")
|
|
213
|
+
if (decision.tier !== "confirm") throw new AgentError("invalid", "A tap-tier decision is answered with a tap in the console: it cannot be armed")
|
|
214
|
+
const record = await arms.arm(user.id, { decisionID: id, sessionID })
|
|
215
|
+
watchLapse(user.id, record)
|
|
216
|
+
const armed = view(record)
|
|
217
|
+
tell(user.id, armed)
|
|
218
|
+
return Response.json({ armed }, { headers: NO_STORE })
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
routes: () => routes,
|
|
225
|
+
act: (userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> => proposals.act(userID, decision, choice, by),
|
|
226
|
+
checkVia: async (userID: string, decision: Decision, _via: "voice" | "text", by: UserActor): Promise<void> => {
|
|
227
|
+
let ok = false
|
|
228
|
+
try {
|
|
229
|
+
const { sessionID } = await identity.current(userID)
|
|
230
|
+
const armed = await arms.current(userID)
|
|
231
|
+
ok =
|
|
232
|
+
sessionID !== undefined &&
|
|
233
|
+
armed !== undefined &&
|
|
234
|
+
armed.decisionID === decision.id &&
|
|
235
|
+
armed.sessionID === sessionID &&
|
|
236
|
+
decision.state === "open" &&
|
|
237
|
+
decision.tier === "confirm" &&
|
|
238
|
+
by.userID === userID &&
|
|
239
|
+
hub.deviceOwner(by.deviceID) === userID &&
|
|
240
|
+
hub.present(by.deviceID, sessionID)
|
|
241
|
+
} catch (cause) {
|
|
242
|
+
console.error(`[hub] concierge via check failed: ${(cause as Error).message}`)
|
|
243
|
+
ok = false
|
|
244
|
+
}
|
|
245
|
+
if (!ok) throw tapOnly()
|
|
246
|
+
},
|
|
247
|
+
decisionChanged: (userID: string, decision: Decision): void => {
|
|
248
|
+
if (decision.state === "open") return
|
|
249
|
+
void arms
|
|
250
|
+
.disarm(userID, decision.id)
|
|
251
|
+
.then((ended) => (ended ? (unwatch(userID), tell(userID, null)) : undefined))
|
|
252
|
+
.catch((cause: unknown) => console.error(`[hub] concierge disarm failed: ${(cause as Error).message}`))
|
|
253
|
+
},
|
|
254
|
+
armedID: async (userID: string) => (await live(userID))?.decisionID,
|
|
255
|
+
stop: () => {
|
|
256
|
+
for (const timer of lapses.values()) clearTimeout(timer)
|
|
257
|
+
lapses.clear()
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// CON-01 watchlist routes on /api/agent (contract: docs/plans/2026-11-01-concierge-contracts.md, "Watchlist
|
|
2
|
+
// (CON-01)"), the per-user config record and the facts the resolution reads:
|
|
3
|
+
//
|
|
4
|
+
// GET /api/agent/concierge/watchlist user; app sessions:read; the Concierge → {config, entries, truncated}
|
|
5
|
+
// PUT /api/agent/concierge/watchlist {…fields, expectedRevision} user; app sessions:manage → {config}
|
|
6
|
+
// POST /api/agent/concierge/watchlist/{watch,unwatch,mute,unmute} {session: <addr>}
|
|
7
|
+
// user; app sessions:manage; the Concierge → {config, sessionID, changed}
|
|
8
|
+
//
|
|
9
|
+
// The Concierge is admitted only as the user's CURRENT Concierge session through its act token (canActAsSession and
|
|
10
|
+
// ConciergeIdentity.current, CON-04's state record; checked on every call, so a relaunch revokes the old session at
|
|
11
|
+
// once). Every other session credential gets 404 on the routes it is admitted to (the route does not reveal itself)
|
|
12
|
+
// and 403 on PUT (framework route class). The verbs change only `sessions` and `muted` and grant nothing.
|
|
13
|
+
//
|
|
14
|
+
// Storage: StateStore collection `concierge-watchlist`, key `config`, per user (the tenant key). A change bumps
|
|
15
|
+
// `revision` (a no-op does not: verbs are idempotent), appends `concierge.changed {what: "watchlist", revision}`
|
|
16
|
+
// (family manage, no subject, never config contents) and adds an admin audit `{actor: owner, action:
|
|
17
|
+
// "concierge.watchlist"}`. Addresses resolve for the caller's user only: another user's session is 404 like an
|
|
18
|
+
// unknown one. Facts come from the hub's own directory, catalog, Decisions, launch labels and the activity tracker;
|
|
19
|
+
// no spoke is called.
|
|
20
|
+
import type { WatchlistConfig, WatchlistView } from "../../../packages/protocol/src/concierge"
|
|
21
|
+
import { WATCHLIST_LIMITS } from "../../../packages/protocol/src/concierge"
|
|
22
|
+
import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
|
|
23
|
+
import { type AgentActor, canActAsSession } from "../agent/actor"
|
|
24
|
+
import { boundedJson } from "../agent/body"
|
|
25
|
+
import type { Decisions } from "../agent/decisions"
|
|
26
|
+
import type { Directory } from "../agent/directory"
|
|
27
|
+
import { AgentError } from "../agent/errors"
|
|
28
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
29
|
+
import type { ReadRegistry } from "../agent/read-routes"
|
|
30
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
31
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
32
|
+
import type { CatalogMachine } from "../registry"
|
|
33
|
+
import type { Store } from "../store"
|
|
34
|
+
import type { ActivityTracker } from "./activity"
|
|
35
|
+
import { defaultWatchlist, parseWatchlistUpdate, resolveWatchlist, type WatchFact, type WatchFacts } from "./watchlist"
|
|
36
|
+
|
|
37
|
+
export const WATCHLIST_COLLECTION = "concierge-watchlist"
|
|
38
|
+
const KEY = "config"
|
|
39
|
+
/** Literal, not from AGENT_PREFIX: routes.ts imports this module (a module-level read would hit the import cycle). */
|
|
40
|
+
const PATH = "/api/agent/concierge/watchlist"
|
|
41
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
42
|
+
/** Version-checked retries of a contended write: full-jitter backoff from backoffBaseMs, doubling, capped at backoffMaxMs. */
|
|
43
|
+
export const WATCHLIST_STORE_LIMITS = { attempts: 16, backoffBaseMs: 4, backoffMaxMs: 100 } as const
|
|
44
|
+
|
|
45
|
+
const backoff = (attempt: number) => {
|
|
46
|
+
const cap = Math.min(WATCHLIST_STORE_LIMITS.backoffMaxMs, WATCHLIST_STORE_LIMITS.backoffBaseMs * 2 ** attempt)
|
|
47
|
+
return new Promise<void>((resolve) => setTimeout(resolve, Math.random() * cap))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The user's Concierge sessions (CON-04's state record); absent until CON-04 lands: no Concierge. */
|
|
51
|
+
export interface ConciergeIdentity {
|
|
52
|
+
current(userID: string): Promise<{ readonly sessionID?: string; readonly hiddenSessionIDs: readonly string[] }>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const NO_CONCIERGE: ConciergeIdentity = { current: async () => ({ hiddenSessionIDs: [] }) }
|
|
56
|
+
|
|
57
|
+
export interface WatchlistRouteDeps {
|
|
58
|
+
readonly state: StateStore
|
|
59
|
+
readonly activity: Pick<ActivityTracker, "list">
|
|
60
|
+
readonly directory: Pick<Directory, "resolveOne" | "entries">
|
|
61
|
+
readonly store: Pick<Store, "agentData" | "viewPrefs" | "audit">
|
|
62
|
+
readonly registry: Pick<ReadRegistry, "catalog" | "sessionTenant">
|
|
63
|
+
readonly decisions?: Pick<Decisions, "list">
|
|
64
|
+
/** Launched sessions' labels (the latest launch wins). */
|
|
65
|
+
readonly labels?: (userID: string) => Promise<ReadonlyMap<string, Readonly<Record<string, string>>>>
|
|
66
|
+
readonly identity?: ConciergeIdentity
|
|
67
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
68
|
+
readonly now?: Clock
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- Config record ---------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const sameConfig = (a: WatchlistConfig, b: WatchlistConfig) => JSON.stringify({ ...a, revision: 0 }) === JSON.stringify({ ...b, revision: 0 })
|
|
74
|
+
|
|
75
|
+
/** The user's watchlist config record: version-checked writes, revision bumped only by a real change. */
|
|
76
|
+
export class WatchlistStore {
|
|
77
|
+
constructor(private readonly state: StateStore) {}
|
|
78
|
+
|
|
79
|
+
async get(userID: string): Promise<WatchlistConfig> {
|
|
80
|
+
return (await this.state.get<WatchlistConfig>(userID, WATCHLIST_COLLECTION, KEY))?.value ?? defaultWatchlist()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Applies `change` to the current config (re-read on every attempt; a lost race backs off with jitter, at most
|
|
85
|
+
* WATCHLIST_STORE_LIMITS.attempts tries) and stores the result (`change` may throw an AgentError, e.g. a stale revision)
|
|
86
|
+
* with the next revision when it differs. Resolves with the stored config and whether it changed.
|
|
87
|
+
*/
|
|
88
|
+
async update(userID: string, change: (current: WatchlistConfig) => WatchlistConfig): Promise<{ config: WatchlistConfig; changed: boolean }> {
|
|
89
|
+
for (let attempt = 0; attempt < WATCHLIST_STORE_LIMITS.attempts; attempt++) {
|
|
90
|
+
if (attempt > 0) await backoff(attempt - 1)
|
|
91
|
+
const stored = await this.state.get<WatchlistConfig>(userID, WATCHLIST_COLLECTION, KEY)
|
|
92
|
+
const current = stored?.value ?? defaultWatchlist()
|
|
93
|
+
const next = change(current)
|
|
94
|
+
if (sameConfig(current, next)) return { config: current, changed: false }
|
|
95
|
+
const config = { ...next, revision: current.revision + 1 }
|
|
96
|
+
const put = await this.state.put(userID, WATCHLIST_COLLECTION, KEY, config, { ifVersion: stored?.version ?? 0 })
|
|
97
|
+
if (put.ok) return { config, changed: true }
|
|
98
|
+
}
|
|
99
|
+
throw new AgentError("conflict", "The watchlist kept changing; retry", { hint: "Retry shortly" })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- Facts -----------------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
const STATE_RANK: Record<string, number> = { error: 4, blocked: 3, working: 2, idle: 1 }
|
|
106
|
+
const worse = (a: string | undefined, b: string | undefined) => ((STATE_RANK[b ?? ""] ?? 0) > (STATE_RANK[a ?? ""] ?? 0) ? b : a)
|
|
107
|
+
|
|
108
|
+
/** What watchFacts reads (CON-03's board passes the same, with one catalog and one Decisions list per render). */
|
|
109
|
+
export type WatchFactDeps = Pick<WatchlistRouteDeps, "activity" | "registry" | "decisions" | "labels" | "now"> & { readonly store: Pick<Store, "agentData" | "viewPrefs"> }
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The resolution's facts for one user: directory records, this hub's catalog (read here unless the caller already
|
|
113
|
+
* did), open Decisions, labels, activity.
|
|
114
|
+
*/
|
|
115
|
+
export async function watchFacts(deps: WatchFactDeps, userID: string, hidden: readonly string[], catalog: readonly CatalogMachine[] = deps.registry.catalog()): Promise<WatchFacts> {
|
|
116
|
+
const facts = new Map<string, { -readonly [K in keyof WatchFact]: WatchFact[K] }>()
|
|
117
|
+
const add = (fact: WatchFact) => {
|
|
118
|
+
const known = facts.get(fact.sessionID)
|
|
119
|
+
if (!known) return void facts.set(fact.sessionID, { ...fact })
|
|
120
|
+
known.state = worse(known.state, fact.state)
|
|
121
|
+
known.blockers = Math.max(known.blockers, fact.blockers)
|
|
122
|
+
known.directory ??= fact.directory
|
|
123
|
+
known.project ??= fact.project
|
|
124
|
+
known.herdrWorkspace ??= fact.herdrWorkspace
|
|
125
|
+
if (fact.archived) known.archived = true
|
|
126
|
+
}
|
|
127
|
+
for (const machine of catalog) {
|
|
128
|
+
for (const process of machine.processes) {
|
|
129
|
+
for (const s of process.sessions) {
|
|
130
|
+
if (deps.registry.sessionTenant(s.id) !== userID) continue
|
|
131
|
+
const herdr = s.open && s.terminal?.source === "herdr" && s.terminal.workspaceID ? { herdrWorkspace: s.terminal.workspaceID } : {}
|
|
132
|
+
const where = { ...(s.directory ? { directory: s.directory } : {}), ...(s.project ? { project: s.project } : {}) }
|
|
133
|
+
add({ sessionID: s.id, ...(s.parentID ? { parentID: s.parentID } : {}), state: s.state, blockers: s.blockers.length, ...where, ...herdr, ...(s.archived ? { archived: true } : {}) })
|
|
134
|
+
for (const child of s.status) {
|
|
135
|
+
if (child.id === s.id) continue
|
|
136
|
+
add({ sessionID: child.id, parentID: child.parentID ?? s.id, state: child.state, blockers: child.blockers.length, ...where })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const prefs = deps.store.viewPrefs(userID)
|
|
142
|
+
const archived = new Set(prefs.hiddenSessions)
|
|
143
|
+
// (CON-12) Machines that report their sessions now: a session one of them no longer reports went away.
|
|
144
|
+
const reporting = new Set(catalog.filter((m) => m.processes.length > 0).map((m) => m.id))
|
|
145
|
+
for (const record of deps.store.agentData(userID).sessions ?? []) {
|
|
146
|
+
const known = facts.get(record.sessionID)
|
|
147
|
+
if (!known) {
|
|
148
|
+
const gone = record.machineID !== undefined && reporting.has(record.machineID)
|
|
149
|
+
add({ sessionID: record.sessionID, ...(record.parentID ? { parentID: record.parentID } : {}), state: "offline", blockers: 0, ...(gone ? { gone: true } : {}) })
|
|
150
|
+
}
|
|
151
|
+
const fact = facts.get(record.sessionID)!
|
|
152
|
+
if (typeof record.project === "string" && record.project) fact.projectSlug = record.project
|
|
153
|
+
if (record.archivedAt !== undefined) fact.archived = true
|
|
154
|
+
if (record.deletedAt !== undefined) fact.deleted = true
|
|
155
|
+
}
|
|
156
|
+
for (const fact of facts.values()) if (archived.has(fact.sessionID)) fact.archived = true
|
|
157
|
+
|
|
158
|
+
const [activity, labels, open] = await Promise.all([deps.activity.list(userID), deps.labels ? deps.labels(userID) : new Map(), deps.decisions ? deps.decisions.list(userID, { state: "open" }) : []])
|
|
159
|
+
for (const [sessionID, at] of activity) {
|
|
160
|
+
const fact = facts.get(sessionID)
|
|
161
|
+
if (fact) fact.lastActiveAt = at
|
|
162
|
+
}
|
|
163
|
+
for (const [sessionID, set] of labels) {
|
|
164
|
+
const fact = facts.get(sessionID)
|
|
165
|
+
if (fact) fact.labels = set
|
|
166
|
+
}
|
|
167
|
+
const decisions = new Set<string>()
|
|
168
|
+
for (const d of open) {
|
|
169
|
+
for (const id of d.about) decisions.add(id)
|
|
170
|
+
if (d.from.kind === "session") decisions.add(d.from.sessionID)
|
|
171
|
+
}
|
|
172
|
+
return { sessions: [...facts.values()], pins: { sessions: prefs.pinnedSessions, workspaces: prefs.pinnedWorkspaces }, decisions: [...decisions], hidden, now: (deps.now ?? Date.now)() }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- Routes ----------------------------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
type ListName = "sessions" | "muted"
|
|
178
|
+
const VERBS: readonly { readonly verb: string; readonly list: ListName; readonly add: boolean }[] = [
|
|
179
|
+
{ verb: "watch", list: "sessions", add: true },
|
|
180
|
+
{ verb: "unwatch", list: "sessions", add: false },
|
|
181
|
+
{ verb: "mute", list: "muted", add: true },
|
|
182
|
+
{ verb: "unmute", list: "muted", add: false },
|
|
183
|
+
]
|
|
184
|
+
const CAP: Record<ListName, number> = { sessions: WATCHLIST_LIMITS.sessions, muted: WATCHLIST_LIMITS.muted }
|
|
185
|
+
|
|
186
|
+
const notFound = () => new AgentError("not_found", "Not found")
|
|
187
|
+
|
|
188
|
+
export function watchlistRoutes(deps: WatchlistRouteDeps): AgentRoute[] {
|
|
189
|
+
const records = new WatchlistStore(deps.state)
|
|
190
|
+
const identity = deps.identity ?? NO_CONCIERGE
|
|
191
|
+
|
|
192
|
+
/** Session callers: only the user's current Concierge, through its act token; anyone else is 404. */
|
|
193
|
+
const admit = async (actor: AgentActor, userID: string) => {
|
|
194
|
+
const current = await identity.current(userID)
|
|
195
|
+
if (actor.kind === "session" && !(canActAsSession(actor) && current.sessionID !== undefined && current.sessionID === actor.sessionID)) throw notFound()
|
|
196
|
+
return current
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const changed = async (userID: string, config: WatchlistConfig) => {
|
|
200
|
+
const event: EventInput = { type: "concierge.changed", family: "manage", subject: {}, data: { what: "watchlist", revision: config.revision } }
|
|
201
|
+
await deps.events?.append(userID, event).catch((cause) => console.error(`[hub] concierge watchlist event: ${(cause as Error).message}`))
|
|
202
|
+
await deps.store.audit({ actor: userID, action: "concierge.watchlist" })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The root of a resolved session (subagents roll up into their root). */
|
|
206
|
+
const rootOf = (userID: string, sessionID: string, parentID: string | undefined): string => {
|
|
207
|
+
const seen = new Set([sessionID])
|
|
208
|
+
let id = sessionID
|
|
209
|
+
let parent = parentID
|
|
210
|
+
while (parent !== undefined && !seen.has(parent)) {
|
|
211
|
+
const entry = deps.directory.entries(userID, [parent]).get(parent)
|
|
212
|
+
if (!entry) break
|
|
213
|
+
seen.add(parent)
|
|
214
|
+
id = entry.sessionID
|
|
215
|
+
parent = entry.parentID
|
|
216
|
+
}
|
|
217
|
+
return id
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const verbRoute = ({ verb, list, add }: (typeof VERBS)[number]): AgentRoute => ({
|
|
221
|
+
method: "POST",
|
|
222
|
+
path: `${PATH}/${verb}`,
|
|
223
|
+
allow: { session: true, scope: "sessions:manage" },
|
|
224
|
+
async handle({ request, actor, user }: AgentContext) {
|
|
225
|
+
await admit(actor, user.id)
|
|
226
|
+
const body = await boundedJson(request)
|
|
227
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "The body is {session: <addr>}")
|
|
228
|
+
const extra = Object.keys(body).find((k) => k !== "session")
|
|
229
|
+
if (extra !== undefined) throw new AgentError("invalid", `Unknown field ${extra.slice(0, 40)}`)
|
|
230
|
+
const addr = (body as { session?: unknown }).session
|
|
231
|
+
if (typeof addr !== "string" || !addr.trim()) throw new AgentError("invalid", "session (an address: ses_…, @name, @@role) is required")
|
|
232
|
+
let sessionID: string
|
|
233
|
+
// A listed ID can be removed even when its session is gone from the directory.
|
|
234
|
+
if (!add && isHubSessionID(addr) && (await records.get(user.id))[list].includes(addr)) sessionID = addr
|
|
235
|
+
else {
|
|
236
|
+
const entry = deps.directory.resolveOne(user.id, addr)
|
|
237
|
+
sessionID = rootOf(user.id, entry.sessionID, entry.parentID)
|
|
238
|
+
}
|
|
239
|
+
const result = await records.update(user.id, (current) => {
|
|
240
|
+
const has = current[list].includes(sessionID)
|
|
241
|
+
if (add === has) return current
|
|
242
|
+
if (add && current[list].length >= CAP[list]) throw new AgentError("rate_limited", `The watchlist's ${list} list has at most ${CAP[list]} sessions`, { extra: { limit: CAP[list] } })
|
|
243
|
+
return { ...current, [list]: add ? [...current[list], sessionID] : current[list].filter((id) => id !== sessionID) }
|
|
244
|
+
})
|
|
245
|
+
if (result.changed) await changed(user.id, result.config)
|
|
246
|
+
return Response.json({ config: result.config, sessionID, changed: result.changed }, { headers: NO_STORE })
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
{
|
|
252
|
+
method: "GET",
|
|
253
|
+
path: PATH,
|
|
254
|
+
allow: { session: true, scope: "sessions:read" },
|
|
255
|
+
async handle({ actor, user }) {
|
|
256
|
+
const current = await admit(actor, user.id)
|
|
257
|
+
const config = await records.get(user.id)
|
|
258
|
+
const facts = await watchFacts(deps, user.id, current.hiddenSessionIDs)
|
|
259
|
+
const { entries, truncated } = resolveWatchlist(config, facts)
|
|
260
|
+
const view: WatchlistView = { config, entries, truncated }
|
|
261
|
+
return Response.json(view, { headers: NO_STORE })
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
method: "PUT",
|
|
266
|
+
path: PATH,
|
|
267
|
+
allow: { scope: "sessions:manage" },
|
|
268
|
+
async handle({ request, user }) {
|
|
269
|
+
const body = await boundedJson(request)
|
|
270
|
+
const result = await records.update(user.id, (current) => parseWatchlistUpdate(body, current))
|
|
271
|
+
if (result.changed) await changed(user.id, result.config)
|
|
272
|
+
return Response.json({ config: result.config }, { headers: NO_STORE })
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
...VERBS.map(verbRoute),
|
|
276
|
+
]
|
|
277
|
+
}
|