@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,888 @@
|
|
|
1
|
+
// Team staffing (HWA-35; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams and the
|
|
2
|
+
// HWA-33/34/05/15 clarifications). Binds live sessions to the roles of a team so the team outlives any one session:
|
|
3
|
+
// attach an existing session (any machine) or launch a new member per role through the launch API.
|
|
4
|
+
//
|
|
5
|
+
// Seats (TeamSeatStore, `team-seats`) stay the single source of team policy rights (HWA-34 reads them fresh per
|
|
6
|
+
// send). This service adds, in its own collection `team-staffing` (one aggregate per team, key: team ID):
|
|
7
|
+
// - the team's workspace link (`workspaceID`) and which workspace members it joined (`managed`: session → member);
|
|
8
|
+
// - staffing launch requests (`launching` → `staffed` | `failed`) and their Idempotency-Keys.
|
|
9
|
+
//
|
|
10
|
+
// Team ↔ workspace (decision recorded in Brain, project hub): ONE workspace per team, created at the team's first
|
|
11
|
+
// staffing (title = team title, metadata `{team: <team ID>}`) and linked here. Every session holding a current
|
|
12
|
+
// seat is one workspace member (role `worker`; one member per session even with several roles in the team). The
|
|
13
|
+
// entry role supervises: each non-entry member without a live supervision link is attached under the earliest
|
|
14
|
+
// staffed entry member (SupervisionStore, HWA-05). Existing links are never rewritten (a user's rearrangement
|
|
15
|
+
// stands); a departing entry member's links move to its replacement (memberDeparted replacedBy) or are detached and
|
|
16
|
+
// re-attached under another entry member. The workspace master is not set (HWA-12 owns explicit master
|
|
17
|
+
// replacement). A team archived workspace is left alone: staffing still works, membership sync pauses until the user
|
|
18
|
+
// restores it. Several teams → several workspaces, so a session's bindings in one team never move when it is staffed
|
|
19
|
+
// in another. Native parentID/rootID are never read or written here.
|
|
20
|
+
//
|
|
21
|
+
// Generations: re-staffing the same (role, session) is its next generation (TeamSeatStore). An explicit replacement
|
|
22
|
+
// ends the old seat and gives the new session the slot's next generation (old + 1) in ONE version-checked write on
|
|
23
|
+
// the seats aggregate, so the old member loses its team policy rights at that instant. Replacing by launch ends the
|
|
24
|
+
// old seat at once (the role is vacant while the launch runs) and the launched session takes the next generation.
|
|
25
|
+
//
|
|
26
|
+
// Presence: seats survive member restarts and disconnects; only deletion (sessionEnded, wired from
|
|
27
|
+
// Registry.onSessionDeleted) or an explicit unstaff/replace ends them. The roster reports each seat's presence
|
|
28
|
+
// (online/offline/archived/deleted/unknown), vacancy, understaffing (below minMembers), launching counts and the
|
|
29
|
+
// role profile's assignment and ack status as stored (HWA-15) — never assumed applied.
|
|
30
|
+
//
|
|
31
|
+
// Authority: team definitions and seats grant nothing. Users staff their own teams. Agents (apps and sessions) need
|
|
32
|
+
// the existing grants of their user, checked and spent in the acting order (kill switch → grants usable → actions
|
|
33
|
+
// spent → kill switch): attach and unstaff need a `roles` grant covering the session's project; a launch needs
|
|
34
|
+
// scope `sessions:launch` (apps), a `launch` and a `roles` grant covering the repo, and — when the role names a
|
|
35
|
+
// profile — scope `profiles:assign` (apps; sessions never pin profiles), exactly as POST /launches. A missing grant
|
|
36
|
+
// answers `needs_user` with `extra.missing` (verb + directory) and is audited (verb team.staff / team.unstaff); no
|
|
37
|
+
// grant is ever issued and no Decision is opened by staffing (an agent may ask through a grant request).
|
|
38
|
+
//
|
|
39
|
+
// Launch correlation: the launch carries labels `hub.team` and `hub.team.request` and the request ID as its launch
|
|
40
|
+
// key. A launch change staffs only when a stored `launching` request of that team has this launch (and launcher):
|
|
41
|
+
// labels alone never grant a seat. roster()/settle() re-check pending launches, so a change missed across a hub
|
|
42
|
+
// restart is picked up on the next read.
|
|
43
|
+
import { randomBytes } from "node:crypto"
|
|
44
|
+
import {
|
|
45
|
+
CONTRACT_VERSION,
|
|
46
|
+
ID_PREFIXES,
|
|
47
|
+
IDEMPOTENCY_KEY,
|
|
48
|
+
IDEMPOTENCY_TTL_MS,
|
|
49
|
+
type IdKind,
|
|
50
|
+
isExpectedRevision,
|
|
51
|
+
TEAM_LIMITS,
|
|
52
|
+
type ProfileAckStatus,
|
|
53
|
+
type ProfileAssignment,
|
|
54
|
+
} from "../../protocol"
|
|
55
|
+
import type { ActingSwitch } from "../agent/acting-switch"
|
|
56
|
+
import { auditOf } from "../agent/acting"
|
|
57
|
+
import type { AgentActor } from "../agent/actor"
|
|
58
|
+
import type { AgentAudit, AuditTarget } from "../agent/audit"
|
|
59
|
+
import { partyFromActor } from "../agent/envelope"
|
|
60
|
+
import { AgentError } from "../agent/errors"
|
|
61
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
62
|
+
import type { Grant, Grants } from "../agent/grants"
|
|
63
|
+
import { canonicalBody } from "../agent/idempotency"
|
|
64
|
+
import type { LaunchEngine } from "../agent/launch-engine"
|
|
65
|
+
import { launchedBy } from "../agent/launch-routes"
|
|
66
|
+
import { validateLaunchSpec } from "../agent/launch-spec"
|
|
67
|
+
import type { Launch } from "../agent/launch-store"
|
|
68
|
+
import { updateVersioned } from "../agent/versioned"
|
|
69
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
70
|
+
import { memberJoinedEvent, memberLeftEvent, supervisionDepartedEvent, supervisionSetEvent, workspaceCreatedEvent } from "../workspaces/events"
|
|
71
|
+
import type { MembershipStore, SessionPresence, SessionPresenceLookup } from "../workspaces/members"
|
|
72
|
+
import type { WorkspaceStore } from "../workspaces/store"
|
|
73
|
+
import type { SupervisionStore } from "../workspaces/supervision"
|
|
74
|
+
import { readSeatsDoc, type SeatsDoc, TEAM_SEATS } from "./collections"
|
|
75
|
+
import { teamStaffedEvent, teamUnstaffedEvent } from "./events"
|
|
76
|
+
import type { TeamSeatRecord, TeamSeatStore } from "./seats"
|
|
77
|
+
import type { Team, TeamStore } from "./store"
|
|
78
|
+
import { TEAM_STORE_LIMITS, type TeamRoleSpec } from "./validation"
|
|
79
|
+
|
|
80
|
+
/** One aggregate per team (key: team ID): workspace link, managed members, launch requests, Idempotency-Keys. */
|
|
81
|
+
export const TEAM_STAFFING = "team-staffing"
|
|
82
|
+
/** Launch labels naming the team and the staffing request (never trusted alone). */
|
|
83
|
+
export const TEAM_LAUNCH_LABEL = "hub.team"
|
|
84
|
+
export const TEAM_REQUEST_LABEL = "hub.team.request"
|
|
85
|
+
/** Finished (staffed/failed) launch requests kept per team. */
|
|
86
|
+
export const FINISHED_REQUESTS_RETAINED = 50
|
|
87
|
+
/** Live launch Idempotency-Keys kept per team. */
|
|
88
|
+
const KEYS_PER_TEAM = 500
|
|
89
|
+
const LAUNCH_FIELDS = new Set(["role", "launch"])
|
|
90
|
+
const REPLACE_FIELDS = new Set(["expectedRevision", "sessionID", "launch"])
|
|
91
|
+
|
|
92
|
+
export type StaffingRequestState = "launching" | "staffed" | "failed"
|
|
93
|
+
|
|
94
|
+
/** A staffing launch: one new member for one role, launched through the launch API. */
|
|
95
|
+
export interface StaffingRequest {
|
|
96
|
+
readonly id: string
|
|
97
|
+
readonly teamID: string
|
|
98
|
+
readonly role: string
|
|
99
|
+
readonly state: StaffingRequestState
|
|
100
|
+
/** The launcher (`user`, `app:<token>`, `ses_…`), as the launch records it. */
|
|
101
|
+
readonly by: string
|
|
102
|
+
readonly launchID?: string
|
|
103
|
+
/** A replacement: the ended seat and its generation (the new seat gets the next one). */
|
|
104
|
+
readonly replaces?: { readonly staffingID: string; readonly generation: number }
|
|
105
|
+
readonly sessionID?: string
|
|
106
|
+
readonly machineID?: string
|
|
107
|
+
readonly staffingID?: string
|
|
108
|
+
readonly reason?: string
|
|
109
|
+
readonly createdAt: number
|
|
110
|
+
readonly updatedAt: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RequestKey {
|
|
114
|
+
readonly requestID: string
|
|
115
|
+
readonly digest: string
|
|
116
|
+
readonly at: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface StaffingDoc {
|
|
120
|
+
readonly v: 1
|
|
121
|
+
readonly teamID: string
|
|
122
|
+
readonly workspaceID?: string
|
|
123
|
+
/** Workspace members this service joined, by session (only these are departed by it). */
|
|
124
|
+
readonly managed: Readonly<Record<string, string>>
|
|
125
|
+
readonly requests: readonly StaffingRequest[]
|
|
126
|
+
readonly keys: Readonly<Record<string, RequestKey>>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The team's workspace as staffing sees it. */
|
|
130
|
+
export interface TeamWorkspaceLink {
|
|
131
|
+
readonly id: string
|
|
132
|
+
readonly state: "active" | "archived" | "missing"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface SeatProfileStatus {
|
|
136
|
+
readonly requested: { readonly profileID: string; readonly version?: number }
|
|
137
|
+
readonly assigned?: { readonly profileID: string; readonly version: number }
|
|
138
|
+
/** The stored ack status of the session's assignment; `unassigned` when it has none. */
|
|
139
|
+
readonly status: ProfileAckStatus | "unassigned"
|
|
140
|
+
/** The assignment is the requested profile (and version, when the role pins one). */
|
|
141
|
+
readonly matches: boolean
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RosterSeat {
|
|
145
|
+
readonly staffing: TeamSeatRecord
|
|
146
|
+
readonly presence: SessionPresence
|
|
147
|
+
readonly memberID?: string
|
|
148
|
+
readonly supervisorMemberID?: string
|
|
149
|
+
readonly profile?: SeatProfileStatus
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface RosterRole {
|
|
153
|
+
readonly role: string
|
|
154
|
+
readonly entry: boolean
|
|
155
|
+
readonly minMembers: number
|
|
156
|
+
readonly maxMembers: number
|
|
157
|
+
readonly seats: readonly RosterSeat[]
|
|
158
|
+
/** Launches still on their way to a session. */
|
|
159
|
+
readonly launching: number
|
|
160
|
+
readonly online: number
|
|
161
|
+
/** Seats whose session is not online now (offline, archived, deleted or unknown). */
|
|
162
|
+
readonly offline: number
|
|
163
|
+
readonly vacant: boolean
|
|
164
|
+
readonly understaffed: boolean
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface TeamRoster {
|
|
168
|
+
readonly teamID: string
|
|
169
|
+
readonly version: number
|
|
170
|
+
readonly state: Team["state"]
|
|
171
|
+
readonly paused: boolean
|
|
172
|
+
readonly workspace?: TeamWorkspaceLink
|
|
173
|
+
readonly roles: readonly RosterRole[]
|
|
174
|
+
readonly requests: readonly StaffingRequest[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface StaffResult {
|
|
178
|
+
readonly staffing: TeamSeatRecord
|
|
179
|
+
readonly created: boolean
|
|
180
|
+
readonly workspace?: TeamWorkspaceLink
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface LaunchResult {
|
|
184
|
+
readonly request: StaffingRequest
|
|
185
|
+
readonly launch: Launch
|
|
186
|
+
readonly created: boolean
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ReplaceResult {
|
|
190
|
+
readonly ended: TeamSeatRecord
|
|
191
|
+
/** The replacement seat (attach). */
|
|
192
|
+
readonly staffing?: TeamSeatRecord
|
|
193
|
+
/** The replacement launch (launch). */
|
|
194
|
+
readonly request?: StaffingRequest
|
|
195
|
+
readonly launch?: Launch
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface StaffingProfiles {
|
|
199
|
+
assignment(userID: string, sessionID: string): Promise<ProfileAssignment | undefined>
|
|
200
|
+
get(userID: string, id: string): Promise<{ readonly latestVersion: number } | undefined>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface TeamStaffingDeps {
|
|
204
|
+
readonly state: StateStore
|
|
205
|
+
readonly teams: TeamStore
|
|
206
|
+
readonly seats: TeamSeatStore
|
|
207
|
+
readonly workspaces: WorkspaceStore
|
|
208
|
+
readonly members: MembershipStore
|
|
209
|
+
readonly supervision: SupervisionStore
|
|
210
|
+
readonly presence: SessionPresenceLookup
|
|
211
|
+
/** The session's project directory now (grant scope); undefined when no spoke reports it. */
|
|
212
|
+
readonly sessionDirectory: (userID: string, sessionID: string) => string | undefined
|
|
213
|
+
readonly launches: Pick<LaunchEngine, "accept" | "view">
|
|
214
|
+
readonly grants: Pick<Grants, "usable" | "consume">
|
|
215
|
+
readonly acting: Pick<ActingSwitch, "ensureActing">
|
|
216
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
217
|
+
readonly profiles?: StaffingProfiles
|
|
218
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
219
|
+
readonly report?: (cause: unknown) => void
|
|
220
|
+
readonly now?: Clock
|
|
221
|
+
readonly newID?: (kind: IdKind) => string
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface Need {
|
|
225
|
+
readonly verb: "launch" | "roles"
|
|
226
|
+
readonly directory: string
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
230
|
+
const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
231
|
+
|
|
232
|
+
function unknownField(input: Record<string, unknown>, allowed: ReadonlySet<string>): void {
|
|
233
|
+
const unknown = Object.keys(input).find((k) => !allowed.has(k))
|
|
234
|
+
if (unknown !== undefined) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(unknown) ? unknown : "(unprintable)"}`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readDoc(raw: unknown, teamID: string): StaffingDoc {
|
|
238
|
+
if (raw === undefined) return { v: 1, teamID, managed: {}, requests: [], keys: {} }
|
|
239
|
+
if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
|
|
240
|
+
throw new AgentError("unsupported", `The staffing of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: TEAM_STAFFING } })
|
|
241
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.teamID !== teamID || !Array.isArray(raw.requests)) throw new AgentError("internal", `Stored staffing of team ${teamID} is malformed`)
|
|
242
|
+
return {
|
|
243
|
+
v: 1,
|
|
244
|
+
teamID,
|
|
245
|
+
...(typeof raw.workspaceID === "string" ? { workspaceID: raw.workspaceID } : {}),
|
|
246
|
+
managed: isObj(raw.managed) ? (raw.managed as Record<string, string>) : {},
|
|
247
|
+
requests: raw.requests as StaffingRequest[],
|
|
248
|
+
keys: isObj(raw.keys) ? (raw.keys as Record<string, RequestKey>) : {},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Current seats plus the newest retained ended ones (as TeamSeatStore keeps them). */
|
|
253
|
+
function retainedSeats(seats: readonly TeamSeatRecord[]): TeamSeatRecord[] {
|
|
254
|
+
const ended = seats.filter((s) => s.until !== undefined).sort((a, b) => b.until! - a.until! || (a.id < b.id ? 1 : -1))
|
|
255
|
+
const keep = new Set(ended.slice(0, TEAM_STORE_LIMITS.endedSeatsRetained).map((s) => s.id))
|
|
256
|
+
return seats.filter((s) => s.until === undefined || keep.has(s.id))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Pending requests plus the newest finished ones. */
|
|
260
|
+
function retainedRequests(requests: readonly StaffingRequest[]): StaffingRequest[] {
|
|
261
|
+
const finished = requests.filter((r) => r.state !== "launching").sort((a, b) => b.updatedAt - a.updatedAt)
|
|
262
|
+
const keep = new Set(finished.slice(0, FINISHED_REQUESTS_RETAINED).map((r) => r.id))
|
|
263
|
+
return requests.filter((r) => r.state === "launching" || keep.has(r.id))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function roleOf(team: Team, role: unknown): TeamRoleSpec {
|
|
267
|
+
if (typeof role !== "string") throw new AgentError("invalid", "role must be a role name of the team")
|
|
268
|
+
const spec = team.roles.find((r) => r.name === role)
|
|
269
|
+
if (!spec) throw new AgentError("invalid", `Team ${team.name} has no role ${role.slice(0, 40)}`)
|
|
270
|
+
return spec
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** New members need an active, unpaused team. */
|
|
274
|
+
function staffable(team: Team): void {
|
|
275
|
+
if (team.state === "archived") throw new AgentError("invalid", `Team ${team.id} is archived`, { hint: "Restore it first" })
|
|
276
|
+
if (team.paused) throw new AgentError("invalid", `Team ${team.id} is paused`, { hint: "Resume it first" })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const roleLimit = (role: TeamRoleSpec) => Math.min(role.maxMembers ?? TEAM_LIMITS.sessionsPerRole, TEAM_LIMITS.sessionsPerRole)
|
|
280
|
+
|
|
281
|
+
export class TeamStaffingService {
|
|
282
|
+
private readonly state: StateStore
|
|
283
|
+
private readonly now: Clock
|
|
284
|
+
private readonly newID: (kind: IdKind) => string
|
|
285
|
+
private readonly report: (cause: unknown) => void
|
|
286
|
+
|
|
287
|
+
constructor(private readonly deps: TeamStaffingDeps) {
|
|
288
|
+
this.state = deps.state
|
|
289
|
+
this.now = deps.now ?? Date.now
|
|
290
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
291
|
+
this.report = deps.report ?? ((cause) => console.error(`[hub] team staffing: ${message(cause)}`))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------- staffing ----------
|
|
295
|
+
|
|
296
|
+
/** Staffs an existing session (any machine) in a role. The same current (role, session) returns its seat. */
|
|
297
|
+
async attach(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<StaffResult> {
|
|
298
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
299
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
300
|
+
staffable(team)
|
|
301
|
+
roleOf(team, input.role)
|
|
302
|
+
const sessionID = await this.knownSession(userID, input.sessionID)
|
|
303
|
+
const needs = actor.kind === "user" ? [] : [{ verb: "roles" as const, directory: this.directoryOf(userID, sessionID) }]
|
|
304
|
+
const result = await this.authorized(userID, actor, "team.staff", needs, { sessionID }, () => this.deps.seats.staff(userID, teamID, input, options))
|
|
305
|
+
if (result.created) await this.emit(userID, teamStaffedEvent(result.staffing))
|
|
306
|
+
const workspace = await this.sync(userID, teamID)
|
|
307
|
+
return { ...result, ...(workspace ? { workspace } : {}) }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Launches a new member for a role through the launch API; the running session is staffed. */
|
|
311
|
+
async launch(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<LaunchResult> {
|
|
312
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
313
|
+
unknownField(input, LAUNCH_FIELDS)
|
|
314
|
+
const key = options.idempotencyKey
|
|
315
|
+
if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
316
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
317
|
+
staffable(team)
|
|
318
|
+
const role = roleOf(team, input.role)
|
|
319
|
+
const by = launchedBy(actor)
|
|
320
|
+
const digest = canonicalBody({ role: role.name, launch: input.launch })
|
|
321
|
+
if (key !== undefined) {
|
|
322
|
+
const held = await this.byKey(userID, teamID, key, digest, by)
|
|
323
|
+
if (held) return held
|
|
324
|
+
}
|
|
325
|
+
return this.launchFor(userID, actor, team, role, input.launch, { ...(key !== undefined ? { key: { key, digest } } : {}) })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Explicit replacement of a current seat (under its expected revision) by an existing session (`sessionID`) or a
|
|
330
|
+
* launch (`launch`). The old seat ends at once; the new member gets the slot's next generation.
|
|
331
|
+
*/
|
|
332
|
+
async replace(userID: string, actor: AgentActor, teamID: string, staffingID: string, input: unknown): Promise<ReplaceResult> {
|
|
333
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
334
|
+
unknownField(input, REPLACE_FIELDS)
|
|
335
|
+
const { expectedRevision } = input
|
|
336
|
+
if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision is required (the revision you read)")
|
|
337
|
+
if ((input.sessionID === undefined) === (input.launch === undefined)) throw new AgentError("invalid", "Name either sessionID (an existing session) or launch (a new one)")
|
|
338
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
339
|
+
staffable(team)
|
|
340
|
+
const old = (await this.deps.seats.list(userID, teamID)).find((s) => s.id === staffingID)
|
|
341
|
+
if (!old) throw new AgentError("not_found", `No staffing ${staffingID} in team ${teamID}`)
|
|
342
|
+
if (old.revision !== expectedRevision)
|
|
343
|
+
throw new AgentError("conflict", `Staffing ${staffingID} changed since you read it (revision ${old.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: old.revision } })
|
|
344
|
+
if (old.until !== undefined) throw new AgentError("invalid", `Staffing ${staffingID} already ended`, { hint: "Staff the role instead" })
|
|
345
|
+
const role = roleOf(team, old.role)
|
|
346
|
+
|
|
347
|
+
if (input.launch !== undefined) {
|
|
348
|
+
const out = await this.launchFor(userID, actor, team, role, input.launch, { replaces: { old, expectedRevision } })
|
|
349
|
+
return { ended: out.ended!, request: out.request, launch: out.launch }
|
|
350
|
+
}
|
|
351
|
+
const sessionID = await this.knownSession(userID, input.sessionID)
|
|
352
|
+
if (sessionID === old.sessionID) throw new AgentError("invalid", `Session ${sessionID} already holds this seat`)
|
|
353
|
+
const needs = actor.kind === "user" ? [] : [{ verb: "roles" as const, directory: this.directoryOf(userID, sessionID) }]
|
|
354
|
+
const { ended, staffing } = await this.authorized(userID, actor, "team.staff", needs, { sessionID }, () =>
|
|
355
|
+
this.writeSeat(userID, team, role, sessionID, { end: { staffingID, expectedRevision } }),
|
|
356
|
+
)
|
|
357
|
+
await this.emit(userID, teamUnstaffedEvent(ended!), teamStaffedEvent(staffing))
|
|
358
|
+
await this.sync(userID, teamID, new Map([[old.sessionID, sessionID]]))
|
|
359
|
+
return { ended: ended!, staffing }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Ends a seat under its expected revision (an ended seat: no-op). */
|
|
363
|
+
async unstaff(userID: string, actor: AgentActor, teamID: string, staffingID: string, expectedRevision: number): Promise<TeamSeatRecord> {
|
|
364
|
+
const seat = (await this.deps.seats.list(userID, teamID)).find((s) => s.id === staffingID)
|
|
365
|
+
if (!seat) throw new AgentError("not_found", `No staffing ${staffingID} in team ${teamID}`)
|
|
366
|
+
const needs = actor.kind === "user" ? [] : [{ verb: "roles" as const, directory: this.directoryOf(userID, seat.sessionID) }]
|
|
367
|
+
const ended = await this.authorized(userID, actor, "team.unstaff", needs, { sessionID: seat.sessionID }, () => this.deps.seats.unstaff(userID, teamID, staffingID, expectedRevision))
|
|
368
|
+
if (seat.until === undefined && ended.until !== undefined) await this.emit(userID, teamUnstaffedEvent(ended))
|
|
369
|
+
await this.sync(userID, teamID)
|
|
370
|
+
return ended
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The session was deleted: its seats end in every team; workspaces and supervision follow. */
|
|
374
|
+
async sessionEnded(userID: string, sessionID: string): Promise<TeamSeatRecord[]> {
|
|
375
|
+
const ended = await this.deps.seats.sessionEnded(userID, sessionID)
|
|
376
|
+
await this.emit(userID, ...ended.map(teamUnstaffedEvent))
|
|
377
|
+
for (const teamID of new Set(ended.map((s) => s.teamID))) {
|
|
378
|
+
try {
|
|
379
|
+
await this.sync(userID, teamID)
|
|
380
|
+
} catch (cause) {
|
|
381
|
+
this.report(cause)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return ended
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------- launches ----------
|
|
388
|
+
|
|
389
|
+
/** A launch changed (LaunchFeed): staffs the running session of a matching request, or fails an ended one. */
|
|
390
|
+
async launchChanged(userID: string, launch: Launch): Promise<void> {
|
|
391
|
+
const teamID = launch.spec.labels?.[TEAM_LAUNCH_LABEL]
|
|
392
|
+
const requestID = launch.spec.labels?.[TEAM_REQUEST_LABEL]
|
|
393
|
+
if (teamID === undefined || requestID === undefined || launch.owner !== userID) return
|
|
394
|
+
const raw = (await this.state.get(userID, TEAM_STAFFING, teamID))?.value
|
|
395
|
+
if (raw === undefined) return
|
|
396
|
+
const request = readDoc(raw, teamID).requests.find((r) => r.id === requestID)
|
|
397
|
+
if (!request || request.state !== "launching") return
|
|
398
|
+
const ours = (request.launchID ?? launch.id) === launch.id && launch.spec.key === request.id && (launch.by ?? "user") === request.by
|
|
399
|
+
if (!ours) return
|
|
400
|
+
if (launch.state === "running" && launch.sessionID !== undefined) {
|
|
401
|
+
await this.staffLaunched(userID, request, launch)
|
|
402
|
+
} else if (launch.state === "ended") {
|
|
403
|
+
await this.finish(userID, teamID, request.id, { state: "failed", reason: launch.reason || "The launch ended before its session was staffed" })
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Re-checks the team's pending launches (a change missed across a restart is picked up here). */
|
|
408
|
+
async settle(userID: string, teamID: string): Promise<void> {
|
|
409
|
+
const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
|
|
410
|
+
for (const request of doc.requests.filter((r) => r.state === "launching" && r.launchID !== undefined)) {
|
|
411
|
+
const launch = await this.deps.launches.view(userID, request.launchID!)
|
|
412
|
+
if (launch) await this.launchChanged(userID, launch)
|
|
413
|
+
else await this.finish(userID, teamID, request.id, { state: "failed", reason: "The launch is no longer known" })
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- reads ----------
|
|
418
|
+
|
|
419
|
+
/** The team's workspace ID (undefined before its first staffing). */
|
|
420
|
+
async workspaceOf(userID: string, teamID: string): Promise<string | undefined> {
|
|
421
|
+
return readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID).workspaceID
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Roles with their seats, presence, vacancy, launches and profile status; settles pending launches first. */
|
|
425
|
+
async roster(userID: string, teamID: string): Promise<TeamRoster> {
|
|
426
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
427
|
+
await this.settle(userID, teamID)
|
|
428
|
+
const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
|
|
429
|
+
const current = await this.deps.seats.list(userID, teamID, { current: true })
|
|
430
|
+
const workspace = await this.link(userID, doc.workspaceID)
|
|
431
|
+
const memberOf = new Map<string, string>()
|
|
432
|
+
const supervisorOf = new Map<string, string>()
|
|
433
|
+
if (workspace?.state === "active" || workspace?.state === "archived") {
|
|
434
|
+
for (const { member } of await this.deps.members.members(userID, workspace.id)) memberOf.set(member.sessionID, member.id)
|
|
435
|
+
for (const { link, member, supervisor } of (await this.deps.supervision.graph(userID, workspace.id)).links)
|
|
436
|
+
if (member === "current" && supervisor === "current") supervisorOf.set(link.memberID, link.supervisorMemberID)
|
|
437
|
+
}
|
|
438
|
+
const presence = new Map<string, SessionPresence>()
|
|
439
|
+
for (const sessionID of new Set(current.map((s) => s.sessionID))) presence.set(sessionID, await this.deps.presence(userID, sessionID))
|
|
440
|
+
const roles: RosterRole[] = []
|
|
441
|
+
for (const role of team.roles) {
|
|
442
|
+
const seats: RosterSeat[] = []
|
|
443
|
+
for (const staffing of current.filter((s) => s.role === role.name)) {
|
|
444
|
+
const memberID = memberOf.get(staffing.sessionID)
|
|
445
|
+
const supervisorMemberID = memberID !== undefined ? supervisorOf.get(memberID) : undefined
|
|
446
|
+
const profile = role.profile ? await this.profileStatus(userID, staffing.sessionID, role.profile) : undefined
|
|
447
|
+
seats.push({
|
|
448
|
+
staffing,
|
|
449
|
+
presence: presence.get(staffing.sessionID)!,
|
|
450
|
+
...(memberID !== undefined ? { memberID } : {}),
|
|
451
|
+
...(supervisorMemberID !== undefined ? { supervisorMemberID } : {}),
|
|
452
|
+
...(profile ? { profile } : {}),
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
const online = seats.filter((s) => s.presence === "online").length
|
|
456
|
+
const minMembers = role.minMembers ?? 0
|
|
457
|
+
roles.push({
|
|
458
|
+
role: role.name,
|
|
459
|
+
entry: role.name === team.entryRole,
|
|
460
|
+
minMembers,
|
|
461
|
+
maxMembers: roleLimit(role),
|
|
462
|
+
seats,
|
|
463
|
+
launching: doc.requests.filter((r) => r.role === role.name && r.state === "launching").length,
|
|
464
|
+
online,
|
|
465
|
+
offline: seats.length - online,
|
|
466
|
+
vacant: seats.length === 0,
|
|
467
|
+
understaffed: seats.length < minMembers,
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
teamID,
|
|
472
|
+
version: team.version,
|
|
473
|
+
state: team.state,
|
|
474
|
+
paused: team.paused === true,
|
|
475
|
+
...(workspace ? { workspace } : {}),
|
|
476
|
+
roles,
|
|
477
|
+
requests: [...doc.requests].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1)),
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------- internals: launches ----------
|
|
482
|
+
|
|
483
|
+
private async launchFor(
|
|
484
|
+
userID: string,
|
|
485
|
+
actor: AgentActor,
|
|
486
|
+
team: Team,
|
|
487
|
+
role: TeamRoleSpec,
|
|
488
|
+
base: unknown,
|
|
489
|
+
options: { readonly key?: { readonly key: string; readonly digest: string }; readonly replaces?: { readonly old: TeamSeatRecord; readonly expectedRevision: number } },
|
|
490
|
+
): Promise<LaunchResult & { ended?: TeamSeatRecord }> {
|
|
491
|
+
if (actor.kind === "app" && !actor.scopes.includes("sessions:launch")) throw new AgentError("forbidden", "Staffing by launch needs scope sessions:launch")
|
|
492
|
+
const requestID = `tsr_${randomBytes(12).toString("hex")}`
|
|
493
|
+
const spec = await this.launchSpec(userID, team, role, base, requestID)
|
|
494
|
+
if (spec.agentProfile !== undefined && actor.kind !== "user" && !(actor.kind === "app" && actor.scopes.includes("profiles:assign")))
|
|
495
|
+
throw new AgentError("forbidden", "A launch's agentProfile needs scope profiles:assign; sessions cannot pin profiles", { hint: "Ask the user to staff this role" })
|
|
496
|
+
const repo = (spec.workspace as { repo: string }).repo
|
|
497
|
+
if (!options.replaces) await this.checkRoom(userID, team, role)
|
|
498
|
+
const by = launchedBy(actor)
|
|
499
|
+
const needs: Need[] = actor.kind === "user" ? [] : [{ verb: "launch", directory: repo }, { verb: "roles", directory: repo }]
|
|
500
|
+
return this.authorized(userID, actor, "team.staff", needs, {}, async () => {
|
|
501
|
+
let ended: TeamSeatRecord | undefined
|
|
502
|
+
if (options.replaces) {
|
|
503
|
+
ended = await this.deps.seats.unstaff(userID, team.id, options.replaces.old.id, options.replaces.expectedRevision)
|
|
504
|
+
await this.emit(userID, teamUnstaffedEvent(ended))
|
|
505
|
+
await this.sync(userID, team.id)
|
|
506
|
+
}
|
|
507
|
+
const at = this.now()
|
|
508
|
+
const request: StaffingRequest = {
|
|
509
|
+
id: requestID,
|
|
510
|
+
teamID: team.id,
|
|
511
|
+
role: role.name,
|
|
512
|
+
state: "launching",
|
|
513
|
+
by,
|
|
514
|
+
...(options.replaces ? { replaces: { staffingID: options.replaces.old.id, generation: options.replaces.old.generation } } : {}),
|
|
515
|
+
createdAt: at,
|
|
516
|
+
updatedAt: at,
|
|
517
|
+
}
|
|
518
|
+
await this.writeDoc(userID, team.id, (doc) => {
|
|
519
|
+
if (!options.replaces && doc.requests.filter((r) => r.role === role.name && r.state === "launching").length >= roleLimit(role))
|
|
520
|
+
throw new AgentError("rate_limited", `Role ${role.name} of team ${team.name} holds at most ${roleLimit(role)} sessions`, { extra: { limit: roleLimit(role) } })
|
|
521
|
+
const live = this.liveKeys(doc)
|
|
522
|
+
if (options.key && live[options.key.key])
|
|
523
|
+
throw new AgentError("rate_limited", `The staffing for Idempotency-Key ${options.key.key} is still being launched`, { extra: { retryAfter: 1 } })
|
|
524
|
+
const keys = options.key ? { ...live, [options.key.key]: { requestID, digest: options.key.digest, at } } : doc.keys
|
|
525
|
+
return { ...doc, requests: retainedRequests([...doc.requests, request]), keys: this.boundedKeys(keys) }
|
|
526
|
+
})
|
|
527
|
+
let accepted: { launch: Launch; created: boolean }
|
|
528
|
+
try {
|
|
529
|
+
accepted = await this.deps.launches.accept(userID, spec, { by })
|
|
530
|
+
} catch (cause) {
|
|
531
|
+
await this.finish(userID, team.id, requestID, { state: "failed", reason: message(cause) })
|
|
532
|
+
throw cause
|
|
533
|
+
}
|
|
534
|
+
const stored = await this.finish(userID, team.id, requestID, { launchID: accepted.launch.id })
|
|
535
|
+
if (accepted.launch.state === "running" || accepted.launch.state === "ended") await this.launchChanged(userID, accepted.launch)
|
|
536
|
+
return { request: stored ?? { ...request, launchID: accepted.launch.id }, launch: accepted.launch, created: true, ...(ended ? { ended } : {}) }
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The launch body: the caller's, with the role's placement/harness/model/profile defaults and team labels. */
|
|
541
|
+
private async launchSpec(userID: string, team: Team, role: TeamRoleSpec, base: unknown, requestID: string): Promise<Record<string, unknown>> {
|
|
542
|
+
if (!isObj(base)) throw new AgentError("invalid", "launch must be a launch body (workspace, prompt, …)")
|
|
543
|
+
if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send an Idempotency-Key header instead" })
|
|
544
|
+
const labels = isObj(base.labels) ? base.labels : base.labels === undefined ? {} : undefined
|
|
545
|
+
if (!labels) throw new AgentError("invalid", "launch.labels must be an object")
|
|
546
|
+
if (TEAM_LAUNCH_LABEL in labels || TEAM_REQUEST_LABEL in labels) throw new AgentError("invalid", `launch.labels ${TEAM_LAUNCH_LABEL} and ${TEAM_REQUEST_LABEL} are set by staffing`)
|
|
547
|
+
const prefs = role.prefs ?? {}
|
|
548
|
+
const placement = isObj(base.placement) ? base.placement : base.placement === undefined ? {} : undefined
|
|
549
|
+
if (!placement) throw new AgentError("invalid", "launch.placement must be an object")
|
|
550
|
+
const machine = placement.machine ?? prefs.machineID
|
|
551
|
+
const harness = base.harness ?? prefs.harness
|
|
552
|
+
const model = base.model ?? prefs.model
|
|
553
|
+
const agentProfile = base.agentProfile ?? (role.profile ? await this.profileRef(userID, role.profile) : undefined)
|
|
554
|
+
const spec: Record<string, unknown> = {
|
|
555
|
+
...base,
|
|
556
|
+
key: requestID,
|
|
557
|
+
...(harness !== undefined ? { harness } : {}),
|
|
558
|
+
...(model !== undefined ? { model } : {}),
|
|
559
|
+
...(machine !== undefined || base.placement !== undefined ? { placement: { ...placement, ...(machine !== undefined ? { machine } : {}) } } : {}),
|
|
560
|
+
...(agentProfile !== undefined ? { agentProfile } : {}),
|
|
561
|
+
labels: { ...labels, [TEAM_LAUNCH_LABEL]: team.id, [TEAM_REQUEST_LABEL]: requestID },
|
|
562
|
+
}
|
|
563
|
+
validateLaunchSpec(spec)
|
|
564
|
+
return spec
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The role's profile as a launch pins it: its version, or the profile's latest. */
|
|
568
|
+
private async profileRef(userID: string, profile: NonNullable<TeamRoleSpec["profile"]>): Promise<{ profileID: string; version: number }> {
|
|
569
|
+
if (profile.version !== undefined) return { profileID: profile.profileID, version: profile.version }
|
|
570
|
+
const found = await this.deps.profiles?.get(userID, profile.profileID)
|
|
571
|
+
if (!found) throw new AgentError("invalid", `The role's profile ${profile.profileID} is not available`, { hint: "Fix the team's role profile, or name launch.agentProfile" })
|
|
572
|
+
return { profileID: profile.profileID, version: found.latestVersion }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** 429 when the role's current seats and pending launches reach its maxMembers. */
|
|
576
|
+
private async checkRoom(userID: string, team: Team, role: TeamRoleSpec): Promise<void> {
|
|
577
|
+
const seats = (await this.deps.seats.list(userID, team.id, { current: true })).filter((s) => s.role === role.name).length
|
|
578
|
+
const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, team.id))?.value, team.id)
|
|
579
|
+
const launching = doc.requests.filter((r) => r.role === role.name && r.state === "launching").length
|
|
580
|
+
if (seats + launching >= roleLimit(role))
|
|
581
|
+
throw new AgentError("rate_limited", `Role ${role.name} of team ${team.name} holds at most ${roleLimit(role)} sessions (staffed and launching)`, {
|
|
582
|
+
hint: "Unstaff or replace a member instead",
|
|
583
|
+
extra: { limit: roleLimit(role) },
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The request a live Idempotency-Key names (same launcher and body), with its launch. */
|
|
588
|
+
private async byKey(userID: string, teamID: string, key: string, digest: string, by: string): Promise<LaunchResult | undefined> {
|
|
589
|
+
const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
|
|
590
|
+
const held = this.liveKeys(doc)[key]
|
|
591
|
+
if (!held) return undefined
|
|
592
|
+
const request = doc.requests.find((r) => r.id === held.requestID)
|
|
593
|
+
if (!request) return undefined
|
|
594
|
+
if (request.by !== by) throw new AgentError("invalid", "This key was used by another launcher", { hint: "Use a key of your own" })
|
|
595
|
+
if (held.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for another staffing`, { hint: "Use a new key for a different body" })
|
|
596
|
+
const launch = request.launchID !== undefined ? await this.deps.launches.view(userID, request.launchID) : undefined
|
|
597
|
+
if (!launch) throw new AgentError("rate_limited", `The staffing for Idempotency-Key ${key} is still being launched`, { extra: { retryAfter: 1 } })
|
|
598
|
+
return { request, launch, created: false }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private async staffLaunched(userID: string, request: StaffingRequest, launch: Launch): Promise<void> {
|
|
602
|
+
const sessionID = launch.sessionID!
|
|
603
|
+
const where = { sessionID, ...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}) }
|
|
604
|
+
let staffing: TeamSeatRecord
|
|
605
|
+
let created: boolean
|
|
606
|
+
try {
|
|
607
|
+
const team = await this.deps.teams.require(userID, request.teamID)
|
|
608
|
+
if (request.replaces) {
|
|
609
|
+
staffable(team)
|
|
610
|
+
const out = await this.writeSeat(userID, team, roleOf(team, request.role), sessionID, { minGeneration: request.replaces.generation + 1 })
|
|
611
|
+
staffing = out.staffing
|
|
612
|
+
created = out.created
|
|
613
|
+
} else {
|
|
614
|
+
const out = await this.deps.seats.staff(userID, request.teamID, { role: request.role, sessionID })
|
|
615
|
+
staffing = out.staffing
|
|
616
|
+
created = out.created
|
|
617
|
+
}
|
|
618
|
+
} catch (cause) {
|
|
619
|
+
if (!(cause instanceof AgentError)) throw cause
|
|
620
|
+
await this.finish(userID, request.teamID, request.id, { state: "failed", reason: `The launched session was not staffed: ${cause.message}`, ...where })
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
const finished = await this.finish(userID, request.teamID, request.id, { state: "staffed", staffingID: staffing.id, ...where })
|
|
624
|
+
if (created && finished) await this.emit(userID, teamStaffedEvent(staffing))
|
|
625
|
+
await this.sync(userID, request.teamID)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Moves a launching request on (state, launch, session); undefined when it is no longer launching. */
|
|
629
|
+
private async finish(userID: string, teamID: string, requestID: string, change: Partial<StaffingRequest>): Promise<StaffingRequest | undefined> {
|
|
630
|
+
let out: StaffingRequest | undefined
|
|
631
|
+
await this.writeDoc(userID, teamID, (doc) => {
|
|
632
|
+
const request = doc.requests.find((r) => r.id === requestID)
|
|
633
|
+
if (!request || request.state !== "launching") return undefined
|
|
634
|
+
out = { ...request, ...change, updatedAt: this.now() }
|
|
635
|
+
return { ...doc, requests: retainedRequests(doc.requests.map((r) => (r.id === requestID ? out! : r))) }
|
|
636
|
+
})
|
|
637
|
+
return out
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------- internals: seats ----------
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* One version-checked write on the team's seats: optionally ends a seat (replacement) and staffs the session with
|
|
644
|
+
* at least `minGeneration` (and above its own earlier generations in the role). An existing current seat of the
|
|
645
|
+
* (role, session) is returned as is.
|
|
646
|
+
*/
|
|
647
|
+
private async writeSeat(
|
|
648
|
+
userID: string,
|
|
649
|
+
team: Team,
|
|
650
|
+
role: TeamRoleSpec,
|
|
651
|
+
sessionID: string,
|
|
652
|
+
options: { readonly end?: { readonly staffingID: string; readonly expectedRevision: number }; readonly minGeneration?: number },
|
|
653
|
+
): Promise<{ staffing: TeamSeatRecord; created: boolean; ended?: TeamSeatRecord }> {
|
|
654
|
+
let result: { staffing: TeamSeatRecord; created: boolean; ended?: TeamSeatRecord } | undefined
|
|
655
|
+
await updateVersioned<SeatsDoc>(this.state, userID, TEAM_SEATS, team.id, (raw) => {
|
|
656
|
+
const doc = readSeatsDoc(raw, team.id)
|
|
657
|
+
let seats = [...((doc?.seats ?? []) as TeamSeatRecord[])]
|
|
658
|
+
const at = this.now()
|
|
659
|
+
let ended: TeamSeatRecord | undefined
|
|
660
|
+
let floor = options.minGeneration ?? 1
|
|
661
|
+
if (options.end) {
|
|
662
|
+
const old = seats.find((s) => s.id === options.end!.staffingID)
|
|
663
|
+
if (!old) throw new AgentError("not_found", `No staffing ${options.end.staffingID} in team ${team.id}`)
|
|
664
|
+
if (old.revision !== options.end.expectedRevision)
|
|
665
|
+
throw new AgentError("conflict", `Staffing ${old.id} changed since you read it (revision ${old.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: old.revision } })
|
|
666
|
+
if (old.until !== undefined) throw new AgentError("invalid", `Staffing ${old.id} already ended`, { hint: "Staff the role instead" })
|
|
667
|
+
ended = { ...old, until: at, revision: old.revision + 1 }
|
|
668
|
+
seats = seats.map((s) => (s.id === old.id ? ended! : s))
|
|
669
|
+
floor = Math.max(floor, old.generation + 1)
|
|
670
|
+
}
|
|
671
|
+
const current = seats.filter((s) => s.until === undefined)
|
|
672
|
+
const same = current.find((s) => s.role === role.name && s.sessionID === sessionID)
|
|
673
|
+
if (same) {
|
|
674
|
+
if (ended) throw new AgentError("invalid", `Session ${sessionID} already holds role ${role.name}`, { hint: "Unstaff the other seat instead" })
|
|
675
|
+
result = { staffing: same, created: false }
|
|
676
|
+
return undefined
|
|
677
|
+
}
|
|
678
|
+
if (current.filter((s) => s.role === role.name).length >= roleLimit(role))
|
|
679
|
+
throw new AgentError("rate_limited", `Role ${role.name} of team ${team.name} holds at most ${roleLimit(role)} sessions`, { extra: { limit: roleLimit(role) } })
|
|
680
|
+
const generation = Math.max(floor, 1 + Math.max(0, ...seats.filter((s) => s.role === role.name && s.sessionID === sessionID).map((s) => s.generation)))
|
|
681
|
+
const staffing: TeamSeatRecord = { v: 1, id: this.newID("staffing"), teamID: team.id, role: role.name, sessionID, generation, since: at, revision: 1, teamVersion: team.version }
|
|
682
|
+
result = { staffing, created: true, ...(ended ? { ended } : {}) }
|
|
683
|
+
return { v: 1, teamID: team.id, seats: retainedSeats([...seats, staffing]), keys: doc?.keys ?? {} }
|
|
684
|
+
})
|
|
685
|
+
return result!
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ---------- internals: workspace and supervision ----------
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Brings the team's workspace in line with its current seats: creates/links it on first staffing, joins seated
|
|
692
|
+
* sessions, departs sessions this service joined that hold no seat any more (links move to `replacements`), and
|
|
693
|
+
* attaches unsupervised non-entry members under the earliest entry member. Archived workspace: left alone.
|
|
694
|
+
*/
|
|
695
|
+
private async sync(userID: string, teamID: string, replacements: ReadonlyMap<string, string> = new Map()): Promise<TeamWorkspaceLink | undefined> {
|
|
696
|
+
const team = await this.deps.teams.get(userID, teamID)
|
|
697
|
+
if (!team) return undefined
|
|
698
|
+
const current = await this.deps.seats.list(userID, teamID, { current: true })
|
|
699
|
+
const doc = readDoc((await this.state.get(userID, TEAM_STAFFING, teamID))?.value, teamID)
|
|
700
|
+
let link = await this.link(userID, doc.workspaceID)
|
|
701
|
+
if (!link || link.state === "missing") {
|
|
702
|
+
if (current.length === 0) return link
|
|
703
|
+
link = await this.createWorkspace(userID, team)
|
|
704
|
+
}
|
|
705
|
+
if (link.state !== "active") return link
|
|
706
|
+
const workspaceID = link.id
|
|
707
|
+
const seated = [...new Set(current.map((s) => s.sessionID))]
|
|
708
|
+
const entries = [...new Set(current.filter((s) => s.role === team.entryRole).map((s) => s.sessionID))]
|
|
709
|
+
const memberOf = new Map((await this.deps.members.members(userID, workspaceID)).map((m) => [m.member.sessionID, m.member.id]))
|
|
710
|
+
const added: Record<string, string> = {}
|
|
711
|
+
const removed = new Set<string>()
|
|
712
|
+
for (const sessionID of seated) {
|
|
713
|
+
if (memberOf.has(sessionID)) continue
|
|
714
|
+
try {
|
|
715
|
+
const { member, created } = await this.deps.members.join(userID, workspaceID, { sessionID, role: "worker" })
|
|
716
|
+
memberOf.set(sessionID, member.id)
|
|
717
|
+
if (created) {
|
|
718
|
+
added[sessionID] = member.id
|
|
719
|
+
await this.emit(userID, memberJoinedEvent(member))
|
|
720
|
+
}
|
|
721
|
+
} catch (cause) {
|
|
722
|
+
this.report(cause)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const seatedSet = new Set(seated)
|
|
726
|
+
for (const [sessionID, memberID] of Object.entries(doc.managed)) {
|
|
727
|
+
if (seatedSet.has(sessionID)) continue
|
|
728
|
+
removed.add(sessionID)
|
|
729
|
+
try {
|
|
730
|
+
const before = await this.deps.members.member(userID, workspaceID, memberID).catch(() => undefined)
|
|
731
|
+
const left = before ? await this.deps.members.leave(userID, workspaceID, memberID, { reason: "removed" }) : undefined
|
|
732
|
+
const next = replacements.get(sessionID)
|
|
733
|
+
const replacedBy = next !== undefined ? memberOf.get(next) : undefined
|
|
734
|
+
const departed = await this.deps.supervision.memberDeparted(userID, workspaceID, memberID, replacedBy !== undefined ? { replacedBy } : {})
|
|
735
|
+
if (left && before && left.revision !== before.revision) await this.emit(userID, memberLeftEvent(left))
|
|
736
|
+
if (departed.detached + departed.transferred + departed.pruned > 0) await this.emit(userID, supervisionDepartedEvent(memberID, departed))
|
|
737
|
+
memberOf.delete(sessionID)
|
|
738
|
+
} catch (cause) {
|
|
739
|
+
this.report(cause)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const supervisor = entries.map((s) => memberOf.get(s)).find((m) => m !== undefined)
|
|
743
|
+
if (supervisor !== undefined) {
|
|
744
|
+
const graph = await this.deps.supervision.graph(userID, workspaceID)
|
|
745
|
+
const supervised = new Set(graph.links.filter((l) => l.member === "current" && l.supervisor === "current").map((l) => l.link.memberID))
|
|
746
|
+
const entrySet = new Set(entries)
|
|
747
|
+
for (const sessionID of seated) {
|
|
748
|
+
const memberID = memberOf.get(sessionID)
|
|
749
|
+
if (entrySet.has(sessionID) || memberID === undefined || supervised.has(memberID)) continue
|
|
750
|
+
try {
|
|
751
|
+
const result = await this.deps.supervision.set(userID, workspaceID, { memberID, supervisorMemberID: supervisor })
|
|
752
|
+
if (result.change !== "unchanged") await this.emit(userID, supervisionSetEvent(memberID, result, result.link))
|
|
753
|
+
} catch (cause) {
|
|
754
|
+
this.report(cause)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (Object.keys(added).length > 0 || removed.size > 0) {
|
|
759
|
+
await this.writeDoc(userID, teamID, (d) => {
|
|
760
|
+
const managed = { ...d.managed, ...added }
|
|
761
|
+
for (const sessionID of removed) delete managed[sessionID]
|
|
762
|
+
return { ...d, managed }
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
return link
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async createWorkspace(userID: string, team: Team): Promise<TeamWorkspaceLink> {
|
|
769
|
+
const body = { title: team.title, metadata: { team: team.id } }
|
|
770
|
+
let created: Awaited<ReturnType<WorkspaceStore["create"]>>
|
|
771
|
+
try {
|
|
772
|
+
created = await this.deps.workspaces.create(userID, body, { idempotencyKey: `team-workspace:${team.id}` })
|
|
773
|
+
} catch (cause) {
|
|
774
|
+
// The key was used with an older title (a crash before the link was written): create without it.
|
|
775
|
+
if (!(cause instanceof AgentError) || cause.code !== "conflict") throw cause
|
|
776
|
+
created = await this.deps.workspaces.create(userID, body)
|
|
777
|
+
}
|
|
778
|
+
let linked = created.workspace.id
|
|
779
|
+
await this.writeDoc(userID, team.id, (doc) => {
|
|
780
|
+
if (doc.workspaceID !== undefined && doc.workspaceID !== created.workspace.id) {
|
|
781
|
+
linked = doc.workspaceID
|
|
782
|
+
return undefined
|
|
783
|
+
}
|
|
784
|
+
return doc.workspaceID === created.workspace.id ? undefined : { ...doc, workspaceID: created.workspace.id }
|
|
785
|
+
})
|
|
786
|
+
if (linked === created.workspace.id && created.created) await this.emit(userID, workspaceCreatedEvent(created.workspace))
|
|
787
|
+
return (await this.link(userID, linked)) ?? { id: linked, state: "missing" }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private async link(userID: string, workspaceID: string | undefined): Promise<TeamWorkspaceLink | undefined> {
|
|
791
|
+
if (workspaceID === undefined) return undefined
|
|
792
|
+
const workspace = await this.deps.workspaces.get(userID, workspaceID)
|
|
793
|
+
return { id: workspaceID, state: workspace ? workspace.state : "missing" }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ---------- internals: authority, profiles, storage ----------
|
|
797
|
+
|
|
798
|
+
/** A session the user's directory knows (unknown/deleted: not_found); returns its ID. */
|
|
799
|
+
private async knownSession(userID: string, sessionID: unknown): Promise<string> {
|
|
800
|
+
if (typeof sessionID !== "string" || sessionID.length === 0 || sessionID.length > 200) throw new AgentError("invalid", "sessionID must be a session ID")
|
|
801
|
+
const presence = await this.deps.presence(userID, sessionID)
|
|
802
|
+
if (presence === "unknown" || presence === "deleted") throw new AgentError("not_found", `No session ${sessionID}`)
|
|
803
|
+
return sessionID
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The session's project for a grant check; offline when no spoke reports it (as role writes answer). */
|
|
807
|
+
private directoryOf(userID: string, sessionID: string): string {
|
|
808
|
+
const directory = this.deps.sessionDirectory(userID, sessionID)
|
|
809
|
+
if (directory === undefined) throw new AgentError("offline", "No spoke reports this session now, so its project cannot be checked against a grant")
|
|
810
|
+
return directory
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Runs `run` for the caller. Agents: kill switch → every needed grant usable (else needs_user listing the missing
|
|
815
|
+
* ones; nothing issued, nothing escalated) → one action spent per grant → kill switch again. Audited.
|
|
816
|
+
*/
|
|
817
|
+
private async authorized<T>(userID: string, actor: AgentActor, verb: string, needs: readonly Need[], target: AuditTarget, run: () => Promise<T>): Promise<T> {
|
|
818
|
+
const used: Grant[] = []
|
|
819
|
+
const record = async (decision: ReturnType<typeof auditOf>[0], outcome: ReturnType<typeof auditOf>[1], reason?: string) => {
|
|
820
|
+
await this.deps.audit
|
|
821
|
+
?.record(userID, { actor: partyFromActor(actor), verb, target, ...(used[0] ? { grant: used[0].id } : {}), decision, ...(reason ? { reason } : {}), outcome })
|
|
822
|
+
.catch(this.report)
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
if (actor.kind !== "user") {
|
|
826
|
+
this.deps.acting.ensureActing(userID)
|
|
827
|
+
const missing: Need[] = []
|
|
828
|
+
const found: Grant[] = []
|
|
829
|
+
for (const need of needs) {
|
|
830
|
+
const usable = await this.deps.grants.usable(actor, need.verb, { directory: need.directory })
|
|
831
|
+
if (usable.length === 0) missing.push(need)
|
|
832
|
+
else found.push(usable[0]!)
|
|
833
|
+
}
|
|
834
|
+
if (missing.length > 0)
|
|
835
|
+
throw new AgentError("needs_user", `Needs the user: no grant lets this ${actor.kind} ${missing.map((m) => `${m.verb} in ${m.directory}`).join(" or ")}`, {
|
|
836
|
+
hint: "The user staffs this role, or grants the missing verbs (an agent may ask with a grant request); staffing never issues grants",
|
|
837
|
+
extra: { missing },
|
|
838
|
+
})
|
|
839
|
+
for (const grant of found) {
|
|
840
|
+
await this.deps.grants.consume(grant)
|
|
841
|
+
used.push(grant)
|
|
842
|
+
}
|
|
843
|
+
this.deps.acting.ensureActing(userID)
|
|
844
|
+
}
|
|
845
|
+
const result = await run()
|
|
846
|
+
await record("allow", "ok")
|
|
847
|
+
return result
|
|
848
|
+
} catch (cause) {
|
|
849
|
+
const [decision, outcome] = auditOf(cause)
|
|
850
|
+
await record(decision, outcome, message(cause))
|
|
851
|
+
throw cause
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private async profileStatus(userID: string, sessionID: string, requested: NonNullable<TeamRoleSpec["profile"]>): Promise<SeatProfileStatus> {
|
|
856
|
+
const want = { profileID: requested.profileID, ...(requested.version !== undefined ? { version: requested.version } : {}) }
|
|
857
|
+
const assigned = await this.deps.profiles?.assignment(userID, sessionID)
|
|
858
|
+
if (!assigned) return { requested: want, status: "unassigned", matches: false }
|
|
859
|
+
return {
|
|
860
|
+
requested: want,
|
|
861
|
+
assigned: { profileID: assigned.profileID, version: assigned.version },
|
|
862
|
+
status: assigned.ack?.status ?? "pending",
|
|
863
|
+
matches: assigned.profileID === requested.profileID && (requested.version === undefined || requested.version === assigned.version),
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private async writeDoc(userID: string, teamID: string, change: (doc: StaffingDoc) => StaffingDoc | undefined): Promise<void> {
|
|
868
|
+
await updateVersioned<StaffingDoc>(this.state, userID, TEAM_STAFFING, teamID, (raw) => change(readDoc(raw, teamID)))
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private liveKeys(doc: StaffingDoc): Record<string, RequestKey> {
|
|
872
|
+
const now = this.now()
|
|
873
|
+
const out: Record<string, RequestKey> = {}
|
|
874
|
+
for (const [k, v] of Object.entries(doc.keys)) if (isObj(v) && typeof v.at === "number" && now - v.at < IDEMPOTENCY_TTL_MS) out[k] = v
|
|
875
|
+
return out
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private boundedKeys(keys: Readonly<Record<string, RequestKey>>): Record<string, RequestKey> {
|
|
879
|
+
const entries = Object.entries(keys)
|
|
880
|
+
if (entries.length <= KEYS_PER_TEAM) return { ...keys }
|
|
881
|
+
return Object.fromEntries(entries.sort((a, b) => b[1].at - a[1].at).slice(0, KEYS_PER_TEAM))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private async emit(userID: string, ...events: EventInput[]): Promise<void> {
|
|
885
|
+
if (!this.deps.events) return
|
|
886
|
+
for (const event of events) await this.deps.events.append(userID, event).catch(this.report)
|
|
887
|
+
}
|
|
888
|
+
}
|