@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
package/src/central/store.ts
CHANGED
|
@@ -126,6 +126,8 @@ export interface PushPrefs {
|
|
|
126
126
|
* (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
|
|
127
127
|
*/
|
|
128
128
|
providerError: boolean
|
|
129
|
+
/** (CON-04) The Concierge needs you: its questions, permissions, Decisions, messages and spoken replies (opens /concierge). */
|
|
130
|
+
concierge: boolean
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export const PUSH_PREF_KEYS = [
|
|
@@ -147,6 +149,7 @@ export const PUSH_PREF_KEYS = [
|
|
|
147
149
|
"decision",
|
|
148
150
|
"message",
|
|
149
151
|
"providerError",
|
|
152
|
+
"concierge",
|
|
150
153
|
] as const satisfies readonly (keyof PushPrefs)[]
|
|
151
154
|
|
|
152
155
|
/** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
|
|
@@ -170,6 +173,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
|
|
|
170
173
|
decision: true,
|
|
171
174
|
message: true,
|
|
172
175
|
providerError: true,
|
|
176
|
+
concierge: true,
|
|
173
177
|
})
|
|
174
178
|
|
|
175
179
|
/** Default and allowed range of a device's activity update interval, in minutes. */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared storage shapes of the team modules (HWA-33): the staffing aggregate is read by the definition store (a
|
|
2
|
+
// publish must not drop a staffed role) and owned by seats.ts.
|
|
3
|
+
import { CONTRACT_VERSION, type TeamStaffing } from "../../protocol"
|
|
4
|
+
import { AgentError } from "../agent/errors"
|
|
5
|
+
|
|
6
|
+
/** One aggregate per team (key: team ID): its staffing records, current and ended, and the staffing Idempotency-Keys. */
|
|
7
|
+
export const TEAM_SEATS = "team-seats"
|
|
8
|
+
|
|
9
|
+
export interface SeatKey {
|
|
10
|
+
readonly staffingID: string
|
|
11
|
+
readonly digest: string
|
|
12
|
+
readonly at: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SeatsDoc {
|
|
16
|
+
readonly v: 1
|
|
17
|
+
readonly teamID: string
|
|
18
|
+
readonly seats: readonly TeamStaffing[]
|
|
19
|
+
readonly keys: Readonly<Record<string, SeatKey>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
23
|
+
|
|
24
|
+
/** The stored staffing aggregate (undefined when missing); newer versions are unsupported, malformed ones internal. */
|
|
25
|
+
export function readSeatsDoc(raw: unknown, teamID: string): SeatsDoc | undefined {
|
|
26
|
+
if (raw === undefined) return undefined
|
|
27
|
+
if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
|
|
28
|
+
throw new AgentError("unsupported", `The staffing of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "team-staffing" } })
|
|
29
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.teamID !== teamID || !Array.isArray(raw.seats)) throw new AgentError("internal", `Stored staffing of team ${teamID} is malformed`)
|
|
30
|
+
return { v: 1, teamID, seats: raw.seats as TeamStaffing[], keys: isObj(raw.keys) ? (raw.keys as Record<string, SeatKey>) : {} }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Current seats (not ended). */
|
|
34
|
+
export const currentSeats = (doc: SeatsDoc | undefined): TeamStaffing[] => (doc?.seats ?? []).filter((s) => s.until === undefined)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Team-aware discovery (HWA-40; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams
|
|
2
|
+
// "Discovery (HWA-40)"; decisions zltiwb4s 2026-10-28 addition, r3vv4eon (+correction), 5etngvgm, qxzrdc6c).
|
|
3
|
+
//
|
|
4
|
+
// Who sees whom, for who / status / sessions / show / log / watch (with or without ?session=) and events:
|
|
5
|
+
// - A session caller holding a seat in at least one ACTIVE team (paused teams count; archived ones do not — the same
|
|
6
|
+
// rule teamDeliveryVerdict uses) is team-scoped: it sees itself, every current member of its teams (grouped by team,
|
|
7
|
+
// with each member's roles there) and, unless the owner turned `teamIsolation` on, sessions in no active team.
|
|
8
|
+
// Sessions seated only in teams the caller is not in are invisible; so are the caller's teammates' seats in teams
|
|
9
|
+
// the caller is not in (never listed). Under isolation, sessions in no team are hidden too.
|
|
10
|
+
// - Sessions in no active team, the user (console/device) and app tokens keep today's owner-wide view (r3vv4eon),
|
|
11
|
+
// whatever teamIsolation says (the frozen contract: isolation hides non-team sessions FROM TEAM MEMBERS only).
|
|
12
|
+
// They may filter by team. Without any team the answers are exactly today's.
|
|
13
|
+
// - An invisible session reads exactly like a foreign or unknown one: not_found "No session <addr>", and address
|
|
14
|
+
// resolution never counts it (no ambiguity, no candidate, no prefix match reveals it): Directory.resolve takes the
|
|
15
|
+
// visibility predicate for that.
|
|
16
|
+
// - Every census reads the teams and seats fresh (no cache), so staffing, unstaffing, replacement generations,
|
|
17
|
+
// pause/archive and the isolation setting apply to the very next read.
|
|
18
|
+
//
|
|
19
|
+
// Directions: `reach` is what the caller may send to a member (`send`) and receive from it (`receive`), each kind
|
|
20
|
+
// asked of teamDeliveryVerdict (never re-derived): per team with that team as the explicit context (the `#team`
|
|
21
|
+
// address), and per entry without one (a plain address). Visibility is not authorization: delivery is still enforced
|
|
22
|
+
// by the TeamPolicy (HWA-34) and acting by grants.
|
|
23
|
+
//
|
|
24
|
+
// Team role addresses (`@@role#team`, `@@role.project#team`) resolve here for reads (show, log, watch): the sessions
|
|
25
|
+
// currently seated in that role of that active team, restricted to what the caller may see.
|
|
26
|
+
//
|
|
27
|
+
// Cost per census: one team list (paged, ≤ TEAM_LIMITS.teamsPerUser) and one current-seat read per active team, plus
|
|
28
|
+
// the settings for team-scoped callers.
|
|
29
|
+
import { parseTeamRoleAddress, teamDeliveryVerdict, type TeamMessageKind, type TeamSeat } from "../../protocol"
|
|
30
|
+
import type { AgentActor } from "../agent/actor"
|
|
31
|
+
import type { Directory, DirectoryEntry, Resolved } from "../agent/directory"
|
|
32
|
+
import { AgentError } from "../agent/errors"
|
|
33
|
+
import type { Registry } from "../registry"
|
|
34
|
+
import type { TeamSeatStore } from "./seats"
|
|
35
|
+
import type { Team, TeamStore } from "./store"
|
|
36
|
+
|
|
37
|
+
const KINDS: readonly TeamMessageKind[] = ["msg", "ask"]
|
|
38
|
+
/** Pages of the team list read per census (teamsPerUser is 100; a page holds the list's default limit). */
|
|
39
|
+
const MAX_TEAM_PAGES = 20
|
|
40
|
+
/** Most candidates listed in an ambiguous error (as the directory). */
|
|
41
|
+
const CANDIDATES_MAX = 20
|
|
42
|
+
|
|
43
|
+
/** What the caller may send to / receive from a session, from teamDeliveryVerdict. */
|
|
44
|
+
export interface Reach {
|
|
45
|
+
readonly send: TeamMessageKind[]
|
|
46
|
+
readonly receive: TeamMessageKind[]
|
|
47
|
+
readonly direction: "two-way" | "outbound" | "inbound" | "none"
|
|
48
|
+
/** The caller can send nothing to it (a restricted path, or outside the caller's teams). */
|
|
49
|
+
readonly unreachable?: true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One team a listed session is seated in. */
|
|
53
|
+
export interface TeamMembership {
|
|
54
|
+
readonly teamID: string
|
|
55
|
+
readonly team: string
|
|
56
|
+
readonly roles: string[]
|
|
57
|
+
/** Team-scoped callers only, never for themselves: with this team as the explicit context. */
|
|
58
|
+
readonly reach?: Reach
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TeamGroupMember {
|
|
62
|
+
readonly sessionID: string
|
|
63
|
+
readonly role: string
|
|
64
|
+
readonly generation: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A team in a discovery answer, with its listed members. */
|
|
68
|
+
export interface TeamGroup {
|
|
69
|
+
readonly teamID: string
|
|
70
|
+
readonly name: string
|
|
71
|
+
readonly title: string
|
|
72
|
+
readonly mode: "open" | "restricted"
|
|
73
|
+
readonly paused?: true
|
|
74
|
+
/** The caller's own roles there (team-scoped callers). */
|
|
75
|
+
readonly yourRoles?: string[]
|
|
76
|
+
readonly members: TeamGroupMember[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A who entry: the directory entry plus additive team fields. */
|
|
80
|
+
export type DiscoveryEntry = DirectoryEntry & {
|
|
81
|
+
readonly harness?: string
|
|
82
|
+
readonly teams?: TeamMembership[]
|
|
83
|
+
/** Team-scoped callers only, never for themselves: what a plain address (no #team) reaches. */
|
|
84
|
+
readonly reach?: Reach
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WhoAnswer {
|
|
88
|
+
readonly sessions: DiscoveryEntry[]
|
|
89
|
+
/** Present when any listed session is seated in a team (always for team-scoped callers). */
|
|
90
|
+
readonly teams?: TeamGroup[]
|
|
91
|
+
/** "team" when the caller sees its teams only; absent for the owner-wide view. */
|
|
92
|
+
readonly scope?: "team"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface WhoFilter {
|
|
96
|
+
readonly project?: string
|
|
97
|
+
readonly role?: string
|
|
98
|
+
/** A team name or ID. */
|
|
99
|
+
readonly team?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Seat extends TeamSeat {
|
|
103
|
+
readonly generation: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One read of the user's active teams and current seats, and what the caller may see. */
|
|
107
|
+
export interface Census {
|
|
108
|
+
readonly userID: string
|
|
109
|
+
/** Set when the caller is team-scoped. */
|
|
110
|
+
readonly callerID?: string
|
|
111
|
+
readonly callerTeams: ReadonlySet<string>
|
|
112
|
+
readonly isolation: boolean
|
|
113
|
+
readonly teams: ReadonlyMap<string, Team>
|
|
114
|
+
/** Current seats in active teams, per session. */
|
|
115
|
+
readonly seats: ReadonlyMap<string, readonly Seat[]>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TeamDiscoveryDeps {
|
|
119
|
+
readonly teams: Pick<TeamStore, "list" | "settings">
|
|
120
|
+
readonly seats: Pick<TeamSeatStore, "list">
|
|
121
|
+
readonly directory: Pick<Directory, "who" | "resolve">
|
|
122
|
+
/** Harness per reported session of the user (who entries carry it when known). */
|
|
123
|
+
readonly harnesses?: (userID: string) => ReadonlyMap<string, string>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const teamScoped = (census: Census): boolean => census.callerTeams.size > 0
|
|
127
|
+
|
|
128
|
+
/** Whether the census's caller may see the session (always, for the owner-wide view). */
|
|
129
|
+
export function visible(census: Census, sessionID: string): boolean {
|
|
130
|
+
if (!teamScoped(census) || sessionID === census.callerID) return true
|
|
131
|
+
const seats = census.seats.get(sessionID) ?? []
|
|
132
|
+
if (seats.some((s) => census.callerTeams.has(s.teamID))) return true
|
|
133
|
+
return !census.isolation && seats.length === 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const missing = (addr: string) => new AgentError("not_found", `No session ${addr}`)
|
|
137
|
+
|
|
138
|
+
/** Journal subject fields that name a session (an event naming any hidden one is hidden as a whole). */
|
|
139
|
+
const SESSION_SUBJECT_FIELDS = ["sessionID", "rootID", "senderID", "recipientID", "sourceSessionID", "parentID"] as const
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The journal events a team-scoped caller may read (unfiltered GET /events and GET /watch, HWA-28): none naming a
|
|
143
|
+
* session it cannot see, nor a team it holds no seat in. Undefined for the owner-wide view (no filtering at all).
|
|
144
|
+
*/
|
|
145
|
+
export function eventVisibility(census: Census): ((subject: Readonly<Record<string, string>>) => boolean) | undefined {
|
|
146
|
+
if (!teamScoped(census)) return undefined
|
|
147
|
+
return (subject) => {
|
|
148
|
+
if (subject.teamID !== undefined && !census.callerTeams.has(subject.teamID)) return false
|
|
149
|
+
return SESSION_SUBJECT_FIELDS.every((field) => subject[field] === undefined || visible(census, subject[field]!))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class TeamDiscovery {
|
|
154
|
+
constructor(private readonly deps: TeamDiscoveryDeps) {}
|
|
155
|
+
|
|
156
|
+
/** Reads the user's active teams, their current seats and the caller's scope. */
|
|
157
|
+
async census(userID: string, actor: AgentActor): Promise<Census> {
|
|
158
|
+
const teams = new Map<string, Team>()
|
|
159
|
+
let cursor: string | undefined
|
|
160
|
+
for (let page = 0; page < MAX_TEAM_PAGES; page++) {
|
|
161
|
+
const { items, cursor: next } = await this.deps.teams.list(userID, { state: "active", ...(cursor ? { cursor } : {}) })
|
|
162
|
+
for (const team of items) teams.set(team.id, team)
|
|
163
|
+
if (!next) break
|
|
164
|
+
cursor = next
|
|
165
|
+
}
|
|
166
|
+
const seats = new Map<string, Seat[]>()
|
|
167
|
+
for (const teamID of [...teams.keys()]) {
|
|
168
|
+
let current
|
|
169
|
+
try {
|
|
170
|
+
current = await this.deps.seats.list(userID, teamID, { current: true })
|
|
171
|
+
} catch {
|
|
172
|
+
teams.delete(teamID) // a seat aggregate this build cannot read takes no part (as in delivery)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
for (const s of current) seats.set(s.sessionID, [...(seats.get(s.sessionID) ?? []), { teamID, role: s.role, generation: s.generation }])
|
|
176
|
+
}
|
|
177
|
+
const callerID = actor.kind === "session" ? actor.sessionID : undefined
|
|
178
|
+
const callerTeams = new Set((callerID !== undefined ? (seats.get(callerID) ?? []) : []).map((s) => s.teamID))
|
|
179
|
+
let isolation = false
|
|
180
|
+
if (callerTeams.size) {
|
|
181
|
+
try {
|
|
182
|
+
isolation = (await this.deps.teams.settings(userID)).teamIsolation
|
|
183
|
+
} catch {
|
|
184
|
+
isolation = true // unreadable settings: show less, never more
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { userID, ...(callerTeams.size ? { callerID } : {}), callerTeams, isolation, teams, seats }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The sessions an address names for the caller (Directory.resolve plus `@@role#team`), never an invisible one.
|
|
192
|
+
* not_found when nothing visible matches; ambiguous lists visible candidates only.
|
|
193
|
+
*/
|
|
194
|
+
resolve(census: Census, addr: string): Resolved {
|
|
195
|
+
const team = parseTeamRoleAddress(addr)
|
|
196
|
+
if (!team) return this.deps.directory.resolve(census.userID, addr, { visible: (id) => visible(census, id) })
|
|
197
|
+
const record = [...census.teams.values()].find((t) => t.name === team.team)
|
|
198
|
+
if (!record || (teamScoped(census) && !census.callerTeams.has(record.id))) throw missing(addr)
|
|
199
|
+
const sessions: DirectoryEntry[] = []
|
|
200
|
+
for (const [sessionID, seats] of census.seats) {
|
|
201
|
+
if (!seats.some((s) => s.teamID === record.id && s.role === team.role) || !visible(census, sessionID)) continue
|
|
202
|
+
const entry = this.entry(census.userID, sessionID)
|
|
203
|
+
if (entry && (team.project === undefined || entry.project === team.project)) sessions.push(entry)
|
|
204
|
+
}
|
|
205
|
+
if (!sessions.length) throw missing(addr)
|
|
206
|
+
return { address: { kind: "role", role: team.role, ...(team.project ? { project: team.project } : {}) }, sessions }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Exactly one visible session (a role with several visible holders is ambiguous). */
|
|
210
|
+
resolveOne(census: Census, addr: string): DirectoryEntry {
|
|
211
|
+
const { sessions } = this.resolve(census, addr)
|
|
212
|
+
if (sessions.length > 1) {
|
|
213
|
+
const candidates = sessions.slice(0, CANDIDATES_MAX).map((s) => ({ sessionID: s.sessionID, ...(s.name ? { name: s.name } : {}), ...(s.project ? { project: s.project } : {}) }))
|
|
214
|
+
throw new AgentError("ambiguous", `${addr} matches ${sessions.length} sessions`, { extra: { candidates } })
|
|
215
|
+
}
|
|
216
|
+
return sessions[0]!
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** GET /who: the caller's visible sessions, filtered, with team memberships, reach and groups. */
|
|
220
|
+
who(census: Census, filter: WhoFilter = {}): WhoAnswer {
|
|
221
|
+
const { userID } = census
|
|
222
|
+
const project = filter.project?.toLowerCase()
|
|
223
|
+
let role = filter.role?.replace(/^@@/, "")
|
|
224
|
+
let teamName = filter.team
|
|
225
|
+
if (role?.includes("#")) {
|
|
226
|
+
const parsed = parseTeamRoleAddress(`@@${role}`)
|
|
227
|
+
if (!parsed) throw new AgentError("invalid", "A role is 2–40 characters: a-z, 0-9 and -, optionally .project and #team")
|
|
228
|
+
if (teamName !== undefined && teamName !== parsed.team) throw new AgentError("invalid", "role names another team than team")
|
|
229
|
+
role = parsed.project ? `${parsed.role}.${parsed.project}` : parsed.role
|
|
230
|
+
teamName = parsed.team
|
|
231
|
+
}
|
|
232
|
+
const team = teamName !== undefined ? this.team(census, teamName) : undefined
|
|
233
|
+
const [roleName, inlineProject] = role?.split(".") ?? []
|
|
234
|
+
const roleProject = inlineProject?.toLowerCase() ?? project
|
|
235
|
+
// Global role holders (Directory.who validates the role) and the base list.
|
|
236
|
+
const globalHolders = role !== undefined ? new Set(this.deps.directory.who(userID, { ...(project ? { project } : {}), role }).map((e) => e.sessionID)) : undefined
|
|
237
|
+
const listed = new Map(this.deps.directory.who(userID, project ? { project } : {}).map((e) => [e.sessionID, e] as const))
|
|
238
|
+
// Seated members the directory does not list (offline, unnamed): every teammate, and a filtered team's members.
|
|
239
|
+
const extra = new Set(team ? [team.id] : [...census.callerTeams])
|
|
240
|
+
for (const [sessionID, seats] of census.seats) {
|
|
241
|
+
if (listed.has(sessionID) || !seats.some((s) => extra.has(s.teamID))) continue
|
|
242
|
+
const entry = this.entry(userID, sessionID)
|
|
243
|
+
if (entry && !entry.archived && (project === undefined || entry.project === project || entry.roles.some((r) => r.project === project))) listed.set(sessionID, entry)
|
|
244
|
+
}
|
|
245
|
+
const entries = [...listed.values()]
|
|
246
|
+
.filter((e) => visible(census, e.sessionID))
|
|
247
|
+
.filter((e) => !team || this.shown(census, e.sessionID).some((s) => s.teamID === team.id))
|
|
248
|
+
.filter((e) => {
|
|
249
|
+
if (role === undefined) return true
|
|
250
|
+
const seated = this.shown(census, e.sessionID).some((s) => s.role === roleName && (!team || s.teamID === team.id) && (roleProject === undefined || e.project === roleProject))
|
|
251
|
+
return seated || (!team && globalHolders!.has(e.sessionID))
|
|
252
|
+
})
|
|
253
|
+
const harnesses = this.deps.harnesses?.(userID)
|
|
254
|
+
const sessions = entries.map((e) => this.annotate(census, e, harnesses?.get(e.sessionID)))
|
|
255
|
+
const teams = this.groups(census, sessions.map((s) => s.sessionID), team?.id)
|
|
256
|
+
return { sessions, ...(teams.length ? { teams } : {}), ...(teamScoped(census) ? { scope: "team" as const } : {}) }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Team groups of the listed sessions (the caller's teams only, all of them, when team-scoped), by team name. */
|
|
260
|
+
groups(census: Census, sessionIDs: readonly string[], only?: string): TeamGroup[] {
|
|
261
|
+
const out = new Map<string, TeamGroupMember[]>()
|
|
262
|
+
if (teamScoped(census)) for (const teamID of census.callerTeams) if (only === undefined || only === teamID) out.set(teamID, [])
|
|
263
|
+
for (const id of sessionIDs) {
|
|
264
|
+
for (const s of this.shown(census, id)) {
|
|
265
|
+
if (only !== undefined && s.teamID !== only) continue
|
|
266
|
+
out.set(s.teamID, [...(out.get(s.teamID) ?? []), { sessionID: id, role: s.role, generation: s.generation }])
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return [...out]
|
|
270
|
+
.map(([teamID, members]) => {
|
|
271
|
+
const team = census.teams.get(teamID)!
|
|
272
|
+
const yours = census.callerID !== undefined ? (census.seats.get(census.callerID) ?? []).filter((s) => s.teamID === teamID).map((s) => s.role) : []
|
|
273
|
+
return { teamID, name: team.name, title: team.title, mode: team.mode, ...(team.paused ? { paused: true as const } : {}), ...(yours.length ? { yourRoles: yours } : {}), members }
|
|
274
|
+
})
|
|
275
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The session's team memberships as the caller may see them (show), each with reach when team-scoped. */
|
|
279
|
+
memberships(census: Census, sessionID: string): TeamMembership[] {
|
|
280
|
+
const byTeam = new Map<string, string[]>()
|
|
281
|
+
for (const s of this.shown(census, sessionID)) byTeam.set(s.teamID, [...(byTeam.get(s.teamID) ?? []), s.role])
|
|
282
|
+
return [...byTeam].map(([teamID, roles]) => {
|
|
283
|
+
const reach = this.reachOf(census, sessionID, teamID)
|
|
284
|
+
return { teamID, team: census.teams.get(teamID)!.name, roles, ...(reach ? { reach } : {}) }
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** What a team-scoped caller may send to / receive from the session (undefined otherwise, and for itself). */
|
|
289
|
+
reachOf(census: Census, sessionID: string, teamID?: string): Reach | undefined {
|
|
290
|
+
const caller = census.callerID
|
|
291
|
+
if (!teamScoped(census) || caller === undefined || sessionID === caller) return undefined
|
|
292
|
+
const mine = census.seats.get(caller) ?? []
|
|
293
|
+
const theirs = census.seats.get(sessionID) ?? []
|
|
294
|
+
const allowed = (from: readonly Seat[], to: readonly Seat[], kind: TeamMessageKind) =>
|
|
295
|
+
teamDeliveryVerdict({ kind, toUser: false, replyToPermittedAsk: false, senderSeats: from, recipientSeats: to, ...(teamID !== undefined ? { teamID } : {}), teams: census.teams }).allow
|
|
296
|
+
const send = KINDS.filter((k) => allowed(mine, theirs, k))
|
|
297
|
+
const receive = KINDS.filter((k) => allowed(theirs, mine, k))
|
|
298
|
+
const direction = send.length && receive.length ? "two-way" : send.length ? "outbound" : receive.length ? "inbound" : "none"
|
|
299
|
+
return { send, receive, direction, ...(send.length ? {} : { unreachable: true as const }) }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- internals ----------
|
|
303
|
+
|
|
304
|
+
/** The seats of a session the caller may be told about: in its own teams when team-scoped, else all. */
|
|
305
|
+
private shown(census: Census, sessionID: string): readonly Seat[] {
|
|
306
|
+
const seats = census.seats.get(sessionID) ?? []
|
|
307
|
+
return teamScoped(census) ? seats.filter((s) => census.callerTeams.has(s.teamID)) : seats
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private annotate(census: Census, entry: DirectoryEntry, harness: string | undefined): DiscoveryEntry {
|
|
311
|
+
const teams = this.memberships(census, entry.sessionID)
|
|
312
|
+
const reach = this.reachOf(census, entry.sessionID)
|
|
313
|
+
return { ...entry, ...(harness ? { harness } : {}), ...(teams.length ? { teams } : {}), ...(reach ? { reach } : {}) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A team filter: by name or ID, among the caller's teams when team-scoped (else not_found, like a missing one). */
|
|
317
|
+
private team(census: Census, nameOrID: string): Team {
|
|
318
|
+
const team = census.teams.get(nameOrID) ?? [...census.teams.values()].find((t) => t.name === nameOrID)
|
|
319
|
+
if (!team || (teamScoped(census) && !census.callerTeams.has(team.id))) throw new AgentError("not_found", `No team ${nameOrID}`)
|
|
320
|
+
return team
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private entry(userID: string, sessionID: string): DirectoryEntry | undefined {
|
|
324
|
+
try {
|
|
325
|
+
return this.deps.directory.resolve(userID, sessionID).sessions.find((e) => e.sessionID === sessionID)
|
|
326
|
+
} catch {
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The harness of each session the user's spokes report now (roots and their subagents), from the catalog. */
|
|
333
|
+
export function catalogHarnesses(registry: Pick<Registry, "catalog" | "sessionTenant">): (userID: string) => ReadonlyMap<string, string> {
|
|
334
|
+
return (userID) => {
|
|
335
|
+
const out = new Map<string, string>()
|
|
336
|
+
for (const machine of registry.catalog()) {
|
|
337
|
+
for (const process of machine.processes) {
|
|
338
|
+
for (const s of process.sessions) {
|
|
339
|
+
if (registry.sessionTenant(s.id) !== userID) continue
|
|
340
|
+
for (const id of [s.id, ...s.status.map((e) => e.id)]) if (!out.has(id)) out.set(id, process.harness)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Role and policy-edge editing of a team's draft (HWA-37): small edits computed against the current draft spec (or
|
|
2
|
+
// the published one) and saved through TeamStore.saveDraft, which validates the whole spec again. Nothing here
|
|
3
|
+
// publishes: delivery and staffing keep using the published version until an explicit publish.
|
|
4
|
+
//
|
|
5
|
+
// - setRole: adds the role, or replaces the role of that name wholesale (its other fields are dropped).
|
|
6
|
+
// - removeRole: drops the role and every edge touching it; the entry role cannot be removed (name another first).
|
|
7
|
+
// - setEdge: replaces the edge drawn from → to (and, for a two-way edge, the one drawn to → from). An edge drawn the
|
|
8
|
+
// other way that already covers the pair is refused by the spec check (one edge per ordered pair).
|
|
9
|
+
// - removeEdge: drops the edge drawn from → to, or a two-way edge drawn to → from.
|
|
10
|
+
import { AgentError } from "../agent/errors"
|
|
11
|
+
import type { TeamSpec } from "./validation"
|
|
12
|
+
|
|
13
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
14
|
+
|
|
15
|
+
/** A patch replacing the roles (and edges) for saveDraft. */
|
|
16
|
+
export interface DraftEdit {
|
|
17
|
+
readonly roles?: readonly unknown[]
|
|
18
|
+
readonly edges?: readonly unknown[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setRole(spec: TeamSpec, name: string, fields: Record<string, unknown>): DraftEdit {
|
|
22
|
+
if (fields.name !== undefined && fields.name !== name) throw new AgentError("invalid", "The role's name is in the path; to rename, add the new role and remove the old one")
|
|
23
|
+
const role = { ...fields, name }
|
|
24
|
+
const at = spec.roles.findIndex((r) => r.name === name)
|
|
25
|
+
const roles = at < 0 ? [...spec.roles, role] : spec.roles.map((r, i) => (i === at ? role : r))
|
|
26
|
+
return { roles }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function removeRole(spec: TeamSpec, name: string): DraftEdit {
|
|
30
|
+
if (!spec.roles.some((r) => r.name === name)) throw new AgentError("not_found", `The draft has no role ${name.slice(0, 40)}`)
|
|
31
|
+
if (spec.entryRole === name) throw new AgentError("invalid", `${name} is the entry role`, { hint: "Name another entry role first (team update entryRole)" })
|
|
32
|
+
return { roles: spec.roles.filter((r) => r.name !== name), edges: spec.edges.filter((e) => e.from !== name && e.to !== name) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setEdge(spec: TeamSpec, edge: Record<string, unknown>): DraftEdit {
|
|
36
|
+
if (!isObj(edge) || typeof edge.from !== "string" || typeof edge.to !== "string") throw new AgentError("invalid", "An edge needs from and to (role names)")
|
|
37
|
+
const { from, to } = edge
|
|
38
|
+
const replaced = (e: TeamSpec["edges"][number]) => (e.from === from && e.to === to) || (edge.direction === "two-way" && e.from === to && e.to === from)
|
|
39
|
+
return { edges: [...spec.edges.filter((e) => !replaced(e)), edge] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function removeEdge(spec: TeamSpec, from: string, to: string): DraftEdit {
|
|
43
|
+
const drop = (e: TeamSpec["edges"][number]) => (e.from === from && e.to === to) || (e.direction === "two-way" && e.from === to && e.to === from)
|
|
44
|
+
if (!spec.edges.some(drop)) throw new AgentError("not_found", `The draft has no edge ${from.slice(0, 40)} → ${to.slice(0, 40)}`)
|
|
45
|
+
return { edges: spec.edges.filter((e) => !drop(e)) }
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Compact journal events of teams (HWA-33; contract TEAM_EVENT_TYPES, family `manage`). Allowlisted payloads only:
|
|
2
|
+
// IDs, enums (state, change), revisions, version numbers, generations and flags. Never names, titles, descriptions,
|
|
3
|
+
// role names or labels, responsibilities, tags, metadata, projects or any other definition text. The routes
|
|
4
|
+
// (HWA-37) emit them after a store call succeeds; HWA-34 owns `team.denied`, HWA-36 `team.intake`.
|
|
5
|
+
import type { EventInput } from "../agent/events"
|
|
6
|
+
import type { TeamSeatRecord } from "./seats"
|
|
7
|
+
import type { Team } from "./store"
|
|
8
|
+
|
|
9
|
+
/** What a `team.updated` changed. */
|
|
10
|
+
export type TeamChange = "draft" | "draft_discarded" | "published" | "paused" | "resumed"
|
|
11
|
+
|
|
12
|
+
export const teamCreatedEvent = (team: Team): EventInput => ({
|
|
13
|
+
type: "team.created",
|
|
14
|
+
family: "manage",
|
|
15
|
+
subject: { teamID: team.id },
|
|
16
|
+
data: { revision: team.revision, version: team.version, state: team.state },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const teamUpdatedEvent = (team: Team, change: TeamChange): EventInput => ({
|
|
20
|
+
type: "team.updated",
|
|
21
|
+
family: "manage",
|
|
22
|
+
subject: { teamID: team.id },
|
|
23
|
+
data: { change, revision: team.revision, version: team.version, paused: team.paused === true },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/** Archived (`state: archived`) or restored (`state: active`). */
|
|
27
|
+
export const teamArchivedEvent = (team: Team): EventInput => ({
|
|
28
|
+
type: "team.archived",
|
|
29
|
+
family: "manage",
|
|
30
|
+
subject: { teamID: team.id },
|
|
31
|
+
data: { state: team.state, revision: team.revision, version: team.version },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const teamStaffedEvent = (seat: TeamSeatRecord): EventInput => ({
|
|
35
|
+
type: "team.staffed",
|
|
36
|
+
family: "manage",
|
|
37
|
+
subject: { teamID: seat.teamID, staffingID: seat.id, sessionID: seat.sessionID },
|
|
38
|
+
data: { generation: seat.generation, revision: seat.revision, version: seat.teamVersion },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export const teamUnstaffedEvent = (seat: TeamSeatRecord): EventInput => ({
|
|
42
|
+
type: "team.unstaffed",
|
|
43
|
+
family: "manage",
|
|
44
|
+
subject: { teamID: seat.teamID, staffingID: seat.id, sessionID: seat.sessionID },
|
|
45
|
+
data: { generation: seat.generation, revision: seat.revision },
|
|
46
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Team intake routes on /api/agent (HWA-36; contract: Teams `POST /teams/:id/intake`, scope teams:submit). The rest
|
|
2
|
+
// of the teams surface (definitions, staffing, roster, teams:read|manage) is HWA-37's.
|
|
3
|
+
//
|
|
4
|
+
// POST /teams/:id/intake {title, brief?, priority?, labels?, due?, criteria?, ref?} Idempotency-Key
|
|
5
|
+
// GET /teams/:id/intake?status&limit&cursor → {items, cursor?}
|
|
6
|
+
// GET /teams/:id/intake/:intake → {intake}
|
|
7
|
+
// POST /teams/:id/intake/:intake/status {status, expectedRevision, reason?} → {intake, assignment?}
|
|
8
|
+
//
|
|
9
|
+
// Who (TeamIntakeService decides the details): users; app tokens with teams:submit (submit, read and cancel their
|
|
10
|
+
// own) — reads also accept teams:read/teams:manage once HWA-37 registers them; session tokens (submit under team
|
|
11
|
+
// policy, entry seats report status). Another user's or an unknown team: 404 not_found. Bodies are bounded here.
|
|
12
|
+
import { boundedText } from "../agent/body"
|
|
13
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
14
|
+
import { AgentError } from "../agent/errors"
|
|
15
|
+
import { idempotencyKeyOf, storeAnswer } from "../agent/idempotency"
|
|
16
|
+
import type { Store } from "../store"
|
|
17
|
+
import type { TeamIntakeService } from "./intake"
|
|
18
|
+
|
|
19
|
+
const PREFIX = "/api/agent/teams/:id/intake"
|
|
20
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
21
|
+
/** Request bodies (the brief is ≤ 16 000 characters). */
|
|
22
|
+
export const INTAKE_BODY_MAX = 96 * 1024
|
|
23
|
+
|
|
24
|
+
export interface IntakeRoutesDeps {
|
|
25
|
+
readonly intake: TeamIntakeService
|
|
26
|
+
readonly audit?: Pick<Store, "audit">
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
30
|
+
|
|
31
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
32
|
+
const declared = Number(request.headers.get("content-length") ?? "0")
|
|
33
|
+
if (declared > INTAKE_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${INTAKE_BODY_MAX} bytes`)
|
|
34
|
+
const text = await boundedText(request, INTAKE_BODY_MAX)
|
|
35
|
+
if (text.length > INTAKE_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${INTAKE_BODY_MAX} bytes`)
|
|
36
|
+
let raw: unknown
|
|
37
|
+
try {
|
|
38
|
+
raw = JSON.parse(text === "" ? "{}" : text)
|
|
39
|
+
} catch {
|
|
40
|
+
throw new AgentError("invalid", "Body must be JSON")
|
|
41
|
+
}
|
|
42
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
43
|
+
return raw as Record<string, unknown>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function intParam(url: URL, name: string): number | undefined {
|
|
47
|
+
const raw = url.searchParams.get(name)
|
|
48
|
+
return raw === null ? undefined : /^-?[0-9]{1,12}$/.test(raw) ? Number(raw) : Number.NaN
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function intakeRoutes(deps: IntakeRoutesDeps): AgentRoute[] {
|
|
52
|
+
const { intake } = deps
|
|
53
|
+
const audited = async (context: AgentContext, action: string, target: string) => {
|
|
54
|
+
await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
|
|
55
|
+
}
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
method: "POST",
|
|
59
|
+
path: PREFIX,
|
|
60
|
+
allow: { scope: "teams:submit", session: true },
|
|
61
|
+
async handle(context) {
|
|
62
|
+
const body = await bodyOf(context.request)
|
|
63
|
+
const key = idempotencyKeyOf(context.request)
|
|
64
|
+
const { intake: item, created } = await intake.submit(context.user.id, context.actor, context.params.id!, body, key === undefined ? {} : { idempotencyKey: key })
|
|
65
|
+
if (created) await audited(context, "team.intake.submit", item.id)
|
|
66
|
+
return storeAnswer(created, { intake: item })
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
method: "GET",
|
|
71
|
+
path: PREFIX,
|
|
72
|
+
allow: { app: true, session: true },
|
|
73
|
+
async handle({ url, user, actor, params }) {
|
|
74
|
+
const limit = intParam(url, "limit")
|
|
75
|
+
const status = url.searchParams.get("status") || undefined
|
|
76
|
+
const cursor = url.searchParams.get("cursor") || undefined
|
|
77
|
+
return ok(await intake.list(user.id, actor, params.id!, { ...(limit !== undefined ? { limit } : {}), ...(status ? { status } : {}), ...(cursor ? { cursor } : {}) }))
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
method: "GET",
|
|
82
|
+
path: `${PREFIX}/:intake`,
|
|
83
|
+
allow: { app: true, session: true },
|
|
84
|
+
async handle({ user, actor, params }) {
|
|
85
|
+
return ok({ intake: await intake.get(user.id, actor, params.id!, params.intake!) })
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
method: "POST",
|
|
90
|
+
path: `${PREFIX}/:intake/status`,
|
|
91
|
+
allow: { app: true, session: true },
|
|
92
|
+
async handle(context) {
|
|
93
|
+
const body = await bodyOf(context.request)
|
|
94
|
+
const result = await intake.setStatus(context.user.id, context.actor, context.params.id!, context.params.intake!, body)
|
|
95
|
+
await audited(context, `team.intake.${result.intake.status === "routed" ? "progress" : result.intake.status}`, result.intake.id)
|
|
96
|
+
return ok(result)
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
}
|