@huynle/hub 0.7.4 → 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 +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- 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 +632 -49
- 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,772 @@
|
|
|
1
|
+
// Team intake (HWA-36; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams and the
|
|
2
|
+
// HWA-33/34/35/04/06/11 clarifications; record TeamIntake in packages/protocol/src/teams.ts).
|
|
3
|
+
//
|
|
4
|
+
// Work is given to a TEAM, never to a session: the user (console/MCP), an app token with `teams:submit` (automations
|
|
5
|
+
// such as a Brain connector: docs/teams-intake.md) or a session submits {title, brief?, priority?, labels?, due?,
|
|
6
|
+
// criteria?, ref?}. The hub
|
|
7
|
+
// 1. stores a TeamIntake (`queued`) in collection `team-intake` (one aggregate per team, key: team ID: items,
|
|
8
|
+
// Idempotency-Keys) and the brief alone in `team-intake-brief` (key: intake ID) until it is delivered;
|
|
9
|
+
// 2. routes it (drain) to a current seat of the team's entry role — an online one first (earliest staffed), else
|
|
10
|
+
// an offline one (Delivery keeps the message pending until its spoke reports the session): creates a
|
|
11
|
+
// WorkspaceAssignment (HWA-04) of that seat's member in the team's workspace (HWA-35, one per team) with
|
|
12
|
+
// `ref` = intake ID, then delivers the task as a hub `msg` and marks the intake `routed` (`progress: open`);
|
|
13
|
+
// 3. while the entry role is vacant (no current seat), the team is paused, or its workspace is not linked yet, the
|
|
14
|
+
// intake stays `queued` (durable, capped at TEAM_LIMITS.openIntakePerTeam open items per team). onEvent drains a
|
|
15
|
+
// team on `team.staffed`, `team.updated`/`team.archived` (resume/restore), and `session.added` or
|
|
16
|
+
// `workspace.member.joined` of an entry seat (staffing emits team.staffed before the member joins); every
|
|
17
|
+
// submit and read drains too, so a trigger missed across a restart is picked up on the next touch.
|
|
18
|
+
//
|
|
19
|
+
// Sender (decided for HWA-36, Brain decision in project hub): the task is delivered from the fixed hub party
|
|
20
|
+
// `app:team-intake` (like HWA-11's `app:workspace-coordinator`; no real token ID has this shape), never as the user
|
|
21
|
+
// or the submitting app. Reasons: queued items are delivered later without the submitter's credential (an app token
|
|
22
|
+
// may be revoked by then); a user-sent message would reset hop chains and pose as a human turn; and the party sits
|
|
23
|
+
// in no team, so delivery policy admits it (r3vv4eon) while who submitted is named in the text. Consequence
|
|
24
|
+
// (HWA-34): the entry seat's reply to this `msg` is a msg to an outsider and is denied without `externalContact`;
|
|
25
|
+
// progress is reported explicitly through setStatus (POST /teams/:id/intake/:intakeID/status, MCP
|
|
26
|
+
// team_intake_update, the OpenCode hub tool's intake_update verb), which the submitter reads (GET, `team.intake`
|
|
27
|
+
// events). The text says so, with the exact verb and IDs (reportingLines).
|
|
28
|
+
//
|
|
29
|
+
// Status (explicit, never inferred from idleness/presence/departure; HWA-04/06): intake `queued` → `routed` →
|
|
30
|
+
// `done` | `failed` | `cancelled` (queued may be cancelled). While routed, `progress` mirrors the assignment:
|
|
31
|
+
// open | in_progress | blocked. Mapping to the assignment: done → done; failed and cancelled → cancelled (an
|
|
32
|
+
// assignment has no failed). Closing the assignment through the workspace routes is reflected on the next read of
|
|
33
|
+
// the intake (get / setStatus). Who: the user and apps with teams:manage — anything; a session holding a current
|
|
34
|
+
// seat of the entry role — anything; the submitter (app or session) — cancel only.
|
|
35
|
+
// Reads: users all; apps with teams:read or teams:manage all, with teams:submit their own submissions; sessions
|
|
36
|
+
// holding a current seat in the team all, other sessions their own submissions. Another user's team: not_found.
|
|
37
|
+
// Sessions submit under team policy: teamDeliveryVerdict for a `msg` from the session to the entry role (no-team
|
|
38
|
+
// sessions: allowed; members need a path or externalContact) — intake is not a way around the drawn edges.
|
|
39
|
+
//
|
|
40
|
+
// Delivery is at-least-once: a routing claim (claimMs) keeps two drains from delivering the same item; a crash
|
|
41
|
+
// after delivery and before the routed write re-delivers after the claim expires (the assignment is reused).
|
|
42
|
+
// Events (`team.intake`, family manage) carry IDs, enums and revisions only: never title, brief, labels or criteria.
|
|
43
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
44
|
+
import {
|
|
45
|
+
ID_PREFIXES,
|
|
46
|
+
IDEMPOTENCY_KEY,
|
|
47
|
+
IDEMPOTENCY_TTL_MS,
|
|
48
|
+
type IdKind,
|
|
49
|
+
isExpectedRevision,
|
|
50
|
+
isLabel,
|
|
51
|
+
isText,
|
|
52
|
+
LIST_LIMIT_DEFAULT,
|
|
53
|
+
LIST_LIMIT_MAX,
|
|
54
|
+
type Page,
|
|
55
|
+
tagsProblem,
|
|
56
|
+
TEAM_LIMITS,
|
|
57
|
+
type TeamIntake,
|
|
58
|
+
type TeamSeat,
|
|
59
|
+
teamDeliveryVerdict,
|
|
60
|
+
} from "../../protocol"
|
|
61
|
+
import type { AgentActor, AppActor } from "../agent/actor"
|
|
62
|
+
import { sanitizeLabel } from "../agent/envelope"
|
|
63
|
+
import { AgentError } from "../agent/errors"
|
|
64
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
65
|
+
import { canonicalBody } from "../agent/idempotency"
|
|
66
|
+
import { updateVersioned } from "../agent/versioned"
|
|
67
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
68
|
+
import { assignmentChangedEvent } from "../workspaces/events"
|
|
69
|
+
import type { MembershipStore, SessionPresence, SessionPresenceLookup, StoredAssignment } from "../workspaces/members"
|
|
70
|
+
import type { TeamSeatStore } from "./seats"
|
|
71
|
+
import type { TeamStaffingService } from "./staffing"
|
|
72
|
+
import type { Team, TeamStore } from "./store"
|
|
73
|
+
|
|
74
|
+
/** One aggregate per team (key: team ID): intake items and their Idempotency-Keys. */
|
|
75
|
+
export const TEAM_INTAKE = "team-intake"
|
|
76
|
+
/** Briefs of undelivered intake (key: intake ID); removed once delivered or cancelled. */
|
|
77
|
+
export const TEAM_INTAKE_BRIEFS = "team-intake-brief"
|
|
78
|
+
/** The fixed party intake is delivered from (an app party no real token can be: those are 16 hex digits). */
|
|
79
|
+
export const INTAKE_TOKEN = "team-intake"
|
|
80
|
+
/** The party intake is delivered as (never the user or the submitter; it holds no scopes and no seats). */
|
|
81
|
+
export const intakeActor = (userID: string): AppActor => ({ kind: "app", userID, tokenID: INTAKE_TOKEN, scopes: [], label: "team intake" })
|
|
82
|
+
|
|
83
|
+
export const INTAKE_PRIORITIES = ["low", "normal", "high", "urgent"] as const
|
|
84
|
+
export type IntakePriority = (typeof INTAKE_PRIORITIES)[number]
|
|
85
|
+
export type IntakeProgress = "open" | "in_progress" | "blocked"
|
|
86
|
+
export type IntakeStatus = TeamIntake["status"]
|
|
87
|
+
/** What setStatus accepts: progress while routed, or a closing status. */
|
|
88
|
+
export const INTAKE_STATUS_INPUTS = ["open", "in_progress", "blocked", "done", "failed", "cancelled"] as const
|
|
89
|
+
export type IntakeStatusInput = (typeof INTAKE_STATUS_INPUTS)[number]
|
|
90
|
+
|
|
91
|
+
export interface IntakeLimits {
|
|
92
|
+
/** Open (queued or routed) items per team. */
|
|
93
|
+
readonly openPerTeam: number
|
|
94
|
+
/** Submissions per team per rolling minute. */
|
|
95
|
+
readonly submitsPerMinute: number
|
|
96
|
+
/** Closed items kept per team (oldest closed dropped). */
|
|
97
|
+
readonly closedRetained: number
|
|
98
|
+
readonly briefMax: number
|
|
99
|
+
readonly refMax: number
|
|
100
|
+
readonly reasonMax: number
|
|
101
|
+
readonly criteriaPerIntake: number
|
|
102
|
+
readonly criterionMax: number
|
|
103
|
+
/** Live Idempotency-Keys kept per team. */
|
|
104
|
+
readonly keysPerTeam: number
|
|
105
|
+
/** How long a routing claim holds before another drain may retry the item. */
|
|
106
|
+
readonly claimMs: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const INTAKE_LIMITS: IntakeLimits = {
|
|
110
|
+
openPerTeam: TEAM_LIMITS.openIntakePerTeam,
|
|
111
|
+
submitsPerMinute: 60,
|
|
112
|
+
closedRetained: 200,
|
|
113
|
+
briefMax: 16_000,
|
|
114
|
+
refMax: 200,
|
|
115
|
+
reasonMax: 200,
|
|
116
|
+
criteriaPerIntake: 8,
|
|
117
|
+
criterionMax: 200,
|
|
118
|
+
keysPerTeam: 1_000,
|
|
119
|
+
claimMs: 60_000,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A TeamIntake as stored and read: the contract record plus additive fields (HWA-36). */
|
|
123
|
+
export interface StoredIntake extends TeamIntake {
|
|
124
|
+
readonly priority: IntakePriority
|
|
125
|
+
readonly labels?: readonly string[]
|
|
126
|
+
/** Due time (epoch ms). */
|
|
127
|
+
readonly due?: number
|
|
128
|
+
readonly criteria?: readonly string[]
|
|
129
|
+
readonly workspaceID?: string
|
|
130
|
+
/** The entry seat's session it was delivered to. */
|
|
131
|
+
readonly sessionID?: string
|
|
132
|
+
readonly messageID?: string
|
|
133
|
+
/** While routed: the assignment's status. */
|
|
134
|
+
readonly progress?: IntakeProgress
|
|
135
|
+
/** Why it failed or was cancelled (bounded, never in events). */
|
|
136
|
+
readonly reason?: string
|
|
137
|
+
/** The last routing failure's error code (the item stays queued and is retried). */
|
|
138
|
+
readonly lastError?: string
|
|
139
|
+
readonly updatedAt: number
|
|
140
|
+
readonly routedAt?: number
|
|
141
|
+
readonly closedAt?: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface IntakeRow extends StoredIntake {
|
|
145
|
+
readonly claim?: { readonly owner: string; readonly at: number }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface IntakeKey {
|
|
149
|
+
readonly intakeID: string
|
|
150
|
+
readonly digest: string
|
|
151
|
+
readonly at: number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface IntakeDoc {
|
|
155
|
+
readonly v: 1
|
|
156
|
+
readonly teamID: string
|
|
157
|
+
readonly items: readonly IntakeRow[]
|
|
158
|
+
/** `<party>|<key>` → the intake it created. */
|
|
159
|
+
readonly keys: Readonly<Record<string, IntakeKey>>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Delivers one task text to a session (production: Delivery.send as intakeActor). */
|
|
163
|
+
export type IntakeDeliver = (userID: string, sessionID: string, text: string) => Promise<{ readonly messageID: string }>
|
|
164
|
+
|
|
165
|
+
export interface TeamIntakeDeps {
|
|
166
|
+
readonly state: StateStore
|
|
167
|
+
readonly teams: Pick<TeamStore, "get" | "require">
|
|
168
|
+
readonly seats: Pick<TeamSeatStore, "list" | "seatsOf" | "deliveryContext">
|
|
169
|
+
readonly staffing: Pick<TeamStaffingService, "workspaceOf">
|
|
170
|
+
readonly members: Pick<MembershipStore, "members" | "assign" | "assignment" | "setStatus" | "reassign">
|
|
171
|
+
readonly presence: SessionPresenceLookup
|
|
172
|
+
readonly deliver: IntakeDeliver
|
|
173
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
174
|
+
readonly report?: (cause: unknown) => void
|
|
175
|
+
readonly now?: Clock
|
|
176
|
+
readonly newID?: (kind: IdKind) => string
|
|
177
|
+
readonly limits?: Partial<IntakeLimits>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface IntakeListQuery {
|
|
181
|
+
readonly status?: string
|
|
182
|
+
readonly limit?: number
|
|
183
|
+
readonly cursor?: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Intake counts of one team (team status, HWA-37): by status, and routed items by progress. */
|
|
187
|
+
export interface IntakeSummary {
|
|
188
|
+
readonly queued: number
|
|
189
|
+
readonly routed: number
|
|
190
|
+
readonly done: number
|
|
191
|
+
readonly cancelled: number
|
|
192
|
+
readonly failed: number
|
|
193
|
+
/** Routed items by progress. */
|
|
194
|
+
readonly open: number
|
|
195
|
+
readonly inProgress: number
|
|
196
|
+
readonly blocked: number
|
|
197
|
+
/** When the oldest still-queued item was submitted (epoch ms). */
|
|
198
|
+
readonly oldestQueuedAt?: number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const SUBMIT_FIELDS = new Set(["title", "brief", "priority", "labels", "due", "criteria", "ref"])
|
|
202
|
+
const STATUS_FIELDS = new Set(["status", "expectedRevision", "reason"])
|
|
203
|
+
const CLOSED: ReadonlySet<IntakeStatus> = new Set(["done", "cancelled", "failed"])
|
|
204
|
+
const OPEN_ASSIGNMENT: ReadonlySet<string> = new Set(["open", "in_progress", "blocked"])
|
|
205
|
+
|
|
206
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
207
|
+
const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
208
|
+
const digestOf = (v: unknown) => createHash("sha256").update(canonicalBody(v)).digest("hex")
|
|
209
|
+
|
|
210
|
+
function unknownField(input: Record<string, unknown>, allowed: ReadonlySet<string>): void {
|
|
211
|
+
const unknown = Object.keys(input).find((k) => !allowed.has(k))
|
|
212
|
+
if (unknown !== undefined) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(unknown) ? unknown : "(unprintable)"}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readDoc(raw: unknown, teamID: string): IntakeDoc {
|
|
216
|
+
if (raw === undefined) return { v: 1, teamID, items: [], keys: {} }
|
|
217
|
+
if (isObj(raw) && typeof raw.v === "number" && raw.v > 1)
|
|
218
|
+
throw new AgentError("unsupported", `The intake of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: TEAM_INTAKE } })
|
|
219
|
+
if (!isObj(raw) || raw.v !== 1 || raw.teamID !== teamID || !Array.isArray(raw.items)) throw new AgentError("internal", `Stored intake of team ${teamID} is malformed`)
|
|
220
|
+
return { v: 1, teamID, items: raw.items as IntakeRow[], keys: isObj(raw.keys) ? (raw.keys as Record<string, IntakeKey>) : {} }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The source of a submission (the contract's TeamIntake.source). */
|
|
224
|
+
function sourceOf(actor: AgentActor): TeamIntake["source"] {
|
|
225
|
+
switch (actor.kind) {
|
|
226
|
+
case "user":
|
|
227
|
+
return { kind: "user" }
|
|
228
|
+
case "app":
|
|
229
|
+
return { kind: "app", tokenID: actor.tokenID }
|
|
230
|
+
case "session":
|
|
231
|
+
return { kind: "session", sessionID: actor.sessionID }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const partyOf = (actor: AgentActor): string => (actor.kind === "user" ? "user" : actor.kind === "app" ? `app:${actor.tokenID}` : `ses:${actor.sessionID}`)
|
|
236
|
+
|
|
237
|
+
const sameSource = (row: StoredIntake, actor: AgentActor): boolean =>
|
|
238
|
+
(actor.kind === "app" && row.source.kind === "app" && row.source.tokenID === actor.tokenID) ||
|
|
239
|
+
(actor.kind === "session" && row.source.kind === "session" && row.source.sessionID === actor.sessionID) ||
|
|
240
|
+
(actor.kind === "user" && row.source.kind === "user")
|
|
241
|
+
|
|
242
|
+
/** The stored row as reads show it (no routing claim). */
|
|
243
|
+
function view(row: IntakeRow): StoredIntake {
|
|
244
|
+
const { claim: _claim, ...rest } = row
|
|
245
|
+
return rest
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const intakeEvent = (row: StoredIntake): EventInput => ({
|
|
249
|
+
type: "team.intake",
|
|
250
|
+
family: "manage",
|
|
251
|
+
subject: {
|
|
252
|
+
teamID: row.teamID,
|
|
253
|
+
intakeID: row.id,
|
|
254
|
+
...(row.assignmentID !== undefined ? { assignmentID: row.assignmentID } : {}),
|
|
255
|
+
...(row.workspaceID !== undefined ? { workspaceID: row.workspaceID } : {}),
|
|
256
|
+
...(row.sessionID !== undefined ? { sessionID: row.sessionID } : {}),
|
|
257
|
+
},
|
|
258
|
+
data: { status: row.status, revision: row.revision, source: row.source.kind, priority: row.priority, ...(row.progress !== undefined ? { progress: row.progress } : {}) },
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
interface Body {
|
|
262
|
+
readonly title: string
|
|
263
|
+
readonly brief?: string
|
|
264
|
+
readonly priority: IntakePriority
|
|
265
|
+
readonly labels?: readonly string[]
|
|
266
|
+
readonly due?: number
|
|
267
|
+
readonly criteria?: readonly string[]
|
|
268
|
+
readonly ref?: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface Target {
|
|
272
|
+
readonly sessionID: string
|
|
273
|
+
readonly workspaceID: string
|
|
274
|
+
readonly memberID: string
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export class TeamIntakeService {
|
|
278
|
+
private readonly state: StateStore
|
|
279
|
+
private readonly now: Clock
|
|
280
|
+
private readonly newID: (kind: IdKind) => string
|
|
281
|
+
private readonly report: (cause: unknown) => void
|
|
282
|
+
private readonly limits: IntakeLimits
|
|
283
|
+
private readonly owner = `intake-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
284
|
+
/** Per user and team: the drain in progress (one at a time in this process). */
|
|
285
|
+
private readonly draining = new Map<string, Promise<number>>()
|
|
286
|
+
|
|
287
|
+
constructor(private readonly deps: TeamIntakeDeps) {
|
|
288
|
+
this.state = deps.state
|
|
289
|
+
this.now = deps.now ?? Date.now
|
|
290
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
291
|
+
this.report = deps.report ?? ((cause) => console.error(`[hub] team intake: ${message(cause)}`))
|
|
292
|
+
this.limits = { ...INTAKE_LIMITS, ...deps.limits }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- submit ----------
|
|
296
|
+
|
|
297
|
+
/** Submits work to a team; routes it at once when an entry seat is staffed, else it waits queued. */
|
|
298
|
+
async submit(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ intake: StoredIntake; created: boolean }> {
|
|
299
|
+
if (actor.kind === "app" && !actor.scopes.includes("teams:submit")) throw new AgentError("forbidden", "Submitting to a team needs scope teams:submit")
|
|
300
|
+
const body = this.body(input)
|
|
301
|
+
const key = options.idempotencyKey
|
|
302
|
+
if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
303
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
304
|
+
if (team.state === "archived") throw new AgentError("invalid", `Team ${team.id} is archived`, { hint: "Restore it first" })
|
|
305
|
+
if (team.paused) throw new AgentError("invalid", `Team ${team.id} is paused: it takes no new work`, { hint: "Resume it first" })
|
|
306
|
+
if (actor.kind === "session") await this.checkPolicy(userID, team, actor.sessionID)
|
|
307
|
+
|
|
308
|
+
const digest = digestOf(body)
|
|
309
|
+
const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
|
|
310
|
+
const id = this.newID("intake")
|
|
311
|
+
if (body.brief !== undefined) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
|
|
312
|
+
let result: { row: IntakeRow; created: boolean } | undefined
|
|
313
|
+
try {
|
|
314
|
+
await this.writeDoc(userID, teamID, (doc) => {
|
|
315
|
+
const at = this.now()
|
|
316
|
+
const keys = this.liveKeys(doc, at)
|
|
317
|
+
const held = slot === undefined ? undefined : keys[slot]
|
|
318
|
+
if (held) {
|
|
319
|
+
if (held.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for another submission`, { hint: "Use a new key for a different body, or resend the original body" })
|
|
320
|
+
const existing = doc.items.find((i) => i.id === held.intakeID)
|
|
321
|
+
if (existing) {
|
|
322
|
+
result = { row: existing, created: false }
|
|
323
|
+
return undefined
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const recent = doc.items.filter((i) => at - i.createdAt < 60_000)
|
|
327
|
+
if (recent.length >= this.limits.submitsPerMinute) {
|
|
328
|
+
const oldest = Math.min(...recent.map((i) => i.createdAt))
|
|
329
|
+
throw new AgentError("rate_limited", `A team takes at most ${this.limits.submitsPerMinute} submissions a minute`, { extra: { retryAfter: Math.max(1, Math.ceil((oldest + 60_000 - at) / 1000)) } })
|
|
330
|
+
}
|
|
331
|
+
if (doc.items.filter((i) => !CLOSED.has(i.status)).length >= this.limits.openPerTeam)
|
|
332
|
+
throw new AgentError("rate_limited", `A team holds at most ${this.limits.openPerTeam} open submissions`, { hint: "Close or cancel some first", extra: { limit: this.limits.openPerTeam } })
|
|
333
|
+
const { brief: _brief, ...fields } = body
|
|
334
|
+
const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at }
|
|
335
|
+
result = { row, created: true }
|
|
336
|
+
const nextKeys = slot === undefined ? keys : { ...keys, [slot]: { intakeID: id, digest, at } }
|
|
337
|
+
return { ...doc, items: this.retained([...doc.items, row]), keys: this.boundedKeys(nextKeys) }
|
|
338
|
+
})
|
|
339
|
+
} catch (cause) {
|
|
340
|
+
if (body.brief !== undefined) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
|
|
341
|
+
throw cause
|
|
342
|
+
}
|
|
343
|
+
if (!result!.created) {
|
|
344
|
+
if (body.brief !== undefined) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
|
|
345
|
+
return { intake: view(await this.reconciled(userID, result!.row)), created: false }
|
|
346
|
+
}
|
|
347
|
+
await this.emit(userID, intakeEvent(result!.row))
|
|
348
|
+
await this.drainSafely(userID, teamID)
|
|
349
|
+
return { intake: view((await this.row(userID, teamID, id)) ?? result!.row), created: true }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- reads ----------
|
|
353
|
+
|
|
354
|
+
async get(userID: string, actor: AgentActor, teamID: string, intakeID: string): Promise<StoredIntake> {
|
|
355
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
356
|
+
const scope = await this.readScope(userID, actor, team)
|
|
357
|
+
await this.drainSafely(userID, teamID)
|
|
358
|
+
const row = await this.row(userID, teamID, intakeID)
|
|
359
|
+
if (!row || (scope === "own" && !sameSource(row, actor))) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
|
|
360
|
+
return view(await this.reconciled(userID, row))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The team's intake the caller may see, oldest first, paged. */
|
|
364
|
+
async list(userID: string, actor: AgentActor, teamID: string, query: IntakeListQuery = {}): Promise<Page<StoredIntake>> {
|
|
365
|
+
const limit = query.limit ?? LIST_LIMIT_DEFAULT
|
|
366
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
367
|
+
if (query.status !== undefined && !["queued", "routed", "done", "cancelled", "failed"].includes(query.status))
|
|
368
|
+
throw new AgentError("invalid", "status must be one of queued, routed, done, cancelled, failed")
|
|
369
|
+
const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor)
|
|
370
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
371
|
+
const scope = await this.readScope(userID, actor, team)
|
|
372
|
+
await this.drainSafely(userID, teamID)
|
|
373
|
+
const doc = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
|
|
374
|
+
const all = doc.items
|
|
375
|
+
.filter((i) => scope === "all" || sameSource(i, actor))
|
|
376
|
+
.filter((i) => query.status === undefined || i.status === query.status)
|
|
377
|
+
.filter((i) => !after || i.createdAt > after[0] || (i.createdAt === after[0] && i.id > after[1]))
|
|
378
|
+
.sort(byCreation)
|
|
379
|
+
const items = all.slice(0, limit).map(view)
|
|
380
|
+
return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!) } : { items }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Counts of the team's intake by status (and routed items by progress), for team status (HWA-37). The caller
|
|
385
|
+
* authorizes the read; counts carry no text.
|
|
386
|
+
*/
|
|
387
|
+
async summary(userID: string, teamID: string): Promise<IntakeSummary> {
|
|
388
|
+
await this.deps.teams.require(userID, teamID)
|
|
389
|
+
const doc = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
|
|
390
|
+
const out = { queued: 0, routed: 0, done: 0, cancelled: 0, failed: 0, open: 0, inProgress: 0, blocked: 0 }
|
|
391
|
+
let oldestQueuedAt: number | undefined
|
|
392
|
+
for (const item of doc.items) {
|
|
393
|
+
out[item.status]++
|
|
394
|
+
if (item.status === "queued") oldestQueuedAt = Math.min(oldestQueuedAt ?? item.createdAt, item.createdAt)
|
|
395
|
+
if (item.status === "routed") {
|
|
396
|
+
if (item.progress === "in_progress") out.inProgress++
|
|
397
|
+
else if (item.progress === "blocked") out.blocked++
|
|
398
|
+
else out.open++
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { ...out, ...(oldestQueuedAt !== undefined ? { oldestQueuedAt } : {}) }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------- status ----------
|
|
405
|
+
|
|
406
|
+
/** Explicit progress or completion (see the header for who may do what). */
|
|
407
|
+
async setStatus(userID: string, actor: AgentActor, teamID: string, intakeID: string, input: unknown): Promise<{ intake: StoredIntake; assignment?: StoredAssignment }> {
|
|
408
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
409
|
+
unknownField(input, STATUS_FIELDS)
|
|
410
|
+
const { status, expectedRevision, reason } = input
|
|
411
|
+
if (!INTAKE_STATUS_INPUTS.includes(status as IntakeStatusInput)) throw new AgentError("invalid", `status must be one of ${INTAKE_STATUS_INPUTS.join(", ")}`)
|
|
412
|
+
if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision is required: the revision you read", { hint: "Read the intake and send its revision" })
|
|
413
|
+
if (reason !== undefined && !isLabel(reason, this.limits.reasonMax)) throw new AgentError("invalid", `reason must be 1–${this.limits.reasonMax} characters of text`)
|
|
414
|
+
const wanted = status as IntakeStatusInput
|
|
415
|
+
const team = await this.deps.teams.require(userID, teamID)
|
|
416
|
+
const scope = await this.readScope(userID, actor, team)
|
|
417
|
+
const stored = await this.row(userID, teamID, intakeID)
|
|
418
|
+
if (!stored || (scope === "own" && !sameSource(stored, actor))) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
|
|
419
|
+
if (!(await this.mayChange(userID, actor, team, stored, wanted)))
|
|
420
|
+
throw new AgentError("forbidden", wanted === "cancelled" ? "Only the user, the team's entry role or the submitter may cancel this" : "Only the user or a session in the team's entry role reports progress", {
|
|
421
|
+
hint: "The submitter may only cancel",
|
|
422
|
+
})
|
|
423
|
+
const row = await this.reconciled(userID, stored)
|
|
424
|
+
if (CLOSED.has(row.status)) {
|
|
425
|
+
if (row.status === wanted) return { intake: view(row) }
|
|
426
|
+
throw new AgentError("invalid", `Intake ${intakeID} is ${row.status}`, { hint: "Submit new work instead" })
|
|
427
|
+
}
|
|
428
|
+
if (row.revision !== expectedRevision)
|
|
429
|
+
throw new AgentError("conflict", `Intake ${intakeID} changed since you read it (revision ${row.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: row.revision } })
|
|
430
|
+
if (row.status === "queued" && wanted !== "cancelled") throw new AgentError("invalid", `Intake ${intakeID} is still queued for the entry role`, { hint: "Only cancelling is possible before it is routed" })
|
|
431
|
+
|
|
432
|
+
let assignment: StoredAssignment | undefined
|
|
433
|
+
if (row.status === "routed" && row.assignmentID !== undefined && row.workspaceID !== undefined) {
|
|
434
|
+
const target = wanted === "done" ? "done" : wanted === "failed" || wanted === "cancelled" ? "cancelled" : wanted
|
|
435
|
+
const before = await this.deps.members.assignment(userID, row.workspaceID, row.assignmentID).catch(() => undefined)
|
|
436
|
+
if (before) {
|
|
437
|
+
assignment = await this.deps.members.setStatus(userID, row.workspaceID, row.assignmentID, target)
|
|
438
|
+
if (assignment.revision !== before.revision) await this.emit(userID, assignmentChangedEvent(assignment, "status"))
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const closing = wanted === "done" || wanted === "failed" || wanted === "cancelled"
|
|
442
|
+
const next = await this.mutate(userID, teamID, intakeID, (current) => {
|
|
443
|
+
if (current.revision !== expectedRevision)
|
|
444
|
+
throw new AgentError("conflict", `Intake ${intakeID} changed since you read it (revision ${current.revision} now)`, { extra: { currentRevision: current.revision } })
|
|
445
|
+
const { progress: _p, claim: _c, ...rest } = current
|
|
446
|
+
if (closing) return { ...rest, status: wanted, ...(reason !== undefined ? { reason: reason as string } : {}), closedAt: this.now() }
|
|
447
|
+
if (current.progress === wanted) return undefined
|
|
448
|
+
return { ...rest, ...(current.claim ? { claim: current.claim } : {}), progress: wanted as IntakeProgress }
|
|
449
|
+
})
|
|
450
|
+
if (next.revision !== row.revision) await this.emit(userID, intakeEvent(next))
|
|
451
|
+
if (closing && row.status === "queued") await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
|
|
452
|
+
return { intake: view(next), ...(assignment ? { assignment } : {}) }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------- routing ----------
|
|
456
|
+
|
|
457
|
+
/** Routes the team's queued intake, oldest first, while an entry seat is reachable. Returns how many it routed. */
|
|
458
|
+
drain(userID: string, teamID: string): Promise<number> {
|
|
459
|
+
const key = `${userID}\u0000${teamID}`
|
|
460
|
+
const previous = this.draining.get(key) ?? Promise.resolve(0)
|
|
461
|
+
const run = previous.catch(() => 0).then(() => this.drainNow(userID, teamID))
|
|
462
|
+
this.draining.set(key, run)
|
|
463
|
+
void run.finally(() => {
|
|
464
|
+
if (this.draining.get(key) === run) this.draining.delete(key)
|
|
465
|
+
}).catch(() => undefined)
|
|
466
|
+
return run
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A journal event appended (http.ts onAppend): drains the teams it may unblock. Never throws. */
|
|
470
|
+
async onEvent(userID: string, event: { readonly type: string; readonly subject?: Readonly<Record<string, string>> }): Promise<void> {
|
|
471
|
+
try {
|
|
472
|
+
const teamID = event.subject?.teamID
|
|
473
|
+
if ((event.type === "team.staffed" || event.type === "team.updated" || event.type === "team.archived") && teamID !== undefined) {
|
|
474
|
+
await this.drain(userID, teamID)
|
|
475
|
+
} else if ((event.type === "session.added" || event.type === "workspace.member.joined") && event.subject?.sessionID !== undefined) {
|
|
476
|
+
for (const seat of await this.deps.seats.seatsOf(userID, event.subject.sessionID)) {
|
|
477
|
+
const team = await this.deps.teams.get(userID, seat.teamID)
|
|
478
|
+
if (team && team.entryRole === seat.role) await this.drain(userID, seat.teamID)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} catch (cause) {
|
|
482
|
+
this.report(cause)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private async drainSafely(userID: string, teamID: string): Promise<void> {
|
|
487
|
+
await this.drain(userID, teamID).catch(this.report)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async drainNow(userID: string, teamID: string): Promise<number> {
|
|
491
|
+
const team = await this.deps.teams.get(userID, teamID)
|
|
492
|
+
if (!team || team.state !== "active" || team.paused) return 0
|
|
493
|
+
const queued = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
|
|
494
|
+
.items.filter((i) => i.status === "queued")
|
|
495
|
+
.sort(byCreation)
|
|
496
|
+
if (queued.length === 0) return 0
|
|
497
|
+
const target = await this.target(userID, team)
|
|
498
|
+
if (!target) return 0
|
|
499
|
+
let routed = 0
|
|
500
|
+
for (const item of queued) {
|
|
501
|
+
const outcome = await this.route(userID, team, item.id, target)
|
|
502
|
+
if (outcome === "failed") break
|
|
503
|
+
if (outcome === "routed") routed++
|
|
504
|
+
}
|
|
505
|
+
return routed
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The entry seat to route to (online first, else offline; earliest staffed) and its workspace member. */
|
|
509
|
+
private async target(userID: string, team: Team): Promise<Target | undefined> {
|
|
510
|
+
const entry = (await this.deps.seats.list(userID, team.id, { current: true }))
|
|
511
|
+
.filter((s) => s.role === team.entryRole)
|
|
512
|
+
.sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))
|
|
513
|
+
if (entry.length === 0) return undefined
|
|
514
|
+
const presence = new Map<string, SessionPresence>()
|
|
515
|
+
for (const s of entry) if (!presence.has(s.sessionID)) presence.set(s.sessionID, await this.deps.presence(userID, s.sessionID))
|
|
516
|
+
const pick = entry.find((s) => presence.get(s.sessionID) === "online") ?? entry.find((s) => presence.get(s.sessionID) === "offline")
|
|
517
|
+
if (!pick) return undefined
|
|
518
|
+
const workspaceID = await this.deps.staffing.workspaceOf(userID, team.id)
|
|
519
|
+
if (workspaceID === undefined) return undefined
|
|
520
|
+
const member = (await this.deps.members.members(userID, workspaceID).catch(() => [])).find((m) => m.current && m.member.sessionID === pick.sessionID)
|
|
521
|
+
if (!member) return undefined
|
|
522
|
+
return { sessionID: pick.sessionID, workspaceID, memberID: member.member.id }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Claims, assigns, delivers and marks one queued item routed. */
|
|
526
|
+
private async route(userID: string, team: Team, intakeID: string, target: Target): Promise<"routed" | "skipped" | "failed"> {
|
|
527
|
+
const at = this.now()
|
|
528
|
+
let claimed: IntakeRow | undefined
|
|
529
|
+
await this.writeDoc(userID, team.id, (doc) => {
|
|
530
|
+
const row = doc.items.find((i) => i.id === intakeID)
|
|
531
|
+
if (!row || row.status !== "queued") return undefined
|
|
532
|
+
if (row.claim && row.claim.owner !== this.owner && at - row.claim.at < this.limits.claimMs) return undefined
|
|
533
|
+
claimed = { ...row, claim: { owner: this.owner, at } }
|
|
534
|
+
return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? claimed! : i)) }
|
|
535
|
+
})
|
|
536
|
+
if (!claimed) return "skipped"
|
|
537
|
+
try {
|
|
538
|
+
const assignment = await this.assignmentFor(userID, claimed, target)
|
|
539
|
+
if (claimed.assignmentID !== assignment.id || claimed.workspaceID !== target.workspaceID)
|
|
540
|
+
await this.mutate(userID, team.id, intakeID, (row) => ({ ...row, assignmentID: assignment.id, workspaceID: target.workspaceID }), false)
|
|
541
|
+
const brief = (await this.state.get<{ brief?: unknown }>(userID, TEAM_INTAKE_BRIEFS, intakeID))?.value?.brief
|
|
542
|
+
const text = this.text(team, claimed, assignment.id, target, typeof brief === "string" ? brief : undefined)
|
|
543
|
+
const sent = await this.deps.deliver(userID, target.sessionID, text)
|
|
544
|
+
let cancelled = false
|
|
545
|
+
let routed: IntakeRow | undefined
|
|
546
|
+
await this.writeDoc(userID, team.id, (doc) => {
|
|
547
|
+
const row = doc.items.find((i) => i.id === intakeID)
|
|
548
|
+
if (!row) {
|
|
549
|
+
cancelled = true
|
|
550
|
+
return undefined
|
|
551
|
+
}
|
|
552
|
+
const { claim, lastError: _e, ...rest } = row
|
|
553
|
+
if (row.status !== "queued") {
|
|
554
|
+
cancelled = true
|
|
555
|
+
return claim ? { ...doc, items: doc.items.map((i) => (i.id === intakeID ? rest : i)) } : undefined
|
|
556
|
+
}
|
|
557
|
+
const t = this.now()
|
|
558
|
+
routed = { ...rest, status: "routed", progress: "open", assignmentID: assignment.id, workspaceID: target.workspaceID, sessionID: target.sessionID, messageID: sent.messageID, routedAt: t, revision: row.revision + 1, updatedAt: t }
|
|
559
|
+
return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? routed! : i)) }
|
|
560
|
+
})
|
|
561
|
+
if (cancelled) {
|
|
562
|
+
await this.deps.members.setStatus(userID, target.workspaceID, assignment.id, "cancelled").catch(this.report)
|
|
563
|
+
return "skipped"
|
|
564
|
+
}
|
|
565
|
+
await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
|
|
566
|
+
await this.emit(userID, intakeEvent(routed!))
|
|
567
|
+
return "routed"
|
|
568
|
+
} catch (cause) {
|
|
569
|
+
const code = cause instanceof AgentError ? cause.code : "internal"
|
|
570
|
+
this.report(cause)
|
|
571
|
+
await this.mutate(userID, team.id, intakeID, (row) => {
|
|
572
|
+
const { claim: _c, ...rest } = row
|
|
573
|
+
return { ...rest, lastError: code }
|
|
574
|
+
}, false).catch(this.report)
|
|
575
|
+
return "failed"
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The item's assignment for this target: reused (moved to the member when needed) or created idempotently. */
|
|
580
|
+
private async assignmentFor(userID: string, row: IntakeRow, target: Target): Promise<StoredAssignment> {
|
|
581
|
+
if (row.assignmentID !== undefined && row.workspaceID === target.workspaceID) {
|
|
582
|
+
const existing = await this.deps.members.assignment(userID, target.workspaceID, row.assignmentID).catch(() => undefined)
|
|
583
|
+
if (existing && OPEN_ASSIGNMENT.has(existing.status)) {
|
|
584
|
+
if (existing.memberID === target.memberID) return existing
|
|
585
|
+
const moved = await this.deps.members.reassign(userID, target.workspaceID, existing.id, target.memberID)
|
|
586
|
+
await this.emit(userID, assignmentChangedEvent(moved, "reassigned"))
|
|
587
|
+
return moved
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const { assignment, created } = await this.deps.members.assign(
|
|
591
|
+
userID,
|
|
592
|
+
target.workspaceID,
|
|
593
|
+
{ memberID: target.memberID, title: row.title, status: "open", ref: row.id, ...(row.criteria ? { criteria: [...row.criteria] } : {}) },
|
|
594
|
+
{ idempotencyKey: `team-intake.${row.id}.${target.memberID}` },
|
|
595
|
+
)
|
|
596
|
+
if (created) await this.emit(userID, assignmentChangedEvent(assignment, "created"))
|
|
597
|
+
return assignment
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The delivered task: team context first (a session may serve several teams), then the brief. */
|
|
601
|
+
private text(team: Team, row: IntakeRow, assignmentID: string, target: Target, brief: string | undefined): string {
|
|
602
|
+
const from = row.source.kind === "user" ? "the user" : row.source.kind === "app" ? `app ${row.source.tokenID}` : `session ${row.source.sessionID}`
|
|
603
|
+
const facts = [`intake ${row.id}`, `revision ${row.revision + 1}`, `assignment ${assignmentID}`, `workspace ${target.workspaceID}`, `priority ${row.priority}`]
|
|
604
|
+
if (row.due !== undefined) facts.push(`due ${new Date(row.due).toISOString()}`)
|
|
605
|
+
if (row.ref !== undefined) facts.push(`ref ${sanitizeLabel(row.ref, this.limits.refMax)}`)
|
|
606
|
+
const lines = [
|
|
607
|
+
`[hub] team intake for #${team.name} (team ${team.id}, entry role ${team.entryRole}), submitted by ${from}`,
|
|
608
|
+
`Title: ${sanitizeLabel(row.title)}`,
|
|
609
|
+
facts.join(" · "),
|
|
610
|
+
]
|
|
611
|
+
if (row.labels?.length) lines.push(`Labels: ${row.labels.join(", ")}`)
|
|
612
|
+
if (row.criteria?.length) lines.push("Done means:", ...row.criteria.map((c) => `- ${sanitizeLabel(c, this.limits.criterionMax)}`))
|
|
613
|
+
lines.push("", brief !== undefined && brief.trim() ? brief : "(no brief)", "")
|
|
614
|
+
lines.push(...reportingLines(team.id, row.id))
|
|
615
|
+
return lines.join("\n")
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------- authority ----------
|
|
619
|
+
|
|
620
|
+
/** "all" or "own" (the caller's submissions); forbidden for apps without a teams scope. */
|
|
621
|
+
private async readScope(userID: string, actor: AgentActor, team: Team): Promise<"all" | "own"> {
|
|
622
|
+
if (actor.kind === "user") return "all"
|
|
623
|
+
if (actor.kind === "app") {
|
|
624
|
+
if (actor.scopes.includes("teams:read") || actor.scopes.includes("teams:manage")) return "all"
|
|
625
|
+
if (actor.scopes.includes("teams:submit")) return "own"
|
|
626
|
+
throw new AgentError("forbidden", "Team intake needs scope teams:submit (own submissions) or teams:read")
|
|
627
|
+
}
|
|
628
|
+
const seats = await this.deps.seats.list(userID, team.id, { current: true })
|
|
629
|
+
return seats.some((s) => s.sessionID === actor.sessionID) ? "all" : "own"
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async mayChange(userID: string, actor: AgentActor, team: Team, row: StoredIntake, wanted: IntakeStatusInput): Promise<boolean> {
|
|
633
|
+
if (actor.kind === "user") return true
|
|
634
|
+
if (actor.kind === "app" && actor.scopes.includes("teams:manage")) return true
|
|
635
|
+
if (actor.kind === "session") {
|
|
636
|
+
const seats = await this.deps.seats.list(userID, team.id, { current: true })
|
|
637
|
+
if (seats.some((s) => s.sessionID === actor.sessionID && s.role === team.entryRole)) return true
|
|
638
|
+
}
|
|
639
|
+
return wanted === "cancelled" && sameSource(row, actor)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** A session submits only where it could `msg` the entry role (teamDeliveryVerdict, no team context). */
|
|
643
|
+
private async checkPolicy(userID: string, team: Team, sessionID: string): Promise<void> {
|
|
644
|
+
const context = await this.deps.seats.deliveryContext(userID, [sessionID])
|
|
645
|
+
const teams = new Map(context.teams)
|
|
646
|
+
teams.set(team.id, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state })
|
|
647
|
+
const recipient: TeamSeat[] = [{ teamID: team.id, role: team.entryRole }]
|
|
648
|
+
const verdict = teamDeliveryVerdict({ kind: "msg", toUser: false, replyToPermittedAsk: false, senderSeats: context.seats.get(sessionID) ?? [], recipientSeats: recipient, teams })
|
|
649
|
+
if (!verdict.allow) throw new AgentError("forbidden", "Team policy does not let this session give work to that team", { extra: { policy: verdict.reason } })
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ---------- internals ----------
|
|
653
|
+
|
|
654
|
+
private body(input: unknown): Body {
|
|
655
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
656
|
+
unknownField(input, SUBMIT_FIELDS)
|
|
657
|
+
const { title, brief, priority, labels, due, criteria, ref } = input
|
|
658
|
+
const l = this.limits
|
|
659
|
+
if (!isLabel(title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
|
|
660
|
+
if (brief !== undefined && !isText(brief, l.briefMax)) throw new AgentError("invalid", `brief must be at most ${l.briefMax} characters of text`)
|
|
661
|
+
if (priority !== undefined && !INTAKE_PRIORITIES.includes(priority as IntakePriority)) throw new AgentError("invalid", `priority must be one of ${INTAKE_PRIORITIES.join(", ")}`)
|
|
662
|
+
const labelProblem = tagsProblem(labels)
|
|
663
|
+
if (labelProblem) throw new AgentError("invalid", labelProblem.replace(/^tags/, "labels").replace(/each tag/, "each label"))
|
|
664
|
+
if (due !== undefined && !(typeof due === "number" && Number.isSafeInteger(due) && due > 0)) throw new AgentError("invalid", "due must be a time in epoch milliseconds")
|
|
665
|
+
if (criteria !== undefined && (!Array.isArray(criteria) || criteria.length > l.criteriaPerIntake || !criteria.every((c) => isLabel(c, l.criterionMax))))
|
|
666
|
+
throw new AgentError("invalid", `criteria must be a list of at most ${l.criteriaPerIntake} lines of 1–${l.criterionMax} characters`)
|
|
667
|
+
if (ref !== undefined && !isLabel(ref, l.refMax)) throw new AgentError("invalid", `ref must be 1–${l.refMax} characters of text`)
|
|
668
|
+
return {
|
|
669
|
+
title: title as string,
|
|
670
|
+
...(brief !== undefined ? { brief: brief as string } : {}),
|
|
671
|
+
priority: (priority as IntakePriority | undefined) ?? "normal",
|
|
672
|
+
...(labels !== undefined ? { labels: [...(labels as string[])] } : {}),
|
|
673
|
+
...(due !== undefined ? { due: due as number } : {}),
|
|
674
|
+
...(criteria !== undefined ? { criteria: [...(criteria as string[])] } : {}),
|
|
675
|
+
...(ref !== undefined ? { ref: ref as string } : {}),
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Reflects an assignment changed through the workspace routes (closed, or another progress) on a routed item. */
|
|
680
|
+
private async reconciled(userID: string, row: IntakeRow): Promise<IntakeRow> {
|
|
681
|
+
if (row.status !== "routed" || row.assignmentID === undefined || row.workspaceID === undefined) return row
|
|
682
|
+
const assignment = await this.deps.members.assignment(userID, row.workspaceID, row.assignmentID).catch(() => undefined)
|
|
683
|
+
if (!assignment) return row
|
|
684
|
+
const closed = assignment.status === "done" || assignment.status === "cancelled"
|
|
685
|
+
if (!closed && assignment.status === row.progress) return row
|
|
686
|
+
const next = await this.mutate(userID, row.teamID, row.id, (current) => {
|
|
687
|
+
if (current.status !== "routed") return undefined
|
|
688
|
+
const { progress: _p, ...rest } = current
|
|
689
|
+
if (closed) return { ...rest, status: assignment.status as "done" | "cancelled", closedAt: this.now() }
|
|
690
|
+
return current.progress === assignment.status ? undefined : { ...current, progress: assignment.status as IntakeProgress }
|
|
691
|
+
})
|
|
692
|
+
if (next.revision !== row.revision) await this.emit(userID, intakeEvent(next))
|
|
693
|
+
return next
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async row(userID: string, teamID: string, intakeID: string): Promise<IntakeRow | undefined> {
|
|
697
|
+
return readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID).items.find((i) => i.id === intakeID)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Version-checked change of one item. `bump` (default) counts it as a visible change: revision + 1 and updatedAt.
|
|
702
|
+
* `change` returning undefined leaves it alone. Resolves with the item as stored.
|
|
703
|
+
*/
|
|
704
|
+
private async mutate(userID: string, teamID: string, intakeID: string, change: (row: IntakeRow) => IntakeRow | undefined, bump = true): Promise<IntakeRow> {
|
|
705
|
+
let out: IntakeRow | undefined
|
|
706
|
+
await this.writeDoc(userID, teamID, (doc) => {
|
|
707
|
+
const row = doc.items.find((i) => i.id === intakeID)
|
|
708
|
+
if (!row) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
|
|
709
|
+
const next = change(row)
|
|
710
|
+
if (!next) {
|
|
711
|
+
out = row
|
|
712
|
+
return undefined
|
|
713
|
+
}
|
|
714
|
+
out = bump ? { ...next, revision: row.revision + 1, updatedAt: this.now() } : next
|
|
715
|
+
return { ...doc, items: this.retained(doc.items.map((i) => (i.id === intakeID ? out! : i))) }
|
|
716
|
+
})
|
|
717
|
+
return out!
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async writeDoc(userID: string, teamID: string, change: (doc: IntakeDoc) => IntakeDoc | undefined): Promise<void> {
|
|
721
|
+
await updateVersioned<IntakeDoc>(this.state, userID, TEAM_INTAKE, teamID, (raw) => change(readDoc(raw, teamID)))
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Open items plus the newest closed ones. */
|
|
725
|
+
private retained(items: readonly IntakeRow[]): IntakeRow[] {
|
|
726
|
+
const closed = items.filter((i) => CLOSED.has(i.status)).sort((a, b) => (b.closedAt ?? b.updatedAt) - (a.closedAt ?? a.updatedAt))
|
|
727
|
+
const keep = new Set(closed.slice(0, this.limits.closedRetained).map((i) => i.id))
|
|
728
|
+
return items.filter((i) => !CLOSED.has(i.status) || keep.has(i.id))
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private liveKeys(doc: IntakeDoc, at: number): Record<string, IntakeKey> {
|
|
732
|
+
const out: Record<string, IntakeKey> = {}
|
|
733
|
+
for (const [k, v] of Object.entries(doc.keys)) if (isObj(v) && typeof v.at === "number" && at - v.at < IDEMPOTENCY_TTL_MS) out[k] = v
|
|
734
|
+
return out
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private boundedKeys(keys: Readonly<Record<string, IntakeKey>>): Record<string, IntakeKey> {
|
|
738
|
+
const entries = Object.entries(keys)
|
|
739
|
+
if (entries.length <= this.limits.keysPerTeam) return { ...keys }
|
|
740
|
+
return Object.fromEntries(entries.sort((a, b) => b[1].at - a[1].at).slice(0, this.limits.keysPerTeam))
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private async emit(userID: string, ...events: EventInput[]): Promise<void> {
|
|
744
|
+
if (!this.deps.events) return
|
|
745
|
+
for (const event of events) await this.deps.events.append(userID, event).catch(this.report)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const byCreation = (a: StoredIntake, b: StoredIntake) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
750
|
+
|
|
751
|
+
function encodeCursor(i: StoredIntake): string {
|
|
752
|
+
return Buffer.from(`${i.createdAt}:${i.id}`).toString("base64url")
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function decodeCursor(cursor: string): [number, string] {
|
|
756
|
+
const match = /^([0-9]{1,15}):([A-Za-z0-9_]{1,64})$/.exec(Buffer.from(cursor, "base64url").toString())
|
|
757
|
+
if (!match) throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
|
|
758
|
+
return [Number(match[1]), match[2]!]
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* How the entry seat reports and closes the item (RP-07 #9), the same in every harness: the OpenCode `hub` tool
|
|
763
|
+
* verb, the MCP tool (Claude Code, Codex) and the route. Nothing is inferred from idleness.
|
|
764
|
+
*/
|
|
765
|
+
export function reportingLines(teamID: string, intakeID: string): string[] {
|
|
766
|
+
return [
|
|
767
|
+
"Report progress and close this item explicitly — nothing is inferred from idleness or from replies (replies to this message do not reach the submitter):",
|
|
768
|
+
`- hub tool: {verb: "intake_update", team: "${teamID}", id: "${intakeID}", status: "in_progress"} (or "blocked"); when finished: status "done", or "failed" with reason: "<one line>"`,
|
|
769
|
+
`- MCP tool team_intake_update {team: "${teamID}", intake: "${intakeID}", status: in_progress|blocked|done|failed, reason?}`,
|
|
770
|
+
`- HTTP: POST /api/agent/teams/${teamID}/intake/${intakeID}/status {status, expectedRevision, reason?}`,
|
|
771
|
+
]
|
|
772
|
+
}
|