@huynle/hub 0.7.3 → 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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- 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 +633 -50
- 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,490 @@
|
|
|
1
|
+
// Explicit master replacement and lifetime recovery (HWA-12; contract: Workspaces `POST /workspaces/:id/master
|
|
2
|
+
// {sessionID, expectedRevision}` in docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md and its
|
|
3
|
+
// clarifications from HWA-04, HWA-05, HWA-06 and HWA-11).
|
|
4
|
+
//
|
|
5
|
+
// What a replacement does, in this order:
|
|
6
|
+
// 1. Checks the workspace record's `expectedRevision` (stale: 409 conflict with currentRevision) and takes the
|
|
7
|
+
// workspace's replacement fence: one record per workspace in the StateStore collection `workspace-master`,
|
|
8
|
+
// claimed by a version-checked write. Of two concurrent replacements exactly one holds the fence; the other is
|
|
9
|
+
// a 409 conflict. After the claim the workspace revision is checked again, so a replacement that finished
|
|
10
|
+
// between the first read and the claim fences the late one out as well.
|
|
11
|
+
// 2. MembershipStore.join(…, {allowMaster, replaces}): ONE write of the members aggregate departs the old master
|
|
12
|
+
// as `replaced` and adds the new master member with the old generation + 1 (no current master: one more than
|
|
13
|
+
// any earlier master of the workspace, so the master slot's generation never goes back).
|
|
14
|
+
// 3. WorkspaceStore.setMaster(new member) under the expected revision: the commit point. Steps 2 and 3 are two
|
|
15
|
+
// records (the StateStore has no cross-record transactions), so the fence records the new member before step
|
|
16
|
+
// 3. A crash or a lost race (an unrelated PATCH moved the revision) rolls FORWARD: the master record is set to
|
|
17
|
+
// the already joined member under a fresh read, as long as it still names the old master or nobody. Nothing
|
|
18
|
+
// is undone, because the old membership is already departed.
|
|
19
|
+
// 4. SupervisionStore.memberDeparted(old, {replacedBy: new}) AFTER masterMemberID names the new member, so the
|
|
20
|
+
// old master's supervised members move to the new one and the new master never ends up with a supervisor.
|
|
21
|
+
// 5. Releases the fence, flushes the StateStore; the route appends compact events: `workspace.member.left`
|
|
22
|
+
// (replaced), `workspace.member.joined`, `workspace.master.replaced` (IDs, generations, revisions, counts).
|
|
23
|
+
// A fence whose holder crashed is finished (rolled forward or released) by the next replacement or read once it is
|
|
24
|
+
// older than MASTER_FENCE_MS; until then a replacement is a 409 conflict with a retry hint.
|
|
25
|
+
//
|
|
26
|
+
// Fencing an old master: member IDs are minted per generation, so everything keyed by the old master member (its
|
|
27
|
+
// checkpoints, its digest ledger, its supervision links) stops applying the moment it departs: checkpoint acks
|
|
28
|
+
// naming it are refused (it left), the coordinator revokes its ledger on its next pass, and assertMaster() refuses
|
|
29
|
+
// anything that names a master member or generation that is no longer current (409 conflict with the current one).
|
|
30
|
+
//
|
|
31
|
+
// Board and checkpoint continuity: the board is derived from live records, so it simply shows the new master.
|
|
32
|
+
// Checkpoints stay per supervisor member and generation: the new master does NOT inherit the old master's
|
|
33
|
+
// checkpoint cursor. A cursor acknowledges what one session processed; the new session has not seen those events,
|
|
34
|
+
// so the coordinator starts it with a fresh snapshot (members, presence, unresolved assignments, revisions). The old
|
|
35
|
+
// master's checkpoints are kept (GET …/checkpoints?supervisorMemberID=<old>) and the replacement answer carries the
|
|
36
|
+
// old master's latest one (`checkpoints.previousCheckpoint`, without the internal journal position).
|
|
37
|
+
//
|
|
38
|
+
// Grants: none move. Grants held by or given to the old master's session stay exactly as they are; the new master's
|
|
39
|
+
// session needs its own explicit grants (the answer says so in `grants`). Membership and the master role grant
|
|
40
|
+
// nothing. Native parentID/rootID are never touched.
|
|
41
|
+
//
|
|
42
|
+
// Sessions and harness restarts: the new master may be any session the user's directory knows, online or offline
|
|
43
|
+
// (presence is reported, never hidden); an unknown session is not_found and a deleted one invalid. The old
|
|
44
|
+
// master's presence never blocks a replacement (an offline or vanished master can always be replaced by the user).
|
|
45
|
+
// A harness that resumes the same session (same session ID) keeps its membership: nothing to replace. A restart that
|
|
46
|
+
// makes a new session (Claude Code and Codex cannot resume a lost process as the same session) cannot own the
|
|
47
|
+
// workspace's lifetime: when the old session is deleted the hub departs it (`ended`) and the workspace stays,
|
|
48
|
+
// without a master, until the user replaces it. `launchID` instead of `sessionID` names the session of one of the
|
|
49
|
+
// user's running launches (the existing launch API starts it under its own authority; nothing is launched here).
|
|
50
|
+
//
|
|
51
|
+
// Rollback: older hubs never read `workspace-master`; workspace and member records keep their shapes.
|
|
52
|
+
import { randomBytes } from "node:crypto"
|
|
53
|
+
import { CONTRACT_VERSION, type HubWorkspace, isExpectedRevision, WORKSPACE_SCOPES, type WorkspaceMember } from "../../protocol"
|
|
54
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
55
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
56
|
+
import { AgentError } from "../agent/errors"
|
|
57
|
+
import { idempotencyKeyOf, RouteIdempotency } from "../agent/idempotency"
|
|
58
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
59
|
+
import type { CheckpointStore, StoredCheckpoint } from "./checkpoints"
|
|
60
|
+
import { memberJoinedEvent, memberLeftEvent, supervisionDepartedEvent } from "./events"
|
|
61
|
+
import { type MembershipStore, SESSION_ID, type SessionPresence } from "./members"
|
|
62
|
+
import { WORKSPACE_ROUTE_IDEMPOTENCY } from "./routes"
|
|
63
|
+
import type { WorkspaceStore } from "./store"
|
|
64
|
+
import type { DepartureResult, SupervisionStore } from "./supervision"
|
|
65
|
+
import { AGENT_JSON_BODY_MAX, boundedText } from "../agent/body"
|
|
66
|
+
|
|
67
|
+
export const WORKSPACE_MASTER = "workspace-master"
|
|
68
|
+
/** A replacement fence older than this belongs to a crashed holder and is finished by the next caller. */
|
|
69
|
+
export const MASTER_FENCE_MS = 60_000
|
|
70
|
+
/** Earlier masters listed by GET /workspaces/:id/master. */
|
|
71
|
+
export const PREVIOUS_MASTERS_MAX = 10
|
|
72
|
+
const ATTEMPTS = 16
|
|
73
|
+
const LAUNCH_ID = /^[A-Za-z0-9_-]{1,128}$/
|
|
74
|
+
|
|
75
|
+
export const GRANTS_NOTE = "Grants are not transferred: grants held by or given to the previous master stay as they are; the new master's session needs its own explicit grants."
|
|
76
|
+
|
|
77
|
+
/** The launch API's read (LaunchEngine.view). */
|
|
78
|
+
export interface MasterLaunches {
|
|
79
|
+
view(userID: string, id: string): Promise<{ readonly state: string; readonly sessionID?: string } | undefined>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MasterStoreDeps {
|
|
83
|
+
readonly state: StateStore
|
|
84
|
+
readonly workspaces: WorkspaceStore
|
|
85
|
+
readonly members: MembershipStore
|
|
86
|
+
readonly supervision: SupervisionStore
|
|
87
|
+
readonly checkpoints?: Pick<CheckpointStore, "latest">
|
|
88
|
+
readonly launches?: MasterLaunches
|
|
89
|
+
readonly now?: Clock
|
|
90
|
+
readonly newOpID?: () => string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The replacement fence of one workspace. `phase: "done"` is a released fence (kept so versions only grow). */
|
|
94
|
+
interface Fence {
|
|
95
|
+
readonly v: 1
|
|
96
|
+
readonly workspaceID: string
|
|
97
|
+
readonly opID: string
|
|
98
|
+
readonly phase: "claimed" | "joined" | "done"
|
|
99
|
+
readonly at: number
|
|
100
|
+
readonly sessionID: string
|
|
101
|
+
readonly previousMemberID?: string
|
|
102
|
+
readonly newMemberID?: string
|
|
103
|
+
}
|
|
104
|
+
type HeldFence = Fence & { readonly version: number }
|
|
105
|
+
|
|
106
|
+
export type PublicCheckpoint = Omit<StoredCheckpoint, "position">
|
|
107
|
+
|
|
108
|
+
export interface MasterReplacement {
|
|
109
|
+
readonly workspace: HubWorkspace
|
|
110
|
+
readonly master: WorkspaceMember
|
|
111
|
+
readonly presence: SessionPresence
|
|
112
|
+
readonly previous?: WorkspaceMember
|
|
113
|
+
readonly previousPresence?: SessionPresence
|
|
114
|
+
/** False when the session already was the current master (nothing changed). */
|
|
115
|
+
readonly changed: boolean
|
|
116
|
+
/** The old master's links, moved to the new master or detached. */
|
|
117
|
+
readonly supervision?: DepartureResult
|
|
118
|
+
/** The new master starts from a fresh snapshot; the old master's latest checkpoint for reference. */
|
|
119
|
+
readonly checkpoints: { readonly inherited: false; readonly previousCheckpoint?: PublicCheckpoint }
|
|
120
|
+
readonly grants: { readonly transferred: false; readonly note: string }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface MasterView {
|
|
124
|
+
readonly workspaceID: string
|
|
125
|
+
readonly revision: number
|
|
126
|
+
readonly master?: { readonly member: WorkspaceMember; readonly presence: SessionPresence }
|
|
127
|
+
/** Earlier masters, newest first (bounded). */
|
|
128
|
+
readonly previous: readonly { readonly member: WorkspaceMember; readonly presence: SessionPresence }[]
|
|
129
|
+
/** A replacement in flight (or one a crashed holder left behind: `stale`). */
|
|
130
|
+
readonly pending?: { readonly phase: "claimed" | "joined"; readonly at: number; readonly stale: boolean }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
134
|
+
|
|
135
|
+
const conflict = (message: string, extra: Record<string, unknown>, hint = "Read the workspace again and retry with its current revision") =>
|
|
136
|
+
new AgentError("conflict", message, { hint, extra })
|
|
137
|
+
|
|
138
|
+
const revisionConflict = (workspace: HubWorkspace) =>
|
|
139
|
+
conflict(`Workspace ${workspace.id} changed since you read it (revision ${workspace.revision} now)`, { currentRevision: workspace.revision })
|
|
140
|
+
|
|
141
|
+
const publicCheckpoint = ({ position: _position, ...rest }: StoredCheckpoint): PublicCheckpoint => rest
|
|
142
|
+
|
|
143
|
+
const isConflict = (error: unknown) => error instanceof AgentError && error.code === "conflict"
|
|
144
|
+
|
|
145
|
+
function readFence(raw: unknown, workspaceID: string): Fence | undefined {
|
|
146
|
+
if (raw === undefined) return undefined
|
|
147
|
+
if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
|
|
148
|
+
throw new AgentError("unsupported", `Workspace ${workspaceID} master state was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: WORKSPACE_MASTER } })
|
|
149
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || typeof raw.opID !== "string") throw new AgentError("internal", "Stored workspace master state is malformed")
|
|
150
|
+
return raw as unknown as Fence
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class MasterStore {
|
|
154
|
+
private readonly now: Clock
|
|
155
|
+
private readonly newOpID: () => string
|
|
156
|
+
|
|
157
|
+
constructor(private readonly deps: MasterStoreDeps) {
|
|
158
|
+
this.now = deps.now ?? Date.now
|
|
159
|
+
this.newOpID = deps.newOpID ?? (() => `mop_${randomBytes(10).toString("hex")}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Replaces (or first sets) the workspace's master: `{sessionID | launchID, expectedRevision}` where
|
|
164
|
+
* expectedRevision is the workspace record's revision. See the header for the steps and their guarantees.
|
|
165
|
+
*/
|
|
166
|
+
async replace(userID: string, workspaceID: string, input: unknown): Promise<MasterReplacement> {
|
|
167
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
168
|
+
for (const key of Object.keys(input))
|
|
169
|
+
if (!["sessionID", "launchID", "expectedRevision"].includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
|
|
170
|
+
const { expectedRevision } = input
|
|
171
|
+
if (expectedRevision === undefined) throw new AgentError("invalid", "expectedRevision is required: the workspace revision you read", { hint: "Read the workspace and send its revision" })
|
|
172
|
+
if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision must be the revision you read")
|
|
173
|
+
const sessionID = await this.targetSession(userID, input)
|
|
174
|
+
|
|
175
|
+
await this.recover(userID, workspaceID)
|
|
176
|
+
const workspace = await this.deps.workspaces.require(userID, workspaceID)
|
|
177
|
+
if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
|
|
178
|
+
if (workspace.revision !== expectedRevision) throw revisionConflict(workspace)
|
|
179
|
+
|
|
180
|
+
const all = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
|
|
181
|
+
const previousView = workspace.masterMemberID === undefined ? undefined : all.find((v) => v.current && v.member.id === workspace.masterMemberID)
|
|
182
|
+
if (previousView && previousView.member.sessionID === sessionID)
|
|
183
|
+
return { workspace, master: previousView.member, presence: previousView.presence, changed: false, checkpoints: { inherited: false }, grants: { transferred: false, note: GRANTS_NOTE } }
|
|
184
|
+
const other = all.find((v) => v.current && v.member.sessionID === sessionID)
|
|
185
|
+
if (other)
|
|
186
|
+
throw new AgentError("invalid", `Session ${sessionID} is already a ${other.member.role} member of this workspace`, {
|
|
187
|
+
hint: "Remove that membership first; a master is always a new membership",
|
|
188
|
+
extra: { memberID: other.member.id },
|
|
189
|
+
})
|
|
190
|
+
const previous = previousView?.member
|
|
191
|
+
const minGeneration = 1 + Math.max(0, ...all.filter((v) => v.member.role === "master").map((v) => v.member.generation))
|
|
192
|
+
|
|
193
|
+
// 1. the fence, then the revision again under it
|
|
194
|
+
const fence = await this.claim(userID, workspaceID, sessionID, previous?.id)
|
|
195
|
+
let master: WorkspaceMember
|
|
196
|
+
try {
|
|
197
|
+
const fenced = await this.deps.workspaces.require(userID, workspaceID)
|
|
198
|
+
if (fenced.revision !== expectedRevision) throw revisionConflict(fenced)
|
|
199
|
+
// 2. one members write: the old master replaced, the new one at generation + 1
|
|
200
|
+
const options = { allowMaster: true, minGeneration, ...(previous ? { replaces: previous.id } : {}) }
|
|
201
|
+
master = (await this.deps.members.join(userID, workspaceID, { sessionID, role: "master" }, options)).member
|
|
202
|
+
} catch (error) {
|
|
203
|
+
await this.release(userID, workspaceID, fence)
|
|
204
|
+
throw error
|
|
205
|
+
}
|
|
206
|
+
const joined = await this.advance(userID, workspaceID, fence, { phase: "joined", newMemberID: master.id })
|
|
207
|
+
// 3. the commit point (rolled forward on a lost race), 4. supervision follows the new master
|
|
208
|
+
const committed = await this.commit(userID, workspaceID, joined, expectedRevision)
|
|
209
|
+
const supervision = previous ? await this.deps.supervision.memberDeparted(userID, workspaceID, previous.id, { replacedBy: master.id }) : undefined
|
|
210
|
+
await this.release(userID, workspaceID, joined)
|
|
211
|
+
await this.deps.state.flush?.()
|
|
212
|
+
|
|
213
|
+
const departed = previous ? await this.deps.members.member(userID, workspaceID, previous.id) : undefined
|
|
214
|
+
const latest = previous ? await this.deps.checkpoints?.latest(userID, workspaceID, previous.id) : undefined
|
|
215
|
+
const presence = (await this.deps.members.members(userID, workspaceID)).find((v) => v.member.id === master.id)?.presence ?? "unknown"
|
|
216
|
+
return {
|
|
217
|
+
workspace: committed,
|
|
218
|
+
master,
|
|
219
|
+
presence,
|
|
220
|
+
...(departed && previousView ? { previous: departed, previousPresence: previousView.presence } : {}),
|
|
221
|
+
changed: true,
|
|
222
|
+
...(supervision ? { supervision } : {}),
|
|
223
|
+
checkpoints: { inherited: false, ...(latest ? { previousCheckpoint: publicCheckpoint(latest) } : {}) },
|
|
224
|
+
grants: { transferred: false, note: GRANTS_NOTE },
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The current master with presence, earlier masters and any replacement in flight. */
|
|
229
|
+
async view(userID: string, workspaceID: string): Promise<MasterView> {
|
|
230
|
+
await this.recover(userID, workspaceID)
|
|
231
|
+
const workspace = await this.deps.workspaces.require(userID, workspaceID)
|
|
232
|
+
const all = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
|
|
233
|
+
const current = all.find((v) => v.current && v.member.id === workspace.masterMemberID)
|
|
234
|
+
const previous = all
|
|
235
|
+
.filter((v) => v.member.role === "master" && v !== current)
|
|
236
|
+
.sort((a, b) => (b.member.leftAt ?? b.member.joinedAt) - (a.member.leftAt ?? a.member.joinedAt))
|
|
237
|
+
.slice(0, PREVIOUS_MASTERS_MAX)
|
|
238
|
+
.map(({ member, presence }) => ({ member, presence }))
|
|
239
|
+
const fence = readFence((await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID))?.value, workspaceID)
|
|
240
|
+
const pending = fence && fence.phase !== "done" ? { phase: fence.phase, at: fence.at, stale: this.now() - fence.at >= MASTER_FENCE_MS } : undefined
|
|
241
|
+
return {
|
|
242
|
+
workspaceID,
|
|
243
|
+
revision: workspace.revision,
|
|
244
|
+
...(current ? { master: { member: current.member, presence: current.presence } } : {}),
|
|
245
|
+
previous,
|
|
246
|
+
...(pending ? { pending } : {}),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Refuses a stale master: `memberID` (and `generation`, when given) must be the workspace's current master.
|
|
252
|
+
* 409 conflict naming the current master member and generation otherwise. For master-only operations.
|
|
253
|
+
*/
|
|
254
|
+
async assertMaster(userID: string, workspaceID: string, memberID: string, generation?: number): Promise<WorkspaceMember> {
|
|
255
|
+
const workspace = await this.deps.workspaces.require(userID, workspaceID)
|
|
256
|
+
const named = workspace.masterMemberID === undefined ? undefined : await this.deps.members.member(userID, workspaceID, workspace.masterMemberID).catch(() => undefined)
|
|
257
|
+
const live = named && named.leftAt === undefined ? named : undefined
|
|
258
|
+
if (!live || live.id !== memberID || (generation !== undefined && live.generation !== generation))
|
|
259
|
+
throw conflict(
|
|
260
|
+
`Member ${memberID} is not the current master of workspace ${workspaceID}`,
|
|
261
|
+
{ ...(live ? { currentMasterMemberID: live.id, currentGeneration: live.generation } : {}), currentRevision: workspace.revision },
|
|
262
|
+
"The master was replaced: act as the current master or replace it explicitly",
|
|
263
|
+
)
|
|
264
|
+
return live
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Finishes a replacement whose holder crashed (fence older than MASTER_FENCE_MS): rolls a joined member forward
|
|
269
|
+
* to master, or releases a fence that never joined. Fresh fences are left alone (`busy`).
|
|
270
|
+
*/
|
|
271
|
+
async recover(userID: string, workspaceID: string): Promise<"none" | "busy" | "rolled_forward" | "released"> {
|
|
272
|
+
const fence = readFence((await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID))?.value, workspaceID)
|
|
273
|
+
if (!fence || fence.phase === "done") return "none"
|
|
274
|
+
if (this.now() - fence.at < MASTER_FENCE_MS) return "busy"
|
|
275
|
+
let newMemberID = fence.newMemberID
|
|
276
|
+
if (newMemberID === undefined) {
|
|
277
|
+
// The join may have landed before the crash without the fence recording it.
|
|
278
|
+
const views = await this.deps.members.members(userID, workspaceID)
|
|
279
|
+
newMemberID = views.find((v) => v.member.role === "master" && v.member.sessionID === fence.sessionID && v.member.joinedAt >= fence.at)?.member.id
|
|
280
|
+
}
|
|
281
|
+
let outcome: "rolled_forward" | "released" = "released"
|
|
282
|
+
if (newMemberID !== undefined) {
|
|
283
|
+
const joined = await this.deps.members.member(userID, workspaceID, newMemberID).catch(() => undefined)
|
|
284
|
+
if (joined && joined.leftAt === undefined) {
|
|
285
|
+
await this.commit(userID, workspaceID, { ...fence, newMemberID }, undefined)
|
|
286
|
+
if (fence.previousMemberID) await this.deps.supervision.memberDeparted(userID, workspaceID, fence.previousMemberID, { replacedBy: newMemberID })
|
|
287
|
+
outcome = "rolled_forward"
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await this.release(userID, workspaceID, fence)
|
|
291
|
+
await this.deps.state.flush?.()
|
|
292
|
+
return outcome
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- internals ----------
|
|
296
|
+
|
|
297
|
+
private async targetSession(userID: string, input: Record<string, unknown>): Promise<string> {
|
|
298
|
+
const { sessionID, launchID } = input
|
|
299
|
+
if ((sessionID === undefined) === (launchID === undefined)) throw new AgentError("invalid", "Send exactly one of sessionID or launchID")
|
|
300
|
+
if (sessionID !== undefined) {
|
|
301
|
+
if (typeof sessionID !== "string" || !SESSION_ID.test(sessionID)) throw new AgentError("invalid", "sessionID must be a session ID (ses_…)")
|
|
302
|
+
return sessionID
|
|
303
|
+
}
|
|
304
|
+
if (typeof launchID !== "string" || !LAUNCH_ID.test(launchID)) throw new AgentError("invalid", "launchID must be a launch ID")
|
|
305
|
+
if (!this.deps.launches) throw new AgentError("unsupported", "This hub does not serve launches", { extra: { feature: "launches" } })
|
|
306
|
+
const launch = await this.deps.launches.view(userID, launchID)
|
|
307
|
+
if (!launch) throw new AgentError("not_found", `No launch ${launchID}`)
|
|
308
|
+
if (launch.state !== "running" || launch.sessionID === undefined)
|
|
309
|
+
throw new AgentError("invalid", `Launch ${launchID} is ${launch.state}, not running`, {
|
|
310
|
+
hint: launch.state === "ended" ? "Launch a new session" : "Wait until it is running",
|
|
311
|
+
extra: { launchState: launch.state },
|
|
312
|
+
})
|
|
313
|
+
return launch.sessionID
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Takes the workspace's fence (409 conflict while another replacement holds a fresh one). */
|
|
317
|
+
private async claim(userID: string, workspaceID: string, sessionID: string, previousMemberID: string | undefined): Promise<HeldFence> {
|
|
318
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
319
|
+
const stored = await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID)
|
|
320
|
+
const held = readFence(stored?.value, workspaceID)
|
|
321
|
+
if (held && held.phase !== "done") {
|
|
322
|
+
if (this.now() - held.at < MASTER_FENCE_MS)
|
|
323
|
+
throw conflict(
|
|
324
|
+
`Another master replacement of workspace ${workspaceID} is in progress`,
|
|
325
|
+
{ retryAfter: Math.max(1, Math.ceil((held.at + MASTER_FENCE_MS - this.now()) / 1000)) },
|
|
326
|
+
"Read the workspace again shortly",
|
|
327
|
+
)
|
|
328
|
+
await this.recover(userID, workspaceID)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
const fence: Fence = { v: 1, workspaceID, opID: this.newOpID(), phase: "claimed", at: this.now(), sessionID, ...(previousMemberID ? { previousMemberID } : {}) }
|
|
332
|
+
const put = await this.deps.state.put(userID, WORKSPACE_MASTER, workspaceID, fence, { ifVersion: stored?.version ?? 0 })
|
|
333
|
+
if (put.ok) return { ...fence, version: put.version }
|
|
334
|
+
// Another writer got there first: the next round reads its fence.
|
|
335
|
+
}
|
|
336
|
+
throw new Error(`state update of ${WORKSPACE_MASTER}/${workspaceID} kept conflicting`)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Records progress under the fence; a fence taken over meanwhile (only after MASTER_FENCE_MS) is a conflict. */
|
|
340
|
+
private async advance(userID: string, workspaceID: string, fence: HeldFence, change: Partial<Fence>): Promise<HeldFence> {
|
|
341
|
+
const { version, ...rest } = fence
|
|
342
|
+
const next: Fence = { ...rest, ...change }
|
|
343
|
+
const put = await this.deps.state.put(userID, WORKSPACE_MASTER, workspaceID, next, { ifVersion: version })
|
|
344
|
+
if (!put.ok) throw conflict(`The master replacement of workspace ${workspaceID} was taken over`, {}, "Read the workspace again")
|
|
345
|
+
return { ...next, version: put.version }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Releases the fence (phase done) if it is still this operation's. */
|
|
349
|
+
private async release(userID: string, workspaceID: string, fence: Fence): Promise<void> {
|
|
350
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
351
|
+
const stored = await this.deps.state.get(userID, WORKSPACE_MASTER, workspaceID)
|
|
352
|
+
const current = readFence(stored?.value, workspaceID)
|
|
353
|
+
if (!stored || !current || current.opID !== fence.opID || current.phase === "done") return
|
|
354
|
+
const done: Fence = { ...current, phase: "done", at: this.now() }
|
|
355
|
+
if ((await this.deps.state.put(userID, WORKSPACE_MASTER, workspaceID, done, { ifVersion: stored.version })).ok) return
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Points masterMemberID at the joined member: under the caller's expected revision first, then (rolled forward)
|
|
361
|
+
* under fresh reads while the record still names the old master or nobody. Another master there means someone
|
|
362
|
+
* else committed: a conflict.
|
|
363
|
+
*/
|
|
364
|
+
private async commit(userID: string, workspaceID: string, fence: Fence, expectedRevision: number | undefined): Promise<HubWorkspace> {
|
|
365
|
+
const target = fence.newMemberID!
|
|
366
|
+
if (expectedRevision !== undefined) {
|
|
367
|
+
try {
|
|
368
|
+
return await this.deps.workspaces.setMaster(userID, workspaceID, target, expectedRevision)
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (!isConflict(error)) throw error
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
374
|
+
const current = await this.deps.workspaces.require(userID, workspaceID)
|
|
375
|
+
if (current.masterMemberID === target) return current
|
|
376
|
+
if (current.masterMemberID !== undefined && current.masterMemberID !== fence.previousMemberID)
|
|
377
|
+
throw conflict(`Workspace ${workspaceID} has another master now`, { currentRevision: current.revision, currentMasterMemberID: current.masterMemberID })
|
|
378
|
+
try {
|
|
379
|
+
return await this.deps.workspaces.setMaster(userID, workspaceID, target, current.revision)
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (!isConflict(error)) throw error
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
throw new Error(`workspace ${workspaceID} master kept conflicting`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------- events ----------
|
|
389
|
+
|
|
390
|
+
/** The master changed: IDs, generations, revisions and counts only. */
|
|
391
|
+
export function masterReplacedEvent(result: MasterReplacement): EventInput {
|
|
392
|
+
return {
|
|
393
|
+
type: "workspace.master.replaced",
|
|
394
|
+
family: "manage",
|
|
395
|
+
subject: { workspaceID: result.workspace.id, memberID: result.master.id, sessionID: result.master.sessionID, ...(result.previous ? { previousMemberID: result.previous.id } : {}) },
|
|
396
|
+
data: {
|
|
397
|
+
generation: result.master.generation,
|
|
398
|
+
revision: result.workspace.revision,
|
|
399
|
+
...(result.previous ? { previousGeneration: result.previous.generation } : {}),
|
|
400
|
+
transferred: result.supervision?.transferred ?? 0,
|
|
401
|
+
detached: result.supervision?.detached ?? 0,
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The journal events of a replacement, in order (none when nothing changed). */
|
|
407
|
+
export function masterEvents(result: MasterReplacement): EventInput[] {
|
|
408
|
+
if (!result.changed) return []
|
|
409
|
+
const links = result.supervision
|
|
410
|
+
return [
|
|
411
|
+
...(result.previous ? [memberLeftEvent(result.previous)] : []),
|
|
412
|
+
memberJoinedEvent(result.master),
|
|
413
|
+
masterReplacedEvent(result),
|
|
414
|
+
...(result.previous && links && links.detached + links.transferred + links.pruned > 0 ? [supervisionDepartedEvent(result.previous.id, links)] : []),
|
|
415
|
+
]
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------- routes ----------
|
|
419
|
+
|
|
420
|
+
const PREFIX = "/api/agent/workspaces"
|
|
421
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
422
|
+
const [READ_SCOPE, MANAGE_SCOPE] = WORKSPACE_SCOPES
|
|
423
|
+
|
|
424
|
+
export interface MasterRoutesDeps extends Omit<MasterStoreDeps, "state"> {
|
|
425
|
+
/** Default: the workspaces' StateStore. */
|
|
426
|
+
readonly state?: StateStore
|
|
427
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
428
|
+
readonly audit?: { audit(entry: { actor: string; action: string; target: string; detail?: string }): unknown }
|
|
429
|
+
readonly idempotency?: RouteIdempotency
|
|
430
|
+
readonly report?: (cause: unknown) => void
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function sessionsVisible(context: AgentContext): void {
|
|
434
|
+
const { actor } = context
|
|
435
|
+
if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Master routes also need scope sessions:read")
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
439
|
+
const text = await boundedText(request, AGENT_JSON_BODY_MAX)
|
|
440
|
+
let raw: unknown
|
|
441
|
+
try {
|
|
442
|
+
raw = JSON.parse(text === "" ? "{}" : text)
|
|
443
|
+
} catch {
|
|
444
|
+
throw new AgentError("invalid", "Body must be JSON")
|
|
445
|
+
}
|
|
446
|
+
if (!isObj(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
447
|
+
return raw
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* GET /api/agent/workspaces/:id/master workspaces:read + sessions:read
|
|
452
|
+
* POST /api/agent/workspaces/:id/master {sessionID | launchID, expectedRevision} workspaces:manage + sessions:read
|
|
453
|
+
* (Idempotency-Key: the answer is replayed)
|
|
454
|
+
* Users and app tokens by scope; session tokens are refused (membership grants nothing).
|
|
455
|
+
*/
|
|
456
|
+
export function masterRoutes(deps: MasterRoutesDeps): AgentRoute[] {
|
|
457
|
+
const state = deps.state ?? deps.workspaces.state
|
|
458
|
+
const store = new MasterStore({ ...deps, state })
|
|
459
|
+
const idempotency = deps.idempotency ?? new RouteIdempotency({ state, collection: WORKSPACE_ROUTE_IDEMPOTENCY, ...(deps.now ? { now: deps.now } : {}) })
|
|
460
|
+
const report = deps.report ?? ((cause: unknown) => console.error(`[hub] workspace event failed: ${cause instanceof Error ? cause.message : String(cause)}`))
|
|
461
|
+
return [
|
|
462
|
+
{
|
|
463
|
+
method: "GET",
|
|
464
|
+
path: `${PREFIX}/:id/master`,
|
|
465
|
+
allow: { scope: READ_SCOPE },
|
|
466
|
+
async handle(context) {
|
|
467
|
+
sessionsVisible(context)
|
|
468
|
+
return Response.json(await store.view(context.user.id, context.params.id!), { headers: NO_STORE })
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
method: "POST",
|
|
473
|
+
path: `${PREFIX}/:id/master`,
|
|
474
|
+
allow: { scope: MANAGE_SCOPE },
|
|
475
|
+
async handle(context) {
|
|
476
|
+
sessionsVisible(context)
|
|
477
|
+
const body = await bodyOf(context.request)
|
|
478
|
+
const userID = context.user.id
|
|
479
|
+
return idempotency.run(userID, `POST ${context.url.pathname}`, idempotencyKeyOf(context.request) ?? null, body, async () => {
|
|
480
|
+
const result = await store.replace(userID, context.params.id!, body)
|
|
481
|
+
if (result.changed) {
|
|
482
|
+
await deps.audit?.audit({ actor: userID, action: "workspace.master.replace", target: result.master.id, detail: context.actor.kind })
|
|
483
|
+
for (const event of masterEvents(result)) await deps.events?.append(userID, event).catch(report)
|
|
484
|
+
}
|
|
485
|
+
return { status: 200, body: result }
|
|
486
|
+
})
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
]
|
|
490
|
+
}
|