@opengeni/db 4.0.0-canary.2 → 4.0.0-canary.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.
Files changed (38) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/{chunk-B5Y3TCSP.js → chunk-3JGOYOWD.js} +2 -2
  3. package/dist/{chunk-NPKBYGAK.js → chunk-CZXYNVEU.js} +2 -2
  4. package/dist/{chunk-L2QFSPFP.js → chunk-DIFDLF2C.js} +2 -2
  5. package/dist/{chunk-XIYAXH47.js → chunk-GPKHEPVX.js} +2 -2
  6. package/dist/{chunk-CQF6G4IF.js → chunk-HUCU5BDD.js} +3 -3
  7. package/dist/{chunk-VU2IK37Y.js → chunk-OWZ36ST7.js} +2 -2
  8. package/dist/{chunk-DUB3LUVM.js → chunk-R6LIJYG6.js} +140 -8
  9. package/dist/chunk-R6LIJYG6.js.map +1 -0
  10. package/dist/{chunk-PWJJKCTF.js → chunk-WU3MSPZS.js} +19 -2
  11. package/dist/chunk-WU3MSPZS.js.map +1 -0
  12. package/dist/editable-artifact-durable-export.js +2 -2
  13. package/dist/editable-artifacts.js +3 -3
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.js +28 -8
  16. package/dist/index.js.map +1 -1
  17. package/dist/managed-auth-session-sets.js +3 -3
  18. package/dist/schema.d.ts +85 -0
  19. package/dist/schema.js +1 -1
  20. package/dist/session-background-commands.js +4 -4
  21. package/dist/session-command-output.js +4 -4
  22. package/dist/session-control.d.ts +26 -1
  23. package/dist/session-event-slices.js +2 -2
  24. package/dist/session-tenancy.js +3 -3
  25. package/dist/video-generation.js +3 -3
  26. package/drizzle/0420_workspace_pause_timers.sql +53 -0
  27. package/package.json +6 -6
  28. package/src/index.ts +21 -0
  29. package/src/schema.ts +20 -1
  30. package/src/session-control.ts +177 -4
  31. package/dist/chunk-DUB3LUVM.js.map +0 -1
  32. package/dist/chunk-PWJJKCTF.js.map +0 -1
  33. /package/dist/{chunk-B5Y3TCSP.js.map → chunk-3JGOYOWD.js.map} +0 -0
  34. /package/dist/{chunk-NPKBYGAK.js.map → chunk-CZXYNVEU.js.map} +0 -0
  35. /package/dist/{chunk-L2QFSPFP.js.map → chunk-DIFDLF2C.js.map} +0 -0
  36. /package/dist/{chunk-XIYAXH47.js.map → chunk-GPKHEPVX.js.map} +0 -0
  37. /package/dist/{chunk-CQF6G4IF.js.map → chunk-HUCU5BDD.js.map} +0 -0
  38. /package/dist/{chunk-VU2IK37Y.js.map → chunk-OWZ36ST7.js.map} +0 -0
@@ -20,9 +20,9 @@ import {
20
20
  reapManagedAuthIsolatedSessions,
21
21
  releaseManagedAuthActorMutationLease,
22
22
  validateManagedAuthActorMutationLease
23
- } from "./chunk-VU2IK37Y.js";
24
- import "./chunk-XIYAXH47.js";
25
- import "./chunk-PWJJKCTF.js";
23
+ } from "./chunk-OWZ36ST7.js";
24
+ import "./chunk-GPKHEPVX.js";
25
+ import "./chunk-WU3MSPZS.js";
26
26
  import "./chunk-PZ5AY32C.js";
27
27
  export {
28
28
  ManagedAuthActorMutationInFlightError,
package/dist/schema.d.ts CHANGED
@@ -1463,6 +1463,91 @@ export declare const workspaceInferenceControls: import("drizzle-orm/pg-core").P
1463
1463
  identity: undefined;
1464
1464
  generated: undefined;
1465
1465
  }, {}, {}>;
1466
+ timerId: import("drizzle-orm/pg-core").PgColumn<{
1467
+ name: "timer_id";
1468
+ tableName: "workspace_inference_controls";
1469
+ dataType: "string";
1470
+ columnType: "PgUUID";
1471
+ data: string;
1472
+ driverParam: string;
1473
+ notNull: false;
1474
+ hasDefault: false;
1475
+ isPrimaryKey: false;
1476
+ isAutoincrement: false;
1477
+ hasRuntimeDefault: false;
1478
+ enumValues: undefined;
1479
+ baseColumn: never;
1480
+ identity: undefined;
1481
+ generated: undefined;
1482
+ }, {}, {}>;
1483
+ timerAction: import("drizzle-orm/pg-core").PgColumn<{
1484
+ name: "timer_action";
1485
+ tableName: "workspace_inference_controls";
1486
+ dataType: "string";
1487
+ columnType: "PgText";
1488
+ data: string;
1489
+ driverParam: string;
1490
+ notNull: false;
1491
+ hasDefault: false;
1492
+ isPrimaryKey: false;
1493
+ isAutoincrement: false;
1494
+ hasRuntimeDefault: false;
1495
+ enumValues: [string, ...string[]];
1496
+ baseColumn: never;
1497
+ identity: undefined;
1498
+ generated: undefined;
1499
+ }, {}, {}>;
1500
+ timerDueAt: import("drizzle-orm/pg-core").PgColumn<{
1501
+ name: "timer_due_at";
1502
+ tableName: "workspace_inference_controls";
1503
+ dataType: "date";
1504
+ columnType: "PgTimestamp";
1505
+ data: Date;
1506
+ driverParam: string;
1507
+ notNull: false;
1508
+ hasDefault: false;
1509
+ isPrimaryKey: false;
1510
+ isAutoincrement: false;
1511
+ hasRuntimeDefault: false;
1512
+ enumValues: undefined;
1513
+ baseColumn: never;
1514
+ identity: undefined;
1515
+ generated: undefined;
1516
+ }, {}, {}>;
1517
+ timerPauseForSeconds: import("drizzle-orm/pg-core").PgColumn<{
1518
+ name: "timer_pause_for_seconds";
1519
+ tableName: "workspace_inference_controls";
1520
+ dataType: "number";
1521
+ columnType: "PgInteger";
1522
+ data: number;
1523
+ driverParam: string | number;
1524
+ notNull: false;
1525
+ hasDefault: false;
1526
+ isPrimaryKey: false;
1527
+ isAutoincrement: false;
1528
+ hasRuntimeDefault: false;
1529
+ enumValues: undefined;
1530
+ baseColumn: never;
1531
+ identity: undefined;
1532
+ generated: undefined;
1533
+ }, {}, {}>;
1534
+ timerPauseRevision: import("drizzle-orm/pg-core").PgColumn<{
1535
+ name: "timer_pause_revision";
1536
+ tableName: "workspace_inference_controls";
1537
+ dataType: "number";
1538
+ columnType: "PgBigInt53";
1539
+ data: number;
1540
+ driverParam: string | number;
1541
+ notNull: false;
1542
+ hasDefault: false;
1543
+ isPrimaryKey: false;
1544
+ isAutoincrement: false;
1545
+ hasRuntimeDefault: false;
1546
+ enumValues: undefined;
1547
+ baseColumn: never;
1548
+ identity: undefined;
1549
+ generated: undefined;
1550
+ }, {}, {}>;
1466
1551
  workspacePauseRevision: import("drizzle-orm/pg-core").PgColumn<{
1467
1552
  name: "workspace_pause_revision";
1468
1553
  tableName: "workspace_inference_controls";
package/dist/schema.js CHANGED
@@ -350,7 +350,7 @@ import {
350
350
  xaiRotationSettings,
351
351
  xaiSessionAccountPins,
352
352
  xaiSubscriptionCredentials
353
- } from "./chunk-PWJJKCTF.js";
353
+ } from "./chunk-WU3MSPZS.js";
354
354
  import "./chunk-PZ5AY32C.js";
355
355
  export {
356
356
  agentRunStates,
@@ -22,10 +22,10 @@ import {
22
22
  settleClaimedConnectedMachineBackgroundCommandWithMutation,
23
23
  settleConnectedMachineSessionBackgroundCommandWithMutation,
24
24
  settleSessionBackgroundCommandForRetainedProcessInTransaction
25
- } from "./chunk-DUB3LUVM.js";
26
- import "./chunk-CQF6G4IF.js";
27
- import "./chunk-XIYAXH47.js";
28
- import "./chunk-PWJJKCTF.js";
25
+ } from "./chunk-R6LIJYG6.js";
26
+ import "./chunk-HUCU5BDD.js";
27
+ import "./chunk-GPKHEPVX.js";
28
+ import "./chunk-WU3MSPZS.js";
29
29
  import "./chunk-PZ5AY32C.js";
30
30
  export {
31
31
  COMMAND_OUTPUT_DEFAULT_BYTES,
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  lockSessionEventWriteRows
3
- } from "./chunk-DUB3LUVM.js";
4
- import "./chunk-CQF6G4IF.js";
3
+ } from "./chunk-R6LIJYG6.js";
4
+ import "./chunk-HUCU5BDD.js";
5
5
  import {
6
6
  withSessionActivityRlsContext
7
- } from "./chunk-XIYAXH47.js";
7
+ } from "./chunk-GPKHEPVX.js";
8
8
  import {
9
9
  fromPostgresLosslessJson,
10
10
  sandboxRetainedProcesses,
@@ -12,7 +12,7 @@ import {
12
12
  sessionEvents,
13
13
  sessions,
14
14
  withLosslessContentWriteVersion
15
- } from "./chunk-PWJJKCTF.js";
15
+ } from "./chunk-WU3MSPZS.js";
16
16
  import "./chunk-PZ5AY32C.js";
17
17
 
18
18
  // src/session-command-output.ts
@@ -1,4 +1,4 @@
1
- import { type SessionMcpApprovalPolicy, type TurnInitiatorContext } from "@opengeni/contracts";
1
+ import { type WorkspacePauseTimerRequest, type SessionMcpApprovalPolicy, type TurnInitiatorContext } from "@opengeni/contracts";
2
2
  import type { Database, SessionActivityDatabase } from "./database.js";
3
3
  import * as schema from "./schema.js";
4
4
  export declare const SESSION_ANCESTRY_LIMIT = 10000;
@@ -131,6 +131,11 @@ export type WorkspaceControlRow = {
131
131
  revision: number | string;
132
132
  workspaceState: string;
133
133
  workspacePauseRevision: number | string | null;
134
+ timerId: string | null;
135
+ timerAction: string | null;
136
+ timerDueAt: Date | string | null;
137
+ timerPauseForSeconds: number | null;
138
+ timerPauseRevision: number | string | null;
134
139
  reason: string | null;
135
140
  changedBy: string | null;
136
141
  changedAt: Date | string | null;
@@ -584,7 +589,27 @@ export declare function mutateWorkspaceControlInTransaction(db: Database, input:
584
589
  operationKey: string;
585
590
  action: "pause" | "resume";
586
591
  reason?: string | null;
592
+ /** Internal timer executor retains/replaces its own timer atomically. */
593
+ timerExecution?: boolean;
587
594
  expectedRevision?: number | null;
588
595
  /** Request-scoped callers bound the control prefix wait; lifecycle callers omit it. */
589
596
  controlLockTimeoutMs?: number;
590
597
  }): Promise<WorkspaceControlMutationResult>;
598
+ export declare class WorkspacePauseTimerInputError extends Error {
599
+ }
600
+ /** Called under workspace RLS. The same control fence serializes timers and human actions. */
601
+ export declare function setWorkspacePauseTimerInTransaction(db: Database, input: WorkspacePauseTimerRequest & {
602
+ accountId: string;
603
+ workspaceId: string;
604
+ subjectId: string;
605
+ }): Promise<{
606
+ workspaceControlEventId: string | null;
607
+ }>;
608
+ /** Discovery is a hint. Identity, deadline and pause ownership are rechecked under the fence. */
609
+ export declare function fireWorkspacePauseTimerInTransaction(db: Database, input: {
610
+ workspaceId: string;
611
+ timerId: string;
612
+ }): Promise<{
613
+ workspaceControlEventId: string | null;
614
+ wakeCount: number;
615
+ } | null>;
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  rawRows,
3
3
  withWorkspaceRls
4
- } from "./chunk-XIYAXH47.js";
4
+ } from "./chunk-GPKHEPVX.js";
5
5
  import {
6
6
  LOSSLESS_JSON_STRING_PREFIX,
7
7
  fromPostgresLosslessJson,
8
8
  sessionEvents,
9
9
  sessionTurns
10
- } from "./chunk-PWJJKCTF.js";
10
+ } from "./chunk-WU3MSPZS.js";
11
11
  import "./chunk-PZ5AY32C.js";
12
12
 
13
13
  // src/session-event-slices.ts
@@ -14,9 +14,9 @@ import {
14
14
  replayAppliedSessionFork,
15
15
  sessionTenancyProductActivated,
16
16
  transitionSessionVisibility
17
- } from "./chunk-B5Y3TCSP.js";
18
- import "./chunk-XIYAXH47.js";
19
- import "./chunk-PWJJKCTF.js";
17
+ } from "./chunk-3JGOYOWD.js";
18
+ import "./chunk-GPKHEPVX.js";
19
+ import "./chunk-WU3MSPZS.js";
20
20
  import "./chunk-PZ5AY32C.js";
21
21
  export {
22
22
  SessionTenancyAccessError,
@@ -29,9 +29,9 @@ import {
29
29
  settleVideoGenerationFailure,
30
30
  settleVideoGenerationReady,
31
31
  updateWorkspaceVideoGenerationPolicy
32
- } from "./chunk-CQF6G4IF.js";
33
- import "./chunk-XIYAXH47.js";
34
- import "./chunk-PWJJKCTF.js";
32
+ } from "./chunk-HUCU5BDD.js";
33
+ import "./chunk-GPKHEPVX.js";
34
+ import "./chunk-WU3MSPZS.js";
35
35
  import "./chunk-PZ5AY32C.js";
36
36
  export {
37
37
  ACTIVE_VIDEO_GENERATION_STATUSES,
@@ -0,0 +1,53 @@
1
+ -- deployment-mode: maintenance
2
+ -- Drain old API/control/turn workers before activation. New timer event actions
3
+ -- and manual-control cancellation semantics must be installed together.
4
+ ALTER TABLE workspace_inference_controls
5
+ ADD COLUMN timer_id uuid,
6
+ ADD COLUMN timer_action text,
7
+ ADD COLUMN timer_due_at timestamptz,
8
+ ADD COLUMN timer_pause_for_seconds integer,
9
+ ADD COLUMN timer_pause_revision bigint,
10
+ ADD CONSTRAINT workspace_pause_timer_shape CHECK (
11
+ (timer_id IS NULL AND timer_action IS NULL AND timer_due_at IS NULL
12
+ AND timer_pause_for_seconds IS NULL AND timer_pause_revision IS NULL)
13
+ OR (timer_id IS NOT NULL AND timer_action IS NOT NULL AND timer_due_at IS NOT NULL
14
+ AND ((timer_action = 'pause' AND timer_pause_revision IS NULL)
15
+ OR (timer_action = 'resume' AND timer_pause_revision IS NOT NULL AND timer_pause_for_seconds IS NULL))
16
+ AND (timer_pause_for_seconds IS NULL OR timer_pause_for_seconds BETWEEN 60 AND 2592000))
17
+ );
18
+ CREATE INDEX workspace_pause_timer_due_idx ON workspace_inference_controls(timer_due_at, workspace_id)
19
+ WHERE timer_id IS NOT NULL;
20
+ ALTER TABLE workspace_control_events DROP CONSTRAINT workspace_control_events_action_check;
21
+ ALTER TABLE workspace_control_events ADD CONSTRAINT workspace_control_events_action_check
22
+ CHECK (action IN ('pause', 'resume', 'timer_set', 'timer_cancelled'));
23
+
24
+ -- Narrow, read-only inventory capability. FORCE RLS stays enabled; only the
25
+ -- table owner inside this SECURITY DEFINER function gets cross-tenant discovery.
26
+ DO $migration$
27
+ DECLARE data_schema text := current_schema(); owner_name text := current_user;
28
+ BEGIN
29
+ EXECUTE format('CREATE POLICY workspace_pause_timer_discovery ON %I.workspace_inference_controls
30
+ FOR SELECT TO %I USING (current_setting(''opengeni.pause_timer_discovery'', true) = ''1'')',
31
+ data_schema, owner_name);
32
+ EXECUTE format($create$
33
+ CREATE FUNCTION opengeni_private.list_due_workspace_pause_timers(p_limit integer)
34
+ RETURNS TABLE(workspace_id uuid, timer_id uuid)
35
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog
36
+ AS $body$
37
+ DECLARE previous text := current_setting('opengeni.pause_timer_discovery', true);
38
+ BEGIN
39
+ PERFORM set_config('opengeni.pause_timer_discovery', '1', true);
40
+ RETURN QUERY SELECT c.workspace_id, c.timer_id FROM %I.workspace_inference_controls c
41
+ WHERE c.timer_id IS NOT NULL AND c.timer_due_at <= clock_timestamp()
42
+ ORDER BY c.timer_due_at, c.workspace_id LIMIT greatest(1, least(coalesce(p_limit, 100), 1000));
43
+ PERFORM set_config('opengeni.pause_timer_discovery', coalesce(previous, ''), true);
44
+ END $body$;
45
+ $create$, data_schema);
46
+ END $migration$;
47
+ REVOKE ALL ON FUNCTION opengeni_private.list_due_workspace_pause_timers(integer) FROM PUBLIC;
48
+ DO $grant$
49
+ BEGIN
50
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
51
+ GRANT EXECUTE ON FUNCTION opengeni_private.list_due_workspace_pause_timers(integer) TO opengeni_app;
52
+ END IF;
53
+ END $grant$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "4.0.0-canary.2",
3
+ "version": "4.0.0-canary.3",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -86,11 +86,11 @@
86
86
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
87
87
  },
88
88
  "dependencies": {
89
- "@opengeni/codemode": "^0.4.27-canary.2",
90
- "@opengeni/codex": "^0.2.21-canary.2",
91
- "@opengeni/config": "^1.0.0-canary.2",
92
- "@opengeni/contracts": "^2.13.0-canary.2",
93
- "@opengeni/network": "^0.3.0-canary.2",
89
+ "@opengeni/codemode": "^0.4.27-canary.3",
90
+ "@opengeni/codex": "^0.2.21-canary.3",
91
+ "@opengeni/config": "^1.0.0-canary.3",
92
+ "@opengeni/contracts": "^2.13.0-canary.3",
93
+ "@opengeni/network": "^0.3.0-canary.3",
94
94
  "drizzle-orm": "^0.45.2",
95
95
  "postgres": "^3.4.7"
96
96
  },
package/src/index.ts CHANGED
@@ -75741,6 +75741,15 @@ async function workspaceControlProjection(
75741
75741
  .limit(1);
75742
75742
  if (!control) throw new Error(`Workspace ${workspaceId} has no inference control`);
75743
75743
  return {
75744
+ timer: control.timerId
75745
+ ? {
75746
+ id: control.timerId,
75747
+ action: control.timerAction as "pause" | "resume",
75748
+ dueAt: control.timerDueAt!.toISOString(),
75749
+ pauseForSeconds: control.timerPauseForSeconds,
75750
+ }
75751
+ : null,
75752
+ serverTime: new Date().toISOString(),
75744
75753
  state: control.workspaceState as "active" | "paused",
75745
75754
  revision: Number(control.revision),
75746
75755
  reason: control.reason,
@@ -76470,3 +76479,15 @@ export * from "./session-tenancy";
76470
76479
  export * from "./governed-learning-activation";
76471
76480
  export * from "./automations";
76472
76481
  export * from "./organization-model-providers";
76482
+
76483
+ export {
76484
+ setWorkspacePauseTimerInTransaction,
76485
+ fireWorkspacePauseTimerInTransaction,
76486
+ } from "./session-control";
76487
+
76488
+ export async function listDueWorkspacePauseTimers(db: Database, limit = 100) {
76489
+ return await rawRows<{ workspace_id: string; timer_id: string }>(
76490
+ db,
76491
+ sql`select * from opengeni_private.list_due_workspace_pause_timers(${limit})`,
76492
+ );
76493
+ }
package/src/schema.ts CHANGED
@@ -405,6 +405,11 @@ export const workspaceInferenceControls = pgTable(
405
405
  accountId: uuid("account_id").notNull(),
406
406
  revision: bigint("revision", { mode: "number" }).notNull().default(0),
407
407
  workspaceState: text("workspace_state").notNull().default("active"),
408
+ timerId: uuid("timer_id"),
409
+ timerAction: text("timer_action"),
410
+ timerDueAt: timestamp("timer_due_at", { withTimezone: true }),
411
+ timerPauseForSeconds: integer("timer_pause_for_seconds"),
412
+ timerPauseRevision: bigint("timer_pause_revision", { mode: "number" }),
408
413
  workspacePauseRevision: bigint("workspace_pause_revision", {
409
414
  mode: "number",
410
415
  }),
@@ -420,6 +425,20 @@ export const workspaceInferenceControls = pgTable(
420
425
  columns: [table.workspaceId, table.accountId],
421
426
  foreignColumns: [workspaces.id, workspaces.accountId],
422
427
  }).onDelete("cascade"),
428
+ timerDue: index("workspace_pause_timer_due_idx")
429
+ .on(table.timerDueAt, table.workspaceId)
430
+ .where(sql`${table.timerId} is not null`),
431
+ timerShape: check(
432
+ "workspace_pause_timer_shape",
433
+ sql`
434
+ (${table.timerId} is null and ${table.timerAction} is null and ${table.timerDueAt} is null
435
+ and ${table.timerPauseForSeconds} is null and ${table.timerPauseRevision} is null)
436
+ or (${table.timerId} is not null and ${table.timerAction} is not null and ${table.timerDueAt} is not null
437
+ and ((${table.timerAction} = 'pause' and ${table.timerPauseRevision} is null)
438
+ or (${table.timerAction} = 'resume' and ${table.timerPauseRevision} is not null and ${table.timerPauseForSeconds} is null))
439
+ and (${table.timerPauseForSeconds} is null or ${table.timerPauseForSeconds} between 60 and 2592000))
440
+ `,
441
+ ),
423
442
  workspaceAccountIdentity: uniqueIndex("workspace_inference_controls_workspace_account_uq").on(
424
443
  table.workspaceId,
425
444
  table.accountId,
@@ -7526,7 +7545,7 @@ export const workspaceControlEvents = pgTable(
7526
7545
  ),
7527
7546
  actionValid: check(
7528
7547
  "workspace_control_events_action_check",
7529
- sql`${table.action} in ('pause', 'resume')`,
7548
+ sql`${table.action} in ('pause', 'resume', 'timer_set', 'timer_cancelled')`,
7530
7549
  ),
7531
7550
  }),
7532
7551
  );
@@ -4,6 +4,7 @@ import {
4
4
  childPausedClassification,
5
5
  McpPersonalConnectionDelegations,
6
6
  workspaceControlUtf8Bytes,
7
+ type WorkspacePauseTimerRequest,
7
8
  type SessionMcpApprovalPolicy,
8
9
  type TurnInitiatorContext,
9
10
  } from "@opengeni/contracts";
@@ -176,6 +177,11 @@ export type WorkspaceControlRow = {
176
177
  revision: number | string;
177
178
  workspaceState: string;
178
179
  workspacePauseRevision: number | string | null;
180
+ timerId: string | null;
181
+ timerAction: string | null;
182
+ timerDueAt: Date | string | null;
183
+ timerPauseForSeconds: number | null;
184
+ timerPauseRevision: number | string | null;
179
185
  reason: string | null;
180
186
  changedBy: string | null;
181
187
  changedAt: Date | string | null;
@@ -546,6 +552,9 @@ export async function lockWorkspaceInferenceControl(
546
552
  revision,
547
553
  workspace_state as "workspaceState",
548
554
  workspace_pause_revision as "workspacePauseRevision",
555
+ timer_id as "timerId", timer_action as "timerAction",
556
+ timer_due_at as "timerDueAt", timer_pause_for_seconds as "timerPauseForSeconds",
557
+ timer_pause_revision as "timerPauseRevision",
549
558
  reason,
550
559
  changed_by as "changedBy",
551
560
  changed_at as "changedAt"
@@ -3921,6 +3930,8 @@ export async function mutateWorkspaceControlInTransaction(
3921
3930
  operationKey: string;
3922
3931
  action: "pause" | "resume";
3923
3932
  reason?: string | null;
3933
+ /** Internal timer executor retains/replaces its own timer atomically. */
3934
+ timerExecution?: boolean;
3924
3935
  expectedRevision?: number | null;
3925
3936
  /** Request-scoped callers bound the control prefix wait; lifecycle callers omit it. */
3926
3937
  controlLockTimeoutMs?: number;
@@ -3986,7 +3997,8 @@ export async function mutateWorkspaceControlInTransaction(
3986
3997
  "workspace pause revision",
3987
3998
  );
3988
3999
  const changed =
3989
- input.action === "pause"
4000
+ (!input.timerExecution && workspace.timerId != null) ||
4001
+ (input.action === "pause"
3990
4002
  ? workspace.workspaceState !== "paused" ||
3991
4003
  workspacePauseRevision === null ||
3992
4004
  (await workspacePauseEffectsNeeded(db, {
@@ -3997,7 +4009,7 @@ export async function mutateWorkspaceControlInTransaction(
3997
4009
  (await continuableWakeRepairNeeded(db, {
3998
4010
  workspaceId: input.workspaceId,
3999
4011
  rootSessionId: null,
4000
- }));
4012
+ })));
4001
4013
  if (!changed) {
4002
4014
  const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
4003
4015
  result: {
@@ -4035,6 +4047,7 @@ export async function mutateWorkspaceControlInTransaction(
4035
4047
  const [updated] = await db
4036
4048
  .update(schema.workspaceInferenceControls)
4037
4049
  .set({
4050
+ ...(!input.timerExecution ? clearWorkspaceTimerFields : {}),
4038
4051
  revision,
4039
4052
  workspaceState: input.action === "pause" ? "paused" : "active",
4040
4053
  workspacePauseRevision: input.action === "pause" ? revision : null,
@@ -4060,7 +4073,7 @@ export async function mutateWorkspaceControlInTransaction(
4060
4073
  scope: "workspace",
4061
4074
  rootSessionId: null,
4062
4075
  action: input.action,
4063
- automatic: false,
4076
+ automatic: input.timerExecution === true,
4064
4077
  reason: input.reason ?? null,
4065
4078
  actor,
4066
4079
  });
@@ -4113,7 +4126,7 @@ async function insertWorkspaceControlEventInTransaction(
4113
4126
  revision: number;
4114
4127
  scope: "workspace" | "session";
4115
4128
  rootSessionId: string | null;
4116
- action: "pause" | "resume";
4129
+ action: "pause" | "resume" | "timer_set" | "timer_cancelled";
4117
4130
  automatic: boolean;
4118
4131
  reason: string | null;
4119
4132
  actor: string;
@@ -4157,3 +4170,163 @@ async function insertWorkspaceControlEventInTransaction(
4157
4170
  }
4158
4171
  return event.id;
4159
4172
  }
4173
+
4174
+ export class WorkspacePauseTimerInputError extends Error {}
4175
+
4176
+ const clearWorkspaceTimerFields = {
4177
+ timerId: null,
4178
+ timerAction: null,
4179
+ timerDueAt: null,
4180
+ timerPauseForSeconds: null,
4181
+ timerPauseRevision: null,
4182
+ };
4183
+
4184
+ /** Called under workspace RLS. The same control fence serializes timers and human actions. */
4185
+ export async function setWorkspacePauseTimerInTransaction(
4186
+ db: Database,
4187
+ input: WorkspacePauseTimerRequest & { accountId: string; workspaceId: string; subjectId: string },
4188
+ ): Promise<{ workspaceControlEventId: string | null }> {
4189
+ let workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update", {
4190
+ lockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
4191
+ });
4192
+ const reserved = await reserveSessionCommandReceipt(db, {
4193
+ accountId: input.accountId,
4194
+ workspaceId: input.workspaceId,
4195
+ actor: { type: "human", subjectId: input.subjectId },
4196
+ action: `workspace.timer.${input.action}`,
4197
+ targetSessionId: null,
4198
+ targetTurnId: null,
4199
+ operationKey: input.clientEventId,
4200
+ canonicalRequestHash: canonicalSessionCommandHash({
4201
+ action: input.action,
4202
+ pauseInSeconds: input.pauseInSeconds ?? 0,
4203
+ pauseForSeconds: input.pauseForSeconds ?? null,
4204
+ expectedRevision: input.expectedRevision,
4205
+ }),
4206
+ });
4207
+ if (reserved.replay)
4208
+ return {
4209
+ workspaceControlEventId:
4210
+ (reserved.receipt.result.workspaceControlEventId as string | null) ?? null,
4211
+ };
4212
+ if (Number(workspace.revision) !== input.expectedRevision)
4213
+ throw new SessionControlConflictError();
4214
+ let action: "pause" | "resume" = "pause";
4215
+ let delay = input.pauseInSeconds ?? 0;
4216
+ let pauseRevision: number | null = null;
4217
+ if (input.action === "set") {
4218
+ if (workspace.workspaceState === "paused") {
4219
+ if (delay !== 0 || input.pauseForSeconds == null) {
4220
+ throw new WorkspacePauseTimerInputError("A paused workspace requires a resume duration");
4221
+ }
4222
+ action = "resume";
4223
+ delay = input.pauseForSeconds;
4224
+ pauseRevision = Number(workspace.workspacePauseRevision);
4225
+ } else if (delay === 0) {
4226
+ await mutateWorkspaceControlInTransaction(db, {
4227
+ accountId: input.accountId,
4228
+ workspaceId: input.workspaceId,
4229
+ actor: { type: "human", subjectId: input.subjectId },
4230
+ operationKey: `timer-pause:${reserved.receipt.id}`,
4231
+ action: "pause",
4232
+ });
4233
+ workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
4234
+ action = "resume";
4235
+ delay = input.pauseForSeconds ?? 0;
4236
+ pauseRevision = Number(workspace.workspacePauseRevision);
4237
+ }
4238
+ }
4239
+ const hasTimer = input.action === "set" && delay > 0;
4240
+ const [clock] = await db.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
4241
+ const revision = nextRevision(workspace);
4242
+ await db
4243
+ .update(schema.workspaceInferenceControls)
4244
+ .set({
4245
+ ...clearWorkspaceTimerFields,
4246
+ ...(hasTimer
4247
+ ? {
4248
+ timerId: reserved.receipt.id,
4249
+ timerAction: action,
4250
+ timerDueAt: new Date(new Date(clock!.now).getTime() + delay * 1000),
4251
+ timerPauseForSeconds: action === "pause" ? (input.pauseForSeconds ?? null) : null,
4252
+ timerPauseRevision: pauseRevision,
4253
+ }
4254
+ : {}),
4255
+ revision,
4256
+ updatedAt: new Date(clock!.now),
4257
+ })
4258
+ .where(eq(schema.workspaceInferenceControls.workspaceId, input.workspaceId));
4259
+ const workspaceControlEventId = await insertWorkspaceControlEventInTransaction(db, {
4260
+ accountId: input.accountId,
4261
+ workspaceId: input.workspaceId,
4262
+ revision,
4263
+ scope: "workspace",
4264
+ rootSessionId: null,
4265
+ action: hasTimer ? "timer_set" : "timer_cancelled",
4266
+ automatic: false,
4267
+ reason: null,
4268
+ actor: input.subjectId,
4269
+ });
4270
+ await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
4271
+ controlRevision: revision,
4272
+ result: { workspaceControlEventId },
4273
+ });
4274
+ return { workspaceControlEventId };
4275
+ }
4276
+
4277
+ /** Discovery is a hint. Identity, deadline and pause ownership are rechecked under the fence. */
4278
+ export async function fireWorkspacePauseTimerInTransaction(
4279
+ db: Database,
4280
+ input: { workspaceId: string; timerId: string },
4281
+ ): Promise<{ workspaceControlEventId: string | null; wakeCount: number } | null> {
4282
+ const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update", {
4283
+ lockTimeoutMs: 1000,
4284
+ });
4285
+ const [clock] = await db.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
4286
+ if (
4287
+ workspace.timerId !== input.timerId ||
4288
+ !workspace.timerDueAt ||
4289
+ new Date(workspace.timerDueAt).getTime() > new Date(clock!.now).getTime()
4290
+ )
4291
+ return null;
4292
+ const action = workspace.timerAction as "pause" | "resume";
4293
+ // A superseding pause must never be undone by an old timer, even across rolling upgrades.
4294
+ if (
4295
+ action === "resume" &&
4296
+ (workspace.workspaceState !== "paused" ||
4297
+ Number(workspace.workspacePauseRevision) !== Number(workspace.timerPauseRevision))
4298
+ ) {
4299
+ await db
4300
+ .update(schema.workspaceInferenceControls)
4301
+ .set(clearWorkspaceTimerFields)
4302
+ .where(eq(schema.workspaceInferenceControls.workspaceId, input.workspaceId));
4303
+ return null;
4304
+ }
4305
+ const result = await mutateWorkspaceControlInTransaction(db, {
4306
+ accountId: workspace.accountId,
4307
+ workspaceId: input.workspaceId,
4308
+ actor: { type: "service", subjectId: "service:workspace-pause-timer" },
4309
+ operationKey: `timer:${input.timerId}:${action}`,
4310
+ action,
4311
+ timerExecution: true,
4312
+ });
4313
+ // Use application time only for display elsewhere; deadlines are database-clock based.
4314
+ const [applied] = await db.execute<{ now: Date }>(sql`select clock_timestamp() as now`);
4315
+ await db
4316
+ .update(schema.workspaceInferenceControls)
4317
+ .set({
4318
+ ...clearWorkspaceTimerFields,
4319
+ ...(action === "pause" && workspace.timerPauseForSeconds !== null
4320
+ ? {
4321
+ timerId: input.timerId,
4322
+ timerAction: "resume",
4323
+ timerDueAt: new Date(
4324
+ new Date(applied!.now).getTime() + workspace.timerPauseForSeconds * 1000,
4325
+ ),
4326
+ timerPauseRevision: result.revision,
4327
+ }
4328
+ : {}),
4329
+ })
4330
+ .where(eq(schema.workspaceInferenceControls.workspaceId, input.workspaceId));
4331
+ return result;
4332
+ }