@opengeni/db 0.7.0 → 0.7.2
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/{chunk-O3D7DABC.js → chunk-B22X3IEZ.js} +369 -49
- package/dist/chunk-B22X3IEZ.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15133 -13199
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.d.ts +475 -211
- package/dist/{schema-Dp2MbBxx.d.ts → schema-BN5mB9xZ.d.ts} +1365 -256
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -3
- package/drizzle/0063_session_control_mega_foundation.sql +1324 -0
- package/package.json +3 -7
- package/src/index.ts +1474 -2898
- package/src/schema.ts +391 -60
- package/src/session-control.ts +1759 -0
- package/src/session-queue-commands.ts +1753 -0
- package/src/session-tool-call-settlement.ts +269 -0
- package/dist/chunk-O3D7DABC.js.map +0 -1
- package/src/session-control-cutover-audit.ts +0 -1203
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
|
+
import type { Database } from "./index";
|
|
4
|
+
import * as schema from "./schema";
|
|
5
|
+
|
|
6
|
+
export const SESSION_ANCESTRY_LIMIT = 10_000;
|
|
7
|
+
|
|
8
|
+
export type WorkspaceControlLockMode = "share" | "update";
|
|
9
|
+
export type EffectiveControlState = "active" | "paused";
|
|
10
|
+
export type SessionCommandActor =
|
|
11
|
+
| { type: "human" | "operator"; subjectId: string }
|
|
12
|
+
| {
|
|
13
|
+
type: "agent_attempt";
|
|
14
|
+
attemptId: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
turnId: string;
|
|
17
|
+
executionGeneration: number;
|
|
18
|
+
};
|
|
19
|
+
export type SessionTurnAttemptOutcome =
|
|
20
|
+
| "completed"
|
|
21
|
+
| "failed"
|
|
22
|
+
| "cancelled"
|
|
23
|
+
| "superseded"
|
|
24
|
+
| "requires_action"
|
|
25
|
+
| "interrupted_recoverable"
|
|
26
|
+
| "lease_lost_recoverable"
|
|
27
|
+
| "pre_cutover_closed";
|
|
28
|
+
|
|
29
|
+
export type EffectiveControlBlocker = {
|
|
30
|
+
kind: "session" | "workspace";
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
actor: string | null;
|
|
34
|
+
reason: string | null;
|
|
35
|
+
changedAt: Date | null;
|
|
36
|
+
revision: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type EffectiveControlResumeOption = {
|
|
40
|
+
scope: "selected" | "session" | "workspace";
|
|
41
|
+
targetId?: string;
|
|
42
|
+
selectedStateAfter: EffectiveControlState;
|
|
43
|
+
remainingPrimaryBlocker?: EffectiveControlBlocker;
|
|
44
|
+
impactCopy: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type EffectiveSessionControl = {
|
|
48
|
+
state: EffectiveControlState;
|
|
49
|
+
controlVersion: number;
|
|
50
|
+
controlEtag: string;
|
|
51
|
+
directState: EffectiveControlState;
|
|
52
|
+
primaryBlocker: EffectiveControlBlocker | null;
|
|
53
|
+
additionalBlockerCount: number;
|
|
54
|
+
blockers: EffectiveControlBlocker[];
|
|
55
|
+
resumeOptions: EffectiveControlResumeOption[];
|
|
56
|
+
override: { rootSessionId: string; revision: number } | null;
|
|
57
|
+
settlement: { state: "stopping"; attemptCount: number } | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function serializeEffectiveSessionControl(control: EffectiveSessionControl) {
|
|
61
|
+
const blocker = (
|
|
62
|
+
value: EffectiveControlBlocker,
|
|
63
|
+
): Omit<EffectiveControlBlocker, "changedAt"> & {
|
|
64
|
+
changedAt: string | null;
|
|
65
|
+
} => ({
|
|
66
|
+
...value,
|
|
67
|
+
changedAt: value.changedAt?.toISOString() ?? null,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
...control,
|
|
71
|
+
primaryBlocker: control.primaryBlocker ? blocker(control.primaryBlocker) : null,
|
|
72
|
+
blockers: control.blockers.map(blocker),
|
|
73
|
+
resumeOptions: control.resumeOptions.map(({ remainingPrimaryBlocker, ...option }) => ({
|
|
74
|
+
...option,
|
|
75
|
+
...(remainingPrimaryBlocker
|
|
76
|
+
? { remainingPrimaryBlocker: blocker(remainingPrimaryBlocker) }
|
|
77
|
+
: {}),
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type WorkspaceControlRow = {
|
|
83
|
+
workspaceId: string;
|
|
84
|
+
accountId: string;
|
|
85
|
+
revision: number | string;
|
|
86
|
+
workspaceState: string;
|
|
87
|
+
workspacePauseRevision: number | string | null;
|
|
88
|
+
reason: string | null;
|
|
89
|
+
changedBy: string | null;
|
|
90
|
+
changedAt: Date | string | null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type AncestryRow = {
|
|
94
|
+
targetId: string;
|
|
95
|
+
sessionId: string;
|
|
96
|
+
parentSessionId: string | null;
|
|
97
|
+
title: string | null;
|
|
98
|
+
directState: string;
|
|
99
|
+
directPauseRevision: number | string | null;
|
|
100
|
+
subtreeRunOverrideRevision: number | string | null;
|
|
101
|
+
controlVersion: number | string;
|
|
102
|
+
directControlChangedBy: string | null;
|
|
103
|
+
directControlReason: string | null;
|
|
104
|
+
directControlChangedAt: Date | string | null;
|
|
105
|
+
depth: number | string;
|
|
106
|
+
cycle: boolean;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type AncestryNode = Omit<AncestryRow, "targetId" | "depth" | "cycle">;
|
|
110
|
+
|
|
111
|
+
export type SessionCommandReceiptRow = typeof schema.sessionCommandReceipts.$inferSelect;
|
|
112
|
+
|
|
113
|
+
export class SessionControlInvariantError extends Error {
|
|
114
|
+
readonly code = "SESSION_CONTROL_INVARIANT";
|
|
115
|
+
|
|
116
|
+
constructor(message: string) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = "SessionControlInvariantError";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class SessionControlConflictError extends Error {
|
|
123
|
+
readonly code = "CONTROL_CHANGED";
|
|
124
|
+
|
|
125
|
+
constructor(message = "The workstream control changed") {
|
|
126
|
+
super(message);
|
|
127
|
+
this.name = "SessionControlConflictError";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class SessionCommandIdempotencyError extends Error {
|
|
132
|
+
readonly code = "IDEMPOTENCY_KEY_REUSED";
|
|
133
|
+
|
|
134
|
+
constructor() {
|
|
135
|
+
super("The operation key was already used with different input");
|
|
136
|
+
this.name = "SessionCommandIdempotencyError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class AgentCommandAuthorityError extends Error {
|
|
141
|
+
constructor(
|
|
142
|
+
readonly code: "CALLER_STALE" | "CALLER_INTERRUPTED" | "SELF_OR_ANCESTOR_PAUSE" | "SELF_STEER",
|
|
143
|
+
message: string,
|
|
144
|
+
) {
|
|
145
|
+
super(message);
|
|
146
|
+
this.name = "AgentCommandAuthorityError";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function assertAgentCommandAuthorityInTransaction(
|
|
151
|
+
db: Database,
|
|
152
|
+
input: {
|
|
153
|
+
workspaceId: string;
|
|
154
|
+
actor: Extract<SessionCommandActor, { type: "agent_attempt" }>;
|
|
155
|
+
targetSessionId: string;
|
|
156
|
+
action: "pause" | "resume" | "steer" | "message";
|
|
157
|
+
},
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const lockedSessions = await db
|
|
160
|
+
.select({ id: schema.sessions.id })
|
|
161
|
+
.from(schema.sessions)
|
|
162
|
+
.where(
|
|
163
|
+
and(
|
|
164
|
+
eq(schema.sessions.workspaceId, input.workspaceId),
|
|
165
|
+
sql`${schema.sessions.id} in (${input.actor.sessionId}::uuid, ${input.targetSessionId}::uuid)`,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
.orderBy(schema.sessions.id)
|
|
169
|
+
.for("update");
|
|
170
|
+
if (!lockedSessions.some((row) => row.id === input.actor.sessionId)) {
|
|
171
|
+
throw new AgentCommandAuthorityError("CALLER_STALE", "The calling session no longer exists");
|
|
172
|
+
}
|
|
173
|
+
if (!lockedSessions.some((row) => row.id === input.targetSessionId)) {
|
|
174
|
+
throw new SessionControlInvariantError(`Target session not found: ${input.targetSessionId}`);
|
|
175
|
+
}
|
|
176
|
+
const rows = await db.execute<{
|
|
177
|
+
attemptId: string;
|
|
178
|
+
attemptState: string;
|
|
179
|
+
attemptSessionId: string;
|
|
180
|
+
attemptTurnId: string;
|
|
181
|
+
executionGeneration: number;
|
|
182
|
+
activeAttemptId: string | null;
|
|
183
|
+
turnStatus: string;
|
|
184
|
+
activeTurnId: string | null;
|
|
185
|
+
interrupted: boolean;
|
|
186
|
+
}>(sql`
|
|
187
|
+
select
|
|
188
|
+
attempt.id as "attemptId",
|
|
189
|
+
attempt.state as "attemptState",
|
|
190
|
+
attempt.session_id as "attemptSessionId",
|
|
191
|
+
attempt.turn_id as "attemptTurnId",
|
|
192
|
+
attempt.execution_generation as "executionGeneration",
|
|
193
|
+
turn.active_attempt_id as "activeAttemptId",
|
|
194
|
+
turn.status as "turnStatus",
|
|
195
|
+
session.active_turn_id as "activeTurnId",
|
|
196
|
+
exists (
|
|
197
|
+
select 1
|
|
198
|
+
from ${schema.sessionAttemptInterruptions} interruption
|
|
199
|
+
where interruption.workspace_id = ${input.workspaceId}
|
|
200
|
+
and interruption.attempt_id = attempt.id
|
|
201
|
+
and interruption.state in ('pending', 'delivered', 'acknowledged')
|
|
202
|
+
) as interrupted
|
|
203
|
+
from ${schema.sessionTurnAttempts} attempt
|
|
204
|
+
join ${schema.sessionTurns} turn
|
|
205
|
+
on turn.workspace_id = attempt.workspace_id and turn.id = attempt.turn_id
|
|
206
|
+
join ${schema.sessions} session
|
|
207
|
+
on session.workspace_id = attempt.workspace_id and session.id = attempt.session_id
|
|
208
|
+
where attempt.workspace_id = ${input.workspaceId}
|
|
209
|
+
and attempt.id = ${input.actor.attemptId}
|
|
210
|
+
for update of attempt, turn
|
|
211
|
+
`);
|
|
212
|
+
const caller = rows[0];
|
|
213
|
+
if (
|
|
214
|
+
!caller ||
|
|
215
|
+
!["claimed", "running"].includes(caller.attemptState) ||
|
|
216
|
+
caller.attemptSessionId !== input.actor.sessionId ||
|
|
217
|
+
caller.attemptTurnId !== input.actor.turnId ||
|
|
218
|
+
Number(caller.executionGeneration) !== input.actor.executionGeneration ||
|
|
219
|
+
caller.activeAttemptId !== input.actor.attemptId ||
|
|
220
|
+
caller.activeTurnId !== input.actor.turnId ||
|
|
221
|
+
!["running", "requires_action", "recovering", "waiting_capacity"].includes(caller.turnStatus)
|
|
222
|
+
) {
|
|
223
|
+
throw new AgentCommandAuthorityError(
|
|
224
|
+
"CALLER_STALE",
|
|
225
|
+
"The calling agent attempt no longer owns its turn",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (caller.interrupted) {
|
|
229
|
+
throw new AgentCommandAuthorityError(
|
|
230
|
+
"CALLER_INTERRUPTED",
|
|
231
|
+
"The calling agent attempt is being interrupted",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (input.action === "steer" && input.targetSessionId === input.actor.sessionId) {
|
|
235
|
+
throw new AgentCommandAuthorityError("SELF_STEER", "An agent cannot steer its own session");
|
|
236
|
+
}
|
|
237
|
+
if (input.action !== "pause") return;
|
|
238
|
+
const ancestry = await db.execute<{
|
|
239
|
+
containsCaller: boolean;
|
|
240
|
+
invalid: boolean;
|
|
241
|
+
}>(sql`
|
|
242
|
+
with recursive caller_ancestry(id, parent_id, depth, path, cycle) as (
|
|
243
|
+
select session.id, session.parent_session_id, 0, array[session.id], false
|
|
244
|
+
from ${schema.sessions} session
|
|
245
|
+
where session.workspace_id = ${input.workspaceId}
|
|
246
|
+
and session.id = ${input.actor.sessionId}
|
|
247
|
+
union all
|
|
248
|
+
select parent.id, parent.parent_session_id, child.depth + 1,
|
|
249
|
+
child.path || parent.id, parent.id = any(child.path)
|
|
250
|
+
from caller_ancestry child
|
|
251
|
+
join ${schema.sessions} parent
|
|
252
|
+
on parent.workspace_id = ${input.workspaceId} and parent.id = child.parent_id
|
|
253
|
+
where child.depth < ${SESSION_ANCESTRY_LIMIT} and not child.cycle
|
|
254
|
+
)
|
|
255
|
+
select
|
|
256
|
+
coalesce(bool_or(id = ${input.targetSessionId}), false) as "containsCaller",
|
|
257
|
+
coalesce(bool_or(cycle), false) or coalesce(max(depth), 0) >= ${SESSION_ANCESTRY_LIMIT}
|
|
258
|
+
as invalid
|
|
259
|
+
from caller_ancestry
|
|
260
|
+
`);
|
|
261
|
+
if (ancestry[0]?.invalid) {
|
|
262
|
+
throw new SessionControlInvariantError("Caller ancestry is invalid");
|
|
263
|
+
}
|
|
264
|
+
if (ancestry[0]?.containsCaller) {
|
|
265
|
+
throw new AgentCommandAuthorityError(
|
|
266
|
+
"SELF_OR_ANCESTOR_PAUSE",
|
|
267
|
+
"An agent cannot pause its own session or an ancestor workstream",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function asSafeRevision(value: number | string | null, label: string): number | null {
|
|
273
|
+
if (value === null) return null;
|
|
274
|
+
const revision = Number(value);
|
|
275
|
+
if (!Number.isSafeInteger(revision) || revision < 0) {
|
|
276
|
+
throw new SessionControlInvariantError(`${label} is not a safe non-negative revision`);
|
|
277
|
+
}
|
|
278
|
+
return revision;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function asDate(value: Date | string | null): Date | null {
|
|
282
|
+
if (value === null) return null;
|
|
283
|
+
return value instanceof Date ? value : new Date(value);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function canonicalize(value: unknown): unknown {
|
|
287
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
288
|
+
if (value && typeof value === "object") {
|
|
289
|
+
return Object.fromEntries(
|
|
290
|
+
Object.entries(value as Record<string, unknown>)
|
|
291
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
292
|
+
.map(([key, item]) => [key, canonicalize(item)]),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function canonicalSessionCommandHash(value: unknown): string {
|
|
299
|
+
return createHash("sha256")
|
|
300
|
+
.update(JSON.stringify(canonicalize(value)))
|
|
301
|
+
.digest("hex");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function controlEtag(value: unknown): string {
|
|
305
|
+
return `sc1:${canonicalSessionCommandHash(value)}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function lockClause(mode: WorkspaceControlLockMode) {
|
|
309
|
+
return mode === "update" ? sql.raw("for update") : sql.raw("for share");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function lockWorkspaceInferenceControl(
|
|
313
|
+
db: Database,
|
|
314
|
+
workspaceId: string,
|
|
315
|
+
mode: WorkspaceControlLockMode,
|
|
316
|
+
): Promise<WorkspaceControlRow> {
|
|
317
|
+
const rows = await db.execute<WorkspaceControlRow>(sql`
|
|
318
|
+
select
|
|
319
|
+
workspace_id as "workspaceId",
|
|
320
|
+
account_id as "accountId",
|
|
321
|
+
revision,
|
|
322
|
+
workspace_state as "workspaceState",
|
|
323
|
+
workspace_pause_revision as "workspacePauseRevision",
|
|
324
|
+
reason,
|
|
325
|
+
changed_by as "changedBy",
|
|
326
|
+
changed_at as "changedAt"
|
|
327
|
+
from ${schema.workspaceInferenceControls}
|
|
328
|
+
where workspace_id = ${workspaceId}
|
|
329
|
+
${lockClause(mode)}
|
|
330
|
+
`);
|
|
331
|
+
const row = rows[0];
|
|
332
|
+
if (!row) {
|
|
333
|
+
throw new SessionControlInvariantError(
|
|
334
|
+
`Workspace ${workspaceId} has no mandatory inference-control row`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return row;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function registerSessionTurnAttemptClaim(
|
|
341
|
+
db: Database,
|
|
342
|
+
input: {
|
|
343
|
+
id: string;
|
|
344
|
+
accountId: string;
|
|
345
|
+
workspaceId: string;
|
|
346
|
+
sessionId: string;
|
|
347
|
+
turnId: string;
|
|
348
|
+
executionGeneration: number;
|
|
349
|
+
temporalWorkflowId: string;
|
|
350
|
+
temporalWorkflowRunId: string;
|
|
351
|
+
temporalActivityId: string;
|
|
352
|
+
verifiedControlRevision: number;
|
|
353
|
+
},
|
|
354
|
+
): Promise<typeof schema.sessionTurnAttempts.$inferSelect> {
|
|
355
|
+
const [inserted] = await db
|
|
356
|
+
.insert(schema.sessionTurnAttempts)
|
|
357
|
+
.values({
|
|
358
|
+
...input,
|
|
359
|
+
state: "claimed",
|
|
360
|
+
})
|
|
361
|
+
// Only an idempotent replay of this exact preallocated attempt ID may
|
|
362
|
+
// enter the comparison path below. A collision on live-session, live-turn,
|
|
363
|
+
// or Temporal dispatch ownership is a distinct invariant violation and
|
|
364
|
+
// must not be disguised as an attempt-ID conflict.
|
|
365
|
+
.onConflictDoNothing({ target: schema.sessionTurnAttempts.id })
|
|
366
|
+
.returning();
|
|
367
|
+
if (inserted) return inserted;
|
|
368
|
+
const [existing] = await db
|
|
369
|
+
.select()
|
|
370
|
+
.from(schema.sessionTurnAttempts)
|
|
371
|
+
.where(
|
|
372
|
+
and(
|
|
373
|
+
eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
|
|
374
|
+
eq(schema.sessionTurnAttempts.id, input.id),
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
.for("update")
|
|
378
|
+
.limit(1);
|
|
379
|
+
if (
|
|
380
|
+
!existing ||
|
|
381
|
+
existing.accountId !== input.accountId ||
|
|
382
|
+
existing.sessionId !== input.sessionId ||
|
|
383
|
+
existing.turnId !== input.turnId ||
|
|
384
|
+
existing.executionGeneration !== input.executionGeneration ||
|
|
385
|
+
existing.temporalWorkflowId !== input.temporalWorkflowId ||
|
|
386
|
+
existing.temporalWorkflowRunId !== input.temporalWorkflowRunId ||
|
|
387
|
+
existing.temporalActivityId !== input.temporalActivityId ||
|
|
388
|
+
existing.state === "closed"
|
|
389
|
+
) {
|
|
390
|
+
throw new SessionControlInvariantError(
|
|
391
|
+
`Attempt ${input.id} conflicts with a different or closed ownership chain`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return existing;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Close the exact first-class owner while the caller still holds the owning
|
|
399
|
+
* session/turn locks. Every path that clears `session_turns.active_attempt_id`
|
|
400
|
+
* must call this in the same transaction; otherwise a later claim would either
|
|
401
|
+
* collide with a zombie live owner or have to weaken the ownership fence.
|
|
402
|
+
*/
|
|
403
|
+
export async function closeSessionTurnAttemptInTransaction(
|
|
404
|
+
db: Database,
|
|
405
|
+
input: {
|
|
406
|
+
id: string;
|
|
407
|
+
accountId: string;
|
|
408
|
+
workspaceId: string;
|
|
409
|
+
sessionId: string;
|
|
410
|
+
turnId: string;
|
|
411
|
+
executionGeneration: number;
|
|
412
|
+
outcome: SessionTurnAttemptOutcome;
|
|
413
|
+
closedAt?: Date;
|
|
414
|
+
},
|
|
415
|
+
): Promise<{ action: "closed" | "already_closed" }> {
|
|
416
|
+
const [attempt] = await db
|
|
417
|
+
.select()
|
|
418
|
+
.from(schema.sessionTurnAttempts)
|
|
419
|
+
.where(
|
|
420
|
+
and(
|
|
421
|
+
eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
|
|
422
|
+
eq(schema.sessionTurnAttempts.id, input.id),
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
.for("update")
|
|
426
|
+
.limit(1);
|
|
427
|
+
if (
|
|
428
|
+
!attempt ||
|
|
429
|
+
attempt.accountId !== input.accountId ||
|
|
430
|
+
attempt.sessionId !== input.sessionId ||
|
|
431
|
+
attempt.turnId !== input.turnId ||
|
|
432
|
+
attempt.executionGeneration !== input.executionGeneration
|
|
433
|
+
) {
|
|
434
|
+
throw new SessionControlInvariantError(
|
|
435
|
+
`Attempt ${input.id} does not own the expected session turn generation`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (attempt.state === "closed") {
|
|
439
|
+
if (attempt.outcome !== input.outcome) {
|
|
440
|
+
throw new SessionControlInvariantError(
|
|
441
|
+
`Attempt ${input.id} is already closed as ${attempt.outcome ?? "unknown"}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return { action: "already_closed" };
|
|
445
|
+
}
|
|
446
|
+
if (attempt.state !== "claimed" && attempt.state !== "running") {
|
|
447
|
+
throw new SessionControlInvariantError(
|
|
448
|
+
`Attempt ${input.id} has invalid live state ${attempt.state}`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
const now = input.closedAt ?? new Date();
|
|
452
|
+
const [closed] = await db
|
|
453
|
+
.update(schema.sessionTurnAttempts)
|
|
454
|
+
.set({
|
|
455
|
+
state: "closed",
|
|
456
|
+
outcome: input.outcome,
|
|
457
|
+
workerId: null,
|
|
458
|
+
leaseId: null,
|
|
459
|
+
leaseExpiresAt: null,
|
|
460
|
+
closedAt: now,
|
|
461
|
+
updatedAt: now,
|
|
462
|
+
})
|
|
463
|
+
.where(
|
|
464
|
+
and(
|
|
465
|
+
eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
|
|
466
|
+
eq(schema.sessionTurnAttempts.id, input.id),
|
|
467
|
+
sql`${schema.sessionTurnAttempts.state} in ('claimed', 'running')`,
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
.returning({ id: schema.sessionTurnAttempts.id });
|
|
471
|
+
if (!closed) {
|
|
472
|
+
throw new SessionControlInvariantError(`Attempt ${input.id} changed while locked`);
|
|
473
|
+
}
|
|
474
|
+
return { action: "closed" };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function targetValues(sessionIds: string[]) {
|
|
478
|
+
if (sessionIds.length === 0) {
|
|
479
|
+
throw new SessionControlInvariantError("At least one target session is required");
|
|
480
|
+
}
|
|
481
|
+
return sql.join(
|
|
482
|
+
sessionIds.map((id) => sql`(${id}::uuid)`),
|
|
483
|
+
sql`, `,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const TARGET_PATH_PROJECTION_LIMIT = 128;
|
|
488
|
+
|
|
489
|
+
async function loadTargetAncestryRows(
|
|
490
|
+
db: Database,
|
|
491
|
+
workspaceId: string,
|
|
492
|
+
sessionIds: string[],
|
|
493
|
+
): Promise<AncestryRow[]> {
|
|
494
|
+
return await db.execute<AncestryRow>(sql`
|
|
495
|
+
with recursive targets(id) as (values ${targetValues(sessionIds)}),
|
|
496
|
+
ancestry as (
|
|
497
|
+
select
|
|
498
|
+
target.id as target_id,
|
|
499
|
+
session.id as session_id,
|
|
500
|
+
session.parent_session_id,
|
|
501
|
+
session.title,
|
|
502
|
+
session.direct_control_state,
|
|
503
|
+
session.direct_pause_revision,
|
|
504
|
+
session.subtree_run_override_revision,
|
|
505
|
+
session.control_version,
|
|
506
|
+
session.direct_control_changed_by,
|
|
507
|
+
session.direct_control_reason,
|
|
508
|
+
session.direct_control_changed_at,
|
|
509
|
+
0::integer as depth,
|
|
510
|
+
array[session.id]::uuid[] as path,
|
|
511
|
+
false as cycle
|
|
512
|
+
from targets target
|
|
513
|
+
join ${schema.sessions} session
|
|
514
|
+
on session.workspace_id = ${workspaceId} and session.id = target.id
|
|
515
|
+
union all
|
|
516
|
+
select
|
|
517
|
+
child.target_id,
|
|
518
|
+
parent.id,
|
|
519
|
+
parent.parent_session_id,
|
|
520
|
+
parent.title,
|
|
521
|
+
parent.direct_control_state,
|
|
522
|
+
parent.direct_pause_revision,
|
|
523
|
+
parent.subtree_run_override_revision,
|
|
524
|
+
parent.control_version,
|
|
525
|
+
parent.direct_control_changed_by,
|
|
526
|
+
parent.direct_control_reason,
|
|
527
|
+
parent.direct_control_changed_at,
|
|
528
|
+
child.depth + 1,
|
|
529
|
+
child.path || parent.id,
|
|
530
|
+
parent.id = any(child.path)
|
|
531
|
+
from ancestry child
|
|
532
|
+
join ${schema.sessions} parent
|
|
533
|
+
on parent.workspace_id = ${workspaceId} and parent.id = child.parent_session_id
|
|
534
|
+
where child.parent_session_id is not null
|
|
535
|
+
and not child.cycle
|
|
536
|
+
and child.depth < ${SESSION_ANCESTRY_LIMIT}
|
|
537
|
+
)
|
|
538
|
+
select
|
|
539
|
+
target_id as "targetId",
|
|
540
|
+
session_id as "sessionId",
|
|
541
|
+
parent_session_id as "parentSessionId",
|
|
542
|
+
title,
|
|
543
|
+
direct_control_state as "directState",
|
|
544
|
+
direct_pause_revision as "directPauseRevision",
|
|
545
|
+
subtree_run_override_revision as "subtreeRunOverrideRevision",
|
|
546
|
+
control_version as "controlVersion",
|
|
547
|
+
direct_control_changed_by as "directControlChangedBy",
|
|
548
|
+
direct_control_reason as "directControlReason",
|
|
549
|
+
direct_control_changed_at as "directControlChangedAt",
|
|
550
|
+
depth,
|
|
551
|
+
cycle
|
|
552
|
+
from ancestry
|
|
553
|
+
order by target_id, depth
|
|
554
|
+
`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function loadAncestryNodes(
|
|
558
|
+
db: Database,
|
|
559
|
+
workspaceId: string,
|
|
560
|
+
sessionIds: string[],
|
|
561
|
+
): Promise<Map<string, AncestryNode>> {
|
|
562
|
+
const rows = await db.execute<AncestryNode>(sql`
|
|
563
|
+
with recursive targets(id) as (values ${targetValues(sessionIds)}),
|
|
564
|
+
ancestry_ids(id) as (
|
|
565
|
+
select session.id
|
|
566
|
+
from targets target
|
|
567
|
+
join ${schema.sessions} session
|
|
568
|
+
on session.workspace_id = ${workspaceId} and session.id = target.id
|
|
569
|
+
union
|
|
570
|
+
select parent.id
|
|
571
|
+
from ancestry_ids child
|
|
572
|
+
join ${schema.sessions} current
|
|
573
|
+
on current.workspace_id = ${workspaceId} and current.id = child.id
|
|
574
|
+
join ${schema.sessions} parent
|
|
575
|
+
on parent.workspace_id = current.workspace_id
|
|
576
|
+
and parent.id = current.parent_session_id
|
|
577
|
+
)
|
|
578
|
+
select
|
|
579
|
+
session.id as "sessionId",
|
|
580
|
+
session.parent_session_id as "parentSessionId",
|
|
581
|
+
session.title,
|
|
582
|
+
session.direct_control_state as "directState",
|
|
583
|
+
session.direct_pause_revision as "directPauseRevision",
|
|
584
|
+
session.subtree_run_override_revision as "subtreeRunOverrideRevision",
|
|
585
|
+
session.control_version as "controlVersion",
|
|
586
|
+
session.direct_control_changed_by as "directControlChangedBy",
|
|
587
|
+
session.direct_control_reason as "directControlReason",
|
|
588
|
+
session.direct_control_changed_at as "directControlChangedAt"
|
|
589
|
+
from ancestry_ids ancestry
|
|
590
|
+
join ${schema.sessions} session
|
|
591
|
+
on session.workspace_id = ${workspaceId} and session.id = ancestry.id
|
|
592
|
+
`);
|
|
593
|
+
return new Map(rows.map((row: AncestryNode) => [row.sessionId, row]));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function ancestryRowsForTarget(targetId: string, nodes: Map<string, AncestryNode>): AncestryRow[] {
|
|
597
|
+
const rows: AncestryRow[] = [];
|
|
598
|
+
const path = new Set<string>();
|
|
599
|
+
let sessionId: string | null = targetId;
|
|
600
|
+
for (let depth = 0; sessionId !== null; depth += 1) {
|
|
601
|
+
const node = nodes.get(sessionId);
|
|
602
|
+
if (!node) break;
|
|
603
|
+
const cycle = path.has(sessionId);
|
|
604
|
+
rows.push({ ...node, targetId, depth, cycle });
|
|
605
|
+
if (cycle || depth >= SESSION_ANCESTRY_LIMIT) break;
|
|
606
|
+
path.add(sessionId);
|
|
607
|
+
sessionId = node.parentSessionId;
|
|
608
|
+
}
|
|
609
|
+
return rows;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function assertCompleteAncestry(sessionId: string, rows: AncestryRow[]): void {
|
|
613
|
+
if (rows.length === 0) {
|
|
614
|
+
throw new SessionControlInvariantError(`Session ${sessionId} does not exist in its workspace`);
|
|
615
|
+
}
|
|
616
|
+
if (rows.some((row) => row.cycle)) {
|
|
617
|
+
throw new SessionControlInvariantError(`Session ${sessionId} has cyclic ancestry`);
|
|
618
|
+
}
|
|
619
|
+
const last = rows.at(-1)!;
|
|
620
|
+
const depth = Number(last.depth);
|
|
621
|
+
if (last.parentSessionId !== null) {
|
|
622
|
+
throw new SessionControlInvariantError(
|
|
623
|
+
depth >= SESSION_ANCESTRY_LIMIT
|
|
624
|
+
? `Session ${sessionId} ancestry exceeds ${SESSION_ANCESTRY_LIMIT}`
|
|
625
|
+
: `Session ${sessionId} has a missing ancestor ${last.parentSessionId}`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function blockerForSession(row: AncestryRow, revision: number): EffectiveControlBlocker {
|
|
631
|
+
return {
|
|
632
|
+
kind: "session",
|
|
633
|
+
sessionId: row.sessionId,
|
|
634
|
+
displayName: row.title?.trim() || "Untitled session",
|
|
635
|
+
actor: row.directControlChangedBy,
|
|
636
|
+
reason: row.directControlReason,
|
|
637
|
+
changedAt: asDate(row.directControlChangedAt),
|
|
638
|
+
revision,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function projectEffectiveControl(
|
|
643
|
+
workspace: WorkspaceControlRow,
|
|
644
|
+
targetId: string,
|
|
645
|
+
rows: AncestryRow[],
|
|
646
|
+
stoppingAttempts: number,
|
|
647
|
+
): EffectiveSessionControl {
|
|
648
|
+
assertCompleteAncestry(targetId, rows);
|
|
649
|
+
const workspaceRevision = asSafeRevision(workspace.revision, "workspace control revision")!;
|
|
650
|
+
const path = rows.map((row) => ({
|
|
651
|
+
row,
|
|
652
|
+
depth: Number(row.depth),
|
|
653
|
+
pauseRevision: asSafeRevision(row.directPauseRevision, "direct pause revision"),
|
|
654
|
+
overrideRevision: asSafeRevision(
|
|
655
|
+
row.subtreeRunOverrideRevision,
|
|
656
|
+
"subtree run override revision",
|
|
657
|
+
),
|
|
658
|
+
controlVersion: asSafeRevision(row.controlVersion, "session control version")!,
|
|
659
|
+
}));
|
|
660
|
+
|
|
661
|
+
const undefeated: Array<{ blocker: EffectiveControlBlocker; depth: number }> = [];
|
|
662
|
+
for (const candidate of path) {
|
|
663
|
+
if (candidate.row.directState !== "paused" || candidate.pauseRevision === null) continue;
|
|
664
|
+
const defeated = path.some(
|
|
665
|
+
(possibleOverride) =>
|
|
666
|
+
possibleOverride.depth < candidate.depth &&
|
|
667
|
+
possibleOverride.overrideRevision !== null &&
|
|
668
|
+
possibleOverride.overrideRevision > candidate.pauseRevision!,
|
|
669
|
+
);
|
|
670
|
+
if (!defeated) {
|
|
671
|
+
undefeated.push({
|
|
672
|
+
blocker: blockerForSession(candidate.row, candidate.pauseRevision),
|
|
673
|
+
depth: candidate.depth,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const workspacePauseRevision = asSafeRevision(
|
|
679
|
+
workspace.workspacePauseRevision,
|
|
680
|
+
"workspace pause revision",
|
|
681
|
+
);
|
|
682
|
+
if (workspace.workspaceState === "paused") {
|
|
683
|
+
if (workspacePauseRevision === null) {
|
|
684
|
+
throw new SessionControlInvariantError("Paused workspace is missing its pause revision");
|
|
685
|
+
}
|
|
686
|
+
const defeated = path.some(
|
|
687
|
+
(candidate) =>
|
|
688
|
+
candidate.overrideRevision !== null && candidate.overrideRevision > workspacePauseRevision,
|
|
689
|
+
);
|
|
690
|
+
if (!defeated) {
|
|
691
|
+
undefeated.push({
|
|
692
|
+
blocker: {
|
|
693
|
+
kind: "workspace",
|
|
694
|
+
displayName: "Workspace",
|
|
695
|
+
actor: workspace.changedBy,
|
|
696
|
+
reason: workspace.reason,
|
|
697
|
+
changedAt: asDate(workspace.changedAt),
|
|
698
|
+
revision: workspacePauseRevision,
|
|
699
|
+
},
|
|
700
|
+
depth: Number.POSITIVE_INFINITY,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
undefeated.sort((left, right) => left.depth - right.depth);
|
|
706
|
+
const blockers = undefeated.map(({ blocker }) => blocker);
|
|
707
|
+
const primaryBlocker = blockers[0] ?? null;
|
|
708
|
+
const target = path[0]!;
|
|
709
|
+
const override = path
|
|
710
|
+
.filter((candidate) => candidate.overrideRevision !== null)
|
|
711
|
+
.sort((left, right) => right.overrideRevision! - left.overrideRevision!)[0];
|
|
712
|
+
|
|
713
|
+
const options: EffectiveControlResumeOption[] = [];
|
|
714
|
+
if (blockers.length > 0) {
|
|
715
|
+
options.push({
|
|
716
|
+
scope: "selected",
|
|
717
|
+
targetId,
|
|
718
|
+
selectedStateAfter: "active",
|
|
719
|
+
impactCopy: "Resume this session and its descendants without changing sibling workstreams.",
|
|
720
|
+
});
|
|
721
|
+
for (const entry of undefeated) {
|
|
722
|
+
if (entry.blocker.kind !== "session" || entry.blocker.sessionId === targetId) continue;
|
|
723
|
+
const remaining = undefeated.find(
|
|
724
|
+
(candidate) =>
|
|
725
|
+
candidate.depth < entry.depth && candidate.blocker.sessionId !== entry.blocker.sessionId,
|
|
726
|
+
)?.blocker;
|
|
727
|
+
options.push({
|
|
728
|
+
scope: "session",
|
|
729
|
+
targetId: entry.blocker.sessionId!,
|
|
730
|
+
selectedStateAfter: remaining ? "paused" : "active",
|
|
731
|
+
...(remaining ? { remainingPrimaryBlocker: remaining } : {}),
|
|
732
|
+
impactCopy: `Resume the workstream rooted at ${entry.blocker.displayName}.`,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (workspace.workspaceState === "paused") {
|
|
736
|
+
const remaining = undefeated.find((entry) => entry.blocker.kind === "session")?.blocker;
|
|
737
|
+
options.push({
|
|
738
|
+
scope: "workspace",
|
|
739
|
+
selectedStateAfter: remaining ? "paused" : "active",
|
|
740
|
+
...(remaining ? { remainingPrimaryBlocker: remaining } : {}),
|
|
741
|
+
impactCopy: "Resume the entire workspace; narrower session pauses remain in force.",
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const etagFacts = {
|
|
747
|
+
workspace: {
|
|
748
|
+
state: workspace.workspaceState,
|
|
749
|
+
pauseRevision: workspacePauseRevision,
|
|
750
|
+
},
|
|
751
|
+
path: path.map((candidate) => ({
|
|
752
|
+
sessionId: candidate.row.sessionId,
|
|
753
|
+
directState: candidate.row.directState,
|
|
754
|
+
pauseRevision: candidate.pauseRevision,
|
|
755
|
+
overrideRevision: candidate.overrideRevision,
|
|
756
|
+
})),
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
state: blockers.length > 0 ? "paused" : "active",
|
|
761
|
+
controlVersion: Math.max(
|
|
762
|
+
workspaceRevision,
|
|
763
|
+
...path.map((candidate) => candidate.controlVersion),
|
|
764
|
+
),
|
|
765
|
+
controlEtag: controlEtag(etagFacts),
|
|
766
|
+
directState: target.row.directState === "paused" ? "paused" : "active",
|
|
767
|
+
primaryBlocker,
|
|
768
|
+
additionalBlockerCount: Math.max(0, blockers.length - 1),
|
|
769
|
+
blockers,
|
|
770
|
+
resumeOptions: options,
|
|
771
|
+
override:
|
|
772
|
+
override?.overrideRevision === null || !override
|
|
773
|
+
? null
|
|
774
|
+
: {
|
|
775
|
+
rootSessionId: override.row.sessionId,
|
|
776
|
+
revision: override.overrideRevision,
|
|
777
|
+
},
|
|
778
|
+
settlement: stoppingAttempts > 0 ? { state: "stopping", attemptCount: stoppingAttempts } : null,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function unsettledAttemptCounts(
|
|
783
|
+
db: Database,
|
|
784
|
+
workspaceId: string,
|
|
785
|
+
sessionIds: string[],
|
|
786
|
+
): Promise<Map<string, number>> {
|
|
787
|
+
const rows = await db.execute<{
|
|
788
|
+
sessionId: string;
|
|
789
|
+
count: number | string;
|
|
790
|
+
}>(sql`
|
|
791
|
+
with recursive targets(id) as (values ${targetValues(sessionIds)}),
|
|
792
|
+
interruptions as (
|
|
793
|
+
select interruption.session_id, interruption.attempt_id
|
|
794
|
+
from ${schema.sessionAttemptInterruptions} interruption
|
|
795
|
+
where interruption.workspace_id = ${workspaceId}
|
|
796
|
+
and interruption.state in ('pending', 'delivered', 'acknowledged')
|
|
797
|
+
), interruption_ancestry(session_id, ancestor_id, attempt_id, depth, path) as (
|
|
798
|
+
select
|
|
799
|
+
interruption.session_id,
|
|
800
|
+
interruption.session_id,
|
|
801
|
+
interruption.attempt_id,
|
|
802
|
+
0::integer,
|
|
803
|
+
array[interruption.session_id]::uuid[]
|
|
804
|
+
from interruptions interruption
|
|
805
|
+
union all
|
|
806
|
+
select
|
|
807
|
+
ancestry.session_id,
|
|
808
|
+
current.parent_session_id,
|
|
809
|
+
ancestry.attempt_id,
|
|
810
|
+
ancestry.depth + 1,
|
|
811
|
+
ancestry.path || current.parent_session_id
|
|
812
|
+
from interruption_ancestry ancestry
|
|
813
|
+
join ${schema.sessions} current
|
|
814
|
+
on current.workspace_id = ${workspaceId} and current.id = ancestry.ancestor_id
|
|
815
|
+
where current.parent_session_id is not null
|
|
816
|
+
and not current.parent_session_id = any(ancestry.path)
|
|
817
|
+
and ancestry.depth < ${SESSION_ANCESTRY_LIMIT}
|
|
818
|
+
)
|
|
819
|
+
select target.id as "sessionId", count(distinct ancestry.attempt_id)::integer as count
|
|
820
|
+
from targets target
|
|
821
|
+
join interruption_ancestry ancestry on ancestry.ancestor_id = target.id
|
|
822
|
+
group by target.id
|
|
823
|
+
`);
|
|
824
|
+
return new Map(
|
|
825
|
+
rows.map((row: { sessionId: string; count: number | string }) => [
|
|
826
|
+
row.sessionId,
|
|
827
|
+
Number(row.count),
|
|
828
|
+
]),
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export async function evaluateSessionControls(
|
|
833
|
+
db: Database,
|
|
834
|
+
workspaceId: string,
|
|
835
|
+
sessionIds: string[],
|
|
836
|
+
options: { lock?: WorkspaceControlLockMode } = {},
|
|
837
|
+
): Promise<Map<string, EffectiveSessionControl>> {
|
|
838
|
+
const uniqueIds = [...new Set(sessionIds)];
|
|
839
|
+
if (uniqueIds.length === 0) {
|
|
840
|
+
return new Map();
|
|
841
|
+
}
|
|
842
|
+
const workspace = await lockWorkspaceInferenceControl(db, workspaceId, options.lock ?? "share");
|
|
843
|
+
const stopping = await unsettledAttemptCounts(db, workspaceId, uniqueIds);
|
|
844
|
+
const result = new Map<string, EffectiveSessionControl>();
|
|
845
|
+
if (uniqueIds.length <= TARGET_PATH_PROJECTION_LIMIT) {
|
|
846
|
+
// PostgreSQL's direct target-path plan is substantially faster for the
|
|
847
|
+
// ordinary one-session and paged-list cases. Keep its bounded row shape.
|
|
848
|
+
const ancestry = await loadTargetAncestryRows(db, workspaceId, uniqueIds);
|
|
849
|
+
const ancestryByTarget = new Map<string, AncestryRow[]>();
|
|
850
|
+
for (const row of ancestry) {
|
|
851
|
+
const rows = ancestryByTarget.get(row.targetId);
|
|
852
|
+
if (rows) rows.push(row);
|
|
853
|
+
else ancestryByTarget.set(row.targetId, [row]);
|
|
854
|
+
}
|
|
855
|
+
for (const sessionId of uniqueIds) {
|
|
856
|
+
result.set(
|
|
857
|
+
sessionId,
|
|
858
|
+
projectEffectiveControl(
|
|
859
|
+
workspace,
|
|
860
|
+
sessionId,
|
|
861
|
+
ancestryByTarget.get(sessionId) ?? [],
|
|
862
|
+
stopping.get(sessionId) ?? 0,
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Large sets fetch each shared ancestor exactly once. Returning one row per
|
|
870
|
+
// target/ancestor pair makes a 10k-session tree explode into millions of
|
|
871
|
+
// protocol objects even though the tree itself contains only 10k nodes.
|
|
872
|
+
const ancestry = await loadAncestryNodes(db, workspaceId, uniqueIds);
|
|
873
|
+
for (const sessionId of uniqueIds) {
|
|
874
|
+
result.set(
|
|
875
|
+
sessionId,
|
|
876
|
+
projectEffectiveControl(
|
|
877
|
+
workspace,
|
|
878
|
+
sessionId,
|
|
879
|
+
ancestryRowsForTarget(sessionId, ancestry),
|
|
880
|
+
stopping.get(sessionId) ?? 0,
|
|
881
|
+
),
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export async function evaluateSessionControl(
|
|
888
|
+
db: Database,
|
|
889
|
+
workspaceId: string,
|
|
890
|
+
sessionId: string,
|
|
891
|
+
options: { lock?: WorkspaceControlLockMode } = {},
|
|
892
|
+
): Promise<EffectiveSessionControl> {
|
|
893
|
+
return (await evaluateSessionControls(db, workspaceId, [sessionId], options)).get(sessionId)!;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function findCommandReceipt(
|
|
897
|
+
db: Database,
|
|
898
|
+
input: {
|
|
899
|
+
workspaceId: string;
|
|
900
|
+
actor: SessionCommandActor;
|
|
901
|
+
action: string;
|
|
902
|
+
targetSessionId: string | null;
|
|
903
|
+
targetTurnId: string | null;
|
|
904
|
+
operationKey: string;
|
|
905
|
+
},
|
|
906
|
+
): Promise<SessionCommandReceiptRow | null> {
|
|
907
|
+
const actorSubjectId = input.actor.type === "agent_attempt" ? null : input.actor.subjectId;
|
|
908
|
+
const actorAttemptId = input.actor.type === "agent_attempt" ? input.actor.attemptId : null;
|
|
909
|
+
const rows = await db
|
|
910
|
+
.select()
|
|
911
|
+
.from(schema.sessionCommandReceipts)
|
|
912
|
+
.where(
|
|
913
|
+
and(
|
|
914
|
+
eq(schema.sessionCommandReceipts.workspaceId, input.workspaceId),
|
|
915
|
+
eq(schema.sessionCommandReceipts.actorType, input.actor.type),
|
|
916
|
+
sql`${schema.sessionCommandReceipts.actorSubjectId} is not distinct from ${actorSubjectId}`,
|
|
917
|
+
sql`${schema.sessionCommandReceipts.actorAttemptId} is not distinct from ${actorAttemptId}::uuid`,
|
|
918
|
+
eq(schema.sessionCommandReceipts.action, input.action),
|
|
919
|
+
sql`${schema.sessionCommandReceipts.targetSessionId} is not distinct from ${input.targetSessionId}::uuid`,
|
|
920
|
+
sql`${schema.sessionCommandReceipts.targetTurnId} is not distinct from ${input.targetTurnId}::uuid`,
|
|
921
|
+
eq(schema.sessionCommandReceipts.operationKey, input.operationKey),
|
|
922
|
+
),
|
|
923
|
+
)
|
|
924
|
+
.for("update");
|
|
925
|
+
return rows[0] ?? null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export async function reserveSessionCommandReceipt(
|
|
929
|
+
db: Database,
|
|
930
|
+
input: {
|
|
931
|
+
accountId: string;
|
|
932
|
+
workspaceId: string;
|
|
933
|
+
actor: SessionCommandActor;
|
|
934
|
+
action: string;
|
|
935
|
+
targetSessionId: string | null;
|
|
936
|
+
targetTurnId: string | null;
|
|
937
|
+
operationKey: string;
|
|
938
|
+
canonicalRequestHash: string;
|
|
939
|
+
},
|
|
940
|
+
): Promise<{ receipt: SessionCommandReceiptRow; replay: boolean }> {
|
|
941
|
+
if (!input.operationKey.trim()) throw new Error("operationKey must not be empty");
|
|
942
|
+
const actorSubjectId = input.actor.type === "agent_attempt" ? null : input.actor.subjectId;
|
|
943
|
+
const actorAttemptId = input.actor.type === "agent_attempt" ? input.actor.attemptId : null;
|
|
944
|
+
const [inserted] = await db
|
|
945
|
+
.insert(schema.sessionCommandReceipts)
|
|
946
|
+
.values({
|
|
947
|
+
accountId: input.accountId,
|
|
948
|
+
workspaceId: input.workspaceId,
|
|
949
|
+
actorType: input.actor.type,
|
|
950
|
+
actorSubjectId,
|
|
951
|
+
actorAttemptId,
|
|
952
|
+
action: input.action,
|
|
953
|
+
targetSessionId: input.targetSessionId,
|
|
954
|
+
targetTurnId: input.targetTurnId,
|
|
955
|
+
operationKey: input.operationKey,
|
|
956
|
+
canonicalRequestHash: input.canonicalRequestHash,
|
|
957
|
+
})
|
|
958
|
+
.onConflictDoNothing()
|
|
959
|
+
.returning();
|
|
960
|
+
const receipt =
|
|
961
|
+
inserted ??
|
|
962
|
+
(await findCommandReceipt(db, {
|
|
963
|
+
workspaceId: input.workspaceId,
|
|
964
|
+
actor: input.actor,
|
|
965
|
+
action: input.action,
|
|
966
|
+
targetSessionId: input.targetSessionId,
|
|
967
|
+
targetTurnId: input.targetTurnId,
|
|
968
|
+
operationKey: input.operationKey,
|
|
969
|
+
}));
|
|
970
|
+
if (!receipt) throw new SessionControlInvariantError("Command receipt conflict was not readable");
|
|
971
|
+
if (receipt.canonicalRequestHash !== input.canonicalRequestHash) {
|
|
972
|
+
throw new SessionCommandIdempotencyError();
|
|
973
|
+
}
|
|
974
|
+
return { receipt, replay: !inserted };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function nextRevision(workspace: WorkspaceControlRow): number {
|
|
978
|
+
const current = asSafeRevision(workspace.revision, "workspace control revision")!;
|
|
979
|
+
if (current >= Number.MAX_SAFE_INTEGER) {
|
|
980
|
+
throw new SessionControlInvariantError("Workspace control revision is exhausted");
|
|
981
|
+
}
|
|
982
|
+
return current + 1;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function advanceWorkspaceRevision(
|
|
986
|
+
db: Database,
|
|
987
|
+
workspaceId: string,
|
|
988
|
+
revision: number,
|
|
989
|
+
): Promise<void> {
|
|
990
|
+
const rows = await db
|
|
991
|
+
.update(schema.workspaceInferenceControls)
|
|
992
|
+
.set({ revision, updatedAt: new Date() })
|
|
993
|
+
.where(
|
|
994
|
+
and(
|
|
995
|
+
eq(schema.workspaceInferenceControls.workspaceId, workspaceId),
|
|
996
|
+
eq(schema.workspaceInferenceControls.revision, revision - 1),
|
|
997
|
+
),
|
|
998
|
+
)
|
|
999
|
+
.returning({ workspaceId: schema.workspaceInferenceControls.workspaceId });
|
|
1000
|
+
if (rows.length !== 1) {
|
|
1001
|
+
throw new SessionControlInvariantError(
|
|
1002
|
+
"Workspace control revision did not advance exactly once",
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function registerContinuableWakes(
|
|
1008
|
+
db: Database,
|
|
1009
|
+
input: { workspaceId: string; rootSessionId: string | null; reason: string },
|
|
1010
|
+
): Promise<number> {
|
|
1011
|
+
const rows = await db.execute<{ wakeCount: number | string }>(sql`
|
|
1012
|
+
with eligible as (
|
|
1013
|
+
select *
|
|
1014
|
+
from opengeni_private.list_continuable_sessions(
|
|
1015
|
+
${input.workspaceId}::uuid,
|
|
1016
|
+
${input.rootSessionId}::uuid
|
|
1017
|
+
)
|
|
1018
|
+
), upserted as (
|
|
1019
|
+
insert into ${schema.sessionWorkflowWakeOutbox} (
|
|
1020
|
+
session_id, account_id, workspace_id, temporal_workflow_id, reason
|
|
1021
|
+
)
|
|
1022
|
+
select session_id, account_id, workspace_id, temporal_workflow_id, ${input.reason}
|
|
1023
|
+
from eligible
|
|
1024
|
+
on conflict (session_id) do update set
|
|
1025
|
+
wake_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
|
|
1026
|
+
temporal_workflow_id = excluded.temporal_workflow_id,
|
|
1027
|
+
reason = excluded.reason,
|
|
1028
|
+
attempts = 0,
|
|
1029
|
+
next_attempt_at = now(),
|
|
1030
|
+
last_error = null,
|
|
1031
|
+
updated_at = now()
|
|
1032
|
+
returning session_id
|
|
1033
|
+
)
|
|
1034
|
+
select count(*)::bigint as "wakeCount" from upserted
|
|
1035
|
+
`);
|
|
1036
|
+
return Number(rows[0]?.wakeCount ?? 0);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function registerDescendantWakes(
|
|
1040
|
+
db: Database,
|
|
1041
|
+
input: { workspaceId: string; sessionId: string; reason: string },
|
|
1042
|
+
): Promise<number> {
|
|
1043
|
+
return await registerContinuableWakes(db, {
|
|
1044
|
+
workspaceId: input.workspaceId,
|
|
1045
|
+
rootSessionId: input.sessionId,
|
|
1046
|
+
reason: input.reason,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function registerWorkspaceWakes(
|
|
1051
|
+
db: Database,
|
|
1052
|
+
input: { workspaceId: string; reason: string },
|
|
1053
|
+
): Promise<number> {
|
|
1054
|
+
return await registerContinuableWakes(db, {
|
|
1055
|
+
workspaceId: input.workspaceId,
|
|
1056
|
+
rootSessionId: null,
|
|
1057
|
+
reason: input.reason,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Pause never needs to rediscover ordinary continuable work: it closes
|
|
1063
|
+
* admission. Only workflows owning an interruption created by this exact
|
|
1064
|
+
* command must wake so they can settle their in-flight attempt. The operation
|
|
1065
|
+
* receipt is indexed and bounds this to the number of affected attempts rather
|
|
1066
|
+
* than the size or depth of the paused tree.
|
|
1067
|
+
*/
|
|
1068
|
+
async function registerInterruptionWakes(
|
|
1069
|
+
db: Database,
|
|
1070
|
+
input: { operationId: string; reason: string },
|
|
1071
|
+
): Promise<number> {
|
|
1072
|
+
const rows = await db.execute<{ wakeCount: number | string }>(sql`
|
|
1073
|
+
with eligible as (
|
|
1074
|
+
select distinct
|
|
1075
|
+
session.id as session_id,
|
|
1076
|
+
session.account_id,
|
|
1077
|
+
session.workspace_id,
|
|
1078
|
+
coalesce(session.temporal_workflow_id, 'session-' || session.id::text)
|
|
1079
|
+
as temporal_workflow_id
|
|
1080
|
+
from ${schema.sessionAttemptInterruptions} interruption
|
|
1081
|
+
join ${schema.sessions} session
|
|
1082
|
+
on session.workspace_id = interruption.workspace_id
|
|
1083
|
+
and session.id = interruption.session_id
|
|
1084
|
+
where interruption.operation_id = ${input.operationId}::uuid
|
|
1085
|
+
and interruption.state in ('pending', 'delivered', 'acknowledged')
|
|
1086
|
+
), upserted as (
|
|
1087
|
+
insert into ${schema.sessionWorkflowWakeOutbox} (
|
|
1088
|
+
session_id, account_id, workspace_id, temporal_workflow_id, reason
|
|
1089
|
+
)
|
|
1090
|
+
select session_id, account_id, workspace_id, temporal_workflow_id, ${input.reason}
|
|
1091
|
+
from eligible
|
|
1092
|
+
on conflict (session_id) do update set
|
|
1093
|
+
wake_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
|
|
1094
|
+
temporal_workflow_id = excluded.temporal_workflow_id,
|
|
1095
|
+
reason = excluded.reason,
|
|
1096
|
+
attempts = 0,
|
|
1097
|
+
next_attempt_at = now(),
|
|
1098
|
+
last_error = null,
|
|
1099
|
+
updated_at = now()
|
|
1100
|
+
returning session_id
|
|
1101
|
+
)
|
|
1102
|
+
select count(*)::bigint as "wakeCount" from upserted
|
|
1103
|
+
`);
|
|
1104
|
+
return Number(rows[0]?.wakeCount ?? 0);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Register one exact post-commit Temporal nudge without encoding eligibility in
|
|
1108
|
+
* the transport. The workflow re-reads canonical Postgres state; coalescing is
|
|
1109
|
+
* revisioned so a lost or stale delivery cannot hide a later mutation. */
|
|
1110
|
+
export async function registerSessionWorkflowWakeInTransaction(
|
|
1111
|
+
db: Database,
|
|
1112
|
+
input: {
|
|
1113
|
+
accountId: string;
|
|
1114
|
+
workspaceId: string;
|
|
1115
|
+
sessionId: string;
|
|
1116
|
+
temporalWorkflowId: string;
|
|
1117
|
+
reason: string;
|
|
1118
|
+
},
|
|
1119
|
+
): Promise<number> {
|
|
1120
|
+
const [row] = await db
|
|
1121
|
+
.insert(schema.sessionWorkflowWakeOutbox)
|
|
1122
|
+
.values({
|
|
1123
|
+
accountId: input.accountId,
|
|
1124
|
+
workspaceId: input.workspaceId,
|
|
1125
|
+
sessionId: input.sessionId,
|
|
1126
|
+
temporalWorkflowId: input.temporalWorkflowId,
|
|
1127
|
+
reason: input.reason,
|
|
1128
|
+
})
|
|
1129
|
+
.onConflictDoUpdate({
|
|
1130
|
+
target: schema.sessionWorkflowWakeOutbox.sessionId,
|
|
1131
|
+
set: {
|
|
1132
|
+
temporalWorkflowId: input.temporalWorkflowId,
|
|
1133
|
+
wakeRevision: sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`,
|
|
1134
|
+
reason: input.reason,
|
|
1135
|
+
attempts: 0,
|
|
1136
|
+
nextAttemptAt: new Date(),
|
|
1137
|
+
lastError: null,
|
|
1138
|
+
updatedAt: new Date(),
|
|
1139
|
+
},
|
|
1140
|
+
})
|
|
1141
|
+
.returning({ wakeRevision: schema.sessionWorkflowWakeOutbox.wakeRevision });
|
|
1142
|
+
if (!row) {
|
|
1143
|
+
throw new SessionControlInvariantError(`Failed to register wake for ${input.sessionId}`);
|
|
1144
|
+
}
|
|
1145
|
+
return Number(row.wakeRevision);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Internal producers share one outstanding session-level receipt. While a wake
|
|
1150
|
+
* remains undelivered, another update makes that same batch richer instead of
|
|
1151
|
+
* manufacturing another transport revision or sequential model turn.
|
|
1152
|
+
*/
|
|
1153
|
+
export async function registerInternalUpdateWakeInTransaction(
|
|
1154
|
+
db: Database,
|
|
1155
|
+
input: {
|
|
1156
|
+
accountId: string;
|
|
1157
|
+
workspaceId: string;
|
|
1158
|
+
sessionId: string;
|
|
1159
|
+
temporalWorkflowId: string;
|
|
1160
|
+
},
|
|
1161
|
+
): Promise<{ wakeRevision: number; shouldSignal: boolean }> {
|
|
1162
|
+
const [existing] = await db
|
|
1163
|
+
.select()
|
|
1164
|
+
.from(schema.sessionWorkflowWakeOutbox)
|
|
1165
|
+
.where(
|
|
1166
|
+
and(
|
|
1167
|
+
eq(schema.sessionWorkflowWakeOutbox.workspaceId, input.workspaceId),
|
|
1168
|
+
eq(schema.sessionWorkflowWakeOutbox.sessionId, input.sessionId),
|
|
1169
|
+
),
|
|
1170
|
+
)
|
|
1171
|
+
.for("update")
|
|
1172
|
+
.limit(1);
|
|
1173
|
+
if (existing && existing.wakeRevision > existing.deliveredRevision) {
|
|
1174
|
+
await db
|
|
1175
|
+
.update(schema.sessionWorkflowWakeOutbox)
|
|
1176
|
+
.set({
|
|
1177
|
+
temporalWorkflowId: input.temporalWorkflowId,
|
|
1178
|
+
reason: "internal_update_batch",
|
|
1179
|
+
nextAttemptAt: new Date(),
|
|
1180
|
+
lastError: null,
|
|
1181
|
+
updatedAt: new Date(),
|
|
1182
|
+
})
|
|
1183
|
+
.where(eq(schema.sessionWorkflowWakeOutbox.sessionId, input.sessionId));
|
|
1184
|
+
return { wakeRevision: existing.wakeRevision, shouldSignal: false };
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
wakeRevision: await registerSessionWorkflowWakeInTransaction(db, {
|
|
1188
|
+
...input,
|
|
1189
|
+
reason: "internal_update_batch",
|
|
1190
|
+
}),
|
|
1191
|
+
shouldSignal: true,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function interruptDescendantAttempts(
|
|
1196
|
+
db: Database,
|
|
1197
|
+
input: {
|
|
1198
|
+
accountId: string;
|
|
1199
|
+
workspaceId: string;
|
|
1200
|
+
sessionId: string;
|
|
1201
|
+
operationId: string;
|
|
1202
|
+
kind: "session_pause" | "steer" | "maintenance";
|
|
1203
|
+
controlRevision: number;
|
|
1204
|
+
},
|
|
1205
|
+
): Promise<number> {
|
|
1206
|
+
const rows = await db.execute<{ count: number | string }>(sql`
|
|
1207
|
+
with recursive live_attempts as (
|
|
1208
|
+
select attempt.id, attempt.account_id, attempt.workspace_id, attempt.session_id
|
|
1209
|
+
from ${schema.sessionTurnAttempts} attempt
|
|
1210
|
+
where attempt.workspace_id = ${input.workspaceId}
|
|
1211
|
+
and attempt.state in ('claimed', 'running')
|
|
1212
|
+
), attempt_ancestry(attempt_id, account_id, workspace_id, session_id, ancestor_id, depth, path) as (
|
|
1213
|
+
select
|
|
1214
|
+
attempt.id,
|
|
1215
|
+
attempt.account_id,
|
|
1216
|
+
attempt.workspace_id,
|
|
1217
|
+
attempt.session_id,
|
|
1218
|
+
attempt.session_id,
|
|
1219
|
+
0::integer,
|
|
1220
|
+
array[attempt.session_id]::uuid[]
|
|
1221
|
+
from live_attempts attempt
|
|
1222
|
+
union all
|
|
1223
|
+
select
|
|
1224
|
+
ancestry.attempt_id,
|
|
1225
|
+
ancestry.account_id,
|
|
1226
|
+
ancestry.workspace_id,
|
|
1227
|
+
ancestry.session_id,
|
|
1228
|
+
current.parent_session_id,
|
|
1229
|
+
ancestry.depth + 1,
|
|
1230
|
+
ancestry.path || current.parent_session_id
|
|
1231
|
+
from attempt_ancestry ancestry
|
|
1232
|
+
join ${schema.sessions} current
|
|
1233
|
+
on current.workspace_id = ${input.workspaceId} and current.id = ancestry.ancestor_id
|
|
1234
|
+
where current.parent_session_id is not null
|
|
1235
|
+
and not current.parent_session_id = any(ancestry.path)
|
|
1236
|
+
and ancestry.depth < ${SESSION_ANCESTRY_LIMIT}
|
|
1237
|
+
), inserted as (
|
|
1238
|
+
insert into ${schema.sessionAttemptInterruptions} (
|
|
1239
|
+
account_id, workspace_id, session_id, operation_id, attempt_id,
|
|
1240
|
+
kind, control_revision
|
|
1241
|
+
)
|
|
1242
|
+
select ancestry.account_id, ancestry.workspace_id, ancestry.session_id,
|
|
1243
|
+
${input.operationId}::uuid, ancestry.attempt_id, ${input.kind}, ${input.controlRevision}
|
|
1244
|
+
from attempt_ancestry ancestry
|
|
1245
|
+
where ancestry.ancestor_id = ${input.sessionId}
|
|
1246
|
+
on conflict (operation_id, attempt_id) do nothing
|
|
1247
|
+
returning id
|
|
1248
|
+
)
|
|
1249
|
+
select count(*)::integer as count from inserted
|
|
1250
|
+
`);
|
|
1251
|
+
return Number(rows[0]?.count ?? 0);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function interruptWorkspaceAttempts(
|
|
1255
|
+
db: Database,
|
|
1256
|
+
input: {
|
|
1257
|
+
workspaceId: string;
|
|
1258
|
+
operationId: string;
|
|
1259
|
+
controlRevision: number;
|
|
1260
|
+
},
|
|
1261
|
+
): Promise<number> {
|
|
1262
|
+
const rows = await db.execute<{ count: number | string }>(sql`
|
|
1263
|
+
with inserted as (
|
|
1264
|
+
insert into ${schema.sessionAttemptInterruptions} (
|
|
1265
|
+
account_id, workspace_id, session_id, operation_id, attempt_id,
|
|
1266
|
+
kind, control_revision
|
|
1267
|
+
)
|
|
1268
|
+
select attempt.account_id, attempt.workspace_id, attempt.session_id,
|
|
1269
|
+
${input.operationId}::uuid, attempt.id, 'workspace_pause',
|
|
1270
|
+
${input.controlRevision}
|
|
1271
|
+
from ${schema.sessionTurnAttempts} attempt
|
|
1272
|
+
where attempt.workspace_id = ${input.workspaceId}
|
|
1273
|
+
and attempt.state in ('claimed', 'running')
|
|
1274
|
+
on conflict (operation_id, attempt_id) do nothing
|
|
1275
|
+
returning id
|
|
1276
|
+
)
|
|
1277
|
+
select count(*)::integer as count from inserted
|
|
1278
|
+
`);
|
|
1279
|
+
return Number(rows[0]?.count ?? 0);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
export async function updateSessionCommandReceiptResult(
|
|
1283
|
+
db: Database,
|
|
1284
|
+
receiptId: string,
|
|
1285
|
+
input: {
|
|
1286
|
+
controlRevision?: number | null;
|
|
1287
|
+
queueVersion?: number | null;
|
|
1288
|
+
turnVersion?: number | null;
|
|
1289
|
+
draftRevision?: number | null;
|
|
1290
|
+
result: Record<string, unknown>;
|
|
1291
|
+
},
|
|
1292
|
+
): Promise<SessionCommandReceiptRow> {
|
|
1293
|
+
const [receipt] = await db
|
|
1294
|
+
.update(schema.sessionCommandReceipts)
|
|
1295
|
+
.set({
|
|
1296
|
+
...(input.controlRevision !== undefined
|
|
1297
|
+
? { appliedControlRevision: input.controlRevision }
|
|
1298
|
+
: {}),
|
|
1299
|
+
...(input.queueVersion !== undefined ? { appliedQueueVersion: input.queueVersion } : {}),
|
|
1300
|
+
...(input.turnVersion !== undefined ? { appliedTurnVersion: input.turnVersion } : {}),
|
|
1301
|
+
...(input.draftRevision !== undefined ? { appliedDraftRevision: input.draftRevision } : {}),
|
|
1302
|
+
result: input.result,
|
|
1303
|
+
updatedAt: new Date(),
|
|
1304
|
+
})
|
|
1305
|
+
.where(eq(schema.sessionCommandReceipts.id, receiptId))
|
|
1306
|
+
.returning();
|
|
1307
|
+
if (!receipt) throw new SessionControlInvariantError("Command receipt disappeared");
|
|
1308
|
+
return receipt;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
export type SessionControlMutationResult = {
|
|
1312
|
+
receipt: SessionCommandReceiptRow;
|
|
1313
|
+
control: EffectiveSessionControl;
|
|
1314
|
+
sessionControlEventId: string;
|
|
1315
|
+
workspaceControlEventId: string;
|
|
1316
|
+
interruptionCount: number;
|
|
1317
|
+
wakeCount: number;
|
|
1318
|
+
replay: boolean;
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
export async function mutateSessionControlInTransaction(
|
|
1322
|
+
db: Database,
|
|
1323
|
+
input: {
|
|
1324
|
+
accountId: string;
|
|
1325
|
+
workspaceId: string;
|
|
1326
|
+
sessionId: string;
|
|
1327
|
+
actor: SessionCommandActor;
|
|
1328
|
+
operationKey: string;
|
|
1329
|
+
action: "pause" | "resume";
|
|
1330
|
+
reason?: string | null;
|
|
1331
|
+
expectedControlEtag?: string | null;
|
|
1332
|
+
},
|
|
1333
|
+
): Promise<SessionControlMutationResult> {
|
|
1334
|
+
const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
|
|
1335
|
+
const hash = canonicalSessionCommandHash({
|
|
1336
|
+
action: input.action,
|
|
1337
|
+
reason: input.reason ?? null,
|
|
1338
|
+
expectedControlEtag: input.expectedControlEtag ?? null,
|
|
1339
|
+
});
|
|
1340
|
+
const reserved = await reserveSessionCommandReceipt(db, {
|
|
1341
|
+
accountId: input.accountId,
|
|
1342
|
+
workspaceId: input.workspaceId,
|
|
1343
|
+
actor: input.actor,
|
|
1344
|
+
action: `session.${input.action}`,
|
|
1345
|
+
targetSessionId: input.sessionId,
|
|
1346
|
+
targetTurnId: null,
|
|
1347
|
+
operationKey: input.operationKey,
|
|
1348
|
+
canonicalRequestHash: hash,
|
|
1349
|
+
});
|
|
1350
|
+
if (reserved.replay && reserved.receipt.appliedControlRevision !== null) {
|
|
1351
|
+
const workspaceControlEventId = String(reserved.receipt.result.workspaceControlEventId ?? "");
|
|
1352
|
+
if (!workspaceControlEventId) {
|
|
1353
|
+
throw new SessionControlInvariantError("Replayed session control receipt has no event");
|
|
1354
|
+
}
|
|
1355
|
+
const sessionControlEventId = String(reserved.receipt.result.eventId ?? "");
|
|
1356
|
+
if (!sessionControlEventId) {
|
|
1357
|
+
throw new SessionControlInvariantError(
|
|
1358
|
+
"Replayed session control receipt has no session event",
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
return {
|
|
1362
|
+
receipt: reserved.receipt,
|
|
1363
|
+
control: await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1364
|
+
lock: "share",
|
|
1365
|
+
}),
|
|
1366
|
+
sessionControlEventId,
|
|
1367
|
+
workspaceControlEventId,
|
|
1368
|
+
interruptionCount: Number(reserved.receipt.result.interruptionCount ?? 0),
|
|
1369
|
+
wakeCount: Number(reserved.receipt.result.wakeCount ?? 0),
|
|
1370
|
+
replay: true,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
if (input.actor.type === "agent_attempt") {
|
|
1374
|
+
await assertAgentCommandAuthorityInTransaction(db, {
|
|
1375
|
+
workspaceId: input.workspaceId,
|
|
1376
|
+
actor: input.actor,
|
|
1377
|
+
targetSessionId: input.sessionId,
|
|
1378
|
+
action: input.action,
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1382
|
+
lock: "share",
|
|
1383
|
+
});
|
|
1384
|
+
if (input.expectedControlEtag && input.expectedControlEtag !== before.controlEtag) {
|
|
1385
|
+
throw new SessionControlConflictError();
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const revision = nextRevision(workspace);
|
|
1389
|
+
await advanceWorkspaceRevision(db, input.workspaceId, revision);
|
|
1390
|
+
const [updated] = await db
|
|
1391
|
+
.update(schema.sessions)
|
|
1392
|
+
.set(
|
|
1393
|
+
input.action === "pause"
|
|
1394
|
+
? {
|
|
1395
|
+
directControlState: "paused",
|
|
1396
|
+
directPauseRevision: revision,
|
|
1397
|
+
controlVersion: revision,
|
|
1398
|
+
directControlReason: input.reason ?? null,
|
|
1399
|
+
directControlChangedBy:
|
|
1400
|
+
input.actor.type === "agent_attempt"
|
|
1401
|
+
? `attempt:${input.actor.attemptId}`
|
|
1402
|
+
: input.actor.subjectId,
|
|
1403
|
+
directControlChangedAt: new Date(),
|
|
1404
|
+
updatedAt: new Date(),
|
|
1405
|
+
}
|
|
1406
|
+
: {
|
|
1407
|
+
directControlState: "active",
|
|
1408
|
+
directPauseRevision: null,
|
|
1409
|
+
subtreeRunOverrideRevision: revision,
|
|
1410
|
+
controlVersion: revision,
|
|
1411
|
+
directControlReason: input.reason ?? null,
|
|
1412
|
+
directControlChangedBy:
|
|
1413
|
+
input.actor.type === "agent_attempt"
|
|
1414
|
+
? `attempt:${input.actor.attemptId}`
|
|
1415
|
+
: input.actor.subjectId,
|
|
1416
|
+
directControlChangedAt: new Date(),
|
|
1417
|
+
updatedAt: new Date(),
|
|
1418
|
+
},
|
|
1419
|
+
)
|
|
1420
|
+
.where(
|
|
1421
|
+
and(
|
|
1422
|
+
eq(schema.sessions.workspaceId, input.workspaceId),
|
|
1423
|
+
eq(schema.sessions.id, input.sessionId),
|
|
1424
|
+
),
|
|
1425
|
+
)
|
|
1426
|
+
.returning({
|
|
1427
|
+
id: schema.sessions.id,
|
|
1428
|
+
lastSequence: schema.sessions.lastSequence,
|
|
1429
|
+
});
|
|
1430
|
+
if (!updated) throw new SessionControlInvariantError(`Session ${input.sessionId} disappeared`);
|
|
1431
|
+
|
|
1432
|
+
const actor =
|
|
1433
|
+
input.actor.type === "agent_attempt"
|
|
1434
|
+
? `attempt:${input.actor.attemptId}`
|
|
1435
|
+
: input.actor.subjectId;
|
|
1436
|
+
const workspaceControlEventId = await insertWorkspaceControlEventInTransaction(db, {
|
|
1437
|
+
accountId: input.accountId,
|
|
1438
|
+
workspaceId: input.workspaceId,
|
|
1439
|
+
revision,
|
|
1440
|
+
scope: "session",
|
|
1441
|
+
rootSessionId: input.sessionId,
|
|
1442
|
+
action: input.action,
|
|
1443
|
+
automatic: false,
|
|
1444
|
+
reason: input.reason ?? null,
|
|
1445
|
+
actor,
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
const interruptionCount =
|
|
1449
|
+
input.action === "pause"
|
|
1450
|
+
? await interruptDescendantAttempts(db, {
|
|
1451
|
+
accountId: input.accountId,
|
|
1452
|
+
workspaceId: input.workspaceId,
|
|
1453
|
+
sessionId: input.sessionId,
|
|
1454
|
+
operationId: reserved.receipt.id,
|
|
1455
|
+
kind: "session_pause",
|
|
1456
|
+
controlRevision: revision,
|
|
1457
|
+
})
|
|
1458
|
+
: 0;
|
|
1459
|
+
const wakeCount =
|
|
1460
|
+
input.action === "pause"
|
|
1461
|
+
? await registerInterruptionWakes(db, {
|
|
1462
|
+
operationId: reserved.receipt.id,
|
|
1463
|
+
reason: "session_pause_interruption",
|
|
1464
|
+
})
|
|
1465
|
+
: await registerDescendantWakes(db, {
|
|
1466
|
+
workspaceId: input.workspaceId,
|
|
1467
|
+
sessionId: input.sessionId,
|
|
1468
|
+
reason: "session_resume",
|
|
1469
|
+
});
|
|
1470
|
+
const [controlEvent] = await db
|
|
1471
|
+
.insert(schema.sessionEvents)
|
|
1472
|
+
.values({
|
|
1473
|
+
accountId: input.accountId,
|
|
1474
|
+
workspaceId: input.workspaceId,
|
|
1475
|
+
sessionId: input.sessionId,
|
|
1476
|
+
sequence: updated.lastSequence + 1,
|
|
1477
|
+
type: input.action === "pause" ? "session.control.paused" : "session.control.resumed",
|
|
1478
|
+
payload: {
|
|
1479
|
+
operationId: reserved.receipt.id,
|
|
1480
|
+
revision,
|
|
1481
|
+
actor,
|
|
1482
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
1483
|
+
interruptionCount,
|
|
1484
|
+
},
|
|
1485
|
+
occurredAt: new Date(),
|
|
1486
|
+
})
|
|
1487
|
+
.returning({ id: schema.sessionEvents.id });
|
|
1488
|
+
if (!controlEvent)
|
|
1489
|
+
throw new SessionControlInvariantError("Session control event was not inserted");
|
|
1490
|
+
await db
|
|
1491
|
+
.update(schema.sessions)
|
|
1492
|
+
.set({ lastSequence: updated.lastSequence + 1, updatedAt: new Date() })
|
|
1493
|
+
.where(eq(schema.sessions.id, input.sessionId));
|
|
1494
|
+
await db.insert(schema.auditEvents).values({
|
|
1495
|
+
accountId: input.accountId,
|
|
1496
|
+
workspaceId: input.workspaceId,
|
|
1497
|
+
subjectId: actor,
|
|
1498
|
+
action: `session.control.${input.action}`,
|
|
1499
|
+
targetType: "session",
|
|
1500
|
+
targetId: input.sessionId,
|
|
1501
|
+
metadata: {
|
|
1502
|
+
operationId: reserved.receipt.id,
|
|
1503
|
+
revision,
|
|
1504
|
+
interruptionCount,
|
|
1505
|
+
...(input.actor.type === "agent_attempt"
|
|
1506
|
+
? {
|
|
1507
|
+
callerSessionId: input.actor.sessionId,
|
|
1508
|
+
callerTurnId: input.actor.turnId,
|
|
1509
|
+
callerAttemptId: input.actor.attemptId,
|
|
1510
|
+
callerExecutionGeneration: input.actor.executionGeneration,
|
|
1511
|
+
}
|
|
1512
|
+
: {}),
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
|
|
1516
|
+
controlRevision: revision,
|
|
1517
|
+
result: {
|
|
1518
|
+
interruptionCount,
|
|
1519
|
+
wakeCount,
|
|
1520
|
+
eventId: controlEvent.id,
|
|
1521
|
+
workspaceControlEventId,
|
|
1522
|
+
},
|
|
1523
|
+
});
|
|
1524
|
+
const control = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1525
|
+
lock: "share",
|
|
1526
|
+
});
|
|
1527
|
+
return {
|
|
1528
|
+
receipt,
|
|
1529
|
+
control,
|
|
1530
|
+
sessionControlEventId: controlEvent.id,
|
|
1531
|
+
workspaceControlEventId,
|
|
1532
|
+
interruptionCount,
|
|
1533
|
+
wakeCount,
|
|
1534
|
+
replay: false,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
export async function autoResumeSessionBranchInTransaction(
|
|
1539
|
+
db: Database,
|
|
1540
|
+
input: {
|
|
1541
|
+
workspaceId: string;
|
|
1542
|
+
sessionId: string;
|
|
1543
|
+
actor: string;
|
|
1544
|
+
reason: "human_send" | "human_steer" | "agent_steer";
|
|
1545
|
+
observedControlEtag?: string | null;
|
|
1546
|
+
},
|
|
1547
|
+
): Promise<{
|
|
1548
|
+
revision: number;
|
|
1549
|
+
control: EffectiveSessionControl;
|
|
1550
|
+
changed: boolean;
|
|
1551
|
+
workspaceControlEventId: string | null;
|
|
1552
|
+
}> {
|
|
1553
|
+
const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
|
|
1554
|
+
const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1555
|
+
lock: "share",
|
|
1556
|
+
});
|
|
1557
|
+
if (input.observedControlEtag && input.observedControlEtag !== before.controlEtag) {
|
|
1558
|
+
throw new SessionControlConflictError();
|
|
1559
|
+
}
|
|
1560
|
+
if (before.state === "active") {
|
|
1561
|
+
return {
|
|
1562
|
+
revision: asSafeRevision(workspace.revision, "workspace control revision")!,
|
|
1563
|
+
control: before,
|
|
1564
|
+
changed: false,
|
|
1565
|
+
workspaceControlEventId: null,
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
const revision = nextRevision(workspace);
|
|
1569
|
+
await advanceWorkspaceRevision(db, input.workspaceId, revision);
|
|
1570
|
+
await db
|
|
1571
|
+
.update(schema.sessions)
|
|
1572
|
+
.set({
|
|
1573
|
+
directControlState: "active",
|
|
1574
|
+
directPauseRevision: null,
|
|
1575
|
+
subtreeRunOverrideRevision: revision,
|
|
1576
|
+
controlVersion: revision,
|
|
1577
|
+
directControlReason: input.reason,
|
|
1578
|
+
directControlChangedBy: input.actor,
|
|
1579
|
+
directControlChangedAt: new Date(),
|
|
1580
|
+
updatedAt: new Date(),
|
|
1581
|
+
})
|
|
1582
|
+
.where(
|
|
1583
|
+
and(
|
|
1584
|
+
eq(schema.sessions.workspaceId, input.workspaceId),
|
|
1585
|
+
eq(schema.sessions.id, input.sessionId),
|
|
1586
|
+
),
|
|
1587
|
+
);
|
|
1588
|
+
const workspaceControlEventId = await insertWorkspaceControlEventInTransaction(db, {
|
|
1589
|
+
accountId: workspace.accountId,
|
|
1590
|
+
workspaceId: input.workspaceId,
|
|
1591
|
+
revision,
|
|
1592
|
+
scope: "session",
|
|
1593
|
+
rootSessionId: input.sessionId,
|
|
1594
|
+
action: "resume",
|
|
1595
|
+
automatic: true,
|
|
1596
|
+
reason: input.reason,
|
|
1597
|
+
actor: input.actor,
|
|
1598
|
+
});
|
|
1599
|
+
return {
|
|
1600
|
+
revision,
|
|
1601
|
+
control: await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1602
|
+
lock: "share",
|
|
1603
|
+
}),
|
|
1604
|
+
changed: true,
|
|
1605
|
+
workspaceControlEventId,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
export type WorkspaceControlMutationResult = {
|
|
1610
|
+
receipt: SessionCommandReceiptRow;
|
|
1611
|
+
revision: number;
|
|
1612
|
+
workspaceControlEventId: string;
|
|
1613
|
+
workspaceState: EffectiveControlState;
|
|
1614
|
+
interruptionCount: number;
|
|
1615
|
+
wakeCount: number;
|
|
1616
|
+
replay: boolean;
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
export async function mutateWorkspaceControlInTransaction(
|
|
1620
|
+
db: Database,
|
|
1621
|
+
input: {
|
|
1622
|
+
accountId: string;
|
|
1623
|
+
workspaceId: string;
|
|
1624
|
+
actor: SessionCommandActor;
|
|
1625
|
+
operationKey: string;
|
|
1626
|
+
action: "pause" | "resume";
|
|
1627
|
+
reason?: string | null;
|
|
1628
|
+
expectedRevision?: number | null;
|
|
1629
|
+
},
|
|
1630
|
+
): Promise<WorkspaceControlMutationResult> {
|
|
1631
|
+
const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
|
|
1632
|
+
const currentRevision = asSafeRevision(workspace.revision, "workspace control revision")!;
|
|
1633
|
+
const hash = canonicalSessionCommandHash({
|
|
1634
|
+
action: input.action,
|
|
1635
|
+
reason: input.reason ?? null,
|
|
1636
|
+
expectedRevision: input.expectedRevision ?? null,
|
|
1637
|
+
});
|
|
1638
|
+
const reserved = await reserveSessionCommandReceipt(db, {
|
|
1639
|
+
accountId: input.accountId,
|
|
1640
|
+
workspaceId: input.workspaceId,
|
|
1641
|
+
actor: input.actor,
|
|
1642
|
+
action: `workspace.${input.action}`,
|
|
1643
|
+
targetSessionId: null,
|
|
1644
|
+
targetTurnId: null,
|
|
1645
|
+
operationKey: input.operationKey,
|
|
1646
|
+
canonicalRequestHash: hash,
|
|
1647
|
+
});
|
|
1648
|
+
if (reserved.replay && reserved.receipt.appliedControlRevision !== null) {
|
|
1649
|
+
const workspaceControlEventId = String(reserved.receipt.result.workspaceControlEventId ?? "");
|
|
1650
|
+
if (!workspaceControlEventId) {
|
|
1651
|
+
throw new SessionControlInvariantError("Replayed workspace control receipt has no event");
|
|
1652
|
+
}
|
|
1653
|
+
return {
|
|
1654
|
+
receipt: reserved.receipt,
|
|
1655
|
+
revision: Number(reserved.receipt.appliedControlRevision),
|
|
1656
|
+
workspaceControlEventId,
|
|
1657
|
+
workspaceState: input.action === "pause" ? "paused" : "active",
|
|
1658
|
+
interruptionCount: Number(reserved.receipt.result.interruptionCount ?? 0),
|
|
1659
|
+
wakeCount: Number(reserved.receipt.result.wakeCount ?? 0),
|
|
1660
|
+
replay: true,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
if (input.expectedRevision !== null && input.expectedRevision !== undefined) {
|
|
1664
|
+
if (input.expectedRevision !== currentRevision) throw new SessionControlConflictError();
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const revision = nextRevision(workspace);
|
|
1668
|
+
const actor =
|
|
1669
|
+
input.actor.type === "agent_attempt"
|
|
1670
|
+
? `attempt:${input.actor.attemptId}`
|
|
1671
|
+
: input.actor.subjectId;
|
|
1672
|
+
const [updated] = await db
|
|
1673
|
+
.update(schema.workspaceInferenceControls)
|
|
1674
|
+
.set({
|
|
1675
|
+
revision,
|
|
1676
|
+
workspaceState: input.action === "pause" ? "paused" : "active",
|
|
1677
|
+
workspacePauseRevision: input.action === "pause" ? revision : null,
|
|
1678
|
+
reason: input.reason ?? null,
|
|
1679
|
+
changedBy: actor,
|
|
1680
|
+
changedAt: new Date(),
|
|
1681
|
+
updatedAt: new Date(),
|
|
1682
|
+
})
|
|
1683
|
+
.where(
|
|
1684
|
+
and(
|
|
1685
|
+
eq(schema.workspaceInferenceControls.workspaceId, input.workspaceId),
|
|
1686
|
+
eq(schema.workspaceInferenceControls.revision, currentRevision),
|
|
1687
|
+
),
|
|
1688
|
+
)
|
|
1689
|
+
.returning({ workspaceId: schema.workspaceInferenceControls.workspaceId });
|
|
1690
|
+
if (!updated) {
|
|
1691
|
+
throw new SessionControlInvariantError("Workspace control did not mutate exactly once");
|
|
1692
|
+
}
|
|
1693
|
+
const workspaceControlEventId = await insertWorkspaceControlEventInTransaction(db, {
|
|
1694
|
+
accountId: input.accountId,
|
|
1695
|
+
workspaceId: input.workspaceId,
|
|
1696
|
+
revision,
|
|
1697
|
+
scope: "workspace",
|
|
1698
|
+
rootSessionId: null,
|
|
1699
|
+
action: input.action,
|
|
1700
|
+
automatic: false,
|
|
1701
|
+
reason: input.reason ?? null,
|
|
1702
|
+
actor,
|
|
1703
|
+
});
|
|
1704
|
+
const interruptionCount =
|
|
1705
|
+
input.action === "pause"
|
|
1706
|
+
? await interruptWorkspaceAttempts(db, {
|
|
1707
|
+
workspaceId: input.workspaceId,
|
|
1708
|
+
operationId: reserved.receipt.id,
|
|
1709
|
+
controlRevision: revision,
|
|
1710
|
+
})
|
|
1711
|
+
: 0;
|
|
1712
|
+
const wakeCount =
|
|
1713
|
+
input.action === "pause"
|
|
1714
|
+
? await registerInterruptionWakes(db, {
|
|
1715
|
+
operationId: reserved.receipt.id,
|
|
1716
|
+
reason: "workspace_pause_interruption",
|
|
1717
|
+
})
|
|
1718
|
+
: await registerWorkspaceWakes(db, {
|
|
1719
|
+
workspaceId: input.workspaceId,
|
|
1720
|
+
reason: "workspace_resume",
|
|
1721
|
+
});
|
|
1722
|
+
const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
|
|
1723
|
+
controlRevision: revision,
|
|
1724
|
+
result: { interruptionCount, wakeCount, workspaceControlEventId },
|
|
1725
|
+
});
|
|
1726
|
+
return {
|
|
1727
|
+
receipt,
|
|
1728
|
+
revision,
|
|
1729
|
+
workspaceControlEventId,
|
|
1730
|
+
workspaceState: input.action === "pause" ? "paused" : "active",
|
|
1731
|
+
interruptionCount,
|
|
1732
|
+
wakeCount,
|
|
1733
|
+
replay: false,
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
async function insertWorkspaceControlEventInTransaction(
|
|
1738
|
+
db: Database,
|
|
1739
|
+
input: {
|
|
1740
|
+
accountId: string;
|
|
1741
|
+
workspaceId: string;
|
|
1742
|
+
revision: number;
|
|
1743
|
+
scope: "workspace" | "session";
|
|
1744
|
+
rootSessionId: string | null;
|
|
1745
|
+
action: "pause" | "resume";
|
|
1746
|
+
automatic: boolean;
|
|
1747
|
+
reason: string | null;
|
|
1748
|
+
actor: string;
|
|
1749
|
+
},
|
|
1750
|
+
): Promise<string> {
|
|
1751
|
+
const [event] = await db
|
|
1752
|
+
.insert(schema.workspaceControlEvents)
|
|
1753
|
+
.values(input)
|
|
1754
|
+
.returning({ id: schema.workspaceControlEvents.id });
|
|
1755
|
+
if (!event) {
|
|
1756
|
+
throw new SessionControlInvariantError("Workspace control event was not inserted");
|
|
1757
|
+
}
|
|
1758
|
+
return event.id;
|
|
1759
|
+
}
|