@opengeni/core 0.4.6 → 0.4.8
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/dist/index.d.ts +293 -32
- package/dist/index.js +1750 -472
- package/dist/index.js.map +1 -1
- package/package.json +17 -17
- package/src/access/index.ts +136 -57
- package/src/application/session-commands.ts +527 -0
- package/src/billing/limits.ts +76 -34
- package/src/dependencies.ts +67 -14
- package/src/domain/capabilities.ts +380 -181
- package/src/domain/environments.ts +58 -38
- package/src/domain/packs.ts +49 -16
- package/src/domain/resources.ts +71 -28
- package/src/domain/scheduled-tasks.ts +107 -41
- package/src/domain/sessions.ts +531 -255
- package/src/domain/workspace-members.ts +6 -2
- package/src/index.ts +3 -0
- package/src/rigs/index.ts +540 -0
- package/src/sandbox/fleet.ts +97 -18
- package/src/sandbox/routing.ts +6 -1
- package/src/sandbox-types.ts +17 -5
- package/src/workflow-wake-contract.ts +6 -0
|
@@ -51,9 +51,13 @@ export function assertWorkspaceMemberRemovable(input: {
|
|
|
51
51
|
throw new HTTPException(404, { message: "member not found" });
|
|
52
52
|
}
|
|
53
53
|
if (memberCanAdminister(target)) {
|
|
54
|
-
const remainingAdmins = members.filter(
|
|
54
|
+
const remainingAdmins = members.filter(
|
|
55
|
+
(member) => member.subjectId !== subjectId && memberCanAdminister(member),
|
|
56
|
+
);
|
|
55
57
|
if (remainingAdmins.length === 0) {
|
|
56
|
-
throw new HTTPException(409, {
|
|
58
|
+
throw new HTTPException(409, {
|
|
59
|
+
message: "cannot remove the last member who can manage this workspace",
|
|
60
|
+
});
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// The central dependency type surface (AppDependencies, ApiRouteDeps,
|
|
28
28
|
// SessionWorkflowClient, DocumentIndexClient, ObjectStorageDependency).
|
|
29
29
|
export * from "./dependencies";
|
|
30
|
+
export * from "./workflow-wake-contract";
|
|
30
31
|
|
|
31
32
|
// Boundary type slots referenced by dependencies.ts. The IMPLEMENTATIONS that
|
|
32
33
|
// construct these (the real sandbox client / Better Auth instance) stay in
|
|
@@ -52,8 +53,10 @@ export * from "./billing/limits";
|
|
|
52
53
|
// scheduled-task/workspace-member logic, …).
|
|
53
54
|
export * from "./domain/capabilities";
|
|
54
55
|
export * from "./domain/environments";
|
|
56
|
+
export * from "./rigs";
|
|
55
57
|
export * from "./domain/packs";
|
|
56
58
|
export * from "./domain/resources";
|
|
57
59
|
export * from "./domain/scheduled-tasks";
|
|
58
60
|
export * from "./domain/sessions";
|
|
59
61
|
export * from "./domain/workspace-members";
|
|
62
|
+
export * from "./application/session-commands";
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// packages/core/src/rigs/index.ts — the rig domain: the workspace-scoped,
|
|
2
|
+
// versioned sandbox-machine-definition business logic the REST routes (M2) and
|
|
3
|
+
// (later) the MCP rig tools (M4) both call. Validation + audit events live here;
|
|
4
|
+
// the raw RLS-scoped persistence lives in @opengeni/db. M4 adds verification /
|
|
5
|
+
// auto-merge / promotion on top of the change substrate created here.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AccessGrant,
|
|
9
|
+
CreateRigRequest,
|
|
10
|
+
RigDefinitionEditPayload,
|
|
11
|
+
ProposeRigChangeRequest,
|
|
12
|
+
Rig,
|
|
13
|
+
RigChange,
|
|
14
|
+
RigVersion,
|
|
15
|
+
UpdateRigRequest,
|
|
16
|
+
} from "@opengeni/contracts";
|
|
17
|
+
import {
|
|
18
|
+
activateRigVersion,
|
|
19
|
+
countRigs,
|
|
20
|
+
createRig,
|
|
21
|
+
createRigChange,
|
|
22
|
+
createRigVersion,
|
|
23
|
+
createRigVersionForChangePromotion,
|
|
24
|
+
deleteRigIfNoActiveSessions,
|
|
25
|
+
getRig,
|
|
26
|
+
getRigByName,
|
|
27
|
+
getRigChange,
|
|
28
|
+
getRigVersion,
|
|
29
|
+
getVariableSet,
|
|
30
|
+
listRigChanges,
|
|
31
|
+
listRigVersions,
|
|
32
|
+
recordAuditEvent,
|
|
33
|
+
RigActiveVersionChangedError,
|
|
34
|
+
RigChangeTransitionError,
|
|
35
|
+
updateRig,
|
|
36
|
+
type Database,
|
|
37
|
+
} from "@opengeni/db";
|
|
38
|
+
import { HTTPException } from "hono/http-exception";
|
|
39
|
+
|
|
40
|
+
export const MAX_RIGS_PER_WORKSPACE = 50;
|
|
41
|
+
export const MAX_CHECKS_PER_RIG = 100;
|
|
42
|
+
export const MAX_CREDENTIAL_HOOKS_PER_RIG = 50;
|
|
43
|
+
export const MAX_DEFAULT_VARIABLE_SETS_PER_RIG = 25;
|
|
44
|
+
|
|
45
|
+
export type RigServices = {
|
|
46
|
+
db: Database;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type RigAuditAction =
|
|
50
|
+
| "rig.created"
|
|
51
|
+
| "rig.updated"
|
|
52
|
+
| "rig.deleted"
|
|
53
|
+
| "rig.change.proposed"
|
|
54
|
+
| "rig.change.verified"
|
|
55
|
+
| "rig.change.rejected"
|
|
56
|
+
| "rig.change.failed"
|
|
57
|
+
| "rig.change.merged"
|
|
58
|
+
| "rig.verification.started"
|
|
59
|
+
| "rig.verification.passed"
|
|
60
|
+
| "rig.verification.failed"
|
|
61
|
+
| "rig.version.activated"
|
|
62
|
+
| "rig.version.promoted";
|
|
63
|
+
|
|
64
|
+
export async function recordRigAuditEvent(
|
|
65
|
+
db: Database,
|
|
66
|
+
input: {
|
|
67
|
+
grant: AccessGrant;
|
|
68
|
+
action: RigAuditAction;
|
|
69
|
+
rigId: string;
|
|
70
|
+
metadata?: Record<string, unknown>;
|
|
71
|
+
},
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
await recordAuditEvent(db, {
|
|
74
|
+
accountId: input.grant.accountId,
|
|
75
|
+
workspaceId: input.grant.workspaceId,
|
|
76
|
+
subjectId: input.grant.subjectId,
|
|
77
|
+
action: input.action,
|
|
78
|
+
targetType: "rig",
|
|
79
|
+
targetId: input.rigId,
|
|
80
|
+
metadata: { rigId: input.rigId, ...(input.metadata ?? {}) },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Version attribution string for an API (user-authenticated) mutation. The MCP
|
|
85
|
+
// session path (M4) will use `session:<id>` instead.
|
|
86
|
+
export function rigActorForGrant(grant: AccessGrant): string {
|
|
87
|
+
return `user:${grant.subjectId}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function requireRigForApi(
|
|
91
|
+
db: Database,
|
|
92
|
+
workspaceId: string,
|
|
93
|
+
rigId: string,
|
|
94
|
+
): Promise<Rig> {
|
|
95
|
+
const rig = await getRig(db, workspaceId, rigId);
|
|
96
|
+
if (!rig) {
|
|
97
|
+
throw new HTTPException(404, { message: "rig not found" });
|
|
98
|
+
}
|
|
99
|
+
return rig;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function requireRigChangeForApi(
|
|
103
|
+
db: Database,
|
|
104
|
+
workspaceId: string,
|
|
105
|
+
rigId: string,
|
|
106
|
+
changeId: string,
|
|
107
|
+
): Promise<RigChange> {
|
|
108
|
+
const change = await getRigChange(db, workspaceId, changeId);
|
|
109
|
+
// RLS + the workspace clause make a cross-workspace id indistinguishable from
|
|
110
|
+
// missing; the rigId clause keeps the change addressable only under its rig.
|
|
111
|
+
if (!change || change.rigId !== rigId) {
|
|
112
|
+
throw new HTTPException(404, { message: "rig change not found" });
|
|
113
|
+
}
|
|
114
|
+
return change;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function trimmedRigName(name: string): string {
|
|
118
|
+
const trimmed = name.trim();
|
|
119
|
+
if (!trimmed) {
|
|
120
|
+
throw new HTTPException(422, { message: "rig name is required" });
|
|
121
|
+
}
|
|
122
|
+
return trimmed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Duplicate check names would make check results ambiguous; reject them.
|
|
126
|
+
function assertUniqueCheckNames(checks: ReadonlyArray<{ name: string }> | undefined): void {
|
|
127
|
+
if (!checks) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
for (const check of checks) {
|
|
132
|
+
if (seen.has(check.name)) {
|
|
133
|
+
throw new HTTPException(422, { message: `duplicate rig check name: ${check.name}` });
|
|
134
|
+
}
|
|
135
|
+
seen.add(check.name);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Every referenced default variable set must exist in the workspace. RLS makes a
|
|
140
|
+
// cross-workspace id indistinguishable from a missing one, so both map to 422.
|
|
141
|
+
async function assertVariableSetsExist(
|
|
142
|
+
db: Database,
|
|
143
|
+
workspaceId: string,
|
|
144
|
+
ids: ReadonlyArray<string> | undefined,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
if (!ids || ids.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const unique = [...new Set(ids)];
|
|
150
|
+
for (const id of unique) {
|
|
151
|
+
const variableSet = await getVariableSet(db, workspaceId, id);
|
|
152
|
+
if (!variableSet) {
|
|
153
|
+
throw new HTTPException(422, { message: `unknown defaultVariableSetId: ${id}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function createRigForApi(
|
|
159
|
+
deps: RigServices,
|
|
160
|
+
grant: AccessGrant,
|
|
161
|
+
payload: CreateRigRequest,
|
|
162
|
+
): Promise<Rig> {
|
|
163
|
+
const workspaceId = grant.workspaceId;
|
|
164
|
+
const name = trimmedRigName(payload.name);
|
|
165
|
+
assertUniqueCheckNames(payload.checks);
|
|
166
|
+
await assertVariableSetsExist(deps.db, workspaceId, payload.defaultVariableSetIds);
|
|
167
|
+
if ((await countRigs(deps.db, workspaceId)) >= MAX_RIGS_PER_WORKSPACE) {
|
|
168
|
+
throw new HTTPException(422, {
|
|
169
|
+
message: `a workspace supports at most ${MAX_RIGS_PER_WORKSPACE} rigs`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (await getRigByName(deps.db, workspaceId, name)) {
|
|
173
|
+
throw new HTTPException(409, { message: `rig name is already in use: ${name}` });
|
|
174
|
+
}
|
|
175
|
+
const createdBy = rigActorForGrant(grant);
|
|
176
|
+
const rig = await createRig(deps.db, {
|
|
177
|
+
accountId: grant.accountId,
|
|
178
|
+
workspaceId,
|
|
179
|
+
name,
|
|
180
|
+
description: payload.description ?? null,
|
|
181
|
+
createdBy,
|
|
182
|
+
initialVersion: {
|
|
183
|
+
image: payload.image ?? null,
|
|
184
|
+
setupScript: payload.setupScript ?? null,
|
|
185
|
+
checks: payload.checks,
|
|
186
|
+
credentialHooks: payload.credentialHooks,
|
|
187
|
+
defaultVariableSetIds: payload.defaultVariableSetIds,
|
|
188
|
+
changelog: "Initial version",
|
|
189
|
+
createdBy,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await recordRigAuditEvent(deps.db, { grant, action: "rig.created", rigId: rig.id });
|
|
193
|
+
return rig;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function updateRigForApi(
|
|
197
|
+
deps: RigServices,
|
|
198
|
+
grant: AccessGrant,
|
|
199
|
+
rig: Rig,
|
|
200
|
+
payload: UpdateRigRequest,
|
|
201
|
+
): Promise<Rig> {
|
|
202
|
+
const workspaceId = grant.workspaceId;
|
|
203
|
+
const name = payload.name !== undefined ? trimmedRigName(payload.name) : undefined;
|
|
204
|
+
if (name !== undefined && name !== rig.name) {
|
|
205
|
+
const existing = await getRigByName(deps.db, workspaceId, name);
|
|
206
|
+
if (existing && existing.id !== rig.id) {
|
|
207
|
+
throw new HTTPException(409, { message: `rig name is already in use: ${name}` });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const updated = await updateRig(deps.db, workspaceId, rig.id, {
|
|
211
|
+
...(name !== undefined ? { name } : {}),
|
|
212
|
+
...(payload.description !== undefined ? { description: payload.description } : {}),
|
|
213
|
+
});
|
|
214
|
+
await recordRigAuditEvent(deps.db, { grant, action: "rig.updated", rigId: rig.id });
|
|
215
|
+
return updated;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function deleteRigForApi(
|
|
219
|
+
deps: RigServices,
|
|
220
|
+
grant: AccessGrant,
|
|
221
|
+
rig: Rig,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const workspaceId = grant.workspaceId;
|
|
224
|
+
const deleted = await deleteRigIfNoActiveSessions(deps.db, workspaceId, rig.id);
|
|
225
|
+
if (deleted.activeSessionCount > 0) {
|
|
226
|
+
throw new HTTPException(409, {
|
|
227
|
+
message: `rig is referenced by ${deleted.activeSessionCount} active session(s); it cannot be deleted`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (!deleted.deleted) {
|
|
231
|
+
throw new HTTPException(404, { message: "rig not found" });
|
|
232
|
+
}
|
|
233
|
+
await recordRigAuditEvent(deps.db, { grant, action: "rig.deleted", rigId: rig.id });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Records a proposed change against the rig's CURRENT active version (the base
|
|
237
|
+
// clean-replay minted new versions from). Verification / auto-merge is M4; here
|
|
238
|
+
// the row is created in `proposed`. `proposedBy` overrides the actor string for
|
|
239
|
+
// the session-scoped MCP path (M4).
|
|
240
|
+
export async function proposeRigChangeForApi(
|
|
241
|
+
deps: RigServices,
|
|
242
|
+
grant: AccessGrant,
|
|
243
|
+
rig: Rig,
|
|
244
|
+
request: ProposeRigChangeRequest,
|
|
245
|
+
options: { proposedBy?: string } = {},
|
|
246
|
+
): Promise<RigChange> {
|
|
247
|
+
const workspaceId = grant.workspaceId;
|
|
248
|
+
if (!rig.activeVersion) {
|
|
249
|
+
throw new HTTPException(422, { message: "rig has no active version to base a change on" });
|
|
250
|
+
}
|
|
251
|
+
if (request.kind === "definition_edit") {
|
|
252
|
+
assertUniqueCheckNames(request.payload.checks);
|
|
253
|
+
await assertVariableSetsExist(
|
|
254
|
+
deps.db,
|
|
255
|
+
workspaceId,
|
|
256
|
+
request.payload.defaultVariableSetIds ?? undefined,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const change = await createRigChange(deps.db, {
|
|
260
|
+
accountId: grant.accountId,
|
|
261
|
+
workspaceId,
|
|
262
|
+
rigId: rig.id,
|
|
263
|
+
baseVersionId: rig.activeVersion.id,
|
|
264
|
+
kind: request.kind,
|
|
265
|
+
payload: request.payload as Record<string, unknown>,
|
|
266
|
+
proposedBy: options.proposedBy ?? rigActorForGrant(grant),
|
|
267
|
+
});
|
|
268
|
+
await recordRigAuditEvent(deps.db, {
|
|
269
|
+
grant,
|
|
270
|
+
action: "rig.change.proposed",
|
|
271
|
+
rigId: rig.id,
|
|
272
|
+
metadata: { changeId: change.id, kind: change.kind },
|
|
273
|
+
});
|
|
274
|
+
return change;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type RigVerificationClassification =
|
|
278
|
+
| { status: "merged"; action: "auto_promote" }
|
|
279
|
+
| { status: "proposed"; action: "await_manage_promote" }
|
|
280
|
+
| { status: "rejected"; action: "reject" }
|
|
281
|
+
| { status: "failed"; action: "retryable_failure" };
|
|
282
|
+
|
|
283
|
+
export function classifyRigVerificationOutcome(input: {
|
|
284
|
+
kind: "setup_append" | "definition_edit";
|
|
285
|
+
passed: boolean;
|
|
286
|
+
infraError?: boolean;
|
|
287
|
+
}): RigVerificationClassification {
|
|
288
|
+
if (input.infraError) {
|
|
289
|
+
return { status: "failed", action: "retryable_failure" };
|
|
290
|
+
}
|
|
291
|
+
if (!input.passed) {
|
|
292
|
+
return { status: "rejected", action: "reject" };
|
|
293
|
+
}
|
|
294
|
+
if (input.kind === "setup_append") {
|
|
295
|
+
return { status: "merged", action: "auto_promote" };
|
|
296
|
+
}
|
|
297
|
+
return { status: "proposed", action: "await_manage_promote" };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function appendRigSetupCommand(
|
|
301
|
+
baseSetupScript: string | null | undefined,
|
|
302
|
+
command: string,
|
|
303
|
+
): string {
|
|
304
|
+
const base = (baseSetupScript ?? "").trimEnd();
|
|
305
|
+
return base ? `${base}\n${command}` : command;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function promoteChangeWithActiveCas(
|
|
309
|
+
deps: RigServices,
|
|
310
|
+
workspaceId: string,
|
|
311
|
+
rigId: string,
|
|
312
|
+
changeId: string,
|
|
313
|
+
input: Parameters<typeof createRigVersionForChangePromotion>[4],
|
|
314
|
+
): Promise<{ version: RigVersion; change: RigChange }> {
|
|
315
|
+
try {
|
|
316
|
+
return await createRigVersionForChangePromotion(deps.db, workspaceId, rigId, changeId, input);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error instanceof RigActiveVersionChangedError) {
|
|
319
|
+
throw new HTTPException(409, {
|
|
320
|
+
message: `rig moved since this change was verified (base ${error.expectedVersionId}, now ${error.actualVersionId ?? "none"}); re-verify before promoting`,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (error instanceof RigChangeTransitionError) {
|
|
324
|
+
throw new HTTPException(409, { message: error.message });
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function promoteSetupAppendChange(
|
|
331
|
+
deps: RigServices,
|
|
332
|
+
grant: AccessGrant,
|
|
333
|
+
rig: Rig,
|
|
334
|
+
change: RigChange,
|
|
335
|
+
): Promise<{ change: RigChange; version: RigVersion }> {
|
|
336
|
+
if (change.kind !== "setup_append") {
|
|
337
|
+
throw new HTTPException(422, {
|
|
338
|
+
message: "only setup_append changes auto-promote through this path",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (change.status !== "proposed" && change.status !== "verifying") {
|
|
342
|
+
throw new HTTPException(409, { message: `rig change is ${change.status}; cannot promote` });
|
|
343
|
+
}
|
|
344
|
+
if (!change.baseVersionId) {
|
|
345
|
+
throw new HTTPException(422, { message: "rig change has no base version" });
|
|
346
|
+
}
|
|
347
|
+
const base = await getRigVersion(deps.db, grant.workspaceId, rig.id, change.baseVersionId);
|
|
348
|
+
if (!base) {
|
|
349
|
+
throw new HTTPException(404, { message: "base rig version not found" });
|
|
350
|
+
}
|
|
351
|
+
const payload = change.payload as { command?: unknown; note?: unknown };
|
|
352
|
+
if (typeof payload.command !== "string" || !payload.command.trim()) {
|
|
353
|
+
throw new HTTPException(422, { message: "setup_append change is missing command" });
|
|
354
|
+
}
|
|
355
|
+
const { version, change: updated } = await promoteChangeWithActiveCas(
|
|
356
|
+
deps,
|
|
357
|
+
grant.workspaceId,
|
|
358
|
+
rig.id,
|
|
359
|
+
change.id,
|
|
360
|
+
{
|
|
361
|
+
expectedActiveVersionId: change.baseVersionId,
|
|
362
|
+
image: base.image,
|
|
363
|
+
setupScript: appendRigSetupCommand(base.setupScript, payload.command),
|
|
364
|
+
checks: base.checks,
|
|
365
|
+
credentialHooks: base.credentialHooks,
|
|
366
|
+
defaultVariableSetIds: base.defaultVariableSetIds,
|
|
367
|
+
changelog:
|
|
368
|
+
typeof payload.note === "string" && payload.note.trim()
|
|
369
|
+
? payload.note
|
|
370
|
+
: "Verified setup append",
|
|
371
|
+
createdBy: change.proposedBy ?? rigActorForGrant(grant),
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
await recordRigAuditEvent(deps.db, {
|
|
375
|
+
grant,
|
|
376
|
+
action: "rig.change.merged",
|
|
377
|
+
rigId: rig.id,
|
|
378
|
+
metadata: { changeId: change.id, versionId: version.id, version: version.version },
|
|
379
|
+
});
|
|
380
|
+
await recordRigAuditEvent(deps.db, {
|
|
381
|
+
grant,
|
|
382
|
+
action: "rig.version.promoted",
|
|
383
|
+
rigId: rig.id,
|
|
384
|
+
metadata: { changeId: change.id, versionId: version.id, version: version.version },
|
|
385
|
+
});
|
|
386
|
+
return { change: updated, version };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function promoteVerifiedDefinitionEditChangeForApi(
|
|
390
|
+
deps: RigServices,
|
|
391
|
+
grant: AccessGrant,
|
|
392
|
+
rig: Rig,
|
|
393
|
+
change: RigChange,
|
|
394
|
+
): Promise<{ change: RigChange; version: RigVersion }> {
|
|
395
|
+
if (change.kind !== "definition_edit") {
|
|
396
|
+
throw new HTTPException(422, { message: "only definition_edit changes use explicit promote" });
|
|
397
|
+
}
|
|
398
|
+
if (change.status !== "proposed") {
|
|
399
|
+
throw new HTTPException(409, { message: `rig change is ${change.status}; cannot promote` });
|
|
400
|
+
}
|
|
401
|
+
if (change.verification?.passed !== true) {
|
|
402
|
+
throw new HTTPException(422, {
|
|
403
|
+
message: "definition_edit change must pass verification before promote",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (!change.baseVersionId) {
|
|
407
|
+
throw new HTTPException(422, { message: "rig change has no base version" });
|
|
408
|
+
}
|
|
409
|
+
const base = await getRigVersion(deps.db, grant.workspaceId, rig.id, change.baseVersionId);
|
|
410
|
+
if (!base) {
|
|
411
|
+
throw new HTTPException(404, { message: "base rig version not found" });
|
|
412
|
+
}
|
|
413
|
+
const payload = change.payload as {
|
|
414
|
+
image?: unknown;
|
|
415
|
+
setupScript?: unknown;
|
|
416
|
+
checks?: unknown;
|
|
417
|
+
credentialHooks?: unknown;
|
|
418
|
+
defaultVariableSetIds?: unknown;
|
|
419
|
+
changelog?: unknown;
|
|
420
|
+
};
|
|
421
|
+
const { version, change: updated } = await promoteChangeWithActiveCas(
|
|
422
|
+
deps,
|
|
423
|
+
grant.workspaceId,
|
|
424
|
+
rig.id,
|
|
425
|
+
change.id,
|
|
426
|
+
{
|
|
427
|
+
expectedActiveVersionId: change.baseVersionId,
|
|
428
|
+
image: payload.image === undefined ? base.image : (payload.image as string | null),
|
|
429
|
+
setupScript:
|
|
430
|
+
payload.setupScript === undefined
|
|
431
|
+
? base.setupScript
|
|
432
|
+
: (payload.setupScript as string | null),
|
|
433
|
+
checks: Array.isArray(payload.checks)
|
|
434
|
+
? (payload.checks as RigVersion["checks"])
|
|
435
|
+
: base.checks,
|
|
436
|
+
credentialHooks: Array.isArray(payload.credentialHooks)
|
|
437
|
+
? (payload.credentialHooks as string[])
|
|
438
|
+
: base.credentialHooks,
|
|
439
|
+
defaultVariableSetIds: Array.isArray(payload.defaultVariableSetIds)
|
|
440
|
+
? (payload.defaultVariableSetIds as string[])
|
|
441
|
+
: base.defaultVariableSetIds,
|
|
442
|
+
changelog:
|
|
443
|
+
typeof payload.changelog === "string" && payload.changelog.trim()
|
|
444
|
+
? payload.changelog
|
|
445
|
+
: "Verified definition edit",
|
|
446
|
+
createdBy: rigActorForGrant(grant),
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
await recordRigAuditEvent(deps.db, {
|
|
450
|
+
grant,
|
|
451
|
+
action: "rig.change.merged",
|
|
452
|
+
rigId: rig.id,
|
|
453
|
+
metadata: { changeId: change.id, versionId: version.id, version: version.version },
|
|
454
|
+
});
|
|
455
|
+
await recordRigAuditEvent(deps.db, {
|
|
456
|
+
grant,
|
|
457
|
+
action: "rig.version.promoted",
|
|
458
|
+
rigId: rig.id,
|
|
459
|
+
metadata: { changeId: change.id, versionId: version.id, version: version.version },
|
|
460
|
+
});
|
|
461
|
+
return { change: updated, version };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function createRigVersionForApi(
|
|
465
|
+
deps: RigServices,
|
|
466
|
+
grant: AccessGrant,
|
|
467
|
+
rig: Rig,
|
|
468
|
+
payload: RigDefinitionEditPayload,
|
|
469
|
+
): Promise<RigVersion> {
|
|
470
|
+
if (!rig.activeVersion) {
|
|
471
|
+
throw new HTTPException(422, { message: "rig has no active version" });
|
|
472
|
+
}
|
|
473
|
+
assertUniqueCheckNames(payload.checks);
|
|
474
|
+
await assertVariableSetsExist(
|
|
475
|
+
deps.db,
|
|
476
|
+
grant.workspaceId,
|
|
477
|
+
payload.defaultVariableSetIds ?? undefined,
|
|
478
|
+
);
|
|
479
|
+
const base = rig.activeVersion;
|
|
480
|
+
const version = await createRigVersion(
|
|
481
|
+
deps.db,
|
|
482
|
+
grant.workspaceId,
|
|
483
|
+
rig.id,
|
|
484
|
+
{
|
|
485
|
+
image: payload.image === undefined ? base.image : payload.image,
|
|
486
|
+
setupScript: payload.setupScript === undefined ? base.setupScript : payload.setupScript,
|
|
487
|
+
checks: payload.checks ?? base.checks,
|
|
488
|
+
credentialHooks: payload.credentialHooks ?? base.credentialHooks,
|
|
489
|
+
defaultVariableSetIds: payload.defaultVariableSetIds ?? base.defaultVariableSetIds,
|
|
490
|
+
changelog: payload.changelog ?? "Manager-created version",
|
|
491
|
+
createdBy: rigActorForGrant(grant),
|
|
492
|
+
},
|
|
493
|
+
{ activate: true },
|
|
494
|
+
);
|
|
495
|
+
await recordRigAuditEvent(deps.db, {
|
|
496
|
+
grant,
|
|
497
|
+
action: "rig.version.promoted",
|
|
498
|
+
rigId: rig.id,
|
|
499
|
+
metadata: { versionId: version.id, version: version.version, direct: true },
|
|
500
|
+
});
|
|
501
|
+
return version;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Rollback / promote-activate: flips which existing version is active. Mints no
|
|
505
|
+
// new version and never touches content.
|
|
506
|
+
export async function activateRigVersionForApi(
|
|
507
|
+
deps: RigServices,
|
|
508
|
+
grant: AccessGrant,
|
|
509
|
+
rig: Rig,
|
|
510
|
+
versionId: string,
|
|
511
|
+
): Promise<RigVersion> {
|
|
512
|
+
const workspaceId = grant.workspaceId;
|
|
513
|
+
const version = await activateRigVersion(deps.db, workspaceId, rig.id, versionId);
|
|
514
|
+
await recordRigAuditEvent(deps.db, {
|
|
515
|
+
grant,
|
|
516
|
+
action: "rig.version.activated",
|
|
517
|
+
rigId: rig.id,
|
|
518
|
+
metadata: { versionId: version.id, version: version.version },
|
|
519
|
+
});
|
|
520
|
+
return version;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Read pass-throughs (route-facing; keep the route thin and the imports in one
|
|
524
|
+
// place). Versions/changes are always addressed under their rig.
|
|
525
|
+
export async function listRigVersionsForApi(
|
|
526
|
+
deps: RigServices,
|
|
527
|
+
workspaceId: string,
|
|
528
|
+
rigId: string,
|
|
529
|
+
): Promise<RigVersion[]> {
|
|
530
|
+
return await listRigVersions(deps.db, workspaceId, rigId);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export async function listRigChangesForApi(
|
|
534
|
+
deps: RigServices,
|
|
535
|
+
workspaceId: string,
|
|
536
|
+
rigId: string,
|
|
537
|
+
limit?: number,
|
|
538
|
+
): Promise<RigChange[]> {
|
|
539
|
+
return await listRigChanges(deps.db, workspaceId, rigId, limit);
|
|
540
|
+
}
|