@celilo/cli 0.25.1 → 0.26.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.
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { type Bus, describeError } from '@celilo/event-bus';
21
21
  import { inArray } from 'drizzle-orm';
22
+ import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
22
23
  import { getModuleStoragePath } from '../config/paths';
23
24
  import { type DbClient, findMigrationsFolder } from '../db/client';
24
25
  import { getMigrationStatus } from '../db/migration-status';
@@ -27,9 +28,8 @@ import { findSchemaDrift } from '../db/schema-introspection';
27
28
  import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
28
29
  import type { ModuleManifest } from '../manifest/schema';
29
30
 
30
- /** The module that IS celilo's control plane. */
31
- const CONTROL_PLANE_MODULE = 'celilo-mgmt';
32
- import { getModuleSystems } from './deployed-systems';
31
+ import { getServiceCredentials, listContainerServices } from './container-service';
32
+ import { getModuleSystems, listAllModuleSystems } from './deployed-systems';
33
33
  import { listDnsInternalRecords } from './dns-internal-records';
34
34
  import {
35
35
  SUPERVISOR_SCOPES,
@@ -38,9 +38,13 @@ import {
38
38
  readInstalledUnit,
39
39
  unitMainPid,
40
40
  } from './events-daemon';
41
+ import { probeMachines } from './machine-probe';
41
42
  import { describePausedModule, listPausedModules } from './module-pause';
42
43
  import { resolveSubscription } from './module-subscriptions';
43
44
 
45
+ /** The module that IS celilo's control plane. */
46
+ const CONTROL_PLANE_MODULE = 'celilo-mgmt';
47
+
44
48
  /**
45
49
  * Zones reachable from the operator's LAN. A celilo placement zone other
46
50
  * than `internal` is firewall-segmented — an unmanaged LAN device has no
@@ -786,6 +790,12 @@ export async function checkServiceDns(db: DbClient): Promise<FleetFinding> {
786
790
  export interface RunFleetChecksOptions {
787
791
  now?: number;
788
792
  installedCodeMtimeMs?: number | null;
793
+ /**
794
+ * Where the host-liveness verdict gets its facts. Injected so the check can
795
+ * be exercised without SSH or a Proxmox credential; defaults to the live
796
+ * fleet (`collectHostLiveness`).
797
+ */
798
+ hostLiveness?: () => Promise<HostLivenessInputs>;
789
799
  }
790
800
 
791
801
  /**
@@ -901,11 +911,272 @@ export function checkPausedModules(db: DbClient): FleetFinding {
901
911
  };
902
912
  }
903
913
 
914
+ /** One module deployment, and the host it landed on. */
915
+ export interface HostPlacement {
916
+ moduleId: string;
917
+ /** The host's user-facing name — a pool hostname, or the container's. */
918
+ hostname: string;
919
+ infraType: 'machine' | 'container_service';
920
+ /** Proxmox VMID for a celilo-provisioned container; null for a pool machine. */
921
+ vmid: number | null;
922
+ }
923
+
924
+ /**
925
+ * Everything the liveness verdict is computed from, injected so the check is a
926
+ * pure function over data and needs neither SSH nor a Proxmox credential to
927
+ * test.
928
+ *
929
+ * ⚠️ Every source here is ALLOWED TO BE ABSENT, and absent is not "fine".
930
+ * A machine missing from `machines` was not probed; a node missing from `nodes`
931
+ * was not reported. Neither means the host is up, and neither means it is down.
932
+ * Conflating "I could not look" with "I looked and it was healthy" is the
933
+ * failure this whole check exists to end — doctor said OK-with-warnings while a
934
+ * node hosting two modules was offline.
935
+ */
936
+ export interface HostLivenessInputs {
937
+ placements: HostPlacement[];
938
+ /** Machine-pool SSH probe results. A hostname absent here was NOT probed. */
939
+ machines: Array<{ hostname: string; reachable: boolean }>;
940
+ /** Proxmox node status. Empty when no container service is configured. */
941
+ nodes: Array<{ node: string; online: boolean }>;
942
+ /** VMID → node name, from `/cluster/resources`. Empty when unqueried. */
943
+ guestNodes: Array<{ vmid: number; node: string }>;
944
+ }
945
+
946
+ type HostState = 'up' | 'down' | 'unknown';
947
+
948
+ interface HostVerdict {
949
+ host: string;
950
+ state: HostState;
951
+ /** Why the state could not be determined. Set only when state is 'unknown'. */
952
+ reason?: string;
953
+ }
954
+
955
+ function resolveHostState(placement: HostPlacement, inputs: HostLivenessInputs): HostVerdict {
956
+ if (placement.infraType === 'machine') {
957
+ const probe = inputs.machines.find((m) => m.hostname === placement.hostname);
958
+ if (!probe) {
959
+ return {
960
+ host: placement.hostname,
961
+ state: 'unknown',
962
+ // Either the SSH probe did not run at all, or this hostname is no
963
+ // longer in the machine pool — a stale `module_systems` row, which is
964
+ // its own defect and worth surfacing rather than rounding off.
965
+ reason: 'no probe result for this machine',
966
+ };
967
+ }
968
+ return { host: placement.hostname, state: probe.reachable ? 'up' : 'down' };
969
+ }
970
+
971
+ // A container's liveness is its NODE's liveness. The guest being stopped is a
972
+ // different condition with a different owner (`module pause --stop-infra`
973
+ // stops guests deliberately), so this deliberately reads the node only.
974
+ if (placement.vmid === null) {
975
+ return {
976
+ host: placement.hostname,
977
+ state: 'unknown',
978
+ reason: 'no VMID recorded — celilo has no liveness source for this provider',
979
+ };
980
+ }
981
+ const guest = inputs.guestNodes.find((g) => g.vmid === placement.vmid);
982
+ if (!guest) {
983
+ return {
984
+ host: placement.hostname,
985
+ state: 'unknown',
986
+ reason: `VMID ${placement.vmid} not present in the cluster's resources`,
987
+ };
988
+ }
989
+ const node = inputs.nodes.find((n) => n.node === guest.node);
990
+ if (!node) {
991
+ return { host: guest.node, state: 'unknown', reason: 'the cluster reported no such node' };
992
+ }
993
+ return { host: guest.node, state: node.online ? 'up' : 'down' };
994
+ }
995
+
996
+ /**
997
+ * Are the hosts this fleet's modules actually run on alive? (celilo#728)
998
+ *
999
+ * Every other `Fleet runtime` check is a control-plane concern — the
1000
+ * dispatcher, bus subscribers, capability-derived config, internal DNS. None of
1001
+ * them asked the most basic data-plane question, so `system doctor` reported
1002
+ * "OK with warnings" while a Proxmox node was OFFLINE with `celilo-apt-repo`
1003
+ * and `lunacycle` on it. The information was already in `proxmox node list`;
1004
+ * doctor simply never consulted it. It surfaced only because a release run got
1005
+ * an HTTP 502 from the apt repo that happened to live there — had nothing tried
1006
+ * to publish, the node could have stayed down indefinitely.
1007
+ *
1008
+ * A down host is a FAILURE, not a warning: it is strictly worse than the
1009
+ * conditions already reported as failures here, and every module on it is down
1010
+ * with it.
1011
+ *
1012
+ * A host celilo TRIED to verify and could not is a WARNING, and the reason is
1013
+ * named per host. This is not the same as "quiet because it might be fine": a
1014
+ * cluster that will not answer its own API is not obviously healthier than one
1015
+ * reporting a node offline, and the failure to answer may BE the outage this
1016
+ * check exists to catch. Reporting it as ok-with-a-note would rebuild the
1017
+ * defect one level down — a report reading healthy over something unmeasured.
1018
+ *
1019
+ * ⚠️ The thing that makes the warning safe to have is that it is not
1020
+ * permanent. A machine-only fleet produces NO unverified hosts at all: every
1021
+ * placement takes the probe path and resolves. The one standing source would be
1022
+ * a provider celilo cannot interrogate — today a DigitalOcean droplet, whose
1023
+ * client can verify the token but never reads droplet status. That is a gap to
1024
+ * close (its own change), not a reason to soften the signal here. A warning
1025
+ * that fires forever is what trains an operator to skim the whole report
1026
+ * (celilo#723, whose false positive was competing for attention in the very
1027
+ * output that missed the offline node) — so if this one ever becomes standing,
1028
+ * the fix is to teach celilo the missing provider, not to quieten it.
1029
+ */
1030
+ export function checkHostLiveness(inputs: HostLivenessInputs): FleetFinding {
1031
+ const base = {
1032
+ id: 'host-liveness',
1033
+ title: 'The hosts running deployed modules are alive',
1034
+ autoFixable: false,
1035
+ } as const;
1036
+
1037
+ if (inputs.placements.length === 0) {
1038
+ return {
1039
+ ...base,
1040
+ status: 'ok',
1041
+ summary: 'no modules are deployed to a host yet',
1042
+ detail: [],
1043
+ remediation: null,
1044
+ };
1045
+ }
1046
+
1047
+ interface HostEntry {
1048
+ state: HostState;
1049
+ modules: Set<string>;
1050
+ reason?: string;
1051
+ }
1052
+
1053
+ const modulesByHost = new Map<string, HostEntry>();
1054
+ for (const placement of inputs.placements) {
1055
+ const { host, state, reason } = resolveHostState(placement, inputs);
1056
+ const entry = modulesByHost.get(host) ?? { state, modules: new Set<string>(), reason };
1057
+ // A host resolved 'down' by any placement stays down — one authoritative
1058
+ // negative outranks an unknown from a sibling placement.
1059
+ if (state === 'down' || entry.state === 'unknown') {
1060
+ entry.state = state;
1061
+ entry.reason = reason;
1062
+ }
1063
+ entry.modules.add(placement.moduleId);
1064
+ modulesByHost.set(host, entry);
1065
+ }
1066
+
1067
+ const describe = (host: string, e: HostEntry) => `${host}: ${[...e.modules].sort().join(', ')}`;
1068
+ const describeUnverified = (host: string, e: HostEntry) =>
1069
+ `unverified — ${describe(host, e)}${e.reason ? ` (${e.reason})` : ''}`;
1070
+
1071
+ const down = [...modulesByHost].filter(([, e]) => e.state === 'down');
1072
+ const unknown = [...modulesByHost].filter(([, e]) => e.state === 'unknown');
1073
+ const up = [...modulesByHost].filter(([, e]) => e.state === 'up');
1074
+
1075
+ if (down.length > 0) {
1076
+ const affected = down.reduce((n, [, e]) => n + e.modules.size, 0);
1077
+ return {
1078
+ ...base,
1079
+ status: 'fail',
1080
+ summary: `${down.length} host(s) down, ${affected} module(s) unreachable: ${down
1081
+ .map(([host]) => host)
1082
+ .join(', ')}`,
1083
+ detail: [
1084
+ ...down.map(([host, e]) => `DOWN ${describe(host, e)}`),
1085
+ ...unknown.map(([host, e]) => describeUnverified(host, e)),
1086
+ 'every module listed against a down host is down with it, whatever its own status says',
1087
+ ],
1088
+ remediation:
1089
+ 'bring the host back, then confirm with "celilo proxmox node list" (container services) or "celilo machine status <hostname>" (pool machines)',
1090
+ };
1091
+ }
1092
+
1093
+ if (unknown.length > 0) {
1094
+ // WARN, not ok-with-a-note. celilo tried and could not find out, and the
1095
+ // reason it could not may be the outage itself — a cluster that will not
1096
+ // answer its own API is not evidence of health. Reporting this quietly
1097
+ // would rebuild #728 one level down.
1098
+ const affected = unknown.reduce((n, [, e]) => n + e.modules.size, 0);
1099
+ return {
1100
+ ...base,
1101
+ status: 'warn',
1102
+ summary: `${up.length} host(s) up, ${unknown.length} could not be verified (${affected} module(s))`,
1103
+ // Named and reasoned, never counted: "1 not verified" tells an operator
1104
+ // neither which host nor what to do about it.
1105
+ detail: unknown.map(([host, e]) => describeUnverified(host, e)),
1106
+ remediation:
1107
+ 'check the host directly — "celilo proxmox node list" for a container service, "celilo machine status <hostname>" for a pool machine; a host celilo cannot reach is not a host known to be healthy',
1108
+ };
1109
+ }
1110
+
1111
+ return {
1112
+ ...base,
1113
+ status: 'ok',
1114
+ summary: `${up.length} host(s) up`,
1115
+ detail: [],
1116
+ remediation: null,
1117
+ };
1118
+ }
1119
+
1120
+ /**
1121
+ * Read the liveness facts off the live fleet.
1122
+ *
1123
+ * Every source degrades to ABSENT rather than to a cheerful default. A Proxmox
1124
+ * cluster that cannot be reached, or a fleet with no container service at all,
1125
+ * contributes no node rows — and `checkHostLiveness` reads that as unverified,
1126
+ * never as healthy. That distinction is the whole point of the check.
1127
+ */
1128
+ export async function collectHostLiveness(db: DbClient): Promise<HostLivenessInputs> {
1129
+ const placements: HostPlacement[] = listAllModuleSystems(db).map((s) => ({
1130
+ moduleId: s.moduleId,
1131
+ hostname: s.hostname,
1132
+ infraType: s.infraType,
1133
+ vmid: s.vmid ?? null,
1134
+ }));
1135
+
1136
+ // Nothing deployed — skip the probes entirely rather than SSH a fleet of none.
1137
+ if (placements.length === 0) {
1138
+ return { placements, machines: [], nodes: [], guestNodes: [] };
1139
+ }
1140
+
1141
+ let machines: HostLivenessInputs['machines'] = [];
1142
+ try {
1143
+ machines = (await probeMachines()).map((m) => ({
1144
+ hostname: m.hostname,
1145
+ reachable: m.reachable,
1146
+ }));
1147
+ } catch {
1148
+ // Leave it empty: unprobed, which reports as unverified rather than up.
1149
+ }
1150
+
1151
+ const nodes: HostLivenessInputs['nodes'] = [];
1152
+ const guestNodes: HostLivenessInputs['guestNodes'] = [];
1153
+ try {
1154
+ for (const service of await listContainerServices()) {
1155
+ if (service.providerName !== 'proxmox') continue;
1156
+ const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
1157
+ const result = await new ProxmoxClient(creds).clusterResources();
1158
+ if (!result.success) continue;
1159
+ for (const row of result.data) {
1160
+ if (row.type === 'node' && row.node) {
1161
+ nodes.push({ node: row.node, online: row.status === 'online' });
1162
+ } else if (typeof row.vmid === 'number' && row.node) {
1163
+ guestNodes.push({ vmid: row.vmid, node: row.node });
1164
+ }
1165
+ }
1166
+ }
1167
+ } catch {
1168
+ // Same rule: unreachable is unverified, not healthy.
1169
+ }
1170
+
1171
+ return { placements, machines, nodes, guestNodes };
1172
+ }
1173
+
904
1174
  export async function runFleetChecks(
905
1175
  bus: Bus,
906
1176
  db: DbClient,
907
1177
  opts: RunFleetChecksOptions = {},
908
1178
  ): Promise<FleetFinding[]> {
1179
+ const hostLiveness = opts.hostLiveness ?? (() => collectHostLiveness(db));
909
1180
  return [
910
1181
  checkSchemaDrift(db),
911
1182
  checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
@@ -913,6 +1184,7 @@ export async function runFleetChecks(
913
1184
  checkCapabilityProviders(db),
914
1185
  checkControlPlaneNetwork(db),
915
1186
  checkPausedModules(db),
1187
+ checkHostLiveness(await hostLiveness()),
916
1188
  await checkServiceDns(db),
917
1189
  ];
918
1190
  }
@@ -21,8 +21,8 @@ describe('local machine reachability', () => {
21
21
  test('a reachable local box produces no finding', async () => {
22
22
  const findings = await auditMachinesReachable({
23
23
  results: [
24
- { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
25
- { id: 'briq', hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
24
+ { hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
25
+ { hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
26
26
  ],
27
27
  });
28
28
 
@@ -33,9 +33,8 @@ describe('local machine reachability', () => {
33
33
  // The skip must not blunt the check for the machines it exists to watch.
34
34
  const findings = await auditMachinesReachable({
35
35
  results: [
36
- { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
36
+ { hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
37
37
  {
38
- id: 'briq',
39
38
  hostname: 'briq',
40
39
  ipAddress: '192.168.0.254',
41
40
  reachable: false,
@@ -38,7 +38,7 @@ export async function probeMachines(): Promise<MachineReachableResult[]> {
38
38
  return Promise.all(
39
39
  machines.map(async (m): Promise<MachineReachableResult> => {
40
40
  if (m.ipAddress === LOCAL_MACHINE_IP) {
41
- return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
41
+ return { hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
42
42
  }
43
43
  try {
44
44
  await execFileAsync(
@@ -57,11 +57,10 @@ export async function probeMachines(): Promise<MachineReachableResult[]> {
57
57
  ],
58
58
  { timeout: 8000 },
59
59
  );
60
- return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
60
+ return { hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
61
61
  } catch (err) {
62
62
  const e = err as { stderr?: string; message?: string };
63
63
  return {
64
- id: m.id,
65
64
  hostname: m.hostname,
66
65
  ipAddress: m.ipAddress,
67
66
  reachable: false,
@@ -71,6 +71,70 @@ describe('module-operations', () => {
71
71
  });
72
72
  });
73
73
 
74
+ /**
75
+ * celilo#737. Recording an outcome is BOOKKEEPING; the caller's error is the
76
+ * information. On celilo-mgr a `SQLITE_BUSY` inside `failOperation` propagated
77
+ * out of the catch block that called it and REPLACED the deploy's own error,
78
+ * so the operator was shown a database-locking problem and never learned what
79
+ * the deploy did wrong — the original was destroyed and is unrecoverable.
80
+ *
81
+ * `breakOperationsTable` stands in for any write failure. The mechanism does
82
+ * not matter; what matters is that no failure of the write can reach the
83
+ * caller.
84
+ */
85
+ describe('recording an outcome cannot destroy what it records (#737)', () => {
86
+ function breakOperationsTable(): void {
87
+ const { getDb } = require('../db/client');
88
+ getDb().$client.run('DROP TABLE module_operations');
89
+ }
90
+
91
+ it('failOperation does not replace the error it was called to record', () => {
92
+ const id = startOperation('caddy', 'deploy');
93
+ breakOperationsTable();
94
+
95
+ const original = new Error('ansible task failed on step 7');
96
+ let surfaced: unknown;
97
+
98
+ // Exactly the shape every call site uses (module-deploy.ts,
99
+ // module-remove.ts): record the failure, then rethrow the original.
100
+ try {
101
+ try {
102
+ throw original;
103
+ } catch (err) {
104
+ failOperation(id, err);
105
+ throw err;
106
+ }
107
+ } catch (err) {
108
+ surfaced = err;
109
+ }
110
+
111
+ expect(surfaced).toBe(original);
112
+ });
113
+
114
+ it('failOperation does not throw when the write fails', () => {
115
+ const id = startOperation('caddy', 'deploy');
116
+ breakOperationsTable();
117
+ expect(() => failOperation(id, new Error('original'))).not.toThrow();
118
+ });
119
+
120
+ it('completeOperation does not throw when the write fails', () => {
121
+ // The mirror bug: a deploy that SUCCEEDED reporting a database error, and
122
+ // skipping the `emitDeployCompleted` that follows the call.
123
+ const id = startOperation('caddy', 'deploy');
124
+ breakOperationsTable();
125
+ expect(() => completeOperation(id)).not.toThrow();
126
+ });
127
+
128
+ it('startOperation still throws — its row IS the in-flight lock', () => {
129
+ // Deliberately NOT swallowed. `checkInFlight` reads this row to refuse a
130
+ // backup during a deploy, so a silently-missing row would let the two run
131
+ // together. Failing before any work happens is honest; failing after it
132
+ // is what #737 is about.
133
+ breakOperationsTable();
134
+ expect(() => startOperation('caddy', 'deploy')).toThrow();
135
+ });
136
+ });
137
+
74
138
  describe('checkInFlight', () => {
75
139
  it('returns empty when no operations are in flight', () => {
76
140
  expect(checkInFlight()).toHaveLength(0);
@@ -96,7 +160,14 @@ describe('module-operations', () => {
96
160
 
97
161
  it('ignores rows whose pid is no longer alive', () => {
98
162
  // Spawn a short-lived process, capture its pid, wait for it to exit.
99
- const child = spawnSync('node', ['-e', 'process.exit(0)']);
163
+ //
164
+ // `process.execPath` (the bun binary running this suite), NOT a bare
165
+ // `node`: this repo is bun-based and nothing guarantees a node on PATH.
166
+ // Where there was none, spawnSync returned `pid: undefined` and the
167
+ // assertion below failed with "Expected and actual values must be numbers
168
+ // or bigints" — which reads as a broken pid check rather than a missing
169
+ // interpreter.
170
+ const child = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
100
171
  const deadPid = child.pid;
101
172
  expect(deadPid).toBeGreaterThan(0);
102
173
  expect(isPidRunnable(deadPid)).toBe(false);
@@ -33,6 +33,7 @@
33
33
  import { spawnSync } from 'node:child_process';
34
34
  import { randomUUID } from 'node:crypto';
35
35
  import { eq } from 'drizzle-orm';
36
+ import { log } from '../cli/prompts';
36
37
  import { getDb } from '../db/client';
37
38
  import { type ModuleOperation, type ModuleOperationKind, moduleOperations } from '../db/schema';
38
39
 
@@ -55,21 +56,59 @@ export function startOperation(moduleId: string, operation: ModuleOperationKind)
55
56
  return id;
56
57
  }
57
58
 
59
+ /**
60
+ * Write an operation's outcome without ever being able to break the flow that
61
+ * is reporting it (celilo#737).
62
+ *
63
+ * Recording an outcome is BOOKKEEPING; the caller's error is the information.
64
+ * On celilo-mgr a `SQLITE_BUSY` inside `failOperation` propagated out of the
65
+ * catch block that called it and REPLACED the deploy's own error, so the
66
+ * operator was shown a database-locking problem and never learned what the
67
+ * deploy actually did wrong. That error was destroyed and is unrecoverable —
68
+ * the whole cost of the bug. It also skipped the `emitDeployFailed` that
69
+ * follows the call, so the event bus never learned the deploy had failed at
70
+ * all, and the module was left `INSTALLED` while in fact verified.
71
+ *
72
+ * `getDb()` is inside the try on purpose: opening the database is one of the
73
+ * things that can throw here.
74
+ *
75
+ * ⚠️ `startOperation` deliberately does NOT get this treatment. Its row IS the
76
+ * in-flight lock `checkInFlight` reads to refuse a backup during a deploy, so a
77
+ * silently-missing row would let the two run together against the same module.
78
+ * Failing before any work happens is honest; failing after it is what #737 is
79
+ * about.
80
+ */
81
+ function recordOutcome(outcome: 'completed' | 'failed', operationId: string, write: () => void) {
82
+ try {
83
+ write();
84
+ } catch (persistError) {
85
+ // Rule 6.2: never a bare catch. Secondary to whatever the caller is already
86
+ // reporting, so it is a warning rather than the headline — the caller's own
87
+ // error is what the operator needs to read.
88
+ const reason = persistError instanceof Error ? persistError.message : String(persistError);
89
+ log.warn(`Could not record operation ${operationId} as ${outcome}: ${reason}`);
90
+ }
91
+ }
92
+
58
93
  export function completeOperation(operationId: string): void {
59
- const db = getDb();
60
- db.update(moduleOperations)
61
- .set({ status: 'completed', completedAt: new Date() })
62
- .where(eq(moduleOperations.id, operationId))
63
- .run();
94
+ recordOutcome('completed', operationId, () => {
95
+ getDb()
96
+ .update(moduleOperations)
97
+ .set({ status: 'completed', completedAt: new Date() })
98
+ .where(eq(moduleOperations.id, operationId))
99
+ .run();
100
+ });
64
101
  }
65
102
 
66
103
  export function failOperation(operationId: string, error: unknown): void {
67
- const db = getDb();
68
104
  const message = error instanceof Error ? error.message : String(error);
69
- db.update(moduleOperations)
70
- .set({ status: 'failed', completedAt: new Date(), errorMessage: message })
71
- .where(eq(moduleOperations.id, operationId))
72
- .run();
105
+ recordOutcome('failed', operationId, () => {
106
+ getDb()
107
+ .update(moduleOperations)
108
+ .set({ status: 'failed', completedAt: new Date(), errorMessage: message })
109
+ .where(eq(moduleOperations.id, operationId))
110
+ .run();
111
+ });
73
112
  }
74
113
 
75
114
  /**