@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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
|
|
9
9
|
import type { AgentEvents } from "./events"
|
|
10
10
|
import type { CursorKeys } from "./event-cursor"
|
|
11
|
-
import { eventsRoutes } from "./events-routes"
|
|
11
|
+
import { eventsRoutes, type EventsRouteDeps } from "./events-routes"
|
|
12
12
|
import type { DeviceRecord, Store, UserRecord } from "../store"
|
|
13
13
|
import type { Directory } from "./directory"
|
|
14
14
|
import { directoryRoutes } from "./directory-routes"
|
|
@@ -30,6 +30,36 @@ import { actingRoutes } from "./acting-routes"
|
|
|
30
30
|
import type { Actions } from "../actions"
|
|
31
31
|
import { launchRoutes, type RouteLaunches } from "./launch-routes"
|
|
32
32
|
import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
|
|
33
|
+
import { pinAuthorizer } from "../profiles/apply"
|
|
34
|
+
import { profileRoutes } from "../profiles/routes"
|
|
35
|
+
import { ProfileUsage } from "../profiles/usage"
|
|
36
|
+
import { usagePorts } from "../profiles/usage-ports"
|
|
37
|
+
import type { ProfileStore } from "../profiles/store"
|
|
38
|
+
import { workspaceRoutes, type WorkspaceRoutesDeps } from "../workspaces/routes"
|
|
39
|
+
import { boardRoutes, registryBoardSessions } from "../workspaces/board"
|
|
40
|
+
import { type CheckpointStore, checkpointRoutes } from "../workspaces/checkpoints"
|
|
41
|
+
import { masterRoutes } from "../workspaces/master"
|
|
42
|
+
import { routePorts, sideChatRoutes } from "../workspaces/sidechats-routes"
|
|
43
|
+
import { type ArtifactRouteDeps, artifactRoutes } from "../artifacts/routes"
|
|
44
|
+
import { artifactJobRoutes, jobPorts } from "../artifacts/jobs-routes"
|
|
45
|
+
import { type ArtifactHostRouteDeps, artifactHostRoutes } from "../artifacts/host"
|
|
46
|
+
import { type GraphRouteDeps, graphRoutes } from "./interaction-graph-routes"
|
|
47
|
+
import type { TeamIntakeService } from "../teams/intake"
|
|
48
|
+
import { intakeRoutes } from "../teams/intake-routes"
|
|
49
|
+
import { eventVisibility, type TeamDiscovery } from "../teams/discovery"
|
|
50
|
+
import { teamRoutes, type TeamRoutesDeps } from "../teams/routes"
|
|
51
|
+
import type { StateStore } from "../backend/types"
|
|
52
|
+
import type { ActivityTracker } from "../concierge/activity"
|
|
53
|
+
import { boardRoutes as conciergeBoardRoutes } from "../concierge/board-routes"
|
|
54
|
+
import type { ResultTracker } from "../concierge/board-results"
|
|
55
|
+
import { type ConciergeIdentity, watchlistRoutes } from "../concierge/watchlist-routes"
|
|
56
|
+
import { conciergeRoutes } from "../concierge/routes"
|
|
57
|
+
import { guardConciergeRoutes } from "../concierge/guard"
|
|
58
|
+
import { isConciergeLaunch } from "../concierge/supervisor"
|
|
59
|
+
import type { ConciergeService } from "../concierge/service"
|
|
60
|
+
import type { SessionIntent } from "../../protocol"
|
|
61
|
+
import type { SessionIntents } from "./intents"
|
|
62
|
+
import { intentRoutes } from "./intent-routes"
|
|
33
63
|
|
|
34
64
|
export const AGENT_PREFIX = "/api/agent"
|
|
35
65
|
|
|
@@ -90,6 +120,22 @@ export interface AgentDeps {
|
|
|
90
120
|
readonly launches?: RouteLaunches
|
|
91
121
|
/** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
|
|
92
122
|
readonly webhooks?: RouteWebhooks
|
|
123
|
+
/** Profiles (HWA-14): the /api/agent/profiles and /sessions/:addr/profile routes when given with the directory. */
|
|
124
|
+
readonly profiles?: ProfileStore
|
|
125
|
+
/** Workspaces (HWA-06): the /api/agent/workspaces routes when given. */
|
|
126
|
+
readonly workspaces?: Pick<WorkspaceRoutesDeps, "workspaces" | "members" | "supervision" | "lifecycle">
|
|
127
|
+
/** Supervisor checkpoints (HWA-11): POST/GET /api/agent/workspaces/:id/checkpoints when given. */
|
|
128
|
+
readonly workspaceCheckpoints?: CheckpointStore
|
|
129
|
+
/** Team intake (HWA-36): /api/agent/teams/:id/intake when given. */
|
|
130
|
+
readonly teamIntake?: TeamIntakeService
|
|
131
|
+
/** Teams (HWA-37): /api/agent/teams definitions, staffing and status when given (the hub's single instances). */
|
|
132
|
+
readonly teams?: Pick<TeamRoutesDeps, "teams" | "seats" | "staffing">
|
|
133
|
+
/** Persistent artifacts (HWA-21): /api/agent/artifacts; without a store the routes answer 422 unsupported. */
|
|
134
|
+
readonly artifacts?: ArtifactRouteDeps
|
|
135
|
+
/** Isolated artifact host (HWA-26): mint/revoke capability URLs for html/app revisions when given. */
|
|
136
|
+
readonly artifactHost?: ArtifactHostRouteDeps
|
|
137
|
+
/** Interaction graph (HWA-31) team overlay; the graph routes are added with the directory, registry and delivery. */
|
|
138
|
+
readonly graph?: Pick<GraphRouteDeps, "teams">
|
|
93
139
|
/** The canonical agent event journal: archive changes are appended to it. */
|
|
94
140
|
readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
|
|
95
141
|
/** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
|
|
@@ -105,6 +151,32 @@ export interface AgentDeps {
|
|
|
105
151
|
/** Aborted when the hub drains: watch streams end with a `retry:` hint. */
|
|
106
152
|
readonly draining?: AbortSignal
|
|
107
153
|
readonly now?: () => number
|
|
154
|
+
/** Team-aware discovery (HWA-40): who and the read routes scope team members to their teams. */
|
|
155
|
+
readonly teamDiscovery?: TeamDiscovery
|
|
156
|
+
/**
|
|
157
|
+
* Concierge (CON-01): the /api/agent/concierge/watchlist routes when given with the directory and registry.
|
|
158
|
+
* `identity` is the user's current Concierge session (CON-04); without it no session token is admitted.
|
|
159
|
+
*/
|
|
160
|
+
readonly concierge?: {
|
|
161
|
+
readonly state: StateStore
|
|
162
|
+
readonly activity: Pick<ActivityTracker, "list">
|
|
163
|
+
readonly identity?: ConciergeIdentity
|
|
164
|
+
readonly results?: Pick<ResultTracker, "many">
|
|
165
|
+
/** (CON-04) Sessions only the user acts on (the Concierge's): the act verbs refuse agents on them. */
|
|
166
|
+
readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
|
|
167
|
+
/** (CON-04) The Concierge service: /api/agent/concierge settings, enable/disable/restart and notes. */
|
|
168
|
+
readonly service?: ConciergeService
|
|
169
|
+
/** (CON-06 and later) More Concierge routes (the tiers slot). */
|
|
170
|
+
readonly routes?: () => AgentRoute[]
|
|
171
|
+
/** (CON-06) The Decision armed now by the current Concierge (the board's `armed` flag). */
|
|
172
|
+
readonly armed?: (userID: string) => Promise<string | undefined>
|
|
173
|
+
/** (CON-10) The user's time zone (Concierge settings): the rendered board's time is local. */
|
|
174
|
+
readonly timeZone?: (userID: string) => Promise<string | undefined>
|
|
175
|
+
/** (CON-13) The board's inGrant (board-routes.ts BoardRouteDeps.coverage). */
|
|
176
|
+
readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
|
|
177
|
+
}
|
|
178
|
+
/** (CON-02) Session intents: GET/PUT /api/agent/sessions/:addr/intent with the directory; reads carry `intent`. */
|
|
179
|
+
readonly intents?: SessionIntents
|
|
108
180
|
}
|
|
109
181
|
|
|
110
182
|
/**
|
|
@@ -125,11 +197,12 @@ const userView = (user: UserRecord) => ({
|
|
|
125
197
|
/** The built-in /api/agent routes. */
|
|
126
198
|
export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
127
199
|
const feed = deps.feed ?? new MessageFeed()
|
|
128
|
-
|
|
129
|
-
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
|
|
130
|
-
...(deps.directory && deps.
|
|
200
|
+
const routes: AgentRoute[] = [
|
|
201
|
+
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
|
|
202
|
+
...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
203
|
+
...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) }) : []),
|
|
131
204
|
...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
|
|
132
|
-
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
|
|
205
|
+
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
|
|
133
206
|
{
|
|
134
207
|
method: "GET",
|
|
135
208
|
path: `${AGENT_PREFIX}/whoami`,
|
|
@@ -145,7 +218,83 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
145
218
|
? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
|
|
146
219
|
: []),
|
|
147
220
|
...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
|
|
221
|
+
...(deps.profiles && deps.directory
|
|
222
|
+
? profileRoutes({
|
|
223
|
+
profiles: deps.profiles,
|
|
224
|
+
directory: deps.directory,
|
|
225
|
+
audit: deps.store,
|
|
226
|
+
...(deps.agentEvents ? { events: deps.agentEvents } : {}),
|
|
227
|
+
// HWA-15: an app's pin also needs a usable prompt grant on the session.
|
|
228
|
+
...(deps.grants && deps.registry ? { authorizePin: pinAuthorizer({ registry: deps.registry, grants: deps.grants, ...(deps.acting ? { acting: deps.acting } : {}) }) } : {}),
|
|
229
|
+
// RP-08: where a profile is used (pins, team roles, launches, workspaces).
|
|
230
|
+
usage: new ProfileUsage({
|
|
231
|
+
profiles: deps.profiles,
|
|
232
|
+
ports: usagePorts({
|
|
233
|
+
directory: deps.directory,
|
|
234
|
+
...(deps.registry ? { registry: deps.registry } : {}),
|
|
235
|
+
...(deps.teams ? { teams: { teams: deps.teams.teams, seats: deps.teams.seats } } : {}),
|
|
236
|
+
...(deps.launches ? { launches: deps.launches } : {}),
|
|
237
|
+
...(deps.workspaces ? { workspaces: { workspaces: deps.workspaces.workspaces, members: deps.workspaces.members } } : {}),
|
|
238
|
+
}),
|
|
239
|
+
}),
|
|
240
|
+
})
|
|
241
|
+
: []),
|
|
242
|
+
...(deps.workspaces ? workspaceRoutes({ ...deps.workspaces, audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}) }) : []),
|
|
243
|
+
...(deps.workspaces ? boardRoutes({ ...deps.workspaces, ...(deps.directory && deps.registry ? { sessions: registryBoardSessions(deps.directory, deps.registry) } : {}), ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.artifacts?.artifacts ? { artifacts: deps.artifacts.artifacts } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
|
|
244
|
+
...(deps.workspaceCheckpoints ? checkpointRoutes({ checkpoints: deps.workspaceCheckpoints, audit: deps.store }) : []),
|
|
245
|
+
...(deps.workspaces ? masterRoutes({ ...deps.workspaces, audit: deps.store, ...(deps.workspaceCheckpoints ? { checkpoints: deps.workspaceCheckpoints } : {}), ...(deps.launches ? { launches: deps.launches } : {}), ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
|
|
246
|
+
...artifactRoutes(deps.artifacts ?? {}),
|
|
247
|
+
...(deps.artifactHost ? artifactHostRoutes(deps.artifactHost) : []),
|
|
248
|
+
...(deps.directory && deps.registry && deps.delivery ? graphRoutes({ store: deps.store, registry: deps.registry, directory: deps.directory, messages: deps.delivery.messages, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.launches ? { launches: deps.launches } : {}), ...(deps.workspaces ? { workspaces: deps.workspaces } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...deps.graph, ...(deps.now ? { now: deps.now } : {}), ...conciergeReads(deps), ...conciergeCurrent(deps) }) : []),
|
|
249
|
+
...(deps.teamIntake ? intakeRoutes({ intake: deps.teamIntake, audit: deps.store }) : []),
|
|
250
|
+
...(deps.teams ? teamRoutes({ ...deps.teams, ...(deps.teamIntake ? { intake: deps.teamIntake } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}) }) : []),
|
|
251
|
+
...(deps.concierge && deps.directory && deps.registry
|
|
252
|
+
? watchlistRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...launchReads(deps), ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) })
|
|
253
|
+
: []),
|
|
254
|
+
// CON-03: the situation board and the Concierge's Decision reads (users and the current Concierge only).
|
|
255
|
+
...(deps.concierge && deps.directory && deps.registry
|
|
256
|
+
? conciergeBoardRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.intents ? { intents: deps.intents } : {}), ...launchReads(deps), ...(deps.now ? { now: deps.now } : {}) })
|
|
257
|
+
: []),
|
|
258
|
+
// CON-04: settings, enable/disable/restart and notes; CON-06's routes through the tiers slot.
|
|
259
|
+
...(deps.concierge?.service && deps.concierge.identity ? conciergeRoutes({ service: deps.concierge.service, identity: deps.concierge.identity }) : []),
|
|
260
|
+
...(deps.concierge?.routes ? deps.concierge.routes() : []),
|
|
148
261
|
]
|
|
262
|
+
// HWA-13: side chats read, launch and message through the routes above, as the caller.
|
|
263
|
+
const { registry } = deps
|
|
264
|
+
const sourceUpdatedAt = registry ? (userID: string, id: string) => (registry.sessionTenant(id) === userID ? registry.session(id)?.updated : undefined) : undefined
|
|
265
|
+
// HWA-24: artifact AI jobs start their handler through the launch and message routes above, as the requester.
|
|
266
|
+
const a = deps.artifacts
|
|
267
|
+
routes.push(...artifactJobRoutes({ ...(a?.artifacts ? { artifacts: a.artifacts } : {}), ...(a?.state ? { state: a.state } : {}), ports: jobPorts(routes), ...(deps.launches ? { launches: deps.launches } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) }))
|
|
268
|
+
const all = deps.workspaces ? [...routes, ...sideChatRoutes({ workspaces: deps.workspaces.workspaces, ports: routePorts(routes), ...(deps.launches ? { launches: deps.launches } : {}), ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) })] : routes
|
|
269
|
+
// CON-04: manage paths never touch a Concierge session (concierge/guard.ts).
|
|
270
|
+
const identity = deps.concierge?.identity
|
|
271
|
+
const directory = deps.directory
|
|
272
|
+
const launches = deps.launches
|
|
273
|
+
return identity && directory
|
|
274
|
+
? guardConciergeRoutes(all, {
|
|
275
|
+
identity,
|
|
276
|
+
resolve: (userID, addr) => directory.resolve(userID, addr).sessions.map((s) => s.sessionID),
|
|
277
|
+
...(launches
|
|
278
|
+
? {
|
|
279
|
+
launch: async (userID: string, id: string) => {
|
|
280
|
+
const l = await launches.view(userID, id)
|
|
281
|
+
return l ? { ...(l.sessionID !== undefined ? { sessionID: l.sessionID } : {}), concierge: isConciergeLaunch(l) } : undefined
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
: {}),
|
|
285
|
+
})
|
|
286
|
+
: all
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** HWA-28/40: unfiltered events and watch read by a team-scoped session leave out what discovery hides from it. */
|
|
290
|
+
function eventScope(deps: AgentDeps): Pick<EventsRouteDeps, "scope" | "resolve"> {
|
|
291
|
+
const discovery = deps.teamDiscovery
|
|
292
|
+
return discovery
|
|
293
|
+
? {
|
|
294
|
+
scope: async (userID, actor) => eventVisibility(await discovery.census(userID, actor)),
|
|
295
|
+
resolve: async (userID, actor, addr) => discovery.resolve(await discovery.census(userID, actor), addr).sessions.map((s) => s.sessionID),
|
|
296
|
+
}
|
|
297
|
+
: {}
|
|
149
298
|
}
|
|
150
299
|
|
|
151
300
|
/** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
|
|
@@ -165,6 +314,24 @@ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
|
|
|
165
314
|
}
|
|
166
315
|
}
|
|
167
316
|
|
|
317
|
+
/** (CON-04) Which sessions reads mark `concierge: true` (the user's current and hidden Concierge sessions). */
|
|
318
|
+
function conciergeReads(deps: AgentDeps): { concierge?: (userID: string) => Promise<readonly string[]> } {
|
|
319
|
+
const identity = deps.concierge?.identity
|
|
320
|
+
return identity ? { concierge: async (userID: string) => (await identity.current(userID)).hiddenSessionIDs } : {}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** (CON-09a) The user's current Concierge session (Watchtower labels it @@concierge). */
|
|
324
|
+
function conciergeCurrent(deps: AgentDeps): { conciergeCurrent?: (userID: string) => Promise<string | undefined> } {
|
|
325
|
+
const identity = deps.concierge?.identity
|
|
326
|
+
return identity ? { conciergeCurrent: async (userID: string) => (await identity.current(userID)).sessionID } : {}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** (CON-02) What reads show of intents: the stored ones of the listed sessions, by session. */
|
|
330
|
+
function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs: readonly string[]) => Promise<Map<string, SessionIntent>> } {
|
|
331
|
+
const { intents } = deps
|
|
332
|
+
return intents ? { intents: (userID: string, sessionIDs: readonly string[]) => intents.many(userID, sessionIDs) } : {}
|
|
333
|
+
}
|
|
334
|
+
|
|
168
335
|
/** The act verbs, when every part they need is given. */
|
|
169
336
|
function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
170
337
|
const { actions, acting, grants, audit, registry, directory, spokes } = deps
|
|
@@ -180,6 +347,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
180
347
|
audit,
|
|
181
348
|
...(deps.escalate ? { escalate: deps.escalate } : {}),
|
|
182
349
|
...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
|
|
350
|
+
...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
|
|
183
351
|
}),
|
|
184
352
|
)
|
|
185
353
|
}
|
|
@@ -192,6 +360,8 @@ export interface AgentApiOptions {
|
|
|
192
360
|
/** Records activity of a resolved caller (device and user lastSeen). */
|
|
193
361
|
touch?(user: UserRecord, device?: DeviceRecord): void
|
|
194
362
|
readonly routes: readonly AgentRoute[]
|
|
363
|
+
/** Runs for every resolved caller before the route class check (CON-04: the Concierge's route allow-list). */
|
|
364
|
+
gate?(actor: AgentActor, route: AgentRoute): Promise<void>
|
|
195
365
|
}
|
|
196
366
|
|
|
197
367
|
export interface AgentApi {
|
|
@@ -250,6 +420,7 @@ export function agentApi(options: AgentApiOptions): AgentApi {
|
|
|
250
420
|
options.touch?.(resolved.user, resolved.device)
|
|
251
421
|
const found = find(method, url.pathname)
|
|
252
422
|
if (!found) throw new AgentError("not_found", "Not found")
|
|
423
|
+
if (options.gate) await options.gate(resolved.actor, found.route)
|
|
253
424
|
if (!agentAllows(found.route.allow, resolved.actor)) throw new AgentError("forbidden", "This credential cannot use this route")
|
|
254
425
|
const { actor, credential, user, device } = resolved
|
|
255
426
|
return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
|
|
10
10
|
// the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
|
|
11
11
|
// one the token did not make, is 404 not_found.
|
|
12
|
+
import { boundedJson } from "./body"
|
|
12
13
|
import type { Store } from "../store"
|
|
13
14
|
import type { AgentActor } from "./actor"
|
|
14
15
|
import { AgentError } from "./errors"
|
|
@@ -37,7 +38,7 @@ const requireEventScopes = (actor: AgentActor, events: unknown) => {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
40
|
-
const raw =
|
|
41
|
+
const raw = (await boundedJson(request)) as unknown
|
|
41
42
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
42
43
|
return raw as Record<string, unknown>
|
|
43
44
|
}
|
|
@@ -38,6 +38,8 @@ export interface AgentSession {
|
|
|
38
38
|
/** Project name (lowercased) used by `.project` scopes. */
|
|
39
39
|
project?: string
|
|
40
40
|
title?: string
|
|
41
|
+
/** The last harness kind a spoke reported it under (offline sessions keep their glyph, RF-02). */
|
|
42
|
+
harness?: string
|
|
41
43
|
firstSeen?: number
|
|
42
44
|
/** Last time a spoke reported it (ms; persisted at minute granularity while it stays online). */
|
|
43
45
|
lastSeen?: number
|
|
@@ -70,6 +72,8 @@ export interface AgentData {
|
|
|
70
72
|
generations?: Record<string, number>
|
|
71
73
|
/** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
|
|
72
74
|
actingPaused?: true
|
|
75
|
+
/** (CON-09a) Sessions unnamed before the directory knew them: their record is never derived a name (≤ 500). */
|
|
76
|
+
unnamed?: string[]
|
|
73
77
|
[field: string]: unknown
|
|
74
78
|
}
|
|
75
79
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Who may do what with an artifact (HWA-20; contract: Cross-cutting rules, Artifacts). Checked on every access by
|
|
2
|
+
// the artifact store. Tenancy comes first: the store only ever reads the caller's own user's records, so another
|
|
3
|
+
// user's artifact is simply missing. Within a user:
|
|
4
|
+
// - The user (console, app token) owns every artifact: read, revise, manage.
|
|
5
|
+
// - A session reads an artifact only when its visibility is "workspace" and the session is a *current* member of
|
|
6
|
+
// that workspace (re-checked on every read, so leaving a workspace ends access). It never reads a private one,
|
|
7
|
+
// even one it created: createdBySessionID is provenance and grants nothing.
|
|
8
|
+
// - A session may create artifacts (they belong to its user). Membership grants no write: revising, metadata,
|
|
9
|
+
// visibility, archive and delete stay with the user unless an explicit grant-aware policy is passed in (the
|
|
10
|
+
// routes, HWA-21, may wire the existing grants through `authorize`).
|
|
11
|
+
// Role labels and workspace roles never confer authority here.
|
|
12
|
+
import type { Artifact } from "../../protocol"
|
|
13
|
+
|
|
14
|
+
export type ArtifactPrincipal = { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string }
|
|
15
|
+
|
|
16
|
+
/** "manage": metadata, visibility, archive/unarchive and delete. */
|
|
17
|
+
export type ArtifactAction = "read" | "create" | "revise" | "manage"
|
|
18
|
+
|
|
19
|
+
/** The workspace facts access needs (HWA-03/04 provide them; tests use fixtures). */
|
|
20
|
+
export interface ArtifactWorkspaces {
|
|
21
|
+
exists(userID: string, workspaceID: string): Promise<boolean>
|
|
22
|
+
isCurrentMember(userID: string, workspaceID: string, sessionID: string): Promise<boolean>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AccessRequest {
|
|
26
|
+
readonly userID: string
|
|
27
|
+
readonly principal: ArtifactPrincipal
|
|
28
|
+
readonly action: ArtifactAction
|
|
29
|
+
/** Absent for create. */
|
|
30
|
+
readonly artifact?: Artifact
|
|
31
|
+
/** Absent when this hub has no workspaces: then no session reads through a workspace. */
|
|
32
|
+
readonly workspaces?: ArtifactWorkspaces
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ArtifactAuthorizer = (request: AccessRequest) => Promise<boolean>
|
|
36
|
+
|
|
37
|
+
/** The contract's default policy (see the header). */
|
|
38
|
+
export const defaultArtifactPolicy: ArtifactAuthorizer = async ({ userID, principal, action, artifact, workspaces }) => {
|
|
39
|
+
if (principal.kind === "user") return true
|
|
40
|
+
if (action === "create") return true
|
|
41
|
+
if (action !== "read" || !artifact) return false
|
|
42
|
+
if (artifact.visibility !== "workspace" || artifact.workspaceID === undefined || !workspaces) return false
|
|
43
|
+
return workspaces.isCurrentMember(userID, artifact.workspaceID, principal.sessionID)
|
|
44
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The BlobStore contract (HWA-20; docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts): artifact
|
|
2
|
+
// bytes, content-addressed by sha256 and immutable, stored per user (the tenant key). Implementations: memory
|
|
3
|
+
// (src/central/backend/memory-blobs.ts, tests and the single-process default), files under the hub data directory
|
|
4
|
+
// (HWA-21) and a chunked Postgres table (HWA-22, decision tctkrik8). Every implementation passes the shared suite in
|
|
5
|
+
// test/blob-store-conformance.ts.
|
|
6
|
+
//
|
|
7
|
+
// Rules every implementation keeps:
|
|
8
|
+
// - Tenancy: a blob belongs to one user. Another user's hash answers like a missing one (no existence oracle), and
|
|
9
|
+
// usage is per user. The same bytes stored by two users are two blobs.
|
|
10
|
+
// - put streams the body: it hashes as it reads, stops reading (and cancels the source) as soon as the body passes
|
|
11
|
+
// `maxBytes` (default and ceiling ARTIFACT_LIMITS.blobBytes), and stores nothing when the body is too large, its
|
|
12
|
+
// hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (never sniffed), or a new blob would
|
|
13
|
+
// take the user past `quotaBytes` (default ARTIFACT_LIMITS.userBlobBytes). Storing bytes that are already there
|
|
14
|
+
// stores nothing new, is never refused for quota, and refreshes `touchedAt`.
|
|
15
|
+
// - A blob becomes visible to get/has/stat/list only once all its bytes are durably stored and verified
|
|
16
|
+
// (write-temp-then-rename, a committed transaction): readers never see a partial blob.
|
|
17
|
+
// - Reference counting lives with the revisions (src/central/artifacts/store.ts), not here: the artifact store
|
|
18
|
+
// decides a blob is unreferenced and then calls delete with `touchedBefore`. delete refuses (false) a blob touched
|
|
19
|
+
// at or after that time, atomically with the removal, so a writer that touched a blob after the deleter read the
|
|
20
|
+
// references keeps it (see the publish protocol in store.ts).
|
|
21
|
+
import type { BlobRef, Page } from "../../protocol"
|
|
22
|
+
|
|
23
|
+
/** A blob body: whole bytes, a web stream, or any async iterable of chunks (read once). */
|
|
24
|
+
export type BlobBody = Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
|
|
25
|
+
|
|
26
|
+
export interface BlobPutOptions {
|
|
27
|
+
/** `sha256:<hex>` the body must hash to; a mismatch stores nothing (BlobHashMismatchError). */
|
|
28
|
+
readonly expectedHash?: string
|
|
29
|
+
/** Largest body accepted (default and ceiling ARTIFACT_LIMITS.blobBytes). */
|
|
30
|
+
readonly maxBytes?: number
|
|
31
|
+
/** The user's total stored bytes a new blob may not exceed (default ARTIFACT_LIMITS.userBlobBytes). */
|
|
32
|
+
readonly quotaBytes?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A stored blob's facts (no media type: that belongs to the revision's BlobRef). */
|
|
36
|
+
export interface BlobInfo {
|
|
37
|
+
readonly hash: string
|
|
38
|
+
readonly bytes: number
|
|
39
|
+
/** When the bytes were first stored. */
|
|
40
|
+
readonly storedAt: number
|
|
41
|
+
/** The latest put or touch of these bytes. */
|
|
42
|
+
readonly touchedAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A blob being read: its facts and its bytes as a stream (read once). */
|
|
46
|
+
export interface BlobRead extends BlobInfo {
|
|
47
|
+
readonly body: ReadableStream<Uint8Array>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BlobUsage {
|
|
51
|
+
readonly blobs: number
|
|
52
|
+
readonly bytes: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BlobDeleteOptions {
|
|
56
|
+
/** Delete only when the blob's touchedAt is before this time (checked atomically with the delete). */
|
|
57
|
+
readonly touchedBefore?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BlobListOptions {
|
|
61
|
+
/** 1–LIST_LIMIT_MAX (default LIST_LIMIT_DEFAULT). */
|
|
62
|
+
readonly limit?: number
|
|
63
|
+
/** The cursor of the previous page. */
|
|
64
|
+
readonly cursor?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BlobStore {
|
|
68
|
+
/** Stores the body (see the rules above) and answers its reference with the given media type. */
|
|
69
|
+
put(userID: string, body: BlobBody, mediaType: string, options?: BlobPutOptions): Promise<BlobRef>
|
|
70
|
+
/** The blob's bytes; undefined when missing (or another user's, or a malformed hash). */
|
|
71
|
+
get(userID: string, hash: string): Promise<BlobRead | undefined>
|
|
72
|
+
stat(userID: string, hash: string): Promise<BlobInfo | undefined>
|
|
73
|
+
has(userID: string, hash: string): Promise<boolean>
|
|
74
|
+
/** Refreshes touchedAt; false when the blob is missing. */
|
|
75
|
+
touch(userID: string, hash: string): Promise<boolean>
|
|
76
|
+
/** Removes the blob; false when missing or (touchedBefore) touched at or after that time. */
|
|
77
|
+
delete(userID: string, hash: string, options?: BlobDeleteOptions): Promise<boolean>
|
|
78
|
+
usage(userID: string): Promise<BlobUsage>
|
|
79
|
+
/** The user's blobs by hash, paged (orphan recovery walks it). */
|
|
80
|
+
list(userID: string, options?: BlobListOptions): Promise<Page<BlobInfo>>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A request the store refuses as malformed: a media type, maxBytes, limit or cursor. Maps to 400 invalid. */
|
|
84
|
+
export class BlobInputError extends Error {
|
|
85
|
+
constructor(message: string) {
|
|
86
|
+
super(message)
|
|
87
|
+
this.name = "BlobInputError"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The body passed maxBytes; nothing was stored. Maps to 400 invalid. */
|
|
92
|
+
export class BlobTooLargeError extends Error {
|
|
93
|
+
constructor(readonly maxBytes: number) {
|
|
94
|
+
super(`blob is larger than ${maxBytes} bytes`)
|
|
95
|
+
this.name = "BlobTooLargeError"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The body did not hash to expectedHash; nothing was stored. Maps to 400 invalid. */
|
|
100
|
+
export class BlobHashMismatchError extends Error {
|
|
101
|
+
constructor(readonly expected: string, readonly actual: string) {
|
|
102
|
+
super(`blob hashes to ${actual}, not ${expected}`)
|
|
103
|
+
this.name = "BlobHashMismatchError"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A new blob would take the user past their quota; nothing was stored. Maps to 429 rate_limited (a user cap). */
|
|
108
|
+
export class BlobQuotaError extends Error {
|
|
109
|
+
constructor(readonly quotaBytes: number, readonly usedBytes: number) {
|
|
110
|
+
super(`blob storage quota of ${quotaBytes} bytes reached`)
|
|
111
|
+
this.name = "BlobQuotaError"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Helpers every BlobStore implementation shares (memory, files HWA-21, Postgres HWA-22): option checks, the
|
|
2
|
+
// bounded hashing body reader, and the list cursor. Keeping them here keeps the implementations alike.
|
|
3
|
+
import { ARTIFACT_LIMITS, ARTIFACT_MEDIA_TYPES, isBlobHash, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX } from "../../protocol"
|
|
4
|
+
import { type BlobBody, BlobHashMismatchError, BlobInputError, type BlobListOptions, type BlobPutOptions, BlobTooLargeError } from "./blob-store"
|
|
5
|
+
|
|
6
|
+
export interface PutLimits {
|
|
7
|
+
readonly maxBytes: number
|
|
8
|
+
readonly quotaBytes: number
|
|
9
|
+
readonly expectedHash?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Checks put's media type and options (BlobInputError) and answers the limits in force. */
|
|
13
|
+
export function putLimits(mediaType: string, options: BlobPutOptions = {}): PutLimits {
|
|
14
|
+
if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new BlobInputError("mediaType is not allowed")
|
|
15
|
+
const maxBytes = options.maxBytes ?? ARTIFACT_LIMITS.blobBytes
|
|
16
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > ARTIFACT_LIMITS.blobBytes) {
|
|
17
|
+
throw new BlobInputError(`maxBytes must be 0–${ARTIFACT_LIMITS.blobBytes}`)
|
|
18
|
+
}
|
|
19
|
+
const quotaBytes = options.quotaBytes ?? ARTIFACT_LIMITS.userBlobBytes
|
|
20
|
+
if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 0) throw new BlobInputError("quotaBytes must be a non-negative integer")
|
|
21
|
+
if (options.expectedHash !== undefined && !isBlobHash(options.expectedHash)) throw new BlobInputError("expectedHash must be sha256:<64 hex>")
|
|
22
|
+
return { maxBytes, quotaBytes, ...(options.expectedHash !== undefined ? { expectedHash: options.expectedHash } : {}) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads the body once, handing each chunk to `sink` as it comes, hashing and counting it. Stops reading and
|
|
27
|
+
* cancels the source as soon as the body passes maxBytes (BlobTooLargeError), and checks expectedHash at the end
|
|
28
|
+
* (BlobHashMismatchError). The caller discards what the sink got when this throws.
|
|
29
|
+
*/
|
|
30
|
+
export async function readBlobBody(
|
|
31
|
+
body: BlobBody,
|
|
32
|
+
limits: PutLimits,
|
|
33
|
+
sink: (chunk: Uint8Array) => void | Promise<void>,
|
|
34
|
+
): Promise<{ hash: string; bytes: number }> {
|
|
35
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
36
|
+
let bytes = 0
|
|
37
|
+
const take = async (chunk: Uint8Array): Promise<boolean> => {
|
|
38
|
+
if (!(chunk instanceof Uint8Array)) throw new BlobInputError("blob chunks must be bytes")
|
|
39
|
+
bytes += chunk.byteLength
|
|
40
|
+
if (bytes > limits.maxBytes) return false
|
|
41
|
+
hasher.update(chunk)
|
|
42
|
+
await sink(chunk)
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
if (body instanceof Uint8Array) {
|
|
46
|
+
if (!(await take(body))) throw new BlobTooLargeError(limits.maxBytes)
|
|
47
|
+
} else if (body instanceof ReadableStream) {
|
|
48
|
+
const reader = body.getReader()
|
|
49
|
+
try {
|
|
50
|
+
for (;;) {
|
|
51
|
+
const { done, value } = await reader.read()
|
|
52
|
+
if (done) break
|
|
53
|
+
if (!(await take(value))) {
|
|
54
|
+
await reader.cancel().catch(() => {})
|
|
55
|
+
throw new BlobTooLargeError(limits.maxBytes)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
// Bun 1.3 can throw from releaseLock() on a finished body reader after other I/O (quirk tfxrxuta).
|
|
60
|
+
try {
|
|
61
|
+
reader.releaseLock()
|
|
62
|
+
} catch {
|
|
63
|
+
// best effort
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const iterator = body[Symbol.asyncIterator]()
|
|
68
|
+
for (;;) {
|
|
69
|
+
const { done, value } = await iterator.next()
|
|
70
|
+
if (done) break
|
|
71
|
+
if (!(await take(value))) {
|
|
72
|
+
await iterator.return?.()
|
|
73
|
+
throw new BlobTooLargeError(limits.maxBytes)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const hash = `sha256:${hasher.digest("hex")}`
|
|
78
|
+
if (limits.expectedHash !== undefined && limits.expectedHash !== hash) throw new BlobHashMismatchError(limits.expectedHash, hash)
|
|
79
|
+
return { hash, bytes }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A list request's limit and the hash after which the page starts (BlobInputError on bad ones). */
|
|
83
|
+
export function listWindow(options: BlobListOptions = {}): { limit: number; after?: string } {
|
|
84
|
+
const limit = options.limit ?? LIST_LIMIT_DEFAULT
|
|
85
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new BlobInputError(`limit must be 1–${LIST_LIMIT_MAX}`)
|
|
86
|
+
if (options.cursor === undefined) return { limit }
|
|
87
|
+
let after: string
|
|
88
|
+
try {
|
|
89
|
+
after = Buffer.from(options.cursor, "base64url").toString("utf8")
|
|
90
|
+
} catch {
|
|
91
|
+
throw new BlobInputError("cursor is not valid")
|
|
92
|
+
}
|
|
93
|
+
if (!isBlobHash(after)) throw new BlobInputError("cursor is not valid")
|
|
94
|
+
return { limit, after }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The cursor that continues a list after `hash`. */
|
|
98
|
+
export const listCursor = (hash: string): string => Buffer.from(hash, "utf8").toString("base64url")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Artifact events for the canonical agent journal (HWA-21; contract: Cross-cutting rules, Events). The store's
|
|
2
|
+
// onEvent hook gives compact events; this maps them to journal input in the `artifact` family (read with
|
|
3
|
+
// artifacts:read). Payloads are IDs, kinds, revisions and counts only: never titles, descriptions or content.
|
|
4
|
+
import type { ArtifactEvent } from "./store"
|
|
5
|
+
|
|
6
|
+
/** Journal input of an artifact event (an EventInput of family "artifact"). */
|
|
7
|
+
export interface ArtifactJournalInput {
|
|
8
|
+
readonly type: ArtifactEvent["type"]
|
|
9
|
+
readonly family: "artifact"
|
|
10
|
+
readonly subject: { readonly artifactID: string }
|
|
11
|
+
readonly data: Readonly<Record<string, string | number>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function artifactJournalEvent(event: ArtifactEvent): ArtifactJournalInput {
|
|
15
|
+
return {
|
|
16
|
+
type: event.type,
|
|
17
|
+
family: "artifact",
|
|
18
|
+
subject: { artifactID: event.artifactID },
|
|
19
|
+
data: {
|
|
20
|
+
kind: event.kind,
|
|
21
|
+
revision: event.revision,
|
|
22
|
+
...(event.revisionID !== undefined ? { revisionID: event.revisionID } : {}),
|
|
23
|
+
...(event.number !== undefined ? { number: event.number } : {}),
|
|
24
|
+
...(event.state !== undefined ? { state: event.state } : {}),
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|