@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,667 @@
|
|
|
1
|
+
// Persistent team definitions (HWA-33; contract packages/protocol/src/teams.ts and docs/plans/2026-10-30-hub-
|
|
2
|
+
// workspaces-artifacts-contracts.md, Teams + clarifications; decisions zltiwb4s, 5etngvgm, r3vv4eon, qxzrdc6c).
|
|
3
|
+
//
|
|
4
|
+
// A team is a versioned definition (roles, one entry role, open/restricted communication with directed edges,
|
|
5
|
+
// tags/metadata/projects), independent of any running session. It grants no authority.
|
|
6
|
+
//
|
|
7
|
+
// Storage: StateStore collections under the hub user (the tenant key; another user's records are absent), so the
|
|
8
|
+
// standalone file store and the cluster's Postgres hub_state behave alike and need no migration:
|
|
9
|
+
// - `teams` (key team ID): the record — the published spec, state (active/archived), `paused`, the published
|
|
10
|
+
// `version`, the retained version history (number, digest, publishedAt) and at most one pending `draft`.
|
|
11
|
+
// `revision` is the StateStore version of the key: every write is version-checked (ifVersion), so a stale
|
|
12
|
+
// expectedRevision is 409 conflict with currentRevision, and of two writers holding one revision exactly one wins.
|
|
13
|
+
// - `team-version.<team ID>` (key `<n>`): one immutable published revision each ({spec, digest, publishedAt}).
|
|
14
|
+
// Slot n is written only by the writer whose record CAS made n the published version (or rebuilt from the record
|
|
15
|
+
// while n is still current, after a crash between the two writes), so a slot never holds another writer's spec.
|
|
16
|
+
// - `team-index` (key `names`): {name → {id, at, archived?}} — unique names per user (the `#team` address) and the
|
|
17
|
+
// cap on active teams (TEAM_LIMITS.teamsPerUser, 429). A reservation whose record never appeared is reclaimed
|
|
18
|
+
// after reservationMs.
|
|
19
|
+
// - `team-idempotency` (key: Idempotency-Key): {teamID, digest, at}, valid IDEMPOTENCY_TTL_MS.
|
|
20
|
+
// - `team-settings` (key `owner`): the owner's `teamIsolation` setting (default off; HWA-40 reads it).
|
|
21
|
+
//
|
|
22
|
+
// Edits are drafts: saveDraft builds on the pending draft (or the published spec) and publish freezes it as the next
|
|
23
|
+
// version; the published spec is what delivery (HWA-34) and staffing (HWA-35) use. Retention keeps the newest
|
|
24
|
+
// versionsRetained versions plus any a caller pins (pinnedVersions, e.g. staffing referencing a revision).
|
|
25
|
+
// Pause and archive are operational states and change no definition. Archived teams keep their name reserved and
|
|
26
|
+
// do not count toward the cap; restore re-checks it.
|
|
27
|
+
//
|
|
28
|
+
// Records from other builds: a newer `v` is never guessed at (lists skip it; reads and writes answer 422
|
|
29
|
+
// unsupported); a malformed record is skipped by lists and a 500 on read. Older hubs never read these collections.
|
|
30
|
+
import { randomBytes } from "node:crypto"
|
|
31
|
+
import {
|
|
32
|
+
CONTRACT_VERSION,
|
|
33
|
+
ID_PREFIXES,
|
|
34
|
+
IDEMPOTENCY_KEY,
|
|
35
|
+
IDEMPOTENCY_TTL_MS,
|
|
36
|
+
type IdKind,
|
|
37
|
+
isExpectedRevision,
|
|
38
|
+
isLabel,
|
|
39
|
+
LIST_LIMIT_DEFAULT,
|
|
40
|
+
LIST_LIMIT_MAX,
|
|
41
|
+
metadataProblem,
|
|
42
|
+
type Page,
|
|
43
|
+
RECORD_STATES,
|
|
44
|
+
type RecordState,
|
|
45
|
+
TEAM_LIMITS,
|
|
46
|
+
type TeamDefinition,
|
|
47
|
+
tagsProblem,
|
|
48
|
+
} from "../../protocol"
|
|
49
|
+
import { AgentError } from "../agent/errors"
|
|
50
|
+
import { updateVersioned } from "../agent/versioned"
|
|
51
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
52
|
+
import { currentSeats, readSeatsDoc, TEAM_SEATS } from "./collections"
|
|
53
|
+
import { diffTeamSpecs, specDigest, TEAM_STORE_LIMITS, type TeamDiff, type TeamRoleSpec, type TeamSpec, teamPatchOf, teamSpecOf } from "./validation"
|
|
54
|
+
|
|
55
|
+
export const TEAMS = "teams"
|
|
56
|
+
export const TEAM_INDEX = "team-index"
|
|
57
|
+
export const TEAM_IDEMPOTENCY = "team-idempotency"
|
|
58
|
+
export const TEAM_SETTINGS = "team-settings"
|
|
59
|
+
export const teamVersionCollection = (teamID: string): string => `team-version.${teamID}`
|
|
60
|
+
const NAMES = "names"
|
|
61
|
+
const OWNER = "owner"
|
|
62
|
+
const ATTEMPTS = 16
|
|
63
|
+
/** Reads of an idempotency key's team: its creator may be between the claim and the write. */
|
|
64
|
+
const READS = 5
|
|
65
|
+
|
|
66
|
+
export interface TeamStoreLimits {
|
|
67
|
+
readonly teamsPerUser: number
|
|
68
|
+
readonly versionsRetained: number
|
|
69
|
+
/** A reservation (name, idempotency key) whose team never got written blocks this long. */
|
|
70
|
+
readonly reservationMs: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One retained published revision as history shows it. */
|
|
74
|
+
export interface TeamVersionEntry {
|
|
75
|
+
readonly version: number
|
|
76
|
+
readonly digest: string
|
|
77
|
+
readonly publishedAt: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An immutable published revision. */
|
|
81
|
+
export interface TeamVersion {
|
|
82
|
+
readonly v: 1
|
|
83
|
+
readonly teamID: string
|
|
84
|
+
readonly version: number
|
|
85
|
+
readonly spec: TeamSpec
|
|
86
|
+
readonly digest: string
|
|
87
|
+
readonly publishedAt: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Pending edits (published on explicit publish). */
|
|
91
|
+
export interface TeamDraft {
|
|
92
|
+
readonly baseVersion: number
|
|
93
|
+
readonly spec: TeamSpec
|
|
94
|
+
readonly digest: string
|
|
95
|
+
readonly savedAt: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A team as reads show it: the contract TeamDefinition of the published spec plus the additive fields. */
|
|
99
|
+
export interface Team extends TeamDefinition {
|
|
100
|
+
readonly roles: readonly TeamRoleSpec[]
|
|
101
|
+
readonly projects?: readonly string[]
|
|
102
|
+
/** The published revision number (1 on create, +1 per publish). */
|
|
103
|
+
readonly version: number
|
|
104
|
+
/** Present (true) while paused: intake and new staffing stop; policy still applies. */
|
|
105
|
+
readonly paused?: true
|
|
106
|
+
readonly draft?: Omit<TeamDraft, "spec">
|
|
107
|
+
readonly createdAt: number
|
|
108
|
+
readonly publishedAt: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TeamListQuery {
|
|
112
|
+
/** active (default), archived or all. */
|
|
113
|
+
readonly state?: string
|
|
114
|
+
/** Teams carrying every one of these tags. */
|
|
115
|
+
readonly tags?: readonly string[]
|
|
116
|
+
readonly project?: string
|
|
117
|
+
/** Teams whose metadata has every one of these key/value pairs. */
|
|
118
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
119
|
+
readonly paused?: boolean
|
|
120
|
+
readonly limit?: number
|
|
121
|
+
readonly cursor?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface TeamSettings {
|
|
125
|
+
readonly teamIsolation: boolean
|
|
126
|
+
readonly revision: number
|
|
127
|
+
readonly updatedAt?: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Versions a caller still references (never dropped by retention). */
|
|
131
|
+
export type PinnedVersions = (userID: string, teamID: string) => Promise<ReadonlySet<number>>
|
|
132
|
+
|
|
133
|
+
export interface TeamStoreDeps {
|
|
134
|
+
readonly state: StateStore
|
|
135
|
+
readonly now?: Clock
|
|
136
|
+
readonly newID?: (kind: IdKind) => string
|
|
137
|
+
readonly limits?: Partial<TeamStoreLimits>
|
|
138
|
+
readonly pinnedVersions?: PinnedVersions
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface TeamRecord {
|
|
142
|
+
readonly v: 1
|
|
143
|
+
readonly id: string
|
|
144
|
+
readonly spec: TeamSpec
|
|
145
|
+
readonly state: RecordState
|
|
146
|
+
readonly paused?: true
|
|
147
|
+
readonly version: number
|
|
148
|
+
readonly history: readonly TeamVersionEntry[]
|
|
149
|
+
readonly draft?: TeamDraft
|
|
150
|
+
readonly createdAt: number
|
|
151
|
+
readonly publishedAt: number
|
|
152
|
+
readonly updatedAt: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface NameEntry {
|
|
156
|
+
readonly id: string
|
|
157
|
+
readonly at: number
|
|
158
|
+
readonly archived?: true
|
|
159
|
+
}
|
|
160
|
+
interface NameIndex {
|
|
161
|
+
readonly v: 1
|
|
162
|
+
readonly names: Readonly<Record<string, NameEntry>>
|
|
163
|
+
}
|
|
164
|
+
interface IdempotencyDoc {
|
|
165
|
+
readonly v: 1
|
|
166
|
+
readonly teamID: string
|
|
167
|
+
readonly digest: string
|
|
168
|
+
readonly at: number
|
|
169
|
+
}
|
|
170
|
+
interface SettingsDoc {
|
|
171
|
+
readonly v: 1
|
|
172
|
+
readonly teamIsolation: boolean
|
|
173
|
+
readonly updatedAt: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type Readout = { readonly ok: true; readonly record: TeamRecord } | { readonly ok: false; readonly why: "newer" | "malformed"; readonly v?: unknown }
|
|
177
|
+
|
|
178
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
179
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
180
|
+
|
|
181
|
+
const conflict = (what: string, currentRevision: number) =>
|
|
182
|
+
new AgentError("conflict", `${what} changed since you read it (revision ${currentRevision} now)`, {
|
|
183
|
+
hint: "Read it again and retry with its current revision",
|
|
184
|
+
extra: { currentRevision },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const notFound = (id: string) => new AgentError("not_found", `No team ${id}`)
|
|
188
|
+
|
|
189
|
+
function checkExpected(v: unknown): number {
|
|
190
|
+
if (!isExpectedRevision(v)) throw new AgentError("invalid", "expectedRevision is required (the revision you read)")
|
|
191
|
+
return v
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isSpecShape(v: unknown): v is TeamSpec {
|
|
195
|
+
return isObj(v) && typeof v.name === "string" && typeof v.title === "string" && typeof v.entryRole === "string" && Array.isArray(v.roles) && Array.isArray(v.edges) && (v.mode === "open" || v.mode === "restricted")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A stored team as this build reads it (newer versions are refused, malformed ones reported). */
|
|
199
|
+
export function readTeam(key: string, raw: unknown): Readout {
|
|
200
|
+
if (!isObj(raw)) return { ok: false, why: "malformed" }
|
|
201
|
+
const v = raw.v
|
|
202
|
+
if (isNum(v) && v > CONTRACT_VERSION) return { ok: false, why: "newer", v }
|
|
203
|
+
if (v !== CONTRACT_VERSION) return { ok: false, why: "malformed" }
|
|
204
|
+
const draft = raw.draft
|
|
205
|
+
const valid =
|
|
206
|
+
raw.id === key &&
|
|
207
|
+
isSpecShape(raw.spec) &&
|
|
208
|
+
RECORD_STATES.includes(raw.state as RecordState) &&
|
|
209
|
+
(raw.paused === undefined || raw.paused === true) &&
|
|
210
|
+
Number.isSafeInteger(raw.version) &&
|
|
211
|
+
(raw.version as number) >= 1 &&
|
|
212
|
+
Array.isArray(raw.history) &&
|
|
213
|
+
(draft === undefined || (isObj(draft) && isSpecShape(draft.spec) && typeof draft.digest === "string")) &&
|
|
214
|
+
isNum(raw.createdAt) &&
|
|
215
|
+
isNum(raw.publishedAt) &&
|
|
216
|
+
isNum(raw.updatedAt)
|
|
217
|
+
return valid ? { ok: true, record: raw as unknown as TeamRecord } : { ok: false, why: "malformed" }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The API view of a record at a StateStore version. */
|
|
221
|
+
export function teamView(record: TeamRecord, revision: number): Team {
|
|
222
|
+
const d = record.draft
|
|
223
|
+
return {
|
|
224
|
+
v: 1,
|
|
225
|
+
id: record.id,
|
|
226
|
+
...record.spec,
|
|
227
|
+
state: record.state,
|
|
228
|
+
...(record.paused ? { paused: true as const } : {}),
|
|
229
|
+
version: record.version,
|
|
230
|
+
revision,
|
|
231
|
+
...(d ? { draft: { baseVersion: d.baseVersion, digest: d.digest, savedAt: d.savedAt } } : {}),
|
|
232
|
+
createdAt: record.createdAt,
|
|
233
|
+
publishedAt: record.publishedAt,
|
|
234
|
+
updatedAt: record.updatedAt,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const byCreation = (a: Team, b: Team) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
239
|
+
const encodeCursor = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url")
|
|
240
|
+
function decodeCursor(cursor: string): unknown {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown
|
|
243
|
+
} catch {
|
|
244
|
+
throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function pageLimit(limit: number | undefined): number {
|
|
248
|
+
const n = limit ?? LIST_LIMIT_DEFAULT
|
|
249
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
250
|
+
return n
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export class TeamStore {
|
|
254
|
+
readonly state: StateStore
|
|
255
|
+
readonly limits: TeamStoreLimits
|
|
256
|
+
private readonly now: Clock
|
|
257
|
+
private readonly newID: (kind: IdKind) => string
|
|
258
|
+
private readonly pinned?: PinnedVersions
|
|
259
|
+
|
|
260
|
+
constructor(deps: TeamStoreDeps) {
|
|
261
|
+
this.state = deps.state
|
|
262
|
+
this.now = deps.now ?? Date.now
|
|
263
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
264
|
+
this.pinned = deps.pinnedVersions
|
|
265
|
+
this.limits = {
|
|
266
|
+
teamsPerUser: deps.limits?.teamsPerUser ?? TEAM_LIMITS.teamsPerUser,
|
|
267
|
+
versionsRetained: deps.limits?.versionsRetained ?? TEAM_STORE_LIMITS.versionsRetained,
|
|
268
|
+
reservationMs: deps.limits?.reservationMs ?? 60_000,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------- create and read ----------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Creates an active team whose version 1 is published at once (name_taken for a used name, 429 at the cap).
|
|
276
|
+
* With an Idempotency-Key, the same key and body within IDEMPOTENCY_TTL_MS return the original (created false);
|
|
277
|
+
* another body is a conflict.
|
|
278
|
+
*/
|
|
279
|
+
async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ team: Team; created: boolean }> {
|
|
280
|
+
const spec = teamSpecOf(input)
|
|
281
|
+
const key = options.idempotencyKey
|
|
282
|
+
if (key === undefined) return { team: await this.insert(userID, this.newID("team"), spec), created: true }
|
|
283
|
+
if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
284
|
+
const digest = specDigest(spec)
|
|
285
|
+
await this.pruneKeys(userID)
|
|
286
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
287
|
+
const held = await this.state.get<IdempotencyDoc>(userID, TEAM_IDEMPOTENCY, key)
|
|
288
|
+
const live = held && isObj(held.value) && isNum(held.value.at) && this.now() - held.value.at < IDEMPOTENCY_TTL_MS ? held.value : undefined
|
|
289
|
+
if (live) {
|
|
290
|
+
if (live.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for a different team`, { hint: "Use a new key for a different body, or resend the original body" })
|
|
291
|
+
const existing = await this.readSoon(userID, live.teamID)
|
|
292
|
+
if (existing) return { team: existing, created: false }
|
|
293
|
+
if (this.now() - live.at < this.limits.reservationMs)
|
|
294
|
+
throw new AgentError("rate_limited", `The team for Idempotency-Key ${key} is still being created`, { extra: { retryAfter: 1 } })
|
|
295
|
+
}
|
|
296
|
+
const id = this.newID("team")
|
|
297
|
+
const claimed = await this.state.put<IdempotencyDoc>(userID, TEAM_IDEMPOTENCY, key, { v: 1, teamID: id, digest, at: this.now() }, { ifVersion: held?.version ?? 0 })
|
|
298
|
+
if (!claimed.ok) continue
|
|
299
|
+
try {
|
|
300
|
+
return { team: await this.insert(userID, id, spec), created: true }
|
|
301
|
+
} catch (error) {
|
|
302
|
+
await this.state.delete(userID, TEAM_IDEMPOTENCY, key, { ifVersion: claimed.version })
|
|
303
|
+
throw error
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
throw new Error(`team idempotency key ${key} kept conflicting`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The user's team, or undefined (also another user's); throws for records this build cannot read. */
|
|
310
|
+
async get(userID: string, id: string): Promise<Team | undefined> {
|
|
311
|
+
const stored = await this.loaded(userID, id)
|
|
312
|
+
return stored && teamView(stored.record, stored.revision)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async require(userID: string, id: string): Promise<Team> {
|
|
316
|
+
const team = await this.get(userID, id)
|
|
317
|
+
if (!team) throw notFound(id)
|
|
318
|
+
return team
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The user's team by its name (the `#team` address), or undefined. */
|
|
322
|
+
async byName(userID: string, name: string): Promise<Team | undefined> {
|
|
323
|
+
const entry = (await this.index(userID))?.names[name]
|
|
324
|
+
if (!entry) return undefined
|
|
325
|
+
const team = await this.get(userID, entry.id)
|
|
326
|
+
return team?.name === name ? team : undefined
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Teams by creation, filtered by state (default active), tags, project, metadata and pause; paged. */
|
|
330
|
+
async list(userID: string, query: TeamListQuery = {}): Promise<Page<Team>> {
|
|
331
|
+
const limit = pageLimit(query.limit)
|
|
332
|
+
const wanted = query.state ?? "active"
|
|
333
|
+
if (wanted !== "all" && !RECORD_STATES.includes(wanted as RecordState)) throw new AgentError("invalid", "state must be active, archived or all")
|
|
334
|
+
const problem = tagsProblem(query.tags) ?? metadataProblem(query.metadata)
|
|
335
|
+
if (problem) throw new AgentError("invalid", problem)
|
|
336
|
+
if (query.project !== undefined && !isLabel(query.project)) throw new AgentError("invalid", "project must be a project name")
|
|
337
|
+
let after: [number, string] | undefined
|
|
338
|
+
if (query.cursor !== undefined) {
|
|
339
|
+
const parsed = decodeCursor(query.cursor)
|
|
340
|
+
if (!Array.isArray(parsed) || parsed.length !== 2 || !isNum(parsed[0]) || typeof parsed[1] !== "string")
|
|
341
|
+
throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
|
|
342
|
+
after = [parsed[0], parsed[1]]
|
|
343
|
+
}
|
|
344
|
+
const all = (await this.readAll(userID))
|
|
345
|
+
.filter((t) => wanted === "all" || t.state === wanted)
|
|
346
|
+
.filter((t) => !query.tags || query.tags.every((tag) => t.tags?.includes(tag)))
|
|
347
|
+
.filter((t) => query.project === undefined || (t.projects ?? []).includes(query.project))
|
|
348
|
+
.filter((t) => !query.metadata || Object.entries(query.metadata).every(([k, v]) => t.metadata?.[k] === v))
|
|
349
|
+
.filter((t) => query.paused === undefined || (t.paused === true) === query.paused)
|
|
350
|
+
.filter((t) => !after || t.createdAt > after[0] || (t.createdAt === after[0] && t.id > after[1]))
|
|
351
|
+
.sort(byCreation)
|
|
352
|
+
const items = all.slice(0, limit)
|
|
353
|
+
return all.length > limit ? { items, cursor: encodeCursor([items.at(-1)!.createdAt, items.at(-1)!.id]) } : { items }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------- drafts, publish, history ----------
|
|
357
|
+
|
|
358
|
+
/** The pending draft, if any. */
|
|
359
|
+
async draft(userID: string, id: string): Promise<TeamDraft | undefined> {
|
|
360
|
+
return (await this.requireLoaded(userID, id)).record.draft
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Saves edits as the pending draft (built on the draft or the published spec); the published spec is unchanged. */
|
|
364
|
+
async saveDraft(userID: string, id: string, patch: unknown, expectedRevision: number): Promise<Team> {
|
|
365
|
+
const expected = checkExpected(expectedRevision)
|
|
366
|
+
return this.mutate(userID, id, expected, (record) => {
|
|
367
|
+
this.active(record)
|
|
368
|
+
const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch)
|
|
369
|
+
return { ...record, draft: { baseVersion: record.version, spec, digest: specDigest(spec), savedAt: this.now() } }
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async discardDraft(userID: string, id: string, expectedRevision: number): Promise<Team> {
|
|
374
|
+
const expected = checkExpected(expectedRevision)
|
|
375
|
+
return this.mutate(userID, id, expected, (record) => {
|
|
376
|
+
if (!record.draft) throw new AgentError("invalid", `Team ${id} has no draft`)
|
|
377
|
+
const { draft: _d, ...rest } = record
|
|
378
|
+
return rest
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Publishes the draft as the next immutable version. A draft equal to the published spec is dropped without a
|
|
384
|
+
* new version (published false). Refused while sessions are staffed in a role the draft removes.
|
|
385
|
+
*/
|
|
386
|
+
async publish(userID: string, id: string, expectedRevision: number): Promise<{ team: Team; published: boolean }> {
|
|
387
|
+
const expected = checkExpected(expectedRevision)
|
|
388
|
+
const { record, revision } = await this.requireLoaded(userID, id)
|
|
389
|
+
if (revision !== expected) throw conflict(`Team ${id}`, revision)
|
|
390
|
+
this.active(record)
|
|
391
|
+
const draft = record.draft
|
|
392
|
+
if (!draft) throw new AgentError("invalid", `Team ${id} has no draft to publish`, { hint: "Save a draft first" })
|
|
393
|
+
if (draft.digest === specDigest(record.spec)) {
|
|
394
|
+
const { draft: _d, ...rest } = record
|
|
395
|
+
return { team: await this.write(userID, id, rest, revision), published: false }
|
|
396
|
+
}
|
|
397
|
+
const roles = new Set(draft.spec.roles.map((r) => r.name))
|
|
398
|
+
const staffed = [...new Set(currentSeats(readSeatsDoc((await this.state.get(userID, TEAM_SEATS, id))?.value, id)).map((s) => s.role))].filter((r) => !roles.has(r)).sort()
|
|
399
|
+
if (staffed.length > 0)
|
|
400
|
+
throw new AgentError("invalid", `Sessions are staffed in roles this draft removes: ${staffed.join(", ")}`, { hint: "Unstaff those roles first", extra: { roles: staffed } })
|
|
401
|
+
await this.ensureSlot(userID, record)
|
|
402
|
+
const at = this.now()
|
|
403
|
+
const number = record.version + 1
|
|
404
|
+
const entry: TeamVersionEntry = { version: number, digest: draft.digest, publishedAt: at }
|
|
405
|
+
const pinned = (await this.pinned?.(userID, id)) ?? new Set<number>()
|
|
406
|
+
const history = [...record.history, entry].sort((a, b) => b.version - a.version)
|
|
407
|
+
const kept = history.filter((h, i) => i < this.limits.versionsRetained || pinned.has(h.version))
|
|
408
|
+
const dropped = history.filter((h) => !kept.includes(h))
|
|
409
|
+
const { draft: _d, ...rest } = record
|
|
410
|
+
const team = await this.write(userID, id, { ...rest, spec: draft.spec, version: number, history: kept.sort((a, b) => a.version - b.version), publishedAt: at }, revision)
|
|
411
|
+
await this.state.put<TeamVersion>(userID, teamVersionCollection(id), String(number), { v: 1, teamID: id, version: number, spec: draft.spec, digest: draft.digest, publishedAt: at }, { ifVersion: 0 })
|
|
412
|
+
for (const victim of dropped) await this.state.delete(userID, teamVersionCollection(id), String(victim.version))
|
|
413
|
+
return { team, published: true }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A retained published revision (undefined when dropped or never published). */
|
|
417
|
+
async version(userID: string, id: string, number: number): Promise<TeamVersion | undefined> {
|
|
418
|
+
const { record } = await this.requireLoaded(userID, id)
|
|
419
|
+
if (!record.history.some((h) => h.version === number)) return undefined
|
|
420
|
+
const slot = await this.state.get<TeamVersion>(userID, teamVersionCollection(id), String(number))
|
|
421
|
+
if (slot) return slot.value
|
|
422
|
+
if (number !== record.version) return undefined
|
|
423
|
+
return this.ensureSlot(userID, record)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Retained versions, newest first, paged. */
|
|
427
|
+
async history(userID: string, id: string, options: { readonly limit?: number; readonly cursor?: string } = {}): Promise<Page<TeamVersionEntry>> {
|
|
428
|
+
const limit = pageLimit(options.limit)
|
|
429
|
+
let before = Number.POSITIVE_INFINITY
|
|
430
|
+
if (options.cursor !== undefined) {
|
|
431
|
+
const parsed = decodeCursor(options.cursor)
|
|
432
|
+
if (!Number.isSafeInteger(parsed)) throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
|
|
433
|
+
before = parsed as number
|
|
434
|
+
}
|
|
435
|
+
const { record } = await this.requireLoaded(userID, id)
|
|
436
|
+
const all = [...record.history].sort((a, b) => b.version - a.version).filter((h) => h.version < before)
|
|
437
|
+
const items = all.slice(0, limit)
|
|
438
|
+
return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!.version) } : { items }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** What changed between two retained versions, or from a version to the pending draft. */
|
|
442
|
+
async diff(userID: string, id: string, from: number, to: number | "draft"): Promise<TeamDiff> {
|
|
443
|
+
const a = await this.version(userID, id, from)
|
|
444
|
+
if (!a) throw new AgentError("not_found", `Team ${id} has no retained version ${from}`)
|
|
445
|
+
if (to === "draft") {
|
|
446
|
+
const draft = await this.draft(userID, id)
|
|
447
|
+
if (!draft) throw new AgentError("not_found", `Team ${id} has no draft`)
|
|
448
|
+
return diffTeamSpecs(a.spec, draft.spec)
|
|
449
|
+
}
|
|
450
|
+
const b = await this.version(userID, id, to)
|
|
451
|
+
if (!b) throw new AgentError("not_found", `Team ${id} has no retained version ${to}`)
|
|
452
|
+
return diffTeamSpecs(a.spec, b.spec)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------- status ----------
|
|
456
|
+
|
|
457
|
+
/** Pauses an active team (intake and new staffing stop; HWA-35/36). Pausing a paused team is a no-op. */
|
|
458
|
+
async pause(userID: string, id: string, expectedRevision: number): Promise<Team> {
|
|
459
|
+
const expected = checkExpected(expectedRevision)
|
|
460
|
+
return this.mutate(userID, id, expected, (record) => {
|
|
461
|
+
this.active(record)
|
|
462
|
+
return record.paused ? undefined : { ...record, paused: true }
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async resume(userID: string, id: string, expectedRevision: number): Promise<Team> {
|
|
467
|
+
const expected = checkExpected(expectedRevision)
|
|
468
|
+
return this.mutate(userID, id, expected, (record) => {
|
|
469
|
+
this.active(record)
|
|
470
|
+
if (!record.paused) return undefined
|
|
471
|
+
const { paused: _p, ...rest } = record
|
|
472
|
+
return rest
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Archives the team (reversible; its name stays reserved). Archiving an archived team is a no-op. */
|
|
477
|
+
async archive(userID: string, id: string, expectedRevision: number): Promise<Team> {
|
|
478
|
+
const expected = checkExpected(expectedRevision)
|
|
479
|
+
const team = await this.mutate(userID, id, expected, (record) => (record.state === "archived" ? undefined : { ...record, state: "archived" }))
|
|
480
|
+
await this.markArchived(userID, team.name, id, true)
|
|
481
|
+
return team
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Makes an archived team active again (429 at the cap). Restoring an active team is a no-op. */
|
|
485
|
+
async restore(userID: string, id: string, expectedRevision: number): Promise<Team> {
|
|
486
|
+
const expected = checkExpected(expectedRevision)
|
|
487
|
+
const { record, revision } = await this.requireLoaded(userID, id)
|
|
488
|
+
if (revision !== expected) throw conflict(`Team ${id}`, revision)
|
|
489
|
+
if (record.state === "active") return teamView(record, revision)
|
|
490
|
+
await this.markArchived(userID, record.spec.name, id, false)
|
|
491
|
+
try {
|
|
492
|
+
return await this.mutate(userID, id, expected, (r) => (r.state === "active" ? undefined : { ...r, state: "active" }))
|
|
493
|
+
} catch (error) {
|
|
494
|
+
await this.markArchived(userID, record.spec.name, id, true)
|
|
495
|
+
throw error
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------- owner settings ----------
|
|
500
|
+
|
|
501
|
+
async settings(userID: string): Promise<TeamSettings> {
|
|
502
|
+
const stored = await this.state.get<SettingsDoc>(userID, TEAM_SETTINGS, OWNER)
|
|
503
|
+
if (!stored) return { teamIsolation: false, revision: 0 }
|
|
504
|
+
return this.settingsOf(stored.value, stored.version)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Changes the owner's team settings under CAS (expectedRevision 0 when never set). */
|
|
508
|
+
async setSettings(userID: string, input: unknown, expectedRevision: number): Promise<TeamSettings> {
|
|
509
|
+
const expected = checkExpected(expectedRevision)
|
|
510
|
+
if (!isObj(input) || Object.keys(input).some((k) => k !== "teamIsolation") || typeof input.teamIsolation !== "boolean")
|
|
511
|
+
throw new AgentError("invalid", "Send {teamIsolation: true|false}")
|
|
512
|
+
const current = await this.state.get<SettingsDoc>(userID, TEAM_SETTINGS, OWNER)
|
|
513
|
+
if (current) this.settingsOf(current.value, current.version)
|
|
514
|
+
const doc: SettingsDoc = { v: 1, teamIsolation: input.teamIsolation, updatedAt: this.now() }
|
|
515
|
+
const result = await this.state.put(userID, TEAM_SETTINGS, OWNER, doc, { ifVersion: expected })
|
|
516
|
+
if (!result.ok) throw conflict("Team settings", result.version)
|
|
517
|
+
return { teamIsolation: doc.teamIsolation, revision: result.version, updatedAt: doc.updatedAt }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Makes buffered writes durable (standalone debounce); a no-op for write-through stores. */
|
|
521
|
+
async flush(): Promise<void> {
|
|
522
|
+
await this.state.flush?.()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------- internals ----------
|
|
526
|
+
|
|
527
|
+
private settingsOf(raw: unknown, version: number): TeamSettings {
|
|
528
|
+
if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION) throw new AgentError("unsupported", "Team settings were written by a newer hub", { extra: { feature: "team-settings" } })
|
|
529
|
+
if (!isObj(raw) || typeof raw.teamIsolation !== "boolean") throw new AgentError("internal", "Stored team settings are malformed")
|
|
530
|
+
return { teamIsolation: raw.teamIsolation, revision: version, ...(isNum(raw.updatedAt) ? { updatedAt: raw.updatedAt } : {}) }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private active(record: TeamRecord): void {
|
|
534
|
+
if (record.state === "archived") throw new AgentError("invalid", `Team ${record.id} is archived`, { hint: "Restore it first" })
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private async loaded(userID: string, id: string): Promise<{ record: TeamRecord; revision: number } | undefined> {
|
|
538
|
+
const stored = await this.state.get(userID, TEAMS, id)
|
|
539
|
+
if (!stored) return undefined
|
|
540
|
+
const read = readTeam(id, stored.value)
|
|
541
|
+
if (read.ok) return { record: read.record, revision: stored.version }
|
|
542
|
+
if (read.why === "newer") throw new AgentError("unsupported", `Team ${id} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "teams", recordVersion: read.v } })
|
|
543
|
+
throw new AgentError("internal", `Stored team ${id} is malformed`)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async requireLoaded(userID: string, id: string): Promise<{ record: TeamRecord; revision: number }> {
|
|
547
|
+
const stored = await this.loaded(userID, id)
|
|
548
|
+
if (!stored) throw notFound(id)
|
|
549
|
+
return stored
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private async readAll(userID: string): Promise<Team[]> {
|
|
553
|
+
const out: Team[] = []
|
|
554
|
+
for (const entry of await this.state.list(userID, TEAMS)) {
|
|
555
|
+
const read = readTeam(entry.key, entry.value)
|
|
556
|
+
if (read.ok) out.push(teamView(read.record, entry.version))
|
|
557
|
+
}
|
|
558
|
+
return out
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private async readSoon(userID: string, id: string): Promise<Team | undefined> {
|
|
562
|
+
for (let i = 0; i < READS; i++) {
|
|
563
|
+
const team = await this.get(userID, id)
|
|
564
|
+
if (team) return team
|
|
565
|
+
}
|
|
566
|
+
return undefined
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Version-checked change of a record; `change` returning undefined leaves it alone. */
|
|
570
|
+
private async mutate(userID: string, id: string, expected: number, change: (record: TeamRecord) => TeamRecord | undefined): Promise<Team> {
|
|
571
|
+
const { record, revision } = await this.requireLoaded(userID, id)
|
|
572
|
+
if (revision !== expected) throw conflict(`Team ${id}`, revision)
|
|
573
|
+
const next = change(record)
|
|
574
|
+
if (!next) return teamView(record, revision)
|
|
575
|
+
return this.write(userID, id, next, revision)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async write(userID: string, id: string, next: TeamRecord, revision: number): Promise<Team> {
|
|
579
|
+
const written: TeamRecord = { ...next, v: 1, id, updatedAt: this.now() }
|
|
580
|
+
const result = await this.state.put(userID, TEAMS, id, written, { ifVersion: revision })
|
|
581
|
+
if (!result.ok) throw conflict(`Team ${id}`, result.version)
|
|
582
|
+
return teamView(written, result.version)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private async insert(userID: string, id: string, spec: TeamSpec): Promise<Team> {
|
|
586
|
+
const at = this.now()
|
|
587
|
+
await this.reserveName(userID, spec.name, id, at)
|
|
588
|
+
try {
|
|
589
|
+
const digest = specDigest(spec)
|
|
590
|
+
const record: TeamRecord = { v: 1, id, spec, state: "active", version: 1, history: [{ version: 1, digest, publishedAt: at }], createdAt: at, publishedAt: at, updatedAt: at }
|
|
591
|
+
const written = await this.state.put(userID, TEAMS, id, record, { ifVersion: 0 })
|
|
592
|
+
if (!written.ok) throw new Error(`team id ${id} already exists`)
|
|
593
|
+
await this.state.put<TeamVersion>(userID, teamVersionCollection(id), "1", { v: 1, teamID: id, version: 1, spec, digest, publishedAt: at }, { ifVersion: 0 })
|
|
594
|
+
return teamView(record, written.version)
|
|
595
|
+
} catch (error) {
|
|
596
|
+
await this.releaseName(userID, spec.name, id).catch(() => {})
|
|
597
|
+
throw error
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Writes the slot of the record's current version when it is missing (crash between record and slot). */
|
|
602
|
+
private async ensureSlot(userID: string, record: TeamRecord): Promise<TeamVersion> {
|
|
603
|
+
const key = String(record.version)
|
|
604
|
+
const slot = await this.state.get<TeamVersion>(userID, teamVersionCollection(record.id), key)
|
|
605
|
+
if (slot) return slot.value
|
|
606
|
+
const entry = record.history.find((h) => h.version === record.version)
|
|
607
|
+
const version: TeamVersion = { v: 1, teamID: record.id, version: record.version, spec: record.spec, digest: entry?.digest ?? specDigest(record.spec), publishedAt: record.publishedAt }
|
|
608
|
+
await this.state.put(userID, teamVersionCollection(record.id), key, version, { ifVersion: 0 })
|
|
609
|
+
return version
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async index(userID: string): Promise<NameIndex | undefined> {
|
|
613
|
+
const raw = (await this.state.get<NameIndex>(userID, TEAM_INDEX, NAMES))?.value
|
|
614
|
+
return isObj(raw) && isObj(raw.names) ? raw : undefined
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Reserves the name for `id` (name_taken; 429 when the user has teamsPerUser active teams). */
|
|
618
|
+
private async reserveName(userID: string, name: string, id: string, now: number): Promise<void> {
|
|
619
|
+
const ids = new Set((await this.state.list(userID, TEAMS)).map((e) => e.key))
|
|
620
|
+
const dead = (e: NameEntry) => !ids.has(e.id) && now - e.at > this.limits.reservationMs
|
|
621
|
+
await updateVersioned<NameIndex>(this.state, userID, TEAM_INDEX, NAMES, (current) => {
|
|
622
|
+
const names: Record<string, NameEntry> = {}
|
|
623
|
+
for (const [n, e] of Object.entries(current?.names ?? {})) if (!dead(e)) names[n] = e
|
|
624
|
+
if (names[name]) throw new AgentError("name_taken", `You already have a team named ${name}`, { hint: "Pick another name, or restore the archived team" })
|
|
625
|
+
const active = Object.values(names).filter((e) => !e.archived).length
|
|
626
|
+
if (active >= this.limits.teamsPerUser)
|
|
627
|
+
throw new AgentError("rate_limited", `A user has at most ${this.limits.teamsPerUser} active teams`, { hint: "Archive a team first", extra: { limit: this.limits.teamsPerUser } })
|
|
628
|
+
names[name] = { id, at: now }
|
|
629
|
+
return { v: 1, names }
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private async releaseName(userID: string, name: string, id: string): Promise<void> {
|
|
634
|
+
await updateVersioned<NameIndex>(this.state, userID, TEAM_INDEX, NAMES, (current) => {
|
|
635
|
+
if (current?.names[name]?.id !== id) return undefined
|
|
636
|
+
const names = { ...current.names }
|
|
637
|
+
delete names[name]
|
|
638
|
+
return { v: 1, names }
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Flags the name entry archived (frees a cap slot) or active again (429 at the cap). */
|
|
643
|
+
private async markArchived(userID: string, name: string, id: string, archived: boolean): Promise<void> {
|
|
644
|
+
await updateVersioned<NameIndex>(this.state, userID, TEAM_INDEX, NAMES, (current) => {
|
|
645
|
+
const names = { ...current?.names }
|
|
646
|
+
const entry = names[name]
|
|
647
|
+
if (entry && entry.id !== id) return undefined
|
|
648
|
+
if (entry && (entry.archived === true) === archived) return undefined
|
|
649
|
+
if (archived) names[name] = { id, at: entry?.at ?? this.now(), archived: true }
|
|
650
|
+
else {
|
|
651
|
+
const active = Object.values(names).filter((e) => !e.archived).length
|
|
652
|
+
if (active >= this.limits.teamsPerUser)
|
|
653
|
+
throw new AgentError("rate_limited", `A user has at most ${this.limits.teamsPerUser} active teams`, { hint: "Archive a team first", extra: { limit: this.limits.teamsPerUser } })
|
|
654
|
+
names[name] = { id, at: entry?.at ?? this.now() }
|
|
655
|
+
}
|
|
656
|
+
return { v: 1, names }
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async pruneKeys(userID: string): Promise<void> {
|
|
661
|
+
const now = this.now()
|
|
662
|
+
for (const entry of await this.state.list<IdempotencyDoc>(userID, TEAM_IDEMPOTENCY)) {
|
|
663
|
+
const at = isObj(entry.value) && isNum(entry.value.at) ? entry.value.at : 0
|
|
664
|
+
if (now - at >= IDEMPOTENCY_TTL_MS) await this.state.delete(userID, TEAM_IDEMPOTENCY, entry.key, { ifVersion: entry.version })
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|