@opengeni/db 0.7.3 → 0.10.7
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-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
- package/dist/chunk-BMFDXFPA.js.map +1 -0
- package/dist/chunk-KW526IJA.js +127 -0
- package/dist/chunk-KW526IJA.js.map +1 -0
- package/dist/{chunk-B22X3IEZ.js → chunk-P6PKXY5W.js} +604 -32
- package/dist/chunk-P6PKXY5W.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7408 -2276
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +29 -4
- package/dist/migrate.js +3 -1
- package/dist/provision-roles.d.ts +1122 -73
- package/dist/provision-roles.js +1 -1
- package/dist/{schema-BN5mB9xZ.d.ts → schema-CqkzrBRS.d.ts} +6967 -4457
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +19 -1
- package/drizzle/0053_codex_credential_leases.sql +2 -2
- package/drizzle/0057_durable_queue_control.sql +1 -1
- package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
- package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
- package/drizzle/0063_session_control_mega_foundation.sql +1 -1
- package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
- package/drizzle/0065_codex_subscription_overview.sql +168 -0
- package/drizzle/0065_session_attempt_quiescence.sql +26 -0
- package/drizzle/0065_session_tool_policy.sql +38 -0
- package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
- package/drizzle/0067_session_event_payload_bounds.sql +209 -0
- package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
- package/drizzle/0069_session_event_history_backfill.sql +32 -0
- package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
- package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
- package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
- package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
- package/drizzle/0074_session_activity_revisions.sql +72 -0
- package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
- package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
- package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
- package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
- package/drizzle/0095_github_existing_installations.sql +84 -0
- package/drizzle/0096_session_turn_initiators.sql +97 -0
- package/drizzle/0097_host_export_outbox.sql +1220 -0
- package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
- package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
- package/drizzle/0100_session_human_input_requests.sql +89 -0
- package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
- package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
- package/drizzle/0103_host_export_root_session.sql +166 -0
- package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
- package/drizzle/0105_session_turn_instructions.sql +9 -0
- package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
- package/drizzle/0107_host_export_lineage_contract.sql +381 -0
- package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
- package/package.json +5 -4
- package/src/codex-token-resolver.ts +175 -14
- package/src/connection-token-resolver.ts +428 -119
- package/src/event-payload-sanitizer.ts +88 -20
- package/src/index.ts +7208 -1273
- package/src/memory-domain.ts +1 -1
- package/src/migrate.ts +86 -23
- package/src/persistence-errors.ts +252 -0
- package/src/provision-roles.ts +42 -0
- package/src/schema.ts +658 -34
- package/src/session-control.ts +520 -38
- package/src/session-queue-commands.ts +385 -61
- package/src/session-tool-call-settlement.ts +58 -7
- package/src/turn-initiator.ts +155 -0
- package/dist/chunk-7LDU7F5P.js +0 -80
- package/dist/chunk-7LDU7F5P.js.map +0 -1
- package/dist/chunk-B22X3IEZ.js.map +0 -1
- package/dist/chunk-YFQ7SGE4.js.map +0 -1
package/src/session-control.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
boundWorkspaceControlEvent,
|
|
4
|
+
workspaceControlUtf8Bytes,
|
|
5
|
+
type SessionMcpApprovalPolicy,
|
|
6
|
+
type TurnInitiatorContext,
|
|
7
|
+
} from "@opengeni/contracts";
|
|
8
|
+
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
3
9
|
import type { Database } from "./index";
|
|
4
10
|
import * as schema from "./schema";
|
|
5
11
|
|
|
@@ -9,6 +15,12 @@ export type WorkspaceControlLockMode = "share" | "update";
|
|
|
9
15
|
export type EffectiveControlState = "active" | "paused";
|
|
10
16
|
export type SessionCommandActor =
|
|
11
17
|
| { type: "human" | "operator"; subjectId: string }
|
|
18
|
+
| {
|
|
19
|
+
type: "service";
|
|
20
|
+
subjectId: string;
|
|
21
|
+
subjectLabel?: string;
|
|
22
|
+
context?: TurnInitiatorContext;
|
|
23
|
+
}
|
|
12
24
|
| {
|
|
13
25
|
type: "agent_attempt";
|
|
14
26
|
attemptId: string;
|
|
@@ -54,7 +66,44 @@ export type EffectiveSessionControl = {
|
|
|
54
66
|
blockers: EffectiveControlBlocker[];
|
|
55
67
|
resumeOptions: EffectiveControlResumeOption[];
|
|
56
68
|
override: { rootSessionId: string; revision: number } | null;
|
|
57
|
-
settlement: {
|
|
69
|
+
settlement: {
|
|
70
|
+
state: "stopping";
|
|
71
|
+
attemptCount: number;
|
|
72
|
+
interruptionPendingCount: number;
|
|
73
|
+
quiescencePendingCount: number;
|
|
74
|
+
} | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type SettlementAttemptCounts = {
|
|
78
|
+
attemptCount: number;
|
|
79
|
+
interruptionPendingCount: number;
|
|
80
|
+
quiescencePendingCount: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const NO_SETTLEMENT_ATTEMPTS: SettlementAttemptCounts = {
|
|
84
|
+
attemptCount: 0,
|
|
85
|
+
interruptionPendingCount: 0,
|
|
86
|
+
quiescencePendingCount: 0,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS = 200;
|
|
90
|
+
export const SESSION_DISCOVERY_CONTROL_TARGET_LIMIT = 100;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Purpose-built control projection for session discovery. Full blocker evidence,
|
|
94
|
+
* actors/reasons, resume options, etags, and settlement state remain available
|
|
95
|
+
* from the ordinary session detail APIs; this shape is intentionally only what
|
|
96
|
+
* `sessions_list` renders.
|
|
97
|
+
*/
|
|
98
|
+
export type SessionDiscoveryControl = {
|
|
99
|
+
state: EffectiveControlState;
|
|
100
|
+
primaryBlocker: {
|
|
101
|
+
kind: "session" | "workspace";
|
|
102
|
+
sessionId?: string;
|
|
103
|
+
displayName: string;
|
|
104
|
+
displayNameOriginalChars: number;
|
|
105
|
+
} | null;
|
|
106
|
+
additionalBlockerCount: number;
|
|
58
107
|
};
|
|
59
108
|
|
|
60
109
|
export function serializeEffectiveSessionControl(control: EffectiveSessionControl) {
|
|
@@ -79,7 +128,7 @@ export function serializeEffectiveSessionControl(control: EffectiveSessionContro
|
|
|
79
128
|
};
|
|
80
129
|
}
|
|
81
130
|
|
|
82
|
-
type WorkspaceControlRow = {
|
|
131
|
+
export type WorkspaceControlRow = {
|
|
83
132
|
workspaceId: string;
|
|
84
133
|
accountId: string;
|
|
85
134
|
revision: number | string;
|
|
@@ -156,17 +205,18 @@ export async function assertAgentCommandAuthorityInTransaction(
|
|
|
156
205
|
action: "pause" | "resume" | "steer" | "message";
|
|
157
206
|
},
|
|
158
207
|
): Promise<void> {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
208
|
+
// Every command caller establishes the control/workspace prefix first.
|
|
209
|
+
// Reusing the event-write helper here keeps cross-session actor authority on
|
|
210
|
+
// the same UUID-ordered session -> exact turn -> exact attempt suffix.
|
|
211
|
+
const authorityLocks = await lockSessionEventWriteRows(db, {
|
|
212
|
+
workspaceId: input.workspaceId,
|
|
213
|
+
controlLock: "already_locked",
|
|
214
|
+
workspaceLock: "already_locked",
|
|
215
|
+
sessionIds: [input.actor.sessionId, input.targetSessionId],
|
|
216
|
+
turnIds: [input.actor.turnId],
|
|
217
|
+
attemptIds: [input.actor.attemptId],
|
|
218
|
+
});
|
|
219
|
+
const lockedSessions = authorityLocks.sessions;
|
|
170
220
|
if (!lockedSessions.some((row) => row.id === input.actor.sessionId)) {
|
|
171
221
|
throw new AgentCommandAuthorityError("CALLER_STALE", "The calling session no longer exists");
|
|
172
222
|
}
|
|
@@ -207,7 +257,6 @@ export async function assertAgentCommandAuthorityInTransaction(
|
|
|
207
257
|
on session.workspace_id = attempt.workspace_id and session.id = attempt.session_id
|
|
208
258
|
where attempt.workspace_id = ${input.workspaceId}
|
|
209
259
|
and attempt.id = ${input.actor.attemptId}
|
|
210
|
-
for update of attempt, turn
|
|
211
260
|
`);
|
|
212
261
|
const caller = rows[0];
|
|
213
262
|
if (
|
|
@@ -337,6 +386,120 @@ export async function lockWorkspaceInferenceControl(
|
|
|
337
386
|
return row;
|
|
338
387
|
}
|
|
339
388
|
|
|
389
|
+
export type SessionEventWriteLockInput = {
|
|
390
|
+
workspaceId: string;
|
|
391
|
+
/**
|
|
392
|
+
* Control-aware writes take this lock first. Callers that already hold the
|
|
393
|
+
* workspace control row (for example a Pause mutation under FOR UPDATE) say
|
|
394
|
+
* `already_locked`; ordinary audit/title appends explicitly use `none`.
|
|
395
|
+
*/
|
|
396
|
+
controlLock: WorkspaceControlLockMode | "already_locked" | "none";
|
|
397
|
+
/** Used only when a staged caller already established the workspace prefix. */
|
|
398
|
+
workspaceLock?: "key_share" | "already_locked";
|
|
399
|
+
sessionIds?: string[];
|
|
400
|
+
turnIds?: string[];
|
|
401
|
+
attemptIds?: string[];
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
export type SessionEventWriteLocks = {
|
|
405
|
+
control: WorkspaceControlRow | null;
|
|
406
|
+
workspace: typeof schema.workspaces.$inferSelect | null;
|
|
407
|
+
sessions: Array<typeof schema.sessions.$inferSelect>;
|
|
408
|
+
turns: Array<typeof schema.sessionTurns.$inferSelect>;
|
|
409
|
+
attempts: Array<typeof schema.sessionTurnAttempts.$inferSelect>;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Establish the one canonical lock prefix for every `session_events` writer:
|
|
414
|
+
*
|
|
415
|
+
* workspace_inference_controls (when control-aware)
|
|
416
|
+
* -> actual workspaces row FOR KEY SHARE
|
|
417
|
+
* -> session rows FOR UPDATE, UUID ordered
|
|
418
|
+
* -> exact turn rows FOR UPDATE, UUID ordered
|
|
419
|
+
* -> exact attempt rows FOR UPDATE, UUID ordered
|
|
420
|
+
*
|
|
421
|
+
* `FOR KEY SHARE` is deliberate. Event inserts need the workspace key to remain
|
|
422
|
+
* stable for their FK, but they do not mutate the workspace. The old generic
|
|
423
|
+
* `FOR UPDATE` lock serialized unrelated sessions and inverted the activity
|
|
424
|
+
* path's session -> implicit workspace-FK edge.
|
|
425
|
+
*
|
|
426
|
+
* Complex lifecycle transactions may acquire allocator/control locks before
|
|
427
|
+
* this helper and may discover exact turn IDs only after locking the session.
|
|
428
|
+
* They use the explicit `already_locked` stages, while retaining the same
|
|
429
|
+
* monotonic table order. New event writers should prefer one complete call.
|
|
430
|
+
*/
|
|
431
|
+
export async function lockSessionEventWriteRows(
|
|
432
|
+
db: Database,
|
|
433
|
+
input: SessionEventWriteLockInput,
|
|
434
|
+
): Promise<SessionEventWriteLocks> {
|
|
435
|
+
const controlLock = input.controlLock;
|
|
436
|
+
const control =
|
|
437
|
+
controlLock === "share" || controlLock === "update"
|
|
438
|
+
? await lockWorkspaceInferenceControl(db, input.workspaceId, controlLock)
|
|
439
|
+
: null;
|
|
440
|
+
|
|
441
|
+
let workspace: typeof schema.workspaces.$inferSelect | null = null;
|
|
442
|
+
if ((input.workspaceLock ?? "key_share") === "key_share") {
|
|
443
|
+
const [lockedWorkspace] = await db
|
|
444
|
+
.select()
|
|
445
|
+
.from(schema.workspaces)
|
|
446
|
+
.where(eq(schema.workspaces.id, input.workspaceId))
|
|
447
|
+
.for("key share")
|
|
448
|
+
.limit(1);
|
|
449
|
+
workspace = lockedWorkspace ?? null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const sessionIds = [...new Set(input.sessionIds ?? [])].sort();
|
|
453
|
+
const sessions =
|
|
454
|
+
sessionIds.length > 0
|
|
455
|
+
? await db
|
|
456
|
+
.select()
|
|
457
|
+
.from(schema.sessions)
|
|
458
|
+
.where(
|
|
459
|
+
and(
|
|
460
|
+
eq(schema.sessions.workspaceId, input.workspaceId),
|
|
461
|
+
inArray(schema.sessions.id, sessionIds),
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
.orderBy(schema.sessions.id)
|
|
465
|
+
.for("update")
|
|
466
|
+
: [];
|
|
467
|
+
|
|
468
|
+
const turnIds = [...new Set(input.turnIds ?? [])].sort();
|
|
469
|
+
const turns =
|
|
470
|
+
turnIds.length > 0
|
|
471
|
+
? await db
|
|
472
|
+
.select()
|
|
473
|
+
.from(schema.sessionTurns)
|
|
474
|
+
.where(
|
|
475
|
+
and(
|
|
476
|
+
eq(schema.sessionTurns.workspaceId, input.workspaceId),
|
|
477
|
+
inArray(schema.sessionTurns.id, turnIds),
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
.orderBy(schema.sessionTurns.id)
|
|
481
|
+
.for("update")
|
|
482
|
+
: [];
|
|
483
|
+
|
|
484
|
+
const attemptIds = [...new Set(input.attemptIds ?? [])].sort();
|
|
485
|
+
const attempts =
|
|
486
|
+
attemptIds.length > 0
|
|
487
|
+
? await db
|
|
488
|
+
.select()
|
|
489
|
+
.from(schema.sessionTurnAttempts)
|
|
490
|
+
.where(
|
|
491
|
+
and(
|
|
492
|
+
eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
|
|
493
|
+
inArray(schema.sessionTurnAttempts.id, attemptIds),
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
.orderBy(schema.sessionTurnAttempts.id)
|
|
497
|
+
.for("update")
|
|
498
|
+
: [];
|
|
499
|
+
|
|
500
|
+
return { control, workspace, sessions, turns, attempts };
|
|
501
|
+
}
|
|
502
|
+
|
|
340
503
|
export async function registerSessionTurnAttemptClaim(
|
|
341
504
|
db: Database,
|
|
342
505
|
input: {
|
|
@@ -350,6 +513,7 @@ export async function registerSessionTurnAttemptClaim(
|
|
|
350
513
|
temporalWorkflowRunId: string;
|
|
351
514
|
temporalActivityId: string;
|
|
352
515
|
verifiedControlRevision: number;
|
|
516
|
+
mcpApprovalPolicies: Record<string, SessionMcpApprovalPolicy>;
|
|
353
517
|
},
|
|
354
518
|
): Promise<typeof schema.sessionTurnAttempts.$inferSelect> {
|
|
355
519
|
const [inserted] = await db
|
|
@@ -643,7 +807,7 @@ function projectEffectiveControl(
|
|
|
643
807
|
workspace: WorkspaceControlRow,
|
|
644
808
|
targetId: string,
|
|
645
809
|
rows: AncestryRow[],
|
|
646
|
-
|
|
810
|
+
settlementAttempts: SettlementAttemptCounts,
|
|
647
811
|
): EffectiveSessionControl {
|
|
648
812
|
assertCompleteAncestry(targetId, rows);
|
|
649
813
|
const workspaceRevision = asSafeRevision(workspace.revision, "workspace control revision")!;
|
|
@@ -775,30 +939,61 @@ function projectEffectiveControl(
|
|
|
775
939
|
rootSessionId: override.row.sessionId,
|
|
776
940
|
revision: override.overrideRevision,
|
|
777
941
|
},
|
|
778
|
-
settlement:
|
|
942
|
+
settlement:
|
|
943
|
+
settlementAttempts.attemptCount > 0 ? { state: "stopping", ...settlementAttempts } : null,
|
|
779
944
|
};
|
|
780
945
|
}
|
|
781
946
|
|
|
782
|
-
async function
|
|
947
|
+
async function settlementAttemptCounts(
|
|
783
948
|
db: Database,
|
|
784
949
|
workspaceId: string,
|
|
785
950
|
sessionIds: string[],
|
|
786
|
-
): Promise<Map<string,
|
|
951
|
+
): Promise<Map<string, SettlementAttemptCounts>> {
|
|
787
952
|
const rows = await db.execute<{
|
|
788
953
|
sessionId: string;
|
|
789
|
-
|
|
954
|
+
attemptCount: number | string;
|
|
955
|
+
interruptionPendingCount: number | string;
|
|
956
|
+
quiescencePendingCount: number | string;
|
|
790
957
|
}>(sql`
|
|
791
958
|
with recursive targets(id) as (values ${targetValues(sessionIds)}),
|
|
792
959
|
interruptions as (
|
|
793
|
-
select
|
|
960
|
+
select
|
|
961
|
+
interruption.session_id,
|
|
962
|
+
interruption.attempt_id,
|
|
963
|
+
bool_or(interruption.state in ('pending', 'delivered', 'acknowledged'))
|
|
964
|
+
as interruption_pending,
|
|
965
|
+
bool_or(
|
|
966
|
+
interruption.state in ('settled', 'rejected_stale')
|
|
967
|
+
and attempt.quiesced_at is null
|
|
968
|
+
) as quiescence_pending
|
|
794
969
|
from ${schema.sessionAttemptInterruptions} interruption
|
|
970
|
+
join ${schema.sessionTurnAttempts} attempt
|
|
971
|
+
on attempt.workspace_id = interruption.workspace_id
|
|
972
|
+
and attempt.id = interruption.attempt_id
|
|
795
973
|
where interruption.workspace_id = ${workspaceId}
|
|
796
|
-
and
|
|
797
|
-
|
|
974
|
+
and (
|
|
975
|
+
interruption.state in ('pending', 'delivered', 'acknowledged')
|
|
976
|
+
or (
|
|
977
|
+
interruption.state in ('settled', 'rejected_stale')
|
|
978
|
+
and attempt.quiesced_at is null
|
|
979
|
+
)
|
|
980
|
+
)
|
|
981
|
+
group by interruption.session_id, interruption.attempt_id
|
|
982
|
+
), interruption_ancestry(
|
|
983
|
+
session_id,
|
|
984
|
+
ancestor_id,
|
|
985
|
+
attempt_id,
|
|
986
|
+
interruption_pending,
|
|
987
|
+
quiescence_pending,
|
|
988
|
+
depth,
|
|
989
|
+
path
|
|
990
|
+
) as (
|
|
798
991
|
select
|
|
799
992
|
interruption.session_id,
|
|
800
993
|
interruption.session_id,
|
|
801
994
|
interruption.attempt_id,
|
|
995
|
+
interruption.interruption_pending,
|
|
996
|
+
interruption.quiescence_pending,
|
|
802
997
|
0::integer,
|
|
803
998
|
array[interruption.session_id]::uuid[]
|
|
804
999
|
from interruptions interruption
|
|
@@ -807,6 +1002,8 @@ async function unsettledAttemptCounts(
|
|
|
807
1002
|
ancestry.session_id,
|
|
808
1003
|
current.parent_session_id,
|
|
809
1004
|
ancestry.attempt_id,
|
|
1005
|
+
ancestry.interruption_pending,
|
|
1006
|
+
ancestry.quiescence_pending,
|
|
810
1007
|
ancestry.depth + 1,
|
|
811
1008
|
ancestry.path || current.parent_session_id
|
|
812
1009
|
from interruption_ancestry ancestry
|
|
@@ -816,16 +1013,33 @@ async function unsettledAttemptCounts(
|
|
|
816
1013
|
and not current.parent_session_id = any(ancestry.path)
|
|
817
1014
|
and ancestry.depth < ${SESSION_ANCESTRY_LIMIT}
|
|
818
1015
|
)
|
|
819
|
-
select
|
|
1016
|
+
select
|
|
1017
|
+
target.id as "sessionId",
|
|
1018
|
+
count(distinct ancestry.attempt_id)::integer as "attemptCount",
|
|
1019
|
+
count(distinct ancestry.attempt_id)
|
|
1020
|
+
filter (where ancestry.interruption_pending)::integer as "interruptionPendingCount",
|
|
1021
|
+
count(distinct ancestry.attempt_id)
|
|
1022
|
+
filter (where ancestry.quiescence_pending)::integer as "quiescencePendingCount"
|
|
820
1023
|
from targets target
|
|
821
1024
|
join interruption_ancestry ancestry on ancestry.ancestor_id = target.id
|
|
822
1025
|
group by target.id
|
|
823
1026
|
`);
|
|
824
1027
|
return new Map(
|
|
825
|
-
rows.map(
|
|
826
|
-
row
|
|
827
|
-
|
|
828
|
-
|
|
1028
|
+
rows.map(
|
|
1029
|
+
(row: {
|
|
1030
|
+
sessionId: string;
|
|
1031
|
+
attemptCount: number | string;
|
|
1032
|
+
interruptionPendingCount: number | string;
|
|
1033
|
+
quiescencePendingCount: number | string;
|
|
1034
|
+
}) => [
|
|
1035
|
+
row.sessionId,
|
|
1036
|
+
{
|
|
1037
|
+
attemptCount: Number(row.attemptCount),
|
|
1038
|
+
interruptionPendingCount: Number(row.interruptionPendingCount),
|
|
1039
|
+
quiescencePendingCount: Number(row.quiescencePendingCount),
|
|
1040
|
+
},
|
|
1041
|
+
],
|
|
1042
|
+
),
|
|
829
1043
|
);
|
|
830
1044
|
}
|
|
831
1045
|
|
|
@@ -833,14 +1047,25 @@ export async function evaluateSessionControls(
|
|
|
833
1047
|
db: Database,
|
|
834
1048
|
workspaceId: string,
|
|
835
1049
|
sessionIds: string[],
|
|
836
|
-
options: {
|
|
1050
|
+
options: {
|
|
1051
|
+
lock?: WorkspaceControlLockMode;
|
|
1052
|
+
/** Reuse a control row already locked before workspace/session/turn rows. */
|
|
1053
|
+
workspaceControl?: WorkspaceControlRow | undefined;
|
|
1054
|
+
} = {},
|
|
837
1055
|
): Promise<Map<string, EffectiveSessionControl>> {
|
|
838
1056
|
const uniqueIds = [...new Set(sessionIds)];
|
|
839
1057
|
if (uniqueIds.length === 0) {
|
|
840
1058
|
return new Map();
|
|
841
1059
|
}
|
|
842
|
-
|
|
843
|
-
|
|
1060
|
+
if (options.workspaceControl && options.workspaceControl.workspaceId !== workspaceId) {
|
|
1061
|
+
throw new SessionControlInvariantError(
|
|
1062
|
+
`Locked workspace control ${options.workspaceControl.workspaceId} does not match ${workspaceId}`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
const workspace =
|
|
1066
|
+
options.workspaceControl ??
|
|
1067
|
+
(await lockWorkspaceInferenceControl(db, workspaceId, options.lock ?? "share"));
|
|
1068
|
+
const stopping = await settlementAttemptCounts(db, workspaceId, uniqueIds);
|
|
844
1069
|
const result = new Map<string, EffectiveSessionControl>();
|
|
845
1070
|
if (uniqueIds.length <= TARGET_PATH_PROJECTION_LIMIT) {
|
|
846
1071
|
// PostgreSQL's direct target-path plan is substantially faster for the
|
|
@@ -859,7 +1084,7 @@ export async function evaluateSessionControls(
|
|
|
859
1084
|
workspace,
|
|
860
1085
|
sessionId,
|
|
861
1086
|
ancestryByTarget.get(sessionId) ?? [],
|
|
862
|
-
stopping.get(sessionId) ??
|
|
1087
|
+
stopping.get(sessionId) ?? NO_SETTLEMENT_ATTEMPTS,
|
|
863
1088
|
),
|
|
864
1089
|
);
|
|
865
1090
|
}
|
|
@@ -877,18 +1102,236 @@ export async function evaluateSessionControls(
|
|
|
877
1102
|
workspace,
|
|
878
1103
|
sessionId,
|
|
879
1104
|
ancestryRowsForTarget(sessionId, ancestry),
|
|
880
|
-
stopping.get(sessionId) ??
|
|
1105
|
+
stopping.get(sessionId) ?? NO_SETTLEMENT_ATTEMPTS,
|
|
881
1106
|
),
|
|
882
1107
|
);
|
|
883
1108
|
}
|
|
884
1109
|
return result;
|
|
885
1110
|
}
|
|
886
1111
|
|
|
1112
|
+
type SessionDiscoveryControlRow = {
|
|
1113
|
+
targetId: string;
|
|
1114
|
+
ancestryCount: number | string;
|
|
1115
|
+
reachedRoot: boolean;
|
|
1116
|
+
maxDepth: number | string | null;
|
|
1117
|
+
blockerCount: number | string;
|
|
1118
|
+
primaryKind: "session" | "workspace" | null;
|
|
1119
|
+
primarySessionId: string | null;
|
|
1120
|
+
primaryDisplayName: string | null;
|
|
1121
|
+
primaryDisplayNameOriginalChars: number | string | null;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Return one compact aggregate row per target for `sessions_list`.
|
|
1126
|
+
*
|
|
1127
|
+
* Unlike `evaluateSessionControls`, the database boundary never returns a row
|
|
1128
|
+
* per blocker/ancestor, nor does application code construct blocker or resume
|
|
1129
|
+
* option arrays. The recursive walk carries no growing visited-path array: a
|
|
1130
|
+
* missing ancestor stops below the limit, while a cycle cannot reach a root and
|
|
1131
|
+
* therefore runs into the hard `SESSION_ANCESTRY_LIMIT`. The externally
|
|
1132
|
+
* supplied target set is capped by `SESSION_DISCOVERY_CONTROL_TARGET_LIMIT`.
|
|
1133
|
+
*/
|
|
1134
|
+
export async function evaluateSessionDiscoveryControls(
|
|
1135
|
+
db: Database,
|
|
1136
|
+
workspaceId: string,
|
|
1137
|
+
sessionIds: string[],
|
|
1138
|
+
): Promise<Map<string, SessionDiscoveryControl>> {
|
|
1139
|
+
const uniqueIds = [...new Set(sessionIds)];
|
|
1140
|
+
if (uniqueIds.length === 0) return new Map();
|
|
1141
|
+
if (uniqueIds.length > SESSION_DISCOVERY_CONTROL_TARGET_LIMIT) {
|
|
1142
|
+
throw new SessionControlInvariantError(
|
|
1143
|
+
`Session discovery control projection exceeds ${SESSION_DISCOVERY_CONTROL_TARGET_LIMIT} targets`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const workspace = await lockWorkspaceInferenceControl(db, workspaceId, "share");
|
|
1148
|
+
const workspacePauseRevision = asSafeRevision(
|
|
1149
|
+
workspace.workspacePauseRevision,
|
|
1150
|
+
"workspace pause revision",
|
|
1151
|
+
);
|
|
1152
|
+
if (workspace.workspaceState === "paused" && workspacePauseRevision === null) {
|
|
1153
|
+
throw new SessionControlInvariantError("Paused workspace is missing its pause revision");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const rows = await db.execute<SessionDiscoveryControlRow>(sql`
|
|
1157
|
+
with recursive targets(id) as (values ${targetValues(uniqueIds)}),
|
|
1158
|
+
ancestry as (
|
|
1159
|
+
select
|
|
1160
|
+
target.id as target_id,
|
|
1161
|
+
session.id as session_id,
|
|
1162
|
+
session.parent_session_id,
|
|
1163
|
+
left(coalesce(nullif(btrim(session.title), ''), 'Untitled session'), ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS}) as display_name,
|
|
1164
|
+
char_length(coalesce(nullif(btrim(session.title), ''), 'Untitled session'))::integer as display_name_original_chars,
|
|
1165
|
+
session.direct_control_state,
|
|
1166
|
+
session.direct_pause_revision,
|
|
1167
|
+
session.subtree_run_override_revision,
|
|
1168
|
+
0::integer as depth
|
|
1169
|
+
from targets target
|
|
1170
|
+
join ${schema.sessions} session
|
|
1171
|
+
on session.workspace_id = ${workspaceId} and session.id = target.id
|
|
1172
|
+
union all
|
|
1173
|
+
select
|
|
1174
|
+
child.target_id,
|
|
1175
|
+
parent.id,
|
|
1176
|
+
parent.parent_session_id,
|
|
1177
|
+
left(coalesce(nullif(btrim(parent.title), ''), 'Untitled session'), ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS}),
|
|
1178
|
+
char_length(coalesce(nullif(btrim(parent.title), ''), 'Untitled session'))::integer,
|
|
1179
|
+
parent.direct_control_state,
|
|
1180
|
+
parent.direct_pause_revision,
|
|
1181
|
+
parent.subtree_run_override_revision,
|
|
1182
|
+
child.depth + 1
|
|
1183
|
+
from ancestry child
|
|
1184
|
+
join ${schema.sessions} parent
|
|
1185
|
+
on parent.workspace_id = ${workspaceId} and parent.id = child.parent_session_id
|
|
1186
|
+
where child.parent_session_id is not null
|
|
1187
|
+
and child.depth < ${SESSION_ANCESTRY_LIMIT}
|
|
1188
|
+
),
|
|
1189
|
+
path as (
|
|
1190
|
+
select
|
|
1191
|
+
ancestry.*,
|
|
1192
|
+
max(ancestry.subtree_run_override_revision) over (
|
|
1193
|
+
partition by ancestry.target_id
|
|
1194
|
+
order by ancestry.depth
|
|
1195
|
+
rows between unbounded preceding and 1 preceding
|
|
1196
|
+
) as descendant_override_revision
|
|
1197
|
+
from ancestry
|
|
1198
|
+
),
|
|
1199
|
+
session_blockers as (
|
|
1200
|
+
select
|
|
1201
|
+
path.target_id,
|
|
1202
|
+
'session'::text as kind,
|
|
1203
|
+
path.session_id,
|
|
1204
|
+
path.display_name,
|
|
1205
|
+
path.display_name_original_chars,
|
|
1206
|
+
path.depth
|
|
1207
|
+
from path
|
|
1208
|
+
where path.direct_control_state = 'paused'
|
|
1209
|
+
and path.direct_pause_revision is not null
|
|
1210
|
+
and (
|
|
1211
|
+
path.descendant_override_revision is null
|
|
1212
|
+
or path.descendant_override_revision <= path.direct_pause_revision
|
|
1213
|
+
)
|
|
1214
|
+
),
|
|
1215
|
+
workspace_blockers as (
|
|
1216
|
+
select
|
|
1217
|
+
target.id as target_id,
|
|
1218
|
+
'workspace'::text as kind,
|
|
1219
|
+
null::uuid as session_id,
|
|
1220
|
+
'Workspace'::text as display_name,
|
|
1221
|
+
9::integer as display_name_original_chars,
|
|
1222
|
+
${SESSION_ANCESTRY_LIMIT + 1}::integer as depth
|
|
1223
|
+
from targets target
|
|
1224
|
+
where ${workspace.workspaceState === "paused"}
|
|
1225
|
+
and not exists (
|
|
1226
|
+
select 1
|
|
1227
|
+
from path
|
|
1228
|
+
where path.target_id = target.id
|
|
1229
|
+
and path.subtree_run_override_revision is not null
|
|
1230
|
+
and path.subtree_run_override_revision > ${workspacePauseRevision}
|
|
1231
|
+
)
|
|
1232
|
+
),
|
|
1233
|
+
blockers as (
|
|
1234
|
+
select * from session_blockers
|
|
1235
|
+
union all
|
|
1236
|
+
select * from workspace_blockers
|
|
1237
|
+
),
|
|
1238
|
+
primary_blockers as (
|
|
1239
|
+
select distinct on (blockers.target_id)
|
|
1240
|
+
blockers.target_id,
|
|
1241
|
+
blockers.kind,
|
|
1242
|
+
blockers.session_id,
|
|
1243
|
+
blockers.display_name,
|
|
1244
|
+
blockers.display_name_original_chars
|
|
1245
|
+
from blockers
|
|
1246
|
+
order by blockers.target_id, blockers.depth
|
|
1247
|
+
),
|
|
1248
|
+
blocker_counts as (
|
|
1249
|
+
select blockers.target_id, count(*)::integer as blocker_count
|
|
1250
|
+
from blockers
|
|
1251
|
+
group by blockers.target_id
|
|
1252
|
+
),
|
|
1253
|
+
ancestry_metadata as (
|
|
1254
|
+
select
|
|
1255
|
+
ancestry.target_id,
|
|
1256
|
+
count(*)::integer as ancestry_count,
|
|
1257
|
+
bool_or(ancestry.parent_session_id is null) as reached_root,
|
|
1258
|
+
max(ancestry.depth)::integer as max_depth
|
|
1259
|
+
from ancestry
|
|
1260
|
+
group by ancestry.target_id
|
|
1261
|
+
)
|
|
1262
|
+
select
|
|
1263
|
+
target.id as "targetId",
|
|
1264
|
+
coalesce(metadata.ancestry_count, 0)::integer as "ancestryCount",
|
|
1265
|
+
coalesce(metadata.reached_root, false) as "reachedRoot",
|
|
1266
|
+
metadata.max_depth as "maxDepth",
|
|
1267
|
+
coalesce(counts.blocker_count, 0)::integer as "blockerCount",
|
|
1268
|
+
primary_blocker.kind as "primaryKind",
|
|
1269
|
+
primary_blocker.session_id as "primarySessionId",
|
|
1270
|
+
primary_blocker.display_name as "primaryDisplayName",
|
|
1271
|
+
primary_blocker.display_name_original_chars as "primaryDisplayNameOriginalChars"
|
|
1272
|
+
from targets target
|
|
1273
|
+
left join ancestry_metadata metadata on metadata.target_id = target.id
|
|
1274
|
+
left join blocker_counts counts on counts.target_id = target.id
|
|
1275
|
+
left join primary_blockers primary_blocker on primary_blocker.target_id = target.id
|
|
1276
|
+
order by target.id
|
|
1277
|
+
`);
|
|
1278
|
+
|
|
1279
|
+
const result = new Map<string, SessionDiscoveryControl>();
|
|
1280
|
+
for (const row of rows) {
|
|
1281
|
+
const ancestryCount = Number(row.ancestryCount);
|
|
1282
|
+
const maxDepth = row.maxDepth === null ? null : Number(row.maxDepth);
|
|
1283
|
+
if (ancestryCount === 0) {
|
|
1284
|
+
throw new SessionControlInvariantError(
|
|
1285
|
+
`Session ${row.targetId} does not exist in its workspace`,
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
if (!row.reachedRoot) {
|
|
1289
|
+
throw new SessionControlInvariantError(
|
|
1290
|
+
maxDepth !== null && maxDepth >= SESSION_ANCESTRY_LIMIT
|
|
1291
|
+
? `Session ${row.targetId} ancestry exceeds ${SESSION_ANCESTRY_LIMIT}`
|
|
1292
|
+
: `Session ${row.targetId} has a missing ancestor`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const blockerCount = Number(row.blockerCount);
|
|
1297
|
+
const primaryBlocker = row.primaryKind
|
|
1298
|
+
? {
|
|
1299
|
+
kind: row.primaryKind,
|
|
1300
|
+
...(row.primarySessionId ? { sessionId: row.primarySessionId } : {}),
|
|
1301
|
+
displayName:
|
|
1302
|
+
row.primaryDisplayName ??
|
|
1303
|
+
(row.primaryKind === "workspace" ? "Workspace" : "Untitled session"),
|
|
1304
|
+
displayNameOriginalChars: Number(
|
|
1305
|
+
row.primaryDisplayNameOriginalChars ??
|
|
1306
|
+
(row.primaryKind === "workspace" ? 9 : "Untitled session".length),
|
|
1307
|
+
),
|
|
1308
|
+
}
|
|
1309
|
+
: null;
|
|
1310
|
+
if (blockerCount > 0 !== (primaryBlocker !== null)) {
|
|
1311
|
+
throw new SessionControlInvariantError(
|
|
1312
|
+
`Session ${row.targetId} discovery blocker aggregate is inconsistent`,
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
result.set(row.targetId, {
|
|
1316
|
+
state: blockerCount > 0 ? "paused" : "active",
|
|
1317
|
+
primaryBlocker,
|
|
1318
|
+
additionalBlockerCount: Math.max(0, blockerCount - 1),
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
if (result.size !== uniqueIds.length) {
|
|
1322
|
+
throw new SessionControlInvariantError("Session discovery control projection is incomplete");
|
|
1323
|
+
}
|
|
1324
|
+
return result;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
887
1327
|
export async function evaluateSessionControl(
|
|
888
1328
|
db: Database,
|
|
889
1329
|
workspaceId: string,
|
|
890
1330
|
sessionId: string,
|
|
891
|
-
options: {
|
|
1331
|
+
options: {
|
|
1332
|
+
lock?: WorkspaceControlLockMode;
|
|
1333
|
+
workspaceControl?: WorkspaceControlRow | undefined;
|
|
1334
|
+
} = {},
|
|
892
1335
|
): Promise<EffectiveSessionControl> {
|
|
893
1336
|
return (await evaluateSessionControls(db, workspaceId, [sessionId], options)).get(sessionId)!;
|
|
894
1337
|
}
|
|
@@ -1332,6 +1775,16 @@ export async function mutateSessionControlInTransaction(
|
|
|
1332
1775
|
},
|
|
1333
1776
|
): Promise<SessionControlMutationResult> {
|
|
1334
1777
|
const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
|
|
1778
|
+
await lockSessionEventWriteRows(db, {
|
|
1779
|
+
workspaceId: input.workspaceId,
|
|
1780
|
+
controlLock: "already_locked",
|
|
1781
|
+
sessionIds:
|
|
1782
|
+
input.actor.type === "agent_attempt"
|
|
1783
|
+
? [input.actor.sessionId, input.sessionId]
|
|
1784
|
+
: [input.sessionId],
|
|
1785
|
+
turnIds: input.actor.type === "agent_attempt" ? [input.actor.turnId] : [],
|
|
1786
|
+
attemptIds: input.actor.type === "agent_attempt" ? [input.actor.attemptId] : [],
|
|
1787
|
+
});
|
|
1335
1788
|
const hash = canonicalSessionCommandHash({
|
|
1336
1789
|
action: input.action,
|
|
1337
1790
|
reason: input.reason ?? null,
|
|
@@ -1361,7 +1814,7 @@ export async function mutateSessionControlInTransaction(
|
|
|
1361
1814
|
return {
|
|
1362
1815
|
receipt: reserved.receipt,
|
|
1363
1816
|
control: await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1364
|
-
|
|
1817
|
+
workspaceControl: workspace,
|
|
1365
1818
|
}),
|
|
1366
1819
|
sessionControlEventId,
|
|
1367
1820
|
workspaceControlEventId,
|
|
@@ -1379,7 +1832,7 @@ export async function mutateSessionControlInTransaction(
|
|
|
1379
1832
|
});
|
|
1380
1833
|
}
|
|
1381
1834
|
const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
|
|
1382
|
-
|
|
1835
|
+
workspaceControl: workspace,
|
|
1383
1836
|
});
|
|
1384
1837
|
if (input.expectedControlEtag && input.expectedControlEtag !== before.controlEtag) {
|
|
1385
1838
|
throw new SessionControlConflictError();
|
|
@@ -1541,7 +1994,7 @@ export async function autoResumeSessionBranchInTransaction(
|
|
|
1541
1994
|
workspaceId: string;
|
|
1542
1995
|
sessionId: string;
|
|
1543
1996
|
actor: string;
|
|
1544
|
-
reason: "human_send" | "human_steer" | "agent_steer";
|
|
1997
|
+
reason: "human_send" | "human_steer" | "service_send" | "service_steer" | "agent_steer";
|
|
1545
1998
|
observedControlEtag?: string | null;
|
|
1546
1999
|
},
|
|
1547
2000
|
): Promise<{
|
|
@@ -1748,9 +2201,38 @@ async function insertWorkspaceControlEventInTransaction(
|
|
|
1748
2201
|
actor: string;
|
|
1749
2202
|
},
|
|
1750
2203
|
): Promise<string> {
|
|
2204
|
+
const projected = boundWorkspaceControlEvent(
|
|
2205
|
+
{
|
|
2206
|
+
id: crypto.randomUUID(),
|
|
2207
|
+
workspaceId: input.workspaceId,
|
|
2208
|
+
sequence: input.revision,
|
|
2209
|
+
revision: input.revision,
|
|
2210
|
+
type: "workspace.control.changed",
|
|
2211
|
+
scope: input.scope,
|
|
2212
|
+
rootSessionId: input.rootSessionId,
|
|
2213
|
+
action: input.action,
|
|
2214
|
+
automatic: input.automatic,
|
|
2215
|
+
reason: input.reason,
|
|
2216
|
+
actor: input.actor,
|
|
2217
|
+
occurredAt: new Date(0).toISOString(),
|
|
2218
|
+
},
|
|
2219
|
+
{ surface: "durable_control" },
|
|
2220
|
+
);
|
|
2221
|
+
const field = (name: "reason" | "actor") =>
|
|
2222
|
+
projected.truncation?.fields.find((candidate) => candidate.field === name);
|
|
1751
2223
|
const [event] = await db
|
|
1752
2224
|
.insert(schema.workspaceControlEvents)
|
|
1753
|
-
.values(
|
|
2225
|
+
.values({
|
|
2226
|
+
...input,
|
|
2227
|
+
reason: projected.reason,
|
|
2228
|
+
reasonOriginalBytes:
|
|
2229
|
+
input.reason === null
|
|
2230
|
+
? null
|
|
2231
|
+
: (field("reason")?.originalBytes ?? workspaceControlUtf8Bytes(projected.reason!)),
|
|
2232
|
+
actor: projected.actor,
|
|
2233
|
+
actorOriginalBytes:
|
|
2234
|
+
field("actor")?.originalBytes ?? workspaceControlUtf8Bytes(projected.actor),
|
|
2235
|
+
})
|
|
1754
2236
|
.returning({ id: schema.workspaceControlEvents.id });
|
|
1755
2237
|
if (!event) {
|
|
1756
2238
|
throw new SessionControlInvariantError("Workspace control event was not inserted");
|