@cat-factory/node-server 0.88.0 → 0.89.1

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.
@@ -2,7 +2,7 @@ import { LLM_WARNING_FINISH_REASONS } from '@cat-factory/kernel';
2
2
  import { agentRunKindSchema, decodeInitiativeRow, isWebSearchProvider, } from '@cat-factory/contracts';
3
3
  import { decodeEnum, tryDecodeRows, blockInsertValues, blockPatchToColumns, parseIssueIntakeColumn, serializeIssueIntakeColumn, rowToBlock, rowToExecution, executionToDetail, rowToPipeline, rowToSandboxExperiment, rowToSandboxFixture, rowToSandboxGrade, rowToSandboxPromptVersion, rowToSandboxRun, rowToWorkspace, } from '@cat-factory/server';
4
4
  import { and, asc, count, desc, eq, gte, inArray, isNull, lt, ne, notInArray, or, sql, } from 'drizzle-orm';
5
- import { accountInvitations, passwordResetTokens, accountSettings, localSettings, accounts, agentContextSnapshots, agentSearchQueries, agentRuns, blocks, consensusSessions, incidentEnrichmentConnections, observabilityConnections, packageRegistryConnections, emailConnections, llmCallMetrics, provisioningLog, sharedStacks, memberships, mergeThresholdPresets, releaseHealthConfigs, testSecrets, pipelineScheduleRuns, pipelineSchedules, pipelines, requirementReviews, docInterviewSessions, kaizenGradings, kaizenVerifiedCombos, clarityReviews, binaryArtifacts, brainstormSessions, initiatives, sandboxPromptVersions, sandboxFixtures, sandboxExperiments, sandboxRuns, sandboxGrades, services, tokenUsage, trackerSettings, modelPresets, userIdentities, users, userSettings, workspaceFragmentDefaults, workspaceServices, workspaceSettings, workspaces, } from '../db/schema.js';
5
+ import { accountInvitations, passwordResetTokens, accountSettings, localSettings, accounts, agentContextSnapshots, agentSearchQueries, agentRuns, blocks, consensusSessions, incidentEnrichmentConnections, observabilityConnections, packageRegistryConnections, emailConnections, llmCallMetrics, provisioningLog, sharedStacks, memberships, riskPolicies, releaseHealthConfigs, testSecrets, pipelineScheduleRuns, pipelineSchedules, pipelines, requirementReviews, docInterviewSessions, kaizenGradings, kaizenVerifiedCombos, clarityReviews, binaryArtifacts, brainstormSessions, initiatives, sandboxPromptVersions, sandboxFixtures, sandboxExperiments, sandboxRuns, sandboxGrades, services, tokenUsage, trackerSettings, modelPresets, userIdentities, users, userSettings, workspaceFragmentDefaults, workspaceServices, workspaceSettings, workspaces, } from '../db/schema.js';
6
6
  // Drizzle/Postgres implementations of the core kernel repository ports. The
7
7
  // row<->domain mapping is the SAME shared mapping the Cloudflare D1 repos use
8
8
  // (@cat-factory/server), so behaviour matches across stores; this layer only owns
@@ -222,6 +222,7 @@ class DrizzlePipelineRepository {
222
222
  gating: pipeline.gating ? JSON.stringify(pipeline.gating) : null,
223
223
  follow_ups: pipeline.followUps ? JSON.stringify(pipeline.followUps) : null,
224
224
  tester_quality: pipeline.testerQuality ? JSON.stringify(pipeline.testerQuality) : null,
225
+ step_options: pipeline.stepOptions ? JSON.stringify(pipeline.stepOptions) : null,
225
226
  labels: pipeline.labels ? JSON.stringify(pipeline.labels) : null,
226
227
  archived: pipeline.archived ? 1 : null,
227
228
  builtin: pipeline.builtin ? 1 : null,
@@ -246,6 +247,7 @@ class DrizzlePipelineRepository {
246
247
  gating: pipeline.gating ? JSON.stringify(pipeline.gating) : null,
247
248
  follow_ups: pipeline.followUps ? JSON.stringify(pipeline.followUps) : null,
248
249
  tester_quality: pipeline.testerQuality ? JSON.stringify(pipeline.testerQuality) : null,
250
+ step_options: pipeline.stepOptions ? JSON.stringify(pipeline.stepOptions) : null,
249
251
  labels: pipeline.labels ? JSON.stringify(pipeline.labels) : null,
250
252
  archived: pipeline.archived ? 1 : null,
251
253
  version: pipeline.version ?? null,
@@ -442,6 +444,21 @@ class DrizzleExecutionRepository {
442
444
  return rows;
443
445
  }
444
446
  async markFailed(workspaceId, id, failure) {
447
+ // Guard against clobbering a row that already reached a terminal state: a `stopRun`
448
+ // racing a run that just merged (`done`) or already failed must not overwrite it. This
449
+ // is the authoritative first-write-wins / no-re-fail-a-merged-run check — `failRun`'s
450
+ // in-memory guard reads a snapshot that can be stale by the time this write lands
451
+ // (race-audit 2.3). Mirrors the D1 `AND status NOT IN ('done','failed')`.
452
+ //
453
+ // BUMP `rev` on the terminal write so it participates in the driver's optimistic
454
+ // concurrency: a `casPersist` from an in-flight driver iteration that loaded the run
455
+ // BEFORE this `stopRun`/`failRun` still holds the pre-fail `rev`, so bumping it here makes
456
+ // that stale write miss its `rev = ?` guard → `RunContendedError` → re-drive → the reload
457
+ // sees `failed` and no-ops. Without the bump `markFailed` left `rev` untouched, so a stale
458
+ // `casPersist` writing a non-terminal status (`pollGate` pending, dispatch, …) would MATCH
459
+ // the unchanged `rev` and RESURRECT the stopped run as `running` (race-audit 2.3, the
460
+ // driver-clobbers-terminal direction — the dual of the SQL status guard above). Mirrors the
461
+ // D1 `rev = rev + 1`.
445
462
  await this.db
446
463
  .update(agentRuns)
447
464
  .set({
@@ -449,8 +466,9 @@ class DrizzleExecutionRepository {
449
466
  error: failure.message,
450
467
  failure: JSON.stringify(failure),
451
468
  updated_at: this.clock.now(),
469
+ rev: sql `${agentRuns.rev} + 1`,
452
470
  })
453
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.id, id), this.isExecution));
471
+ .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.id, id), this.isExecution, notInArray(agentRuns.status, ['done', 'failed'])));
454
472
  }
455
473
  }
456
474
  class DrizzleAgentRunRepository {
@@ -969,9 +987,41 @@ class DrizzleTokenUsageRepository {
969
987
  input_tokens: usage.inputTokens,
970
988
  output_tokens: usage.outputTokens,
971
989
  cost_estimate: usage.costEstimate,
990
+ billing: usage.billing,
991
+ vendor: usage.vendor,
972
992
  created_at: usage.createdAt,
973
993
  });
974
994
  }
995
+ async usageBreakdownForWorkspace(workspaceId, epochMs) {
996
+ // One GROUP BY over the workspace's current period — both billing kinds (the report
997
+ // shows total usage). Never a per-model loop. sum() of int columns is bigint; cast +
998
+ // coerce like the totals rollups. Ordered heaviest-first in SQL, mirroring the D1 repo.
999
+ const rows = await this.db
1000
+ .select({
1001
+ billing: tokenUsage.billing,
1002
+ vendor: tokenUsage.vendor,
1003
+ provider: tokenUsage.provider,
1004
+ model: tokenUsage.model,
1005
+ input: sql `coalesce(sum(${tokenUsage.input_tokens}), 0)::bigint`,
1006
+ output: sql `coalesce(sum(${tokenUsage.output_tokens}), 0)::bigint`,
1007
+ cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1008
+ calls: sql `count(*)::bigint`,
1009
+ })
1010
+ .from(tokenUsage)
1011
+ .where(and(eq(tokenUsage.workspace_id, workspaceId), gte(tokenUsage.created_at, epochMs)))
1012
+ .groupBy(tokenUsage.billing, tokenUsage.vendor, tokenUsage.provider, tokenUsage.model)
1013
+ .orderBy(sql `(coalesce(sum(${tokenUsage.input_tokens}), 0) + coalesce(sum(${tokenUsage.output_tokens}), 0)) desc`);
1014
+ return rows.map((r) => ({
1015
+ billing: (r.billing === 'subscription' ? 'subscription' : 'metered'),
1016
+ vendor: r.vendor,
1017
+ provider: r.provider,
1018
+ model: r.model,
1019
+ inputTokens: Number(r.input ?? 0),
1020
+ outputTokens: Number(r.output ?? 0),
1021
+ costEstimate: r.cost ?? 0,
1022
+ calls: Number(r.calls ?? 0),
1023
+ }));
1024
+ }
975
1025
  async totalsSince(epochMs) {
976
1026
  // sum() of int columns is bigint in Postgres — cast to bigint (NOT int4, which
977
1027
  // overflows past ~2.1B tokens) and coerce: node-postgres returns bigint as a
@@ -984,7 +1034,7 @@ class DrizzleTokenUsageRepository {
984
1034
  cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
985
1035
  })
986
1036
  .from(tokenUsage)
987
- .where(gte(tokenUsage.created_at, epochMs));
1037
+ .where(and(gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
988
1038
  return {
989
1039
  inputTokens: Number(row?.input ?? 0),
990
1040
  outputTokens: Number(row?.output ?? 0),
@@ -999,7 +1049,7 @@ class DrizzleTokenUsageRepository {
999
1049
  cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1000
1050
  })
1001
1051
  .from(tokenUsage)
1002
- .where(and(eq(tokenUsage.workspace_id, workspaceId), gte(tokenUsage.created_at, epochMs)));
1052
+ .where(and(eq(tokenUsage.workspace_id, workspaceId), gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1003
1053
  return {
1004
1054
  inputTokens: Number(row?.input ?? 0),
1005
1055
  outputTokens: Number(row?.output ?? 0),
@@ -1014,7 +1064,7 @@ class DrizzleTokenUsageRepository {
1014
1064
  cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1015
1065
  })
1016
1066
  .from(tokenUsage)
1017
- .where(and(eq(tokenUsage.account_id, accountId), gte(tokenUsage.created_at, epochMs)));
1067
+ .where(and(eq(tokenUsage.account_id, accountId), gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1018
1068
  return {
1019
1069
  inputTokens: Number(row?.input ?? 0),
1020
1070
  outputTokens: Number(row?.output ?? 0),
@@ -1029,7 +1079,7 @@ class DrizzleTokenUsageRepository {
1029
1079
  cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1030
1080
  })
1031
1081
  .from(tokenUsage)
1032
- .where(and(eq(tokenUsage.user_id, userId), gte(tokenUsage.created_at, epochMs)));
1082
+ .where(and(eq(tokenUsage.user_id, userId), gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1033
1083
  return {
1034
1084
  inputTokens: Number(row?.input ?? 0),
1035
1085
  outputTokens: Number(row?.output ?? 0),
@@ -2801,7 +2851,7 @@ class DrizzleInitiativeRepository {
2801
2851
  .where(and(eq(initiatives.workspace_id, workspaceId), eq(initiatives.id, id)));
2802
2852
  }
2803
2853
  }
2804
- function rowToMergePreset(row) {
2854
+ function rowToRiskPolicy(row) {
2805
2855
  return {
2806
2856
  id: row.id,
2807
2857
  name: row.name,
@@ -2823,13 +2873,13 @@ function rowToMergePreset(row) {
2823
2873
  }
2824
2874
  /**
2825
2875
  * Per-workspace merge threshold presets over Postgres (the Drizzle mirror of the
2826
- * Worker's `D1MergePresetRepository`, migration 0024). Enforces the single-default
2876
+ * Worker's `D1RiskPolicyRepository`, migration 0024). Enforces the single-default
2827
2877
  * invariant: promoting a preset to default demotes every other in the workspace
2828
2878
  * before the upsert. The default preset cannot be removed (the service keeps that
2829
2879
  * rule too; the DELETE also guards `is_default = 0`). Behaviourally identical to the
2830
2880
  * D1 repo so the cross-runtime conformance suite asserts the same preset resolution.
2831
2881
  */
2832
- class DrizzleMergePresetRepository {
2882
+ class DrizzleRiskPolicyRepository {
2833
2883
  db;
2834
2884
  constructor(db) {
2835
2885
  this.db = db;
@@ -2837,27 +2887,27 @@ class DrizzleMergePresetRepository {
2837
2887
  async get(workspaceId, id) {
2838
2888
  const rows = await this.db
2839
2889
  .select()
2840
- .from(mergeThresholdPresets)
2841
- .where(and(eq(mergeThresholdPresets.workspace_id, workspaceId), eq(mergeThresholdPresets.id, id)))
2890
+ .from(riskPolicies)
2891
+ .where(and(eq(riskPolicies.workspace_id, workspaceId), eq(riskPolicies.id, id)))
2842
2892
  .limit(1);
2843
- return rows[0] ? rowToMergePreset(rows[0]) : null;
2893
+ return rows[0] ? rowToRiskPolicy(rows[0]) : null;
2844
2894
  }
2845
2895
  async list(workspaceId) {
2846
2896
  const rows = await this.db
2847
2897
  .select()
2848
- .from(mergeThresholdPresets)
2849
- .where(eq(mergeThresholdPresets.workspace_id, workspaceId))
2850
- .orderBy(mergeThresholdPresets.created_at);
2851
- return rows.map(rowToMergePreset);
2898
+ .from(riskPolicies)
2899
+ .where(eq(riskPolicies.workspace_id, workspaceId))
2900
+ .orderBy(riskPolicies.created_at);
2901
+ return rows.map(rowToRiskPolicy);
2852
2902
  }
2853
2903
  async getDefault(workspaceId) {
2854
2904
  const rows = await this.db
2855
2905
  .select()
2856
- .from(mergeThresholdPresets)
2857
- .where(and(eq(mergeThresholdPresets.workspace_id, workspaceId), eq(mergeThresholdPresets.is_default, 1)))
2858
- .orderBy(mergeThresholdPresets.created_at)
2906
+ .from(riskPolicies)
2907
+ .where(and(eq(riskPolicies.workspace_id, workspaceId), eq(riskPolicies.is_default, 1)))
2908
+ .orderBy(riskPolicies.created_at)
2859
2909
  .limit(1);
2860
- return rows[0] ? rowToMergePreset(rows[0]) : null;
2910
+ return rows[0] ? rowToRiskPolicy(rows[0]) : null;
2861
2911
  }
2862
2912
  async upsert(workspaceId, preset) {
2863
2913
  const values = {
@@ -2885,15 +2935,15 @@ class DrizzleMergePresetRepository {
2885
2935
  // Promoting this preset to default demotes any other default first.
2886
2936
  if (preset.isDefault) {
2887
2937
  await tx
2888
- .update(mergeThresholdPresets)
2938
+ .update(riskPolicies)
2889
2939
  .set({ is_default: 0 })
2890
- .where(and(eq(mergeThresholdPresets.workspace_id, workspaceId), sql `${mergeThresholdPresets.id} <> ${preset.id}`));
2940
+ .where(and(eq(riskPolicies.workspace_id, workspaceId), sql `${riskPolicies.id} <> ${preset.id}`));
2891
2941
  }
2892
2942
  await tx
2893
- .insert(mergeThresholdPresets)
2943
+ .insert(riskPolicies)
2894
2944
  .values(values)
2895
2945
  .onConflictDoUpdate({
2896
- target: [mergeThresholdPresets.workspace_id, mergeThresholdPresets.id],
2946
+ target: [riskPolicies.workspace_id, riskPolicies.id],
2897
2947
  set: {
2898
2948
  name: values.name,
2899
2949
  max_complexity: values.max_complexity,
@@ -2915,8 +2965,8 @@ class DrizzleMergePresetRepository {
2915
2965
  }
2916
2966
  async remove(workspaceId, id) {
2917
2967
  await this.db
2918
- .delete(mergeThresholdPresets)
2919
- .where(and(eq(mergeThresholdPresets.workspace_id, workspaceId), eq(mergeThresholdPresets.id, id), eq(mergeThresholdPresets.is_default, 0)));
2968
+ .delete(riskPolicies)
2969
+ .where(and(eq(riskPolicies.workspace_id, workspaceId), eq(riskPolicies.id, id), eq(riskPolicies.is_default, 0)));
2920
2970
  }
2921
2971
  }
2922
2972
  // Shape-guarded parsers matching the D1 mirror (`D1SharedStackRepository`) EXACTLY, so a
@@ -3929,7 +3979,7 @@ export function createDrizzleRepositories(db, clock) {
3929
3979
  clarityReviewRepository: new DrizzleClarityReviewRepository(db),
3930
3980
  brainstormSessionRepository: new DrizzleBrainstormSessionRepository(db),
3931
3981
  initiativeRepository: new DrizzleInitiativeRepository(db),
3932
- mergePresetRepository: new DrizzleMergePresetRepository(db),
3982
+ riskPolicyRepository: new DrizzleRiskPolicyRepository(db),
3933
3983
  sharedStackRepository: new DrizzleSharedStackRepository(db),
3934
3984
  workspaceSettingsRepository: new DrizzleWorkspaceSettingsRepository(db),
3935
3985
  userSettingsRepository: new DrizzleUserSettingsRepository(db),