@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,316 @@
|
|
|
1
|
+
// Durable supervisor checkpoints (HWA-11; contract: Workspaces `POST /workspaces/:id/checkpoints {cursor}` in
|
|
2
|
+
// docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, SupervisorCheckpoint and
|
|
3
|
+
// WORKSPACE_LIMITS.checkpointsPerSupervisor in packages/protocol/src/hub-workspace.ts):
|
|
4
|
+
//
|
|
5
|
+
// POST /api/agent/workspaces/:id/checkpoints {cursor, supervisorMemberID?, batchID?} workspaces:manage
|
|
6
|
+
// GET /api/agent/workspaces/:id/checkpoints?supervisorMemberID= workspaces:read
|
|
7
|
+
//
|
|
8
|
+
// A checkpoint says "this supervisor has processed the workspace's events up to `cursor`" (an agent-events cursor,
|
|
9
|
+
// hev1_…, as GET /api/agent/events and the batched digests of coordinator.ts hand out). The supervisor defaults to
|
|
10
|
+
// the workspace's master. Each record also pins what the acknowledgment was made against: the supervisor's member
|
|
11
|
+
// generation and session, the workspace and supervision-graph revisions (the snapshot revision), the digest batch
|
|
12
|
+
// it acknowledges (batch identity, so an uncertain delivery can be resolved, see coordinator.ts) and the unresolved
|
|
13
|
+
// work under that supervisor (open / in_progress / blocked assignments of the members it supervises: IDs and a
|
|
14
|
+
// count only, never titles). HWA-12's recovery reads these to resume a replaced master.
|
|
15
|
+
//
|
|
16
|
+
// Who: users and app tokens by scope. Session tokens are refused (contract clarification HWA-06: membership grants
|
|
17
|
+
// nothing; a session reading or writing its own workspace through its token is an open question owned by the user).
|
|
18
|
+
//
|
|
19
|
+
// Durability: every acknowledgment is written (one version-checked aggregate per supervisor) and then the
|
|
20
|
+
// StateStore is flushed BEFORE the answer, so the standalone file store's debounce cannot lose an acknowledged
|
|
21
|
+
// checkpoint. A failed flush is an error to the caller (nothing is claimed); a repeat flushes again.
|
|
22
|
+
//
|
|
23
|
+
// Ordering and idempotency: within one journal epoch a cursor older than the latest checkpoint is a 409 conflict;
|
|
24
|
+
// the same cursor with the same batch again answers the existing record (200). A cursor of another epoch (the
|
|
25
|
+
// standalone journal restarted, or a cluster journal was reset) is not comparable and is recorded as given. A
|
|
26
|
+
// cursor beyond the journal's latest position, another user's or a forged one is 400 invalid_cursor.
|
|
27
|
+
//
|
|
28
|
+
// Storage: StateStore collection `workspace-checkpoints`, key `<workspaceID>.<supervisorMemberID>`, holding the
|
|
29
|
+
// newest `perSupervisor` (contract: 50) records; older ones are dropped. No journal event is appended (the compact
|
|
30
|
+
// journal is unchanged). Rollback: older hubs never read the collection.
|
|
31
|
+
import { randomBytes } from "node:crypto"
|
|
32
|
+
import {
|
|
33
|
+
CONTRACT_VERSION,
|
|
34
|
+
ID_PREFIXES,
|
|
35
|
+
type IdKind,
|
|
36
|
+
isContractID,
|
|
37
|
+
LIST_LIMIT_MAX,
|
|
38
|
+
type SupervisorCheckpoint,
|
|
39
|
+
WORKSPACE_LIMITS,
|
|
40
|
+
WORKSPACE_SCOPES,
|
|
41
|
+
type HubWorkspace,
|
|
42
|
+
type WorkspaceMember,
|
|
43
|
+
} from "../../protocol"
|
|
44
|
+
import { type CursorKeys, decodeCursor, encodeCursor, parsePosition } from "../agent/event-cursor"
|
|
45
|
+
import type { AgentEvents } from "../agent/events"
|
|
46
|
+
import { AgentError } from "../agent/errors"
|
|
47
|
+
import type { AgentRoute } from "../agent/routes"
|
|
48
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
49
|
+
import type { MembershipStore } from "./members"
|
|
50
|
+
import type { WorkspaceStore } from "./store"
|
|
51
|
+
import type { SupervisionStore } from "./supervision"
|
|
52
|
+
import { AGENT_JSON_BODY_MAX, boundedText } from "../agent/body"
|
|
53
|
+
|
|
54
|
+
export const WORKSPACE_CHECKPOINTS = "workspace-checkpoints"
|
|
55
|
+
/** Digest batch IDs (coordinator.ts mints them). */
|
|
56
|
+
export const BATCH_ID = /^dig_[0-9a-f]{20}$/
|
|
57
|
+
export const newBatchID = (): string => `dig_${randomBytes(10).toString("hex")}`
|
|
58
|
+
/** Assignment IDs a checkpoint lists as unresolved (the count is always exact). */
|
|
59
|
+
export const UNRESOLVED_IDS_MAX = 50
|
|
60
|
+
/** Version-checked write attempts before a contended acknowledgment gives up. */
|
|
61
|
+
const ATTEMPTS = 16
|
|
62
|
+
const PREFIX = "/api/agent/workspaces"
|
|
63
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
64
|
+
const [READ_SCOPE, MANAGE_SCOPE] = WORKSPACE_SCOPES
|
|
65
|
+
const OPEN_STATUSES: ReadonlySet<string> = new Set(["open", "in_progress", "blocked"])
|
|
66
|
+
|
|
67
|
+
export interface CheckpointLimits {
|
|
68
|
+
readonly perSupervisor: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A checkpoint as stored: the contract record plus what it was made against. `position` never leaves the hub. */
|
|
72
|
+
export interface StoredCheckpoint extends SupervisorCheckpoint {
|
|
73
|
+
/** The journal position the cursor names. */
|
|
74
|
+
readonly position: string
|
|
75
|
+
readonly sessionID: string
|
|
76
|
+
/** The supervisor member's generation when it acknowledged. */
|
|
77
|
+
readonly generation: number
|
|
78
|
+
/** The digest batch it acknowledges, when it names one. */
|
|
79
|
+
readonly batchID?: string
|
|
80
|
+
/** The snapshot revision: the workspace record's and the supervision graph's. */
|
|
81
|
+
readonly snapshot: { readonly workspaceRevision: number; readonly supervisionRevision: number }
|
|
82
|
+
/** Not-closed assignments of the members this supervisor supervises. */
|
|
83
|
+
readonly unresolved: { readonly count: number; readonly assignmentIDs: readonly string[]; readonly truncated: boolean }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Cursor handling over the agent journal: decode (tenant-bound), encode and the latest position. */
|
|
87
|
+
export interface CheckpointCursors {
|
|
88
|
+
decode(userID: string, cursor: string): string | undefined
|
|
89
|
+
encode(userID: string, position: string): string
|
|
90
|
+
latest(userID: string): Promise<string>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function journalCursors(keys: CursorKeys, events: Pick<AgentEvents, "latest">): CheckpointCursors {
|
|
94
|
+
return {
|
|
95
|
+
decode: (userID, cursor) => decodeCursor(keys, userID, cursor),
|
|
96
|
+
encode: (userID, position) => encodeCursor(keys, userID, position),
|
|
97
|
+
latest: (userID) => events.latest(userID),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CheckpointStoreDeps {
|
|
102
|
+
readonly state: StateStore
|
|
103
|
+
readonly workspaces: Pick<WorkspaceStore, "require">
|
|
104
|
+
readonly members: Pick<MembershipStore, "members" | "assignments">
|
|
105
|
+
readonly supervision: Pick<SupervisionStore, "graph">
|
|
106
|
+
readonly cursors: CheckpointCursors
|
|
107
|
+
readonly now?: Clock
|
|
108
|
+
readonly newID?: (kind: IdKind) => string
|
|
109
|
+
readonly limits?: Partial<CheckpointLimits>
|
|
110
|
+
/** Called after a durable acknowledgment (coordinator.ts resolves the batch it names). Failures are reported. */
|
|
111
|
+
readonly onAck?: (userID: string, workspaceID: string, supervisorMemberID: string, batchID: string | undefined) => Promise<void>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface CheckpointDoc {
|
|
115
|
+
readonly v: 1
|
|
116
|
+
readonly workspaceID: string
|
|
117
|
+
readonly supervisorMemberID: string
|
|
118
|
+
/** Oldest first. */
|
|
119
|
+
readonly items: readonly StoredCheckpoint[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
123
|
+
export const checkpointKey = (workspaceID: string, memberID: string) => `${workspaceID}.${memberID}`
|
|
124
|
+
|
|
125
|
+
/** Links as the scope walk needs them (live: both ends current). */
|
|
126
|
+
export interface ScopeLink {
|
|
127
|
+
readonly memberID: string
|
|
128
|
+
readonly supervisorMemberID: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The members a supervisor is responsible for: the master supervises every other current member; anyone else its
|
|
133
|
+
* subtree of live supervision links. undefined when the member supervises nobody and is not the master.
|
|
134
|
+
*/
|
|
135
|
+
export function supervisedMembers(workspace: Pick<HubWorkspace, "masterMemberID">, current: readonly WorkspaceMember[], links: readonly ScopeLink[], supervisorMemberID: string): Set<string> | undefined {
|
|
136
|
+
const ids = new Set(current.map((m) => m.id))
|
|
137
|
+
if (!ids.has(supervisorMemberID)) return undefined
|
|
138
|
+
if (workspace.masterMemberID === supervisorMemberID) return new Set([...ids].filter((id) => id !== supervisorMemberID))
|
|
139
|
+
const children = new Map<string, string[]>()
|
|
140
|
+
for (const l of links) if (ids.has(l.memberID) && ids.has(l.supervisorMemberID)) children.set(l.supervisorMemberID, [...(children.get(l.supervisorMemberID) ?? []), l.memberID])
|
|
141
|
+
if (!children.has(supervisorMemberID)) return undefined
|
|
142
|
+
const out = new Set<string>()
|
|
143
|
+
const queue = [supervisorMemberID]
|
|
144
|
+
while (queue.length) for (const child of children.get(queue.shift()!) ?? []) if (child !== supervisorMemberID && !out.has(child)) out.add(child), queue.push(child)
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readDoc(raw: unknown, workspaceID: string, memberID: string): CheckpointDoc {
|
|
149
|
+
if (raw === undefined) return { v: 1, workspaceID, supervisorMemberID: memberID, items: [] }
|
|
150
|
+
if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
|
|
151
|
+
throw new AgentError("unsupported", "These checkpoints were written by a newer hub", { hint: "Upgrade this hub", extra: { feature: WORKSPACE_CHECKPOINTS } })
|
|
152
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || !Array.isArray(raw.items)) throw new AgentError("internal", "Stored checkpoints are malformed")
|
|
153
|
+
return raw as unknown as CheckpointDoc
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** -1, 0, 1 for comparable positions (same epoch); undefined across epochs. */
|
|
157
|
+
function compare(a: string, b: string): number | undefined {
|
|
158
|
+
const x = parsePosition(a)
|
|
159
|
+
const y = parsePosition(b)
|
|
160
|
+
if (!x || !y || x.epoch !== y.epoch) return undefined
|
|
161
|
+
return Math.sign(x.n - y.n)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class CheckpointStore {
|
|
165
|
+
readonly limits: CheckpointLimits
|
|
166
|
+
private readonly now: Clock
|
|
167
|
+
private readonly newID: (kind: IdKind) => string
|
|
168
|
+
|
|
169
|
+
constructor(private readonly deps: CheckpointStoreDeps) {
|
|
170
|
+
this.limits = { perSupervisor: WORKSPACE_LIMITS.checkpointsPerSupervisor, ...deps.limits }
|
|
171
|
+
this.now = deps.now ?? Date.now
|
|
172
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Records an acknowledgment and makes it durable before resolving. `created: false` for a repeat. */
|
|
176
|
+
async ack(userID: string, workspaceID: string, input: unknown): Promise<{ checkpoint: StoredCheckpoint; created: boolean }> {
|
|
177
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
178
|
+
for (const key of Object.keys(input)) if (!["cursor", "supervisorMemberID", "batchID"].includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
|
|
179
|
+
const { cursor, batchID } = input
|
|
180
|
+
if (typeof cursor !== "string" || cursor === "") throw new AgentError("invalid", "cursor is required: the agent-events cursor you processed up to")
|
|
181
|
+
if (input.supervisorMemberID !== undefined && !isContractID("member", input.supervisorMemberID)) throw new AgentError("invalid", "supervisorMemberID must be a workspace member ID")
|
|
182
|
+
if (batchID !== undefined && (typeof batchID !== "string" || !BATCH_ID.test(batchID))) throw new AgentError("invalid", "batchID must be a digest batch ID (dig_…)")
|
|
183
|
+
const position = this.deps.cursors.decode(userID, cursor)
|
|
184
|
+
if (position === undefined) throw new AgentError("invalid_cursor", "That cursor is not one this hub gave you")
|
|
185
|
+
if (compare(position, await this.deps.cursors.latest(userID)) === 1) throw new AgentError("invalid_cursor", "That cursor is beyond the journal")
|
|
186
|
+
|
|
187
|
+
const workspace = await this.deps.workspaces.require(userID, workspaceID)
|
|
188
|
+
if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
|
|
189
|
+
const memberID = (input.supervisorMemberID as string | undefined) ?? workspace.masterMemberID
|
|
190
|
+
if (memberID === undefined) throw new AgentError("invalid", "The workspace has no master: name the supervisorMemberID")
|
|
191
|
+
const views = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
|
|
192
|
+
const supervisor = views.find((v) => v.member.id === memberID)?.member
|
|
193
|
+
if (!supervisor) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
|
|
194
|
+
if (supervisor.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`)
|
|
195
|
+
const graph = await this.deps.supervision.graph(userID, workspaceID)
|
|
196
|
+
const live = graph.links.filter((l) => l.member === "current" && l.supervisor === "current").map((l) => l.link)
|
|
197
|
+
const scope = supervisedMembers(workspace, views.filter((v) => v.current).map((v) => v.member), live, memberID)
|
|
198
|
+
if (!scope) throw new AgentError("invalid", `Member ${memberID} supervises nobody`, { hint: "Checkpoints belong to the master or a member with supervised members" })
|
|
199
|
+
const unresolved = await this.unresolved(userID, workspaceID, scope)
|
|
200
|
+
|
|
201
|
+
const key = checkpointKey(workspaceID, memberID)
|
|
202
|
+
let answer: { checkpoint: StoredCheckpoint; created: boolean } | undefined
|
|
203
|
+
for (let attempt = 0; attempt < ATTEMPTS && !answer; attempt++) {
|
|
204
|
+
const stored = await this.deps.state.get(userID, WORKSPACE_CHECKPOINTS, key)
|
|
205
|
+
const doc = readDoc(stored?.value, workspaceID, memberID)
|
|
206
|
+
const last = doc.items.at(-1)
|
|
207
|
+
if (last) {
|
|
208
|
+
const order = compare(position, last.position)
|
|
209
|
+
if (order === -1)
|
|
210
|
+
throw new AgentError("conflict", "A later checkpoint is already acknowledged", { hint: "Acknowledge a newer cursor", extra: { currentCursor: last.cursor } })
|
|
211
|
+
if (order === 0 && last.batchID === batchID) {
|
|
212
|
+
answer = { checkpoint: last, created: false }
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const checkpoint: StoredCheckpoint = {
|
|
217
|
+
v: 1,
|
|
218
|
+
id: this.newID("checkpoint"),
|
|
219
|
+
workspaceID,
|
|
220
|
+
supervisorMemberID: memberID,
|
|
221
|
+
cursor,
|
|
222
|
+
at: this.now(),
|
|
223
|
+
position,
|
|
224
|
+
sessionID: supervisor.sessionID,
|
|
225
|
+
generation: supervisor.generation,
|
|
226
|
+
...(batchID !== undefined ? { batchID: batchID as string } : {}),
|
|
227
|
+
snapshot: { workspaceRevision: workspace.revision, supervisionRevision: graph.revision },
|
|
228
|
+
unresolved,
|
|
229
|
+
}
|
|
230
|
+
const next: CheckpointDoc = { v: 1, workspaceID, supervisorMemberID: memberID, items: [...doc.items, checkpoint].slice(-this.limits.perSupervisor) }
|
|
231
|
+
if ((await this.deps.state.put(userID, WORKSPACE_CHECKPOINTS, key, next, { ifVersion: stored?.version ?? 0 })).ok) answer = { checkpoint, created: true }
|
|
232
|
+
}
|
|
233
|
+
if (!answer) throw new Error(`state update of ${WORKSPACE_CHECKPOINTS}/${key} kept conflicting`)
|
|
234
|
+
// Durable before the answer: the standalone store debounces writes.
|
|
235
|
+
await this.deps.state.flush?.()
|
|
236
|
+
if (this.deps.onAck)
|
|
237
|
+
await this.deps.onAck(userID, workspaceID, memberID, answer.checkpoint.batchID).catch((cause) => console.error(`[hub] checkpoint batch resolve failed: ${(cause as Error).message}`))
|
|
238
|
+
return answer
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A supervisor's retained checkpoints, newest first; without one, the latest of each supervisor. */
|
|
242
|
+
async list(userID: string, workspaceID: string, supervisorMemberID?: string): Promise<StoredCheckpoint[]> {
|
|
243
|
+
await this.deps.workspaces.require(userID, workspaceID)
|
|
244
|
+
if (supervisorMemberID !== undefined) {
|
|
245
|
+
if (!isContractID("member", supervisorMemberID)) throw new AgentError("invalid", "supervisorMemberID must be a workspace member ID")
|
|
246
|
+
const doc = readDoc((await this.deps.state.get(userID, WORKSPACE_CHECKPOINTS, checkpointKey(workspaceID, supervisorMemberID)))?.value, workspaceID, supervisorMemberID)
|
|
247
|
+
return [...doc.items].reverse()
|
|
248
|
+
}
|
|
249
|
+
const out: StoredCheckpoint[] = []
|
|
250
|
+
for (const { key, value } of await this.deps.state.list(userID, WORKSPACE_CHECKPOINTS)) {
|
|
251
|
+
if (!key.startsWith(`${workspaceID}.`)) continue
|
|
252
|
+
const last = readDoc(value, workspaceID, key.slice(workspaceID.length + 1)).items.at(-1)
|
|
253
|
+
if (last) out.push(last)
|
|
254
|
+
}
|
|
255
|
+
return out.sort((a, b) => b.at - a.at)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The supervisor's latest checkpoint (no workspace check: internal callers already read it). */
|
|
259
|
+
async latest(userID: string, workspaceID: string, supervisorMemberID: string): Promise<StoredCheckpoint | undefined> {
|
|
260
|
+
const raw = (await this.deps.state.get(userID, WORKSPACE_CHECKPOINTS, checkpointKey(workspaceID, supervisorMemberID)))?.value
|
|
261
|
+
return readDoc(raw, workspaceID, supervisorMemberID).items.at(-1)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async unresolved(userID: string, workspaceID: string, scope: ReadonlySet<string>): Promise<StoredCheckpoint["unresolved"]> {
|
|
265
|
+
const ids: string[] = []
|
|
266
|
+
let count = 0
|
|
267
|
+
let cursor: string | undefined
|
|
268
|
+
do {
|
|
269
|
+
const page = await this.deps.members.assignments(userID, workspaceID, { limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
|
|
270
|
+
for (const { assignment } of page.items) {
|
|
271
|
+
if (!OPEN_STATUSES.has(assignment.status) || !scope.has(assignment.memberID)) continue
|
|
272
|
+
count++
|
|
273
|
+
if (ids.length < UNRESOLVED_IDS_MAX) ids.push(assignment.id)
|
|
274
|
+
}
|
|
275
|
+
cursor = page.cursor
|
|
276
|
+
} while (cursor)
|
|
277
|
+
return { count, assignmentIDs: ids, truncated: count > ids.length }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The public form: without the internal journal position. */
|
|
282
|
+
const publicCheckpoint = ({ position: _position, ...rest }: StoredCheckpoint) => rest
|
|
283
|
+
|
|
284
|
+
async function bodyOf(request: Request): Promise<unknown> {
|
|
285
|
+
const text = await boundedText(request, AGENT_JSON_BODY_MAX)
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(text === "" ? "{}" : text) as unknown
|
|
288
|
+
} catch {
|
|
289
|
+
throw new AgentError("invalid", "Body must be JSON")
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function checkpointRoutes(deps: { readonly checkpoints: CheckpointStore; readonly audit?: { audit(entry: { actor: string; action: string; target: string; detail?: string }): unknown } }): AgentRoute[] {
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
method: "POST",
|
|
297
|
+
path: `${PREFIX}/:id/checkpoints`,
|
|
298
|
+
allow: { scope: MANAGE_SCOPE },
|
|
299
|
+
async handle(context) {
|
|
300
|
+
const { checkpoint, created } = await deps.checkpoints.ack(context.user.id, context.params.id!, await bodyOf(context.request))
|
|
301
|
+
if (created) await deps.audit?.audit({ actor: context.user.id, action: "workspace.checkpoint", target: checkpoint.workspaceID, detail: context.actor.kind })
|
|
302
|
+
return Response.json({ checkpoint: publicCheckpoint(checkpoint) }, { status: created ? 201 : 200, headers: NO_STORE })
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
method: "GET",
|
|
307
|
+
path: `${PREFIX}/:id/checkpoints`,
|
|
308
|
+
allow: { scope: READ_SCOPE },
|
|
309
|
+
async handle(context) {
|
|
310
|
+
const member = context.url.searchParams.get("supervisorMemberID") || undefined
|
|
311
|
+
const items = await deps.checkpoints.list(context.user.id, context.params.id!, member)
|
|
312
|
+
return Response.json({ checkpoints: items.map(publicCheckpoint) }, { headers: NO_STORE })
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
]
|
|
316
|
+
}
|