@huynle/hub 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
// Persistent artifacts (HWA-20; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts):
|
|
2
|
+
// versioned per-user metadata and immutable revisions in the StateStore, bytes in a BlobStore. Not LaunchArtifacts
|
|
3
|
+
// (launch git metadata): these records have their own collections and lifecycle, so launch pruning and session or
|
|
4
|
+
// launch expiry never touch them.
|
|
5
|
+
//
|
|
6
|
+
// Storage, per user (the tenant key):
|
|
7
|
+
// - "artifacts" (ARTIFACTS): artifactID → { artifact, head, archivedAt?, deletingAt? }. `head` is the number of
|
|
8
|
+
// the latest revision; `artifact.revision` is the record's CAS counter (every change, including a new revision).
|
|
9
|
+
// - "artifact-revisions.<artifactID>": zero-padded revision number → ArtifactRevision (immutable). Only numbers up
|
|
10
|
+
// to `head` are visible; a higher one is an upload that has not committed (or never will).
|
|
11
|
+
// - "artifact-blob-refs" (BLOB_REFS): hash → { holders }. A holder is an artifact ID (a published revision of it
|
|
12
|
+
// uses the blob) or `<artifactID>/<revisionID>` (an upload in flight). A blob without holders is unreferenced.
|
|
13
|
+
// - "artifact-uploads" (UPLOADS): revisionID → the upload in flight (phase storing → publishing).
|
|
14
|
+
//
|
|
15
|
+
// Commit before publish. A new revision (create or revise) is published in this order:
|
|
16
|
+
// 1. record the upload; 2. store each file's bytes (the BlobStore verifies size, quota and hash) and add the
|
|
17
|
+
// upload's holder to its blob; 3. touch every blob (fails if one vanished); 4. claim the publishing phase (CAS on
|
|
18
|
+
// the upload); 5. write the revision record (create-only); 6. CAS the artifact to point at it: the only step that
|
|
19
|
+
// makes the revision visible; 7. turn the upload's holders into artifact holders and drop the upload record.
|
|
20
|
+
// Every stored file of a bundle records progress on the upload, so the grace period below is an inactivity limit.
|
|
21
|
+
// A failure before 6 that is known not to have committed cleans up at once. A failure whose outcome is unknown (a
|
|
22
|
+
// lost reply on step 6) leaves everything for recover(), which after the grace period decides from the artifact
|
|
23
|
+
// record whether the revision was published, and finishes or undoes it.
|
|
24
|
+
//
|
|
25
|
+
// Deleting bytes. Removing a blob's last holder is followed by BlobStore.delete(hash, {touchedBefore: readTime -
|
|
26
|
+
// grace}). A writer always adds its holder before touching the blob, so a writer the deleter could not see has
|
|
27
|
+
// touched it later than that and keeps it; bytes that were stored but never referenced (a failed upload) are
|
|
28
|
+
// removed by recover() once untouched for the grace period. The grace period also covers clock skew between hub
|
|
29
|
+
// replicas.
|
|
30
|
+
//
|
|
31
|
+
// Lifecycle: archive is reversible and keeps everything; delete is irreversible (tombstone first, so a crash half
|
|
32
|
+
// way is finished by recover()); archived artifacts are purged only when `archivedRetentionMs` is set (default:
|
|
33
|
+
// kept until deleted).
|
|
34
|
+
import { randomBytes } from "node:crypto"
|
|
35
|
+
import {
|
|
36
|
+
ARTIFACT_LIMITS,
|
|
37
|
+
ARTIFACT_MEDIA_TYPES,
|
|
38
|
+
artifactInputProblem,
|
|
39
|
+
blobRefProblem,
|
|
40
|
+
ID_PREFIXES,
|
|
41
|
+
type IdKind,
|
|
42
|
+
isContractID,
|
|
43
|
+
isExpectedRevision,
|
|
44
|
+
LIST_LIMIT_DEFAULT,
|
|
45
|
+
LIST_LIMIT_MAX,
|
|
46
|
+
type Artifact,
|
|
47
|
+
type ArtifactKind,
|
|
48
|
+
type ArtifactRevision,
|
|
49
|
+
type ArtifactVisibility,
|
|
50
|
+
type BlobRef,
|
|
51
|
+
type Page,
|
|
52
|
+
type RecordState,
|
|
53
|
+
} from "../../protocol"
|
|
54
|
+
import { AgentError } from "../agent/errors"
|
|
55
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
56
|
+
import { type ArtifactAction, type ArtifactAuthorizer, type ArtifactPrincipal, type ArtifactWorkspaces, defaultArtifactPolicy } from "./access"
|
|
57
|
+
import { type BlobBody, BlobHashMismatchError, BlobInputError, BlobQuotaError, type BlobRead, type BlobStore, BlobTooLargeError } from "./blob-store"
|
|
58
|
+
|
|
59
|
+
export type { ArtifactPrincipal, ArtifactWorkspaces } from "./access"
|
|
60
|
+
|
|
61
|
+
export const ARTIFACTS = "artifacts"
|
|
62
|
+
export const BLOB_REFS = "artifact-blob-refs"
|
|
63
|
+
export const UPLOADS = "artifact-uploads"
|
|
64
|
+
export const revisionsCollection = (artifactID: string): string => `artifact-revisions.${artifactID}`
|
|
65
|
+
/** How long an upload may run, and an unreferenced blob stays, before recovery treats it as abandoned. */
|
|
66
|
+
export const ARTIFACT_GRACE_MS = 15 * 60_000
|
|
67
|
+
/** CAS attempts on the artifact record when unrelated changes race a commit. */
|
|
68
|
+
const ATTEMPTS = 16
|
|
69
|
+
|
|
70
|
+
export interface ArtifactLimits {
|
|
71
|
+
readonly artifactsPerUser: number
|
|
72
|
+
readonly revisionsPerArtifact: number
|
|
73
|
+
readonly blobBytes: number
|
|
74
|
+
readonly userBlobBytes: number
|
|
75
|
+
readonly bundleFiles: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compact event payload: IDs, kinds, revisions and counts only (never titles or content). */
|
|
79
|
+
export interface ArtifactEvent {
|
|
80
|
+
readonly type: "artifact.created" | "artifact.revised" | "artifact.archived" | "artifact.visibility"
|
|
81
|
+
readonly artifactID: string
|
|
82
|
+
readonly kind: ArtifactKind
|
|
83
|
+
readonly revision: number
|
|
84
|
+
readonly revisionID?: string
|
|
85
|
+
readonly number?: number
|
|
86
|
+
/** artifact.archived: the state it moved to (archive and unarchive share the event). */
|
|
87
|
+
readonly state?: RecordState
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ArtifactStoreOptions {
|
|
91
|
+
readonly state: StateStore
|
|
92
|
+
readonly blobs: BlobStore
|
|
93
|
+
readonly now?: Clock
|
|
94
|
+
/** Workspace facts; without them workspace visibility is `unsupported`. */
|
|
95
|
+
readonly workspaces?: ArtifactWorkspaces
|
|
96
|
+
/** Access policy (default: defaultArtifactPolicy). */
|
|
97
|
+
readonly authorize?: ArtifactAuthorizer
|
|
98
|
+
/** Tighter limits than ARTIFACT_LIMITS (never looser for blobBytes). */
|
|
99
|
+
readonly limits?: Partial<ArtifactLimits>
|
|
100
|
+
readonly graceMs?: number
|
|
101
|
+
/** Purge archived artifacts this long after archiving (default: never). */
|
|
102
|
+
readonly archivedRetentionMs?: number
|
|
103
|
+
readonly onEvent?: (userID: string, event: ArtifactEvent) => void
|
|
104
|
+
readonly newID?: (kind: IdKind) => string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A file of a new revision: new bytes, or a blob this user already stored (unchanged bundle files). */
|
|
108
|
+
export type RevisionFile =
|
|
109
|
+
| { readonly body: BlobBody; readonly mediaType: string; readonly path?: string; readonly expectedHash?: string }
|
|
110
|
+
| { readonly ref: BlobRef }
|
|
111
|
+
|
|
112
|
+
export interface RevisionInput {
|
|
113
|
+
readonly files: readonly RevisionFile[]
|
|
114
|
+
/** Entry file of a bundle: one of the files' paths. */
|
|
115
|
+
readonly entry?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ArtifactInput {
|
|
119
|
+
readonly title: string
|
|
120
|
+
readonly kind: string
|
|
121
|
+
readonly description?: string
|
|
122
|
+
readonly visibility?: ArtifactVisibility
|
|
123
|
+
readonly workspaceID?: string
|
|
124
|
+
readonly tags?: readonly string[]
|
|
125
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ArtifactUpdate {
|
|
129
|
+
readonly expectedRevision: number
|
|
130
|
+
readonly title?: string
|
|
131
|
+
readonly description?: string
|
|
132
|
+
readonly tags?: readonly string[]
|
|
133
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ArtifactListOptions {
|
|
137
|
+
readonly limit?: number
|
|
138
|
+
readonly cursor?: string
|
|
139
|
+
/** Default "active". */
|
|
140
|
+
readonly state?: RecordState
|
|
141
|
+
readonly workspaceID?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RecoveryReport {
|
|
145
|
+
readonly artifactsDeleted: number
|
|
146
|
+
readonly uploadsRecovered: number
|
|
147
|
+
readonly revisionsRemoved: number
|
|
148
|
+
readonly holdersRemoved: number
|
|
149
|
+
readonly blobsDeleted: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface StoredArtifact {
|
|
153
|
+
readonly artifact: Artifact
|
|
154
|
+
readonly head: number
|
|
155
|
+
readonly archivedAt?: number
|
|
156
|
+
readonly deletingAt?: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface Holder {
|
|
160
|
+
readonly id: string
|
|
161
|
+
readonly at: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface StoredRefs {
|
|
165
|
+
readonly holders: readonly Holder[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface Upload {
|
|
169
|
+
readonly artifactID: string
|
|
170
|
+
readonly revisionID: string
|
|
171
|
+
readonly number: number
|
|
172
|
+
readonly phase: "storing" | "publishing"
|
|
173
|
+
readonly startedAt: number
|
|
174
|
+
readonly phaseAt: number
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type Published = { readonly artifact: Artifact; readonly revision: ArtifactRevision }
|
|
178
|
+
|
|
179
|
+
const ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
180
|
+
const randomID = (kind: IdKind): string => `${ID_PREFIXES[kind]}_${[...randomBytes(24)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("")}`
|
|
181
|
+
const revisionKey = (number: number): string => String(number).padStart(6, "0")
|
|
182
|
+
const pendingHolder = (artifactID: string, revisionID: string): string => `${artifactID}/${revisionID}`
|
|
183
|
+
/** Any placeholder hash: request checks of a file's media type and path happen before its bytes are read. */
|
|
184
|
+
const NO_HASH = `sha256:${"0".repeat(64)}`
|
|
185
|
+
|
|
186
|
+
const notFound = (): AgentError => new AgentError("not_found", "No such artifact")
|
|
187
|
+
const forbidden = (): AgentError => new AgentError("forbidden", "Not allowed on this artifact")
|
|
188
|
+
const invalid = (message: string): AgentError => new AgentError("invalid", message)
|
|
189
|
+
const conflict = (message: string, stored: StoredArtifact | undefined): AgentError =>
|
|
190
|
+
new AgentError("conflict", message, {
|
|
191
|
+
extra: { currentRevision: stored?.artifact.revision ?? 0, ...(stored ? { latestRevisionID: stored.artifact.latestRevisionID } : {}) },
|
|
192
|
+
})
|
|
193
|
+
const capReached = (what: string, limit: number): AgentError => new AgentError("rate_limited", `${what} limit of ${limit} reached`, { extra: { limit } })
|
|
194
|
+
|
|
195
|
+
const encodeCursor = (value: unknown): string => Buffer.from(JSON.stringify(value), "utf8").toString("base64url")
|
|
196
|
+
function decodeCursor(cursor: string): unknown {
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))
|
|
199
|
+
} catch {
|
|
200
|
+
throw invalid("cursor is not valid")
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function pageLimit(limit: number | undefined): number {
|
|
204
|
+
const value = limit ?? LIST_LIMIT_DEFAULT
|
|
205
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > LIST_LIMIT_MAX) throw invalid(`limit must be 1–${LIST_LIMIT_MAX}`)
|
|
206
|
+
return value
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class ArtifactStore {
|
|
210
|
+
private readonly state: StateStore
|
|
211
|
+
private readonly blobs: BlobStore
|
|
212
|
+
private readonly now: Clock
|
|
213
|
+
private readonly workspaces: ArtifactWorkspaces | undefined
|
|
214
|
+
private readonly authorize: ArtifactAuthorizer
|
|
215
|
+
private readonly limits: ArtifactLimits
|
|
216
|
+
private readonly graceMs: number
|
|
217
|
+
private readonly archivedRetentionMs: number | undefined
|
|
218
|
+
private readonly onEvent: ((userID: string, event: ArtifactEvent) => void) | undefined
|
|
219
|
+
private readonly newID: (kind: IdKind) => string
|
|
220
|
+
|
|
221
|
+
constructor(options: ArtifactStoreOptions) {
|
|
222
|
+
this.state = options.state
|
|
223
|
+
this.blobs = options.blobs
|
|
224
|
+
this.now = options.now ?? Date.now
|
|
225
|
+
this.workspaces = options.workspaces
|
|
226
|
+
this.authorize = options.authorize ?? defaultArtifactPolicy
|
|
227
|
+
this.limits = { ...ARTIFACT_LIMITS, ...options.limits }
|
|
228
|
+
if (this.limits.blobBytes > ARTIFACT_LIMITS.blobBytes) throw new Error("limits.blobBytes exceeds ARTIFACT_LIMITS.blobBytes")
|
|
229
|
+
this.graceMs = options.graceMs ?? ARTIFACT_GRACE_MS
|
|
230
|
+
this.archivedRetentionMs = options.archivedRetentionMs
|
|
231
|
+
this.onEvent = options.onEvent
|
|
232
|
+
this.newID = options.newID ?? randomID
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- reads ----------------------------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async get(userID: string, principal: ArtifactPrincipal, id: string): Promise<Artifact> {
|
|
238
|
+
return (await this.load(userID, principal, id, "read")).stored.artifact
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The artifacts the principal may read, most recently updated first. */
|
|
242
|
+
async list(userID: string, principal: ArtifactPrincipal, options: ArtifactListOptions = {}): Promise<Page<Artifact>> {
|
|
243
|
+
const limit = pageLimit(options.limit)
|
|
244
|
+
let after: { updatedAt: number; id: string } | undefined
|
|
245
|
+
if (options.cursor !== undefined) {
|
|
246
|
+
const raw = decodeCursor(options.cursor)
|
|
247
|
+
if (!Array.isArray(raw) || typeof raw[0] !== "number" || typeof raw[1] !== "string") throw invalid("cursor is not valid")
|
|
248
|
+
after = { updatedAt: raw[0], id: raw[1] }
|
|
249
|
+
}
|
|
250
|
+
const state = options.state ?? "active"
|
|
251
|
+
const candidates = (await this.state.list<StoredArtifact>(userID, ARTIFACTS))
|
|
252
|
+
.map((entry) => entry.value)
|
|
253
|
+
.filter((s) => s.deletingAt === undefined && s.artifact.state === state)
|
|
254
|
+
.filter((s) => options.workspaceID === undefined || s.artifact.workspaceID === options.workspaceID)
|
|
255
|
+
.map((s) => s.artifact)
|
|
256
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
257
|
+
.filter((a) => !after || a.updatedAt < after.updatedAt || (a.updatedAt === after.updatedAt && a.id > after.id))
|
|
258
|
+
const items: Artifact[] = []
|
|
259
|
+
let more = false
|
|
260
|
+
for (const artifact of candidates) {
|
|
261
|
+
if (!(await this.allowed(userID, principal, "read", artifact))) continue
|
|
262
|
+
if (items.length === limit) {
|
|
263
|
+
more = true
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
items.push(artifact)
|
|
267
|
+
}
|
|
268
|
+
const last = items[items.length - 1]
|
|
269
|
+
return more && last ? { items, cursor: encodeCursor([last.updatedAt, last.id]) } : { items }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The visible revisions, newest first. */
|
|
273
|
+
async listRevisions(userID: string, principal: ArtifactPrincipal, id: string, options: { limit?: number; cursor?: string } = {}): Promise<Page<ArtifactRevision>> {
|
|
274
|
+
const limit = pageLimit(options.limit)
|
|
275
|
+
let before = Infinity
|
|
276
|
+
if (options.cursor !== undefined) {
|
|
277
|
+
const raw = decodeCursor(options.cursor)
|
|
278
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1) throw invalid("cursor is not valid")
|
|
279
|
+
before = raw
|
|
280
|
+
}
|
|
281
|
+
const { stored } = await this.load(userID, principal, id, "read")
|
|
282
|
+
const visible = (await this.visibleRevisions(userID, stored)).filter((r) => r.number < before)
|
|
283
|
+
const items = visible.slice(0, limit)
|
|
284
|
+
const last = items[items.length - 1]
|
|
285
|
+
return visible.length > limit && last ? { items, cursor: encodeCursor(last.number) } : { items }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async getRevision(userID: string, principal: ArtifactPrincipal, id: string, revisionID: string): Promise<ArtifactRevision> {
|
|
289
|
+
const { stored } = await this.load(userID, principal, id, "read")
|
|
290
|
+
if (!isContractID("revision", revisionID)) throw new AgentError("not_found", "No such revision")
|
|
291
|
+
const revision = (await this.visibleRevisions(userID, stored)).find((r) => r.id === revisionID)
|
|
292
|
+
if (!revision) throw new AgentError("not_found", "No such revision")
|
|
293
|
+
return revision
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** One blob of a visible revision, checked against that revision's references. */
|
|
297
|
+
async readBlob(userID: string, principal: ArtifactPrincipal, id: string, revisionID: string, hash: string): Promise<{ ref: BlobRef; blob: BlobRead }> {
|
|
298
|
+
const revision = await this.getRevision(userID, principal, id, revisionID)
|
|
299
|
+
const ref = revision.blobs.find((b) => b.hash === hash)
|
|
300
|
+
if (!ref) throw new AgentError("not_found", "No such blob in this revision")
|
|
301
|
+
const blob = await this.blobs.get(userID, hash)
|
|
302
|
+
// Bytes are stored before a revision is published and kept while it exists: a miss is a broken store.
|
|
303
|
+
if (!blob) throw new AgentError("internal", "The artifact's bytes are missing")
|
|
304
|
+
return { ref, blob }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---- revisions ------------------------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/** Creates an artifact with its first revision; the artifact appears only after the revision's bytes are stored. */
|
|
310
|
+
async create(userID: string, principal: ArtifactPrincipal, input: ArtifactInput, revision: RevisionInput): Promise<Published> {
|
|
311
|
+
const problem = artifactInputProblem(input)
|
|
312
|
+
if (problem) throw invalid(problem)
|
|
313
|
+
if (input.workspaceID !== undefined) await this.checkWorkspace(userID, input.workspaceID)
|
|
314
|
+
if (!(await this.allowed(userID, principal, "create"))) throw forbidden()
|
|
315
|
+
this.checkRevisionInput(revision)
|
|
316
|
+
const live = (await this.state.list<StoredArtifact>(userID, ARTIFACTS)).filter((e) => e.value.deletingAt === undefined)
|
|
317
|
+
if (live.length >= this.limits.artifactsPerUser) throw capReached("Artifact", this.limits.artifactsPerUser)
|
|
318
|
+
const artifactID = this.newID("artifact")
|
|
319
|
+
const published = await this.publish(userID, artifactID, 1, undefined, revision, authorOf(principal), async (rev) => {
|
|
320
|
+
const at = rev.createdAt
|
|
321
|
+
const artifact: Artifact = {
|
|
322
|
+
v: 1,
|
|
323
|
+
id: artifactID,
|
|
324
|
+
revision: 1,
|
|
325
|
+
title: input.title,
|
|
326
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
327
|
+
kind: input.kind as ArtifactKind,
|
|
328
|
+
state: "active",
|
|
329
|
+
visibility: input.visibility ?? "private",
|
|
330
|
+
...(input.workspaceID !== undefined ? { workspaceID: input.workspaceID } : {}),
|
|
331
|
+
...(principal.kind === "session" ? { createdBySessionID: principal.sessionID } : {}),
|
|
332
|
+
latestRevisionID: rev.id,
|
|
333
|
+
...(input.tags !== undefined ? { tags: [...input.tags] } : {}),
|
|
334
|
+
...(input.metadata !== undefined ? { metadata: { ...input.metadata } } : {}),
|
|
335
|
+
createdAt: at,
|
|
336
|
+
updatedAt: at,
|
|
337
|
+
}
|
|
338
|
+
const written = await this.state.put<StoredArtifact>(userID, ARTIFACTS, artifactID, { artifact, head: 1 }, { ifVersion: 0 })
|
|
339
|
+
if (!written.ok) throw conflict("Artifact ID collision; retry", undefined)
|
|
340
|
+
return artifact
|
|
341
|
+
})
|
|
342
|
+
this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1 })
|
|
343
|
+
return published
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Adds a revision on `baseRevisionID`, which must be the latest (else 409 conflict; there is no silent merge). */
|
|
347
|
+
async revise(
|
|
348
|
+
userID: string,
|
|
349
|
+
principal: ArtifactPrincipal,
|
|
350
|
+
id: string,
|
|
351
|
+
input: RevisionInput & { readonly baseRevisionID: string },
|
|
352
|
+
options: { readonly aiJobID?: string } = {},
|
|
353
|
+
): Promise<Published> {
|
|
354
|
+
const { stored } = await this.load(userID, principal, id, "revise")
|
|
355
|
+
if (stored.artifact.state === "archived") throw invalid("The artifact is archived; unarchive it first")
|
|
356
|
+
const base = input.baseRevisionID
|
|
357
|
+
if (stored.artifact.latestRevisionID !== base) throw conflict("baseRevisionID is not the latest revision", stored)
|
|
358
|
+
const number = stored.head + 1
|
|
359
|
+
if (number > this.limits.revisionsPerArtifact) throw capReached("Revision", this.limits.revisionsPerArtifact)
|
|
360
|
+
if (options.aiJobID !== undefined && !isContractID("aiJob", options.aiJobID)) throw invalid("aiJobID must be a job ID")
|
|
361
|
+
this.checkRevisionInput(input)
|
|
362
|
+
const author: ArtifactRevision["author"] = options.aiJobID !== undefined ? { kind: "ai_job", jobID: options.aiJobID } : authorOf(principal)
|
|
363
|
+
const published = await this.publish(userID, id, number, base, input, author, async (rev) => {
|
|
364
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
365
|
+
const current = await this.state.get<StoredArtifact>(userID, ARTIFACTS, id)
|
|
366
|
+
if (!current || current.value.deletingAt !== undefined) throw notFound()
|
|
367
|
+
if (current.value.artifact.state === "archived") throw invalid("The artifact is archived; unarchive it first")
|
|
368
|
+
if (current.value.artifact.latestRevisionID !== base) throw conflict("baseRevisionID is not the latest revision", current.value)
|
|
369
|
+
const artifact: Artifact = { ...current.value.artifact, latestRevisionID: rev.id, revision: current.value.artifact.revision + 1, updatedAt: rev.createdAt }
|
|
370
|
+
const written = await this.state.put<StoredArtifact>(userID, ARTIFACTS, id, { ...current.value, artifact, head: rev.number }, { ifVersion: current.version })
|
|
371
|
+
if (written.ok) return artifact
|
|
372
|
+
}
|
|
373
|
+
throw conflict("The artifact kept changing; retry", undefined)
|
|
374
|
+
})
|
|
375
|
+
this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number })
|
|
376
|
+
return published
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- metadata and lifecycle -----------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
async update(userID: string, principal: ArtifactPrincipal, id: string, input: ArtifactUpdate): Promise<Artifact> {
|
|
382
|
+
return this.mutate(userID, principal, id, input.expectedRevision, async (s) => {
|
|
383
|
+
const a = s.artifact
|
|
384
|
+
const next = {
|
|
385
|
+
title: input.title ?? a.title,
|
|
386
|
+
description: input.description ?? a.description,
|
|
387
|
+
kind: a.kind,
|
|
388
|
+
visibility: a.visibility,
|
|
389
|
+
workspaceID: a.workspaceID,
|
|
390
|
+
tags: input.tags ?? a.tags,
|
|
391
|
+
metadata: input.metadata ?? a.metadata,
|
|
392
|
+
}
|
|
393
|
+
const problem = artifactInputProblem(dropUndefined(next))
|
|
394
|
+
if (problem) throw invalid(problem)
|
|
395
|
+
return { ...s, artifact: dropUndefined({ ...a, ...next, tags: next.tags && [...next.tags], metadata: next.metadata && { ...next.metadata } }) as Artifact }
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async setVisibility(
|
|
400
|
+
userID: string,
|
|
401
|
+
principal: ArtifactPrincipal,
|
|
402
|
+
id: string,
|
|
403
|
+
input: { readonly expectedRevision: number; readonly visibility: ArtifactVisibility; readonly workspaceID?: string },
|
|
404
|
+
): Promise<Artifact> {
|
|
405
|
+
const artifact = await this.mutate(userID, principal, id, input.expectedRevision, async (s) => {
|
|
406
|
+
const workspaceID = input.workspaceID ?? s.artifact.workspaceID
|
|
407
|
+
const problem = artifactInputProblem(dropUndefined({ title: s.artifact.title, kind: s.artifact.kind, visibility: input.visibility, workspaceID }))
|
|
408
|
+
if (problem) throw invalid(problem)
|
|
409
|
+
// Making an artifact private never needs the workspace directory; sharing or moving it does.
|
|
410
|
+
if (workspaceID !== undefined && (input.visibility === "workspace" || workspaceID !== s.artifact.workspaceID)) await this.checkWorkspace(userID, workspaceID)
|
|
411
|
+
return { ...s, artifact: dropUndefined({ ...s.artifact, visibility: input.visibility, workspaceID }) as Artifact }
|
|
412
|
+
})
|
|
413
|
+
this.emit(userID, { type: "artifact.visibility", artifactID: id, kind: artifact.kind, revision: artifact.revision })
|
|
414
|
+
return artifact
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async archive(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<Artifact> {
|
|
418
|
+
return this.setState(userID, principal, id, expectedRevision, "archived")
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async unarchive(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<Artifact> {
|
|
422
|
+
return this.setState(userID, principal, id, expectedRevision, "active")
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Irreversible: removes the artifact, its revisions and its blob references (unreferenced bytes go too). */
|
|
426
|
+
async delete(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number): Promise<void> {
|
|
427
|
+
await this.mutate(userID, principal, id, expectedRevision, async (s) => ({ ...s, deletingAt: this.now() }))
|
|
428
|
+
await this.finishDelete(userID, id)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Purges archived artifacts older than archivedRetentionMs (none when unset); resolves with their IDs. */
|
|
432
|
+
async purgeArchived(userID: string): Promise<string[]> {
|
|
433
|
+
if (this.archivedRetentionMs === undefined) return []
|
|
434
|
+
const cutoff = this.now() - this.archivedRetentionMs
|
|
435
|
+
const purged: string[] = []
|
|
436
|
+
for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
|
|
437
|
+
const s = entry.value
|
|
438
|
+
if (s.deletingAt !== undefined || s.artifact.state !== "archived" || s.archivedAt === undefined || s.archivedAt > cutoff) continue
|
|
439
|
+
const marked = await this.state.put<StoredArtifact>(userID, ARTIFACTS, entry.key, { ...s, deletingAt: this.now() }, { ifVersion: entry.version })
|
|
440
|
+
if (!marked.ok) continue
|
|
441
|
+
await this.finishDelete(userID, entry.key)
|
|
442
|
+
purged.push(entry.key)
|
|
443
|
+
}
|
|
444
|
+
return purged
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---- recovery -------------------------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Settles what crashed or lost writers left behind, for one user: finishes deletes, decides stale uploads
|
|
451
|
+
* (published: keep and finalize; not: remove the record and holders), drops dangling holders, and removes
|
|
452
|
+
* blobs without holders that nobody touched for the grace period. Safe to run any time and concurrently.
|
|
453
|
+
*/
|
|
454
|
+
async recover(userID: string): Promise<RecoveryReport> {
|
|
455
|
+
let artifactsDeleted = 0
|
|
456
|
+
let uploadsRecovered = 0
|
|
457
|
+
let revisionsRemoved = 0
|
|
458
|
+
let holdersRemoved = 0
|
|
459
|
+
let blobsDeleted = 0
|
|
460
|
+
for (const entry of await this.state.list<StoredArtifact>(userID, ARTIFACTS)) {
|
|
461
|
+
if (entry.value.deletingAt === undefined) continue
|
|
462
|
+
await this.finishDelete(userID, entry.key)
|
|
463
|
+
artifactsDeleted++
|
|
464
|
+
}
|
|
465
|
+
const stale = this.now() - this.graceMs
|
|
466
|
+
for (const entry of await this.state.list<Upload>(userID, UPLOADS)) {
|
|
467
|
+
const upload = entry.value
|
|
468
|
+
if (upload.phaseAt > stale) continue
|
|
469
|
+
// Taking the record first: a writer still alive fails its next CAS on it instead of racing us.
|
|
470
|
+
if (!(await this.state.delete(userID, UPLOADS, entry.key, { ifVersion: entry.version })).ok) continue
|
|
471
|
+
uploadsRecovered++
|
|
472
|
+
const published = await this.isPublished(userID, upload)
|
|
473
|
+
const holder = pendingHolder(upload.artifactID, upload.revisionID)
|
|
474
|
+
for (const ref of await this.state.list<StoredRefs>(userID, BLOB_REFS)) {
|
|
475
|
+
if (!ref.value.holders.some((h) => h.id === holder)) continue
|
|
476
|
+
await this.changeHolders(userID, ref.key, (holders) => [...holders.filter((h) => h.id !== holder && h.id !== upload.artifactID), ...(published ? [{ id: upload.artifactID, at: this.now() }] : [])])
|
|
477
|
+
}
|
|
478
|
+
if (!published && (await this.removeRevisionRecord(userID, upload))) revisionsRemoved++
|
|
479
|
+
}
|
|
480
|
+
const uploads = new Set((await this.state.list<Upload>(userID, UPLOADS)).map((e) => e.key))
|
|
481
|
+
const artifacts = new Set((await this.state.list<StoredArtifact>(userID, ARTIFACTS)).map((e) => e.key))
|
|
482
|
+
for (const ref of await this.state.list<StoredRefs>(userID, BLOB_REFS)) {
|
|
483
|
+
// Only holders older than the grace period: a fresh one may belong to a write after our snapshots.
|
|
484
|
+
const dangling = (h: Holder): boolean => {
|
|
485
|
+
if (h.at > stale) return false
|
|
486
|
+
const slash = h.id.indexOf("/")
|
|
487
|
+
return slash < 0 ? !artifacts.has(h.id) : !uploads.has(h.id.slice(slash + 1))
|
|
488
|
+
}
|
|
489
|
+
if (!ref.value.holders.some(dangling)) continue
|
|
490
|
+
const before = ref.value.holders.length
|
|
491
|
+
const left = await this.changeHolders(userID, ref.key, (holders) => holders.filter((h) => !dangling(h)))
|
|
492
|
+
holdersRemoved += Math.max(0, before - left)
|
|
493
|
+
}
|
|
494
|
+
let cursor: string | undefined
|
|
495
|
+
do {
|
|
496
|
+
const page = await this.blobs.list(userID, { limit: LIST_LIMIT_MAX, ...(cursor !== undefined ? { cursor } : {}) })
|
|
497
|
+
for (const blob of page.items) if (await this.deleteIfUnreferenced(userID, blob.hash)) blobsDeleted++
|
|
498
|
+
cursor = page.cursor
|
|
499
|
+
} while (cursor !== undefined)
|
|
500
|
+
return { artifactsDeleted, uploadsRecovered, revisionsRemoved, holdersRemoved, blobsDeleted }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---- internals ------------------------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
/** The steps of "commit before publish" (see the header); `commit` is step 6 and the only publishing write. */
|
|
506
|
+
private async publish(
|
|
507
|
+
userID: string,
|
|
508
|
+
artifactID: string,
|
|
509
|
+
number: number,
|
|
510
|
+
baseRevisionID: string | undefined,
|
|
511
|
+
input: RevisionInput,
|
|
512
|
+
author: ArtifactRevision["author"],
|
|
513
|
+
commit: (revision: ArtifactRevision) => Promise<Artifact>,
|
|
514
|
+
): Promise<Published> {
|
|
515
|
+
const revisionID = this.newID("revision")
|
|
516
|
+
const startedAt = this.now()
|
|
517
|
+
const upload: Upload = { artifactID, revisionID, number, phase: "storing", startedAt, phaseAt: startedAt }
|
|
518
|
+
const recorded = await this.state.put<Upload>(userID, UPLOADS, revisionID, upload, { ifVersion: 0 })
|
|
519
|
+
if (!recorded.ok) throw conflict("Revision ID collision; retry", undefined)
|
|
520
|
+
let uploadVersion = recorded.version
|
|
521
|
+
/** Keeps the upload alive: the grace period counts from its latest progress, not its start. */
|
|
522
|
+
const progress = async (phase: Upload["phase"]): Promise<number> => {
|
|
523
|
+
const at = this.now()
|
|
524
|
+
const written = await this.state.put<Upload>(userID, UPLOADS, revisionID, { ...upload, phase, phaseAt: at }, { ifVersion: uploadVersion })
|
|
525
|
+
if (!written.ok) throw conflict("The upload expired; retry", undefined)
|
|
526
|
+
uploadVersion = written.version
|
|
527
|
+
return at
|
|
528
|
+
}
|
|
529
|
+
const holder = pendingHolder(artifactID, revisionID)
|
|
530
|
+
const held = new Set<string>()
|
|
531
|
+
/** Past step 6's write: its outcome may be unknown, so only recover() may undo anything. */
|
|
532
|
+
let committing = false
|
|
533
|
+
try {
|
|
534
|
+
const refs: BlobRef[] = []
|
|
535
|
+
for (const file of input.files) {
|
|
536
|
+
const ref = await this.storeFile(userID, file)
|
|
537
|
+
if (!held.has(ref.hash)) {
|
|
538
|
+
await this.changeHolders(userID, ref.hash, (holders) => [...holders.filter((h) => h.id !== holder), { id: holder, at: this.now() }])
|
|
539
|
+
held.add(ref.hash)
|
|
540
|
+
}
|
|
541
|
+
refs.push(ref)
|
|
542
|
+
if (input.files.length > 1) await progress("storing")
|
|
543
|
+
}
|
|
544
|
+
for (const hash of held) {
|
|
545
|
+
if (!(await this.blobs.touch(userID, hash))) throw conflict("A blob was removed during the upload; retry", undefined)
|
|
546
|
+
}
|
|
547
|
+
const publishingAt = await progress("publishing")
|
|
548
|
+
const revision: ArtifactRevision = {
|
|
549
|
+
v: 1,
|
|
550
|
+
id: revisionID,
|
|
551
|
+
artifactID,
|
|
552
|
+
number,
|
|
553
|
+
...(baseRevisionID !== undefined ? { baseRevisionID } : {}),
|
|
554
|
+
blobs: refs,
|
|
555
|
+
...(input.entry !== undefined ? { entry: input.entry } : {}),
|
|
556
|
+
author,
|
|
557
|
+
createdAt: publishingAt,
|
|
558
|
+
}
|
|
559
|
+
await this.writeRevision(userID, revision)
|
|
560
|
+
if (this.now() - publishingAt > this.graceMs / 2) throw conflict("The upload expired; retry", undefined)
|
|
561
|
+
committing = true
|
|
562
|
+
let artifact: Artifact
|
|
563
|
+
try {
|
|
564
|
+
artifact = await commit(revision)
|
|
565
|
+
} catch (error) {
|
|
566
|
+
// An AgentError is a decided refusal (nothing written); anything else may have committed.
|
|
567
|
+
if (error instanceof AgentError) committing = false
|
|
568
|
+
throw error
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
for (const hash of held) {
|
|
572
|
+
await this.changeHolders(userID, hash, (holders) => [...holders.filter((h) => h.id !== holder && h.id !== artifactID), { id: artifactID, at: this.now() }])
|
|
573
|
+
}
|
|
574
|
+
await this.state.delete(userID, UPLOADS, revisionID)
|
|
575
|
+
} catch {
|
|
576
|
+
// Published already; recover() finalizes the holders and the upload record after the grace period.
|
|
577
|
+
}
|
|
578
|
+
return { artifact, revision }
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (!committing) await this.abandon(userID, upload, held).catch(() => {})
|
|
581
|
+
throw error
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Undoes an upload known not to have committed: its record, its holders and its revision record. */
|
|
586
|
+
private async abandon(userID: string, upload: Upload, held: Iterable<string>): Promise<void> {
|
|
587
|
+
const holder = pendingHolder(upload.artifactID, upload.revisionID)
|
|
588
|
+
for (const hash of held) await this.changeHolders(userID, hash, (holders) => holders.filter((h) => h.id !== holder))
|
|
589
|
+
await this.removeRevisionRecord(userID, upload)
|
|
590
|
+
await this.state.delete(userID, UPLOADS, upload.revisionID)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Writes the revision record create-only; replaces a stale one left by a crashed writer, else 409 conflict. */
|
|
594
|
+
private async writeRevision(userID: string, revision: ArtifactRevision): Promise<void> {
|
|
595
|
+
const collection = revisionsCollection(revision.artifactID)
|
|
596
|
+
const key = revisionKey(revision.number)
|
|
597
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
598
|
+
if ((await this.state.put(userID, collection, key, revision, { ifVersion: 0 })).ok) return
|
|
599
|
+
const existing = await this.state.get<ArtifactRevision>(userID, collection, key)
|
|
600
|
+
if (!existing) continue
|
|
601
|
+
const stored = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, revision.artifactID))?.value
|
|
602
|
+
if (stored && stored.head >= revision.number) throw conflict("baseRevisionID is not the latest revision", stored)
|
|
603
|
+
const other = (await this.state.get<Upload>(userID, UPLOADS, existing.value.id))?.value
|
|
604
|
+
if (other && other.phaseAt > this.now() - this.graceMs) throw conflict("Another revision on this base is being published", stored)
|
|
605
|
+
await this.state.delete(userID, collection, key, { ifVersion: existing.version })
|
|
606
|
+
}
|
|
607
|
+
throw conflict("Another revision on this base is being published", undefined)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private async removeRevisionRecord(userID: string, upload: Upload): Promise<boolean> {
|
|
611
|
+
const collection = revisionsCollection(upload.artifactID)
|
|
612
|
+
const key = revisionKey(upload.number)
|
|
613
|
+
const existing = await this.state.get<ArtifactRevision>(userID, collection, key)
|
|
614
|
+
if (existing?.value.id !== upload.revisionID) return false
|
|
615
|
+
const stored = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, upload.artifactID))?.value
|
|
616
|
+
if (stored && stored.head >= upload.number && stored.deletingAt === undefined) return false
|
|
617
|
+
return (await this.state.delete(userID, collection, key, { ifVersion: existing.version })).ok
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private async isPublished(userID: string, upload: Upload): Promise<boolean> {
|
|
621
|
+
const stored = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, upload.artifactID))?.value
|
|
622
|
+
if (!stored || stored.deletingAt !== undefined || stored.head < upload.number) return false
|
|
623
|
+
const record = await this.state.get<ArtifactRevision>(userID, revisionsCollection(upload.artifactID), revisionKey(upload.number))
|
|
624
|
+
return record?.value.id === upload.revisionID
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Stores one file's bytes (or checks a reused blob) and answers its reference; BlobStore errors become agent errors. */
|
|
628
|
+
private async storeFile(userID: string, file: RevisionFile): Promise<BlobRef> {
|
|
629
|
+
if ("ref" in file) {
|
|
630
|
+
const info = await this.blobs.stat(userID, file.ref.hash)
|
|
631
|
+
if (!info || info.bytes !== file.ref.bytes) throw invalid(`blob ${file.ref.hash} is not stored`)
|
|
632
|
+
return { hash: file.ref.hash, bytes: file.ref.bytes, mediaType: file.ref.mediaType, ...(file.ref.path !== undefined ? { path: file.ref.path } : {}) }
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
const ref = await this.blobs.put(userID, file.body, file.mediaType, {
|
|
636
|
+
maxBytes: this.limits.blobBytes,
|
|
637
|
+
quotaBytes: this.limits.userBlobBytes,
|
|
638
|
+
...(file.expectedHash !== undefined ? { expectedHash: file.expectedHash } : {}),
|
|
639
|
+
})
|
|
640
|
+
return { ...ref, ...(file.path !== undefined ? { path: file.path } : {}) }
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (error instanceof BlobQuotaError) throw capReached("Blob storage", error.quotaBytes)
|
|
643
|
+
if (error instanceof BlobTooLargeError || error instanceof BlobHashMismatchError || error instanceof BlobInputError) throw invalid(error.message)
|
|
644
|
+
throw error
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Request checks before any bytes are read: counts, media types, paths, the entry. */
|
|
649
|
+
private checkRevisionInput(input: RevisionInput): void {
|
|
650
|
+
const files = input.files
|
|
651
|
+
if (!Array.isArray(files) || files.length < 1) throw invalid("a revision needs at least one file")
|
|
652
|
+
if (files.length > this.limits.bundleFiles) throw invalid(`a revision has at most ${this.limits.bundleFiles} files`)
|
|
653
|
+
const paths = new Set<string>()
|
|
654
|
+
for (const file of files) {
|
|
655
|
+
const ref = "ref" in file ? file.ref : { hash: file.expectedHash ?? NO_HASH, bytes: 0, mediaType: file.mediaType, ...(file.path !== undefined ? { path: file.path } : {}) }
|
|
656
|
+
const problem = blobRefProblem(ref)
|
|
657
|
+
if (problem) throw invalid(problem)
|
|
658
|
+
if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(ref.mediaType)) throw invalid("mediaType is not allowed")
|
|
659
|
+
if (ref.path !== undefined) {
|
|
660
|
+
if (paths.has(ref.path)) throw invalid(`path ${ref.path} appears twice`)
|
|
661
|
+
paths.add(ref.path)
|
|
662
|
+
} else if (files.length > 1) {
|
|
663
|
+
throw invalid("every file of a bundle needs a path")
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (input.entry !== undefined && !paths.has(input.entry)) throw invalid("entry must be the path of one of the files")
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Read-modify-write of the holders of a blob (deleting the record when none are left); resolves with how many are left. */
|
|
670
|
+
private async changeHolders(userID: string, hash: string, change: (holders: readonly Holder[]) => Holder[]): Promise<number> {
|
|
671
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
672
|
+
const current = await this.state.get<StoredRefs>(userID, BLOB_REFS, hash)
|
|
673
|
+
const holders = change(current?.value.holders ?? [])
|
|
674
|
+
if (holders.length === 0) {
|
|
675
|
+
if (!current) return 0
|
|
676
|
+
if ((await this.state.delete(userID, BLOB_REFS, hash, { ifVersion: current.version })).ok) return 0
|
|
677
|
+
} else if ((await this.state.put<StoredRefs>(userID, BLOB_REFS, hash, { holders }, { ifVersion: current?.version ?? 0 })).ok) {
|
|
678
|
+
return holders.length
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
throw new Error(`blob references of ${hash} kept conflicting`)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Deletes the bytes when nothing holds them and nobody touched them within the grace period. */
|
|
685
|
+
private async deleteIfUnreferenced(userID: string, hash: string): Promise<boolean> {
|
|
686
|
+
const readAt = this.now()
|
|
687
|
+
const refs = await this.state.get<StoredRefs>(userID, BLOB_REFS, hash)
|
|
688
|
+
if (refs && refs.value.holders.length > 0) return false
|
|
689
|
+
return this.blobs.delete(userID, hash, { touchedBefore: readAt - this.graceMs })
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The rest of a delete after the tombstone: holders, unreferenced bytes, revision records, the record. */
|
|
693
|
+
private async finishDelete(userID: string, artifactID: string): Promise<void> {
|
|
694
|
+
const collection = revisionsCollection(artifactID)
|
|
695
|
+
const revisions = await this.state.list<ArtifactRevision>(userID, collection)
|
|
696
|
+
const hashes = new Set(revisions.flatMap((r) => r.value.blobs.map((b) => b.hash)))
|
|
697
|
+
for (const hash of hashes) {
|
|
698
|
+
const left = await this.changeHolders(userID, hash, (holders) => holders.filter((h) => h.id !== artifactID && !h.id.startsWith(`${artifactID}/`)))
|
|
699
|
+
if (left === 0) await this.deleteIfUnreferenced(userID, hash)
|
|
700
|
+
}
|
|
701
|
+
for (const entry of revisions) await this.state.delete(userID, collection, entry.key)
|
|
702
|
+
await this.state.delete(userID, ARTIFACTS, artifactID)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private async setState(userID: string, principal: ArtifactPrincipal, id: string, expectedRevision: number, to: RecordState): Promise<Artifact> {
|
|
706
|
+
const artifact = await this.mutate(userID, principal, id, expectedRevision, async (s) => {
|
|
707
|
+
const { archivedAt: _, ...rest } = s
|
|
708
|
+
return { ...rest, ...(to === "archived" ? { archivedAt: this.now() } : {}), artifact: { ...s.artifact, state: to } }
|
|
709
|
+
})
|
|
710
|
+
this.emit(userID, { type: "artifact.archived", artifactID: id, kind: artifact.kind, revision: artifact.revision, state: to })
|
|
711
|
+
return artifact
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** A versioned change of the artifact record by a principal allowed to manage it. */
|
|
715
|
+
private async mutate(
|
|
716
|
+
userID: string,
|
|
717
|
+
principal: ArtifactPrincipal,
|
|
718
|
+
id: string,
|
|
719
|
+
expectedRevision: number,
|
|
720
|
+
change: (stored: StoredArtifact) => Promise<StoredArtifact>,
|
|
721
|
+
): Promise<Artifact> {
|
|
722
|
+
if (!isExpectedRevision(expectedRevision)) throw invalid("expectedRevision must be a non-negative integer")
|
|
723
|
+
const { stored, version } = await this.load(userID, principal, id, "manage")
|
|
724
|
+
if (stored.artifact.revision !== expectedRevision) throw conflict("expectedRevision is not the current revision", stored)
|
|
725
|
+
const changed = await change(stored)
|
|
726
|
+
const next: StoredArtifact = { ...changed, artifact: { ...changed.artifact, revision: stored.artifact.revision + 1, updatedAt: this.now() } }
|
|
727
|
+
const written = await this.state.put<StoredArtifact>(userID, ARTIFACTS, id, next, { ifVersion: version })
|
|
728
|
+
if (!written.ok) {
|
|
729
|
+
const current = (await this.state.get<StoredArtifact>(userID, ARTIFACTS, id))?.value
|
|
730
|
+
throw conflict("expectedRevision is not the current revision", current)
|
|
731
|
+
}
|
|
732
|
+
return next.artifact
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The record, if the principal may read it (else not_found) and do `action` (else forbidden). */
|
|
736
|
+
private async load(userID: string, principal: ArtifactPrincipal, id: string, action: ArtifactAction): Promise<{ stored: StoredArtifact; version: number }> {
|
|
737
|
+
if (!isContractID("artifact", id)) throw notFound()
|
|
738
|
+
const entry = await this.state.get<StoredArtifact>(userID, ARTIFACTS, id)
|
|
739
|
+
if (!entry || entry.value.deletingAt !== undefined) throw notFound()
|
|
740
|
+
if (!(await this.allowed(userID, principal, "read", entry.value.artifact))) throw notFound()
|
|
741
|
+
if (action !== "read" && !(await this.allowed(userID, principal, action, entry.value.artifact))) throw forbidden()
|
|
742
|
+
return { stored: entry.value, version: entry.version }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private allowed(userID: string, principal: ArtifactPrincipal, action: ArtifactAction, artifact?: Artifact): Promise<boolean> {
|
|
746
|
+
return this.authorize({ userID, principal, action, ...(artifact ? { artifact } : {}), ...(this.workspaces ? { workspaces: this.workspaces } : {}) })
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private async visibleRevisions(userID: string, stored: StoredArtifact): Promise<ArtifactRevision[]> {
|
|
750
|
+
return (await this.state.list<ArtifactRevision>(userID, revisionsCollection(stored.artifact.id)))
|
|
751
|
+
.map((e) => e.value)
|
|
752
|
+
.filter((r) => r.number <= stored.head)
|
|
753
|
+
.sort((a, b) => b.number - a.number)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private async checkWorkspace(userID: string, workspaceID: string): Promise<void> {
|
|
757
|
+
if (!this.workspaces) throw new AgentError("unsupported", "Workspaces are not available on this hub", { extra: { feature: "workspaces" } })
|
|
758
|
+
if (!(await this.workspaces.exists(userID, workspaceID))) throw invalid("workspaceID is not a workspace of this user")
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private emit(userID: string, event: ArtifactEvent): void {
|
|
762
|
+
try {
|
|
763
|
+
this.onEvent?.(userID, event)
|
|
764
|
+
} catch {
|
|
765
|
+
// An event sink never fails the write that happened.
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function authorOf(principal: ArtifactPrincipal): ArtifactRevision["author"] {
|
|
771
|
+
return principal.kind === "session" ? { kind: "session", sessionID: principal.sessionID } : { kind: "user" }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function dropUndefined<T extends object>(value: T): T {
|
|
775
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
|
|
776
|
+
}
|