@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,632 @@
|
|
|
1
|
+
// Workspace membership and versioned assignments (HWA-04; contract docs/plans/2026-10-30-hub-workspaces-artifacts-
|
|
2
|
+
// contracts.md, packages/protocol/src/hub-workspace.ts WorkspaceMember / WorkspaceAssignment).
|
|
3
|
+
//
|
|
4
|
+
// Storage: StateStore collections under the hub user (the tenant key), like the workspace store (store.ts):
|
|
5
|
+
// - `workspace-members`: one aggregate per workspace (key: workspace ID) holding its WorkspaceMembers, current and
|
|
6
|
+
// departed. One aggregate makes the per-workspace cap, "one current membership per session" and an internal
|
|
7
|
+
// master replacement (old member `replaced`, new one generation + 1) a single version-checked write. Each member
|
|
8
|
+
// carries its own `revision` (1 on join, +1 per change); a stale `expectedRevision` is a 409 conflict with
|
|
9
|
+
// `currentRevision`.
|
|
10
|
+
// - `workspace-assignments`: one aggregate per workspace holding its assignments and the assignment
|
|
11
|
+
// Idempotency-Keys (so a key's claim and its assignment are written together: no crash window). Per-assignment
|
|
12
|
+
// `revision` as for members, so two reassignments under one revision: exactly one wins.
|
|
13
|
+
//
|
|
14
|
+
// Session references: members name sessions by ID and never own them. Joining needs a session the user's
|
|
15
|
+
// directory knows (unknown or another user's: not_found; deleted: invalid). Offline and archived sessions may join.
|
|
16
|
+
// Reads report every reference truthfully through the presence lookup (online / offline / archived / deleted /
|
|
17
|
+
// unknown) and an assignment's member as current / left / missing; nothing is hidden or silently repaired.
|
|
18
|
+
//
|
|
19
|
+
// Lifecycle and retention:
|
|
20
|
+
// - Departure is explicit: leave (`removed`), sessionEnded (`ended`, for the hub's session-deleted path), an internal
|
|
21
|
+
// replacement (`replaced`) or archiving the workspace (`archived`). Departed records are kept (they count toward
|
|
22
|
+
// membersPerWorkspace, as the contract says); a join at the cap prunes the oldest departed member that no open
|
|
23
|
+
// assignment references, and answers 429 rate_limited when there is none.
|
|
24
|
+
// - Archiving the workspace departs every current member (`archived`); restore re-joins nobody (a re-join is a new
|
|
25
|
+
// generation). Assignments keep their status. Mutations of an archived workspace's members/assignments are invalid.
|
|
26
|
+
// - Assignment completion is explicit: only setStatus moves an assignment to `done` or `cancelled`; nothing infers it
|
|
27
|
+
// from idleness, presence or departure. Closed assignments are final and retained up to closedAssignmentsRetained
|
|
28
|
+
// per workspace (oldest closed dropped); open ones are capped at openAssignmentsPerWorkspace (429).
|
|
29
|
+
//
|
|
30
|
+
// Authority: membership, roles and assignments grant nothing. This store holds records only; grants, native
|
|
31
|
+
// parentID/rootID, the directory and UserRecord.agent are never touched. The master role is not given by joining:
|
|
32
|
+
// HWA-12's explicit replacement passes `allowMaster` and maintains HubWorkspace.masterMemberID.
|
|
33
|
+
// Assignment text (title, criteria) never belongs in events; events (HWA-06) carry IDs, revisions and counts only.
|
|
34
|
+
//
|
|
35
|
+
// Other builds: an aggregate with a newer `v` answers 422 unsupported and is left untouched; a malformed one is a
|
|
36
|
+
// 500. Rollback: older hubs never read these collections, so they stay dormant.
|
|
37
|
+
import { randomBytes } from "node:crypto"
|
|
38
|
+
import {
|
|
39
|
+
CONTRACT_VERSION,
|
|
40
|
+
ID_PREFIXES,
|
|
41
|
+
IDEMPOTENCY_KEY,
|
|
42
|
+
IDEMPOTENCY_TTL_MS,
|
|
43
|
+
type IdKind,
|
|
44
|
+
isContractID,
|
|
45
|
+
isExpectedRevision,
|
|
46
|
+
isLabel,
|
|
47
|
+
LIST_LIMIT_DEFAULT,
|
|
48
|
+
LIST_LIMIT_MAX,
|
|
49
|
+
type Page,
|
|
50
|
+
type WorkspaceAssignment,
|
|
51
|
+
type WorkspaceMember,
|
|
52
|
+
WORKSPACE_LIMITS,
|
|
53
|
+
WORKSPACE_ROLES,
|
|
54
|
+
type WorkspaceRole,
|
|
55
|
+
type HubWorkspace,
|
|
56
|
+
} from "../../protocol"
|
|
57
|
+
import type { AgentSession } from "../agent-data"
|
|
58
|
+
import type { Directory } from "../agent/directory"
|
|
59
|
+
import { AgentError } from "../agent/errors"
|
|
60
|
+
import { updateVersioned } from "../agent/versioned"
|
|
61
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
62
|
+
import { digestOf, type WorkspaceStore } from "./store"
|
|
63
|
+
|
|
64
|
+
export const WORKSPACE_MEMBERS = "workspace-members"
|
|
65
|
+
export const WORKSPACE_ASSIGNMENTS = "workspace-assignments"
|
|
66
|
+
|
|
67
|
+
export type AssignmentStatus = WorkspaceAssignment["status"]
|
|
68
|
+
export const ASSIGNMENT_STATUSES: readonly AssignmentStatus[] = ["open", "in_progress", "blocked", "done", "cancelled"]
|
|
69
|
+
/** Statuses an assignment may be created with (closing is an explicit later step). */
|
|
70
|
+
const OPENING: readonly AssignmentStatus[] = ["open", "in_progress", "blocked"]
|
|
71
|
+
const CLOSED: ReadonlySet<AssignmentStatus> = new Set(["done", "cancelled"])
|
|
72
|
+
/** Session IDs as the directory resolves them (`ses_…`), bounded. */
|
|
73
|
+
export const SESSION_ID = /^ses_[A-Za-z0-9]{1,124}$/
|
|
74
|
+
|
|
75
|
+
export interface MembershipLimits {
|
|
76
|
+
/** Members per workspace, current and departed together (the contract's membersPerWorkspace). */
|
|
77
|
+
readonly membersPerWorkspace: number
|
|
78
|
+
/** Assignments not yet done or cancelled, per workspace. */
|
|
79
|
+
readonly openAssignmentsPerWorkspace: number
|
|
80
|
+
/** Closed assignments kept per workspace (oldest closed dropped beyond it). */
|
|
81
|
+
readonly closedAssignmentsRetained: number
|
|
82
|
+
/** Outcome criteria per assignment, and characters per criterion. */
|
|
83
|
+
readonly criteriaPerAssignment: number
|
|
84
|
+
readonly criterionMax: number
|
|
85
|
+
/** Characters of an assignment's external reference. */
|
|
86
|
+
readonly refMax: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const MEMBERSHIP_LIMITS: MembershipLimits = {
|
|
90
|
+
membersPerWorkspace: WORKSPACE_LIMITS.membersPerWorkspace,
|
|
91
|
+
openAssignmentsPerWorkspace: 200,
|
|
92
|
+
closedAssignmentsRetained: 100,
|
|
93
|
+
criteriaPerAssignment: 8,
|
|
94
|
+
criterionMax: 200,
|
|
95
|
+
refMax: 200,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Idempotency-Keys kept per workspace (beyond the TTL bound, oldest dropped). */
|
|
99
|
+
const KEYS_MAX = 500
|
|
100
|
+
|
|
101
|
+
/** What the hub knows about a referenced session now. `unknown`: not (or no longer) in the user's directory. */
|
|
102
|
+
export type SessionPresence = "online" | "offline" | "archived" | "deleted" | "unknown"
|
|
103
|
+
export type SessionPresenceLookup = (userID: string, sessionID: string) => SessionPresence | Promise<SessionPresence>
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A WorkspaceAssignment as stored: the contract fields plus outcome criteria (what "done" means, bounded text;
|
|
107
|
+
* never in events), creation time and when it was closed.
|
|
108
|
+
*/
|
|
109
|
+
export interface StoredAssignment extends WorkspaceAssignment {
|
|
110
|
+
readonly criteria?: readonly string[]
|
|
111
|
+
readonly createdAt: number
|
|
112
|
+
readonly closedAt?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface MemberView {
|
|
116
|
+
readonly member: WorkspaceMember
|
|
117
|
+
/** Not departed. */
|
|
118
|
+
readonly current: boolean
|
|
119
|
+
readonly presence: SessionPresence
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface AssignmentView {
|
|
123
|
+
readonly assignment: StoredAssignment
|
|
124
|
+
/** The assignee's membership: current, departed (left) or no longer retained (missing). */
|
|
125
|
+
readonly member: "current" | "left" | "missing"
|
|
126
|
+
/** The assignee session's presence (unknown when the member is missing). */
|
|
127
|
+
readonly presence: SessionPresence
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AssignmentListQuery {
|
|
131
|
+
readonly memberID?: string
|
|
132
|
+
readonly status?: string
|
|
133
|
+
readonly limit?: number
|
|
134
|
+
readonly cursor?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface JoinOptions {
|
|
138
|
+
/** Internal (HWA-12): allows role master; routes for members never pass it. */
|
|
139
|
+
readonly allowMaster?: boolean
|
|
140
|
+
/** Internal (HWA-12): departs this current member as `replaced`; the new member gets its generation + 1. */
|
|
141
|
+
readonly replaces?: string
|
|
142
|
+
/** Internal (HWA-12): the new member's generation is at least this (a master slot re-staffed after its master left). */
|
|
143
|
+
readonly minGeneration?: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface MembershipStoreDeps {
|
|
147
|
+
readonly state: StateStore
|
|
148
|
+
readonly workspaces: WorkspaceStore
|
|
149
|
+
readonly presence: SessionPresenceLookup
|
|
150
|
+
readonly now?: Clock
|
|
151
|
+
readonly newID?: (kind: IdKind) => string
|
|
152
|
+
readonly limits?: Partial<MembershipLimits>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface MembersDoc {
|
|
156
|
+
readonly v: 1
|
|
157
|
+
readonly workspaceID: string
|
|
158
|
+
readonly members: readonly WorkspaceMember[]
|
|
159
|
+
}
|
|
160
|
+
interface KeyEntry {
|
|
161
|
+
readonly key: string
|
|
162
|
+
readonly assignmentID: string
|
|
163
|
+
readonly digest: string
|
|
164
|
+
readonly at: number
|
|
165
|
+
}
|
|
166
|
+
interface AssignmentsDoc {
|
|
167
|
+
readonly v: 1
|
|
168
|
+
readonly workspaceID: string
|
|
169
|
+
readonly assignments: readonly StoredAssignment[]
|
|
170
|
+
readonly keys: readonly KeyEntry[]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
174
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
175
|
+
|
|
176
|
+
const conflict = (what: string, currentRevision: number) =>
|
|
177
|
+
new AgentError("conflict", `${what} changed since you read it (revision ${currentRevision} now)`, {
|
|
178
|
+
hint: "Read it again and retry with its current revision",
|
|
179
|
+
extra: { currentRevision },
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const archivedError = (workspaceID: string) => new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
|
|
183
|
+
|
|
184
|
+
function checkExpected(expectedRevision: number | undefined): void {
|
|
185
|
+
if (expectedRevision !== undefined && !isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A stored per-workspace aggregate, or undefined when missing; newer versions are unsupported, bad ones internal. */
|
|
189
|
+
function aggregate<T>(raw: unknown, workspaceID: string, field: string, feature: string): T | undefined {
|
|
190
|
+
if (raw === undefined) return undefined
|
|
191
|
+
if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION)
|
|
192
|
+
throw new AgentError("unsupported", `Workspace ${workspaceID} ${feature} were written by a newer hub`, { hint: "Upgrade this hub", extra: { feature } })
|
|
193
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || !Array.isArray(raw[field]))
|
|
194
|
+
throw new AgentError("internal", `Stored workspace ${feature} are malformed`)
|
|
195
|
+
return raw as T
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const membersDoc = (raw: unknown, workspaceID: string) => aggregate<MembersDoc>(raw, workspaceID, "members", "workspace-members")
|
|
199
|
+
function assignmentsDoc(raw: unknown, workspaceID: string): AssignmentsDoc | undefined {
|
|
200
|
+
const doc = aggregate<AssignmentsDoc>(raw, workspaceID, "assignments", "workspace-assignments")
|
|
201
|
+
return doc && { ...doc, keys: Array.isArray(doc.keys) ? doc.keys : [] }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function encodeCursor(a: StoredAssignment): string {
|
|
205
|
+
return Buffer.from(JSON.stringify([a.createdAt, a.id])).toString("base64url")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decodeCursor(cursor: string): [number, string] {
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown
|
|
211
|
+
if (Array.isArray(parsed) && parsed.length === 2 && isNum(parsed[0]) && typeof parsed[1] === "string") return [parsed[0], parsed[1]]
|
|
212
|
+
} catch {
|
|
213
|
+
// answered below
|
|
214
|
+
}
|
|
215
|
+
throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const byCreation = (a: StoredAssignment, b: StoredAssignment) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Presence from the agent directory: exact session IDs of this user only (a prefix or another user's session is
|
|
222
|
+
* unknown). `sessions` gives the user's stored session records (for deletedAt, which directory entries omit).
|
|
223
|
+
*/
|
|
224
|
+
export function directoryPresence(directory: Pick<Directory, "resolve">, sessions: (userID: string) => readonly AgentSession[]): SessionPresenceLookup {
|
|
225
|
+
return (userID, sessionID) => {
|
|
226
|
+
if (!SESSION_ID.test(sessionID)) return "unknown"
|
|
227
|
+
if (sessions(userID).some((s) => s.sessionID === sessionID && s.deletedAt !== undefined)) return "deleted"
|
|
228
|
+
let found
|
|
229
|
+
try {
|
|
230
|
+
found = directory.resolve(userID, sessionID).sessions.find((s) => s.sessionID === sessionID)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof AgentError) return "unknown"
|
|
233
|
+
throw error
|
|
234
|
+
}
|
|
235
|
+
if (!found) return "unknown"
|
|
236
|
+
if (found.archived) return "archived"
|
|
237
|
+
return found.online ? "online" : "offline"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class MembershipStore {
|
|
242
|
+
readonly limits: MembershipLimits
|
|
243
|
+
private readonly state: StateStore
|
|
244
|
+
private readonly workspaces: WorkspaceStore
|
|
245
|
+
private readonly now: Clock
|
|
246
|
+
private readonly newID: (kind: IdKind) => string
|
|
247
|
+
|
|
248
|
+
constructor(private readonly deps: MembershipStoreDeps) {
|
|
249
|
+
this.state = deps.state
|
|
250
|
+
this.workspaces = deps.workspaces
|
|
251
|
+
this.now = deps.now ?? Date.now
|
|
252
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
253
|
+
this.limits = { ...MEMBERSHIP_LIMITS, ...deps.limits }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------- membership ----------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Adds the session as a member. The same session already a current member with the same role returns that
|
|
260
|
+
* membership (created false); with another role it is a conflict. Not the master role (see JoinOptions).
|
|
261
|
+
*/
|
|
262
|
+
async join(userID: string, workspaceID: string, input: unknown, options: JoinOptions = {}): Promise<{ member: WorkspaceMember; created: boolean }> {
|
|
263
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
264
|
+
const { sessionID, role } = input
|
|
265
|
+
if (typeof sessionID !== "string" || !SESSION_ID.test(sessionID)) throw new AgentError("invalid", "sessionID must be a session ID (ses_…)")
|
|
266
|
+
if (!WORKSPACE_ROLES.includes(role as WorkspaceRole)) throw new AgentError("invalid", `role must be one of ${WORKSPACE_ROLES.join(", ")}`)
|
|
267
|
+
if (role === "master" && !options.allowMaster)
|
|
268
|
+
throw new AgentError("invalid", "Joining does not make a master", { hint: "The master is set by explicit replacement (POST /workspaces/:id/master)" })
|
|
269
|
+
if (options.replaces !== undefined && !isContractID("member", options.replaces)) throw new AgentError("invalid", "replaces must be a member ID")
|
|
270
|
+
await this.active(userID, workspaceID)
|
|
271
|
+
const presence = await this.deps.presence(userID, sessionID)
|
|
272
|
+
if (presence === "unknown") throw new AgentError("not_found", `No session ${sessionID}`)
|
|
273
|
+
if (presence === "deleted") throw new AgentError("invalid", `Session ${sessionID} was deleted`)
|
|
274
|
+
const busy = await this.openAssignees(userID, workspaceID)
|
|
275
|
+
let result: { member: WorkspaceMember; created: boolean } | undefined
|
|
276
|
+
await updateVersioned<MembersDoc>(this.state, userID, WORKSPACE_MEMBERS, workspaceID, (raw) => {
|
|
277
|
+
let list = [...(membersDoc(raw, workspaceID)?.members ?? [])]
|
|
278
|
+
const same = list.find((m) => m.sessionID === sessionID && m.leftAt === undefined)
|
|
279
|
+
if (same) {
|
|
280
|
+
if (same.role !== role)
|
|
281
|
+
throw new AgentError("conflict", `Session ${sessionID} is already a ${same.role} here`, { hint: "Change its role instead", extra: { currentRevision: same.revision } })
|
|
282
|
+
result = { member: same, created: false }
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
285
|
+
const at = this.now()
|
|
286
|
+
let generation = 1 + Math.max(0, ...list.filter((m) => m.sessionID === sessionID).map((m) => m.generation))
|
|
287
|
+
if (options.replaces !== undefined) {
|
|
288
|
+
const old = list.find((m) => m.id === options.replaces)
|
|
289
|
+
if (!old) throw new AgentError("not_found", `No member ${options.replaces} in workspace ${workspaceID}`)
|
|
290
|
+
if (old.leftAt !== undefined) throw new AgentError("invalid", `Member ${old.id} already left`)
|
|
291
|
+
generation = old.generation + 1
|
|
292
|
+
list = list.map((m) => (m.id === old.id ? { ...m, leftAt: at, leftReason: "replaced", revision: m.revision + 1 } : m))
|
|
293
|
+
}
|
|
294
|
+
if (options.minGeneration !== undefined) generation = Math.max(generation, options.minGeneration)
|
|
295
|
+
if (list.length >= this.limits.membersPerWorkspace) {
|
|
296
|
+
const prunable = list.filter((m) => m.leftAt !== undefined && !busy.has(m.id)).sort((a, b) => a.leftAt! - b.leftAt!)[0]
|
|
297
|
+
if (!prunable)
|
|
298
|
+
throw new AgentError("rate_limited", `A workspace has at most ${this.limits.membersPerWorkspace} members, current and departed`, {
|
|
299
|
+
hint: "Remove members, or close the open assignments of departed ones",
|
|
300
|
+
})
|
|
301
|
+
list = list.filter((m) => m.id !== prunable.id)
|
|
302
|
+
}
|
|
303
|
+
const member: WorkspaceMember = { v: 1, id: this.newID("member"), workspaceID, sessionID, role: role as WorkspaceRole, generation, joinedAt: at, revision: 1 }
|
|
304
|
+
result = { member, created: true }
|
|
305
|
+
return { v: 1, workspaceID, members: [...list, member] }
|
|
306
|
+
})
|
|
307
|
+
return result!
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** One member (current or departed); not_found otherwise. */
|
|
311
|
+
async member(userID: string, workspaceID: string, memberID: string): Promise<WorkspaceMember> {
|
|
312
|
+
const member = (await this.readMembers(userID, workspaceID)).find((m) => m.id === memberID)
|
|
313
|
+
if (!member) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
|
|
314
|
+
return member
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The workspace's members by join order (current only unless includeLeft), with their sessions' presence. */
|
|
318
|
+
async members(userID: string, workspaceID: string, query: { readonly includeLeft?: boolean } = {}): Promise<MemberView[]> {
|
|
319
|
+
const members = (await this.readMembers(userID, workspaceID)).filter((m) => query.includeLeft || m.leftAt === undefined)
|
|
320
|
+
const presence = await this.presenceOf(userID, members.map((m) => m.sessionID))
|
|
321
|
+
return members.map((member) => ({ member, current: member.leftAt === undefined, presence: presence.get(member.sessionID)! }))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Changes a current member's role between worker and observer (master: HWA-12's replacement). */
|
|
325
|
+
async setRole(userID: string, workspaceID: string, memberID: string, role: unknown, expectedRevision?: number): Promise<WorkspaceMember> {
|
|
326
|
+
checkExpected(expectedRevision)
|
|
327
|
+
if (role !== "worker" && role !== "observer") throw new AgentError("invalid", "role must be worker or observer", { hint: "The master is set by explicit replacement" })
|
|
328
|
+
await this.active(userID, workspaceID)
|
|
329
|
+
return this.mutateMember(userID, workspaceID, memberID, expectedRevision, (m) => {
|
|
330
|
+
if (m.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`, { hint: "Join the session again" })
|
|
331
|
+
if (m.role === "master") throw new AgentError("invalid", `Member ${memberID} is the master`, { hint: "Replace the master instead" })
|
|
332
|
+
return m.role === role ? undefined : { ...m, role }
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Departs a member (record kept). Leaving again is a no-op. Allowed on an archived workspace. */
|
|
337
|
+
async leave(
|
|
338
|
+
userID: string,
|
|
339
|
+
workspaceID: string,
|
|
340
|
+
memberID: string,
|
|
341
|
+
options: { readonly expectedRevision?: number; readonly reason?: "removed" | "ended" } = {},
|
|
342
|
+
): Promise<WorkspaceMember> {
|
|
343
|
+
checkExpected(options.expectedRevision)
|
|
344
|
+
const reason = options.reason ?? "removed"
|
|
345
|
+
const workspace = await this.workspaces.require(userID, workspaceID)
|
|
346
|
+
const left = await this.mutateMember(userID, workspaceID, memberID, options.expectedRevision, (m) =>
|
|
347
|
+
m.leftAt !== undefined ? undefined : { ...m, leftAt: this.now(), leftReason: reason },
|
|
348
|
+
)
|
|
349
|
+
if (workspace.masterMemberID === memberID) await this.workspaces.setMaster(userID, workspaceID, undefined)
|
|
350
|
+
return left
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The session's current memberships across the user's workspaces. */
|
|
354
|
+
async membershipsOf(userID: string, sessionID: string): Promise<{ workspaceID: string; member: WorkspaceMember }[]> {
|
|
355
|
+
const out: { workspaceID: string; member: WorkspaceMember }[] = []
|
|
356
|
+
for (const entry of await this.state.list(userID, WORKSPACE_MEMBERS)) {
|
|
357
|
+
let doc: MembersDoc | undefined
|
|
358
|
+
try {
|
|
359
|
+
doc = membersDoc(entry.value, entry.key)
|
|
360
|
+
} catch {
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
for (const member of doc?.members ?? []) if (member.sessionID === sessionID && member.leftAt === undefined) out.push({ workspaceID: entry.key, member })
|
|
364
|
+
}
|
|
365
|
+
return out
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** (RP-08) Current memberships of several sessions, in one scan of the user's workspaces. */
|
|
369
|
+
async membershipsOfMany(userID: string, sessionIDs: readonly string[]): Promise<Map<string, string[]>> {
|
|
370
|
+
const wanted = new Set(sessionIDs)
|
|
371
|
+
const out = new Map<string, string[]>()
|
|
372
|
+
for (const entry of await this.state.list(userID, WORKSPACE_MEMBERS)) {
|
|
373
|
+
let doc: MembersDoc | undefined
|
|
374
|
+
try {
|
|
375
|
+
doc = membersDoc(entry.value, entry.key)
|
|
376
|
+
} catch {
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
for (const member of doc?.members ?? []) {
|
|
380
|
+
if (!wanted.has(member.sessionID) || member.leftAt !== undefined) continue
|
|
381
|
+
const list = out.get(member.sessionID) ?? []
|
|
382
|
+
if (!list.includes(entry.key)) list.push(entry.key)
|
|
383
|
+
out.set(member.sessionID, list)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return out
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The session ended (deleted): departs its current memberships as `ended`; returns those it departed. */
|
|
390
|
+
async sessionEnded(userID: string, sessionID: string): Promise<WorkspaceMember[]> {
|
|
391
|
+
const ended: WorkspaceMember[] = []
|
|
392
|
+
for (const { workspaceID, member } of await this.membershipsOf(userID, sessionID)) {
|
|
393
|
+
const left = await this.leave(userID, workspaceID, member.id, { reason: "ended" })
|
|
394
|
+
if (left.leftReason === "ended" && left.revision !== member.revision) ended.push(left)
|
|
395
|
+
}
|
|
396
|
+
return ended
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Archives the workspace (WorkspaceStore.archive) and departs its current members as `archived`. Re-running it
|
|
401
|
+
* finishes a sweep a crash interrupted. Assignments keep their status.
|
|
402
|
+
*/
|
|
403
|
+
async archiveWorkspace(userID: string, workspaceID: string, expectedRevision?: number): Promise<{ workspace: HubWorkspace; departed: number }> {
|
|
404
|
+
const workspace = await this.workspaces.archive(userID, workspaceID, expectedRevision)
|
|
405
|
+
let departed = 0
|
|
406
|
+
await updateVersioned<MembersDoc>(this.state, userID, WORKSPACE_MEMBERS, workspaceID, (raw) => {
|
|
407
|
+
const doc = membersDoc(raw, workspaceID)
|
|
408
|
+
departed = doc?.members.filter((m) => m.leftAt === undefined).length ?? 0
|
|
409
|
+
if (!doc || departed === 0) return undefined
|
|
410
|
+
const at = this.now()
|
|
411
|
+
return { ...doc, members: doc.members.map((m) => (m.leftAt === undefined ? { ...m, leftAt: at, leftReason: "archived" as const, revision: m.revision + 1 } : m)) }
|
|
412
|
+
})
|
|
413
|
+
return { workspace, departed }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Makes buffered writes durable (standalone debounce). */
|
|
417
|
+
async flush(): Promise<void> {
|
|
418
|
+
await this.state.flush?.()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------- assignments ----------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Gives a current member an assignment (status open unless given in_progress/blocked). With an idempotency key,
|
|
425
|
+
* the same key and body within IDEMPOTENCY_TTL_MS return the original (created false); another body is a conflict.
|
|
426
|
+
*/
|
|
427
|
+
async assign(userID: string, workspaceID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ assignment: StoredAssignment; created: boolean }> {
|
|
428
|
+
const body = this.assignmentBody(input)
|
|
429
|
+
const key = options.idempotencyKey
|
|
430
|
+
if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
431
|
+
await this.active(userID, workspaceID)
|
|
432
|
+
await this.currentMember(userID, workspaceID, body.memberID)
|
|
433
|
+
const digest = digestOf(body)
|
|
434
|
+
let result: { assignment: StoredAssignment; created: boolean } | undefined
|
|
435
|
+
await updateVersioned<AssignmentsDoc>(this.state, userID, WORKSPACE_ASSIGNMENTS, workspaceID, (raw) => {
|
|
436
|
+
const doc = assignmentsDoc(raw, workspaceID)
|
|
437
|
+
const at = this.now()
|
|
438
|
+
const assignments = doc?.assignments ?? []
|
|
439
|
+
let keys = (doc?.keys ?? []).filter((k) => at - k.at < IDEMPOTENCY_TTL_MS)
|
|
440
|
+
const held = key === undefined ? undefined : keys.find((k) => k.key === key)
|
|
441
|
+
if (held) {
|
|
442
|
+
if (held.digest !== digest)
|
|
443
|
+
throw new AgentError("conflict", `Idempotency-Key ${key} was used for a different assignment`, { hint: "Use a new key for a different body, or resend the original body" })
|
|
444
|
+
const existing = assignments.find((a) => a.id === held.assignmentID)
|
|
445
|
+
if (existing) {
|
|
446
|
+
result = { assignment: existing, created: false }
|
|
447
|
+
return undefined
|
|
448
|
+
}
|
|
449
|
+
keys = keys.filter((k) => k !== held)
|
|
450
|
+
}
|
|
451
|
+
if (assignments.filter((a) => !CLOSED.has(a.status)).length >= this.limits.openAssignmentsPerWorkspace)
|
|
452
|
+
throw new AgentError("rate_limited", `A workspace has at most ${this.limits.openAssignmentsPerWorkspace} open assignments`, { hint: "Close some first" })
|
|
453
|
+
const assignment: StoredAssignment = { v: 1, id: this.newID("assignment"), workspaceID, ...body, revision: 1, createdAt: at, updatedAt: at }
|
|
454
|
+
if (key !== undefined) keys = [...keys, { key, assignmentID: assignment.id, digest, at }].slice(-KEYS_MAX)
|
|
455
|
+
result = { assignment, created: true }
|
|
456
|
+
return { v: 1, workspaceID, assignments: [...assignments, assignment], keys }
|
|
457
|
+
})
|
|
458
|
+
return result!
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** One retained assignment; not_found otherwise. */
|
|
462
|
+
async assignment(userID: string, workspaceID: string, id: string): Promise<StoredAssignment> {
|
|
463
|
+
const found = (await this.readAssignments(userID, workspaceID)).find((a) => a.id === id)
|
|
464
|
+
if (!found) throw new AgentError("not_found", `No assignment ${id} in workspace ${workspaceID}`)
|
|
465
|
+
return found
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Retained assignments by creation, paged and filtered, each with its assignee's membership and presence. */
|
|
469
|
+
async assignments(userID: string, workspaceID: string, query: AssignmentListQuery = {}): Promise<Page<AssignmentView>> {
|
|
470
|
+
const limit = query.limit ?? LIST_LIMIT_DEFAULT
|
|
471
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
472
|
+
if (query.status !== undefined && !ASSIGNMENT_STATUSES.includes(query.status as AssignmentStatus))
|
|
473
|
+
throw new AgentError("invalid", `status must be one of ${ASSIGNMENT_STATUSES.join(", ")}`)
|
|
474
|
+
const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor)
|
|
475
|
+
const all = (await this.readAssignments(userID, workspaceID))
|
|
476
|
+
.filter((a) => query.memberID === undefined || a.memberID === query.memberID)
|
|
477
|
+
.filter((a) => query.status === undefined || a.status === query.status)
|
|
478
|
+
.filter((a) => !after || a.createdAt > after[0] || (a.createdAt === after[0] && a.id > after[1]))
|
|
479
|
+
.sort(byCreation)
|
|
480
|
+
const page = all.slice(0, limit)
|
|
481
|
+
const members = new Map((await this.readMembers(userID, workspaceID)).map((m) => [m.id, m]))
|
|
482
|
+
const presence = await this.presenceOf(userID, page.flatMap((a) => members.get(a.memberID)?.sessionID ?? []))
|
|
483
|
+
const items = page.map((assignment): AssignmentView => {
|
|
484
|
+
const member = members.get(assignment.memberID)
|
|
485
|
+
return {
|
|
486
|
+
assignment,
|
|
487
|
+
member: !member ? "missing" : member.leftAt === undefined ? "current" : "left",
|
|
488
|
+
presence: member ? presence.get(member.sessionID)! : "unknown",
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
return all.length > limit ? { items, cursor: encodeCursor(page.at(-1)!) } : { items }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Moves an open assignment to another current member. The same member again is a no-op. */
|
|
495
|
+
async reassign(userID: string, workspaceID: string, id: string, memberID: unknown, expectedRevision?: number): Promise<StoredAssignment> {
|
|
496
|
+
checkExpected(expectedRevision)
|
|
497
|
+
if (!isContractID("member", memberID)) throw new AgentError("invalid", "memberID must be a workspace member ID")
|
|
498
|
+
await this.active(userID, workspaceID)
|
|
499
|
+
await this.currentMember(userID, workspaceID, memberID)
|
|
500
|
+
return this.mutateAssignment(userID, workspaceID, id, expectedRevision, (a) => {
|
|
501
|
+
if (CLOSED.has(a.status)) throw new AgentError("invalid", `Assignment ${id} is ${a.status}`, { hint: "Create a new assignment" })
|
|
502
|
+
return a.memberID === memberID ? undefined : { ...a, memberID }
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Sets the status. `done` and `cancelled` are the explicit completion: a closed assignment is final (the same
|
|
508
|
+
* status again is a no-op, anything else invalid). Nothing else ever closes an assignment.
|
|
509
|
+
*/
|
|
510
|
+
async setStatus(userID: string, workspaceID: string, id: string, status: unknown, expectedRevision?: number): Promise<StoredAssignment> {
|
|
511
|
+
checkExpected(expectedRevision)
|
|
512
|
+
if (!ASSIGNMENT_STATUSES.includes(status as AssignmentStatus)) throw new AgentError("invalid", `status must be one of ${ASSIGNMENT_STATUSES.join(", ")}`)
|
|
513
|
+
await this.active(userID, workspaceID)
|
|
514
|
+
return this.mutateAssignment(userID, workspaceID, id, expectedRevision, (a) => {
|
|
515
|
+
if (a.status === status) return undefined
|
|
516
|
+
if (CLOSED.has(a.status)) throw new AgentError("invalid", `Assignment ${id} is ${a.status}`, { hint: "Create a new assignment" })
|
|
517
|
+
const next = { ...a, status: status as AssignmentStatus }
|
|
518
|
+
return CLOSED.has(next.status) ? { ...next, closedAt: this.now() } : next
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---------- internals ----------
|
|
523
|
+
|
|
524
|
+
private assignmentBody(input: unknown): Pick<StoredAssignment, "memberID" | "title" | "status" | "criteria" | "ref"> {
|
|
525
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
526
|
+
const { memberID, title, status, criteria, ref } = input
|
|
527
|
+
if (!isContractID("member", memberID)) throw new AgentError("invalid", "memberID must be a workspace member ID")
|
|
528
|
+
if (!isLabel(title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
|
|
529
|
+
if (status !== undefined && !OPENING.includes(status as AssignmentStatus))
|
|
530
|
+
throw new AgentError("invalid", `status must be one of ${OPENING.join(", ")}`, { hint: "Closing is an explicit status change after creation" })
|
|
531
|
+
const { criteriaPerAssignment, criterionMax, refMax } = this.limits
|
|
532
|
+
if (criteria !== undefined && (!Array.isArray(criteria) || criteria.length > criteriaPerAssignment || !criteria.every((c) => isLabel(c, criterionMax))))
|
|
533
|
+
throw new AgentError("invalid", `criteria must be a list of at most ${criteriaPerAssignment} lines of 1–${criterionMax} characters`)
|
|
534
|
+
if (ref !== undefined && !isLabel(ref, refMax)) throw new AgentError("invalid", `ref must be 1–${refMax} characters of text`)
|
|
535
|
+
return {
|
|
536
|
+
memberID,
|
|
537
|
+
title,
|
|
538
|
+
status: (status as AssignmentStatus | undefined) ?? "open",
|
|
539
|
+
...(criteria !== undefined ? { criteria: [...(criteria as string[])] } : {}),
|
|
540
|
+
...(ref !== undefined ? { ref: ref as string } : {}),
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** The user's active workspace (not_found; invalid when archived). */
|
|
545
|
+
private async active(userID: string, workspaceID: string): Promise<HubWorkspace> {
|
|
546
|
+
const workspace = await this.workspaces.require(userID, workspaceID)
|
|
547
|
+
if (workspace.state === "archived") throw archivedError(workspaceID)
|
|
548
|
+
return workspace
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async readMembers(userID: string, workspaceID: string): Promise<readonly WorkspaceMember[]> {
|
|
552
|
+
await this.workspaces.require(userID, workspaceID)
|
|
553
|
+
return membersDoc((await this.state.get(userID, WORKSPACE_MEMBERS, workspaceID))?.value, workspaceID)?.members ?? []
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async readAssignments(userID: string, workspaceID: string): Promise<readonly StoredAssignment[]> {
|
|
557
|
+
await this.workspaces.require(userID, workspaceID)
|
|
558
|
+
return assignmentsDoc((await this.state.get(userID, WORKSPACE_ASSIGNMENTS, workspaceID))?.value, workspaceID)?.assignments ?? []
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A current member (not_found when missing, invalid when departed). */
|
|
562
|
+
private async currentMember(userID: string, workspaceID: string, memberID: string): Promise<WorkspaceMember> {
|
|
563
|
+
const member = await this.member(userID, workspaceID, memberID)
|
|
564
|
+
if (member.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`, { hint: "Assign a current member" })
|
|
565
|
+
return member
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Members referenced by open assignments (never pruned). */
|
|
569
|
+
private async openAssignees(userID: string, workspaceID: string): Promise<Set<string>> {
|
|
570
|
+
const doc = assignmentsDoc((await this.state.get(userID, WORKSPACE_ASSIGNMENTS, workspaceID))?.value, workspaceID)
|
|
571
|
+
return new Set((doc?.assignments ?? []).filter((a) => !CLOSED.has(a.status)).map((a) => a.memberID))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private async presenceOf(userID: string, sessionIDs: readonly string[]): Promise<Map<string, SessionPresence>> {
|
|
575
|
+
const out = new Map<string, SessionPresence>()
|
|
576
|
+
for (const id of sessionIDs) if (!out.has(id)) out.set(id, await this.deps.presence(userID, id))
|
|
577
|
+
return out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Version-checked change of one member inside the aggregate; `change` returning undefined leaves it alone. */
|
|
581
|
+
private async mutateMember(
|
|
582
|
+
userID: string,
|
|
583
|
+
workspaceID: string,
|
|
584
|
+
memberID: string,
|
|
585
|
+
expectedRevision: number | undefined,
|
|
586
|
+
change: (current: WorkspaceMember) => WorkspaceMember | undefined,
|
|
587
|
+
): Promise<WorkspaceMember> {
|
|
588
|
+
let result: WorkspaceMember | undefined
|
|
589
|
+
await updateVersioned<MembersDoc>(this.state, userID, WORKSPACE_MEMBERS, workspaceID, (raw) => {
|
|
590
|
+
const doc = membersDoc(raw, workspaceID)
|
|
591
|
+
const current = doc?.members.find((m) => m.id === memberID)
|
|
592
|
+
if (!doc || !current) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
|
|
593
|
+
if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Member ${memberID}`, current.revision)
|
|
594
|
+
const next = change(current)
|
|
595
|
+
result = next ? { ...next, revision: current.revision + 1 } : current
|
|
596
|
+
return next ? { ...doc, members: doc.members.map((m) => (m.id === memberID ? result! : m)) } : undefined
|
|
597
|
+
})
|
|
598
|
+
return result!
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Version-checked change of one assignment, then closed-assignment retention. */
|
|
602
|
+
private async mutateAssignment(
|
|
603
|
+
userID: string,
|
|
604
|
+
workspaceID: string,
|
|
605
|
+
id: string,
|
|
606
|
+
expectedRevision: number | undefined,
|
|
607
|
+
change: (current: StoredAssignment) => StoredAssignment | undefined,
|
|
608
|
+
): Promise<StoredAssignment> {
|
|
609
|
+
let result: StoredAssignment | undefined
|
|
610
|
+
await updateVersioned<AssignmentsDoc>(this.state, userID, WORKSPACE_ASSIGNMENTS, workspaceID, (raw) => {
|
|
611
|
+
const doc = assignmentsDoc(raw, workspaceID)
|
|
612
|
+
const current = doc?.assignments.find((a) => a.id === id)
|
|
613
|
+
if (!doc || !current) throw new AgentError("not_found", `No assignment ${id} in workspace ${workspaceID}`)
|
|
614
|
+
if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Assignment ${id}`, current.revision)
|
|
615
|
+
const next = change(current)
|
|
616
|
+
if (!next) {
|
|
617
|
+
result = current
|
|
618
|
+
return undefined
|
|
619
|
+
}
|
|
620
|
+
result = { ...next, revision: current.revision + 1, updatedAt: this.now() }
|
|
621
|
+
return { ...doc, assignments: this.retained(doc.assignments.map((a) => (a.id === id ? result! : a))) }
|
|
622
|
+
})
|
|
623
|
+
return result!
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Drops the oldest closed assignments beyond closedAssignmentsRetained. */
|
|
627
|
+
private retained(assignments: readonly StoredAssignment[]): StoredAssignment[] {
|
|
628
|
+
const closed = assignments.filter((a) => CLOSED.has(a.status)).sort((a, b) => (a.closedAt ?? 0) - (b.closedAt ?? 0))
|
|
629
|
+
const drop = new Set(closed.slice(0, Math.max(0, closed.length - this.limits.closedAssignmentsRetained)).map((a) => a.id))
|
|
630
|
+
return assignments.filter((a) => !drop.has(a.id))
|
|
631
|
+
}
|
|
632
|
+
}
|