@huynle/hub 0.7.4 → 0.8.1
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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- 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/paths.ts +3 -3
- 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 +6871 -3460
- 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/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- 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 +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -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 +175 -6
- package/packages/protocol/src/launch.ts +36 -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 +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- 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 +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- 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 +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- 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 +776 -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 +350 -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 +86 -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 +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- 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 +69 -7
- 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 +786 -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 +910 -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 +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- 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-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -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,597 @@
|
|
|
1
|
+
// The versioned profile registry (HWA-14; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md,
|
|
2
|
+
// packages/protocol/src/profiles.ts). Per user (the StateStore userID is the tenant key; another user's records
|
|
3
|
+
// are simply absent):
|
|
4
|
+
//
|
|
5
|
+
// profiles key prof_… the AgentProfile plus its internal version history (number, digest,
|
|
6
|
+
// bytes, createdAt), never more than versionsPerProfile entries
|
|
7
|
+
// profile-index key "names" one aggregate {name → {id, at}}: unique names and the count bound
|
|
8
|
+
// profile-version.<prof_…> key "<n>" one immutable AgentProfileVersion per collection (one small file each
|
|
9
|
+
// in the file store), written once with ifVersion 0
|
|
10
|
+
// profile-assignments key <sessionID> ProfileAssignment pins (HWA-15 applies them)
|
|
11
|
+
//
|
|
12
|
+
// Concurrency: every mutation names expectedRevision; a mismatch is 409 conflict with currentRevision. A new
|
|
13
|
+
// version races for its number through the version slot (ifVersion 0): the first writer wins and the record is
|
|
14
|
+
// never overwritten. A slot left by a writer that stopped before updating the profile is adopted (healed) by the
|
|
15
|
+
// next writer, which then gets conflict and retries with the new revision. Retention drops the oldest versions
|
|
16
|
+
// no session pins once a profile holds more than versionsPerProfile; when every older version is pinned, a new
|
|
17
|
+
// one is refused (rate_limited). Archiving is reversible; an archived profile takes no versions or new pins.
|
|
18
|
+
// Bounded: profiles per user, versions per profile, bytes of retained versions per user (bytesPerUser).
|
|
19
|
+
import { randomBytes } from "node:crypto"
|
|
20
|
+
import {
|
|
21
|
+
type AgentProfile,
|
|
22
|
+
type AgentProfileVersion,
|
|
23
|
+
DESCRIPTION_MAX,
|
|
24
|
+
isExpectedRevision,
|
|
25
|
+
isLabel,
|
|
26
|
+
isText,
|
|
27
|
+
LIST_LIMIT_DEFAULT,
|
|
28
|
+
LIST_LIMIT_MAX,
|
|
29
|
+
metadataProblem,
|
|
30
|
+
type Page,
|
|
31
|
+
PROFILE_APPLY_LIMITS,
|
|
32
|
+
PROFILE_LIMITS,
|
|
33
|
+
type ProfileAck,
|
|
34
|
+
type ProfileAssignment,
|
|
35
|
+
type ProfileVersionContent,
|
|
36
|
+
profileInputProblem,
|
|
37
|
+
profileProjectsProblem,
|
|
38
|
+
RECORD_STATES,
|
|
39
|
+
type RecordState,
|
|
40
|
+
tagsProblem,
|
|
41
|
+
} from "../../protocol"
|
|
42
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
43
|
+
import { AgentError } from "../agent/errors"
|
|
44
|
+
import { updateVersioned } from "../agent/versioned"
|
|
45
|
+
import { checkContent, contentBytes, contentDigest, credentialFinding, ProfileContentError } from "./content"
|
|
46
|
+
|
|
47
|
+
export const PROFILES = "profiles"
|
|
48
|
+
export const PROFILE_INDEX = "profile-index"
|
|
49
|
+
export const PROFILE_ASSIGNMENTS = "profile-assignments"
|
|
50
|
+
export const versionCollection = (profileID: string): string => `profile-version.${profileID}`
|
|
51
|
+
|
|
52
|
+
export interface ProfileStoreLimits {
|
|
53
|
+
readonly profilesPerUser: number
|
|
54
|
+
readonly versionsPerProfile: number
|
|
55
|
+
readonly bytesPerUser: number
|
|
56
|
+
readonly reservationMs: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const PROFILE_STORE_LIMITS: ProfileStoreLimits = {
|
|
60
|
+
profilesPerUser: PROFILE_LIMITS.profilesPerUser,
|
|
61
|
+
versionsPerProfile: PROFILE_LIMITS.versionsPerProfile,
|
|
62
|
+
/** Serialized bytes of all retained versions of one user (extension of the frozen limits). */
|
|
63
|
+
bytesPerUser: 32 * 1024 * 1024,
|
|
64
|
+
/** A name reservation whose profile never got written is reclaimed after this long. */
|
|
65
|
+
reservationMs: 60_000,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One retained version as the history shows it (never its content). */
|
|
69
|
+
export interface VersionEntry {
|
|
70
|
+
readonly version: number
|
|
71
|
+
readonly digest: string
|
|
72
|
+
readonly bytes: number
|
|
73
|
+
readonly createdAt: number
|
|
74
|
+
/** (RP-08) Cached counts of the version's content (absent on entries written before; summaries() reads those). */
|
|
75
|
+
readonly skills?: number
|
|
76
|
+
readonly commands?: number
|
|
77
|
+
readonly instructions?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** (RP-08) The latest version of a listed profile, without reading it (list rows). */
|
|
81
|
+
export interface LatestSummary {
|
|
82
|
+
readonly version: number
|
|
83
|
+
readonly skills: number
|
|
84
|
+
readonly commands: number
|
|
85
|
+
readonly instructions: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const countsOf = (content: ProfileVersionContent) => ({ skills: content.skills.length, commands: content.commands.length, instructions: content.instructions !== undefined })
|
|
89
|
+
|
|
90
|
+
interface ProfileRecord extends AgentProfile {
|
|
91
|
+
readonly createdAt: number
|
|
92
|
+
readonly history: readonly VersionEntry[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface NameIndex {
|
|
96
|
+
readonly v: 1
|
|
97
|
+
readonly names: Readonly<Record<string, { readonly id: string; readonly at: number }>>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The acknowledgment recorded when a pin is stored (HWA-15 plugs in the spoke application). */
|
|
101
|
+
export type AckFor = (userID: string, assignment: ProfileAssignment, version: AgentProfileVersion) => Promise<ProfileAck>
|
|
102
|
+
|
|
103
|
+
export interface ProfileStoreOptions {
|
|
104
|
+
readonly state: StateStore
|
|
105
|
+
readonly now?: Clock
|
|
106
|
+
readonly limits?: Partial<ProfileStoreLimits>
|
|
107
|
+
/** Without it a pin is recorded as `unsupported` (this hub does not apply profiles yet), never as applied. */
|
|
108
|
+
readonly ack?: AckFor
|
|
109
|
+
readonly newID?: () => string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CreateResult {
|
|
113
|
+
readonly profile: AgentProfile
|
|
114
|
+
readonly version: AgentProfileVersion
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface VersionInput {
|
|
118
|
+
readonly expectedRevision?: unknown
|
|
119
|
+
readonly content?: unknown
|
|
120
|
+
/** Explicit rollback: copy this retained version into a new one. */
|
|
121
|
+
readonly fromVersion?: unknown
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ProfilePatch {
|
|
125
|
+
readonly expectedRevision?: unknown
|
|
126
|
+
readonly title?: unknown
|
|
127
|
+
readonly description?: unknown
|
|
128
|
+
readonly tags?: unknown
|
|
129
|
+
readonly state?: unknown
|
|
130
|
+
/** (RP-08) Replaces the metadata; null clears it. */
|
|
131
|
+
readonly metadata?: unknown
|
|
132
|
+
/** (RP-08) Replaces the projects; null clears them. */
|
|
133
|
+
readonly projects?: unknown
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface AssignInput {
|
|
137
|
+
readonly profileID?: unknown
|
|
138
|
+
readonly version?: unknown
|
|
139
|
+
readonly expectedRevision?: unknown
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ListOptions {
|
|
143
|
+
readonly state?: string
|
|
144
|
+
readonly limit?: number
|
|
145
|
+
readonly cursor?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const CREATE_FIELDS = new Set(["name", "title", "description", "tags", "metadata", "projects", "content"])
|
|
149
|
+
const PATCH_FIELDS = new Set(["expectedRevision", "title", "description", "tags", "state", "metadata", "projects"])
|
|
150
|
+
|
|
151
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
152
|
+
|
|
153
|
+
export const conflict = (message: string, currentRevision: number, extra: Record<string, unknown> = {}): AgentError =>
|
|
154
|
+
new AgentError("conflict", message, { extra: { ...extra, currentRevision }, hint: "Read the current record and retry with its revision" })
|
|
155
|
+
|
|
156
|
+
/** A refused content body as the agent error envelope. */
|
|
157
|
+
export function contentError(cause: unknown): unknown {
|
|
158
|
+
if (!(cause instanceof ProfileContentError)) return cause
|
|
159
|
+
return cause.code === "unsupported" ? new AgentError("unsupported", cause.message, { extra: { feature: "setupHooks" } }) : new AgentError("invalid", cause.message)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Validated, normalized version content; throws the agent error. */
|
|
163
|
+
export function contentOf(v: unknown): ProfileVersionContent {
|
|
164
|
+
try {
|
|
165
|
+
return checkContent(v)
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
throw contentError(cause)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function noCredential(field: string, value: unknown): void {
|
|
172
|
+
if (typeof value !== "string") return
|
|
173
|
+
const kind = credentialFinding(value)
|
|
174
|
+
if (kind) throw new AgentError("invalid", `${field} contains what looks like a ${kind}; keep credentials out of profiles`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Metadata values are free text: no credentials in them either. */
|
|
178
|
+
function noCredentialIn(metadata: unknown, projects?: unknown): void {
|
|
179
|
+
if (typeof metadata === "object" && metadata !== null) for (const [key, value] of Object.entries(metadata)) noCredential(`metadata.${key}`, value)
|
|
180
|
+
if (Array.isArray(projects)) for (const project of projects) noCredential("projects", project)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function unknownField(body: Record<string, unknown>, allowed: ReadonlySet<string>): void {
|
|
184
|
+
for (const key of Object.keys(body)) {
|
|
185
|
+
if (allowed.has(key)) continue
|
|
186
|
+
if (key === "name") throw new AgentError("invalid", "A profile's name cannot change; create a new profile")
|
|
187
|
+
throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const encodeCursor = (value: string | number): string => Buffer.from(JSON.stringify({ k: value })).toString("base64url")
|
|
192
|
+
function decodeCursor(cursor: string | undefined): string | number | undefined {
|
|
193
|
+
if (cursor === undefined) return undefined
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { k?: unknown }
|
|
196
|
+
if (typeof parsed.k === "string" || typeof parsed.k === "number") return parsed.k
|
|
197
|
+
} catch {
|
|
198
|
+
// answered below
|
|
199
|
+
}
|
|
200
|
+
throw new AgentError("invalid", "Bad cursor; start again without one")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pageLimit(limit: number | undefined): number {
|
|
204
|
+
if (limit === undefined) return LIST_LIMIT_DEFAULT
|
|
205
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
206
|
+
return limit
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A profile as the API shows it: the contract record, never the internal history. */
|
|
210
|
+
export function profileView(record: ProfileRecord): AgentProfile {
|
|
211
|
+
const { history: _history, createdAt: _createdAt, ...profile } = record
|
|
212
|
+
return profile
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class ProfileStore {
|
|
216
|
+
readonly state: StateStore
|
|
217
|
+
private readonly now: Clock
|
|
218
|
+
readonly limits: ProfileStoreLimits
|
|
219
|
+
private readonly ack?: AckFor
|
|
220
|
+
private readonly newID: () => string
|
|
221
|
+
|
|
222
|
+
constructor(options: ProfileStoreOptions) {
|
|
223
|
+
this.state = options.state
|
|
224
|
+
this.now = options.now ?? Date.now
|
|
225
|
+
this.limits = { ...PROFILE_STORE_LIMITS, ...options.limits }
|
|
226
|
+
this.ack = options.ack
|
|
227
|
+
this.newID = options.newID ?? (() => `prof_${randomBytes(12).toString("hex")}`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- profiles ----------
|
|
231
|
+
|
|
232
|
+
async create(userID: string, body: unknown): Promise<CreateResult> {
|
|
233
|
+
if (!isRecord(body)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
234
|
+
unknownField(body, CREATE_FIELDS)
|
|
235
|
+
const content = contentOf(body.content)
|
|
236
|
+
const problem = profileInputProblem({ ...body, content })
|
|
237
|
+
if (problem) throw new AgentError("invalid", problem)
|
|
238
|
+
noCredential("title", body.title)
|
|
239
|
+
noCredential("description", body.description)
|
|
240
|
+
noCredentialIn(body.metadata, body.projects)
|
|
241
|
+
const name = body.name as string
|
|
242
|
+
const bytes = contentBytes(content)
|
|
243
|
+
await this.ensureQuota(userID, bytes)
|
|
244
|
+
|
|
245
|
+
const id = this.newID()
|
|
246
|
+
const now = this.now()
|
|
247
|
+
await this.reserveName(userID, name, id, now)
|
|
248
|
+
try {
|
|
249
|
+
const version: AgentProfileVersion = { v: 1, profileID: id, version: 1, content, digest: contentDigest(content), createdAt: now }
|
|
250
|
+
const record: ProfileRecord = {
|
|
251
|
+
v: 1,
|
|
252
|
+
id,
|
|
253
|
+
name,
|
|
254
|
+
title: body.title as string,
|
|
255
|
+
...(body.description !== undefined ? { description: body.description as string } : {}),
|
|
256
|
+
state: "active",
|
|
257
|
+
...(body.tags !== undefined ? { tags: [...(body.tags as string[])] } : {}),
|
|
258
|
+
...(body.metadata !== undefined ? { metadata: { ...(body.metadata as Record<string, string>) } } : {}),
|
|
259
|
+
...(body.projects !== undefined ? { projects: [...(body.projects as string[])] } : {}),
|
|
260
|
+
latestVersion: 1,
|
|
261
|
+
revision: 1,
|
|
262
|
+
updatedAt: now,
|
|
263
|
+
createdAt: now,
|
|
264
|
+
history: [{ version: 1, digest: version.digest, bytes, createdAt: now, ...countsOf(content) }],
|
|
265
|
+
}
|
|
266
|
+
if (!(await this.state.put(userID, versionCollection(id), "1", version, { ifVersion: 0 })).ok) throw new Error(`profile version slot ${id}/1 already exists`)
|
|
267
|
+
if (!(await this.state.put(userID, PROFILES, id, record, { ifVersion: 0 })).ok) throw new Error(`profile ${id} already exists`)
|
|
268
|
+
return { profile: profileView(record), version }
|
|
269
|
+
} catch (cause) {
|
|
270
|
+
await this.releaseName(userID, name, id).catch(() => {})
|
|
271
|
+
throw cause
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async get(userID: string, id: string): Promise<AgentProfile | undefined> {
|
|
276
|
+
const record = await this.record(userID, id)
|
|
277
|
+
return record && profileView(record)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async list(userID: string, options: ListOptions): Promise<Page<AgentProfile>> {
|
|
281
|
+
const limit = pageLimit(options.limit)
|
|
282
|
+
const wanted = options.state ?? "active"
|
|
283
|
+
if (wanted !== "all" && !RECORD_STATES.includes(wanted as RecordState)) throw new AgentError("invalid", "state must be active, archived or all")
|
|
284
|
+
const after = decodeCursor(options.cursor)
|
|
285
|
+
const all = (await this.state.list<ProfileRecord>(userID, PROFILES))
|
|
286
|
+
.map((e) => e.value)
|
|
287
|
+
.filter((p) => wanted === "all" || p.state === wanted)
|
|
288
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
289
|
+
const rest = after === undefined ? all : all.filter((p) => p.name > String(after))
|
|
290
|
+
const items = rest.slice(0, limit)
|
|
291
|
+
return { items: items.map(profileView), ...(rest.length > limit ? { cursor: encodeCursor(items[items.length - 1]!.name) } : {}) }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async update(userID: string, id: string, patch: ProfilePatch): Promise<AgentProfile> {
|
|
295
|
+
return (await this.updateWithPrior(userID, id, patch)).after
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The update, with the profile before it (the routes announce state changes). */
|
|
299
|
+
async updateWithPrior(userID: string, id: string, patch: ProfilePatch): Promise<{ before: AgentProfile; after: AgentProfile }> {
|
|
300
|
+
if (!isRecord(patch)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
301
|
+
unknownField(patch, PATCH_FIELDS)
|
|
302
|
+
const expected = this.expected(patch.expectedRevision)
|
|
303
|
+
if (patch.title !== undefined && !isLabel(patch.title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
|
|
304
|
+
if (patch.description !== undefined && patch.description !== null && !isText(patch.description, DESCRIPTION_MAX)) throw new AgentError("invalid", `description must be at most ${DESCRIPTION_MAX} characters`)
|
|
305
|
+
const tags = tagsProblem(patch.tags)
|
|
306
|
+
if (tags) throw new AgentError("invalid", tags)
|
|
307
|
+
if (patch.state !== undefined && !RECORD_STATES.includes(patch.state as RecordState)) throw new AgentError("invalid", "state must be active or archived")
|
|
308
|
+
const extra = (patch.metadata === null ? undefined : metadataProblem(patch.metadata)) ?? (patch.projects === null ? undefined : profileProjectsProblem(patch.projects))
|
|
309
|
+
if (extra) throw new AgentError("invalid", extra)
|
|
310
|
+
noCredential("title", patch.title)
|
|
311
|
+
noCredential("description", patch.description)
|
|
312
|
+
noCredentialIn(patch.metadata, patch.projects)
|
|
313
|
+
let before: ProfileRecord | undefined
|
|
314
|
+
const after = await updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) => {
|
|
315
|
+
if (!current) throw notFound(id)
|
|
316
|
+
if (current.revision !== expected) throw conflict(`Profile ${id} changed (revision ${current.revision})`, current.revision)
|
|
317
|
+
before = current
|
|
318
|
+
const { description: _d, metadata: _m, projects: _p, ...rest } = current
|
|
319
|
+
const description = patch.description === undefined ? current.description : (patch.description as string | null) ?? undefined
|
|
320
|
+
const metadata = patch.metadata === undefined ? current.metadata : (patch.metadata as Record<string, string> | null) ?? undefined
|
|
321
|
+
const projects = patch.projects === undefined ? current.projects : (patch.projects as string[] | null) ?? undefined
|
|
322
|
+
return {
|
|
323
|
+
...rest,
|
|
324
|
+
...(patch.title !== undefined ? { title: patch.title as string } : {}),
|
|
325
|
+
...(description !== undefined ? { description } : {}),
|
|
326
|
+
...(metadata !== undefined ? { metadata: { ...metadata } } : {}),
|
|
327
|
+
...(projects !== undefined ? { projects: [...projects] } : {}),
|
|
328
|
+
...(patch.tags !== undefined ? { tags: [...(patch.tags as string[])] } : {}),
|
|
329
|
+
...(patch.state !== undefined ? { state: patch.state as RecordState } : {}),
|
|
330
|
+
revision: current.revision + 1,
|
|
331
|
+
updatedAt: this.now(),
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
return { before: profileView(before!), after: profileView(after!) }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------- versions ----------
|
|
338
|
+
|
|
339
|
+
async addVersion(userID: string, id: string, input: VersionInput): Promise<CreateResult> {
|
|
340
|
+
if (!isRecord(input)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
341
|
+
const expected = this.expected(input.expectedRevision)
|
|
342
|
+
if ((input.content === undefined) === (input.fromVersion === undefined)) throw new AgentError("invalid", "Send exactly one of content or fromVersion")
|
|
343
|
+
const profile = await this.record(userID, id)
|
|
344
|
+
if (!profile) throw notFound(id)
|
|
345
|
+
if (profile.revision !== expected) throw conflict(`Profile ${id} changed (revision ${profile.revision})`, profile.revision)
|
|
346
|
+
if (profile.state !== "active") throw conflict(`Profile ${id} is archived; unarchive it first`, profile.revision, { state: profile.state })
|
|
347
|
+
let content: ProfileVersionContent
|
|
348
|
+
if (input.fromVersion !== undefined) {
|
|
349
|
+
if (!Number.isSafeInteger(input.fromVersion) || (input.fromVersion as number) < 1) throw new AgentError("invalid", "fromVersion must be a version number")
|
|
350
|
+
const old = await this.version(userID, id, input.fromVersion as number)
|
|
351
|
+
if (!old) throw new AgentError("not_found", `Profile ${id} has no retained version ${input.fromVersion as number}`)
|
|
352
|
+
content = old.content
|
|
353
|
+
} else content = contentOf(input.content)
|
|
354
|
+
const bytes = contentBytes(content)
|
|
355
|
+
await this.ensureQuota(userID, bytes)
|
|
356
|
+
|
|
357
|
+
// Retention: which older versions go once this one is in (oldest first, never a pinned one).
|
|
358
|
+
const pinned = await this.pinnedVersions(userID, id)
|
|
359
|
+
const excess = profile.history.length + 1 - this.limits.versionsPerProfile
|
|
360
|
+
const candidates = [...profile.history].sort((a, b) => a.version - b.version).filter((h) => !pinned.has(h.version))
|
|
361
|
+
if (excess > candidates.length) {
|
|
362
|
+
throw new AgentError("rate_limited", `Profile ${id} already keeps ${profile.history.length} versions and sessions pin the older ones`, { hint: "Unassign an old version from its sessions first" })
|
|
363
|
+
}
|
|
364
|
+
const victims = excess > 0 ? candidates.slice(0, excess) : []
|
|
365
|
+
|
|
366
|
+
const number = profile.latestVersion + 1
|
|
367
|
+
const now = this.now()
|
|
368
|
+
const version: AgentProfileVersion = { v: 1, profileID: id, version: number, content, digest: contentDigest(content), createdAt: now }
|
|
369
|
+
if (!(await this.state.put(userID, versionCollection(id), String(number), version, { ifVersion: 0 })).ok) {
|
|
370
|
+
const healed = await this.heal(userID, id)
|
|
371
|
+
throw conflict(`Profile ${id} got version ${number} from another writer`, healed?.revision ?? profile.revision)
|
|
372
|
+
}
|
|
373
|
+
const entry: VersionEntry = { version: number, digest: version.digest, bytes, createdAt: now, ...countsOf(content) }
|
|
374
|
+
const gone = new Set(victims.map((v) => v.version))
|
|
375
|
+
const written = await updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) => {
|
|
376
|
+
if (!current) return undefined
|
|
377
|
+
const present = current.history.some((h) => h.version === number)
|
|
378
|
+
if (present && !victims.length) return undefined
|
|
379
|
+
const history = [...current.history.filter((h) => !gone.has(h.version) && h.version !== number), entry].sort((a, b) => a.version - b.version)
|
|
380
|
+
return { ...current, history, latestVersion: Math.max(current.latestVersion, number), revision: current.revision + (present ? 0 : 1), updatedAt: now }
|
|
381
|
+
})
|
|
382
|
+
const after = written ?? (await this.record(userID, id))
|
|
383
|
+
if (!after) throw notFound(id)
|
|
384
|
+
await this.dropVersions(userID, id, victims)
|
|
385
|
+
return { profile: profileView(after), version }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A retained version (pruned or never written: undefined). */
|
|
389
|
+
async version(userID: string, id: string, number: number): Promise<AgentProfileVersion | undefined> {
|
|
390
|
+
const profile = await this.record(userID, id)
|
|
391
|
+
if (!profile || !profile.history.some((h) => h.version === number)) return undefined
|
|
392
|
+
return (await this.state.get<AgentProfileVersion>(userID, versionCollection(id), String(number)))?.value
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* (RP-08) The latest version's counts for these profiles: one scan of the records, plus one version read only for
|
|
397
|
+
* a profile whose latest entry predates the cached counts (bounded by the page).
|
|
398
|
+
*/
|
|
399
|
+
async summaries(userID: string, ids: readonly string[]): Promise<Record<string, LatestSummary>> {
|
|
400
|
+
const wanted = new Set(ids)
|
|
401
|
+
const out: Record<string, LatestSummary> = {}
|
|
402
|
+
for (const { value } of await this.state.list<ProfileRecord>(userID, PROFILES)) {
|
|
403
|
+
if (!wanted.has(value.id)) continue
|
|
404
|
+
const entry = value.history.find((h) => h.version === value.latestVersion)
|
|
405
|
+
if (entry?.skills !== undefined && entry.commands !== undefined) {
|
|
406
|
+
out[value.id] = { version: value.latestVersion, skills: entry.skills, commands: entry.commands, instructions: entry.instructions === true }
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
const latest = (await this.state.get<AgentProfileVersion>(userID, versionCollection(value.id), String(value.latestVersion)))?.value
|
|
410
|
+
if (latest) out[value.id] = { version: value.latestVersion, ...countsOf(latest.content) }
|
|
411
|
+
}
|
|
412
|
+
return out
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The retained versions, newest first. */
|
|
416
|
+
async history(userID: string, id: string, options: { limit?: number; cursor?: string }): Promise<Page<VersionEntry>> {
|
|
417
|
+
const limit = pageLimit(options.limit)
|
|
418
|
+
const after = decodeCursor(options.cursor)
|
|
419
|
+
const profile = await this.record(userID, id)
|
|
420
|
+
if (!profile) throw notFound(id)
|
|
421
|
+
const all = [...profile.history].sort((a, b) => b.version - a.version)
|
|
422
|
+
const rest = after === undefined ? all : all.filter((h) => h.version < Number(after))
|
|
423
|
+
const items = rest.slice(0, limit)
|
|
424
|
+
return { items, ...(rest.length > limit ? { cursor: encodeCursor(items[items.length - 1]!.version) } : {}) }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------- assignments (pins) ----------
|
|
428
|
+
|
|
429
|
+
async assignment(userID: string, sessionID: string): Promise<ProfileAssignment | undefined> {
|
|
430
|
+
return (await this.state.get<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS, sessionID))?.value
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* (RP-08) The user's pins in one scan, at most `cap` of them (usage reads; no reverse index, see usage.ts).
|
|
435
|
+
* `truncated` when the user has more.
|
|
436
|
+
*/
|
|
437
|
+
async assignmentsScan(userID: string, cap: number): Promise<{ items: ProfileAssignment[]; truncated: boolean }> {
|
|
438
|
+
const all = await this.state.list<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS)
|
|
439
|
+
return { items: all.slice(0, cap).map((e) => e.value), truncated: all.length > cap }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Pins a retained version of an active profile to a session (CAS on the session's assignment). */
|
|
443
|
+
async assign(userID: string, sessionID: string, input: AssignInput): Promise<ProfileAssignment> {
|
|
444
|
+
if (!isRecord(input)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
445
|
+
const expected = this.expected(input.expectedRevision)
|
|
446
|
+
if (typeof input.profileID !== "string" || !input.profileID) throw new AgentError("invalid", "profileID is required")
|
|
447
|
+
if (!Number.isSafeInteger(input.version) || (input.version as number) < 1) throw new AgentError("invalid", "version must be a version number")
|
|
448
|
+
const profileID = input.profileID
|
|
449
|
+
const number = input.version as number
|
|
450
|
+
const profile = await this.record(userID, profileID)
|
|
451
|
+
if (!profile) throw notFound(profileID)
|
|
452
|
+
if (profile.state !== "active") throw conflict(`Profile ${profileID} is archived; unarchive it first`, profile.revision, { state: profile.state })
|
|
453
|
+
const version = await this.version(userID, profileID, number)
|
|
454
|
+
if (!version) throw new AgentError("not_found", `Profile ${profileID} has no retained version ${number}`)
|
|
455
|
+
|
|
456
|
+
const now = this.now()
|
|
457
|
+
const pending: ProfileAck = { status: "unsupported", digest: version.digest, notes: ["this hub does not apply profiles to sessions yet"], at: now }
|
|
458
|
+
const written = await updateVersioned<ProfileAssignment>(this.state, userID, PROFILE_ASSIGNMENTS, sessionID, (current) => {
|
|
459
|
+
const revision = current?.revision ?? 0
|
|
460
|
+
if (revision !== expected) throw conflict(`The profile assignment of ${sessionID} changed (revision ${revision})`, revision)
|
|
461
|
+
// The effective version (what the session last confirmed) outlives re-pins until a spoke confirms another.
|
|
462
|
+
return { v: 1, sessionID, profileID, version: number, revision: revision + 1, ...(current?.effective ? { effective: current.effective } : {}), ...(this.ack ? {} : { ack: pending }) }
|
|
463
|
+
})
|
|
464
|
+
let assignment = written!
|
|
465
|
+
// Retention may have dropped the version between the check and the pin: undo rather than pin a gone version.
|
|
466
|
+
if (!(await this.version(userID, profileID, number))) {
|
|
467
|
+
const stored = await this.state.get<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS, sessionID)
|
|
468
|
+
if (stored && stored.value.revision === assignment.revision) await this.state.delete(userID, PROFILE_ASSIGNMENTS, sessionID, { ifVersion: stored.version })
|
|
469
|
+
throw conflict(`Profile ${profileID} version ${number} was just dropped`, profile.revision)
|
|
470
|
+
}
|
|
471
|
+
if (this.ack) {
|
|
472
|
+
const ack = await this.ack(userID, assignment, version)
|
|
473
|
+
assignment = (await this.recordAck(userID, sessionID, assignment.revision, ack)) ?? assignment
|
|
474
|
+
}
|
|
475
|
+
return assignment
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Records the latest acknowledgment of a pin (HWA-15). Acknowledgments are the hub's own status, not a user
|
|
480
|
+
* mutation: they keep the assignment's revision. Undefined when the pin changed meanwhile. A pending ack never
|
|
481
|
+
* replaces a final one of the same revision and digest (the spoke may answer before the pending is stored);
|
|
482
|
+
* applied and partial also become the session's effective version.
|
|
483
|
+
*/
|
|
484
|
+
async recordAck(userID: string, sessionID: string, revision: number, ack: ProfileAck): Promise<ProfileAssignment | undefined> {
|
|
485
|
+
let kept: ProfileAssignment | undefined
|
|
486
|
+
const written = await updateVersioned<ProfileAssignment>(this.state, userID, PROFILE_ASSIGNMENTS, sessionID, (current) => {
|
|
487
|
+
if (!current || current.revision !== revision) return undefined
|
|
488
|
+
if (ack.status === "pending" && current.ack && current.ack.status !== "pending" && current.ack.digest === ack.digest) {
|
|
489
|
+
kept = current
|
|
490
|
+
return undefined
|
|
491
|
+
}
|
|
492
|
+
const notes = ack.notes ? { notes: ack.notes.slice(0, PROFILE_APPLY_LIMITS.notes) } : {}
|
|
493
|
+
const effective =
|
|
494
|
+
ack.status === "applied" || ack.status === "partial"
|
|
495
|
+
? { effective: { profileID: current.profileID, version: current.version, digest: ack.digest, status: ack.status, at: ack.at } }
|
|
496
|
+
: {}
|
|
497
|
+
return { ...current, ack: { ...ack, ...notes }, ...effective }
|
|
498
|
+
})
|
|
499
|
+
return written ?? kept
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async unassign(userID: string, sessionID: string, expectedRevision: unknown): Promise<ProfileAssignment> {
|
|
503
|
+
const expected = this.expected(expectedRevision)
|
|
504
|
+
const stored = await this.state.get<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS, sessionID)
|
|
505
|
+
if (!stored) throw new AgentError("not_found", `Session ${sessionID} has no profile`)
|
|
506
|
+
if (stored.value.revision !== expected) throw conflict(`The profile assignment of ${sessionID} changed (revision ${stored.value.revision})`, stored.value.revision)
|
|
507
|
+
if (!(await this.state.delete(userID, PROFILE_ASSIGNMENTS, sessionID, { ifVersion: stored.version })).ok) {
|
|
508
|
+
const now = await this.assignment(userID, sessionID)
|
|
509
|
+
throw conflict(`The profile assignment of ${sessionID} changed`, now?.revision ?? 0)
|
|
510
|
+
}
|
|
511
|
+
return stored.value
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------- internals ----------
|
|
515
|
+
|
|
516
|
+
private expected(v: unknown): number {
|
|
517
|
+
if (!isExpectedRevision(v)) throw new AgentError("invalid", "expectedRevision is required (the revision you read; 0 when there is none)")
|
|
518
|
+
return v
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async record(userID: string, id: string): Promise<ProfileRecord | undefined> {
|
|
522
|
+
return (await this.state.get<ProfileRecord>(userID, PROFILES, id))?.value
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async pinnedVersions(userID: string, profileID: string): Promise<Set<number>> {
|
|
526
|
+
const out = new Set<number>()
|
|
527
|
+
for (const { value } of await this.state.list<ProfileAssignment>(userID, PROFILE_ASSIGNMENTS)) if (value.profileID === profileID) out.add(value.version)
|
|
528
|
+
return out
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Bytes of retained versions per user stay under bytesPerUser (a soft bound: concurrent writers may pass it by one version). */
|
|
532
|
+
private async ensureQuota(userID: string, bytes: number): Promise<void> {
|
|
533
|
+
let used = 0
|
|
534
|
+
for (const { value } of await this.state.list<ProfileRecord>(userID, PROFILES)) for (const h of value.history) used += h.bytes
|
|
535
|
+
if (used + bytes > this.limits.bytesPerUser) {
|
|
536
|
+
throw new AgentError("rate_limited", `Profile storage is full (${this.limits.bytesPerUser} bytes per user)`, { hint: "Unpin and drop old versions or profiles you no longer use" })
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async reserveName(userID: string, name: string, id: string, now: number): Promise<void> {
|
|
541
|
+
const index = (await this.state.get<NameIndex>(userID, PROFILE_INDEX, "names"))?.value
|
|
542
|
+
const held = index?.names[name]
|
|
543
|
+
const stale = held && now - held.at > this.limits.reservationMs && !(await this.record(userID, held.id)) ? held.id : undefined
|
|
544
|
+
await updateVersioned<NameIndex>(this.state, userID, PROFILE_INDEX, "names", (current) => {
|
|
545
|
+
const names = { ...current?.names }
|
|
546
|
+
const holder = names[name]
|
|
547
|
+
if (holder && holder.id !== stale) throw new AgentError("name_taken", `You already have a profile named ${name}`)
|
|
548
|
+
delete names[name]
|
|
549
|
+
if (Object.keys(names).length >= this.limits.profilesPerUser) throw new AgentError("rate_limited", `At most ${this.limits.profilesPerUser} profiles per user`, { hint: "Reuse or archive profiles" })
|
|
550
|
+
names[name] = { id, at: now }
|
|
551
|
+
return { v: 1, names }
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async releaseName(userID: string, name: string, id: string): Promise<void> {
|
|
556
|
+
await updateVersioned<NameIndex>(this.state, userID, PROFILE_INDEX, "names", (current) => {
|
|
557
|
+
if (current?.names[name]?.id !== id) return undefined
|
|
558
|
+
const names = { ...current.names }
|
|
559
|
+
delete names[name]
|
|
560
|
+
return { v: 1, names }
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Adopts version slots past latestVersion that a stopped writer left; returns the profile. */
|
|
565
|
+
private async heal(userID: string, id: string): Promise<ProfileRecord | undefined> {
|
|
566
|
+
const profile = await this.record(userID, id)
|
|
567
|
+
if (!profile) return undefined
|
|
568
|
+
const found: VersionEntry[] = []
|
|
569
|
+
for (let n = profile.latestVersion + 1; ; n++) {
|
|
570
|
+
const slot = await this.state.get<AgentProfileVersion>(userID, versionCollection(id), String(n))
|
|
571
|
+
if (!slot) break
|
|
572
|
+
found.push({ version: n, digest: slot.value.digest, bytes: contentBytes(slot.value.content), createdAt: slot.value.createdAt, ...countsOf(slot.value.content) })
|
|
573
|
+
}
|
|
574
|
+
if (!found.length) return profile
|
|
575
|
+
return updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) => {
|
|
576
|
+
if (!current) return undefined
|
|
577
|
+
const add = found.filter((f) => f.version > current.latestVersion)
|
|
578
|
+
if (!add.length) return undefined
|
|
579
|
+
return { ...current, history: [...current.history, ...add], latestVersion: add[add.length - 1]!.version, revision: current.revision + 1, updatedAt: this.now() }
|
|
580
|
+
}).then(async (written) => written ?? (await this.record(userID, id)))
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Deletes pruned version records, except any a session pinned meanwhile (those go back into the history). */
|
|
584
|
+
private async dropVersions(userID: string, id: string, victims: readonly VersionEntry[]): Promise<void> {
|
|
585
|
+
if (!victims.length) return
|
|
586
|
+
const pinned = await this.pinnedVersions(userID, id)
|
|
587
|
+
const keep = victims.filter((v) => pinned.has(v.version))
|
|
588
|
+
if (keep.length) {
|
|
589
|
+
await updateVersioned<ProfileRecord>(this.state, userID, PROFILES, id, (current) =>
|
|
590
|
+
current ? { ...current, history: [...current.history, ...keep.filter((k) => !current.history.some((h) => h.version === k.version))].sort((a, b) => a.version - b.version) } : undefined,
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
for (const victim of victims) if (!pinned.has(victim.version)) await this.state.delete(userID, versionCollection(id), String(victim.version))
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const notFound = (id: string) => new AgentError("not_found", `No profile ${id}`)
|