@huynle/hub 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// Logical coordination workspaces and their host repository bindings (HWA-03; contract
|
|
2
|
+
// docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, packages/protocol/src/hub-workspace.ts).
|
|
3
|
+
//
|
|
4
|
+
// Storage: StateStore collections under the hub user (the tenant key), so the standalone file store and the
|
|
5
|
+
// cluster's Postgres hub_state table behave alike and no schema migration is needed:
|
|
6
|
+
// - `workspaces`: one HubWorkspace per key (its ID). `revision` is the StateStore version of the key: every write
|
|
7
|
+
// is version-checked (ifVersion), so a stale `expectedRevision` is a 409 conflict with `currentRevision`, and of
|
|
8
|
+
// two writers holding one revision exactly one wins. Keys are never deleted, so revisions are never reused.
|
|
9
|
+
// - `workspace-bindings`: one aggregate per workspace (key: workspace ID) holding its HostRepoBindings. Identity is
|
|
10
|
+
// the explicit machineID + canonical absolute repo; IDs are minted, never derived from paths.
|
|
11
|
+
// - `workspace-index` (key `active`): the reservations counting active workspaces toward the per-user cap. A
|
|
12
|
+
// create or restore reserves a slot by CAS before writing the record; archive gives it back. The index is
|
|
13
|
+
// derived state: when it is missing or full it is rebuilt from the records (active records count, reservations
|
|
14
|
+
// younger than RESERVATION_MS whose record is not active yet count, anything else is dropped), so a creator that
|
|
15
|
+
// crashed between reservation and write frees its slot after RESERVATION_MS.
|
|
16
|
+
// - `workspace-idempotency`: Idempotency-Key → {workspaceID, digest, at}, created with ifVersion 0 (the claim),
|
|
17
|
+
// valid IDEMPOTENCY_TTL_MS and pruned when expired.
|
|
18
|
+
//
|
|
19
|
+
// Records from other builds: a v1 record missing optional fields reads with defaults (state active, updatedAt =
|
|
20
|
+
// createdAt, revision = stored version). A record with a newer `v` is never guessed at: lists skip it and reads
|
|
21
|
+
// and writes answer 422 unsupported, leaving it untouched. A malformed record is skipped by lists and a 500 on read.
|
|
22
|
+
// Rollback: hubs before HWA-03 never read these collections (separate files / hub_state rows), so rolling back
|
|
23
|
+
// leaves them dormant and rolling forward picks them up again.
|
|
24
|
+
//
|
|
25
|
+
// Durability: writes go through the StateStore; the standalone file store debounces (FILE_STATE_DEBOUNCE_MS) and
|
|
26
|
+
// flushes on stop. Callers that acknowledge durability (HWA-11 checkpoints) call `flush()` before answering.
|
|
27
|
+
// Membership, masters and supervision grant nothing: this store holds records, never authority. Native session
|
|
28
|
+
// ancestry (parentID/rootID), UserRecord.agent and the console's inferred groups are not touched.
|
|
29
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
30
|
+
import {
|
|
31
|
+
CONTRACT_VERSION,
|
|
32
|
+
DESCRIPTION_MAX,
|
|
33
|
+
type HostRepoBinding,
|
|
34
|
+
type HubWorkspace,
|
|
35
|
+
ID_PREFIXES,
|
|
36
|
+
IDEMPOTENCY_KEY,
|
|
37
|
+
IDEMPOTENCY_TTL_MS,
|
|
38
|
+
type IdKind,
|
|
39
|
+
isAbsolutePath,
|
|
40
|
+
isContractID,
|
|
41
|
+
isExpectedRevision,
|
|
42
|
+
isLabel,
|
|
43
|
+
isText,
|
|
44
|
+
LIST_LIMIT_DEFAULT,
|
|
45
|
+
LIST_LIMIT_MAX,
|
|
46
|
+
metadataProblem,
|
|
47
|
+
type Page,
|
|
48
|
+
RECORD_STATES,
|
|
49
|
+
type RecordState,
|
|
50
|
+
tagsProblem,
|
|
51
|
+
WORKSPACE_LIMITS,
|
|
52
|
+
workspaceInputProblem,
|
|
53
|
+
} from "../../protocol"
|
|
54
|
+
import { AgentError } from "../agent/errors"
|
|
55
|
+
import { updateVersioned } from "../agent/versioned"
|
|
56
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
57
|
+
|
|
58
|
+
export const WORKSPACES = "workspaces"
|
|
59
|
+
export const WORKSPACE_BINDINGS = "workspace-bindings"
|
|
60
|
+
export const WORKSPACE_INDEX = "workspace-index"
|
|
61
|
+
export const WORKSPACE_IDEMPOTENCY = "workspace-idempotency"
|
|
62
|
+
/** The workspace-index key. */
|
|
63
|
+
const ACTIVE = "active"
|
|
64
|
+
/** How long a reservation (index slot, idempotency key) without its record blocks, before it counts as crashed. */
|
|
65
|
+
export const RESERVATION_MS = 60_000
|
|
66
|
+
/** Machine IDs as the hub mints them (`mch_…`), bounded. */
|
|
67
|
+
export const MACHINE_ID = /^[A-Za-z0-9_.:-]{1,128}$/
|
|
68
|
+
/** Version-checked write attempts before a contended change gives up. */
|
|
69
|
+
const ATTEMPTS = 16
|
|
70
|
+
/** Reads of an idempotency key's workspace: its creator may be between the claim and the write. */
|
|
71
|
+
const READS = 5
|
|
72
|
+
|
|
73
|
+
export type WorkspaceStoreLimits = { readonly [K in "workspacesPerUser" | "bindingsPerWorkspace"]: number }
|
|
74
|
+
|
|
75
|
+
/** Says whether the user owns the (unrevoked) machine; routes wire it to the hub's machine records. */
|
|
76
|
+
export type MachineOwnership = (userID: string, machineID: string) => boolean | Promise<boolean>
|
|
77
|
+
|
|
78
|
+
export interface WorkspaceStoreDeps {
|
|
79
|
+
readonly state: StateStore
|
|
80
|
+
/** Required: a binding names a machine of the binding user only. */
|
|
81
|
+
readonly ownsMachine: MachineOwnership
|
|
82
|
+
readonly now?: Clock
|
|
83
|
+
readonly newID?: (kind: IdKind) => string
|
|
84
|
+
/** Lower caps for tests; defaults are WORKSPACE_LIMITS. */
|
|
85
|
+
readonly limits?: Partial<WorkspaceStoreLimits>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface WorkspaceListQuery {
|
|
89
|
+
/** Only this state (default: both). */
|
|
90
|
+
readonly state?: string
|
|
91
|
+
readonly limit?: number
|
|
92
|
+
readonly cursor?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The fields PATCH /workspaces/:id may change; null clears an optional one. */
|
|
96
|
+
export interface WorkspacePatch {
|
|
97
|
+
readonly title?: string
|
|
98
|
+
readonly description?: string | null
|
|
99
|
+
readonly tags?: readonly string[] | null
|
|
100
|
+
readonly metadata?: Readonly<Record<string, string>> | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MachineBinding {
|
|
104
|
+
readonly workspaceID: string
|
|
105
|
+
readonly binding: HostRepoBinding
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface IndexEntry {
|
|
109
|
+
readonly id: string
|
|
110
|
+
readonly at: number
|
|
111
|
+
}
|
|
112
|
+
interface IndexDoc {
|
|
113
|
+
readonly v: 1
|
|
114
|
+
readonly entries: readonly IndexEntry[]
|
|
115
|
+
}
|
|
116
|
+
interface IdempotencyDoc {
|
|
117
|
+
readonly v: 1
|
|
118
|
+
readonly workspaceID: string
|
|
119
|
+
readonly digest: string
|
|
120
|
+
readonly at: number
|
|
121
|
+
}
|
|
122
|
+
interface BindingsDoc {
|
|
123
|
+
readonly v: 1
|
|
124
|
+
readonly workspaceID: string
|
|
125
|
+
readonly bindings: readonly HostRepoBinding[]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type Readout = { readonly ok: true; readonly workspace: HubWorkspace } | { readonly ok: false; readonly why: "newer" | "malformed"; readonly v?: unknown }
|
|
129
|
+
|
|
130
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
131
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
132
|
+
const PATCH_KEYS = new Set(["title", "description", "tags", "metadata"])
|
|
133
|
+
|
|
134
|
+
const conflict = (what: string, currentRevision: number) =>
|
|
135
|
+
new AgentError("conflict", `${what} changed since you read it (revision ${currentRevision} now)`, {
|
|
136
|
+
hint: "Read it again and retry with its current revision",
|
|
137
|
+
extra: { currentRevision },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/** A stored workspace as this build reads it (defaults for older v1 records; newer versions are refused). */
|
|
141
|
+
export function readWorkspace(key: string, raw: unknown, version: number): Readout {
|
|
142
|
+
if (!isObj(raw)) return { ok: false, why: "malformed" }
|
|
143
|
+
const v = raw.v ?? CONTRACT_VERSION
|
|
144
|
+
if (isNum(v) && v > CONTRACT_VERSION) return { ok: false, why: "newer", v }
|
|
145
|
+
if (v !== CONTRACT_VERSION) return { ok: false, why: "malformed" }
|
|
146
|
+
const { id, title, description, tags, metadata, masterMemberID } = raw
|
|
147
|
+
const state = raw.state ?? "active"
|
|
148
|
+
const createdAt = raw.createdAt ?? 0
|
|
149
|
+
const updatedAt = raw.updatedAt ?? createdAt
|
|
150
|
+
const valid =
|
|
151
|
+
id === key &&
|
|
152
|
+
typeof title === "string" &&
|
|
153
|
+
(description === undefined || typeof description === "string") &&
|
|
154
|
+
(tags === undefined || (Array.isArray(tags) && tags.every((t) => typeof t === "string"))) &&
|
|
155
|
+
(metadata === undefined || (isObj(metadata) && Object.values(metadata).every((m) => typeof m === "string"))) &&
|
|
156
|
+
(masterMemberID === undefined || typeof masterMemberID === "string") &&
|
|
157
|
+
RECORD_STATES.includes(state as RecordState) &&
|
|
158
|
+
isNum(createdAt) &&
|
|
159
|
+
isNum(updatedAt)
|
|
160
|
+
if (!valid) return { ok: false, why: "malformed" }
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
workspace: {
|
|
164
|
+
v: 1,
|
|
165
|
+
id: id as string,
|
|
166
|
+
title: title as string,
|
|
167
|
+
...(description !== undefined ? { description: description as string } : {}),
|
|
168
|
+
state: state as RecordState,
|
|
169
|
+
...(tags !== undefined ? { tags: tags as string[] } : {}),
|
|
170
|
+
...(metadata !== undefined ? { metadata: metadata as Record<string, string> } : {}),
|
|
171
|
+
...(masterMemberID !== undefined ? { masterMemberID: masterMemberID as string } : {}),
|
|
172
|
+
revision: version,
|
|
173
|
+
createdAt: createdAt as number,
|
|
174
|
+
updatedAt: updatedAt as number,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The create body, validated by the contract and reduced to the fields a client may set. */
|
|
180
|
+
function createBody(input: unknown): Pick<HubWorkspace, "title" | "description" | "tags" | "metadata"> {
|
|
181
|
+
const problem = workspaceInputProblem(input)
|
|
182
|
+
if (problem) throw new AgentError("invalid", problem)
|
|
183
|
+
const body = input as Record<string, unknown>
|
|
184
|
+
return {
|
|
185
|
+
title: body.title as string,
|
|
186
|
+
...(body.description !== undefined ? { description: body.description as string } : {}),
|
|
187
|
+
...(body.tags !== undefined ? { tags: [...(body.tags as string[])] } : {}),
|
|
188
|
+
...(body.metadata !== undefined ? { metadata: { ...(body.metadata as Record<string, string>) } } : {}),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function patchBody(input: unknown): WorkspacePatch {
|
|
193
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
194
|
+
const keys = Object.keys(input)
|
|
195
|
+
const unknown = keys.find((k) => !PATCH_KEYS.has(k))
|
|
196
|
+
if (unknown !== undefined)
|
|
197
|
+
throw new AgentError("invalid", `${JSON.stringify(unknown.slice(0, 50))} cannot be changed here`, { hint: "Patch title, description, tags or metadata; archive and restore have their own routes" })
|
|
198
|
+
if (keys.length === 0) throw new AgentError("invalid", "Nothing to change")
|
|
199
|
+
if (input.title !== undefined && !isLabel(input.title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
|
|
200
|
+
if (input.description !== undefined && input.description !== null && !isText(input.description, DESCRIPTION_MAX))
|
|
201
|
+
throw new AgentError("invalid", `description must be at most ${DESCRIPTION_MAX} characters`)
|
|
202
|
+
const problem = (input.tags === null ? undefined : tagsProblem(input.tags)) ?? (input.metadata === null ? undefined : metadataProblem(input.metadata))
|
|
203
|
+
if (problem) throw new AgentError("invalid", problem)
|
|
204
|
+
return input as WorkspacePatch
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkExpected(expectedRevision: number | undefined): void {
|
|
208
|
+
if (expectedRevision !== undefined && !isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Canonical JSON (sorted keys) for comparing request bodies. */
|
|
212
|
+
function canonical(v: unknown): string {
|
|
213
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`
|
|
214
|
+
if (isObj(v))
|
|
215
|
+
return `{${Object.keys(v)
|
|
216
|
+
.sort()
|
|
217
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
|
|
218
|
+
.join(",")}}`
|
|
219
|
+
return JSON.stringify(v)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** sha256 of the canonical JSON of a request body (idempotency comparisons). */
|
|
223
|
+
export const digestOf = (body: unknown) => createHash("sha256").update(canonical(body)).digest("hex")
|
|
224
|
+
|
|
225
|
+
/** An absolute repo path without its trailing slash (the root stays "/"). */
|
|
226
|
+
const canonicalRepo = (repo: string) => (repo.length > 1 ? repo.replace(/\/$/, "") : repo)
|
|
227
|
+
|
|
228
|
+
const byCreation = (a: HubWorkspace, b: HubWorkspace) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
229
|
+
|
|
230
|
+
function encodeCursor(w: HubWorkspace): string {
|
|
231
|
+
return Buffer.from(JSON.stringify([w.createdAt, w.id])).toString("base64url")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function decodeCursor(cursor: string): [number, string] {
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown
|
|
237
|
+
if (Array.isArray(parsed) && parsed.length === 2 && isNum(parsed[0]) && typeof parsed[1] === "string") return [parsed[0], parsed[1]]
|
|
238
|
+
} catch {
|
|
239
|
+
// answered below
|
|
240
|
+
}
|
|
241
|
+
throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function bindingsDoc(raw: unknown, workspaceID: string): BindingsDoc | undefined {
|
|
245
|
+
if (raw === undefined) return undefined
|
|
246
|
+
if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION)
|
|
247
|
+
throw new AgentError("unsupported", "These bindings were written by a newer hub", { extra: { feature: "workspace-bindings" } })
|
|
248
|
+
if (!isObj(raw) || raw.v !== 1 || raw.workspaceID !== workspaceID || !Array.isArray(raw.bindings)) throw new AgentError("internal", "Stored workspace bindings are malformed")
|
|
249
|
+
return raw as unknown as BindingsDoc
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class WorkspaceStore {
|
|
253
|
+
readonly limits: WorkspaceStoreLimits
|
|
254
|
+
/** The StateStore under these records (route helpers keep their records next to them). */
|
|
255
|
+
readonly state: StateStore
|
|
256
|
+
private readonly now: Clock
|
|
257
|
+
private readonly newID: (kind: IdKind) => string
|
|
258
|
+
|
|
259
|
+
constructor(private readonly deps: WorkspaceStoreDeps) {
|
|
260
|
+
this.state = deps.state
|
|
261
|
+
this.now = deps.now ?? Date.now
|
|
262
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
263
|
+
this.limits = {
|
|
264
|
+
workspacesPerUser: deps.limits?.workspacesPerUser ?? WORKSPACE_LIMITS.workspacesPerUser,
|
|
265
|
+
bindingsPerWorkspace: deps.limits?.bindingsPerWorkspace ?? WORKSPACE_LIMITS.bindingsPerWorkspace,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- workspaces ----------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Creates an active workspace (rate_limited at the per-user cap). With an idempotency key, the same key and body
|
|
273
|
+
* within IDEMPOTENCY_TTL_MS return the original (created false); another body is a conflict.
|
|
274
|
+
*/
|
|
275
|
+
async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ workspace: HubWorkspace; created: boolean }> {
|
|
276
|
+
const body = createBody(input)
|
|
277
|
+
const key = options.idempotencyKey
|
|
278
|
+
if (key === undefined) return { workspace: await this.insert(userID, this.newID("workspace"), body), created: true }
|
|
279
|
+
if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
280
|
+
const digest = digestOf(body)
|
|
281
|
+
await this.pruneKeys(userID)
|
|
282
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
283
|
+
const held = await this.state.get<IdempotencyDoc>(userID, WORKSPACE_IDEMPOTENCY, key)
|
|
284
|
+
const live = held && isObj(held.value) && isNum(held.value.at) && this.now() - held.value.at < IDEMPOTENCY_TTL_MS ? held.value : undefined
|
|
285
|
+
if (live) {
|
|
286
|
+
if (live.digest !== digest)
|
|
287
|
+
throw new AgentError("conflict", `Idempotency-Key ${key} was used for a different workspace`, { hint: "Use a new key for a different body, or resend the original body" })
|
|
288
|
+
const existing = await this.readSoon(userID, live.workspaceID)
|
|
289
|
+
if (existing) return { workspace: existing, created: false }
|
|
290
|
+
if (this.now() - live.at < RESERVATION_MS)
|
|
291
|
+
throw new AgentError("rate_limited", `The workspace for Idempotency-Key ${key} is still being created`, { extra: { retryAfter: 1 } })
|
|
292
|
+
// Its creator crashed before writing the workspace: take the key over.
|
|
293
|
+
}
|
|
294
|
+
const id = this.newID("workspace")
|
|
295
|
+
const doc: IdempotencyDoc = { v: 1, workspaceID: id, digest, at: this.now() }
|
|
296
|
+
const claimed = await this.state.put(userID, WORKSPACE_IDEMPOTENCY, key, doc, { ifVersion: held?.version ?? 0 })
|
|
297
|
+
if (!claimed.ok) continue
|
|
298
|
+
try {
|
|
299
|
+
return { workspace: await this.insert(userID, id, body), created: true }
|
|
300
|
+
} catch (error) {
|
|
301
|
+
await this.state.delete(userID, WORKSPACE_IDEMPOTENCY, key, { ifVersion: claimed.version })
|
|
302
|
+
throw error
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new Error(`workspace idempotency key ${key} kept conflicting`)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The user's workspace, or undefined (also for another user's); throws for records this build cannot read. */
|
|
309
|
+
async get(userID: string, id: string): Promise<HubWorkspace | undefined> {
|
|
310
|
+
const stored = await this.state.get(userID, WORKSPACES, id)
|
|
311
|
+
if (!stored) return undefined
|
|
312
|
+
return this.usable(id, readWorkspace(id, stored.value, stored.version))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The user's workspace, or not_found. */
|
|
316
|
+
async require(userID: string, id: string): Promise<HubWorkspace> {
|
|
317
|
+
const workspace = await this.get(userID, id)
|
|
318
|
+
if (!workspace) throw new AgentError("not_found", `No workspace ${id}`)
|
|
319
|
+
return workspace
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The user's readable workspaces by creation, paged (records this build cannot read are skipped). */
|
|
323
|
+
async list(userID: string, query: WorkspaceListQuery = {}): Promise<Page<HubWorkspace>> {
|
|
324
|
+
const limit = query.limit ?? LIST_LIMIT_DEFAULT
|
|
325
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
326
|
+
if (query.state !== undefined && !RECORD_STATES.includes(query.state as RecordState)) throw new AgentError("invalid", `state must be one of ${RECORD_STATES.join(", ")}`)
|
|
327
|
+
const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor)
|
|
328
|
+
const all = (await this.readAll(userID))
|
|
329
|
+
.filter((w) => query.state === undefined || w.state === query.state)
|
|
330
|
+
.filter((w) => !after || w.createdAt > after[0] || (w.createdAt === after[0] && w.id > after[1]))
|
|
331
|
+
.sort(byCreation)
|
|
332
|
+
const items = all.slice(0, limit)
|
|
333
|
+
return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!) } : { items }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Changes title, description, tags or metadata of an active workspace under its expected revision. */
|
|
337
|
+
async update(userID: string, id: string, patch: unknown, expectedRevision: number): Promise<HubWorkspace> {
|
|
338
|
+
if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
|
|
339
|
+
const change = patchBody(patch)
|
|
340
|
+
return this.mutate(userID, id, expectedRevision, (current) => {
|
|
341
|
+
if (current.state === "archived") throw new AgentError("invalid", `Workspace ${id} is archived`, { hint: "Restore it first" })
|
|
342
|
+
const next: Record<string, unknown> = { ...current }
|
|
343
|
+
for (const [field, value] of Object.entries(change)) {
|
|
344
|
+
if (value === null) delete next[field]
|
|
345
|
+
else next[field] = value
|
|
346
|
+
}
|
|
347
|
+
return next as unknown as HubWorkspace
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Archives the workspace (reversible; its bindings and records stay). Archiving an archived one is a no-op. */
|
|
352
|
+
async archive(userID: string, id: string, expectedRevision?: number): Promise<HubWorkspace> {
|
|
353
|
+
checkExpected(expectedRevision)
|
|
354
|
+
const archived = await this.mutate(userID, id, expectedRevision, (current) => (current.state === "archived" ? undefined : { ...current, state: "archived" }))
|
|
355
|
+
await this.unreserve(userID, id)
|
|
356
|
+
return archived
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Makes an archived workspace active again (rate_limited at the cap). Restoring an active one is a no-op. */
|
|
360
|
+
async restore(userID: string, id: string, expectedRevision?: number): Promise<HubWorkspace> {
|
|
361
|
+
checkExpected(expectedRevision)
|
|
362
|
+
const current = await this.require(userID, id)
|
|
363
|
+
if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Workspace ${id}`, current.revision)
|
|
364
|
+
if (current.state === "active") return current
|
|
365
|
+
await this.reserve(userID, id)
|
|
366
|
+
try {
|
|
367
|
+
return await this.mutate(userID, id, expectedRevision, (w) => (w.state === "active" ? undefined : { ...w, state: "active" }))
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await this.unreserve(userID, id)
|
|
370
|
+
throw error
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Records (or clears) the current master membership; HWA-04/12 own the membership semantics. */
|
|
375
|
+
async setMaster(userID: string, id: string, masterMemberID: string | undefined, expectedRevision?: number): Promise<HubWorkspace> {
|
|
376
|
+
checkExpected(expectedRevision)
|
|
377
|
+
if (masterMemberID !== undefined && !isContractID("member", masterMemberID)) throw new AgentError("invalid", "masterMemberID must be a workspace member ID")
|
|
378
|
+
return this.mutate(userID, id, expectedRevision, (current) => {
|
|
379
|
+
if (current.masterMemberID === masterMemberID) return undefined
|
|
380
|
+
const { masterMemberID: _old, ...rest } = current
|
|
381
|
+
return masterMemberID === undefined ? rest : { ...rest, masterMemberID }
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Makes buffered writes durable (standalone debounce); a no-op for write-through stores. */
|
|
386
|
+
async flush(): Promise<void> {
|
|
387
|
+
await this.state.flush?.()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------- host repository bindings ----------
|
|
391
|
+
|
|
392
|
+
/** The workspace's bindings (not_found for a missing or another user's workspace). */
|
|
393
|
+
async bindings(userID: string, workspaceID: string): Promise<HostRepoBinding[]> {
|
|
394
|
+
await this.require(userID, workspaceID)
|
|
395
|
+
const doc = bindingsDoc((await this.state.get(userID, WORKSPACE_BINDINGS, workspaceID))?.value, workspaceID)
|
|
396
|
+
return [...(doc?.bindings ?? [])]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Binds the workspace to a repository on one of the user's machines. The same machine + canonical repo again
|
|
401
|
+
* returns the existing binding (created false); with another label it is a conflict.
|
|
402
|
+
*/
|
|
403
|
+
async bind(userID: string, workspaceID: string, input: unknown): Promise<{ binding: HostRepoBinding; created: boolean }> {
|
|
404
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
405
|
+
const { machineID, repo: rawRepo, label } = input
|
|
406
|
+
if (typeof machineID !== "string" || !MACHINE_ID.test(machineID)) throw new AgentError("invalid", "machineID must be a machine ID")
|
|
407
|
+
if (!isAbsolutePath(rawRepo)) throw new AgentError("invalid", "repo must be an absolute path without . or .. segments")
|
|
408
|
+
if (label !== undefined && !isLabel(label)) throw new AgentError("invalid", "label must be 1–120 characters of text")
|
|
409
|
+
const repo = canonicalRepo(rawRepo)
|
|
410
|
+
const workspace = await this.require(userID, workspaceID)
|
|
411
|
+
if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
|
|
412
|
+
if (!(await this.deps.ownsMachine(userID, machineID))) throw new AgentError("not_found", `No machine ${machineID}`)
|
|
413
|
+
let result: { binding: HostRepoBinding; created: boolean } | undefined
|
|
414
|
+
await updateVersioned<BindingsDoc>(this.state, userID, WORKSPACE_BINDINGS, workspaceID, (raw) => {
|
|
415
|
+
const list = bindingsDoc(raw, workspaceID)?.bindings ?? []
|
|
416
|
+
const same = list.find((b) => b.machineID === machineID && b.repo === repo)
|
|
417
|
+
if (same) {
|
|
418
|
+
if (same.label !== label) throw new AgentError("conflict", `${repo} on ${machineID} is already bound with another label`, { extra: { currentRevision: same.revision } })
|
|
419
|
+
result = { binding: same, created: false }
|
|
420
|
+
return undefined
|
|
421
|
+
}
|
|
422
|
+
if (list.length >= this.limits.bindingsPerWorkspace)
|
|
423
|
+
throw new AgentError("rate_limited", `A workspace has at most ${this.limits.bindingsPerWorkspace} repository bindings`, { hint: "Remove a binding first" })
|
|
424
|
+
const binding: HostRepoBinding = { v: 1, id: this.newID("binding"), workspaceID, machineID, repo, ...(label !== undefined ? { label } : {}), revision: 1 }
|
|
425
|
+
result = { binding, created: true }
|
|
426
|
+
return { v: 1, workspaceID, bindings: [...list, binding] }
|
|
427
|
+
})
|
|
428
|
+
return result!
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Removes a binding (optionally only at the expected revision); not_found when it does not exist. */
|
|
432
|
+
async unbind(userID: string, workspaceID: string, bindingID: string, expectedRevision?: number): Promise<HostRepoBinding> {
|
|
433
|
+
checkExpected(expectedRevision)
|
|
434
|
+
await this.require(userID, workspaceID)
|
|
435
|
+
let removed: HostRepoBinding | undefined
|
|
436
|
+
await updateVersioned<BindingsDoc>(this.state, userID, WORKSPACE_BINDINGS, workspaceID, (raw) => {
|
|
437
|
+
const list = bindingsDoc(raw, workspaceID)?.bindings ?? []
|
|
438
|
+
const binding = list.find((b) => b.id === bindingID)
|
|
439
|
+
if (!binding) throw new AgentError("not_found", `No binding ${bindingID} in workspace ${workspaceID}`)
|
|
440
|
+
if (expectedRevision !== undefined && binding.revision !== expectedRevision) throw conflict(`Binding ${bindingID}`, binding.revision)
|
|
441
|
+
removed = binding
|
|
442
|
+
return { v: 1, workspaceID, bindings: list.filter((b) => b.id !== bindingID) }
|
|
443
|
+
})
|
|
444
|
+
return removed!
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Every binding of the user on one machine, with its workspace (for placement and offline reporting). */
|
|
448
|
+
async bindingsForMachine(userID: string, machineID: string): Promise<MachineBinding[]> {
|
|
449
|
+
const docs = await this.state.list(userID, WORKSPACE_BINDINGS)
|
|
450
|
+
const out: MachineBinding[] = []
|
|
451
|
+
for (const entry of docs) {
|
|
452
|
+
let doc: BindingsDoc | undefined
|
|
453
|
+
try {
|
|
454
|
+
doc = bindingsDoc(entry.value, entry.key)
|
|
455
|
+
} catch {
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
for (const binding of doc?.bindings ?? []) if (binding.machineID === machineID) out.push({ workspaceID: entry.key, binding })
|
|
459
|
+
}
|
|
460
|
+
return out
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------- internals ----------
|
|
464
|
+
|
|
465
|
+
private usable(id: string, read: Readout): HubWorkspace {
|
|
466
|
+
if (read.ok) return read.workspace
|
|
467
|
+
if (read.why === "newer")
|
|
468
|
+
throw new AgentError("unsupported", `Workspace ${id} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "workspaces", recordVersion: read.v } })
|
|
469
|
+
throw new AgentError("internal", `Stored workspace ${id} is malformed`)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async readAll(userID: string): Promise<HubWorkspace[]> {
|
|
473
|
+
const out: HubWorkspace[] = []
|
|
474
|
+
for (const entry of await this.state.list(userID, WORKSPACES)) {
|
|
475
|
+
const read = readWorkspace(entry.key, entry.value, entry.version)
|
|
476
|
+
if (read.ok) out.push(read.workspace)
|
|
477
|
+
}
|
|
478
|
+
return out
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A workspace a concurrent creator may be about to write: re-read a few times. */
|
|
482
|
+
private async readSoon(userID: string, id: string): Promise<HubWorkspace | undefined> {
|
|
483
|
+
for (let i = 0; i < READS; i++) {
|
|
484
|
+
const workspace = await this.get(userID, id)
|
|
485
|
+
if (workspace) return workspace
|
|
486
|
+
}
|
|
487
|
+
return undefined
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Version-checked change of a workspace; `change` returning undefined leaves it alone. */
|
|
491
|
+
private async mutate(userID: string, id: string, expectedRevision: number | undefined, change: (current: HubWorkspace) => HubWorkspace | undefined): Promise<HubWorkspace> {
|
|
492
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
493
|
+
const current = await this.require(userID, id)
|
|
494
|
+
if (expectedRevision !== undefined && current.revision !== expectedRevision) throw conflict(`Workspace ${id}`, current.revision)
|
|
495
|
+
const next = change(current)
|
|
496
|
+
if (!next) return current
|
|
497
|
+
const written: HubWorkspace = { ...next, v: 1, id, createdAt: current.createdAt, revision: current.revision + 1, updatedAt: this.now() }
|
|
498
|
+
const result = await this.state.put(userID, WORKSPACES, id, written, { ifVersion: current.revision })
|
|
499
|
+
if (result.ok) return { ...written, revision: result.version }
|
|
500
|
+
if (expectedRevision !== undefined) throw conflict(`Workspace ${id}`, result.version)
|
|
501
|
+
}
|
|
502
|
+
throw new Error(`workspace ${id} kept conflicting`)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private async insert(userID: string, id: string, body: Pick<HubWorkspace, "title" | "description" | "tags" | "metadata">): Promise<HubWorkspace> {
|
|
506
|
+
await this.reserve(userID, id)
|
|
507
|
+
const at = this.now()
|
|
508
|
+
const workspace: HubWorkspace = { v: 1, id, ...body, state: "active", revision: 1, createdAt: at, updatedAt: at }
|
|
509
|
+
const written = await this.state.put(userID, WORKSPACES, id, workspace, { ifVersion: 0 })
|
|
510
|
+
if (!written.ok) {
|
|
511
|
+
await this.unreserve(userID, id)
|
|
512
|
+
throw new Error(`workspace id ${id} already exists`)
|
|
513
|
+
}
|
|
514
|
+
return workspace
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Takes an active slot for `id` (rate_limited when the user is at the cap even after rebuilding the index). */
|
|
518
|
+
private async reserve(userID: string, id: string): Promise<void> {
|
|
519
|
+
const missing = (await this.state.get(userID, WORKSPACE_INDEX, ACTIVE)) === undefined
|
|
520
|
+
if (await this.tryReserve(userID, id, missing)) return
|
|
521
|
+
if (!missing && (await this.tryReserve(userID, id, true))) return
|
|
522
|
+
throw new AgentError("rate_limited", `A user has at most ${this.limits.workspacesPerUser} active workspaces`, { hint: "Archive a workspace first" })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async tryReserve(userID: string, id: string, rebuild: boolean): Promise<boolean> {
|
|
526
|
+
const records = rebuild ? await this.state.list(userID, WORKSPACES) : undefined
|
|
527
|
+
let reserved = false
|
|
528
|
+
await updateVersioned<IndexDoc>(this.state, userID, WORKSPACE_INDEX, ACTIVE, (raw) => {
|
|
529
|
+
const stored = isObj(raw) && Array.isArray(raw.entries) ? (raw.entries as IndexEntry[]) : []
|
|
530
|
+
const entries = records ? this.rebuilt(stored, records) : stored
|
|
531
|
+
reserved = entries.some((e) => e.id === id)
|
|
532
|
+
if (!reserved && entries.length < this.limits.workspacesPerUser) {
|
|
533
|
+
reserved = true
|
|
534
|
+
return { v: 1, entries: [...entries, { id, at: this.now() }] }
|
|
535
|
+
}
|
|
536
|
+
return records ? { v: 1, entries } : undefined
|
|
537
|
+
})
|
|
538
|
+
return reserved
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The index from the records: active (or unreadable-newer) ones count, plus fresh reservations. */
|
|
542
|
+
private rebuilt(stored: readonly IndexEntry[], records: readonly { key: string; value: unknown; version: number }[]): IndexEntry[] {
|
|
543
|
+
const live = new Map<string, number>()
|
|
544
|
+
for (const record of records) {
|
|
545
|
+
const read = readWorkspace(record.key, record.value, record.version)
|
|
546
|
+
if (read.ok ? read.workspace.state === "active" : read.why === "newer") live.set(record.key, read.ok ? read.workspace.createdAt : this.now())
|
|
547
|
+
}
|
|
548
|
+
const now = this.now()
|
|
549
|
+
const entries = stored.filter((e) => isObj(e) && typeof e.id === "string" && isNum(e.at) && (live.has(e.id) || now - e.at < RESERVATION_MS))
|
|
550
|
+
const have = new Set(entries.map((e) => e.id))
|
|
551
|
+
for (const [id, at] of live) if (!have.has(id)) entries.push({ id, at })
|
|
552
|
+
return entries
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async unreserve(userID: string, id: string): Promise<void> {
|
|
556
|
+
await updateVersioned<IndexDoc>(this.state, userID, WORKSPACE_INDEX, ACTIVE, (raw) => {
|
|
557
|
+
if (!isObj(raw) || !Array.isArray(raw.entries)) return undefined
|
|
558
|
+
const entries = (raw.entries as IndexEntry[]).filter((e) => e.id !== id)
|
|
559
|
+
return entries.length === raw.entries.length ? undefined : { v: 1, entries }
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Drops expired idempotency keys. */
|
|
564
|
+
private async pruneKeys(userID: string): Promise<void> {
|
|
565
|
+
const now = this.now()
|
|
566
|
+
for (const entry of await this.state.list<IdempotencyDoc>(userID, WORKSPACE_IDEMPOTENCY)) {
|
|
567
|
+
const at = isObj(entry.value) && isNum(entry.value.at) ? entry.value.at : 0
|
|
568
|
+
if (now - at >= IDEMPOTENCY_TTL_MS) await this.state.delete(userID, WORKSPACE_IDEMPOTENCY, entry.key, { ifVersion: entry.version })
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|