@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,516 @@
|
|
|
1
|
+
// Team routes on /api/agent (HWA-37; contract: Teams in docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md
|
|
2
|
+
// and the HWA-33/34/35/36 clarifications). Intake (/teams/:id/intake…) lives in intake-routes.ts (HWA-36).
|
|
3
|
+
//
|
|
4
|
+
// POST /teams {name, title, mode, roles, entryRole, edges?, …} Idempotency-Key M
|
|
5
|
+
// GET /teams?state&tag…&project&metadata=k=v…&paused&limit&cursor R
|
|
6
|
+
// GET /teams/:id → {team, draft?} (:id is the team ID or its name) R
|
|
7
|
+
// PATCH /teams/:id {expectedRevision, publish?, …spec fields} → draft saved M
|
|
8
|
+
// GET /teams/:id/draft → {draft} R
|
|
9
|
+
// DELETE /teams/:id/draft?expectedRevision= discard the draft M
|
|
10
|
+
// PUT /teams/:id/draft/roles/:role {expectedRevision, label?, description?, …} add/replace M
|
|
11
|
+
// DELETE /teams/:id/draft/roles/:role?expectedRevision= (and its edges) M
|
|
12
|
+
// PUT /teams/:id/draft/edges {expectedRevision, from, to, kinds, direction} M
|
|
13
|
+
// DELETE /teams/:id/draft/edges?from&to&expectedRevision= M
|
|
14
|
+
// POST /teams/:id/publish {expectedRevision} → {team, published} M
|
|
15
|
+
// POST /teams/:id/{pause,resume,archive,restore} {expectedRevision} → {team} M
|
|
16
|
+
// GET /teams/:id/versions?limit&cursor retained published versions, newest first R
|
|
17
|
+
// GET /teams/:id/versions/:version → {version} (the frozen spec) R
|
|
18
|
+
// GET /teams/:id/diff?from&to from (default: published) → to (version or draft, default) R
|
|
19
|
+
// GET /teams/:id/staffing?ended=1 seats (current; with ended ones on ended=1) R + S
|
|
20
|
+
// PUT /teams/:id/staffing {role, sessionID} Idempotency-Key → {staffing, workspace?} M + S
|
|
21
|
+
// POST /teams/:id/staffing/launch {role, launch} Idempotency-Key → {request, launch} M + S
|
|
22
|
+
// POST /teams/:id/staffing/:staffing/replace {expectedRevision, sessionID | launch} M + S
|
|
23
|
+
// DELETE /teams/:id/staffing/:staffing?expectedRevision= M + S
|
|
24
|
+
// GET /teams/:id/roster → {roster} R + S
|
|
25
|
+
// GET /teams/:id/status → {team, roster, intake} (intake: counts only) R + S
|
|
26
|
+
// GET /team-settings → {settings: {teamIsolation, revision, updatedAt?}} users only
|
|
27
|
+
// PUT /team-settings {teamIsolation, expectedRevision} → {settings} users only
|
|
28
|
+
//
|
|
29
|
+
// The owner settings (RP-09) live outside /teams/ so a team named `settings` keeps its address; users only: app and
|
|
30
|
+
// session tokens never read or change them (isolation narrows what team members see; a member must not widen it).
|
|
31
|
+
// Who. Users: their own teams (another user's, by ID or name, is not_found). App tokens: R needs teams:read or
|
|
32
|
+
// teams:manage, M needs teams:manage; routes that show or name sessions (S: seats, roster, status, staffing) also
|
|
33
|
+
// need sessions:read (as the workspace routes). No existing token gains anything: the scopes are new. Session
|
|
34
|
+
// tokens: never change definitions (a team member editing its own team's policy would bypass it); they read only
|
|
35
|
+
// the teams they hold a current seat in (others are not_found), and may staff under the existing grants exactly as
|
|
36
|
+
// TeamStaffingService checks them (needs_user when missing; no grant or Decision is created here). Decision
|
|
37
|
+
// answers and grant administration stay console-only: no route here touches them.
|
|
38
|
+
// Every mutation of an existing record requires expectedRevision (409 conflict with currentRevision when stale);
|
|
39
|
+
// creates take Idempotency-Key (store-level: TeamStore and TeamSeatStore keep the key). Changes are audited (IDs
|
|
40
|
+
// only) and journaled with the compact team events (events.ts). Lists page (limit ≤ LIST_LIMIT_MAX); seats are
|
|
41
|
+
// capped per role and the ended ones retained per team.
|
|
42
|
+
import { boundedText } from "../agent/body"
|
|
43
|
+
import { isContractID, isExpectedRevision, isSlug, LIST_LIMIT_MAX } from "../../protocol"
|
|
44
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
45
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
46
|
+
import { AgentError } from "../agent/errors"
|
|
47
|
+
import { idempotencyKeyOf, storeAnswer } from "../agent/idempotency"
|
|
48
|
+
import type { Store } from "../store"
|
|
49
|
+
import { removeEdge, removeRole, setEdge, setRole } from "./draft-edits"
|
|
50
|
+
import { teamArchivedEvent, teamCreatedEvent, teamUpdatedEvent } from "./events"
|
|
51
|
+
import type { TeamIntakeService } from "./intake"
|
|
52
|
+
import type { TeamSeatStore } from "./seats"
|
|
53
|
+
import type { TeamStaffingService } from "./staffing"
|
|
54
|
+
import type { Team, TeamListQuery, TeamStore } from "./store"
|
|
55
|
+
import type { TeamSpec } from "./validation"
|
|
56
|
+
|
|
57
|
+
const PREFIX = "/api/agent/teams"
|
|
58
|
+
const SETTINGS_PATH = "/api/agent/team-settings"
|
|
59
|
+
/** Users only (no app or session token). */
|
|
60
|
+
const USERS = {} as const
|
|
61
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
62
|
+
/** Request bodies: a full team spec (32 roles, 256 edges, descriptions) stays well below this. */
|
|
63
|
+
export const TEAM_BODY_MAX = 256 * 1024
|
|
64
|
+
const MANAGE = { scope: "teams:manage" } as const
|
|
65
|
+
const READ = { app: true, session: true } as const
|
|
66
|
+
const STAFF = { scope: "teams:manage", session: true } as const
|
|
67
|
+
|
|
68
|
+
export interface TeamRoutesDeps {
|
|
69
|
+
readonly teams: TeamStore
|
|
70
|
+
readonly seats: TeamSeatStore
|
|
71
|
+
readonly staffing: TeamStaffingService
|
|
72
|
+
/** Intake counts in team status (HWA-36); omitted when absent. */
|
|
73
|
+
readonly intake?: Pick<TeamIntakeService, "summary">
|
|
74
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
75
|
+
readonly audit?: Pick<Store, "audit">
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
79
|
+
const printable = (v: string) => (/^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : "(unprintable)")
|
|
80
|
+
|
|
81
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
82
|
+
const declared = Number(request.headers.get("content-length") ?? "0")
|
|
83
|
+
if (declared > TEAM_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${TEAM_BODY_MAX} bytes`)
|
|
84
|
+
const text = await boundedText(request, TEAM_BODY_MAX)
|
|
85
|
+
if (text.length > TEAM_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${TEAM_BODY_MAX} bytes`)
|
|
86
|
+
let raw: unknown
|
|
87
|
+
try {
|
|
88
|
+
raw = JSON.parse(text === "" ? "{}" : text)
|
|
89
|
+
} catch {
|
|
90
|
+
throw new AgentError("invalid", "Body must be JSON")
|
|
91
|
+
}
|
|
92
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
93
|
+
return raw as Record<string, unknown>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function only(body: Record<string, unknown>, allowed: readonly string[]): void {
|
|
97
|
+
for (const key of Object.keys(body)) if (!allowed.includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The required expectedRevision of a mutation (body field or query parameter). */
|
|
101
|
+
function expected(value: unknown): number {
|
|
102
|
+
const n = typeof value === "string" && /^[0-9]{1,15}$/.test(value) ? Number(value) : value
|
|
103
|
+
if (n === undefined || n === null || n === "") throw new AgentError("invalid", "expectedRevision is required: the revision you read", { hint: "Read the team and send its revision" })
|
|
104
|
+
if (!isExpectedRevision(n)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
|
|
105
|
+
return n as number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function intParam(url: URL, name: string): number | undefined {
|
|
109
|
+
const raw = url.searchParams.get(name)
|
|
110
|
+
return raw === null ? undefined : /^-?[0-9]{1,12}$/.test(raw) ? Number(raw) : Number.NaN
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const text = (url: URL, name: string) => url.searchParams.get(name) || undefined
|
|
114
|
+
|
|
115
|
+
function boolParam(url: URL, name: string): boolean | undefined {
|
|
116
|
+
const raw = url.searchParams.get(name)
|
|
117
|
+
if (raw === null || raw === "") return undefined
|
|
118
|
+
if (raw === "true" || raw === "1") return true
|
|
119
|
+
if (raw === "false" || raw === "0") return false
|
|
120
|
+
throw new AgentError("invalid", `${name} must be true or false`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The list query: repeated `tag`, repeated `metadata=key=value`, state, project, paused, limit, cursor. */
|
|
124
|
+
function listQuery(url: URL): TeamListQuery {
|
|
125
|
+
const tags = url.searchParams.getAll("tag")
|
|
126
|
+
const pairs = url.searchParams.getAll("metadata")
|
|
127
|
+
const metadata: Record<string, string> = {}
|
|
128
|
+
for (const pair of pairs) {
|
|
129
|
+
const at = pair.indexOf("=")
|
|
130
|
+
if (at <= 0) throw new AgentError("invalid", "metadata filters are key=value")
|
|
131
|
+
metadata[pair.slice(0, at)] = pair.slice(at + 1)
|
|
132
|
+
}
|
|
133
|
+
const limit = intParam(url, "limit")
|
|
134
|
+
const paused = boolParam(url, "paused")
|
|
135
|
+
const state = text(url, "state")
|
|
136
|
+
const project = text(url, "project")
|
|
137
|
+
const cursor = text(url, "cursor")
|
|
138
|
+
return {
|
|
139
|
+
...(state ? { state } : {}),
|
|
140
|
+
...(tags.length ? { tags } : {}),
|
|
141
|
+
...(project ? { project } : {}),
|
|
142
|
+
...(pairs.length ? { metadata } : {}),
|
|
143
|
+
...(paused !== undefined ? { paused } : {}),
|
|
144
|
+
...(limit !== undefined ? { limit } : {}),
|
|
145
|
+
...(cursor ? { cursor } : {}),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Apps reading teams need teams:read or teams:manage, plus sessions:read where sessions are shown. */
|
|
150
|
+
function appMayRead(context: AgentContext, sessions: boolean): void {
|
|
151
|
+
const { actor } = context
|
|
152
|
+
if (actor.kind !== "app") return
|
|
153
|
+
if (!actor.scopes.includes("teams:read") && !actor.scopes.includes("teams:manage")) throw new AgentError("forbidden", "Reading teams needs scope teams:read (or teams:manage)")
|
|
154
|
+
if (sessions && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Seats, roster, status and staffing also need scope sessions:read")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function appMayStaff(context: AgentContext): void {
|
|
158
|
+
const { actor } = context
|
|
159
|
+
if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Staffing also needs scope sessions:read")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
|
|
163
|
+
const { teams, seats, staffing } = deps
|
|
164
|
+
|
|
165
|
+
async function changed(context: AgentContext, action: string, target: string, ...events: EventInput[]): Promise<void> {
|
|
166
|
+
await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
|
|
167
|
+
for (const event of events) await deps.events?.append(context.user.id, event)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The user's team by ID or name (another user's, or none: not_found). */
|
|
171
|
+
async function resolve(userID: string, raw: string): Promise<Team> {
|
|
172
|
+
const team = isContractID("team", raw) ? await teams.get(userID, raw) : isSlug(raw) ? await teams.byName(userID, raw) : undefined
|
|
173
|
+
if (!team) throw new AgentError("not_found", `No team ${printable(raw)}`)
|
|
174
|
+
return team
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A team the caller may read: sessions only the teams they hold a current seat in. */
|
|
178
|
+
async function readable(context: AgentContext, sessions = false): Promise<Team> {
|
|
179
|
+
appMayRead(context, sessions)
|
|
180
|
+
const team = await resolve(context.user.id, context.params.id!)
|
|
181
|
+
const { actor } = context
|
|
182
|
+
if (actor.kind === "session" && !(await seats.seatsOf(context.user.id, actor.sessionID)).some((s) => s.teamID === team.id)) throw new AgentError("not_found", `No team ${printable(context.params.id!)}`)
|
|
183
|
+
return team
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const managed = (context: AgentContext) => resolve(context.user.id, context.params.id!)
|
|
187
|
+
|
|
188
|
+
/** A status change under expectedRevision; journaled only when it changed something. */
|
|
189
|
+
const statusRoute = (verb: "pause" | "resume" | "archive" | "restore"): AgentRoute => ({
|
|
190
|
+
method: "POST",
|
|
191
|
+
path: `${PREFIX}/:id/${verb}`,
|
|
192
|
+
allow: MANAGE,
|
|
193
|
+
async handle(context) {
|
|
194
|
+
const body = await bodyOf(context.request)
|
|
195
|
+
only(body, ["expectedRevision"])
|
|
196
|
+
const revision = expected(body.expectedRevision)
|
|
197
|
+
const { id } = await managed(context)
|
|
198
|
+
const team = await teams[verb](context.user.id, id, revision)
|
|
199
|
+
if (team.revision !== revision) {
|
|
200
|
+
const event = verb === "archive" || verb === "restore" ? teamArchivedEvent(team) : teamUpdatedEvent(team, verb === "pause" ? "paused" : "resumed")
|
|
201
|
+
await changed(context, `team.${verb}`, team.id, event)
|
|
202
|
+
}
|
|
203
|
+
return ok({ team })
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/** Saves a draft edit computed from the current draft (or published) spec, under expectedRevision. */
|
|
208
|
+
async function draftEdit(context: AgentContext, revision: number, edit: (spec: TeamSpec) => unknown): Promise<Response> {
|
|
209
|
+
const current = await managed(context)
|
|
210
|
+
if (current.revision !== revision)
|
|
211
|
+
throw new AgentError("conflict", `Team ${current.id} changed since you read it (revision ${current.revision} now)`, { hint: "Read it again and retry with its current revision", extra: { currentRevision: current.revision } })
|
|
212
|
+
const base = (await teams.draft(context.user.id, current.id))?.spec ?? (await teams.version(context.user.id, current.id, current.version))?.spec
|
|
213
|
+
if (!base) throw new AgentError("internal", `Team ${current.id} has no readable published version`)
|
|
214
|
+
const team = await teams.saveDraft(context.user.id, current.id, edit(base), revision)
|
|
215
|
+
await changed(context, "team.draft", team.id, teamUpdatedEvent(team, "draft"))
|
|
216
|
+
return ok({ team, draft: await teams.draft(context.user.id, team.id) })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A publish journals `published`, or `draft_discarded` when the draft equalled the published spec. */
|
|
220
|
+
async function publishedEvent(context: AgentContext, team: Team, published: boolean): Promise<void> {
|
|
221
|
+
await changed(context, published ? "team.publish" : "team.draft.discard", team.id, teamUpdatedEvent(team, published ? "published" : "draft_discarded"))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return [
|
|
225
|
+
// ---------- owner settings (users only) ----------
|
|
226
|
+
{
|
|
227
|
+
method: "GET",
|
|
228
|
+
path: SETTINGS_PATH,
|
|
229
|
+
allow: USERS,
|
|
230
|
+
async handle(context) {
|
|
231
|
+
return ok({ settings: await teams.settings(context.user.id) })
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
method: "PUT",
|
|
236
|
+
path: SETTINGS_PATH,
|
|
237
|
+
allow: USERS,
|
|
238
|
+
async handle(context) {
|
|
239
|
+
const { expectedRevision, ...input } = await bodyOf(context.request)
|
|
240
|
+
const settings = await teams.setSettings(context.user.id, input, expected(expectedRevision))
|
|
241
|
+
await changed(context, "team.settings", "team-settings")
|
|
242
|
+
return ok({ settings })
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
// ---------- definitions ----------
|
|
246
|
+
{
|
|
247
|
+
method: "POST",
|
|
248
|
+
path: PREFIX,
|
|
249
|
+
allow: MANAGE,
|
|
250
|
+
async handle(context) {
|
|
251
|
+
const body = await bodyOf(context.request)
|
|
252
|
+
const key = idempotencyKeyOf(context.request)
|
|
253
|
+
const { team, created } = await teams.create(context.user.id, body, key === undefined ? {} : { idempotencyKey: key })
|
|
254
|
+
if (created) await changed(context, "team.create", team.id, teamCreatedEvent(team))
|
|
255
|
+
return storeAnswer(created, { team })
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
method: "GET",
|
|
260
|
+
path: PREFIX,
|
|
261
|
+
allow: READ,
|
|
262
|
+
async handle(context) {
|
|
263
|
+
appMayRead(context, false)
|
|
264
|
+
const query = listQuery(context.url)
|
|
265
|
+
const { actor, user } = context
|
|
266
|
+
if (actor.kind !== "session") return ok(await teams.list(user.id, query))
|
|
267
|
+
// Sessions: only the teams they are seated in (a handful), unpaged.
|
|
268
|
+
const mine = new Set((await seats.seatsOf(user.id, actor.sessionID)).map((s) => s.teamID))
|
|
269
|
+
const items: Team[] = []
|
|
270
|
+
let cursor: string | undefined
|
|
271
|
+
do {
|
|
272
|
+
const page = await teams.list(user.id, { ...query, limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
|
|
273
|
+
items.push(...page.items.filter((t) => mine.has(t.id)))
|
|
274
|
+
cursor = page.cursor
|
|
275
|
+
} while (cursor)
|
|
276
|
+
return ok({ items })
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
method: "GET",
|
|
281
|
+
path: `${PREFIX}/:id`,
|
|
282
|
+
allow: READ,
|
|
283
|
+
async handle(context) {
|
|
284
|
+
const team = await readable(context)
|
|
285
|
+
const draft = await teams.draft(context.user.id, team.id)
|
|
286
|
+
return ok({ team, ...(draft ? { draft } : {}) })
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
method: "PATCH",
|
|
291
|
+
path: `${PREFIX}/:id`,
|
|
292
|
+
allow: MANAGE,
|
|
293
|
+
async handle(context) {
|
|
294
|
+
const { expectedRevision, publish, ...patch } = await bodyOf(context.request)
|
|
295
|
+
if (publish !== undefined && typeof publish !== "boolean") throw new AgentError("invalid", "publish must be true or false")
|
|
296
|
+
const revision = expected(expectedRevision)
|
|
297
|
+
const { id } = await managed(context)
|
|
298
|
+
const team = await teams.saveDraft(context.user.id, id, patch, revision)
|
|
299
|
+
await changed(context, "team.draft", team.id, teamUpdatedEvent(team, "draft"))
|
|
300
|
+
if (publish !== true) return ok({ team })
|
|
301
|
+
const result = await teams.publish(context.user.id, id, team.revision)
|
|
302
|
+
await publishedEvent(context, result.team, result.published)
|
|
303
|
+
return ok(result)
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
method: "GET",
|
|
308
|
+
path: `${PREFIX}/:id/draft`,
|
|
309
|
+
allow: READ,
|
|
310
|
+
async handle(context) {
|
|
311
|
+
const team = await readable(context)
|
|
312
|
+
const draft = await teams.draft(context.user.id, team.id)
|
|
313
|
+
if (!draft) throw new AgentError("not_found", `Team ${team.id} has no draft`)
|
|
314
|
+
return ok({ draft })
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
method: "DELETE",
|
|
319
|
+
path: `${PREFIX}/:id/draft`,
|
|
320
|
+
allow: MANAGE,
|
|
321
|
+
async handle(context) {
|
|
322
|
+
const revision = expected(text(context.url, "expectedRevision"))
|
|
323
|
+
const { id } = await managed(context)
|
|
324
|
+
const team = await teams.discardDraft(context.user.id, id, revision)
|
|
325
|
+
await changed(context, "team.draft.discard", team.id, teamUpdatedEvent(team, "draft_discarded"))
|
|
326
|
+
return ok({ team })
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
method: "PUT",
|
|
331
|
+
path: `${PREFIX}/:id/draft/roles/:role`,
|
|
332
|
+
allow: MANAGE,
|
|
333
|
+
async handle(context) {
|
|
334
|
+
const { expectedRevision, ...fields } = await bodyOf(context.request)
|
|
335
|
+
return draftEdit(context, expected(expectedRevision), (spec) => setRole(spec, context.params.role!, fields))
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
method: "DELETE",
|
|
340
|
+
path: `${PREFIX}/:id/draft/roles/:role`,
|
|
341
|
+
allow: MANAGE,
|
|
342
|
+
async handle(context) {
|
|
343
|
+
return draftEdit(context, expected(text(context.url, "expectedRevision")), (spec) => removeRole(spec, context.params.role!))
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
method: "PUT",
|
|
348
|
+
path: `${PREFIX}/:id/draft/edges`,
|
|
349
|
+
allow: MANAGE,
|
|
350
|
+
async handle(context) {
|
|
351
|
+
const { expectedRevision, ...edge } = await bodyOf(context.request)
|
|
352
|
+
return draftEdit(context, expected(expectedRevision), (spec) => setEdge(spec, edge))
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
method: "DELETE",
|
|
357
|
+
path: `${PREFIX}/:id/draft/edges`,
|
|
358
|
+
allow: MANAGE,
|
|
359
|
+
async handle(context) {
|
|
360
|
+
const from = text(context.url, "from")
|
|
361
|
+
const to = text(context.url, "to")
|
|
362
|
+
if (!from || !to) throw new AgentError("invalid", "from and to (role names) are required")
|
|
363
|
+
return draftEdit(context, expected(text(context.url, "expectedRevision")), (spec) => removeEdge(spec, from, to))
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
method: "POST",
|
|
368
|
+
path: `${PREFIX}/:id/publish`,
|
|
369
|
+
allow: MANAGE,
|
|
370
|
+
async handle(context) {
|
|
371
|
+
const body = await bodyOf(context.request)
|
|
372
|
+
only(body, ["expectedRevision"])
|
|
373
|
+
const { id } = await managed(context)
|
|
374
|
+
const result = await teams.publish(context.user.id, id, expected(body.expectedRevision))
|
|
375
|
+
await publishedEvent(context, result.team, result.published)
|
|
376
|
+
return ok(result)
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
statusRoute("pause"),
|
|
380
|
+
statusRoute("resume"),
|
|
381
|
+
statusRoute("archive"),
|
|
382
|
+
statusRoute("restore"),
|
|
383
|
+
|
|
384
|
+
// ---------- history ----------
|
|
385
|
+
{
|
|
386
|
+
method: "GET",
|
|
387
|
+
path: `${PREFIX}/:id/versions`,
|
|
388
|
+
allow: READ,
|
|
389
|
+
async handle(context) {
|
|
390
|
+
const team = await readable(context)
|
|
391
|
+
const limit = intParam(context.url, "limit")
|
|
392
|
+
const cursor = text(context.url, "cursor")
|
|
393
|
+
return ok(await teams.history(context.user.id, team.id, { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) }))
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
method: "GET",
|
|
398
|
+
path: `${PREFIX}/:id/versions/:version`,
|
|
399
|
+
allow: READ,
|
|
400
|
+
async handle(context) {
|
|
401
|
+
const team = await readable(context)
|
|
402
|
+
const raw = context.params.version!
|
|
403
|
+
const number = /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN
|
|
404
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new AgentError("invalid", "version must be a version number")
|
|
405
|
+
const version = await teams.version(context.user.id, team.id, number)
|
|
406
|
+
if (!version) throw new AgentError("not_found", `Team ${team.id} has no retained version ${number}`)
|
|
407
|
+
return ok({ version })
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
method: "GET",
|
|
412
|
+
path: `${PREFIX}/:id/diff`,
|
|
413
|
+
allow: READ,
|
|
414
|
+
async handle(context) {
|
|
415
|
+
const team = await readable(context)
|
|
416
|
+
const versionOf = (name: string): number | undefined => {
|
|
417
|
+
const raw = text(context.url, name)
|
|
418
|
+
if (raw === undefined) return undefined
|
|
419
|
+
if (!/^[0-9]{1,9}$/.test(raw) || Number(raw) < 1) throw new AgentError("invalid", `${name} must be a version number${name === "to" ? " or draft" : ""}`)
|
|
420
|
+
return Number(raw)
|
|
421
|
+
}
|
|
422
|
+
const from = versionOf("from") ?? team.version
|
|
423
|
+
const to: number | "draft" = text(context.url, "to") === "draft" ? "draft" : (versionOf("to") ?? "draft")
|
|
424
|
+
return ok({ from, to, diff: await teams.diff(context.user.id, team.id, from, to) })
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
// ---------- staffing ----------
|
|
429
|
+
{
|
|
430
|
+
method: "GET",
|
|
431
|
+
path: `${PREFIX}/:id/staffing`,
|
|
432
|
+
allow: READ,
|
|
433
|
+
async handle(context) {
|
|
434
|
+
const team = await readable(context, true)
|
|
435
|
+
const ended = boolParam(context.url, "ended") === true
|
|
436
|
+
return ok({ items: await seats.list(context.user.id, team.id, ended ? {} : { current: true }) })
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
method: "PUT",
|
|
441
|
+
path: `${PREFIX}/:id/staffing`,
|
|
442
|
+
allow: STAFF,
|
|
443
|
+
async handle(context) {
|
|
444
|
+
appMayStaff(context)
|
|
445
|
+
const body = await bodyOf(context.request)
|
|
446
|
+
const key = idempotencyKeyOf(context.request)
|
|
447
|
+
const { id } = await managed(context)
|
|
448
|
+
const result = await staffing.attach(context.user.id, context.actor, id, body, key === undefined ? {} : { idempotencyKey: key })
|
|
449
|
+
if (result.created) await deps.audit?.audit({ actor: context.user.id, action: "team.staff", target: result.staffing.id, detail: context.actor.kind })
|
|
450
|
+
return storeAnswer(result.created, { staffing: result.staffing, ...(result.workspace ? { workspace: result.workspace } : {}) })
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
method: "POST",
|
|
455
|
+
path: `${PREFIX}/:id/staffing/launch`,
|
|
456
|
+
allow: STAFF,
|
|
457
|
+
async handle(context) {
|
|
458
|
+
appMayStaff(context)
|
|
459
|
+
const body = await bodyOf(context.request)
|
|
460
|
+
const key = idempotencyKeyOf(context.request)
|
|
461
|
+
const { id } = await managed(context)
|
|
462
|
+
const result = await staffing.launch(context.user.id, context.actor, id, body, key === undefined ? {} : { idempotencyKey: key })
|
|
463
|
+
if (result.created) await deps.audit?.audit({ actor: context.user.id, action: "team.staff.launch", target: result.request.id, detail: context.actor.kind })
|
|
464
|
+
return storeAnswer(result.created, { request: result.request, launch: result.launch })
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
method: "POST",
|
|
469
|
+
path: `${PREFIX}/:id/staffing/:staffing/replace`,
|
|
470
|
+
allow: STAFF,
|
|
471
|
+
async handle(context) {
|
|
472
|
+
appMayStaff(context)
|
|
473
|
+
const body = await bodyOf(context.request)
|
|
474
|
+
const { id } = await managed(context)
|
|
475
|
+
const result = await staffing.replace(context.user.id, context.actor, id, context.params.staffing!, body)
|
|
476
|
+
await deps.audit?.audit({ actor: context.user.id, action: "team.replace", target: result.ended.id, detail: context.actor.kind })
|
|
477
|
+
return ok(result)
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
method: "DELETE",
|
|
482
|
+
path: `${PREFIX}/:id/staffing/:staffing`,
|
|
483
|
+
allow: STAFF,
|
|
484
|
+
async handle(context) {
|
|
485
|
+
appMayStaff(context)
|
|
486
|
+
const revision = expected(text(context.url, "expectedRevision"))
|
|
487
|
+
const { id } = await managed(context)
|
|
488
|
+
const ended = await staffing.unstaff(context.user.id, context.actor, id, context.params.staffing!, revision)
|
|
489
|
+
await deps.audit?.audit({ actor: context.user.id, action: "team.unstaff", target: ended.id, detail: context.actor.kind })
|
|
490
|
+
return ok({ staffing: ended })
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
// ---------- roster and status ----------
|
|
495
|
+
{
|
|
496
|
+
method: "GET",
|
|
497
|
+
path: `${PREFIX}/:id/roster`,
|
|
498
|
+
allow: READ,
|
|
499
|
+
async handle(context) {
|
|
500
|
+
const team = await readable(context, true)
|
|
501
|
+
return ok({ roster: await staffing.roster(context.user.id, team.id) })
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
method: "GET",
|
|
506
|
+
path: `${PREFIX}/:id/status`,
|
|
507
|
+
allow: READ,
|
|
508
|
+
async handle(context) {
|
|
509
|
+
const team = await readable(context, true)
|
|
510
|
+
const roster = await staffing.roster(context.user.id, team.id)
|
|
511
|
+
const intake = await deps.intake?.summary(context.user.id, team.id)
|
|
512
|
+
return ok({ team, roster, ...(intake ? { intake } : {}) })
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
]
|
|
516
|
+
}
|