@opengeni/contracts 0.9.0 → 0.10.0

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/src/index.ts CHANGED
@@ -5,6 +5,9 @@ export const SessionStatus = z.enum([
5
5
  "running",
6
6
  "idle",
7
7
  "requires_action",
8
+ "recovering",
9
+ "waiting_capacity",
10
+ "paused",
8
11
  "failed",
9
12
  "cancelled",
10
13
  ]);
@@ -472,8 +475,12 @@ export const Permission = z.enum([
472
475
  "api_keys:manage",
473
476
  "connections:read",
474
477
  "connections:write",
478
+ /** @deprecated alias of variable-sets:manage */
475
479
  "environments:manage",
480
+ /** @deprecated alias of variable-sets:use */
476
481
  "environments:use",
482
+ "variable-sets:manage",
483
+ "variable-sets:use",
477
484
  // Attach or rotate per-session third-party MCP server credentials. Deliberately
478
485
  // not part of the worker's default first-party MCP permission set: a sandboxed
479
486
  // agent must not be able to hand itself new bearer credentials.
@@ -490,6 +497,13 @@ export const Permission = z.enum([
490
497
  // admin-shaped action. workspace:admin is the super-wildcard over both.
491
498
  "enrollments:read",
492
499
  "enrollments:manage",
500
+ // Rigs (workspace-scoped, versioned sandbox machine definitions). rigs:use is
501
+ // read + propose-change (the agent-native, additive path a sandboxed session
502
+ // is trusted with); rigs:manage is create/edit/activate/promote/delete (the
503
+ // admin-shaped path that mints or rolls versions). workspace:admin is the
504
+ // super-wildcard over both.
505
+ "rigs:use",
506
+ "rigs:manage",
493
507
  ]);
494
508
  export type Permission = z.infer<typeof Permission>;
495
509
 
@@ -532,14 +546,66 @@ export const Workspace = z.object({
532
546
  // Per-workspace agent persona template (white-label override). null means
533
547
  // the deployment default (OPENGENI_AGENT_INSTRUCTIONS_TEMPLATE /
534
548
  // DEFAULT_AGENT_INSTRUCTIONS) is used. The runtime always injects the
535
- // non-bypassable CORE (goal-loop ownership + environment block), so an
549
+ // non-bypassable CORE (goal-loop ownership + variableSet block), so an
536
550
  // override restyles the persona without dropping that contract.
537
551
  agentInstructions: z.string().nullable(),
552
+ // Growth-ready per-workspace settings bag (migration 0045). Known keys are
553
+ // validated by WorkspaceSettingsSchema; unknown keys are preserved across
554
+ // PATCH merges so newer settings survive an older server.
555
+ settings: z.record(z.string(), z.unknown()),
556
+ inferenceState: z.enum(["active", "paused"]),
557
+ inferenceGeneration: z.number().int().nonnegative(),
558
+ inferenceReason: z.string().nullable(),
559
+ inferenceChangedBy: z.string().nullable(),
560
+ inferenceChangedAt: z.string().nullable(),
561
+ // Workspace default rig used by session/scheduled-task create fallback.
562
+ defaultRigId: z.string().uuid().nullable(),
538
563
  createdAt: z.string(),
539
564
  updatedAt: z.string(),
540
565
  });
541
566
  export type Workspace = z.infer<typeof Workspace>;
542
567
 
568
+ // Validates the KNOWN keys of workspaces.settings; passthrough keeps unknown
569
+ // (future) keys rather than stripping them. memoryEnabled gates Workspace Memory
570
+ // V1 agent surfaces (turn injection + first-party memory tools); default false.
571
+ export const WorkspaceSettingsSchema = z
572
+ .object({
573
+ memoryEnabled: z.boolean().optional(),
574
+ })
575
+ .passthrough();
576
+ export type WorkspaceSettings = z.infer<typeof WorkspaceSettingsSchema>;
577
+
578
+ // Resolve the effective memoryEnabled flag from a raw settings bag (default off).
579
+ export function resolveWorkspaceMemoryEnabled(settings: unknown): boolean {
580
+ const parsed = WorkspaceSettingsSchema.safeParse(settings ?? {});
581
+ return parsed.success ? parsed.data.memoryEnabled === true : false;
582
+ }
583
+
584
+ // PATCH body for workspace settings: a partial patch that deep-merges into the
585
+ // stored bag. memoryEnabled is the only typed key today; passthrough carries
586
+ // forward-compatible unknown keys through validation.
587
+ export const UpdateWorkspaceSettingsRequest = z
588
+ .object({
589
+ memoryEnabled: z.boolean().optional(),
590
+ })
591
+ .passthrough();
592
+ export type UpdateWorkspaceSettingsRequest = z.infer<typeof UpdateWorkspaceSettingsRequest>;
593
+
594
+ export const SetWorkspaceDefaultRigRequest = z.object({
595
+ rigId: z.string().uuid().nullable(),
596
+ });
597
+ export type SetWorkspaceDefaultRigRequest = z.infer<typeof SetWorkspaceDefaultRigRequest>;
598
+
599
+ // PUT body for the workspace model policy (full replace, not a merge). null (or
600
+ // omitted) = unrestricted for that dimension; an EMPTY array is a valid,
601
+ // explicit total block. Entries are provider ids / exact model ids as the
602
+ // router resolves them (see evaluateWorkspaceModelPolicy).
603
+ export const UpdateWorkspaceModelPolicyRequest = z.object({
604
+ allowedProviders: z.array(z.string().min(1).max(128)).max(64).nullable().optional(),
605
+ allowedModels: z.array(z.string().min(1).max(256)).max(256).nullable().optional(),
606
+ });
607
+ export type UpdateWorkspaceModelPolicyRequest = z.infer<typeof UpdateWorkspaceModelPolicyRequest>;
608
+
543
609
  export const AccountGrant = z.object({
544
610
  accountId: z.string().uuid(),
545
611
  subjectId: z.string().min(1),
@@ -580,17 +646,32 @@ export const DelegatedAccessTokenPayload = z.object({
580
646
  // Worker-asserted session scope for first-party MCP calls (HMAC-signed, not
581
647
  // agent-controlled); enables session-scoped tools such as goal management.
582
648
  sessionId: z.string().uuid().optional(),
649
+ // The turn making the call (the caller's identity), HMAC-signed by the worker
650
+ // at turn setup. Lets a tool classify WHO is calling from the token itself,
651
+ // instead of racily re-reading the session's live active_turn_id — e.g. the
652
+ // sacred-pause guard must know if the CALLER is a machine child-notification
653
+ // turn, and the active pointer can flip to another turn mid-check.
654
+ turnId: z.string().uuid().optional(),
583
655
  exp: z.number().int().positive(),
584
656
  });
585
657
  export type DelegatedAccessTokenPayload = z.infer<typeof DelegatedAccessTokenPayload>;
586
658
 
587
- export async function signDelegatedAccessToken(secret: string, payload: DelegatedAccessTokenPayload): Promise<string> {
588
- const encodedPayload = base64UrlEncode(JSON.stringify(DelegatedAccessTokenPayload.parse(payload)));
659
+ export async function signDelegatedAccessToken(
660
+ secret: string,
661
+ payload: DelegatedAccessTokenPayload,
662
+ ): Promise<string> {
663
+ const encodedPayload = base64UrlEncode(
664
+ JSON.stringify(DelegatedAccessTokenPayload.parse(payload)),
665
+ );
589
666
  const signature = await hmacSha256Base64Url(secret, encodedPayload);
590
667
  return `ogd_${encodedPayload}.${signature}`;
591
668
  }
592
669
 
593
- export async function verifyDelegatedAccessToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<DelegatedAccessTokenPayload | null> {
670
+ export async function verifyDelegatedAccessToken(
671
+ secret: string,
672
+ token: string,
673
+ nowSeconds = Math.floor(Date.now() / 1000),
674
+ ): Promise<DelegatedAccessTokenPayload | null> {
594
675
  if (!token.startsWith("ogd_")) {
595
676
  return null;
596
677
  }
@@ -605,7 +686,9 @@ export async function verifyDelegatedAccessToken(secret: string, token: string,
605
686
  if (!constantTimeEqual(signature, expected)) {
606
687
  return null;
607
688
  }
608
- const payload = DelegatedAccessTokenPayload.safeParse(JSON.parse(base64UrlDecode(encodedPayload)));
689
+ const payload = DelegatedAccessTokenPayload.safeParse(
690
+ JSON.parse(base64UrlDecode(encodedPayload)),
691
+ );
609
692
  if (!payload.success || payload.data.exp < nowSeconds) {
610
693
  return null;
611
694
  }
@@ -633,13 +716,20 @@ export const EnrollmentBearerPayload = z.object({
633
716
  });
634
717
  export type EnrollmentBearerPayload = z.infer<typeof EnrollmentBearerPayload>;
635
718
 
636
- export async function signEnrollmentBearer(secret: string, payload: EnrollmentBearerPayload): Promise<string> {
719
+ export async function signEnrollmentBearer(
720
+ secret: string,
721
+ payload: EnrollmentBearerPayload,
722
+ ): Promise<string> {
637
723
  const encodedPayload = base64UrlEncode(JSON.stringify(EnrollmentBearerPayload.parse(payload)));
638
724
  const signature = await hmacSha256Base64Url(secret, encodedPayload);
639
725
  return `oge_${encodedPayload}.${signature}`;
640
726
  }
641
727
 
642
- export async function verifyEnrollmentBearer(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<EnrollmentBearerPayload | null> {
728
+ export async function verifyEnrollmentBearer(
729
+ secret: string,
730
+ token: string,
731
+ nowSeconds = Math.floor(Date.now() / 1000),
732
+ ): Promise<EnrollmentBearerPayload | null> {
643
733
  if (!token.startsWith("oge_")) {
644
734
  return null;
645
735
  }
@@ -689,7 +779,10 @@ export const EnrollTokenPayload = z.object({
689
779
  });
690
780
  export type EnrollTokenPayload = z.infer<typeof EnrollTokenPayload>;
691
781
 
692
- export async function signEnrollToken(secret: string, payload: EnrollTokenPayload): Promise<string> {
782
+ export async function signEnrollToken(
783
+ secret: string,
784
+ payload: EnrollTokenPayload,
785
+ ): Promise<string> {
693
786
  const encodedPayload = base64UrlEncode(JSON.stringify(EnrollTokenPayload.parse(payload)));
694
787
  const signature = await hmacSha256Base64Url(secret, encodedPayload);
695
788
  return `oget_${encodedPayload}.${signature}`;
@@ -703,7 +796,11 @@ export async function signEnrollToken(secret: string, payload: EnrollTokenPayloa
703
796
  * bearer fails the prefix gate; a same-secret token that lacks the typ claim fails
704
797
  * the schema gate — both halves of the domain separation are enforced here.
705
798
  */
706
- export async function verifyEnrollToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<EnrollTokenPayload | null> {
799
+ export async function verifyEnrollToken(
800
+ secret: string,
801
+ token: string,
802
+ nowSeconds = Math.floor(Date.now() / 1000),
803
+ ): Promise<EnrollTokenPayload | null> {
707
804
  if (!token.startsWith("oget_")) {
708
805
  return null;
709
806
  }
@@ -765,7 +862,10 @@ export const StreamTokenPayload = z.object({
765
862
  });
766
863
  export type StreamTokenPayload = z.infer<typeof StreamTokenPayload>;
767
864
 
768
- export async function signStreamToken(secret: string, payload: StreamTokenPayload): Promise<string> {
865
+ export async function signStreamToken(
866
+ secret: string,
867
+ payload: StreamTokenPayload,
868
+ ): Promise<string> {
769
869
  const encodedPayload = base64UrlEncode(JSON.stringify(StreamTokenPayload.parse(payload)));
770
870
  const signature = await hmacSha256Base64Url(secret, encodedPayload);
771
871
  return `ogs_${encodedPayload}.${signature}`;
@@ -781,7 +881,11 @@ export async function signStreamToken(secret: string, payload: StreamTokenPayloa
781
881
  * lease + route params — verify proves the token is authentic + unexpired, the
782
882
  * caller proves it is for THIS box's current epoch and THIS workspace+session.
783
883
  */
784
- export async function verifyStreamToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<StreamTokenPayload | null> {
884
+ export async function verifyStreamToken(
885
+ secret: string,
886
+ token: string,
887
+ nowSeconds = Math.floor(Date.now() / 1000),
888
+ ): Promise<StreamTokenPayload | null> {
785
889
  if (!token.startsWith("ogs_")) {
786
890
  return null;
787
891
  }
@@ -858,7 +962,11 @@ export async function signRelayToken(secret: string, payload: RelayTokenPayload)
858
962
  * The channel-key scope (claim.workspaceId/agentId vs the StreamOpen channel key)
859
963
  * is enforced by the relay at USE — verify proves authenticity + freshness only.
860
964
  */
861
- export async function verifyRelayToken(secret: string, token: string, nowSeconds = Math.floor(Date.now() / 1000)): Promise<RelayTokenPayload | null> {
965
+ export async function verifyRelayToken(
966
+ secret: string,
967
+ token: string,
968
+ nowSeconds = Math.floor(Date.now() / 1000),
969
+ ): Promise<RelayTokenPayload | null> {
862
970
  if (!token.startsWith("ogr_")) {
863
971
  return null;
864
972
  }
@@ -1064,7 +1172,12 @@ export type LimitDecision = z.infer<typeof LimitDecision>;
1064
1172
  // the admitted quantity so a PULL host can grant a partial allowance.
1065
1173
  export const EntitlementDecision = z.discriminatedUnion("allowed", [
1066
1174
  z.object({ allowed: z.literal(true), quantity: z.number().optional() }),
1067
- z.object({ allowed: z.literal(false), reason: z.string(), code: z.string().optional(), quantity: z.number().optional() }),
1175
+ z.object({
1176
+ allowed: z.literal(false),
1177
+ reason: z.string(),
1178
+ code: z.string().optional(),
1179
+ quantity: z.number().optional(),
1180
+ }),
1068
1181
  ]);
1069
1182
  export type EntitlementDecision = z.infer<typeof EntitlementDecision>;
1070
1183
 
@@ -1079,16 +1192,32 @@ export type EntitlementsPort = {
1079
1192
  admitRun(input: AdmitRunInput): Promise<EntitlementDecision>;
1080
1193
  };
1081
1194
 
1195
+ export const GitCredentialProvider = z.enum(["github", "gitlab", "azure_devops"]);
1196
+ export type GitCredentialProvider = z.infer<typeof GitCredentialProvider>;
1197
+
1198
+ const GitProviderRepositoryId = z.union([z.number().int().positive(), z.string().min(1)]);
1199
+
1200
+ export const GitCredentialRepositoryRef = z.object({
1201
+ provider: GitCredentialProvider.optional(),
1202
+ uri: z.string().min(1),
1203
+ ref: z.string().min(1),
1204
+ repositoryId: GitProviderRepositoryId.optional(),
1205
+ installationId: GitProviderRepositoryId.optional(),
1206
+ projectId: GitProviderRepositoryId.optional(),
1207
+ connectionId: z.string().min(1).optional(),
1208
+ });
1209
+ export type GitCredentialRepositoryRef = z.infer<typeof GitCredentialRepositoryRef>;
1210
+
1082
1211
  // ============ P4a — Connection-credential provider (§7.6) ============
1083
1212
  //
1084
1213
  // The host-providable per-run credential-mint seam over OpenGeni's TWO
1085
1214
  // run-scoped credential sites in the worker:
1086
- // - GIT credentials: the GitHub App installation token minted in
1087
- // `sandboxEnvironmentForRun` (today `createGitHubAppInstallationToken`
1088
- // from `settings`) and injected as `GH_TOKEN`/`GITHUB_TOKEN`/the git
1089
- // extraheader.
1090
- // - SANDBOX secrets: the decrypted workspace environment values loaded in
1091
- // `loadWorkspaceEnvironmentForRun` (today decrypted with
1215
+ // - GIT credentials: run-scoped provider tokens minted in
1216
+ // `sandboxEnvironmentForRun` (standalone self-mints GitHub App tokens from
1217
+ // `settings`; embedded hosts can broker GitHub, GitLab, and Azure DevOps)
1218
+ // and seeded off-manifest into sandbox token files for git + provider CLIs.
1219
+ // - SANDBOX secrets: the decrypted variable set values loaded in
1220
+ // `loadVariableSetForRun` (today decrypted with
1092
1221
  // `environmentsEncryptionKeyBytes(settings)`).
1093
1222
  //
1094
1223
  // In embedded/separate topologies the HOST owns these external connections
@@ -1100,28 +1229,41 @@ export type EntitlementsPort = {
1100
1229
  // FORK-7 CROSS-CHECK (the host-mapping safety guardrail): a credential
1101
1230
  // provider returns the `workspaceId` it scoped the credential to, and the
1102
1231
  // activity ASSERTS it agrees with the run's workspace BEFORE injecting
1103
- // `GH_TOKEN` (or applying the decrypted values). A host mapping bug that
1232
+ // any git provider token seed (or applying decrypted environment values). A host mapping bug that
1104
1233
  // returns tenant B's creds while the run is tenant A is thereby caught at the
1105
1234
  // seam, never silently injected into tenant A's sandbox.
1106
1235
 
1107
1236
  export type GitCredentialsRequest = {
1108
1237
  accountId: string;
1109
1238
  workspaceId: string;
1110
- // The GitHub App installation the run's repository resources resolved to,
1111
- // and the specific repositories the token must be scoped to. Mirrors the
1112
- // shape `createGitHubAppInstallationToken` consumes today.
1239
+ // Provider defaults to "github" for the legacy request shape. GitHub-only
1240
+ // hosts can keep reading installationId/repositoryIds exactly as before;
1241
+ // provider-aware hosts should branch on this and repositoryRefs.
1242
+ provider?: GitCredentialProvider;
1243
+ // Token requests are the existing behavior. Identity requests let lazy
1244
+ // sandbox provisioning resolve stable git author/committer identity before
1245
+ // the box exists while deferring the rotating token value to first provision.
1246
+ purpose?: "token" | "identity";
1247
+ // Provider-neutral repository refs for hosts that broker non-GitHub tokens.
1248
+ // For GitHub requests these are additive to the legacy fields below.
1249
+ repositoryRefs?: GitCredentialRepositoryRef[];
1250
+ // Legacy GitHub App installation shape retained for 0.x compatibility.
1113
1251
  installationId: number;
1114
1252
  repositoryIds: number[];
1115
1253
  };
1116
1254
 
1117
1255
  export type GitCredentials = {
1118
- // The minted installation token the activity injects as GH_TOKEN/GITHUB_TOKEN
1119
- // and into the git http extraheader (identical downstream handling to the
1120
- // self-mint path).
1121
- token: string;
1256
+ // The minted provider token. Required for purpose="token"; optional for
1257
+ // purpose="identity" so hosts can return only stable git identity before lazy
1258
+ // sandbox provision. The value never enters the manifest.
1259
+ token?: string;
1122
1260
  // FORK-7 echo: the workspace the provider scoped this token to. The activity
1123
1261
  // asserts `workspaceId === request.workspaceId` before injecting.
1124
1262
  workspaceId: string;
1263
+ // Optional provider expiry for host-managed proactive renewal. ISO-8601;
1264
+ // null/omitted means the host does not expose a deadline and OpenGeni uses
1265
+ // its conservative bounded refresh cadence instead.
1266
+ expiresAt?: string | null;
1125
1267
  // Optional git identity override. When omitted the activity falls back to
1126
1268
  // today's `githubAppBotIdentity(settings)`.
1127
1269
  identity?: { name: string; email: string } | null;
@@ -1130,20 +1272,20 @@ export type GitCredentials = {
1130
1272
  export type SandboxSecretsRequest = {
1131
1273
  accountId: string;
1132
1274
  workspaceId: string;
1133
- // The workspace environment the run's session declares (null = unattached;
1275
+ // The variable set the run's session declares (null = unattached;
1134
1276
  // the provider, like the self-mint path, returns null values for it).
1135
- environmentId: string;
1277
+ variableSetId: string;
1136
1278
  };
1137
1279
 
1138
1280
  export type SandboxSecrets = {
1139
- // The decrypted environment values the run injects, replacing the local
1281
+ // The decrypted variableSet values the run injects, replacing the local
1140
1282
  // `environmentsEncryptionKeyBytes` decrypt. Same shape the self-mint path
1141
1283
  // produces (plaintext name→value).
1142
1284
  values: Record<string, string>;
1143
1285
  // FORK-7 echo: the workspace the provider scoped these secrets to.
1144
1286
  workspaceId: string;
1145
- // Optional environment metadata; when omitted the activity uses the
1146
- // environmentId as both id and name (the local decrypt carries the row's
1287
+ // Optional variableSet metadata; when omitted the activity uses the
1288
+ // variableSetId as both id and name (the local decrypt carries the row's
1147
1289
  // id/name/description, but only `id` is load-bearing downstream).
1148
1290
  id?: string;
1149
1291
  name?: string;
@@ -1154,8 +1296,8 @@ export type ConnectionCredentialsPort = {
1154
1296
  // Both legs are optional: a host may drive ONLY git creds (BYO-GitHub-App)
1155
1297
  // and leave sandbox secrets to OpenGeni's local decrypt, or vice-versa. An
1156
1298
  // unset leg falls through to today's self-mint for THAT leg only.
1157
- gitCredentials?: (input: GitCredentialsRequest) => Promise<GitCredentials>;
1158
- sandboxSecrets?: (input: SandboxSecretsRequest) => Promise<SandboxSecrets>;
1299
+ gitCredentials?(input: GitCredentialsRequest): Promise<GitCredentials>;
1300
+ sandboxSecrets?(input: SandboxSecretsRequest): Promise<SandboxSecrets>;
1159
1301
  };
1160
1302
 
1161
1303
  // ============ P4a — GitHub App API port (BYO-App, §7.6 / SPIKE-2 remainder) ===
@@ -1187,9 +1329,7 @@ export type GitHubAppApiPort = {
1187
1329
  code: string;
1188
1330
  installationId: number;
1189
1331
  }) => Promise<GitHubInstallationSummary>;
1190
- listRepositories?: (input: {
1191
- installationIds?: number[];
1192
- }) => Promise<GitHubRepository[]>;
1332
+ listRepositories?: (input: { installationIds?: number[] }) => Promise<GitHubRepository[]>;
1193
1333
  };
1194
1334
 
1195
1335
  export const BillingBalance = z.object({
@@ -1202,10 +1342,14 @@ export type BillingBalance = z.infer<typeof BillingBalance>;
1202
1342
 
1203
1343
  export const CreateCheckoutRequest = z.object({
1204
1344
  accountId: z.string().uuid().optional(),
1205
- amountUsd: z.number().min(5).max(10_000).refine(
1206
- (value) => Number.isFinite(value) && Math.abs(value - Math.round(value * 100) / 100) < 1e-9,
1207
- { message: "amountUsd must use cent precision" },
1208
- ),
1345
+ amountUsd: z
1346
+ .number()
1347
+ .min(5)
1348
+ .max(10_000)
1349
+ .refine(
1350
+ (value) => Number.isFinite(value) && Math.abs(value - Math.round(value * 100) / 100) < 1e-9,
1351
+ { message: "amountUsd must use cent precision" },
1352
+ ),
1209
1353
  successUrl: z.string().url().optional(),
1210
1354
  cancelUrl: z.string().url().optional(),
1211
1355
  });
@@ -1223,6 +1367,11 @@ export const RepositoryResourceRef = z.object({
1223
1367
  ref: z.string().min(1),
1224
1368
  mountPath: z.string().min(1).optional(),
1225
1369
  subpath: z.string().min(1).optional(),
1370
+ provider: GitCredentialProvider.optional(),
1371
+ repositoryId: GitProviderRepositoryId.optional(),
1372
+ installationId: GitProviderRepositoryId.optional(),
1373
+ projectId: GitProviderRepositoryId.optional(),
1374
+ connectionId: z.string().min(1).optional(),
1226
1375
  githubInstallationId: z.number().int().positive().optional(),
1227
1376
  githubRepositoryId: z.number().int().positive().optional(),
1228
1377
  });
@@ -1241,7 +1390,13 @@ export type ResourceRef = z.infer<typeof ResourceRef>;
1241
1390
  export const FileStatus = z.enum(["pending_upload", "ready", "failed", "expired", "deleted"]);
1242
1391
  export type FileStatus = z.infer<typeof FileStatus>;
1243
1392
 
1244
- export const FileUploadStatus = z.enum(["pending", "completed", "expired", "failed"]);
1393
+ export const FileUploadStatus = z.enum([
1394
+ "pending",
1395
+ "cleanup_pending",
1396
+ "completed",
1397
+ "expired",
1398
+ "failed",
1399
+ ]);
1245
1400
  export type FileUploadStatus = z.infer<typeof FileUploadStatus>;
1246
1401
 
1247
1402
  export const FileAsset = z.object({
@@ -1292,7 +1447,16 @@ export type FileDownloadUrlResponse = z.infer<typeof FileDownloadUrlResponse>;
1292
1447
  export const DocumentStatus = z.enum(["queued", "indexing", "ready", "failed"]);
1293
1448
  export type DocumentStatus = z.infer<typeof DocumentStatus>;
1294
1449
 
1295
- export const KnowledgeSourceKind = z.enum(["manual_upload", "meeting_transcript", "repository", "email", "chat", "document", "web", "other"]);
1450
+ export const KnowledgeSourceKind = z.enum([
1451
+ "manual_upload",
1452
+ "meeting_transcript",
1453
+ "repository",
1454
+ "email",
1455
+ "chat",
1456
+ "document",
1457
+ "web",
1458
+ "other",
1459
+ ]);
1296
1460
  export type KnowledgeSourceKind = z.infer<typeof KnowledgeSourceKind>;
1297
1461
 
1298
1462
  export const DocumentSearchMode = z.enum(["hybrid", "vector", "keyword"]);
@@ -1389,10 +1553,28 @@ export const DocumentSearchRequest = z.object({
1389
1553
  });
1390
1554
  export type DocumentSearchRequest = z.infer<typeof DocumentSearchRequest>;
1391
1555
 
1392
- export const KnowledgeMemoryStatus = z.enum(["proposed", "approved", "rejected"]);
1556
+ // proposed/approved/rejected are the legacy curated-knowledge review states
1557
+ // (docs-MCP memory_propose lane). active/superseded/archived are Workspace
1558
+ // Memory V1: agent-written memories land `active` (usable immediately — human is
1559
+ // auditor, not gatekeeper), get `superseded` when replaced, `archived` when
1560
+ // retired. Agent-visible set = active ∪ approved.
1561
+ export const KnowledgeMemoryStatus = z.enum([
1562
+ "proposed",
1563
+ "approved",
1564
+ "rejected",
1565
+ "active",
1566
+ "superseded",
1567
+ "archived",
1568
+ ]);
1393
1569
  export type KnowledgeMemoryStatus = z.infer<typeof KnowledgeMemoryStatus>;
1394
1570
 
1395
- export const KnowledgeMemoryKind = z.enum(["semantic", "episodic", "procedural", "decision", "preference"]);
1571
+ export const KnowledgeMemoryKind = z.enum([
1572
+ "semantic",
1573
+ "episodic",
1574
+ "procedural",
1575
+ "decision",
1576
+ "preference",
1577
+ ]);
1396
1578
  export type KnowledgeMemoryKind = z.infer<typeof KnowledgeMemoryKind>;
1397
1579
 
1398
1580
  export const KnowledgeSourceRef = z.object({
@@ -1417,13 +1599,29 @@ export const KnowledgeMemory = z.object({
1417
1599
  createdBySessionId: z.string().uuid().nullable(),
1418
1600
  reviewedBy: z.string().nullable(),
1419
1601
  reviewedAt: z.string().nullable(),
1602
+ // Workspace Memory V1 fields. usageCount/lastUsedAt feed end-state ranking and
1603
+ // decay; supersedesId/supersededById link correction chains; validFrom/validUntil
1604
+ // are the point-in-time window. embedding/embeddingModel/textHash are internal
1605
+ // and never exposed on the wire.
1606
+ pinned: z.boolean(),
1607
+ usageCount: z.number().int(),
1608
+ lastUsedAt: z.string().nullable(),
1609
+ supersedesId: z.string().uuid().nullable(),
1610
+ supersededById: z.string().uuid().nullable(),
1611
+ validFrom: z.string(),
1612
+ validUntil: z.string().nullable(),
1420
1613
  createdAt: z.string(),
1421
1614
  updatedAt: z.string(),
1422
1615
  });
1423
1616
  export type KnowledgeMemory = z.infer<typeof KnowledgeMemory>;
1424
1617
 
1618
+ // Default status is `active`: a create through this request lands an
1619
+ // agent-visible memory via the one write gate (saveWorkspaceMemory). Passing an
1620
+ // explicit `proposed`/`approved`/`rejected` status routes to the legacy curated
1621
+ // create instead (the docs-MCP memory_propose lane). pinned/replacesId apply to
1622
+ // the active (memory) path.
1425
1623
  export const CreateKnowledgeMemoryRequest = z.object({
1426
- status: KnowledgeMemoryStatus.default("proposed"),
1624
+ status: KnowledgeMemoryStatus.default("active"),
1427
1625
  kind: KnowledgeMemoryKind.default("semantic"),
1428
1626
  scope: z.string().min(1).default("workspace"),
1429
1627
  text: z.string().min(1),
@@ -1431,6 +1629,8 @@ export const CreateKnowledgeMemoryRequest = z.object({
1431
1629
  confidence: z.number().min(0).max(1).default(0.5),
1432
1630
  metadata: z.record(z.string(), z.unknown()).default({}),
1433
1631
  createdBySessionId: z.string().uuid().optional(),
1632
+ pinned: z.boolean().optional(),
1633
+ replacesId: z.string().min(1).optional(),
1434
1634
  });
1435
1635
  export type CreateKnowledgeMemoryRequest = z.infer<typeof CreateKnowledgeMemoryRequest>;
1436
1636
 
@@ -1443,9 +1643,12 @@ export const UpdateKnowledgeMemoryRequest = z.object({
1443
1643
  confidence: z.number().min(0).max(1).optional(),
1444
1644
  metadata: z.record(z.string(), z.unknown()).optional(),
1445
1645
  reviewedBy: z.string().min(1).optional(),
1646
+ // Human audit action: pin (never decays) / unpin.
1647
+ pinned: z.boolean().optional(),
1446
1648
  });
1447
1649
  export type UpdateKnowledgeMemoryRequest = z.infer<typeof UpdateKnowledgeMemoryRequest>;
1448
1650
 
1651
+ // GET list/filter over knowledge memories (curated + memory).
1449
1652
  export const KnowledgeMemorySearchRequest = z.object({
1450
1653
  query: z.string().min(1).optional(),
1451
1654
  status: KnowledgeMemoryStatus.optional(),
@@ -1455,6 +1658,32 @@ export const KnowledgeMemorySearchRequest = z.object({
1455
1658
  });
1456
1659
  export type KnowledgeMemorySearchRequest = z.infer<typeof KnowledgeMemorySearchRequest>;
1457
1660
 
1661
+ export const WorkspaceMemorySearchMode = z.enum(["hybrid", "vector", "keyword"]);
1662
+ export type WorkspaceMemorySearchMode = z.infer<typeof WorkspaceMemorySearchMode>;
1663
+
1664
+ // POST hybrid search over the workspace's agent-visible memory (active ∪ approved).
1665
+ export const WorkspaceMemorySearchRequest = z.object({
1666
+ query: z.string().min(1),
1667
+ kind: KnowledgeMemoryKind.optional(),
1668
+ limit: z.number().int().positive().max(20).optional(),
1669
+ mode: WorkspaceMemorySearchMode.optional(),
1670
+ });
1671
+ export type WorkspaceMemorySearchRequest = z.infer<typeof WorkspaceMemorySearchRequest>;
1672
+
1673
+ export const WorkspaceMemorySearchResult = z.object({
1674
+ memory: KnowledgeMemory,
1675
+ score: z.number(),
1676
+ matchType: WorkspaceMemorySearchMode,
1677
+ vectorScore: z.number().nullable(),
1678
+ keywordScore: z.number().nullable(),
1679
+ });
1680
+ export type WorkspaceMemorySearchResult = z.infer<typeof WorkspaceMemorySearchResult>;
1681
+
1682
+ export const WorkspaceMemorySearchResponse = z.object({
1683
+ results: z.array(WorkspaceMemorySearchResult),
1684
+ });
1685
+ export type WorkspaceMemorySearchResponse = z.infer<typeof WorkspaceMemorySearchResponse>;
1686
+
1458
1687
  export const ToolRef = z.object({
1459
1688
  kind: z.literal("mcp"),
1460
1689
  id: z.string().min(1),
@@ -1469,13 +1698,19 @@ export const ToolRef = z.object({
1469
1698
  export type ToolRef = z.infer<typeof ToolRef>;
1470
1699
 
1471
1700
  const registryId = /^[A-Za-z0-9_-]+$/;
1472
- const httpsUrl = z.string().url().refine((value) => {
1473
- try {
1474
- return new URL(value).protocol === "https:";
1475
- } catch {
1476
- return false;
1477
- }
1478
- }, { message: "URL must use https" });
1701
+ const httpsUrl = z
1702
+ .string()
1703
+ .url()
1704
+ .refine(
1705
+ (value) => {
1706
+ try {
1707
+ return new URL(value).protocol === "https:";
1708
+ } catch {
1709
+ return false;
1710
+ }
1711
+ },
1712
+ { message: "URL must use https" },
1713
+ );
1479
1714
 
1480
1715
  export const SessionMcpServerInput = z.object({
1481
1716
  id: z.string().min(1).regex(registryId),
@@ -1502,13 +1737,15 @@ export const SessionMcpCredentialUpdateInput = z.object({
1502
1737
  });
1503
1738
  export type SessionMcpCredentialUpdateInput = z.infer<typeof SessionMcpCredentialUpdateInput>;
1504
1739
 
1505
- export const SessionMcpServerMetadata = z.object({
1506
- id: z.string().min(1).regex(registryId),
1507
- name: z.string().min(1).nullable(),
1508
- url: httpsUrl,
1509
- headerNames: z.array(z.string()).default([]),
1510
- credentialVersion: z.number().int().positive(),
1511
- }).strict();
1740
+ export const SessionMcpServerMetadata = z
1741
+ .object({
1742
+ id: z.string().min(1).regex(registryId),
1743
+ name: z.string().min(1).nullable(),
1744
+ url: httpsUrl,
1745
+ headerNames: z.array(z.string()).default([]),
1746
+ credentialVersion: z.number().int().positive(),
1747
+ })
1748
+ .strict();
1512
1749
  export type SessionMcpServerMetadata = z.infer<typeof SessionMcpServerMetadata>;
1513
1750
 
1514
1751
  export class ResourceRefConflictError extends Error {
@@ -1547,8 +1784,14 @@ export function mergeResourceRefs(
1547
1784
  options: { rejectConflicts?: boolean } = {},
1548
1785
  ): ResourceRef[] {
1549
1786
  const out = [...existing];
1550
- const mountPaths = new Map(existing.flatMap((resource) => resource.mountPath ? [[resource.mountPath, stableJson(resource)] as const] : []));
1551
- const identities = new Map(existing.map((resource) => [resourceIdentityKey(resource), stableJson(resource)] as const));
1787
+ const mountPaths = new Map(
1788
+ existing.flatMap((resource) =>
1789
+ resource.mountPath ? [[resource.mountPath, stableJson(resource)] as const] : [],
1790
+ ),
1791
+ );
1792
+ const identities = new Map(
1793
+ existing.map((resource) => [resourceIdentityKey(resource), stableJson(resource)] as const),
1794
+ );
1552
1795
  const exact = new Set(existing.map(stableJson));
1553
1796
 
1554
1797
  for (const resource of additions) {
@@ -1559,12 +1802,16 @@ export function mergeResourceRefs(
1559
1802
  if (options.rejectConflicts) {
1560
1803
  const existingAtMount = resource.mountPath ? mountPaths.get(resource.mountPath) : undefined;
1561
1804
  if (existingAtMount && existingAtMount !== serialized) {
1562
- throw new ResourceRefConflictError(`resource mount path is already attached: ${resource.mountPath}`);
1805
+ throw new ResourceRefConflictError(
1806
+ `resource mount path is already attached: ${resource.mountPath}`,
1807
+ );
1563
1808
  }
1564
1809
  const identity = resourceIdentityKey(resource);
1565
1810
  const existingIdentity = identities.get(identity);
1566
1811
  if (existingIdentity && existingIdentity !== serialized) {
1567
- throw new ResourceRefConflictError(`resource is already attached with different settings: ${identity}`);
1812
+ throw new ResourceRefConflictError(
1813
+ `resource is already attached with different settings: ${identity}`,
1814
+ );
1568
1815
  }
1569
1816
  }
1570
1817
  out.push(resource);
@@ -1577,9 +1824,17 @@ export function mergeResourceRefs(
1577
1824
  return out;
1578
1825
  }
1579
1826
 
1580
- export function reasoningEffortForMetadata(metadata: Record<string, unknown>, fallback: ReasoningEffort): ReasoningEffort {
1827
+ export function reasoningEffortForMetadata(
1828
+ metadata: Record<string, unknown>,
1829
+ fallback: ReasoningEffort,
1830
+ ): ReasoningEffort {
1581
1831
  const value = metadata.reasoningEffort;
1582
- return value === "none" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh"
1832
+ return value === "none" ||
1833
+ value === "minimal" ||
1834
+ value === "low" ||
1835
+ value === "medium" ||
1836
+ value === "high" ||
1837
+ value === "xhigh"
1583
1838
  ? value
1584
1839
  : fallback;
1585
1840
  }
@@ -1600,17 +1855,44 @@ function sortJson(value: unknown): unknown {
1600
1855
  return value.map(sortJson);
1601
1856
  }
1602
1857
  if (value && typeof value === "object") {
1603
- return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, nested]) => [key, sortJson(nested)]));
1858
+ return Object.fromEntries(
1859
+ Object.entries(value)
1860
+ .sort(([a], [b]) => a.localeCompare(b))
1861
+ .map(([key, nested]) => [key, sortJson(nested)]),
1862
+ );
1604
1863
  }
1605
1864
  return value;
1606
1865
  }
1607
1866
 
1608
- export const SessionTurnStatus = z.enum(["queued", "running", "requires_action", "completed", "failed", "cancelled"]);
1867
+ export const SessionTurnStatus = z.enum([
1868
+ "queued",
1869
+ "running",
1870
+ "requires_action",
1871
+ "recovering",
1872
+ "waiting_capacity",
1873
+ "completed",
1874
+ "failed",
1875
+ "cancelled",
1876
+ "superseded",
1877
+ ]);
1609
1878
  export type SessionTurnStatus = z.infer<typeof SessionTurnStatus>;
1610
1879
 
1611
- export const SessionTurnSource = z.enum(["user", "scheduled_task", "api", "goal"]);
1880
+ export const SessionTurnSource = z.enum([
1881
+ "user",
1882
+ "scheduled_task",
1883
+ "api",
1884
+ "goal",
1885
+ "system",
1886
+ "compaction",
1887
+ ]);
1612
1888
  export type SessionTurnSource = z.infer<typeof SessionTurnSource>;
1613
1889
 
1890
+ export const SessionControlState = z.enum(["active", "paused"]);
1891
+ export type SessionControlState = z.infer<typeof SessionControlState>;
1892
+
1893
+ export const WorkspaceInferenceState = z.enum(["active", "paused"]);
1894
+ export type WorkspaceInferenceState = z.infer<typeof WorkspaceInferenceState>;
1895
+
1614
1896
  export const SessionGoalStatus = z.enum(["active", "paused", "completed"]);
1615
1897
  export type SessionGoalStatus = z.infer<typeof SessionGoalStatus>;
1616
1898
 
@@ -1619,7 +1901,7 @@ export type SessionGoalCreatedBy = z.infer<typeof SessionGoalCreatedBy>;
1619
1901
 
1620
1902
  export const SessionGoalPausedReason = z.enum([
1621
1903
  "agent",
1622
- "user_interrupt",
1904
+ "user_pause",
1623
1905
  "api",
1624
1906
  "no_progress",
1625
1907
  "max_auto_continuations",
@@ -1667,6 +1949,18 @@ export const UpdateSessionRequest = z.object({
1667
1949
  });
1668
1950
  export type UpdateSessionRequest = z.infer<typeof UpdateSessionRequest>;
1669
1951
 
1952
+ /**
1953
+ * A member's personal pin preference for a session. `expectedVersion` is
1954
+ * optional: ordinary pin/unpin actions are idempotent last-write-wins, while a
1955
+ * client that has a known version can fail closed rather than overwrite a newer
1956
+ * action from another browser.
1957
+ */
1958
+ export const UpdateSessionPinRequest = z.object({
1959
+ pinned: z.boolean(),
1960
+ expectedVersion: z.number().int().nonnegative().optional(),
1961
+ });
1962
+ export type UpdateSessionPinRequest = z.infer<typeof UpdateSessionPinRequest>;
1963
+
1670
1964
  // Operator context controls (slash-command palette: /clear, /compact). These
1671
1965
  // are session/operator actions, NOT a structured way to talk to the agent —
1672
1966
  // the human↔agent channel stays plain chat. Both require `sessions:control`.
@@ -1709,9 +2003,11 @@ export function isClearedRunStateBlob(serialized: string | null | undefined): bo
1709
2003
  }
1710
2004
  try {
1711
2005
  const parsed = JSON.parse(serialized) as unknown;
1712
- return typeof parsed === "object"
1713
- && parsed !== null
1714
- && (parsed as Record<string, unknown>)[CLEARED_RUN_STATE_MARKER] === true;
2006
+ return (
2007
+ typeof parsed === "object" &&
2008
+ parsed !== null &&
2009
+ (parsed as Record<string, unknown>)[CLEARED_RUN_STATE_MARKER] === true
2010
+ );
1715
2011
  } catch {
1716
2012
  return false;
1717
2013
  }
@@ -1723,9 +2019,10 @@ export type CompactSessionContextRequest = z.infer<typeof CompactSessionContextR
1723
2019
 
1724
2020
  /** Outcome of a manual /compact trigger. */
1725
2021
  export const CompactSessionContextResult = z.object({
1726
- // queued: a client-side (Azure) compaction will run before the next turn.
1727
- // noop: nothing to do (server-managed provider, mode off, or no history).
1728
- status: z.enum(["queued", "noop"]),
2022
+ // pending: an active/paused session will compact at its next safe boundary.
2023
+ // completed: an idle compaction-only activity completed synchronously.
2024
+ // noop: there is no active history to compact.
2025
+ status: z.enum(["pending", "completed", "noop"]),
1729
2026
  message: z.string(),
1730
2027
  });
1731
2028
  export type CompactSessionContextResult = z.infer<typeof CompactSessionContextResult>;
@@ -1738,7 +2035,7 @@ export const SessionTurn = z.object({
1738
2035
  temporalWorkflowId: z.string(),
1739
2036
  status: SessionTurnStatus,
1740
2037
  source: SessionTurnSource,
1741
- position: z.number().int().positive(),
2038
+ position: z.number().int(),
1742
2039
  prompt: z.string().min(1),
1743
2040
  resources: z.array(ResourceRef),
1744
2041
  tools: z.array(ToolRef),
@@ -1748,6 +2045,12 @@ export const SessionTurn = z.object({
1748
2045
  // Per-turn OS override. NULL = inherit the session's sandboxOs.
1749
2046
  sandboxOs: SandboxOs.nullable(),
1750
2047
  metadata: z.record(z.string(), z.unknown()),
2048
+ version: z.number().int().positive(),
2049
+ executionGeneration: z.number().int().nonnegative(),
2050
+ activeAttemptId: z.string().uuid().nullable(),
2051
+ lineage: z.record(z.string(), z.unknown()),
2052
+ cancelledBy: z.string().nullable(),
2053
+ cancelReason: z.string().nullable(),
1751
2054
  startedAt: z.string().nullable(),
1752
2055
  finishedAt: z.string().nullable(),
1753
2056
  createdAt: z.string(),
@@ -1755,68 +2058,316 @@ export const SessionTurn = z.object({
1755
2058
  });
1756
2059
  export type SessionTurn = z.infer<typeof SessionTurn>;
1757
2060
 
1758
- export const UpdateSessionTurnRequest = z.object({
1759
- prompt: z.string().min(1).optional(),
1760
- resources: z.array(ResourceRef).optional(),
1761
- tools: z.array(ToolRef).optional(),
1762
- model: z.string().min(1).optional(),
1763
- reasoningEffort: ReasoningEffort.optional(),
1764
- sandboxBackend: SandboxBackend.optional(),
1765
- metadata: z.record(z.string(), z.unknown()).optional(),
2061
+ export const SessionQueueSnapshot = z.object({
2062
+ version: z.number().int().nonnegative(),
2063
+ controlState: SessionControlState,
2064
+ controlGeneration: z.number().int().nonnegative(),
2065
+ workspaceInferenceState: WorkspaceInferenceState,
2066
+ workspaceInferenceGeneration: z.number().int().nonnegative(),
2067
+ workspaceRunExceptionGeneration: z.number().int().nonnegative().nullable(),
2068
+ items: z.array(SessionTurn),
2069
+ });
2070
+ export type SessionQueueSnapshot = z.infer<typeof SessionQueueSnapshot>;
2071
+
2072
+ export const CancelSessionQueueItemRequest = z.object({
2073
+ expectedQueueVersion: z.number().int().nonnegative(),
2074
+ expectedItemVersion: z.number().int().positive(),
2075
+ reason: z.string().min(1).optional(),
1766
2076
  });
1767
- export type UpdateSessionTurnRequest = z.infer<typeof UpdateSessionTurnRequest>;
2077
+ export type CancelSessionQueueItemRequest = z.infer<typeof CancelSessionQueueItemRequest>;
2078
+
2079
+ export const SessionControlRequest = z.object({
2080
+ mode: z.enum(["pause", "resume"]),
2081
+ reason: z.string().min(1).optional(),
2082
+ clientEventId: z.string().min(1).optional(),
2083
+ expectedControlState: SessionControlState.optional(),
2084
+ expectedControlGeneration: z.number().int().nonnegative().optional(),
2085
+ expectedWorkspaceInferenceGeneration: z.number().int().nonnegative().optional(),
2086
+ });
2087
+ export type SessionControlRequest = z.infer<typeof SessionControlRequest>;
2088
+
2089
+ export const WorkspaceInferenceControlRequest = z.object({
2090
+ state: WorkspaceInferenceState,
2091
+ reason: z.string().min(1),
2092
+ clientEventId: z.string().min(1),
2093
+ expectedState: WorkspaceInferenceState,
2094
+ expectedGeneration: z.number().int().nonnegative(),
2095
+ exceptSessionIds: z.array(z.string().uuid()).default([]),
2096
+ });
2097
+ export type WorkspaceInferenceControlRequest = z.infer<typeof WorkspaceInferenceControlRequest>;
2098
+
2099
+ export const WorkspaceInferenceControlResponse = z.object({
2100
+ operationId: z.string().uuid(),
2101
+ state: WorkspaceInferenceState,
2102
+ generation: z.number().int().nonnegative(),
2103
+ affectedSessionIds: z.array(z.string().uuid()),
2104
+ controlSessionIds: z.array(z.string().uuid()),
2105
+ exceptionSessionIds: z.array(z.string().uuid()),
2106
+ });
2107
+ export type WorkspaceInferenceControlResponse = z.infer<typeof WorkspaceInferenceControlResponse>;
2108
+
2109
+ export const SystemUpdateClassification = z.enum(["success", "failure", "action_required", "info"]);
2110
+ export type SystemUpdateClassification = z.infer<typeof SystemUpdateClassification>;
2111
+
2112
+ export const SessionSystemUpdateKind = z.enum([
2113
+ "child_session_update",
2114
+ "scheduled_wake",
2115
+ "lifecycle_event",
2116
+ "runtime_notice",
2117
+ ]);
2118
+ export type SessionSystemUpdateKind = z.infer<typeof SessionSystemUpdateKind>;
1768
2119
 
1769
- export const ReorderSessionTurnsRequest = z.object({
1770
- turnIds: z.array(z.string().uuid()).min(1),
2120
+ export const SessionSystemUpdateState = z.enum([
2121
+ "pending",
2122
+ "deferred",
2123
+ "delivered",
2124
+ "cancelled",
2125
+ "failed",
2126
+ ]);
2127
+ export type SessionSystemUpdateState = z.infer<typeof SessionSystemUpdateState>;
2128
+
2129
+ export const SessionSystemUpdate = z.object({
2130
+ id: z.string().uuid(),
2131
+ sessionId: z.string().uuid(),
2132
+ kind: SessionSystemUpdateKind,
2133
+ classification: SystemUpdateClassification,
2134
+ sourceId: z.string(),
2135
+ dedupeKey: z.string(),
2136
+ summary: z.string(),
2137
+ payload: z.record(z.string(), z.unknown()),
2138
+ lineage: z.record(z.string(), z.unknown()),
2139
+ state: SessionSystemUpdateState,
2140
+ deliveredTurnId: z.string().uuid().nullable(),
2141
+ deliveredAt: z.string().nullable(),
2142
+ createdAt: z.string(),
1771
2143
  });
1772
- export type ReorderSessionTurnsRequest = z.infer<typeof ReorderSessionTurnsRequest>;
2144
+ export type SessionSystemUpdate = z.infer<typeof SessionSystemUpdate>;
2145
+
2146
+ export const VariableSetVariableName = z
2147
+ .string()
2148
+ .regex(/^[A-Z][A-Z0-9_]*$/)
2149
+ .max(128);
2150
+ export type VariableSetVariableName = z.infer<typeof VariableSetVariableName>;
1773
2151
 
1774
- export const WorkspaceEnvironmentVariableName = z.string().regex(/^[A-Z][A-Z0-9_]*$/).max(128);
1775
- export type WorkspaceEnvironmentVariableName = z.infer<typeof WorkspaceEnvironmentVariableName>;
2152
+ function withVariableSetIdAlias<T extends z.ZodRawShape>(shape: T) {
2153
+ return z.preprocess((input) => {
2154
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
2155
+ return input;
2156
+ }
2157
+ const record = input as Record<string, unknown>;
2158
+ if (record.variableSetId !== undefined || record.environmentId === undefined) {
2159
+ return record;
2160
+ }
2161
+ return { ...record, variableSetId: record.environmentId };
2162
+ }, z.object(shape));
2163
+ }
1776
2164
 
1777
2165
  // Metadata only by design: no schema in this file ever carries a variable value
1778
2166
  // back to a client. Values are write-only and decrypted exclusively inside the
1779
2167
  // worker at sandbox materialization time.
1780
- export const WorkspaceEnvironmentVariableMetadata = z.object({
1781
- name: WorkspaceEnvironmentVariableName,
2168
+ export const VariableSetVariableMetadata = z.object({
2169
+ name: VariableSetVariableName,
1782
2170
  version: z.number().int().positive(),
1783
2171
  createdAt: z.string(),
1784
2172
  updatedAt: z.string(),
1785
2173
  });
1786
- export type WorkspaceEnvironmentVariableMetadata = z.infer<typeof WorkspaceEnvironmentVariableMetadata>;
2174
+ export type VariableSetVariableMetadata = z.infer<typeof VariableSetVariableMetadata>;
2175
+ /** @deprecated use VariableSetVariableMetadata */
2176
+ export const WorkspaceEnvironmentVariableMetadata = VariableSetVariableMetadata;
2177
+ /** @deprecated use VariableSetVariableMetadata */
2178
+ export type WorkspaceEnvironmentVariableMetadata = VariableSetVariableMetadata;
1787
2179
 
1788
- export const WorkspaceEnvironment = z.object({
2180
+ export const VariableSet = z.object({
1789
2181
  id: z.string().uuid(),
1790
2182
  accountId: z.string().uuid(),
1791
2183
  workspaceId: z.string().uuid(),
1792
2184
  name: z.string(),
1793
2185
  description: z.string().nullable(),
1794
- variables: z.array(WorkspaceEnvironmentVariableMetadata),
2186
+ variables: z.array(VariableSetVariableMetadata),
1795
2187
  createdAt: z.string(),
1796
2188
  updatedAt: z.string(),
1797
2189
  });
1798
- export type WorkspaceEnvironment = z.infer<typeof WorkspaceEnvironment>;
2190
+ export type VariableSet = z.infer<typeof VariableSet>;
2191
+ /** @deprecated use VariableSet */
2192
+ export const WorkspaceEnvironment = VariableSet;
2193
+ /** @deprecated use VariableSet */
2194
+ export type WorkspaceEnvironment = VariableSet;
1799
2195
 
1800
- export const CreateWorkspaceEnvironmentRequest = z.object({
2196
+ export const CreateVariableSetRequest = z.object({
1801
2197
  name: z.string().min(1).max(120),
1802
2198
  description: z.string().max(2000).optional(),
1803
- variables: z.array(z.object({
1804
- name: WorkspaceEnvironmentVariableName,
1805
- value: z.string().min(1).max(32768),
1806
- })).default([]),
2199
+ variables: z
2200
+ .array(
2201
+ z.object({
2202
+ name: VariableSetVariableName,
2203
+ value: z.string().min(1).max(32768),
2204
+ }),
2205
+ )
2206
+ .default([]),
2207
+ });
2208
+ export type CreateVariableSetRequest = z.infer<typeof CreateVariableSetRequest>;
2209
+ /** @deprecated use CreateVariableSetRequest */
2210
+ export const CreateWorkspaceEnvironmentRequest = CreateVariableSetRequest;
2211
+ /** @deprecated use CreateVariableSetRequest */
2212
+ export type CreateWorkspaceEnvironmentRequest = CreateVariableSetRequest;
2213
+
2214
+ export const UpdateVariableSetRequest = z.object({
2215
+ name: z.string().min(1).max(120).optional(),
2216
+ description: z.string().max(2000).nullable().optional(),
2217
+ });
2218
+ export type UpdateVariableSetRequest = z.infer<typeof UpdateVariableSetRequest>;
2219
+ /** @deprecated use UpdateVariableSetRequest */
2220
+ export const UpdateWorkspaceEnvironmentRequest = UpdateVariableSetRequest;
2221
+ /** @deprecated use UpdateVariableSetRequest */
2222
+ export type UpdateWorkspaceEnvironmentRequest = UpdateVariableSetRequest;
2223
+
2224
+ export const SetVariableSetVariableRequest = z.object({
2225
+ value: z.string().min(1).max(32768),
1807
2226
  });
1808
- export type CreateWorkspaceEnvironmentRequest = z.infer<typeof CreateWorkspaceEnvironmentRequest>;
2227
+ export type SetVariableSetVariableRequest = z.infer<typeof SetVariableSetVariableRequest>;
2228
+ /** @deprecated use SetVariableSetVariableRequest */
2229
+ export const SetWorkspaceEnvironmentVariableRequest = SetVariableSetVariableRequest;
2230
+ /** @deprecated use SetVariableSetVariableRequest */
2231
+ export type SetWorkspaceEnvironmentVariableRequest = SetVariableSetVariableRequest;
1809
2232
 
1810
- export const UpdateWorkspaceEnvironmentRequest = z.object({
2233
+ // --- Rigs ---------------------------------------------------------------------
2234
+ // Workspace-scoped, versioned sandbox machine definitions. A rig is the named
2235
+ // truth; each sandbox is a disposable fork of a rig version. Versions are
2236
+ // append-only and content-immutable; exactly one is active per rig.
2237
+
2238
+ // A self-declared health check: a name + the shell command that must exit 0.
2239
+ export const RigCheck = z.object({
2240
+ name: z.string().min(1).max(120),
2241
+ command: z.string().min(1).max(8192),
2242
+ });
2243
+ export type RigCheck = z.infer<typeof RigCheck>;
2244
+
2245
+ export const RigVersion = z.object({
2246
+ id: z.string().uuid(),
2247
+ rigId: z.string().uuid(),
2248
+ version: z.number().int().positive(),
2249
+ image: z.string().nullable(),
2250
+ setupScript: z.string().nullable(),
2251
+ checks: z.array(RigCheck),
2252
+ credentialHooks: z.array(z.string()),
2253
+ defaultVariableSetIds: z.array(z.string().uuid()),
2254
+ changelog: z.string().nullable(),
2255
+ // Attribution: 'user:<subject>' | 'session:<id>' | 'system'.
2256
+ createdBy: z.string().nullable(),
2257
+ active: z.boolean(),
2258
+ createdAt: z.string(),
2259
+ });
2260
+ export type RigVersion = z.infer<typeof RigVersion>;
2261
+
2262
+ export const RigVerificationHealth = z.object({
2263
+ checkHealth: z.enum(["passing", "failing", "unknown"]),
2264
+ lastVerifiedAt: z.string().nullable(),
2265
+ });
2266
+ export type RigVerificationHealth = z.infer<typeof RigVerificationHealth>;
2267
+
2268
+ export const Rig = z.object({
2269
+ id: z.string().uuid(),
2270
+ accountId: z.string().uuid(),
2271
+ workspaceId: z.string().uuid(),
2272
+ name: z.string(),
2273
+ description: z.string().nullable(),
2274
+ createdBy: z.string().nullable(),
2275
+ // The rig's currently-active version (present after create; nullable so a
2276
+ // partial/list read can omit it without a schema change).
2277
+ activeVersion: RigVersion.nullable(),
2278
+ // Summary for the currently active version. null only when there is no active
2279
+ // version; otherwise "unknown" means the active version has no verification.
2280
+ activeVersionHealth: RigVerificationHealth.nullable(),
2281
+ versionCount: z.number().int().nonnegative(),
2282
+ createdAt: z.string(),
2283
+ updatedAt: z.string(),
2284
+ });
2285
+ export type Rig = z.infer<typeof Rig>;
2286
+
2287
+ export const RigChangeKind = z.enum(["setup_append", "definition_edit"]);
2288
+ export type RigChangeKind = z.infer<typeof RigChangeKind>;
2289
+
2290
+ export const RigChangeStatus = z.enum(["proposed", "verifying", "merged", "rejected", "failed"]);
2291
+ export type RigChangeStatus = z.infer<typeof RigChangeStatus>;
2292
+
2293
+ // A single check's outcome inside a verification run (populated in M4).
2294
+ export const RigCheckResult = z.object({
2295
+ name: z.string(),
2296
+ command: z.string(),
2297
+ exitCode: z.number().int().nullable(),
2298
+ output: z.string().optional(),
2299
+ });
2300
+ export type RigCheckResult = z.infer<typeof RigCheckResult>;
2301
+
2302
+ // The verification record a rig-CI run writes onto a change (M4). Open-ended
2303
+ // (passthrough) so M4 can enrich it without a contracts break.
2304
+ export const RigChangeVerification = z
2305
+ .object({
2306
+ startedAt: z.string().optional(),
2307
+ finishedAt: z.string().optional(),
2308
+ log: z.string().optional(),
2309
+ checkResults: z.array(RigCheckResult).optional(),
2310
+ })
2311
+ .passthrough();
2312
+ export type RigChangeVerification = z.infer<typeof RigChangeVerification>;
2313
+
2314
+ export const RigChange = z.object({
2315
+ id: z.string().uuid(),
2316
+ rigId: z.string().uuid(),
2317
+ baseVersionId: z.string().uuid().nullable(),
2318
+ kind: RigChangeKind,
2319
+ payload: z.record(z.string(), z.unknown()),
2320
+ status: RigChangeStatus,
2321
+ proposedBy: z.string().nullable(),
2322
+ verification: RigChangeVerification.nullable(),
2323
+ resultVersionId: z.string().uuid().nullable(),
2324
+ createdAt: z.string(),
2325
+ updatedAt: z.string(),
2326
+ });
2327
+ export type RigChange = z.infer<typeof RigChange>;
2328
+
2329
+ export const CreateRigRequest = z.object({
2330
+ name: z.string().min(1).max(120),
2331
+ description: z.string().max(2000).optional(),
2332
+ // Initial (version 1) content, inline.
2333
+ image: z.string().max(1024).optional(),
2334
+ setupScript: z.string().max(131072).optional(),
2335
+ checks: z.array(RigCheck).max(100).default([]),
2336
+ credentialHooks: z.array(z.string().min(1).max(200)).max(50).default([]),
2337
+ defaultVariableSetIds: z.array(z.string().uuid()).max(25).default([]),
2338
+ });
2339
+ export type CreateRigRequest = z.infer<typeof CreateRigRequest>;
2340
+
2341
+ export const UpdateRigRequest = z.object({
1811
2342
  name: z.string().min(1).max(120).optional(),
1812
2343
  description: z.string().max(2000).nullable().optional(),
1813
2344
  });
1814
- export type UpdateWorkspaceEnvironmentRequest = z.infer<typeof UpdateWorkspaceEnvironmentRequest>;
2345
+ export type UpdateRigRequest = z.infer<typeof UpdateRigRequest>;
1815
2346
 
1816
- export const SetWorkspaceEnvironmentVariableRequest = z.object({
1817
- value: z.string().min(1).max(32768),
2347
+ // setup_append: the exact command that already worked (+ an optional note).
2348
+ export const RigSetupAppendPayload = z.object({
2349
+ command: z.string().min(1).max(8192),
2350
+ note: z.string().max(2000).optional(),
1818
2351
  });
1819
- export type SetWorkspaceEnvironmentVariableRequest = z.infer<typeof SetWorkspaceEnvironmentVariableRequest>;
2352
+ export type RigSetupAppendPayload = z.infer<typeof RigSetupAppendPayload>;
2353
+
2354
+ // definition_edit: the full next-version content (all fields optional; unset
2355
+ // fields inherit from the base version at promote time).
2356
+ export const RigDefinitionEditPayload = z.object({
2357
+ image: z.string().max(1024).nullish(),
2358
+ setupScript: z.string().max(131072).nullish(),
2359
+ checks: z.array(RigCheck).max(100).optional(),
2360
+ credentialHooks: z.array(z.string().min(1).max(200)).max(50).optional(),
2361
+ defaultVariableSetIds: z.array(z.string().uuid()).max(25).optional(),
2362
+ changelog: z.string().max(4096).nullish(),
2363
+ });
2364
+ export type RigDefinitionEditPayload = z.infer<typeof RigDefinitionEditPayload>;
2365
+
2366
+ export const ProposeRigChangeRequest = z.discriminatedUnion("kind", [
2367
+ z.object({ kind: z.literal("setup_append"), payload: RigSetupAppendPayload }),
2368
+ z.object({ kind: z.literal("definition_edit"), payload: RigDefinitionEditPayload }),
2369
+ ]);
2370
+ export type ProposeRigChangeRequest = z.infer<typeof ProposeRigChangeRequest>;
1820
2371
 
1821
2372
  export const ScheduledTaskStatus = z.enum(["active", "paused"]);
1822
2373
  export type ScheduledTaskStatus = z.infer<typeof ScheduledTaskStatus>;
@@ -1850,7 +2401,10 @@ export const ScheduledTaskScheduleSpec = z.discriminatedUnion("type", [
1850
2401
  timeZone: z.string().min(1).default("UTC"),
1851
2402
  hour: z.number().int().min(0).max(23),
1852
2403
  minute: z.number().int().min(0).max(59),
1853
- daysOfWeek: z.array(z.enum(["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"])).min(1).optional(),
2404
+ daysOfWeek: z
2405
+ .array(z.enum(["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"]))
2406
+ .min(1)
2407
+ .optional(),
1854
2408
  }),
1855
2409
  ]);
1856
2410
  export type ScheduledTaskScheduleSpec = z.infer<typeof ScheduledTaskScheduleSpec>;
@@ -1879,7 +2433,13 @@ export const ScheduledTask = z.object({
1879
2433
  overlapPolicy: ScheduledTaskOverlapPolicy,
1880
2434
  agentConfig: ScheduledTaskAgentConfig,
1881
2435
  reusableSessionId: z.string().uuid().nullable(),
1882
- environmentId: z.string().uuid().nullable(),
2436
+ variableSetId: z.string().uuid().nullable().default(null),
2437
+ /** @deprecated use variableSetId */
2438
+ environmentId: z.string().uuid().nullable().default(null),
2439
+ // The rig each run binds to (M3). Stored on the task; the ACTIVE version is
2440
+ // resolved PER FIRE (at dispatch), so a task always runs the rig's current
2441
+ // version rather than one frozen at task-create time. Null ⇒ rig-less runs.
2442
+ rigId: z.string().uuid().nullable().default(null),
1883
2443
  metadata: z.record(z.string(), z.unknown()),
1884
2444
  createdAt: z.string(),
1885
2445
  updatedAt: z.string(),
@@ -1903,26 +2463,33 @@ export const ScheduledTaskRun = z.object({
1903
2463
  });
1904
2464
  export type ScheduledTaskRun = z.infer<typeof ScheduledTaskRun>;
1905
2465
 
1906
- export const CreateScheduledTaskRequest = z.object({
2466
+ export const CreateScheduledTaskRequest = withVariableSetIdAlias({
1907
2467
  name: z.string().min(1),
1908
2468
  schedule: ScheduledTaskScheduleSpec,
1909
2469
  runMode: ScheduledTaskRunMode.default("new_session_per_run"),
1910
2470
  overlapPolicy: ScheduledTaskOverlapPolicy.default("allow_concurrent"),
1911
2471
  agentConfig: ScheduledTaskAgentConfig,
1912
2472
  status: ScheduledTaskStatus.default("active"),
2473
+ variableSetId: z.string().uuid().nullable().optional(),
1913
2474
  environmentId: z.string().uuid().nullable().optional(),
2475
+ // The rig each run binds to (M3); its active version is resolved per fire.
2476
+ rigId: z.string().uuid().nullable().optional(),
1914
2477
  metadata: z.record(z.string(), z.unknown()).default({}),
1915
2478
  });
1916
2479
  export type CreateScheduledTaskRequest = z.infer<typeof CreateScheduledTaskRequest>;
1917
2480
 
1918
- export const UpdateScheduledTaskRequest = z.object({
2481
+ export const UpdateScheduledTaskRequest = withVariableSetIdAlias({
1919
2482
  name: z.string().min(1).optional(),
1920
2483
  schedule: ScheduledTaskScheduleSpec.optional(),
1921
2484
  runMode: ScheduledTaskRunMode.optional(),
1922
2485
  overlapPolicy: ScheduledTaskOverlapPolicy.optional(),
1923
2486
  agentConfig: ScheduledTaskAgentConfig.optional(),
1924
2487
  status: ScheduledTaskStatus.optional(),
2488
+ variableSetId: z.string().uuid().nullable().optional(),
1925
2489
  environmentId: z.string().uuid().nullable().optional(),
2490
+ // The rig each run binds to (M3); null clears it. Its active version is
2491
+ // resolved per fire, so an update takes effect on the next dispatch.
2492
+ rigId: z.string().uuid().nullable().optional(),
1926
2493
  metadata: z.record(z.string(), z.unknown()).optional(),
1927
2494
  });
1928
2495
  export type UpdateScheduledTaskRequest = z.infer<typeof UpdateScheduledTaskRequest>;
@@ -1977,7 +2544,9 @@ export const CapabilityPackScheduledTaskTemplate = z.object({
1977
2544
  // instantiable templates; built-in packs may instead build prompts in code.
1978
2545
  prompt: z.string().min(1).optional(),
1979
2546
  });
1980
- export type CapabilityPackScheduledTaskTemplate = z.infer<typeof CapabilityPackScheduledTaskTemplate>;
2547
+ export type CapabilityPackScheduledTaskTemplate = z.infer<
2548
+ typeof CapabilityPackScheduledTaskTemplate
2549
+ >;
1981
2550
 
1982
2551
  // One file inside a pack skill directory. Paths are workspace-relative POSIX
1983
2552
  // paths inside the skill directory (for example "SKILL.md" or
@@ -1995,66 +2564,119 @@ export type CapabilityPackSkillFile = z.infer<typeof CapabilityPackSkillFile>;
1995
2564
  // A skill delivered by a capability pack. The name doubles as the skill
1996
2565
  // directory under the sandbox skill index (skills/<name>), so it must be a
1997
2566
  // single safe path segment. Every skill must ship a top-level SKILL.md.
1998
- export const CapabilityPackSkill = z.object({
1999
- name: z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
2000
- message: "skill name must be a single path segment of letters, digits, '.', '_' or '-'",
2001
- }),
2002
- description: z.string().min(1).max(2048).optional(),
2003
- files: z.array(CapabilityPackSkillFile).min(1).max(64),
2004
- }).superRefine((skill, ctx) => {
2005
- const seen = new Set<string>();
2006
- skill.files.forEach((file, index) => {
2007
- if (seen.has(file.path)) {
2008
- ctx.addIssue({ code: "custom", message: `duplicate skill file path: ${file.path}`, path: ["files", index, "path"] });
2567
+ export const CapabilityPackSkill = z
2568
+ .object({
2569
+ name: z
2570
+ .string()
2571
+ .min(1)
2572
+ .max(64)
2573
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
2574
+ message: "skill name must be a single path segment of letters, digits, '.', '_' or '-'",
2575
+ }),
2576
+ description: z.string().min(1).max(2048).optional(),
2577
+ files: z.array(CapabilityPackSkillFile).min(1).max(64),
2578
+ })
2579
+ .superRefine((skill, ctx) => {
2580
+ const seen = new Set<string>();
2581
+ skill.files.forEach((file, index) => {
2582
+ if (seen.has(file.path)) {
2583
+ ctx.addIssue({
2584
+ code: "custom",
2585
+ message: `duplicate skill file path: ${file.path}`,
2586
+ path: ["files", index, "path"],
2587
+ });
2588
+ }
2589
+ seen.add(file.path);
2590
+ });
2591
+ if (!skill.files.some((file) => file.path === "SKILL.md")) {
2592
+ ctx.addIssue({
2593
+ code: "custom",
2594
+ message: "skill must include a top-level SKILL.md file",
2595
+ path: ["files"],
2596
+ });
2009
2597
  }
2010
- seen.add(file.path);
2011
2598
  });
2012
- if (!skill.files.some((file) => file.path === "SKILL.md")) {
2013
- ctx.addIssue({ code: "custom", message: "skill must include a top-level SKILL.md file", path: ["files"] });
2014
- }
2015
- });
2016
2599
  export type CapabilityPackSkill = z.infer<typeof CapabilityPackSkill>;
2017
2600
 
2018
2601
  function isSafePackSkillRelativePath(path: string): boolean {
2019
2602
  if (path.startsWith("/") || path.includes("\\")) {
2020
2603
  return false;
2021
2604
  }
2022
- return path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
2605
+ return path
2606
+ .split("/")
2607
+ .every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
2023
2608
  }
2024
2609
 
2025
- export const CapabilityPack = z.object({
2026
- id: z.string().min(1),
2027
- name: z.string().min(1),
2610
+ const CapabilityPackVariableSet = z.object({
2028
2611
  description: z.string().min(1),
2029
- role: z.string().min(1),
2030
- category: z.string().min(1),
2031
- version: z.string().min(1),
2032
- // Container image ref (digest-pinned recommended) the pack's sessions run
2033
- // in. At most one enabled pack per workspace may declare one; with none,
2034
- // sessions use the deployment-wide image settings.
2035
- sandboxImage: z.string().trim().min(1).max(512).optional(),
2036
- // Skills delivered into the sandbox skill index when the pack is enabled.
2037
- skills: z.array(CapabilityPackSkill).max(32).superRefine((skills, ctx) => {
2038
- const seen = new Set<string>();
2039
- skills.forEach((skill, index) => {
2040
- const key = skill.name.toLowerCase();
2041
- if (seen.has(key)) {
2042
- ctx.addIssue({ code: "custom", message: `duplicate pack skill name: ${skill.name}`, path: [index, "name"] });
2043
- }
2044
- seen.add(key);
2045
- });
2046
- }).default([]),
2047
- tools: z.array(ToolRef).default([]),
2048
- connectors: z.array(CapabilityPackConnector).default([]),
2049
- knowledge: z.array(CapabilityPackKnowledge).default([]),
2050
- scheduledTaskTemplates: z.array(CapabilityPackScheduledTaskTemplate).default([]),
2051
- environment: z.object({
2052
- description: z.string().min(1),
2053
- requiredVariables: z.array(WorkspaceEnvironmentVariableName).default([]),
2054
- required: z.boolean().default(false),
2055
- }).optional(),
2056
- metadata: z.record(z.string(), z.unknown()).default({}),
2612
+ requiredVariables: z.array(VariableSetVariableName).default([]),
2613
+ required: z.boolean().default(false),
2057
2614
  });
2615
+
2616
+ export const CapabilityPack = z.preprocess(
2617
+ (input) => {
2618
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
2619
+ return input;
2620
+ }
2621
+ const record = input as Record<string, unknown>;
2622
+ if (record.variableSet !== undefined) {
2623
+ return record;
2624
+ }
2625
+ if (record.environment !== undefined) {
2626
+ const { environment: _environment, ...rest } = record;
2627
+ return { ...rest, variableSet: record.environment };
2628
+ }
2629
+ if (record.requiredVariables !== undefined) {
2630
+ const { requiredVariables: _requiredVariables, ...rest } = record;
2631
+ return {
2632
+ ...rest,
2633
+ variableSet: {
2634
+ description: "Required variables",
2635
+ requiredVariables: record.requiredVariables,
2636
+ required: Array.isArray(record.requiredVariables) && record.requiredVariables.length > 0,
2637
+ },
2638
+ };
2639
+ }
2640
+ return record;
2641
+ },
2642
+ z.object({
2643
+ id: z.string().min(1),
2644
+ name: z.string().min(1),
2645
+ description: z.string().min(1),
2646
+ role: z.string().min(1),
2647
+ category: z.string().min(1),
2648
+ version: z.string().min(1),
2649
+ // Container image ref (digest-pinned recommended) the pack's sessions run
2650
+ // in. At most one enabled pack per workspace may declare one; with none,
2651
+ // sessions use the deployment-wide image settings.
2652
+ sandboxImage: z.string().trim().min(1).max(512).optional(),
2653
+ // Skills delivered into the sandbox skill index when the pack is enabled.
2654
+ skills: z
2655
+ .array(CapabilityPackSkill)
2656
+ .max(32)
2657
+ .superRefine((skills, ctx) => {
2658
+ const seen = new Set<string>();
2659
+ skills.forEach((skill, index) => {
2660
+ const key = skill.name.toLowerCase();
2661
+ if (seen.has(key)) {
2662
+ ctx.addIssue({
2663
+ code: "custom",
2664
+ message: `duplicate pack skill name: ${skill.name}`,
2665
+ path: [index, "name"],
2666
+ });
2667
+ }
2668
+ seen.add(key);
2669
+ });
2670
+ })
2671
+ .default([]),
2672
+ tools: z.array(ToolRef).default([]),
2673
+ connectors: z.array(CapabilityPackConnector).default([]),
2674
+ knowledge: z.array(CapabilityPackKnowledge).default([]),
2675
+ scheduledTaskTemplates: z.array(CapabilityPackScheduledTaskTemplate).default([]),
2676
+ variableSet: CapabilityPackVariableSet.optional(),
2677
+ metadata: z.record(z.string(), z.unknown()).default({}),
2678
+ }),
2679
+ );
2058
2680
  export type CapabilityPack = z.infer<typeof CapabilityPack>;
2059
2681
 
2060
2682
  // Registering a pack stores the manifest itself; the request body is a full
@@ -2086,7 +2708,8 @@ export const PackInstallation = z.object({
2086
2708
  });
2087
2709
  export type PackInstallation = z.infer<typeof PackInstallation>;
2088
2710
 
2089
- export const EnablePackRequest = z.object({
2711
+ export const EnablePackRequest = withVariableSetIdAlias({
2712
+ variableSetId: z.string().uuid().optional(),
2090
2713
  environmentId: z.string().uuid().optional(),
2091
2714
  metadata: z.record(z.string(), z.unknown()).default({}),
2092
2715
  });
@@ -2172,14 +2795,16 @@ export type ConnectionKind = z.infer<typeof ConnectionKind>;
2172
2795
  export const ConnectionStatus = z.enum(["active", "needs_reauth", "revoked", "error"]);
2173
2796
  export type ConnectionStatus = z.infer<typeof ConnectionStatus>;
2174
2797
 
2175
- export const McpServerConnectionRef = z.object({
2176
- connectionId: z.string().uuid().optional(),
2177
- providerDomain: z.string().min(1),
2178
- kind: ConnectionKind.optional(),
2179
- scopes: z.array(z.string().min(1)).optional(),
2180
- resource: z.string().min(1).optional(),
2181
- subjectScope: z.enum(["workspace", "subject"]).optional(),
2182
- }).strict();
2798
+ export const McpServerConnectionRef = z
2799
+ .object({
2800
+ connectionId: z.string().uuid().optional(),
2801
+ providerDomain: z.string().min(1),
2802
+ kind: ConnectionKind.optional(),
2803
+ scopes: z.array(z.string().min(1)).optional(),
2804
+ resource: z.string().min(1).optional(),
2805
+ subjectScope: z.enum(["workspace", "subject"]).optional(),
2806
+ })
2807
+ .strict();
2183
2808
  export type McpServerConnectionRef = z.infer<typeof McpServerConnectionRef>;
2184
2809
 
2185
2810
  export const ConnectionMetadata = z.object({
@@ -2240,17 +2865,28 @@ export const ListConnectionsResponse = z.object({
2240
2865
  });
2241
2866
  export type ListConnectionsResponse = z.infer<typeof ListConnectionsResponse>;
2242
2867
 
2243
- export const OAuthStartRequest = z.object({
2244
- providerDomain: z.string().min(1).optional(),
2245
- mcpUrl: z.string().url().optional(),
2246
- resource: z.string().url().optional(),
2247
- requestedScopes: z.array(z.string().min(1)).default([]),
2248
- returnPath: z.string().min(1).optional(),
2249
- connectionId: z.string().uuid().optional(),
2250
- }).refine((value) => Boolean(value.mcpUrl ?? value.resource), {
2251
- message: "mcpUrl is required",
2252
- path: ["mcpUrl"],
2253
- });
2868
+ export const OAuthStartRequest = z
2869
+ .object({
2870
+ providerDomain: z.string().min(1).optional(),
2871
+ mcpUrl: z.string().url().optional(),
2872
+ resource: z.string().url().optional(),
2873
+ requestedScopes: z.array(z.string().min(1)).default([]),
2874
+ returnPath: z.string().min(1).optional(),
2875
+ connectionId: z.string().uuid().optional(),
2876
+ oauthClient: z
2877
+ .object({
2878
+ clientId: z.string().min(1),
2879
+ clientSecret: z.string().min(1).optional(),
2880
+ tokenEndpointAuthMethod: z
2881
+ .enum(["none", "client_secret_post", "client_secret_basic"])
2882
+ .optional(),
2883
+ })
2884
+ .optional(),
2885
+ })
2886
+ .refine((value) => Boolean(value.mcpUrl ?? value.resource), {
2887
+ message: "mcpUrl is required",
2888
+ path: ["mcpUrl"],
2889
+ });
2254
2890
  export type OAuthStartRequest = z.infer<typeof OAuthStartRequest>;
2255
2891
 
2256
2892
  export const OAuthStartResponse = z.object({
@@ -2287,7 +2923,13 @@ export type MarketingDailyAnalysisTaskRequest = z.infer<typeof MarketingDailyAna
2287
2923
  export const CapabilityKind = z.enum(["pack", "mcp", "api", "skill", "plugin"]);
2288
2924
  export type CapabilityKind = z.infer<typeof CapabilityKind>;
2289
2925
 
2290
- export const CapabilitySource = z.enum(["built_in", "configured", "public_registry", "registry", "manual"]);
2926
+ export const CapabilitySource = z.enum([
2927
+ "built_in",
2928
+ "configured",
2929
+ "public_registry",
2930
+ "registry",
2931
+ "manual",
2932
+ ]);
2291
2933
  export type CapabilitySource = z.infer<typeof CapabilitySource>;
2292
2934
 
2293
2935
  export const CapabilityInstallationStatus = z.enum(["active", "disabled"]);
@@ -2337,6 +2979,18 @@ export const CapabilityCatalogItem = z.object({
2337
2979
  runtime: CapabilityRuntime.default({ available: false, notes: null }),
2338
2980
  enabled: z.boolean().default(false),
2339
2981
  enabledReason: z.string().nullable().default(null),
2982
+ // The connection backing this enabled installation, when the enable-time
2983
+ // connectionRef resolved to one (null for header/credential-free items —
2984
+ // that means "no connection involved", not "broken"). Lets the UI match
2985
+ // connection health by id instead of guessing from providerDomain alone.
2986
+ connectionRef: z
2987
+ .object({
2988
+ connectionId: z.string().min(1),
2989
+ providerDomain: z.string().min(1),
2990
+ kind: z.string().min(1),
2991
+ })
2992
+ .nullable()
2993
+ .default(null),
2340
2994
  metadata: z.record(z.string(), z.unknown()).default({}),
2341
2995
  createdAt: z.string().optional(),
2342
2996
  updatedAt: z.string().optional(),
@@ -2373,23 +3027,24 @@ export const CreateCapabilityCatalogItemRequest = z.object({
2373
3027
  });
2374
3028
  export type CreateCapabilityCatalogItemRequest = z.infer<typeof CreateCapabilityCatalogItemRequest>;
2375
3029
 
2376
- export const EnableCapabilityRequest = z.object({
3030
+ export const EnableCapabilityRequest = withVariableSetIdAlias({
2377
3031
  config: z.record(z.string(), z.unknown()).default({}),
2378
3032
  metadata: z.record(z.string(), z.unknown()).default({}),
2379
3033
  connectionRef: McpServerConnectionRef.optional(),
2380
3034
  /**
2381
3035
  * Credential headers for remote MCP capabilities (for example an
2382
3036
  * Authorization bearer token). Values are encrypted at rest with the
2383
- * workspace-environments key, injected only into the runtime MCP client,
3037
+ * workspace-variable-sets key, injected only into the runtime MCP client,
2384
3038
  * and never returned by the API — responses expose header names only.
2385
3039
  */
2386
3040
  headers: z.record(z.string(), z.string()).default({}),
2387
3041
  /**
2388
- * Initial environment attachment for kind=pack capabilities. Mirrors the
3042
+ * Initial variableSet attachment for kind=pack capabilities. Mirrors the
2389
3043
  * dedicated POST /packs/:id/enable body: required to enable an
2390
- * environment.required pack through the unified capability-enable path,
3044
+ * variableSet.required pack through the unified capability-enable path,
2391
3045
  * optional otherwise. Ignored by non-pack capabilities.
2392
3046
  */
3047
+ variableSetId: z.string().uuid().optional(),
2393
3048
  environmentId: z.string().uuid().optional(),
2394
3049
  });
2395
3050
  export type EnableCapabilityRequest = z.infer<typeof EnableCapabilityRequest>;
@@ -2437,7 +3092,16 @@ export const Session = z.object({
2437
3092
  // stale in-flight op and retry against the new active sandbox.
2438
3093
  activeSandboxId: z.string().uuid().nullable(),
2439
3094
  activeEpoch: z.number().int().nonnegative(),
2440
- environmentId: z.string().uuid().nullable(),
3095
+ variableSetId: z.string().uuid().nullable().default(null),
3096
+ /** @deprecated use variableSetId */
3097
+ environmentId: z.string().uuid().nullable().default(null),
3098
+ // The rig this session rides (M3 runtime binding). Both are resolved and
3099
+ // FROZEN at session create: rigId names the rig, rigVersionId pins the exact
3100
+ // active version the session's box/env/setup/doctrine are built from for the
3101
+ // session's whole life (a later promote does NOT move an existing session).
3102
+ // Both null ⇒ a rig-less session (byte-for-byte today's behavior).
3103
+ rigId: z.string().uuid().nullable().default(null),
3104
+ rigVersionId: z.string().uuid().nullable().default(null),
2441
3105
  // Non-default first-party MCP token permissions (manager-style sessions);
2442
3106
  // null means the fixed worker default set.
2443
3107
  firstPartyMcpPermissions: z.array(Permission).nullable(),
@@ -2456,9 +3120,18 @@ export const Session = z.object({
2456
3120
  temporalWorkflowId: z.string().nullable(),
2457
3121
  activeTurnId: z.string().uuid().nullable(),
2458
3122
  // Actual input tokens of the last model call of the most recent turn; the
2459
- // pre-turn client-side context-compaction trigger reads it as its budget
3123
+ // pre-turn portable context-compaction trigger reads it as its budget
2460
3124
  // signal. Null until a turn with usage has completed.
2461
3125
  lastInputTokens: z.number().int().nonnegative().nullable(),
3126
+ queueVersion: z.number().int().nonnegative(),
3127
+ queueHeadPosition: z.number().int(),
3128
+ queueTailPosition: z.number().int(),
3129
+ controlState: SessionControlState,
3130
+ controlGeneration: z.number().int().nonnegative(),
3131
+ controlReason: z.string().nullable(),
3132
+ controlChangedBy: z.string().nullable(),
3133
+ controlChangedAt: z.string().nullable(),
3134
+ workspaceRunExceptionGeneration: z.number().int().nonnegative().nullable(),
2462
3135
  lastSequence: z.number().int().nonnegative(),
2463
3136
  // Multi-account Codex (P1). codexPinnedCredentialId: the account this session is
2464
3137
  // manually PINNED to (null ⇒ follow the workspace active pointer).
@@ -2466,34 +3139,100 @@ export const Session = z.object({
2466
3139
  // "Running on:" indicator's source). Both are credential-row ids, null until set.
2467
3140
  codexPinnedCredentialId: z.string().uuid().nullable(),
2468
3141
  codexLastCredentialId: z.string().uuid().nullable(),
3142
+ /** Personal (authenticated subject) workspace pin state, never workspace-global. */
3143
+ pinned: z.boolean().default(false),
3144
+ /** Stable pin ordering key; null when this subject has not pinned the session. */
3145
+ pinnedAt: z.string().nullable().default(null),
3146
+ /** Optimistic pin-state revision; zero represents an absent pin relation. */
3147
+ pinVersion: z.number().int().nonnegative().default(0),
3148
+ /**
3149
+ * Server-authoritative hierarchy summary populated on session-list reads.
3150
+ * Detail reads may omit it. The rail uses this instead of guessing a tree
3151
+ * from whichever global recency page happened to be loaded.
3152
+ */
3153
+ treeStats: z
3154
+ .object({
3155
+ directChildren: z.number().int().nonnegative(),
3156
+ totalDescendants: z.number().int().nonnegative(),
3157
+ runningDescendants: z.number().int().nonnegative(),
3158
+ queuedDescendants: z.number().int().nonnegative(),
3159
+ attentionDescendants: z.number().int().nonnegative(),
3160
+ pausedDescendants: z.number().int().nonnegative(),
3161
+ failedDescendants: z.number().int().nonnegative(),
3162
+ })
3163
+ .optional(),
2469
3164
  createdAt: z.string(),
2470
3165
  updatedAt: z.string(),
2471
3166
  });
2472
3167
  export type Session = z.infer<typeof Session>;
2473
3168
 
3169
+ export type SessionSummary = Session;
3170
+
3171
+ /**
3172
+ * The canonical session-list page. Pinned rows are returned separately and are
3173
+ * excluded from `sessions`, so a cursor can page ordinary recency rows without
3174
+ * duplicating a pin. Pins are filtered by the same parent/search predicates as
3175
+ * ordinary rows and ordered by pinnedAt DESC, id DESC.
3176
+ */
3177
+ export const SessionListResponse = z.object({
3178
+ pinned: z.array(Session),
3179
+ sessions: z.array(Session),
3180
+ nextCursor: z.string().nullable(),
3181
+ });
3182
+ export type SessionListResponse = z.infer<typeof SessionListResponse>;
3183
+
3184
+ // Recursive: the TS type is declared first so the schema annotation can carry
3185
+ // the FULL recursive shape (a shallow annotation loses type information for
3186
+ // contracts consumers after one level of nesting).
3187
+ export type LineageNode = {
3188
+ session: SessionSummary;
3189
+ children: LineageNode[];
3190
+ };
3191
+ export const LineageNode: z.ZodType<LineageNode> = z.lazy(() =>
3192
+ z.object({
3193
+ session: Session,
3194
+ children: z.array(LineageNode),
3195
+ }),
3196
+ );
3197
+
3198
+ export const SessionLineageResponse = z.object({
3199
+ ancestors: z.array(Session),
3200
+ children: z.array(LineageNode),
3201
+ truncated: z.boolean().default(false),
3202
+ });
3203
+ export type SessionLineageResponse = z.infer<typeof SessionLineageResponse>;
3204
+
2474
3205
  export const SessionEventType = z.enum([
2475
3206
  "session.created",
2476
3207
  "session.status.changed",
2477
3208
  "session.requiresAction",
3209
+ "session.context.compaction.requested",
2478
3210
  "session.context.compacted",
3211
+ "session.context.compaction.skipped",
2479
3212
  "session.context.cleared",
2480
3213
  "user.message",
2481
- "user.interrupt",
3214
+ "user.pause",
2482
3215
  "user.approvalDecision",
2483
3216
  "turn.queued",
2484
- "turn.updated",
2485
3217
  "turn.started",
2486
3218
  "turn.completed",
2487
3219
  "turn.failed",
2488
3220
  "turn.cancelled",
2489
- "turn.preempted",
3221
+ "turn.superseded",
3222
+ "turn.recovery.requested",
3223
+ "turn.capacity_waiting",
2490
3224
  "agent.message.delta",
2491
3225
  "agent.message.completed",
2492
3226
  "agent.reasoning.delta",
2493
3227
  "agent.toolCall.created",
2494
3228
  "agent.toolCall.output",
3229
+ "agent.model.usage",
2495
3230
  "tool.auth_needed",
2496
3231
  "agent.updated",
3232
+ "rig.setup.started",
3233
+ "rig.setup.completed",
3234
+ "rig.setup.skipped",
3235
+ "rig.setup.failed",
2497
3236
  "sandbox.operation.started",
2498
3237
  "sandbox.operation.completed",
2499
3238
  "sandbox.operation.failed",
@@ -2504,7 +3243,22 @@ export const SessionEventType = z.enum([
2504
3243
  "goal.completed",
2505
3244
  "goal.paused",
2506
3245
  "goal.resumed",
3246
+ "goal.cleared",
2507
3247
  "goal.continuation",
3248
+ "system.update.pending",
3249
+ "system.update.delivered",
3250
+ "session.control.paused",
3251
+ "session.control.resumed",
3252
+ "session.control.steer_requested",
3253
+ "workspace.inference.paused",
3254
+ "workspace.inference.resumed",
3255
+ "session.queue.prompt.cancelled",
3256
+ "session.queue.history",
3257
+ // A terminal/stale activity callback is retained as an audit wrapper rather
3258
+ // than being dropped or emitted as though it belonged to the current turn.
3259
+ "turn.event.rejected_late",
3260
+ "memory.saved",
3261
+ "memory.corrected",
2508
3262
  // Channel-B desktop pixel-plane signals (07-channel-b §1.2). The pixel socket
2509
3263
  // carries opaque RFB and cannot carry a control message the client can act on,
2510
3264
  // so these ride the durable, sequenced, gap-filled Channel-A SSE spine.
@@ -2537,6 +3291,78 @@ export const SessionEventType = z.enum([
2537
3291
  // (manual switch in P1; failover/rotation in P3 reuse the same event). Drives
2538
3292
  // the in-session "Running on:" indicator's live flip.
2539
3293
  "codex.account.switched",
3294
+ // OPE-21 per-turn selection audit. Payload is metadata only: credential row
3295
+ // id, bounded strategy/reason, and pool counts — never token material.
3296
+ "codex.credential.selected",
3297
+ // OPE-21 durable zero-capacity wait lifecycle. Runtime/system events only;
3298
+ // no synthetic user message is created when capacity returns.
3299
+ "codex.capacity.waiting",
3300
+ "codex.capacity.resumed",
3301
+ "codex.capacity.superseded",
3302
+ // Sandbox durability observability (sandbox-file-persistence). The 2026-07
3303
+ // incidents (mid-session box death with /workspace loss; a fatal manifest-env
3304
+ // delta on a live box) were near-unattributable because box lifecycle left no
3305
+ // durable trace — only worker logs, which rotate within hours. These events
3306
+ // make every box transition and env recomputation drift readable from the DB
3307
+ // alone. Payloads carry ids/flags/key NAMES only — never env values (secrets).
3308
+ "sandbox.box.created", // box cold-created/cold-restored ({hydrated: "archive"|"none"})
3309
+ "sandbox.box.lost", // resume-by-id found the box gone (provider NotFound)
3310
+ "sandbox.box.terminated", // reaper drain terminated the box ({actor, persisted})
3311
+ "sandbox.box.snapshot", // mid-session /workspace snapshot persisted ({trigger})
3312
+ "sandbox.env.drift", // recomputed manifest env != live box env (key names only)
3313
+ // Active-sandbox pointer reconcile (issue #341 invariant B). Turn start found the
3314
+ // persisted (active_sandbox_id, active_epoch) pointing at a target the turn cannot
3315
+ // establish — a deleted/absent sandbox, a Modal sibling with no establisher, or a
3316
+ // selfhosted sandbox with no enrollment — and reset it to the session HOME under
3317
+ // the epoch fence instead of routing every op into the dead target. A VISIBLE,
3318
+ // never-silent downgrade: payload carries the typed reason + from/to epoch, never a
3319
+ // target id or command content. Announce-only; hits the timeline projection default
3320
+ // (no rendered item) like the other sandbox.* diagnostics.
3321
+ "session.route.reconciled",
3322
+ // Workbench v2 turn-end workspace capture (dossier §10.1). ANNOUNCE-ONLY: a new
3323
+ // capture revision was persisted at turn end; the client refetches the latest
3324
+ // capture. It carries metadata only (revision/turnId/capturedAt/leaseEpoch/stats),
3325
+ // never file content. Hits the timeline projection default case (ignored) — it
3326
+ // must NEVER gain a rendered timeline item without regenerating the golden
3327
+ // snapshots (dossier §7.3 golden-grammar gate).
3328
+ "workspace.revision.captured",
3329
+ // Repository discovery could not prove a complete capture. The worker
3330
+ // persisted a failed/degraded revision marker and clients must fall back to
3331
+ // the live box rather than trust a zero-repository snapshot.
3332
+ "workspace.revision.degraded",
3333
+ // Connected Machine (selfhosted) op-outcome observability (failure-visibility
3334
+ // doctrine, out-of-band plane). SESSION-scoped facts only: these fire for the
3335
+ // session whose turn ran the op (the two-planes rule — machine-plane facts like
3336
+ // pressure live in the M10 metrics DB, never as session events). Payloads carry
3337
+ // the op kind + a typed fault class + attempt count — NEVER command content.
3338
+ //
3339
+ // `machine.op.failed` fires ONLY for INFRASTRUCTURE fault classes (offline,
3340
+ // draining-exhausted, payload-too-large, reconnecting-timeout, OS/stream/protocol)
3341
+ // — a semantic miss the model asked about (a missing path, a consent gate, a
3342
+ // nonzero exit) is an OUTCOME, not an infra fault, and never fires this.
3343
+ // `machine.op.recovered` is the healed-fault leading indicator (a blip/backpressure
3344
+ // the transport absorbed): announce-only, quiet. Both hit the timeline projection's
3345
+ // quiet status-tick tier (the severity split's "degraded"); adding a rendered item
3346
+ // requires regenerating the golden snapshots (the golden-grammar gate).
3347
+ "machine.op.failed",
3348
+ "machine.op.recovered",
3349
+ // Connected Machine (selfhosted) LINK-plane observability (failure-visibility
3350
+ // doctrine). SESSION-scoped, ANNOUNCE-ONLY facts fanned out to the sessions that
3351
+ // had an active op running on the machine when its control link changed — never
3352
+ // to idle/historical sessions. Payloads carry ids / a typed reason / key-names
3353
+ // only, NEVER command content.
3354
+ //
3355
+ // `machine.link.lost` — the machine announced a clean GoingOffline (its control
3356
+ // link is going away) while a session had a running turn on it. `machine.link.
3357
+ // restored` — a reconnect Hello re-established the link that was previously lost.
3358
+ // `machine.runner.restarted` — the additional signal that the going-offline was
3359
+ // a self-update restart specifically (link.lost also fires for it; this
3360
+ // distinguishes a restart from a plain stop / host shutdown). All three hit the
3361
+ // timeline projection's quiet default tier (no rendered item); adding a rendered
3362
+ // item requires regenerating the golden snapshots (the golden-grammar gate).
3363
+ "machine.link.lost",
3364
+ "machine.link.restored",
3365
+ "machine.runner.restarted",
2540
3366
  ]);
2541
3367
  export type SessionEventType = z.infer<typeof SessionEventType>;
2542
3368
 
@@ -2676,13 +3502,17 @@ export type SandboxCommandOutputDeltaPayload = z.infer<typeof SandboxCommandOutp
2676
3502
  export const FsChangeKind = z.enum(["created", "modified", "deleted", "renamed"]);
2677
3503
  export type FsChangeKind = z.infer<typeof FsChangeKind>;
2678
3504
  export const FsChangedPayload = z.object({
2679
- changes: z.array(z.object({
2680
- path: z.string(), // workspace-relative POSIX path
2681
- kind: FsChangeKind,
2682
- isDir: z.boolean().default(false),
2683
- sizeBytes: z.number().int().nonnegative().nullable().default(null),
2684
- oldPath: z.string().optional(), // for "renamed"
2685
- })).min(1),
3505
+ changes: z
3506
+ .array(
3507
+ z.object({
3508
+ path: z.string(), // workspace-relative POSIX path
3509
+ kind: FsChangeKind,
3510
+ isDir: z.boolean().default(false),
3511
+ sizeBytes: z.number().int().nonnegative().nullable().default(null),
3512
+ oldPath: z.string().optional(), // for "renamed"
3513
+ }),
3514
+ )
3515
+ .min(1),
2686
3516
  source: z.enum(["write", "watch", "agent"]).default("write"),
2687
3517
  // Monotonic FS revision (per-lease, paired with leaseEpoch for staleness).
2688
3518
  revision: z.number().int().nonnegative(),
@@ -2699,7 +3529,9 @@ export const GitChangedPayload = z.object({
2699
3529
  ahead: z.number().int().nonnegative().default(0),
2700
3530
  behind: z.number().int().nonnegative().default(0),
2701
3531
  changedFileCount: z.number().int().nonnegative(),
2702
- reason: z.enum(["commit", "checkout", "stage", "worktree", "fetch", "unknown"]).default("unknown"),
3532
+ reason: z
3533
+ .enum(["commit", "checkout", "stage", "worktree", "fetch", "unknown"])
3534
+ .default("unknown"),
2703
3535
  revision: z.number().int().nonnegative().default(0),
2704
3536
  leaseEpoch: z.number().int().nonnegative().default(0),
2705
3537
  });
@@ -2744,16 +3576,18 @@ export interface FsTreeNode {
2744
3576
  children?: FsTreeNode[] | undefined;
2745
3577
  truncated: boolean; // dir had more entries than the cap
2746
3578
  }
2747
- export const FsTreeNode: z.ZodType<FsTreeNode> = z.lazy(() => z.object({
2748
- name: z.string(),
2749
- path: z.string(),
2750
- type: FsNodeType,
2751
- sizeBytes: z.number().int().nonnegative().nullable(),
2752
- mtimeMs: z.number().int().nonnegative().nullable(),
2753
- mode: z.number().int().nullable(),
2754
- children: z.array(FsTreeNode).optional(),
2755
- truncated: z.boolean().default(false),
2756
- })) as z.ZodType<FsTreeNode>;
3579
+ export const FsTreeNode: z.ZodType<FsTreeNode> = z.lazy(() =>
3580
+ z.object({
3581
+ name: z.string(),
3582
+ path: z.string(),
3583
+ type: FsNodeType,
3584
+ sizeBytes: z.number().int().nonnegative().nullable(),
3585
+ mtimeMs: z.number().int().nonnegative().nullable(),
3586
+ mode: z.number().int().nullable(),
3587
+ children: z.array(FsTreeNode).optional(),
3588
+ truncated: z.boolean().default(false),
3589
+ }),
3590
+ ) as z.ZodType<FsTreeNode>;
2757
3591
 
2758
3592
  export const FsListRequest = z.object({
2759
3593
  path: z.string().default(""), // "" = workspace root
@@ -2774,7 +3608,12 @@ export type FsEncoding = z.infer<typeof FsEncoding>;
2774
3608
  export const FsReadRequest = z.object({
2775
3609
  path: z.string(),
2776
3610
  encoding: FsEncoding.default("utf8"),
2777
- maxBytes: z.number().int().positive().max(25 * 1024 * 1024).default(5 * 1024 * 1024),
3611
+ maxBytes: z
3612
+ .number()
3613
+ .int()
3614
+ .positive()
3615
+ .max(25 * 1024 * 1024)
3616
+ .default(5 * 1024 * 1024),
2778
3617
  });
2779
3618
  export type FsReadRequest = z.infer<typeof FsReadRequest>;
2780
3619
  export const FsReadResponse = z.object({
@@ -2838,7 +3677,15 @@ export type FsMkdirResponse = z.infer<typeof FsMkdirResponse>;
2838
3677
 
2839
3678
  // --- A2 Git request/response (read-only; feeds Pierre diff/tree) -------------
2840
3679
  export const GitFileStatusCode = z.enum([
2841
- "added", "modified", "deleted", "renamed", "copied", "untracked", "ignored", "conflicted", "typechange",
3680
+ "added",
3681
+ "modified",
3682
+ "deleted",
3683
+ "renamed",
3684
+ "copied",
3685
+ "untracked",
3686
+ "ignored",
3687
+ "conflicted",
3688
+ "typechange",
2842
3689
  ]);
2843
3690
  export type GitFileStatusCode = z.infer<typeof GitFileStatusCode>;
2844
3691
  export const GitFileStatus = z.object({
@@ -2905,7 +3752,12 @@ export const GitDiffRequest = z.object({
2905
3752
  toRef: z.string().optional(),
2906
3753
  pathspec: z.array(z.string()).default([]),
2907
3754
  contextLines: z.number().int().min(0).max(10).default(3),
2908
- maxBytesPerFile: z.number().int().positive().max(2 * 1024 * 1024).default(512 * 1024),
3755
+ maxBytesPerFile: z
3756
+ .number()
3757
+ .int()
3758
+ .positive()
3759
+ .max(2 * 1024 * 1024)
3760
+ .default(512 * 1024),
2909
3761
  });
2910
3762
  export type GitDiffRequest = z.infer<typeof GitDiffRequest>;
2911
3763
  export const GitDiffResponse = z.object({
@@ -2914,6 +3766,182 @@ export const GitDiffResponse = z.object({
2914
3766
  });
2915
3767
  export type GitDiffResponse = z.infer<typeof GitDiffResponse>;
2916
3768
 
3769
+ // ─── Workbench v2 turn-end workspace capture (dossier §10.1/§10.2) ────────────
3770
+ // A capture is a point-in-time snapshot of the session workspace's CHANGES,
3771
+ // probed live off the box at turn end (detectRepos → gitStatus/gitDiff → fsRead
3772
+ // after-images → fsList tree index). It is the cold/offline read source that
3773
+ // lets the workbench paint instantly with zero machine round-trips. Live always
3774
+ // wins when the box is warm; a capture is a labelled cache, never a replacement.
3775
+ // This is the shape the M1 worker writes and the M2 API serves inline.
3776
+
3777
+ // One touched file in the capture. `contentRef` is the content-addressed storage
3778
+ // key of its after-image blob (shared across revisions → the GC set-difference
3779
+ // key). Deleted / binary / >5MB (tooLarge) files carry no contentRef; the UI
3780
+ // renders "too large — open live" for tooLarge, and the diff hunks for the rest.
3781
+ export const WorkspaceCaptureFile = z.object({
3782
+ path: z.string(),
3783
+ status: GitFileStatusCode,
3784
+ // sha256 of the captured after-image bytes; null when deleted / tooLarge.
3785
+ hash: z.string().nullable(),
3786
+ // git blob sha of the HEAD version — the wake-on-edit flush guard (dossier
3787
+ // §10.1). null when the path is new/untracked (no HEAD blob).
3788
+ baseHash: z.string().nullable(),
3789
+ // Content-addressed storage key of the after-image; null when deleted /
3790
+ // tooLarge / binary (no inline content captured).
3791
+ contentRef: z.string().nullable(),
3792
+ sizeBytes: z.number().int().nonnegative(),
3793
+ isBinary: z.boolean().default(false),
3794
+ // >5MB per-file content guard tripped: content NOT captured, render "open live".
3795
+ tooLarge: z.boolean().default(false),
3796
+ deleted: z.boolean().default(false),
3797
+ });
3798
+ export type WorkspaceCaptureFile = z.infer<typeof WorkspaceCaptureFile>;
3799
+
3800
+ // One repo discovered in the workspace. `diff` is `git diff HEAD` (combined
3801
+ // staged+unstaged tracked changes vs HEAD — the review diff); `status` is the
3802
+ // full porcelain file list (drives the rail glyphs incl. untracked, which the
3803
+ // HEAD diff omits). root "" = the workspace root repo.
3804
+ export const WorkspaceCaptureRepo = z.object({
3805
+ root: z.string(),
3806
+ head: z.string().nullable(),
3807
+ detached: z.boolean().default(false),
3808
+ upstream: z.string().nullable(),
3809
+ ahead: z.number().int().nonnegative().default(0),
3810
+ behind: z.number().int().nonnegative().default(0),
3811
+ status: z.array(GitFileStatus),
3812
+ diff: z.array(GitFileDiff),
3813
+ });
3814
+ export type WorkspaceCaptureRepo = z.infer<typeof WorkspaceCaptureRepo>;
3815
+
3816
+ export const WorkspaceCaptureDegradedReason = z.enum([
3817
+ "repository_discovery_command_failed",
3818
+ "repository_discovery_timed_out",
3819
+ "repository_discovery_result_limit_exceeded",
3820
+ ]);
3821
+ export type WorkspaceCaptureDegradedReason = z.infer<typeof WorkspaceCaptureDegradedReason>;
3822
+
3823
+ // Rollup counters — carried on the row (jsonb) and the announce event so the UI
3824
+ // can reserve layout (dossier §12 no-layout-shift) before fetching the manifest.
3825
+ export const WorkspaceCaptureStats = z.object({
3826
+ repoCount: z.number().int().nonnegative(),
3827
+ fileCount: z.number().int().nonnegative(),
3828
+ additions: z.number().int().nonnegative(),
3829
+ deletions: z.number().int().nonnegative(),
3830
+ totalBytes: z.number().int().nonnegative(),
3831
+ tooLargeCount: z.number().int().nonnegative(),
3832
+ binaryCount: z.number().int().nonnegative(),
3833
+ treeEntryCount: z.number().int().nonnegative(),
3834
+ treeTruncated: z.boolean().default(false),
3835
+ durationMs: z.number().int().nonnegative(),
3836
+ // sha256 over the change surface (per-file path/hash/status + per-repo diff
3837
+ // summary, tree/mtime excluded). The empty-turn gate skips a capture whose
3838
+ // fingerprint equals the previous revision's — "no new revision when nothing
3839
+ // changed" holds even when the tree stays dirty across read-only turns.
3840
+ fingerprint: z.string().optional(),
3841
+ });
3842
+ export type WorkspaceCaptureStats = z.infer<typeof WorkspaceCaptureStats>;
3843
+
3844
+ // The single manifest blob (one per revision). Holds everything the workbench
3845
+ // needs for a cold paint: the tree index, per-repo status+diff, and the file
3846
+ // index (after-image refs). The M2 API serves this inline when small (≤2MB).
3847
+ export const WorkspaceCaptureManifest = z.object({
3848
+ version: z.literal(1),
3849
+ revision: z.number().int().nonnegative(),
3850
+ capturedAt: z.string(),
3851
+ turnId: z.string().nullable(),
3852
+ leaseEpoch: z.number().int().nonnegative(),
3853
+ treeIndex: FsTreeNode,
3854
+ treeTruncated: z.boolean().default(false),
3855
+ repos: z.array(WorkspaceCaptureRepo),
3856
+ files: z.array(WorkspaceCaptureFile),
3857
+ stats: WorkspaceCaptureStats,
3858
+ });
3859
+ export type WorkspaceCaptureManifest = z.infer<typeof WorkspaceCaptureManifest>;
3860
+
3861
+ // Announce-only event payload (dossier §10.1). Metadata only — never content.
3862
+ export const WorkspaceRevisionCapturedPayload = z.object({
3863
+ revision: z.number().int().nonnegative(),
3864
+ turnId: z.string().nullable(),
3865
+ capturedAt: z.string(),
3866
+ leaseEpoch: z.number().int().nonnegative(),
3867
+ stats: WorkspaceCaptureStats,
3868
+ });
3869
+ export type WorkspaceRevisionCapturedPayload = z.infer<typeof WorkspaceRevisionCapturedPayload>;
3870
+
3871
+ export const WorkspaceRevisionDegradedPayload = z.object({
3872
+ revision: z.number().int().nonnegative(),
3873
+ turnId: z.string().nullable(),
3874
+ capturedAt: z.string(),
3875
+ leaseEpoch: z.number().int().nonnegative(),
3876
+ reason: WorkspaceCaptureDegradedReason,
3877
+ });
3878
+ export type WorkspaceRevisionDegradedPayload = z.infer<typeof WorkspaceRevisionDegradedPayload>;
3879
+
3880
+ // --- M2 capture READ API (dossier §10.3) -------------------------------------
3881
+ // A short-TTL signed GET URL minted PER REQUEST (never stored). The manifest is
3882
+ // served inline for the ≤2MB common case (the <200ms one-round-trip paint); a
3883
+ // >2MB manifest and a >256KB single-file after-image fall back to one of these.
3884
+ export const WorkspaceCaptureSignedUrl = z.object({
3885
+ url: z.string().url(),
3886
+ expiresAt: z.string(),
3887
+ });
3888
+ export type WorkspaceCaptureSignedUrl = z.infer<typeof WorkspaceCaptureSignedUrl>;
3889
+
3890
+ // GET …/sessions/:sid/workspace/capture. `{available:false}` when no capture row
3891
+ // exists yet (or its manifest blob was GC'd) — the client falls back to the
3892
+ // live/wake path (status-quo behavior, NEVER an error → served 200). When
3893
+ // available: the row metadata (revision/turn/epoch/stats/size) is always inline;
3894
+ // the manifest is inline (`manifest`) for the ≤2MB common case and a signed GET
3895
+ // URL (`manifestUrl`) above that. Exactly one of manifest/manifestUrl is non-null.
3896
+ export const GetWorkspaceCaptureResponse = z.discriminatedUnion("available", [
3897
+ z.object({
3898
+ available: z.literal(false),
3899
+ // Optional for additive compatibility with older servers. New servers set
3900
+ // these fields when the newest durable revision is an explicit degraded
3901
+ // marker rather than "no capture exists yet".
3902
+ degradedReason: WorkspaceCaptureDegradedReason.nullable().optional(),
3903
+ revision: z.number().int().nonnegative().nullable().optional(),
3904
+ capturedAt: z.string().nullable().optional(),
3905
+ turnId: z.string().nullable().optional(),
3906
+ leaseEpoch: z.number().int().nonnegative().nullable().optional(),
3907
+ }),
3908
+ z.object({
3909
+ available: z.literal(true),
3910
+ revision: z.number().int().nonnegative(),
3911
+ capturedAt: z.string(),
3912
+ turnId: z.string().nullable(),
3913
+ leaseEpoch: z.number().int().nonnegative(),
3914
+ sizeBytes: z.number().int().nonnegative(),
3915
+ stats: WorkspaceCaptureStats,
3916
+ manifest: WorkspaceCaptureManifest.nullable().default(null),
3917
+ manifestUrl: WorkspaceCaptureSignedUrl.nullable().default(null),
3918
+ }),
3919
+ ]);
3920
+ export type GetWorkspaceCaptureResponse = z.infer<typeof GetWorkspaceCaptureResponse>;
3921
+
3922
+ // GET …/sessions/:sid/workspace/capture/file?path=…&revision=…. A single
3923
+ // after-image resolved from the (revision|latest) manifest. The file metadata
3924
+ // (from the manifest entry) is always present; `content` is inline for ≤256KB
3925
+ // (base64 for binary, utf8 otherwise), else a signed GET URL (`contentUrl`) to
3926
+ // the raw content-addressed blob. A tooLarge marker (or a captured file with no
3927
+ // content blob — e.g. the after-image was GC'd) returns metadata only, no
3928
+ // content and no URL. Path-not-in-manifest / deleted → 404 at the route (not
3929
+ // represented here).
3930
+ export const GetWorkspaceCaptureFileResponse = z.object({
3931
+ path: z.string(),
3932
+ revision: z.number().int().nonnegative(),
3933
+ status: GitFileStatusCode,
3934
+ hash: z.string().nullable(),
3935
+ baseHash: z.string().nullable(),
3936
+ sizeBytes: z.number().int().nonnegative(),
3937
+ isBinary: z.boolean(),
3938
+ tooLarge: z.boolean(),
3939
+ encoding: FsEncoding.nullable().default(null), // set iff content is inline
3940
+ content: z.string().nullable().default(null), // inline ≤256KB (per encoding)
3941
+ contentUrl: WorkspaceCaptureSignedUrl.nullable().default(null), // signed >256KB
3942
+ });
3943
+ export type GetWorkspaceCaptureFileResponse = z.infer<typeof GetWorkspaceCaptureFileResponse>;
3944
+
2917
3945
  export const GitLogRequest = z.object({
2918
3946
  path: z.string().default(""),
2919
3947
  ref: z.string().default("HEAD"),
@@ -2941,13 +3969,25 @@ export const GitShowRequest = z.object({
2941
3969
  ref: z.string(), // a commit/tag/tree-ish
2942
3970
  filePath: z.string().optional(), // ref + filePath => raw blob ("open file at commit")
2943
3971
  encoding: FsEncoding.default("utf8"),
2944
- maxBytesPerFile: z.number().int().positive().max(2 * 1024 * 1024).default(512 * 1024),
3972
+ maxBytesPerFile: z
3973
+ .number()
3974
+ .int()
3975
+ .positive()
3976
+ .max(2 * 1024 * 1024)
3977
+ .default(512 * 1024),
2945
3978
  });
2946
3979
  export type GitShowRequest = z.infer<typeof GitShowRequest>;
2947
3980
  export const GitShowResponse = z.object({
2948
3981
  commit: GitCommit.nullable(), // null when fetching a raw blob
2949
3982
  files: z.array(GitFileDiff), // commit diff vs first parent
2950
- blob: z.object({ content: z.string(), encoding: FsEncoding, sizeBytes: z.number().int(), truncated: z.boolean() }).nullable(),
3983
+ blob: z
3984
+ .object({
3985
+ content: z.string(),
3986
+ encoding: FsEncoding,
3987
+ sizeBytes: z.number().int(),
3988
+ truncated: z.boolean(),
3989
+ })
3990
+ .nullable(),
2951
3991
  revision: z.number().int().nonnegative(),
2952
3992
  });
2953
3993
  export type GitShowResponse = z.infer<typeof GitShowResponse>;
@@ -2995,7 +4035,11 @@ export const PtyOpenResponse = z.object({
2995
4035
  export type PtyOpenResponse = z.infer<typeof PtyOpenResponse>;
2996
4036
  export const PtyWriteRequest = z.object({ ptyId: z.string().uuid(), data: z.string() }); // utf-8 stdin
2997
4037
  export type PtyWriteRequest = z.infer<typeof PtyWriteRequest>;
2998
- export const PtyResizeRequest = z.object({ ptyId: z.string().uuid(), cols: z.number().int().positive(), rows: z.number().int().positive() });
4038
+ export const PtyResizeRequest = z.object({
4039
+ ptyId: z.string().uuid(),
4040
+ cols: z.number().int().positive(),
4041
+ rows: z.number().int().positive(),
4042
+ });
2999
4043
  export type PtyResizeRequest = z.infer<typeof PtyResizeRequest>;
3000
4044
  export const PtyCloseRequest = z.object({ ptyId: z.string().uuid() });
3001
4045
  export type PtyCloseRequest = z.infer<typeof PtyCloseRequest>;
@@ -3024,10 +4068,36 @@ export const SessionEvent = z.object({
3024
4068
  occurredAt: z.string(),
3025
4069
  clientEventId: z.string().min(1).nullable().optional(),
3026
4070
  turnId: z.string().uuid().nullable().optional(),
4071
+ turnGeneration: z.number().int().nonnegative().nullable().optional(),
4072
+ turnAttemptId: z.string().uuid().nullable().optional(),
4073
+ turnAssociation: z.enum(["current", "late_rejected", "duplicate"]).nullable().optional(),
4074
+ duplicateOfEventId: z.string().uuid().nullable().optional(),
4075
+ duplicateReason: z.string().min(1).nullable().optional(),
3027
4076
  });
3028
4077
  export type SessionEvent = z.infer<typeof SessionEvent>;
3029
4078
 
3030
- export const CreateSessionRequest = z.object({
4079
+ export const SessionQueueMutationResponse = z.object({
4080
+ snapshot: SessionQueueSnapshot,
4081
+ events: z.array(SessionEvent),
4082
+ shouldWake: z.boolean(),
4083
+ });
4084
+ export type SessionQueueMutationResponse = z.infer<typeof SessionQueueMutationResponse>;
4085
+
4086
+ export const SessionControlResponse = z.object({
4087
+ operationId: z.string().uuid(),
4088
+ event: SessionEvent,
4089
+ controlState: SessionControlState,
4090
+ controlGeneration: z.number().int().nonnegative(),
4091
+ expectedActiveTurnId: z.string().uuid().nullable(),
4092
+ expectedExecutionGeneration: z.number().int().nonnegative().nullable(),
4093
+ expectedAttemptId: z.string().uuid().nullable(),
4094
+ deliveryEventId: z.string().uuid().nullable(),
4095
+ shouldSignalControl: z.boolean(),
4096
+ shouldWake: z.boolean(),
4097
+ });
4098
+ export type SessionControlResponse = z.infer<typeof SessionControlResponse>;
4099
+
4100
+ export const CreateSessionRequest = withVariableSetIdAlias({
3031
4101
  initialMessage: z.string().min(1),
3032
4102
  // Per-session agent persona/system instructions (org-visible metadata, NOT a
3033
4103
  // secret). Rides the SAME system-level instructions channel the per-workspace
@@ -3036,7 +4106,7 @@ export const CreateSessionRequest = z.object({
3036
4106
  // leaking them into the user-visible timeline (it is NEVER emitted as an
3037
4107
  // event, unlike goal/initialMessage). Trimmed, non-empty. The 32768-char cap
3038
4108
  // matches the codebase's largest free-form string convention (workspace
3039
- // environment variable values). Absent ⇒ byte-identical to today.
4109
+ // variable set variable values). Absent ⇒ byte-identical to today.
3040
4110
  instructions: z.string().trim().min(1).max(32768).optional(),
3041
4111
  resources: z.array(ResourceRef).default([]),
3042
4112
  tools: z.array(ToolRef).default([]),
@@ -3055,9 +4125,15 @@ export const CreateSessionRequest = z.object({
3055
4125
  // (the agent's resolve_cwd handles both). Only valid WITH targetSandboxId
3056
4126
  // (workingDir alone is a 422); omitted ⇒ the machine's default workspace_root.
3057
4127
  workingDir: z.string().min(1).optional(),
3058
- // Workspace environment attachment is fixed at session creation; follow-up
4128
+ // Variable set attachment is fixed at session creation; follow-up
3059
4129
  // user.message events cannot switch or add one.
4130
+ variableSetId: z.string().uuid().optional(),
3060
4131
  environmentId: z.string().uuid().optional(),
4132
+ // The rig to bind this session to (M3). Its ACTIVE version is resolved and
4133
+ // FROZEN onto the session at create. Omitted ⇒ the workspace's default rig
4134
+ // (workspaces.default_rig_id) when set, else a rig-less session (today's
4135
+ // behavior). An id that does not name a rig in the workspace is a 422.
4136
+ rigId: z.string().uuid().optional(),
3061
4137
  goal: GoalSpec.optional(),
3062
4138
  clientEventId: z.string().min(1).optional(),
3063
4139
  // Workspace-scoped CREATE idempotency key: collapses concurrent/retried
@@ -3069,7 +4145,7 @@ export const CreateSessionRequest = z.object({
3069
4145
  idempotencyKey: z.string().min(1).max(200).optional(),
3070
4146
  // Permissions the session's first-party MCP token should carry instead of
3071
4147
  // the fixed worker default — how an operator hands a manager-style session
3072
- // the orchestration/environment/github tools. Capped at creation: every
4148
+ // the orchestration/variableSet/github tools. Capped at creation: every
3073
4149
  // requested permission must be held by the creating grant (no escalation).
3074
4150
  firstPartyMcpPermissions: z.array(Permission).optional(),
3075
4151
  // Third-party MCP servers attached only to this session. Credential headers are
@@ -3087,16 +4163,14 @@ export const CreateSessionRequest = z.object({
3087
4163
  // A shared spawn inherits the box's (backend, os) — it is literally the same
3088
4164
  // box; the child cannot pick its own backend. Cross-workspace sharing is
3089
4165
  // forbidden by construction (the parent/group reads are RLS-workspace-scoped).
3090
- // ENV-AWARE: the box's environment is fixed at creation, so a share requires
3091
- // the SAME environmentId as the creator's box. On a mismatch the inherited
4166
+ // ENV-AWARE: the box's variable set is fixed at creation, so a share requires
4167
+ // the SAME variableSetId as the creator's box. On a mismatch the inherited
3092
4168
  // default silently falls back to an own box; an explicit "shared"/{groupId}
3093
4169
  // request 422s at create (instead of the first turn dying on the SDK's
3094
4170
  // manifest-env guard).
3095
- sandbox: z.union([
3096
- z.literal("shared"),
3097
- z.literal("new"),
3098
- z.object({ groupId: z.string().uuid() }),
3099
- ]).optional(),
4171
+ sandbox: z
4172
+ .union([z.literal("shared"), z.literal("new"), z.object({ groupId: z.string().uuid() })])
4173
+ .optional(),
3100
4174
  });
3101
4175
  export type CreateSessionRequest = z.infer<typeof CreateSessionRequest>;
3102
4176
 
@@ -3115,11 +4189,6 @@ export const ClientSessionEvent = z.discriminatedUnion("type", [
3115
4189
  mcpCredentialUpdates: z.array(SessionMcpCredentialUpdateInput).optional(),
3116
4190
  }),
3117
4191
  }),
3118
- z.object({
3119
- type: z.literal("user.interrupt"),
3120
- clientEventId: z.string().min(1).optional(),
3121
- payload: z.object({ reason: z.string().optional() }).default({}),
3122
- }),
3123
4192
  z.object({
3124
4193
  type: z.literal("user.approvalDecision"),
3125
4194
  clientEventId: z.string().min(1).optional(),
@@ -3132,6 +4201,25 @@ export const ClientSessionEvent = z.discriminatedUnion("type", [
3132
4201
  ]);
3133
4202
  export type ClientSessionEvent = z.infer<typeof ClientSessionEvent>;
3134
4203
 
4204
+ export const SteerSessionMessageRequest = z.object({
4205
+ text: z.string().min(1),
4206
+ resources: z.array(ResourceRef).default([]),
4207
+ tools: z.array(ToolRef).default([]),
4208
+ model: z.string().min(1).optional(),
4209
+ reasoningEffort: ReasoningEffort.optional(),
4210
+ clientEventId: z.string().min(1).optional(),
4211
+ expectedControlGeneration: z.number().int().nonnegative().optional(),
4212
+ expectedWorkspaceInferenceGeneration: z.number().int().nonnegative().optional(),
4213
+ mcpCredentialUpdates: z.array(SessionMcpCredentialUpdateInput).optional(),
4214
+ });
4215
+ export type SteerSessionMessageRequest = z.infer<typeof SteerSessionMessageRequest>;
4216
+
4217
+ export const SteerSessionMessageResponse = z.object({
4218
+ accepted: SessionEvent,
4219
+ turn: SessionTurn,
4220
+ });
4221
+ export type SteerSessionMessageResponse = z.infer<typeof SteerSessionMessageResponse>;
4222
+
3135
4223
  export const SessionBusMessage = z.object({
3136
4224
  workspaceId: z.string().uuid(),
3137
4225
  sessionId: z.string().uuid(),
@@ -3435,7 +4523,13 @@ export const DeviceEnrollmentPollRequest = z.object({
3435
4523
  });
3436
4524
  export type DeviceEnrollmentPollRequest = z.infer<typeof DeviceEnrollmentPollRequest>;
3437
4525
 
3438
- export const DeviceEnrollmentState = z.enum(["pending", "authorized", "denied", "expired", "disabled"]);
4526
+ export const DeviceEnrollmentState = z.enum([
4527
+ "pending",
4528
+ "authorized",
4529
+ "denied",
4530
+ "expired",
4531
+ "disabled",
4532
+ ]);
3439
4533
  export type DeviceEnrollmentState = z.infer<typeof DeviceEnrollmentState>;
3440
4534
 
3441
4535
  // The EnrollmentCredentials (field names match the proto's JSON). natsAccountCreds
@@ -3717,6 +4811,19 @@ export const SwapActiveSandboxResponse = z.object({
3717
4811
  activeSandboxId: z.string().nullable(),
3718
4812
  activeEpoch: z.number().int(),
3719
4813
  reason: z.string().optional(),
4814
+ // Typed rejection discriminant (issue #341). Present only when swapped is false,
4815
+ // so a client distinguishes a deleted/absent target from an unaddressable
4816
+ // enrollment from a backend the turn cannot establish from a lost epoch race —
4817
+ // without parsing the human reason string.
4818
+ code: z
4819
+ .enum([
4820
+ "stale_pointer",
4821
+ "offline_enrollment",
4822
+ "unsupported_backend_context",
4823
+ "transient_establishment",
4824
+ "concurrent_swap",
4825
+ ])
4826
+ .optional(),
3720
4827
  });
3721
4828
  export type SwapActiveSandboxResponse = z.infer<typeof SwapActiveSandboxResponse>;
3722
4829
 
@@ -3739,7 +4846,7 @@ export type MachineMetricsSeriesResponse = z.infer<typeof MachineMetricsSeriesRe
3739
4846
  export const ClientModel = z.object({
3740
4847
  id: z.string(),
3741
4848
  label: z.string(),
3742
- provider: z.string(), // provider id
4849
+ provider: z.string(), // provider id
3743
4850
  providerLabel: z.string(),
3744
4851
  api: z.enum(["responses", "chat"]),
3745
4852
  contextWindowTokens: z.number().int().positive().optional(),
@@ -3759,10 +4866,14 @@ export const ClientConfig = z.object({
3759
4866
  models: z.array(ClientModel).default([]),
3760
4867
  defaultReasoningEffort: ReasoningEffort,
3761
4868
  allowedReasoningEfforts: z.array(ReasoningEffort).min(1),
3762
- mcpServers: z.array(z.object({
3763
- id: z.string(),
3764
- name: z.string(),
3765
- })).default([]),
4869
+ mcpServers: z
4870
+ .array(
4871
+ z.object({
4872
+ id: z.string(),
4873
+ name: z.string(),
4874
+ }),
4875
+ )
4876
+ .default([]),
3766
4877
  fileUploads: z.object({
3767
4878
  enabled: z.boolean(),
3768
4879
  maxSizeBytes: z.number().int().positive(),
@@ -3773,11 +4884,13 @@ export const ClientConfig = z.object({
3773
4884
  // at all (P4.4). Per-session availability is negotiated on /stream-capabilities
3774
4885
  // (it depends on the session's pinned backend); this is the coarse on/off the
3775
4886
  // client uses to decide whether to even attempt the fs/git/terminal panels.
3776
- structuredServices: z.object({
3777
- fileSystem: z.boolean(),
3778
- git: z.boolean(),
3779
- terminalEvents: z.boolean(),
3780
- }).default({ fileSystem: false, git: false, terminalEvents: false }),
4887
+ structuredServices: z
4888
+ .object({
4889
+ fileSystem: z.boolean(),
4890
+ git: z.boolean(),
4891
+ terminalEvents: z.boolean(),
4892
+ })
4893
+ .default({ fileSystem: false, git: false, terminalEvents: false }),
3781
4894
  });
3782
4895
  export type ClientConfig = z.infer<typeof ClientConfig>;
3783
4896
 
@@ -3816,6 +4929,42 @@ function constantTimeEqual(actual: string, expected: string): boolean {
3816
4929
 
3817
4930
  export type HealthResponse = {
3818
4931
  service: string;
3819
- environment: string;
4932
+ variableSet: string;
3820
4933
  ok: boolean;
3821
4934
  };
4935
+
4936
+ /**
4937
+ * Per-workspace model/provider availability policy (see @opengeni/db
4938
+ * workspace_model_policies). NULL fields = unrestricted; a non-null
4939
+ * allowedProviders is a strict allowlist over RESOLVED provider identities
4940
+ * (the built-in OpenAI/Azure client's id — "openai"/"azure" — including the
4941
+ * legacy null-resolution fallback; "codex-subscription" for the ChatGPT/Codex
4942
+ * overlay; registry providers by their declared ids); a non-null allowedModels
4943
+ * is an additional exact-model-id allowlist. Pure and shared so the API edge
4944
+ * (422) and the worker's authoritative post-resolution gate (fail-loud
4945
+ * turn.failed, never a silent remap) can never disagree on semantics.
4946
+ */
4947
+ export type WorkspaceModelPolicyContract = {
4948
+ allowedProviders: string[] | null;
4949
+ allowedModels: string[] | null;
4950
+ };
4951
+
4952
+ export type WorkspaceModelPolicyVerdict =
4953
+ | { allowed: true }
4954
+ | { allowed: false; reason: "provider" | "model" };
4955
+
4956
+ export function evaluateWorkspaceModelPolicy(
4957
+ policy: WorkspaceModelPolicyContract | null | undefined,
4958
+ candidate: { providerId: string; modelId: string },
4959
+ ): WorkspaceModelPolicyVerdict {
4960
+ if (!policy) {
4961
+ return { allowed: true };
4962
+ }
4963
+ if (policy.allowedProviders !== null && !policy.allowedProviders.includes(candidate.providerId)) {
4964
+ return { allowed: false, reason: "provider" };
4965
+ }
4966
+ if (policy.allowedModels !== null && !policy.allowedModels.includes(candidate.modelId)) {
4967
+ return { allowed: false, reason: "model" };
4968
+ }
4969
+ return { allowed: true };
4970
+ }