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