@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,256 @@
|
|
|
1
|
+
// Team definition bodies (HWA-33): the frozen contract check (teamInputProblem, packages/protocol/src/teams.ts)
|
|
2
|
+
// plus the additive fields this store keeps next to it, normalization, the content digest and the revision diff.
|
|
3
|
+
//
|
|
4
|
+
// A TeamSpec is the publishable content of a team: everything a published revision freezes. The store adds the
|
|
5
|
+
// record fields around it (id, state, revision, version, paused, timestamps). Additive (contract questions in the
|
|
6
|
+
// HWA-33 handoff), all optional, none of them grants authority:
|
|
7
|
+
// - team `projects`: project names the team works on (filters).
|
|
8
|
+
// - role `responsibilities` (short lines), `profile` (desired profile ref {profileID, version?}), `prefs`
|
|
9
|
+
// (harness / model / machineID placement preferences for staffing), `minMembers` / `maxMembers`.
|
|
10
|
+
// Stricter than the contract (never looser): unknown fields are refused, a role description is bounded text, an
|
|
11
|
+
// edge's kinds are unique, and one ordered role pair is covered by at most one edge (a two-way edge covers both).
|
|
12
|
+
import { createHash } from "node:crypto"
|
|
13
|
+
import {
|
|
14
|
+
DESCRIPTION_MAX,
|
|
15
|
+
isContractID,
|
|
16
|
+
isLabel,
|
|
17
|
+
isSlug,
|
|
18
|
+
isText,
|
|
19
|
+
TEAM_LIMITS,
|
|
20
|
+
type TeamEdge,
|
|
21
|
+
type TeamMessageKind,
|
|
22
|
+
type TeamMode,
|
|
23
|
+
type TeamRole,
|
|
24
|
+
teamInputProblem,
|
|
25
|
+
} from "../../protocol"
|
|
26
|
+
import { canonicalBody } from "../agent/idempotency"
|
|
27
|
+
import { AgentError } from "../agent/errors"
|
|
28
|
+
|
|
29
|
+
export const TEAM_STORE_LIMITS = {
|
|
30
|
+
...TEAM_LIMITS,
|
|
31
|
+
responsibilitiesPerRole: 16,
|
|
32
|
+
responsibilityMax: 200,
|
|
33
|
+
projectsPerTeam: 16,
|
|
34
|
+
/** Published revisions kept per team (older ones nobody pins are dropped). */
|
|
35
|
+
versionsRetained: 50,
|
|
36
|
+
/** Ended staffing records kept per team (oldest dropped first). */
|
|
37
|
+
endedSeatsRetained: 200,
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
/** Machine IDs as the hub mints them (same shape the workspace bindings accept). */
|
|
41
|
+
const MACHINE_ID = /^[A-Za-z0-9_.:-]{1,128}$/
|
|
42
|
+
const MODEL_MAX = 200
|
|
43
|
+
|
|
44
|
+
/** Staffing preferences of a role (advisory: staffing still runs under the caller's grants). */
|
|
45
|
+
export interface TeamRolePrefs {
|
|
46
|
+
readonly harness?: string
|
|
47
|
+
readonly model?: string
|
|
48
|
+
readonly machineID?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A contract TeamRole plus the additive fields. */
|
|
52
|
+
export interface TeamRoleSpec extends TeamRole {
|
|
53
|
+
readonly responsibilities?: readonly string[]
|
|
54
|
+
readonly profile?: { readonly profileID: string; readonly version?: number }
|
|
55
|
+
readonly prefs?: TeamRolePrefs
|
|
56
|
+
readonly minMembers?: number
|
|
57
|
+
readonly maxMembers?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The publishable content of a team. */
|
|
61
|
+
export interface TeamSpec {
|
|
62
|
+
readonly name: string
|
|
63
|
+
readonly title: string
|
|
64
|
+
readonly description?: string
|
|
65
|
+
readonly mode: TeamMode
|
|
66
|
+
readonly roles: readonly TeamRoleSpec[]
|
|
67
|
+
readonly entryRole: string
|
|
68
|
+
readonly edges: readonly TeamEdge[]
|
|
69
|
+
readonly tags?: readonly string[]
|
|
70
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
71
|
+
readonly projects?: readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TeamDiff {
|
|
75
|
+
/** Changed scalar fields, in a fixed order: title, description, mode, entryRole. */
|
|
76
|
+
readonly fields: readonly string[]
|
|
77
|
+
readonly roles: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] }
|
|
78
|
+
/** Edge keys `from>to` as drawn. */
|
|
79
|
+
readonly edges: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] }
|
|
80
|
+
readonly tags: { readonly added: readonly string[]; readonly removed: readonly string[] }
|
|
81
|
+
readonly metadata: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] }
|
|
82
|
+
readonly projects: { readonly added: readonly string[]; readonly removed: readonly string[] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
|
|
86
|
+
const ROLE_FIELDS = new Set(["name", "label", "description", "externalContact", "responsibilities", "profile", "prefs", "minMembers", "maxMembers"])
|
|
87
|
+
const EDGE_FIELDS = new Set(["from", "to", "kinds", "direction"])
|
|
88
|
+
const PROFILE_FIELDS = new Set(["profileID", "version"])
|
|
89
|
+
const PREFS_FIELDS = new Set(["harness", "model", "machineID"])
|
|
90
|
+
const REQUIRED = new Set(["title", "mode", "roles", "entryRole"])
|
|
91
|
+
const SCALARS = ["title", "description", "mode", "entryRole"] as const
|
|
92
|
+
|
|
93
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
94
|
+
const fail = (message: string): never => {
|
|
95
|
+
throw new AgentError("invalid", message)
|
|
96
|
+
}
|
|
97
|
+
const printable = (key: string) => (/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)")
|
|
98
|
+
|
|
99
|
+
function onlyFields(v: Record<string, unknown>, allowed: ReadonlySet<string>, where: string): void {
|
|
100
|
+
for (const key of Object.keys(v)) if (!allowed.has(key)) fail(`${where}unknown field ${printable(key)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function roleProblem(role: Record<string, unknown>): string | undefined {
|
|
104
|
+
const name = role.name as string
|
|
105
|
+
if (role.description !== undefined && !isText(role.description, DESCRIPTION_MAX)) return `role ${name} description must be at most ${DESCRIPTION_MAX} characters`
|
|
106
|
+
const r = role.responsibilities
|
|
107
|
+
if (r !== undefined) {
|
|
108
|
+
const limit = TEAM_STORE_LIMITS.responsibilitiesPerRole
|
|
109
|
+
if (!Array.isArray(r) || r.length > limit || !r.every((line) => isLabel(line, TEAM_STORE_LIMITS.responsibilityMax)))
|
|
110
|
+
return `role ${name} responsibilities must be at most ${limit} lines of 1–${TEAM_STORE_LIMITS.responsibilityMax} characters`
|
|
111
|
+
}
|
|
112
|
+
const p = role.profile
|
|
113
|
+
if (p !== undefined) {
|
|
114
|
+
if (!isObj(p)) return `role ${name} profile must be {profileID, version?}`
|
|
115
|
+
onlyFields(p, PROFILE_FIELDS, `role ${name} profile: `)
|
|
116
|
+
if (!isContractID("profile", p.profileID)) return `role ${name} profile.profileID must be a profile ID`
|
|
117
|
+
if (p.version !== undefined && !(Number.isSafeInteger(p.version) && (p.version as number) >= 1)) return `role ${name} profile.version must be a version number`
|
|
118
|
+
}
|
|
119
|
+
const prefs = role.prefs
|
|
120
|
+
if (prefs !== undefined) {
|
|
121
|
+
if (!isObj(prefs)) return `role ${name} prefs must be an object`
|
|
122
|
+
onlyFields(prefs, PREFS_FIELDS, `role ${name} prefs: `)
|
|
123
|
+
if (prefs.harness !== undefined && !isSlug(prefs.harness)) return `role ${name} prefs.harness must be a harness name`
|
|
124
|
+
if (prefs.model !== undefined && !isLabel(prefs.model, MODEL_MAX)) return `role ${name} prefs.model must be 1–${MODEL_MAX} characters`
|
|
125
|
+
if (prefs.machineID !== undefined && !(typeof prefs.machineID === "string" && MACHINE_ID.test(prefs.machineID))) return `role ${name} prefs.machineID must be a machine ID`
|
|
126
|
+
}
|
|
127
|
+
const cap = TEAM_LIMITS.sessionsPerRole
|
|
128
|
+
const { minMembers: min, maxMembers: max } = role
|
|
129
|
+
if (min !== undefined && !(Number.isSafeInteger(min) && (min as number) >= 0 && (min as number) <= cap)) return `role ${name} minMembers must be 0–${cap}`
|
|
130
|
+
if (max !== undefined && !(Number.isSafeInteger(max) && (max as number) >= 1 && (max as number) <= cap)) return `role ${name} maxMembers must be 1–${cap}`
|
|
131
|
+
if (min !== undefined && max !== undefined && (min as number) > (max as number)) return `role ${name} minMembers must not exceed maxMembers`
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizedRole(r: Record<string, unknown>): TeamRoleSpec {
|
|
136
|
+
const profile = r.profile as Record<string, unknown> | undefined
|
|
137
|
+
const prefs = r.prefs as Record<string, unknown> | undefined
|
|
138
|
+
return {
|
|
139
|
+
name: r.name as string,
|
|
140
|
+
...(r.label !== undefined ? { label: r.label as string } : {}),
|
|
141
|
+
...(r.description !== undefined ? { description: r.description as string } : {}),
|
|
142
|
+
...(r.externalContact !== undefined ? { externalContact: r.externalContact as boolean } : {}),
|
|
143
|
+
...(r.responsibilities !== undefined ? { responsibilities: [...(r.responsibilities as string[])] } : {}),
|
|
144
|
+
...(profile !== undefined ? { profile: { profileID: profile.profileID as string, ...(profile.version !== undefined ? { version: profile.version as number } : {}) } } : {}),
|
|
145
|
+
...(prefs !== undefined
|
|
146
|
+
? {
|
|
147
|
+
prefs: {
|
|
148
|
+
...(prefs.harness !== undefined ? { harness: prefs.harness as string } : {}),
|
|
149
|
+
...(prefs.model !== undefined ? { model: prefs.model as string } : {}),
|
|
150
|
+
...(prefs.machineID !== undefined ? { machineID: prefs.machineID as string } : {}),
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
: {}),
|
|
154
|
+
...(r.minMembers !== undefined ? { minMembers: r.minMembers as number } : {}),
|
|
155
|
+
...(r.maxMembers !== undefined ? { maxMembers: r.maxMembers as number } : {}),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A validated, normalized team spec; throws AgentError invalid. */
|
|
160
|
+
export function teamSpecOf(input: unknown): TeamSpec {
|
|
161
|
+
if (!isObj(input)) return fail("body must be an object")
|
|
162
|
+
onlyFields(input, SPEC_FIELDS, "")
|
|
163
|
+
if (Array.isArray(input.roles)) for (const role of input.roles) if (isObj(role)) onlyFields(role, ROLE_FIELDS, `role ${printable(String(role.name))}: `)
|
|
164
|
+
if (Array.isArray(input.edges)) for (const edge of input.edges) if (isObj(edge)) onlyFields(edge, EDGE_FIELDS, "edge: ")
|
|
165
|
+
const problem = teamInputProblem(input)
|
|
166
|
+
if (problem) fail(problem)
|
|
167
|
+
const roles = input.roles as Record<string, unknown>[]
|
|
168
|
+
for (const role of roles) {
|
|
169
|
+
const p = roleProblem(role)
|
|
170
|
+
if (p) fail(p)
|
|
171
|
+
}
|
|
172
|
+
const projects = input.projects
|
|
173
|
+
if (projects !== undefined) {
|
|
174
|
+
const limit = TEAM_STORE_LIMITS.projectsPerTeam
|
|
175
|
+
if (!Array.isArray(projects) || projects.length > limit || !projects.every((p) => isLabel(p)) || new Set(projects).size !== projects.length)
|
|
176
|
+
fail(`projects must be at most ${limit} unique project names`)
|
|
177
|
+
}
|
|
178
|
+
const covered = new Set<string>()
|
|
179
|
+
const edges: TeamEdge[] = []
|
|
180
|
+
for (const e of (input.edges ?? []) as Record<string, unknown>[]) {
|
|
181
|
+
const kinds = e.kinds as TeamMessageKind[]
|
|
182
|
+
if (new Set(kinds).size !== kinds.length) fail(`edge ${e.from as string} → ${e.to as string} lists a kind twice`)
|
|
183
|
+
const pairs = e.direction === "two-way" ? [[e.from, e.to], [e.to, e.from]] : [[e.from, e.to]]
|
|
184
|
+
for (const [from, to] of pairs) {
|
|
185
|
+
const key = `${from as string} → ${to as string}`
|
|
186
|
+
if (covered.has(key)) fail(`There are two edges for ${key}; one edge per role pair (two-way covers both directions)`)
|
|
187
|
+
covered.add(key)
|
|
188
|
+
}
|
|
189
|
+
edges.push({ from: e.from as string, to: e.to as string, kinds: [...kinds].sort(), direction: e.direction as TeamEdge["direction"] })
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
name: input.name as string,
|
|
193
|
+
title: input.title as string,
|
|
194
|
+
...(input.description !== undefined ? { description: input.description as string } : {}),
|
|
195
|
+
mode: input.mode as TeamMode,
|
|
196
|
+
roles: roles.map(normalizedRole),
|
|
197
|
+
entryRole: input.entryRole as string,
|
|
198
|
+
edges,
|
|
199
|
+
...(input.tags !== undefined ? { tags: [...(input.tags as string[])] } : {}),
|
|
200
|
+
...(input.metadata !== undefined ? { metadata: { ...(input.metadata as Record<string, string>) } } : {}),
|
|
201
|
+
...(projects !== undefined ? { projects: [...(projects as string[])] } : {}),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The spec after a patch: fields replace the base's (roles and edges wholesale), null clears an optional one, the
|
|
207
|
+
* name never changes (it is the `#team` address), and the result is validated as a whole.
|
|
208
|
+
*/
|
|
209
|
+
export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
|
|
210
|
+
if (!isObj(patch)) return fail("body must be an object")
|
|
211
|
+
onlyFields(patch, SPEC_FIELDS, "")
|
|
212
|
+
const keys = Object.keys(patch)
|
|
213
|
+
if (keys.length === 0) fail("Nothing to change")
|
|
214
|
+
if (patch.name !== undefined && patch.name !== base.name) fail("A team's name cannot change (it is the #team address); create a new team")
|
|
215
|
+
const next: Record<string, unknown> = { ...base }
|
|
216
|
+
for (const key of keys) {
|
|
217
|
+
const value = patch[key]
|
|
218
|
+
if (value === null) {
|
|
219
|
+
if (REQUIRED.has(key) || key === "name") fail(`${key} cannot be cleared`)
|
|
220
|
+
if (key === "edges") next.edges = []
|
|
221
|
+
else delete next[key]
|
|
222
|
+
} else if (value !== undefined) next[key] = value
|
|
223
|
+
}
|
|
224
|
+
return teamSpecOf(next)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** sha256 of the spec's canonical JSON (key order never matters). */
|
|
228
|
+
export const specDigest = (spec: TeamSpec): string => createHash("sha256").update(canonicalBody(spec)).digest("hex")
|
|
229
|
+
|
|
230
|
+
const same = (a: unknown, b: unknown) => canonicalBody(a) === canonicalBody(b)
|
|
231
|
+
|
|
232
|
+
function setDiff(from: readonly string[] = [], to: readonly string[] = []) {
|
|
233
|
+
return { added: to.filter((x) => !from.includes(x)), removed: from.filter((x) => !to.includes(x)) }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function keyedDiff<T>(from: ReadonlyMap<string, T>, to: ReadonlyMap<string, T>) {
|
|
237
|
+
return {
|
|
238
|
+
added: [...to.keys()].filter((k) => !from.has(k)),
|
|
239
|
+
removed: [...from.keys()].filter((k) => !to.has(k)),
|
|
240
|
+
changed: [...to.keys()].filter((k) => from.has(k) && !same(from.get(k), to.get(k))),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** What changed from one spec to another (names and keys only, never descriptions). */
|
|
245
|
+
export function diffTeamSpecs(from: TeamSpec, to: TeamSpec): TeamDiff {
|
|
246
|
+
const edgeMap = (s: TeamSpec) => new Map(s.edges.map((e) => [`${e.from}>${e.to}`, e] as const))
|
|
247
|
+
const metaMap = (s: TeamSpec) => new Map(Object.entries(s.metadata ?? {}))
|
|
248
|
+
return {
|
|
249
|
+
fields: SCALARS.filter((f) => !same(from[f], to[f])),
|
|
250
|
+
roles: keyedDiff(new Map(from.roles.map((r) => [r.name, r] as const)), new Map(to.roles.map((r) => [r.name, r] as const))),
|
|
251
|
+
edges: keyedDiff(edgeMap(from), edgeMap(to)),
|
|
252
|
+
tags: setDiff(from.tags, to.tags),
|
|
253
|
+
metadata: keyedDiff(metaMap(from), metaMap(to)),
|
|
254
|
+
projects: setDiff(from.projects, to.projects),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// The master situation board (HWA-10; contract: Workspaces `GET /workspaces/:id/board` in docs/plans/2026-10-30-
|
|
2
|
+
// hub-workspaces-artifacts-contracts.md and its HWA-06 clarification):
|
|
3
|
+
//
|
|
4
|
+
// GET /api/agent/workspaces/:id/board workspaces:read + sessions:read
|
|
5
|
+
//
|
|
6
|
+
// One read-only, bounded snapshot of a workspace for whoever coordinates it: the workspace, its current members
|
|
7
|
+
// with presence, last-known session state and blocker references, supervision links, assignments with their
|
|
8
|
+
// explicit status, open Decisions concerning members, and artifact references.
|
|
9
|
+
//
|
|
10
|
+
// Who: users (their own workspaces; another user's is not_found). App tokens with workspaces:read and
|
|
11
|
+
// sessions:read (the board names sessions and shows presence). Session tokens are refused: membership grants
|
|
12
|
+
// nothing, and a master session reading its board through its own token would be a new authority (an open
|
|
13
|
+
// contract question, not implemented). Sections an app may not read are `{status: "omitted", reason: "scope"}`:
|
|
14
|
+
// Decisions need messages:read and show only those the app asked (as GET /decisions does); artifacts need
|
|
15
|
+
// artifacts:read and are listed through the artifact store's ACL as the user.
|
|
16
|
+
//
|
|
17
|
+
// What it never carries: prompts, message bodies, Decision questions/options/answers, blocker summaries,
|
|
18
|
+
// assignment criteria or artifact titles. Those stay behind their existing detail routes (GET /sessions/:addr,
|
|
19
|
+
// /decisions/:id, /workspaces/:id/assignments/:id, /artifacts/:id), each with its own authorization. Assignment
|
|
20
|
+
// titles are shown: the same caller reads them on the assignment routes with the same scopes.
|
|
21
|
+
//
|
|
22
|
+
// Truthfulness: assignment status is exactly the stored, explicitly set status; idleness, presence or departure
|
|
23
|
+
// never complete, start or cancel anything. A session the catalog does not report here (another pod, offline) has
|
|
24
|
+
// `blockers: {reported: false}` rather than an empty list. Members and open assignments carry `stale` markers when
|
|
25
|
+
// the hub cannot vouch for them now (offline, archived, deleted or unknown sessions; departed or missing assignees;
|
|
26
|
+
// no update within staleAfterMs). Unwired sections are `{status: "unsupported", feature}`; a section whose source
|
|
27
|
+
// fails is `{status: "failed"}` without failing the whole board.
|
|
28
|
+
//
|
|
29
|
+
// Bounds and fanout: every section is capped (BOARD_LIMITS) and says `truncated`; the session facts are one
|
|
30
|
+
// batched lookup over the shown members from the hub's own directory and catalog — the board never calls a spoke.
|
|
31
|
+
// No model or runtime runs here, nothing is written and no event is appended (the compact journal is unchanged).
|
|
32
|
+
import { LIST_LIMIT_MAX, WORKSPACE_LIMITS, WORKSPACE_SCOPES, type Artifact, type Page } from "../../protocol"
|
|
33
|
+
import type { AgentActor } from "../agent/actor"
|
|
34
|
+
import type { Decision, Decisions } from "../agent/decisions"
|
|
35
|
+
import type { Directory } from "../agent/directory"
|
|
36
|
+
import { partyFromActor } from "../agent/envelope"
|
|
37
|
+
import { AgentError } from "../agent/errors"
|
|
38
|
+
import type { ReadRegistry } from "../agent/read-routes"
|
|
39
|
+
import type { AgentRoute } from "../agent/routes"
|
|
40
|
+
import type { ArtifactPrincipal } from "../artifacts/access"
|
|
41
|
+
import type { AssignmentStatus, MembershipStore, SessionPresence, StoredAssignment } from "./members"
|
|
42
|
+
import type { WorkspaceStore } from "./store"
|
|
43
|
+
import type { SupervisionStore } from "./supervision"
|
|
44
|
+
|
|
45
|
+
const PREFIX = "/api/agent/workspaces"
|
|
46
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
47
|
+
const [READ_SCOPE] = WORKSPACE_SCOPES
|
|
48
|
+
|
|
49
|
+
export interface BoardLimits {
|
|
50
|
+
/** Current members shown (the session lookup covers only these). */
|
|
51
|
+
readonly members: number
|
|
52
|
+
/** Open (not done or cancelled) assignments shown, oldest first. */
|
|
53
|
+
readonly assignments: number
|
|
54
|
+
/** Most recently closed assignments shown. */
|
|
55
|
+
readonly closedAssignments: number
|
|
56
|
+
/** Supervision links shown. */
|
|
57
|
+
readonly links: number
|
|
58
|
+
/** Open Decisions shown, newest first. */
|
|
59
|
+
readonly decisions: number
|
|
60
|
+
/** Artifact references shown, most recently updated first. */
|
|
61
|
+
readonly artifacts: number
|
|
62
|
+
/** Blocker references per member. */
|
|
63
|
+
readonly blockersPerMember: number
|
|
64
|
+
/** An open assignment without an update for this long is marked `no_recent_update`. */
|
|
65
|
+
readonly assignmentStaleMs: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const BOARD_LIMITS: BoardLimits = {
|
|
69
|
+
members: WORKSPACE_LIMITS.membersPerWorkspace,
|
|
70
|
+
assignments: LIST_LIMIT_MAX,
|
|
71
|
+
closedAssignments: 20,
|
|
72
|
+
links: WORKSPACE_LIMITS.supervisionLinksPerWorkspace,
|
|
73
|
+
decisions: 50,
|
|
74
|
+
artifacts: 50,
|
|
75
|
+
blockersPerMember: 8,
|
|
76
|
+
assignmentStaleMs: 24 * 60 * 60_000,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A blocker reference from the catalog (`summary` may be present at the source; the board never copies it). */
|
|
80
|
+
export interface BoardBlockerSource {
|
|
81
|
+
readonly kind: string
|
|
82
|
+
readonly id: string
|
|
83
|
+
readonly sessionID?: string
|
|
84
|
+
readonly summary?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What the hub knows about a member's session now, without asking a spoke. */
|
|
88
|
+
export interface BoardSession {
|
|
89
|
+
/** idle / working / blocked / error from the catalog; "offline" when no spoke reports it; else last known. */
|
|
90
|
+
readonly state: string
|
|
91
|
+
readonly online: boolean
|
|
92
|
+
readonly lastSeen?: number
|
|
93
|
+
readonly machineID?: string
|
|
94
|
+
readonly name?: string
|
|
95
|
+
readonly busySince?: number
|
|
96
|
+
/** Present only when this hub's catalog reports the session now (absent: not reported, never "none"). */
|
|
97
|
+
readonly blockers?: readonly BoardBlockerSource[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One batched lookup of the user's sessions; IDs it does not know are left out of the map. */
|
|
101
|
+
export type BoardSessions = (userID: string, sessionIDs: readonly string[]) => Promise<ReadonlyMap<string, BoardSession>>
|
|
102
|
+
|
|
103
|
+
export interface BoardDeps {
|
|
104
|
+
readonly workspaces: Pick<WorkspaceStore, "require">
|
|
105
|
+
readonly members: Pick<MembershipStore, "members" | "assignments">
|
|
106
|
+
readonly supervision: Pick<SupervisionStore, "graph">
|
|
107
|
+
/** Session facts (the hub's directory and catalog); without it members show presence only. */
|
|
108
|
+
readonly sessions?: BoardSessions
|
|
109
|
+
/** Decisions (ask @me); without it the section is unsupported. */
|
|
110
|
+
readonly decisions?: Pick<Decisions, "list">
|
|
111
|
+
/** The artifact store (its ACL applies); without it the section is unsupported. */
|
|
112
|
+
readonly artifacts?: { list(userID: string, principal: ArtifactPrincipal, options?: { limit?: number; workspaceID?: string }): Promise<Page<Artifact>> }
|
|
113
|
+
readonly limits?: Partial<BoardLimits>
|
|
114
|
+
readonly now?: () => number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Section<T> =
|
|
118
|
+
| { readonly status: "ok"; readonly items: readonly T[]; readonly truncated: boolean }
|
|
119
|
+
| { readonly status: "omitted"; readonly reason: "scope"; readonly scope: string }
|
|
120
|
+
| { readonly status: "unsupported"; readonly feature: string }
|
|
121
|
+
| { readonly status: "failed" }
|
|
122
|
+
|
|
123
|
+
export type AssignmentStale = "assignee_left" | "assignee_missing" | "assignee_offline" | "no_recent_update"
|
|
124
|
+
|
|
125
|
+
export interface BoardMember {
|
|
126
|
+
readonly memberID: string
|
|
127
|
+
readonly sessionID: string
|
|
128
|
+
readonly role: string
|
|
129
|
+
readonly generation: number
|
|
130
|
+
readonly revision: number
|
|
131
|
+
readonly joinedAt: number
|
|
132
|
+
readonly master: boolean
|
|
133
|
+
readonly presence: SessionPresence
|
|
134
|
+
readonly session?: Omit<BoardSession, "blockers">
|
|
135
|
+
readonly blockers: { readonly reported: false } | { readonly reported: true; readonly count: number; readonly items: readonly { kind: string; id: string; sessionID?: string }[]; readonly truncated?: true }
|
|
136
|
+
/** Not-closed assignments by status. */
|
|
137
|
+
readonly assignments: { readonly open: number; readonly in_progress: number; readonly blocked: number }
|
|
138
|
+
/** Open Decisions concerning this member's session (absent when the Decisions section is not readable). */
|
|
139
|
+
readonly decisions?: number
|
|
140
|
+
/** Why the hub cannot vouch for this member's session now (absent: online). */
|
|
141
|
+
readonly stale?: Exclude<SessionPresence, "online">
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface BoardAssignment {
|
|
145
|
+
readonly id: string
|
|
146
|
+
readonly memberID: string
|
|
147
|
+
readonly title: string
|
|
148
|
+
readonly status: AssignmentStatus
|
|
149
|
+
readonly ref?: string
|
|
150
|
+
readonly revision: number
|
|
151
|
+
readonly createdAt: number
|
|
152
|
+
readonly updatedAt: number
|
|
153
|
+
readonly closedAt?: number
|
|
154
|
+
readonly assignee: "current" | "left" | "missing"
|
|
155
|
+
readonly stale?: readonly AssignmentStale[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface BoardDecision {
|
|
159
|
+
readonly id: string
|
|
160
|
+
readonly state: Decision["state"]
|
|
161
|
+
readonly urgency: Decision["urgency"]
|
|
162
|
+
readonly tier: Decision["tier"]
|
|
163
|
+
readonly at: number
|
|
164
|
+
readonly expiresAt: number
|
|
165
|
+
readonly deadline?: number
|
|
166
|
+
readonly escalatedAt?: number
|
|
167
|
+
/** Member sessions it concerns (the asker when a member, then `about`). */
|
|
168
|
+
readonly sessionIDs: readonly string[]
|
|
169
|
+
readonly memberIDs: readonly string[]
|
|
170
|
+
readonly stale?: "past_deadline"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface BoardArtifact {
|
|
174
|
+
readonly id: string
|
|
175
|
+
readonly kind: Artifact["kind"]
|
|
176
|
+
readonly visibility: Artifact["visibility"]
|
|
177
|
+
readonly state: Artifact["state"]
|
|
178
|
+
readonly revision: number
|
|
179
|
+
readonly latestRevisionID: string
|
|
180
|
+
readonly updatedAt: number
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const CLOSED: ReadonlySet<AssignmentStatus> = new Set(["done", "cancelled"])
|
|
184
|
+
const ASSIGNMENT_STATUS_LIST: readonly AssignmentStatus[] = ["open", "in_progress", "blocked", "done", "cancelled"]
|
|
185
|
+
|
|
186
|
+
const hasScope = (actor: AgentActor, scope: string) => actor.kind !== "app" || actor.scopes.includes(scope)
|
|
187
|
+
|
|
188
|
+
/** A section's source failed: say so instead of failing the board (unsupported stays truthful). */
|
|
189
|
+
function failure<T>(feature: string, cause: unknown): Section<T> {
|
|
190
|
+
if (cause instanceof AgentError && cause.code === "unsupported") return { status: "unsupported", feature }
|
|
191
|
+
console.error(`[hub] workspace board: ${feature} failed: ${(cause as Error).message}`)
|
|
192
|
+
return { status: "failed" }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The production session facts: the user's directory and this hub's catalog, never a spoke. */
|
|
196
|
+
export function registryBoardSessions(directory: Pick<Directory, "resolve">, registry: Pick<ReadRegistry, "session" | "sessionTenant">): BoardSessions {
|
|
197
|
+
return async (userID, sessionIDs) => {
|
|
198
|
+
const out = new Map<string, BoardSession>()
|
|
199
|
+
for (const sessionID of sessionIDs) {
|
|
200
|
+
let entry
|
|
201
|
+
try {
|
|
202
|
+
entry = directory.resolve(userID, sessionID).sessions.find((s) => s.sessionID === sessionID)
|
|
203
|
+
} catch (cause) {
|
|
204
|
+
if (cause instanceof AgentError) continue
|
|
205
|
+
throw cause
|
|
206
|
+
}
|
|
207
|
+
if (!entry) continue
|
|
208
|
+
const named = { ...(entry.name ? { name: `@${entry.name}` } : {}), ...(entry.machineID ? { machineID: entry.machineID } : {}) }
|
|
209
|
+
const live = registry.sessionTenant(sessionID) === userID ? registry.session(sessionID) : undefined
|
|
210
|
+
if (live) {
|
|
211
|
+
const since = live.status.find((s) => s.id === sessionID) as { since?: number } | undefined
|
|
212
|
+
out.set(sessionID, {
|
|
213
|
+
state: live.state,
|
|
214
|
+
online: true,
|
|
215
|
+
...named,
|
|
216
|
+
...(since?.since !== undefined ? { busySince: since.since } : {}),
|
|
217
|
+
blockers: live.blockers.map((b) => ({ kind: b.kind, id: b.id, ...(b.sessionID && b.sessionID !== sessionID ? { sessionID: b.sessionID } : {}) })),
|
|
218
|
+
})
|
|
219
|
+
} else if (!entry.online) {
|
|
220
|
+
out.set(sessionID, { state: "offline", online: false, ...named, ...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}) })
|
|
221
|
+
} else {
|
|
222
|
+
// Online per the (shared) directory but not in this hub's catalog: state as last known, blockers unknown.
|
|
223
|
+
out.set(sessionID, { state: entry.state ?? "unknown", online: true, ...named })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Every retained assignment (≤ open + closed caps, so at most a couple of pages). */
|
|
231
|
+
async function allAssignments(members: BoardDeps["members"], userID: string, workspaceID: string) {
|
|
232
|
+
const out = []
|
|
233
|
+
let cursor: string | undefined
|
|
234
|
+
do {
|
|
235
|
+
const page = await members.assignments(userID, workspaceID, { limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
|
|
236
|
+
out.push(...page.items)
|
|
237
|
+
cursor = page.cursor
|
|
238
|
+
} while (cursor)
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Builds the board for one caller. Read-only. */
|
|
243
|
+
export async function workspaceBoard(deps: BoardDeps, userID: string, actor: AgentActor, workspaceID: string) {
|
|
244
|
+
const limits = { ...BOARD_LIMITS, ...deps.limits }
|
|
245
|
+
const now = (deps.now ?? Date.now)()
|
|
246
|
+
const workspace = await deps.workspaces.require(userID, workspaceID)
|
|
247
|
+
const [memberViews, assignmentViews, graph] = await Promise.all([
|
|
248
|
+
deps.members.members(userID, workspaceID),
|
|
249
|
+
allAssignments(deps.members, userID, workspaceID),
|
|
250
|
+
deps.supervision.graph(userID, workspaceID),
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
// ---------- Decisions (open, concerning current members) ----------
|
|
254
|
+
const sessionToMember = new Map(memberViews.map((v) => [v.member.sessionID, v.member.id]))
|
|
255
|
+
let decisions: Section<BoardDecision>
|
|
256
|
+
const perSession = new Map<string, number>()
|
|
257
|
+
if (!deps.decisions) decisions = { status: "unsupported", feature: "decisions" }
|
|
258
|
+
else if (!hasScope(actor, "messages:read")) decisions = { status: "omitted", reason: "scope", scope: "messages:read" }
|
|
259
|
+
else {
|
|
260
|
+
try {
|
|
261
|
+
const open = await deps.decisions.list(userID, { state: "open", ...(actor.kind === "user" ? {} : { from: partyFromActor(actor) }) })
|
|
262
|
+
const relevant: BoardDecision[] = []
|
|
263
|
+
for (const d of open) {
|
|
264
|
+
const sessionIDs = [...new Set([...(d.from.kind === "session" ? [d.from.sessionID] : []), ...d.about])].filter((s) => sessionToMember.has(s))
|
|
265
|
+
if (sessionIDs.length === 0) continue
|
|
266
|
+
for (const s of sessionIDs) perSession.set(s, (perSession.get(s) ?? 0) + 1)
|
|
267
|
+
relevant.push({
|
|
268
|
+
id: d.id,
|
|
269
|
+
state: d.state,
|
|
270
|
+
urgency: d.urgency,
|
|
271
|
+
tier: d.tier,
|
|
272
|
+
at: d.at,
|
|
273
|
+
expiresAt: d.expiresAt,
|
|
274
|
+
...(d.deadline !== undefined ? { deadline: d.deadline } : {}),
|
|
275
|
+
...(d.escalatedAt !== undefined ? { escalatedAt: d.escalatedAt } : {}),
|
|
276
|
+
sessionIDs,
|
|
277
|
+
memberIDs: sessionIDs.map((s) => sessionToMember.get(s)!),
|
|
278
|
+
...(d.deadline !== undefined && d.deadline < now ? { stale: "past_deadline" as const } : {}),
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
decisions = { status: "ok", items: relevant.slice(0, limits.decisions), truncated: relevant.length > limits.decisions }
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
decisions = failure("decisions", cause)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------- members ----------
|
|
288
|
+
const shown = memberViews.slice(0, limits.members)
|
|
289
|
+
const facts = deps.sessions && shown.length > 0 ? await deps.sessions(userID, shown.map((v) => v.member.sessionID)) : new Map<string, BoardSession>()
|
|
290
|
+
const workload = new Map<string, { open: number; in_progress: number; blocked: number }>()
|
|
291
|
+
for (const { assignment } of assignmentViews) {
|
|
292
|
+
if (CLOSED.has(assignment.status)) continue
|
|
293
|
+
const counts = workload.get(assignment.memberID) ?? { open: 0, in_progress: 0, blocked: 0 }
|
|
294
|
+
counts[assignment.status as "open" | "in_progress" | "blocked"]++
|
|
295
|
+
workload.set(assignment.memberID, counts)
|
|
296
|
+
}
|
|
297
|
+
const boardMembers = shown.map(({ member, presence }): BoardMember => {
|
|
298
|
+
const fact = facts.get(member.sessionID)
|
|
299
|
+
const { blockers, ...session } = fact ?? ({} as BoardSession)
|
|
300
|
+
const refs = blockers?.map((b) => ({ kind: b.kind, id: b.id, ...(b.sessionID ? { sessionID: b.sessionID } : {}) }))
|
|
301
|
+
return {
|
|
302
|
+
memberID: member.id,
|
|
303
|
+
sessionID: member.sessionID,
|
|
304
|
+
role: member.role,
|
|
305
|
+
generation: member.generation,
|
|
306
|
+
revision: member.revision,
|
|
307
|
+
joinedAt: member.joinedAt,
|
|
308
|
+
master: workspace.masterMemberID === member.id,
|
|
309
|
+
presence,
|
|
310
|
+
...(fact ? { session } : {}),
|
|
311
|
+
blockers: refs
|
|
312
|
+
? { reported: true, count: refs.length, items: refs.slice(0, limits.blockersPerMember), ...(refs.length > limits.blockersPerMember ? { truncated: true as const } : {}) }
|
|
313
|
+
: { reported: false },
|
|
314
|
+
assignments: workload.get(member.id) ?? { open: 0, in_progress: 0, blocked: 0 },
|
|
315
|
+
...(decisions.status === "ok" ? { decisions: perSession.get(member.sessionID) ?? 0 } : {}),
|
|
316
|
+
...(presence !== "online" ? { stale: presence } : {}),
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// ---------- assignments ----------
|
|
321
|
+
const counts = Object.fromEntries(ASSIGNMENT_STATUS_LIST.map((s) => [s, 0])) as Record<AssignmentStatus, number>
|
|
322
|
+
for (const { assignment } of assignmentViews) counts[assignment.status]++
|
|
323
|
+
const open = assignmentViews.filter((v) => !CLOSED.has(v.assignment.status))
|
|
324
|
+
const closed = assignmentViews
|
|
325
|
+
.filter((v) => CLOSED.has(v.assignment.status))
|
|
326
|
+
.sort((a, b) => ((b.assignment as StoredAssignment).closedAt ?? 0) - ((a.assignment as StoredAssignment).closedAt ?? 0))
|
|
327
|
+
const assignmentItems = [...open.slice(0, limits.assignments), ...closed.slice(0, limits.closedAssignments)].map(({ assignment, member, presence }): BoardAssignment => {
|
|
328
|
+
const a = assignment as StoredAssignment
|
|
329
|
+
const stale: AssignmentStale[] = []
|
|
330
|
+
if (!CLOSED.has(a.status)) {
|
|
331
|
+
if (member === "left") stale.push("assignee_left")
|
|
332
|
+
else if (member === "missing") stale.push("assignee_missing")
|
|
333
|
+
else if (presence !== "online") stale.push("assignee_offline")
|
|
334
|
+
if (now - a.updatedAt > limits.assignmentStaleMs) stale.push("no_recent_update")
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
id: a.id,
|
|
338
|
+
memberID: a.memberID,
|
|
339
|
+
title: a.title,
|
|
340
|
+
status: a.status,
|
|
341
|
+
...(a.ref !== undefined ? { ref: a.ref } : {}),
|
|
342
|
+
revision: a.revision,
|
|
343
|
+
createdAt: a.createdAt,
|
|
344
|
+
updatedAt: a.updatedAt,
|
|
345
|
+
...(a.closedAt !== undefined ? { closedAt: a.closedAt } : {}),
|
|
346
|
+
assignee: member,
|
|
347
|
+
...(stale.length ? { stale } : {}),
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// ---------- artifacts (references only, through the artifact ACL as the user) ----------
|
|
352
|
+
let artifacts: Section<BoardArtifact>
|
|
353
|
+
if (!deps.artifacts) artifacts = { status: "unsupported", feature: "artifacts" }
|
|
354
|
+
else if (!hasScope(actor, "artifacts:read")) artifacts = { status: "omitted", reason: "scope", scope: "artifacts:read" }
|
|
355
|
+
else {
|
|
356
|
+
try {
|
|
357
|
+
const page = await deps.artifacts.list(userID, { kind: "user" }, { workspaceID, limit: limits.artifacts })
|
|
358
|
+
artifacts = {
|
|
359
|
+
status: "ok",
|
|
360
|
+
items: page.items.slice(0, limits.artifacts).map((a) => ({ id: a.id, kind: a.kind, visibility: a.visibility, state: a.state, revision: a.revision, latestRevisionID: a.latestRevisionID, updatedAt: a.updatedAt })),
|
|
361
|
+
truncated: page.cursor !== undefined || page.items.length > limits.artifacts,
|
|
362
|
+
}
|
|
363
|
+
} catch (cause) {
|
|
364
|
+
artifacts = failure("artifacts", cause)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
v: 1 as const,
|
|
370
|
+
at: now,
|
|
371
|
+
staleAfterMs: limits.assignmentStaleMs,
|
|
372
|
+
workspace: {
|
|
373
|
+
id: workspace.id,
|
|
374
|
+
title: workspace.title,
|
|
375
|
+
state: workspace.state,
|
|
376
|
+
revision: workspace.revision,
|
|
377
|
+
...(workspace.masterMemberID ? { masterMemberID: workspace.masterMemberID } : {}),
|
|
378
|
+
createdAt: workspace.createdAt,
|
|
379
|
+
updatedAt: workspace.updatedAt,
|
|
380
|
+
},
|
|
381
|
+
members: { items: boardMembers, total: memberViews.length, truncated: memberViews.length > shown.length },
|
|
382
|
+
supervision: {
|
|
383
|
+
revision: graph.revision,
|
|
384
|
+
links: graph.links.slice(0, limits.links).map(({ link, member, supervisor }) => ({ memberID: link.memberID, supervisorMemberID: link.supervisorMemberID, order: link.order, member, supervisor })),
|
|
385
|
+
truncated: graph.links.length > limits.links,
|
|
386
|
+
},
|
|
387
|
+
assignments: { items: assignmentItems, counts, truncated: open.length > limits.assignments || closed.length > limits.closedAssignments },
|
|
388
|
+
decisions,
|
|
389
|
+
artifacts,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export type WorkspaceBoard = Awaited<ReturnType<typeof workspaceBoard>>
|
|
394
|
+
|
|
395
|
+
/** GET /workspaces/:id/board. */
|
|
396
|
+
export function boardRoutes(deps: BoardDeps): AgentRoute[] {
|
|
397
|
+
return [
|
|
398
|
+
{
|
|
399
|
+
method: "GET",
|
|
400
|
+
path: `${PREFIX}/:id/board`,
|
|
401
|
+
allow: { scope: READ_SCOPE },
|
|
402
|
+
async handle({ actor, user, params }) {
|
|
403
|
+
if (!hasScope(actor, "sessions:read")) throw new AgentError("forbidden", "The board also needs scope sessions:read")
|
|
404
|
+
return Response.json(await workspaceBoard(deps, user.id, actor, params.id!), { headers: NO_STORE })
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
]
|
|
408
|
+
}
|