@huynle/hub 0.7.3 → 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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- 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 +633 -50
- 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,272 @@
|
|
|
1
|
+
// Team membership storage (HWA-33; contract TeamStaffing / TeamSeat in packages/protocol/src/teams.ts, decision
|
|
2
|
+
// zltiwb4s 2026-10-28: many-to-many membership per (team, role, session, generation)).
|
|
3
|
+
//
|
|
4
|
+
// Storage: `team-seats`, one aggregate per team (key: team ID; collections.ts) holding its staffing records, current
|
|
5
|
+
// and ended, plus the staffing Idempotency-Keys, so the per-role cap, "one current seat per (role, session)" and a
|
|
6
|
+
// key's claim are one version-checked write. A session may hold several roles in one team and seats in several
|
|
7
|
+
// teams. Each record carries its own `revision` (1 on staffing, +1 when ended); a stale expectedRevision is 409
|
|
8
|
+
// conflict with currentRevision. Re-staffing a (role, session) whose seat ended is the next `generation` (counted
|
|
9
|
+
// from the retained records). Additive: `teamVersion`, the published team version the seat was staffed under; current
|
|
10
|
+
// seats pin it against version retention (pinnedVersions).
|
|
11
|
+
//
|
|
12
|
+
// Seats grant no authority: staffing a session needs the caller's existing grants (HWA-35 launches and checks them;
|
|
13
|
+
// this store only records). Pause and archive refuse new seats; existing seats stay (an archived team's seats are
|
|
14
|
+
// ignored by teamDeliveryVerdict, which only consults active teams). Session deletion ends seats via sessionEnded
|
|
15
|
+
// (HWA-35 wires Registry.onSessionDeleted). Ended seats are retained up to endedSeatsRetained per team.
|
|
16
|
+
//
|
|
17
|
+
// Read API for delivery (HWA-34) and discovery (HWA-40): seatsOf(userID, sessionID) and deliveryContext(userID,
|
|
18
|
+
// sessionIDs) give exactly teamDeliveryVerdict's senderSeats / recipientSeats / teams inputs.
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
20
|
+
import {
|
|
21
|
+
ID_PREFIXES,
|
|
22
|
+
IDEMPOTENCY_KEY,
|
|
23
|
+
IDEMPOTENCY_TTL_MS,
|
|
24
|
+
type IdKind,
|
|
25
|
+
isExpectedRevision,
|
|
26
|
+
isSlug,
|
|
27
|
+
TEAM_LIMITS,
|
|
28
|
+
type TeamDefinition,
|
|
29
|
+
type TeamSeat,
|
|
30
|
+
type TeamStaffing,
|
|
31
|
+
} from "../../protocol"
|
|
32
|
+
import { canonicalBody } from "../agent/idempotency"
|
|
33
|
+
import { AgentError } from "../agent/errors"
|
|
34
|
+
import { updateVersioned } from "../agent/versioned"
|
|
35
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
36
|
+
import { currentSeats, readSeatsDoc, type SeatKey, type SeatsDoc, TEAM_SEATS } from "./collections"
|
|
37
|
+
import type { TeamStore } from "./store"
|
|
38
|
+
import { TEAM_STORE_LIMITS } from "./validation"
|
|
39
|
+
|
|
40
|
+
const SESSION_ID = /^[A-Za-z0-9_.:-]{1,200}$/
|
|
41
|
+
const STAFF_FIELDS = new Set(["role", "sessionID"])
|
|
42
|
+
/** Live staffing Idempotency-Keys kept per team aggregate (oldest dropped first). */
|
|
43
|
+
const KEYS_PER_TEAM = 1_000
|
|
44
|
+
|
|
45
|
+
/** A staffing record: the contract TeamStaffing plus the published version it was staffed under. */
|
|
46
|
+
export interface TeamSeatRecord extends TeamStaffing {
|
|
47
|
+
readonly teamVersion: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>
|
|
51
|
+
|
|
52
|
+
export interface DeliveryContext {
|
|
53
|
+
/** Each asked session's current seats (empty when it holds none). */
|
|
54
|
+
readonly seats: ReadonlyMap<string, readonly TeamSeat[]>
|
|
55
|
+
/** The teams those seats are in, as teamDeliveryVerdict reads them. */
|
|
56
|
+
readonly teams: DeliveryTeams
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TeamSeatStoreDeps {
|
|
60
|
+
readonly state: StateStore
|
|
61
|
+
readonly teams: TeamStore
|
|
62
|
+
readonly now?: Clock
|
|
63
|
+
readonly newID?: (kind: IdKind) => string
|
|
64
|
+
/** Says whether the user's directory knows the session (unknown or another user's: not_found). */
|
|
65
|
+
readonly knowsSession?: (userID: string, sessionID: string) => boolean | Promise<boolean>
|
|
66
|
+
readonly limits?: { readonly endedSeatsRetained?: number }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
70
|
+
const digestOf = (v: unknown) => createHash("sha256").update(canonicalBody(v)).digest("hex")
|
|
71
|
+
|
|
72
|
+
export class TeamSeatStore {
|
|
73
|
+
private readonly state: StateStore
|
|
74
|
+
private readonly teams: TeamStore
|
|
75
|
+
private readonly now: Clock
|
|
76
|
+
private readonly newID: (kind: IdKind) => string
|
|
77
|
+
private readonly endedRetained: number
|
|
78
|
+
|
|
79
|
+
constructor(private readonly deps: TeamSeatStoreDeps) {
|
|
80
|
+
this.state = deps.state
|
|
81
|
+
this.teams = deps.teams
|
|
82
|
+
this.now = deps.now ?? Date.now
|
|
83
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
84
|
+
this.endedRetained = deps.limits?.endedSeatsRetained ?? TEAM_STORE_LIMITS.endedSeatsRetained
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Staffs a session in a role of an active, unpaused team (its published definition). The same current
|
|
89
|
+
* (role, session) again returns the seat (created false). 429 past the role's maxMembers (else sessionsPerRole).
|
|
90
|
+
*/
|
|
91
|
+
async staff(userID: string, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ staffing: TeamSeatRecord; created: boolean }> {
|
|
92
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
93
|
+
const unknown = Object.keys(input).find((k) => !STAFF_FIELDS.has(k))
|
|
94
|
+
if (unknown !== undefined) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(unknown) ? unknown : "(unprintable)"}`)
|
|
95
|
+
const { role, sessionID } = input
|
|
96
|
+
if (!isSlug(role)) throw new AgentError("invalid", "role must be a role name of the team")
|
|
97
|
+
if (typeof sessionID !== "string" || !SESSION_ID.test(sessionID)) throw new AgentError("invalid", "sessionID must be a session ID")
|
|
98
|
+
const key = options.idempotencyKey
|
|
99
|
+
if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
100
|
+
const team = await this.teams.require(userID, teamID)
|
|
101
|
+
if (team.state === "archived") throw new AgentError("invalid", `Team ${teamID} is archived`, { hint: "Restore it first" })
|
|
102
|
+
if (team.paused) throw new AgentError("invalid", `Team ${teamID} is paused`, { hint: "Resume it first" })
|
|
103
|
+
const roleSpec = team.roles.find((r) => r.name === role)
|
|
104
|
+
if (!roleSpec) throw new AgentError("invalid", `Team ${team.name} has no role ${role}`)
|
|
105
|
+
if (this.deps.knowsSession && !(await this.deps.knowsSession(userID, sessionID))) throw new AgentError("not_found", `No session ${sessionID}`)
|
|
106
|
+
const limit = Math.min(roleSpec.maxMembers ?? TEAM_LIMITS.sessionsPerRole, TEAM_LIMITS.sessionsPerRole)
|
|
107
|
+
const digest = digestOf({ role, sessionID })
|
|
108
|
+
let result: { staffing: TeamSeatRecord; created: boolean } | undefined
|
|
109
|
+
await updateVersioned<SeatsDoc>(this.state, userID, TEAM_SEATS, teamID, (raw) => {
|
|
110
|
+
const doc = readSeatsDoc(raw, teamID)
|
|
111
|
+
const now = this.now()
|
|
112
|
+
const keys = this.liveKeys(doc, now)
|
|
113
|
+
const seats = (doc?.seats ?? []) as TeamSeatRecord[]
|
|
114
|
+
if (key !== undefined && keys[key]) {
|
|
115
|
+
if (keys[key].digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for another staffing`, { hint: "Use a new key for a different body" })
|
|
116
|
+
const original = seats.find((s) => s.id === keys[key]!.staffingID)
|
|
117
|
+
if (original) {
|
|
118
|
+
result = { staffing: original, created: false }
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const current = currentSeats(doc) as TeamSeatRecord[]
|
|
123
|
+
const same = current.find((s) => s.role === role && s.sessionID === sessionID)
|
|
124
|
+
if (same) {
|
|
125
|
+
result = { staffing: same, created: false }
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
if (current.filter((s) => s.role === role).length >= limit)
|
|
129
|
+
throw new AgentError("rate_limited", `Role ${role} of team ${team.name} holds at most ${limit} sessions`, { hint: "Unstaff a session first", extra: { limit } })
|
|
130
|
+
const generation = 1 + Math.max(0, ...seats.filter((s) => s.role === role && s.sessionID === sessionID).map((s) => s.generation))
|
|
131
|
+
const staffing: TeamSeatRecord = { v: 1, id: this.newID("staffing"), teamID, role, sessionID, generation, since: now, revision: 1, teamVersion: team.version }
|
|
132
|
+
result = { staffing, created: true }
|
|
133
|
+
const nextKeys = key === undefined ? keys : { ...keys, [key]: { staffingID: staffing.id, digest, at: now } }
|
|
134
|
+
return { v: 1, teamID, seats: this.retained([...seats, staffing]), keys: this.boundedKeys(nextKeys) }
|
|
135
|
+
})
|
|
136
|
+
return result!
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Ends a seat under its expected revision; ending an ended seat is a no-op. Allowed on archived teams. */
|
|
140
|
+
async unstaff(userID: string, teamID: string, staffingID: string, expectedRevision: number): Promise<TeamSeatRecord> {
|
|
141
|
+
if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision is required (the revision you read)")
|
|
142
|
+
await this.teams.require(userID, teamID)
|
|
143
|
+
let result: TeamSeatRecord | undefined
|
|
144
|
+
await updateVersioned<SeatsDoc>(this.state, userID, TEAM_SEATS, teamID, (raw) => {
|
|
145
|
+
const doc = readSeatsDoc(raw, teamID)
|
|
146
|
+
const seats = (doc?.seats ?? []) as TeamSeatRecord[]
|
|
147
|
+
const seat = seats.find((s) => s.id === staffingID)
|
|
148
|
+
if (!seat) throw new AgentError("not_found", `No staffing ${staffingID} in team ${teamID}`)
|
|
149
|
+
if (seat.revision !== expectedRevision)
|
|
150
|
+
throw new AgentError("conflict", `Staffing ${staffingID} changed since you read it (revision ${seat.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: seat.revision } })
|
|
151
|
+
if (seat.until !== undefined) {
|
|
152
|
+
result = seat
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
const ended: TeamSeatRecord = { ...seat, until: this.now(), revision: seat.revision + 1 }
|
|
156
|
+
result = ended
|
|
157
|
+
return { v: 1, teamID, seats: this.retained(seats.map((s) => (s.id === staffingID ? ended : s))), keys: doc?.keys ?? {} }
|
|
158
|
+
})
|
|
159
|
+
return result!
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Ends every current seat of a deleted session, in every team; returns the ended seats. */
|
|
163
|
+
async sessionEnded(userID: string, sessionID: string): Promise<TeamSeatRecord[]> {
|
|
164
|
+
const ended: TeamSeatRecord[] = []
|
|
165
|
+
for (const entry of await this.state.list(userID, TEAM_SEATS)) {
|
|
166
|
+
if (!this.readable(entry.value, entry.key)?.seats.some((s) => s.sessionID === sessionID && s.until === undefined)) continue
|
|
167
|
+
await updateVersioned<SeatsDoc>(this.state, userID, TEAM_SEATS, entry.key, (raw) => {
|
|
168
|
+
const doc = readSeatsDoc(raw, entry.key)
|
|
169
|
+
if (!doc) return undefined
|
|
170
|
+
const at = this.now()
|
|
171
|
+
const now: TeamSeatRecord[] = []
|
|
172
|
+
const seats = (doc.seats as TeamSeatRecord[]).map((s) => {
|
|
173
|
+
if (s.sessionID !== sessionID || s.until !== undefined) return s
|
|
174
|
+
const e = { ...s, until: at, revision: s.revision + 1 }
|
|
175
|
+
now.push(e)
|
|
176
|
+
return e
|
|
177
|
+
})
|
|
178
|
+
if (now.length === 0) return undefined
|
|
179
|
+
ended.push(...now)
|
|
180
|
+
return { ...doc, seats: this.retained(seats) }
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return ended
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The team's seats by start time (current only with `current: true`). */
|
|
187
|
+
async list(userID: string, teamID: string, options: { readonly current?: boolean } = {}): Promise<TeamSeatRecord[]> {
|
|
188
|
+
await this.teams.require(userID, teamID)
|
|
189
|
+
const doc = readSeatsDoc((await this.state.get(userID, TEAM_SEATS, teamID))?.value, teamID)
|
|
190
|
+
const seats = (options.current ? currentSeats(doc) : [...(doc?.seats ?? [])]) as TeamSeatRecord[]
|
|
191
|
+
return seats.sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The session's current seats in all the user's teams (unreadable aggregates are skipped). */
|
|
195
|
+
async seatsOf(userID: string, sessionID: string): Promise<TeamSeat[]> {
|
|
196
|
+
return (await this.seatsBySession(userID, [sessionID])).get(sessionID) ?? []
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Seats of several sessions and the teams they are in: teamDeliveryVerdict's inputs (HWA-34). */
|
|
200
|
+
async deliveryContext(userID: string, sessionIDs: readonly string[]): Promise<DeliveryContext> {
|
|
201
|
+
const seats = await this.seatsBySession(userID, sessionIDs)
|
|
202
|
+
const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>()
|
|
203
|
+
for (const teamID of new Set([...seats.values()].flat().map((s) => s.teamID))) {
|
|
204
|
+
try {
|
|
205
|
+
const team = await this.teams.get(userID, teamID)
|
|
206
|
+
if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state })
|
|
207
|
+
} catch {
|
|
208
|
+
// A team this build cannot read takes no part in delivery (its seats are then ignored by the verdict).
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Seats of unreadable or missing teams are dropped: the verdict requires every seat's team to be in the map.
|
|
212
|
+
const known = new Map([...seats].map(([id, list]) => [id, list.filter((s) => teams.has(s.teamID))] as const))
|
|
213
|
+
return { seats: known, teams }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** (RP-08) Current seat counts per team and role, in one scan of the user's seats. */
|
|
217
|
+
async currentCounts(userID: string): Promise<Map<string, Map<string, number>>> {
|
|
218
|
+
const out = new Map<string, Map<string, number>>()
|
|
219
|
+
for (const entry of await this.state.list(userID, TEAM_SEATS)) {
|
|
220
|
+
const roles = new Map<string, number>()
|
|
221
|
+
for (const seat of currentSeats(this.readable(entry.value, entry.key))) roles.set(seat.role, (roles.get(seat.role) ?? 0) + 1)
|
|
222
|
+
out.set(entry.key, roles)
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Published versions current seats were staffed under (TeamStore retention keeps them). */
|
|
228
|
+
async pinnedVersions(userID: string, teamID: string): Promise<ReadonlySet<number>> {
|
|
229
|
+
const doc = this.readable((await this.state.get(userID, TEAM_SEATS, teamID))?.value, teamID)
|
|
230
|
+
return new Set((currentSeats(doc) as TeamSeatRecord[]).map((s) => s.teamVersion).filter((v) => Number.isSafeInteger(v)))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- internals ----------
|
|
234
|
+
|
|
235
|
+
private async seatsBySession(userID: string, sessionIDs: readonly string[]): Promise<Map<string, TeamSeat[]>> {
|
|
236
|
+
const wanted = new Set(sessionIDs)
|
|
237
|
+
const out = new Map<string, TeamSeat[]>(sessionIDs.map((id) => [id, []]))
|
|
238
|
+
for (const entry of await this.state.list(userID, TEAM_SEATS)) {
|
|
239
|
+
for (const seat of currentSeats(this.readable(entry.value, entry.key))) {
|
|
240
|
+
if (wanted.has(seat.sessionID)) out.get(seat.sessionID)!.push({ teamID: seat.teamID, role: seat.role })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private readable(raw: unknown, teamID: string): SeatsDoc | undefined {
|
|
247
|
+
try {
|
|
248
|
+
return readSeatsDoc(raw, teamID)
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Current seats plus the newest endedRetained ended ones. */
|
|
255
|
+
private retained(seats: readonly TeamSeatRecord[]): TeamSeatRecord[] {
|
|
256
|
+
const ended = seats.filter((s) => s.until !== undefined).sort((a, b) => b.until! - a.until! || (a.id < b.id ? 1 : -1))
|
|
257
|
+
const keep = new Set(ended.slice(0, this.endedRetained).map((s) => s.id))
|
|
258
|
+
return seats.filter((s) => s.until === undefined || keep.has(s.id))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private liveKeys(doc: SeatsDoc | undefined, now: number): Record<string, SeatKey> {
|
|
262
|
+
const out: Record<string, SeatKey> = {}
|
|
263
|
+
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
|
|
264
|
+
return out
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private boundedKeys(keys: Record<string, SeatKey>): Record<string, SeatKey> {
|
|
268
|
+
const entries = Object.entries(keys)
|
|
269
|
+
if (entries.length <= KEYS_PER_TEAM) return keys
|
|
270
|
+
return Object.fromEntries(entries.sort((a, b) => b[1].at - a[1].at).slice(0, KEYS_PER_TEAM))
|
|
271
|
+
}
|
|
272
|
+
}
|