@synth-deploy/server 0.1.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.
Files changed (317) hide show
  1. package/dist/agent/debrief-retention.d.ts +12 -0
  2. package/dist/agent/debrief-retention.d.ts.map +1 -0
  3. package/dist/agent/debrief-retention.js +27 -0
  4. package/dist/agent/debrief-retention.js.map +1 -0
  5. package/dist/agent/envoy-client.d.ts +216 -0
  6. package/dist/agent/envoy-client.d.ts.map +1 -0
  7. package/dist/agent/envoy-client.js +266 -0
  8. package/dist/agent/envoy-client.js.map +1 -0
  9. package/dist/agent/envoy-registry.d.ts +102 -0
  10. package/dist/agent/envoy-registry.d.ts.map +1 -0
  11. package/dist/agent/envoy-registry.js +319 -0
  12. package/dist/agent/envoy-registry.js.map +1 -0
  13. package/dist/agent/health-checker.d.ts +39 -0
  14. package/dist/agent/health-checker.d.ts.map +1 -0
  15. package/dist/agent/health-checker.js +49 -0
  16. package/dist/agent/health-checker.js.map +1 -0
  17. package/dist/agent/mcp-client-manager.d.ts +36 -0
  18. package/dist/agent/mcp-client-manager.d.ts.map +1 -0
  19. package/dist/agent/mcp-client-manager.js +106 -0
  20. package/dist/agent/mcp-client-manager.js.map +1 -0
  21. package/dist/agent/stale-deployment-detector.d.ts +15 -0
  22. package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
  23. package/dist/agent/stale-deployment-detector.js +50 -0
  24. package/dist/agent/stale-deployment-detector.js.map +1 -0
  25. package/dist/agent/step-runner.d.ts +31 -0
  26. package/dist/agent/step-runner.d.ts.map +1 -0
  27. package/dist/agent/step-runner.js +80 -0
  28. package/dist/agent/step-runner.js.map +1 -0
  29. package/dist/agent/synth-agent.d.ts +168 -0
  30. package/dist/agent/synth-agent.d.ts.map +1 -0
  31. package/dist/agent/synth-agent.js +1195 -0
  32. package/dist/agent/synth-agent.js.map +1 -0
  33. package/dist/api/agent.d.ts +36 -0
  34. package/dist/api/agent.d.ts.map +1 -0
  35. package/dist/api/agent.js +867 -0
  36. package/dist/api/agent.js.map +1 -0
  37. package/dist/api/api-keys.d.ts +4 -0
  38. package/dist/api/api-keys.d.ts.map +1 -0
  39. package/dist/api/api-keys.js +118 -0
  40. package/dist/api/api-keys.js.map +1 -0
  41. package/dist/api/artifacts.d.ts +5 -0
  42. package/dist/api/artifacts.d.ts.map +1 -0
  43. package/dist/api/artifacts.js +142 -0
  44. package/dist/api/artifacts.js.map +1 -0
  45. package/dist/api/auth.d.ts +4 -0
  46. package/dist/api/auth.d.ts.map +1 -0
  47. package/dist/api/auth.js +280 -0
  48. package/dist/api/auth.js.map +1 -0
  49. package/dist/api/deployments.d.ts +11 -0
  50. package/dist/api/deployments.d.ts.map +1 -0
  51. package/dist/api/deployments.js +1098 -0
  52. package/dist/api/deployments.js.map +1 -0
  53. package/dist/api/environments.d.ts +5 -0
  54. package/dist/api/environments.d.ts.map +1 -0
  55. package/dist/api/environments.js +69 -0
  56. package/dist/api/environments.js.map +1 -0
  57. package/dist/api/envoy-reports.d.ts +17 -0
  58. package/dist/api/envoy-reports.d.ts.map +1 -0
  59. package/dist/api/envoy-reports.js +138 -0
  60. package/dist/api/envoy-reports.js.map +1 -0
  61. package/dist/api/envoys.d.ts +5 -0
  62. package/dist/api/envoys.d.ts.map +1 -0
  63. package/dist/api/envoys.js +192 -0
  64. package/dist/api/envoys.js.map +1 -0
  65. package/dist/api/fleet.d.ts +11 -0
  66. package/dist/api/fleet.d.ts.map +1 -0
  67. package/dist/api/fleet.js +394 -0
  68. package/dist/api/fleet.js.map +1 -0
  69. package/dist/api/graph.d.ts +8 -0
  70. package/dist/api/graph.d.ts.map +1 -0
  71. package/dist/api/graph.js +355 -0
  72. package/dist/api/graph.js.map +1 -0
  73. package/dist/api/health.d.ts +20 -0
  74. package/dist/api/health.d.ts.map +1 -0
  75. package/dist/api/health.js +248 -0
  76. package/dist/api/health.js.map +1 -0
  77. package/dist/api/idp-schemas.d.ts +41 -0
  78. package/dist/api/idp-schemas.d.ts.map +1 -0
  79. package/dist/api/idp-schemas.js +17 -0
  80. package/dist/api/idp-schemas.js.map +1 -0
  81. package/dist/api/idp.d.ts +6 -0
  82. package/dist/api/idp.d.ts.map +1 -0
  83. package/dist/api/idp.js +620 -0
  84. package/dist/api/idp.js.map +1 -0
  85. package/dist/api/intake.d.ts +10 -0
  86. package/dist/api/intake.d.ts.map +1 -0
  87. package/dist/api/intake.js +418 -0
  88. package/dist/api/intake.js.map +1 -0
  89. package/dist/api/partitions.d.ts +5 -0
  90. package/dist/api/partitions.d.ts.map +1 -0
  91. package/dist/api/partitions.js +113 -0
  92. package/dist/api/partitions.js.map +1 -0
  93. package/dist/api/progress-event-store.d.ts +62 -0
  94. package/dist/api/progress-event-store.d.ts.map +1 -0
  95. package/dist/api/progress-event-store.js +118 -0
  96. package/dist/api/progress-event-store.js.map +1 -0
  97. package/dist/api/schemas.d.ts +1000 -0
  98. package/dist/api/schemas.d.ts.map +1 -0
  99. package/dist/api/schemas.js +328 -0
  100. package/dist/api/schemas.js.map +1 -0
  101. package/dist/api/security-boundaries.d.ts +4 -0
  102. package/dist/api/security-boundaries.d.ts.map +1 -0
  103. package/dist/api/security-boundaries.js +32 -0
  104. package/dist/api/security-boundaries.js.map +1 -0
  105. package/dist/api/settings.d.ts +4 -0
  106. package/dist/api/settings.d.ts.map +1 -0
  107. package/dist/api/settings.js +99 -0
  108. package/dist/api/settings.js.map +1 -0
  109. package/dist/api/system.d.ts +75 -0
  110. package/dist/api/system.d.ts.map +1 -0
  111. package/dist/api/system.js +558 -0
  112. package/dist/api/system.js.map +1 -0
  113. package/dist/api/telemetry.d.ts +4 -0
  114. package/dist/api/telemetry.d.ts.map +1 -0
  115. package/dist/api/telemetry.js +24 -0
  116. package/dist/api/telemetry.js.map +1 -0
  117. package/dist/api/users.d.ts +4 -0
  118. package/dist/api/users.d.ts.map +1 -0
  119. package/dist/api/users.js +173 -0
  120. package/dist/api/users.js.map +1 -0
  121. package/dist/archive-unpacker.d.ts +24 -0
  122. package/dist/archive-unpacker.d.ts.map +1 -0
  123. package/dist/archive-unpacker.js +239 -0
  124. package/dist/archive-unpacker.js.map +1 -0
  125. package/dist/artifact-analyzer.d.ts +59 -0
  126. package/dist/artifact-analyzer.d.ts.map +1 -0
  127. package/dist/artifact-analyzer.js +334 -0
  128. package/dist/artifact-analyzer.js.map +1 -0
  129. package/dist/auth/idp/index.d.ts +9 -0
  130. package/dist/auth/idp/index.d.ts.map +1 -0
  131. package/dist/auth/idp/index.js +5 -0
  132. package/dist/auth/idp/index.js.map +1 -0
  133. package/dist/auth/idp/ldap.d.ts +56 -0
  134. package/dist/auth/idp/ldap.d.ts.map +1 -0
  135. package/dist/auth/idp/ldap.js +276 -0
  136. package/dist/auth/idp/ldap.js.map +1 -0
  137. package/dist/auth/idp/oidc.d.ts +27 -0
  138. package/dist/auth/idp/oidc.d.ts.map +1 -0
  139. package/dist/auth/idp/oidc.js +97 -0
  140. package/dist/auth/idp/oidc.js.map +1 -0
  141. package/dist/auth/idp/role-mapping.d.ts +9 -0
  142. package/dist/auth/idp/role-mapping.d.ts.map +1 -0
  143. package/dist/auth/idp/role-mapping.js +16 -0
  144. package/dist/auth/idp/role-mapping.js.map +1 -0
  145. package/dist/auth/idp/saml.d.ts +40 -0
  146. package/dist/auth/idp/saml.d.ts.map +1 -0
  147. package/dist/auth/idp/saml.js +117 -0
  148. package/dist/auth/idp/saml.js.map +1 -0
  149. package/dist/auth/idp/types.d.ts +23 -0
  150. package/dist/auth/idp/types.d.ts.map +1 -0
  151. package/dist/auth/idp/types.js +2 -0
  152. package/dist/auth/idp/types.js.map +1 -0
  153. package/dist/fleet/fleet-executor.d.ts +35 -0
  154. package/dist/fleet/fleet-executor.d.ts.map +1 -0
  155. package/dist/fleet/fleet-executor.js +228 -0
  156. package/dist/fleet/fleet-executor.js.map +1 -0
  157. package/dist/fleet/fleet-store.d.ts +13 -0
  158. package/dist/fleet/fleet-store.d.ts.map +1 -0
  159. package/dist/fleet/fleet-store.js +13 -0
  160. package/dist/fleet/fleet-store.js.map +1 -0
  161. package/dist/fleet/index.d.ts +5 -0
  162. package/dist/fleet/index.d.ts.map +1 -0
  163. package/dist/fleet/index.js +4 -0
  164. package/dist/fleet/index.js.map +1 -0
  165. package/dist/fleet/representative-selector.d.ts +15 -0
  166. package/dist/fleet/representative-selector.d.ts.map +1 -0
  167. package/dist/fleet/representative-selector.js +71 -0
  168. package/dist/fleet/representative-selector.js.map +1 -0
  169. package/dist/graph/graph-executor.d.ts +36 -0
  170. package/dist/graph/graph-executor.d.ts.map +1 -0
  171. package/dist/graph/graph-executor.js +348 -0
  172. package/dist/graph/graph-executor.js.map +1 -0
  173. package/dist/graph/graph-inference.d.ts +22 -0
  174. package/dist/graph/graph-inference.d.ts.map +1 -0
  175. package/dist/graph/graph-inference.js +149 -0
  176. package/dist/graph/graph-inference.js.map +1 -0
  177. package/dist/graph/graph-store.d.ts +12 -0
  178. package/dist/graph/graph-store.d.ts.map +1 -0
  179. package/dist/graph/graph-store.js +61 -0
  180. package/dist/graph/graph-store.js.map +1 -0
  181. package/dist/graph/index.d.ts +5 -0
  182. package/dist/graph/index.d.ts.map +1 -0
  183. package/dist/graph/index.js +4 -0
  184. package/dist/graph/index.js.map +1 -0
  185. package/dist/index.d.ts +2 -0
  186. package/dist/index.d.ts.map +1 -0
  187. package/dist/index.js +837 -0
  188. package/dist/index.js.map +1 -0
  189. package/dist/intake/index.d.ts +6 -0
  190. package/dist/intake/index.d.ts.map +1 -0
  191. package/dist/intake/index.js +5 -0
  192. package/dist/intake/index.js.map +1 -0
  193. package/dist/intake/intake-processor.d.ts +17 -0
  194. package/dist/intake/intake-processor.d.ts.map +1 -0
  195. package/dist/intake/intake-processor.js +99 -0
  196. package/dist/intake/intake-processor.js.map +1 -0
  197. package/dist/intake/intake-store.d.ts +7 -0
  198. package/dist/intake/intake-store.d.ts.map +1 -0
  199. package/dist/intake/intake-store.js +7 -0
  200. package/dist/intake/intake-store.js.map +1 -0
  201. package/dist/intake/registry-poller.d.ts +41 -0
  202. package/dist/intake/registry-poller.d.ts.map +1 -0
  203. package/dist/intake/registry-poller.js +202 -0
  204. package/dist/intake/registry-poller.js.map +1 -0
  205. package/dist/intake/webhook-handlers.d.ts +37 -0
  206. package/dist/intake/webhook-handlers.d.ts.map +1 -0
  207. package/dist/intake/webhook-handlers.js +268 -0
  208. package/dist/intake/webhook-handlers.js.map +1 -0
  209. package/dist/logger.d.ts +5 -0
  210. package/dist/logger.d.ts.map +1 -0
  211. package/dist/logger.js +15 -0
  212. package/dist/logger.js.map +1 -0
  213. package/dist/mcp/resources.d.ts +9 -0
  214. package/dist/mcp/resources.d.ts.map +1 -0
  215. package/dist/mcp/resources.js +72 -0
  216. package/dist/mcp/resources.js.map +1 -0
  217. package/dist/mcp/server.d.ts +15 -0
  218. package/dist/mcp/server.d.ts.map +1 -0
  219. package/dist/mcp/server.js +20 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/mcp/tools.d.ts +9 -0
  222. package/dist/mcp/tools.d.ts.map +1 -0
  223. package/dist/mcp/tools.js +88 -0
  224. package/dist/mcp/tools.js.map +1 -0
  225. package/dist/middleware/auth.d.ts +29 -0
  226. package/dist/middleware/auth.d.ts.map +1 -0
  227. package/dist/middleware/auth.js +76 -0
  228. package/dist/middleware/auth.js.map +1 -0
  229. package/dist/middleware/permissions.d.ts +13 -0
  230. package/dist/middleware/permissions.d.ts.map +1 -0
  231. package/dist/middleware/permissions.js +32 -0
  232. package/dist/middleware/permissions.js.map +1 -0
  233. package/dist/pattern-store.d.ts +104 -0
  234. package/dist/pattern-store.d.ts.map +1 -0
  235. package/dist/pattern-store.js +299 -0
  236. package/dist/pattern-store.js.map +1 -0
  237. package/package.json +54 -0
  238. package/src/agent/debrief-retention.ts +44 -0
  239. package/src/agent/envoy-client.ts +474 -0
  240. package/src/agent/envoy-registry.ts +384 -0
  241. package/src/agent/health-checker.ts +70 -0
  242. package/src/agent/mcp-client-manager.ts +131 -0
  243. package/src/agent/stale-deployment-detector.ts +79 -0
  244. package/src/agent/step-runner.ts +124 -0
  245. package/src/agent/synth-agent.ts +1567 -0
  246. package/src/api/agent.ts +1075 -0
  247. package/src/api/api-keys.ts +129 -0
  248. package/src/api/artifacts.ts +194 -0
  249. package/src/api/auth.ts +320 -0
  250. package/src/api/deployments.ts +1347 -0
  251. package/src/api/environments.ts +97 -0
  252. package/src/api/envoy-reports.ts +159 -0
  253. package/src/api/envoys.ts +237 -0
  254. package/src/api/fleet.ts +510 -0
  255. package/src/api/graph.ts +516 -0
  256. package/src/api/health.ts +311 -0
  257. package/src/api/idp-schemas.ts +19 -0
  258. package/src/api/idp.ts +735 -0
  259. package/src/api/intake.ts +537 -0
  260. package/src/api/partitions.ts +147 -0
  261. package/src/api/progress-event-store.ts +153 -0
  262. package/src/api/schemas.ts +376 -0
  263. package/src/api/security-boundaries.ts +54 -0
  264. package/src/api/settings.ts +118 -0
  265. package/src/api/system.ts +704 -0
  266. package/src/api/telemetry.ts +32 -0
  267. package/src/api/users.ts +210 -0
  268. package/src/archive-unpacker.ts +271 -0
  269. package/src/artifact-analyzer.ts +438 -0
  270. package/src/auth/idp/index.ts +8 -0
  271. package/src/auth/idp/ldap.ts +340 -0
  272. package/src/auth/idp/oidc.ts +117 -0
  273. package/src/auth/idp/role-mapping.ts +22 -0
  274. package/src/auth/idp/saml.ts +148 -0
  275. package/src/auth/idp/types.ts +22 -0
  276. package/src/fleet/fleet-executor.ts +309 -0
  277. package/src/fleet/fleet-store.ts +13 -0
  278. package/src/fleet/index.ts +4 -0
  279. package/src/fleet/representative-selector.ts +83 -0
  280. package/src/graph/graph-executor.ts +446 -0
  281. package/src/graph/graph-inference.ts +184 -0
  282. package/src/graph/graph-store.ts +75 -0
  283. package/src/graph/index.ts +4 -0
  284. package/src/index.ts +916 -0
  285. package/src/intake/index.ts +5 -0
  286. package/src/intake/intake-processor.ts +111 -0
  287. package/src/intake/intake-store.ts +7 -0
  288. package/src/intake/registry-poller.ts +230 -0
  289. package/src/intake/webhook-handlers.ts +328 -0
  290. package/src/logger.ts +19 -0
  291. package/src/mcp/resources.ts +98 -0
  292. package/src/mcp/server.ts +34 -0
  293. package/src/mcp/tools.ts +117 -0
  294. package/src/middleware/auth.ts +103 -0
  295. package/src/middleware/permissions.ts +35 -0
  296. package/src/pattern-store.ts +409 -0
  297. package/tests/agent-mode.test.ts +536 -0
  298. package/tests/api-handlers.test.ts +1245 -0
  299. package/tests/archive-unpacker.test.ts +179 -0
  300. package/tests/artifact-analyzer.test.ts +240 -0
  301. package/tests/auth-middleware.test.ts +189 -0
  302. package/tests/decision-diary.test.ts +957 -0
  303. package/tests/diary-reader.test.ts +782 -0
  304. package/tests/envoy-client.test.ts +342 -0
  305. package/tests/envoy-reports.test.ts +156 -0
  306. package/tests/mcp-tools.test.ts +213 -0
  307. package/tests/orchestration.test.ts +536 -0
  308. package/tests/partition-deletion.test.ts +143 -0
  309. package/tests/partition-isolation.test.ts +830 -0
  310. package/tests/pattern-store.test.ts +371 -0
  311. package/tests/rbac-enforcement.test.ts +409 -0
  312. package/tests/ssrf-validation.test.ts +56 -0
  313. package/tests/stale-deployment.test.ts +85 -0
  314. package/tests/step-runner.test.ts +308 -0
  315. package/tests/ui-journey.test.ts +330 -0
  316. package/tsconfig.json +11 -0
  317. package/vitest.config.ts +27 -0
@@ -0,0 +1,957 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import {
6
+ DecisionDebrief,
7
+ PersistentDecisionDebrief,
8
+ PartitionStore,
9
+ EnvironmentStore,
10
+ ArtifactStore,
11
+ formatDebriefEntry,
12
+ formatDebriefEntries,
13
+ } from "@synth-deploy/core";
14
+ import type {
15
+ DebriefEntry,
16
+ DecisionType,
17
+ } from "@synth-deploy/core";
18
+ import {
19
+ SynthAgent,
20
+ InMemoryDeploymentStore,
21
+ } from "../src/agent/synth-agent.js";
22
+ import type {
23
+ ServiceHealthChecker,
24
+ HealthCheckResult,
25
+ } from "../src/agent/health-checker.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Test helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ class MockHealthChecker implements ServiceHealthChecker {
32
+ private responses: HealthCheckResult[] = [];
33
+
34
+ willReturn(...results: HealthCheckResult[]): void {
35
+ this.responses.push(...results);
36
+ }
37
+
38
+ async check(): Promise<HealthCheckResult> {
39
+ const next = this.responses.shift();
40
+ if (next) return next;
41
+ return { reachable: true, responseTimeMs: 1, error: null };
42
+ }
43
+ }
44
+
45
+ const HEALTHY: HealthCheckResult = {
46
+ reachable: true,
47
+ responseTimeMs: 5,
48
+ error: null,
49
+ };
50
+
51
+ const CONN_REFUSED: HealthCheckResult = {
52
+ reachable: false,
53
+ responseTimeMs: null,
54
+ error: "ECONNREFUSED: Connection refused",
55
+ };
56
+
57
+ function findDecisions(entries: DebriefEntry[], substr: string): DebriefEntry[] {
58
+ return entries.filter((e) =>
59
+ e.decision.toLowerCase().includes(substr.toLowerCase()),
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Minimum word count to qualify as "specific" rather than generic.
65
+ * A decision like "ok" (1 word) is generic.
66
+ * "Post-deployment verification passed" (3 words) is specific enough —
67
+ * it names the step and outcome.
68
+ */
69
+ const MIN_SPECIFIC_WORD_COUNT = 3;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Shared factory: sets up agent with seeded stores
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function createTestAgent(diary: DecisionDebrief | PersistentDecisionDebrief, healthChecker: MockHealthChecker) {
76
+ const deployments = new InMemoryDeploymentStore();
77
+ const artifactStore = new ArtifactStore();
78
+ const environmentStore = new EnvironmentStore();
79
+ const partitionStore = new PartitionStore();
80
+ const agent = new SynthAgent(
81
+ diary, deployments, artifactStore, environmentStore, partitionStore,
82
+ healthChecker, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
83
+ );
84
+ return { agent, deployments, artifactStore, environmentStore, partitionStore };
85
+ }
86
+
87
+ /** Seed a minimal artifact for testing. */
88
+ function seedArtifact(store: ArtifactStore, name = "web-app") {
89
+ return store.create({
90
+ name,
91
+ type: "nodejs",
92
+ analysis: {
93
+ summary: "Test artifact",
94
+ dependencies: [],
95
+ configurationExpectations: {},
96
+ deploymentIntent: "rolling",
97
+ confidence: 0.9,
98
+ },
99
+ annotations: [],
100
+ learningHistory: [],
101
+ });
102
+ }
103
+
104
+ /** Run a deployment through the agent with minimal setup. */
105
+ async function testDeploy(
106
+ agent: SynthAgent,
107
+ artifactStore: ArtifactStore,
108
+ environmentStore: EnvironmentStore,
109
+ partitionStore: PartitionStore,
110
+ opts: {
111
+ partitionName?: string;
112
+ partitionId?: string;
113
+ partitionVars?: Record<string, string>;
114
+ envName?: string;
115
+ envVars?: Record<string, string>;
116
+ version?: string;
117
+ variables?: Record<string, string>;
118
+ } = {},
119
+ ) {
120
+ const artifact = seedArtifact(artifactStore);
121
+ const partition = partitionStore.create(
122
+ opts.partitionName ?? "Acme Corp",
123
+ opts.partitionVars ?? {},
124
+ );
125
+ const env = environmentStore.create(
126
+ opts.envName ?? "production",
127
+ opts.envVars ?? {},
128
+ );
129
+
130
+ const trigger = {
131
+ artifactId: artifact.id,
132
+ artifactVersionId: opts.version ?? "2.0.0",
133
+ partitionId: opts.partitionId ?? partition.id,
134
+ environmentId: env.id,
135
+ triggeredBy: "user" as const,
136
+ ...(opts.variables ? { variables: opts.variables } : {}),
137
+ };
138
+
139
+ return agent.triggerDeployment(trigger);
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Test suite: Entry specificity — entries must be actionable, not generic
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe("Decision Diary — entry specificity", () => {
147
+ let diary: DecisionDebrief;
148
+ let healthChecker: MockHealthChecker;
149
+ let agent: SynthAgent;
150
+ let artifactStore: ArtifactStore;
151
+ let environmentStore: EnvironmentStore;
152
+ let partitionStore: PartitionStore;
153
+
154
+ beforeEach(() => {
155
+ diary = new DecisionDebrief();
156
+ healthChecker = new MockHealthChecker();
157
+ const ctx = createTestAgent(diary, healthChecker);
158
+ agent = ctx.agent;
159
+ artifactStore = ctx.artifactStore;
160
+ environmentStore = ctx.environmentStore;
161
+ partitionStore = ctx.partitionStore;
162
+ });
163
+
164
+ it("every decision text is specific — contains artifact, version, or environment names", async () => {
165
+ healthChecker.willReturn(HEALTHY);
166
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore, {
167
+ partitionVars: { APP_ENV: "production", DB_HOST: "acme-db-1" },
168
+ envVars: { APP_ENV: "production", LOG_LEVEL: "warn" },
169
+ variables: { LOG_LEVEL: "error" },
170
+ });
171
+
172
+ const entries = diary.getByDeployment(result.id);
173
+ expect(entries.length).toBeGreaterThanOrEqual(5);
174
+
175
+ for (const entry of entries) {
176
+ // Every entry must have substantial decision and reasoning
177
+ const decisionWords = entry.decision.split(/\s+/).length;
178
+ const reasoningWords = entry.reasoning.split(/\s+/).length;
179
+
180
+ expect(decisionWords).toBeGreaterThanOrEqual(MIN_SPECIFIC_WORD_COUNT);
181
+ expect(reasoningWords).toBeGreaterThanOrEqual(8);
182
+ }
183
+ });
184
+
185
+ it("reasoning always references concrete values — never generic placeholder text", async () => {
186
+ healthChecker.willReturn(HEALTHY);
187
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore, {
188
+ partitionVars: { APP_ENV: "production", DB_HOST: "acme-db-1" },
189
+ envName: "staging",
190
+ envVars: { APP_ENV: "staging", LOG_LEVEL: "debug" },
191
+ variables: { LOG_LEVEL: "error" },
192
+ });
193
+
194
+ const entries = diary.getByDeployment(result.id);
195
+
196
+ // Reasoning must contain at least one concrete reference
197
+ const genericPhrases = [
198
+ "something went wrong",
199
+ "an error occurred",
200
+ "check the logs",
201
+ "contact support",
202
+ "unknown error",
203
+ ];
204
+
205
+ for (const entry of entries) {
206
+ for (const phrase of genericPhrases) {
207
+ expect(entry.reasoning.toLowerCase()).not.toContain(phrase);
208
+ }
209
+ }
210
+
211
+ // Pipeline plan must reference the actual artifact and version
212
+ const planEntries = findDecisions(entries, "pipeline");
213
+ expect(planEntries[0].reasoning).toContain("web-app");
214
+ expect(planEntries[0].reasoning).toContain("2.0.0");
215
+ expect(planEntries[0].reasoning).toContain("staging");
216
+ });
217
+
218
+ it("failure entries include actionable recommendations", async () => {
219
+ healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
220
+
221
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore);
222
+
223
+ expect(result.status).toBe("failed");
224
+
225
+ const entries = diary.getByDeployment(result.id);
226
+ const failEntry = findDecisions(entries, "Deployment failed")[0];
227
+
228
+ // Failure reasoning must contain recommended action
229
+ expect(failEntry.reasoning).toContain("Recommended action");
230
+ // Must reference the specific environment
231
+ expect(failEntry.reasoning).toContain("production");
232
+ });
233
+
234
+ it("variable conflict entries name the specific variables involved", async () => {
235
+ healthChecker.willReturn(HEALTHY);
236
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore, {
237
+ partitionVars: { LOG_LEVEL: "error", APP_ENV: "production" },
238
+ envVars: { LOG_LEVEL: "warn", APP_ENV: "production" },
239
+ variables: { LOG_LEVEL: "debug" },
240
+ });
241
+
242
+ const entries = diary.getByDeployment(result.id);
243
+ const conflictEntries = findDecisions(entries, "conflict");
244
+ expect(conflictEntries.length).toBeGreaterThanOrEqual(1);
245
+
246
+ // At least one conflict entry must name the actual variable
247
+ const mentionsVariable = conflictEntries.some(
248
+ (e) =>
249
+ e.decision.includes("LOG_LEVEL") ||
250
+ e.reasoning.includes("LOG_LEVEL"),
251
+ );
252
+ expect(mentionsVariable).toBe(true);
253
+ });
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Test suite: All agent decisions produce diary entries
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe("Decision Diary — orchestration completeness", () => {
261
+ let diary: DecisionDebrief;
262
+ let healthChecker: MockHealthChecker;
263
+ let agent: SynthAgent;
264
+ let artifactStore: ArtifactStore;
265
+ let environmentStore: EnvironmentStore;
266
+ let partitionStore: PartitionStore;
267
+
268
+ beforeEach(() => {
269
+ diary = new DecisionDebrief();
270
+ healthChecker = new MockHealthChecker();
271
+ const ctx = createTestAgent(diary, healthChecker);
272
+ agent = ctx.agent;
273
+ artifactStore = ctx.artifactStore;
274
+ environmentStore = ctx.environmentStore;
275
+ partitionStore = ctx.partitionStore;
276
+ });
277
+
278
+ it("successful deployment produces entries for every pipeline step", async () => {
279
+ healthChecker.willReturn(HEALTHY);
280
+
281
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore);
282
+
283
+ const entries = diary.getByDeployment(result.id);
284
+ const types = entries.map((e) => e.decisionType);
285
+
286
+ expect(types).toContain("artifact-analysis");
287
+ expect(types).toContain("pipeline-plan");
288
+ expect(types).toContain("plan-generation");
289
+ expect(types).toContain("plan-approval");
290
+ expect(types).toContain("configuration-resolved");
291
+ expect(types).toContain("health-check");
292
+ expect(types).toContain("deployment-completion");
293
+ });
294
+
295
+ it("failed deployment produces entries up to the failure plus the failure entry", async () => {
296
+ healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
297
+
298
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore);
299
+
300
+ const entries = diary.getByDeployment(result.id);
301
+ const types = entries.map((e) => e.decisionType);
302
+
303
+ // Should have artifact analysis, plan, config, health check (retry), and failure
304
+ expect(types).toContain("artifact-analysis");
305
+ expect(types).toContain("pipeline-plan");
306
+ expect(types).toContain("configuration-resolved");
307
+ expect(types).toContain("health-check");
308
+ expect(types).toContain("deployment-failure");
309
+
310
+ // Should NOT have completion (pipeline aborted at health check)
311
+ expect(types).not.toContain("deployment-completion");
312
+ });
313
+
314
+ it("variable conflict deployment produces variable-conflict entries", async () => {
315
+ healthChecker.willReturn(HEALTHY);
316
+
317
+ const artifact = seedArtifact(artifactStore);
318
+ const partition = partitionStore.create("Acme Corp", { DB_HOST: "prod-db.internal" });
319
+ const env = environmentStore.create("staging", { DB_HOST: "staging-db.internal" });
320
+
321
+ const trigger = {
322
+ artifactId: artifact.id,
323
+ artifactVersionId: "2.0.0",
324
+ partitionId: partition.id,
325
+ environmentId: env.id,
326
+ triggeredBy: "user" as const,
327
+ };
328
+ const result = await agent.triggerDeployment(trigger);
329
+
330
+ const entries = diary.getByDeployment(result.id);
331
+ const types = entries.map((e) => e.decisionType);
332
+
333
+ expect(types).toContain("variable-conflict");
334
+ });
335
+
336
+ it("every entry has a valid decisionType from the enum", async () => {
337
+ const validTypes: DecisionType[] = [
338
+ "pipeline-plan",
339
+ "configuration-resolved",
340
+ "variable-conflict",
341
+ "health-check",
342
+ "deployment-execution",
343
+ "deployment-verification",
344
+ "deployment-completion",
345
+ "deployment-failure",
346
+ "diagnostic-investigation",
347
+ "environment-scan",
348
+ "system",
349
+ "llm-call",
350
+ "artifact-analysis",
351
+ "plan-generation",
352
+ "plan-approval",
353
+ "plan-rejection",
354
+ "rollback-execution",
355
+ "cross-system-context",
356
+ ];
357
+
358
+ healthChecker.willReturn(HEALTHY);
359
+
360
+ await testDeploy(agent, artifactStore, environmentStore, partitionStore);
361
+
362
+ const entries = diary.getRecent(100);
363
+ for (const entry of entries) {
364
+ expect(validTypes).toContain(entry.decisionType);
365
+ }
366
+ });
367
+ });
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Test suite: Retrieval across all four dimensions
371
+ // ---------------------------------------------------------------------------
372
+
373
+ describe("Decision Diary — retrieval dimensions", () => {
374
+ let diary: DecisionDebrief;
375
+ let healthChecker: MockHealthChecker;
376
+ let agent: SynthAgent;
377
+ let artifactStore: ArtifactStore;
378
+ let environmentStore: EnvironmentStore;
379
+ let partitionStore: PartitionStore;
380
+
381
+ beforeEach(() => {
382
+ diary = new DecisionDebrief();
383
+ healthChecker = new MockHealthChecker();
384
+ const ctx = createTestAgent(diary, healthChecker);
385
+ agent = ctx.agent;
386
+ artifactStore = ctx.artifactStore;
387
+ environmentStore = ctx.environmentStore;
388
+ partitionStore = ctx.partitionStore;
389
+ });
390
+
391
+ it("retrieval by deployment — returns only entries for the specified deployment", async () => {
392
+ healthChecker.willReturn(HEALTHY, HEALTHY);
393
+
394
+ const result1 = await testDeploy(agent, artifactStore, environmentStore, partitionStore, { version: "1.0.0" });
395
+ const result2 = await testDeploy(agent, artifactStore, environmentStore, partitionStore, { version: "2.0.0" });
396
+
397
+ const entries1 = diary.getByDeployment(result1.id);
398
+ const entries2 = diary.getByDeployment(result2.id);
399
+
400
+ // Each deployment has its own entries
401
+ expect(entries1.length).toBeGreaterThanOrEqual(5);
402
+ expect(entries2.length).toBeGreaterThanOrEqual(5);
403
+
404
+ // No cross-contamination
405
+ for (const e of entries1) {
406
+ expect(e.deploymentId).toBe(result1.id);
407
+ }
408
+ for (const e of entries2) {
409
+ expect(e.deploymentId).toBe(result2.id);
410
+ }
411
+ });
412
+
413
+ it("retrieval by partition — returns only entries for the specified partition", async () => {
414
+ healthChecker.willReturn(HEALTHY, HEALTHY);
415
+
416
+ // Create two partitions and deploy to each
417
+ const partA = partitionStore.create("Partition A");
418
+ const partB = partitionStore.create("Partition B");
419
+ const envA = environmentStore.create("production-a");
420
+ const envB = environmentStore.create("production-b");
421
+ const artifactA = seedArtifact(artifactStore, "app-a");
422
+ const artifactB = seedArtifact(artifactStore, "app-b");
423
+
424
+ await agent.triggerDeployment({
425
+ artifactId: artifactA.id, artifactVersionId: "1.0.0",
426
+ partitionId: partA.id, environmentId: envA.id, triggeredBy: "user",
427
+ });
428
+ await agent.triggerDeployment({
429
+ artifactId: artifactB.id, artifactVersionId: "1.0.0",
430
+ partitionId: partB.id, environmentId: envB.id, triggeredBy: "user",
431
+ });
432
+
433
+ const entriesA = diary.getByPartition(partA.id);
434
+ const entriesB = diary.getByPartition(partB.id);
435
+
436
+ expect(entriesA.length).toBeGreaterThanOrEqual(5);
437
+ expect(entriesB.length).toBeGreaterThanOrEqual(5);
438
+
439
+ for (const e of entriesA) {
440
+ expect(e.partitionId).toBe(partA.id);
441
+ }
442
+ for (const e of entriesB) {
443
+ expect(e.partitionId).toBe(partB.id);
444
+ }
445
+
446
+ // No overlap
447
+ const idsA = new Set(entriesA.map((e) => e.id));
448
+ const idsB = new Set(entriesB.map((e) => e.id));
449
+ for (const id of idsA) {
450
+ expect(idsB.has(id)).toBe(false);
451
+ }
452
+ });
453
+
454
+ it("retrieval by decision type — filters correctly across deployments", async () => {
455
+ healthChecker.willReturn(HEALTHY, CONN_REFUSED, CONN_REFUSED);
456
+
457
+ // One success, one failure
458
+ await testDeploy(agent, artifactStore, environmentStore, partitionStore, { version: "1.0.0" });
459
+ await testDeploy(agent, artifactStore, environmentStore, partitionStore, { version: "2.0.0" });
460
+
461
+ const planEntries = diary.getByType("pipeline-plan");
462
+ const healthEntries = diary.getByType("health-check");
463
+ const failEntries = diary.getByType("deployment-failure");
464
+ const completionEntries = diary.getByType("deployment-completion");
465
+
466
+ // Two deployments = two pipeline plans
467
+ expect(planEntries).toHaveLength(2);
468
+ for (const e of planEntries) {
469
+ expect(e.decisionType).toBe("pipeline-plan");
470
+ }
471
+
472
+ // Health check entries from both
473
+ expect(healthEntries.length).toBeGreaterThanOrEqual(2);
474
+ for (const e of healthEntries) {
475
+ expect(e.decisionType).toBe("health-check");
476
+ }
477
+
478
+ // One failure, one completion
479
+ expect(failEntries).toHaveLength(1);
480
+ expect(completionEntries).toHaveLength(1);
481
+ });
482
+
483
+ it("retrieval by time range — returns entries within the window", async () => {
484
+ const before = new Date();
485
+
486
+ healthChecker.willReturn(HEALTHY);
487
+ await testDeploy(agent, artifactStore, environmentStore, partitionStore);
488
+
489
+ const after = new Date();
490
+
491
+ const entries = diary.getByTimeRange(before, after);
492
+ expect(entries.length).toBeGreaterThanOrEqual(5);
493
+
494
+ for (const e of entries) {
495
+ expect(e.timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime());
496
+ expect(e.timestamp.getTime()).toBeLessThanOrEqual(after.getTime());
497
+ }
498
+
499
+ // Future window returns nothing
500
+ const futureStart = new Date(after.getTime() + 60_000);
501
+ const futureEnd = new Date(after.getTime() + 120_000);
502
+ const futureEntries = diary.getByTimeRange(futureStart, futureEnd);
503
+ expect(futureEntries).toHaveLength(0);
504
+ });
505
+
506
+ it("retrieval by time range returns entries sorted chronologically", async () => {
507
+ const before = new Date();
508
+
509
+ healthChecker.willReturn(HEALTHY);
510
+ await testDeploy(agent, artifactStore, environmentStore, partitionStore);
511
+
512
+ const after = new Date();
513
+ const entries = diary.getByTimeRange(before, after);
514
+
515
+ for (let i = 1; i < entries.length; i++) {
516
+ expect(entries[i].timestamp.getTime()).toBeGreaterThanOrEqual(
517
+ entries[i - 1].timestamp.getTime(),
518
+ );
519
+ }
520
+ });
521
+ });
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Test suite: Persistent Decision Diary (SQLite)
525
+ // ---------------------------------------------------------------------------
526
+
527
+ describe("PersistentDecisionDebrief — SQLite backing store", () => {
528
+ let dbPath: string;
529
+ let diary: PersistentDecisionDebrief;
530
+
531
+ beforeEach(() => {
532
+ dbPath = path.join(
533
+ os.tmpdir(),
534
+ `synth-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
535
+ );
536
+ diary = new PersistentDecisionDebrief(dbPath);
537
+ });
538
+
539
+ afterEach(() => {
540
+ diary.close();
541
+ try {
542
+ fs.unlinkSync(dbPath);
543
+ fs.unlinkSync(dbPath + "-wal");
544
+ fs.unlinkSync(dbPath + "-shm");
545
+ } catch {
546
+ // ignore cleanup errors
547
+ }
548
+ });
549
+
550
+ it("persists entries across close and reopen", () => {
551
+ const entry = diary.record({
552
+ partitionId: "partition-1",
553
+ deploymentId: "deploy-1",
554
+ agent: "command",
555
+ decisionType: "pipeline-plan",
556
+ decision: "Planned deployment pipeline: resolve → execute → verify",
557
+ reasoning: "Standard three-step pipeline for web-app v1.0.0 to production.",
558
+ context: { artifactId: "web-app", version: "1.0.0" },
559
+ });
560
+
561
+ diary.close();
562
+
563
+ // Reopen the same database
564
+ const diary2 = new PersistentDecisionDebrief(dbPath);
565
+ const retrieved = diary2.getById(entry.id);
566
+ expect(retrieved).toBeDefined();
567
+ expect(retrieved!.decision).toBe(entry.decision);
568
+ expect(retrieved!.reasoning).toBe(entry.reasoning);
569
+ expect(retrieved!.partitionId).toBe("partition-1");
570
+ expect(retrieved!.deploymentId).toBe("deploy-1");
571
+ expect(retrieved!.decisionType).toBe("pipeline-plan");
572
+ expect(retrieved!.context).toEqual({ artifactId: "web-app", version: "1.0.0" });
573
+ diary2.close();
574
+ });
575
+
576
+ it("retrieval by deployment returns correct entries", () => {
577
+ diary.record({
578
+ partitionId: "t1",
579
+ deploymentId: "d1",
580
+ agent: "command",
581
+ decisionType: "pipeline-plan",
582
+ decision: "Planned pipeline for d1",
583
+ reasoning: "Deploy web-app v1.0 to production for Acme.",
584
+ });
585
+ diary.record({
586
+ partitionId: "t1",
587
+ deploymentId: "d2",
588
+ agent: "command",
589
+ decisionType: "pipeline-plan",
590
+ decision: "Planned pipeline for d2",
591
+ reasoning: "Deploy web-app v2.0 to staging for Acme.",
592
+ });
593
+ diary.record({
594
+ partitionId: "t1",
595
+ deploymentId: "d1",
596
+ agent: "command",
597
+ decisionType: "deployment-completion",
598
+ decision: "Deployment d1 completed",
599
+ reasoning: "All steps passed.",
600
+ });
601
+
602
+ const d1Entries = diary.getByDeployment("d1");
603
+ expect(d1Entries).toHaveLength(2);
604
+ for (const e of d1Entries) {
605
+ expect(e.deploymentId).toBe("d1");
606
+ }
607
+
608
+ const d2Entries = diary.getByDeployment("d2");
609
+ expect(d2Entries).toHaveLength(1);
610
+ expect(d2Entries[0].deploymentId).toBe("d2");
611
+ });
612
+
613
+ it("retrieval by partition returns correct entries", () => {
614
+ diary.record({
615
+ partitionId: "acme",
616
+ deploymentId: "d1",
617
+ agent: "command",
618
+ decisionType: "pipeline-plan",
619
+ decision: "Acme deployment plan",
620
+ reasoning: "Standard pipeline for Acme Corp.",
621
+ });
622
+ diary.record({
623
+ partitionId: "beta",
624
+ deploymentId: "d2",
625
+ agent: "command",
626
+ decisionType: "pipeline-plan",
627
+ decision: "Beta deployment plan",
628
+ reasoning: "Standard pipeline for Beta Inc.",
629
+ });
630
+
631
+ const acmeEntries = diary.getByPartition("acme");
632
+ expect(acmeEntries).toHaveLength(1);
633
+ expect(acmeEntries[0].partitionId).toBe("acme");
634
+
635
+ const betaEntries = diary.getByPartition("beta");
636
+ expect(betaEntries).toHaveLength(1);
637
+ expect(betaEntries[0].partitionId).toBe("beta");
638
+ });
639
+
640
+ it("retrieval by decision type filters correctly", () => {
641
+ diary.record({
642
+ partitionId: "t1",
643
+ deploymentId: "d1",
644
+ agent: "command",
645
+ decisionType: "health-check",
646
+ decision: "Health check passed",
647
+ reasoning: "Service responding in 5ms.",
648
+ });
649
+ diary.record({
650
+ partitionId: "t1",
651
+ deploymentId: "d1",
652
+ agent: "command",
653
+ decisionType: "variable-conflict",
654
+ decision: "LOG_LEVEL conflict resolved",
655
+ reasoning: "Trigger value 'debug' overrides partition value 'error'.",
656
+ });
657
+ diary.record({
658
+ partitionId: "t1",
659
+ deploymentId: "d1",
660
+ agent: "command",
661
+ decisionType: "health-check",
662
+ decision: "Post-flight check passed",
663
+ reasoning: "Service healthy after deploy.",
664
+ });
665
+
666
+ const healthEntries = diary.getByType("health-check");
667
+ expect(healthEntries).toHaveLength(2);
668
+ for (const e of healthEntries) {
669
+ expect(e.decisionType).toBe("health-check");
670
+ }
671
+
672
+ const conflictEntries = diary.getByType("variable-conflict");
673
+ expect(conflictEntries).toHaveLength(1);
674
+ expect(conflictEntries[0].decisionType).toBe("variable-conflict");
675
+ });
676
+
677
+ it("retrieval by time range works correctly", () => {
678
+ const before = new Date();
679
+ diary.record({
680
+ partitionId: "t1",
681
+ deploymentId: "d1",
682
+ agent: "command",
683
+ decisionType: "pipeline-plan",
684
+ decision: "First entry",
685
+ reasoning: "First reasoning.",
686
+ });
687
+ diary.record({
688
+ partitionId: "t1",
689
+ deploymentId: "d1",
690
+ agent: "command",
691
+ decisionType: "deployment-completion",
692
+ decision: "Second entry",
693
+ reasoning: "Second reasoning.",
694
+ });
695
+ const after = new Date();
696
+
697
+ // Current window should find both
698
+ const current = diary.getByTimeRange(before, after);
699
+ expect(current).toHaveLength(2);
700
+
701
+ // Past window should find nothing
702
+ const t1 = new Date("2026-01-01T00:00:00Z");
703
+ const t2 = new Date("2026-01-02T00:00:00Z");
704
+ const past = diary.getByTimeRange(t1, t2);
705
+ expect(past).toHaveLength(0);
706
+ });
707
+
708
+ it("getRecent returns entries in reverse chronological order", () => {
709
+ for (let i = 0; i < 5; i++) {
710
+ diary.record({
711
+ partitionId: "t1",
712
+ deploymentId: `d${i}`,
713
+ agent: "command",
714
+ decisionType: "pipeline-plan",
715
+ decision: `Entry ${i}`,
716
+ reasoning: `Reasoning for entry ${i}.`,
717
+ });
718
+ }
719
+
720
+ const recent = diary.getRecent(3);
721
+ expect(recent).toHaveLength(3);
722
+
723
+ // Most recent first
724
+ for (let i = 1; i < recent.length; i++) {
725
+ expect(recent[i - 1].timestamp.getTime()).toBeGreaterThanOrEqual(
726
+ recent[i].timestamp.getTime(),
727
+ );
728
+ }
729
+ });
730
+
731
+ it("context round-trips through JSON correctly", () => {
732
+ const entry = diary.record({
733
+ partitionId: "t1",
734
+ deploymentId: "d1",
735
+ agent: "command",
736
+ decisionType: "health-check",
737
+ decision: "Health check with complex context",
738
+ reasoning: "Detailed reasoning here.",
739
+ context: {
740
+ serviceId: "web-app/production",
741
+ responseTimeMs: 42,
742
+ nested: { retries: 3, errors: ["timeout", "refused"] },
743
+ },
744
+ });
745
+
746
+ const retrieved = diary.getById(entry.id);
747
+ expect(retrieved!.context).toEqual({
748
+ serviceId: "web-app/production",
749
+ responseTimeMs: 42,
750
+ nested: { retries: 3, errors: ["timeout", "refused"] },
751
+ });
752
+ });
753
+ });
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // Test suite: Integration — PersistentDecisionDebrief with SynthAgent
757
+ // ---------------------------------------------------------------------------
758
+
759
+ describe("PersistentDecisionDebrief — integration with SynthAgent", () => {
760
+ let dbPath: string;
761
+ let diary: PersistentDecisionDebrief;
762
+ let healthChecker: MockHealthChecker;
763
+ let agent: SynthAgent;
764
+ let artifactStore: ArtifactStore;
765
+ let environmentStore: EnvironmentStore;
766
+ let partitionStore: PartitionStore;
767
+
768
+ beforeEach(() => {
769
+ dbPath = path.join(
770
+ os.tmpdir(),
771
+ `synth-agent-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
772
+ );
773
+ diary = new PersistentDecisionDebrief(dbPath);
774
+ healthChecker = new MockHealthChecker();
775
+ const ctx = createTestAgent(diary as any, healthChecker);
776
+ agent = ctx.agent;
777
+ artifactStore = ctx.artifactStore;
778
+ environmentStore = ctx.environmentStore;
779
+ partitionStore = ctx.partitionStore;
780
+ });
781
+
782
+ afterEach(() => {
783
+ diary.close();
784
+ try {
785
+ fs.unlinkSync(dbPath);
786
+ fs.unlinkSync(dbPath + "-wal");
787
+ fs.unlinkSync(dbPath + "-shm");
788
+ } catch {
789
+ // ignore cleanup errors
790
+ }
791
+ });
792
+
793
+ it("agent decisions persist to SQLite and survive reconnection", async () => {
794
+ healthChecker.willReturn(HEALTHY);
795
+
796
+ const result = await testDeploy(agent, artifactStore, environmentStore, partitionStore, {
797
+ partitionName: "Acme Corp",
798
+ });
799
+
800
+ expect(result.status).toBe("succeeded");
801
+
802
+ // Verify entries exist before close
803
+ const entriesBefore = diary.getByDeployment(result.id);
804
+ expect(entriesBefore.length).toBeGreaterThanOrEqual(5);
805
+
806
+ diary.close();
807
+
808
+ // Reopen and verify persistence
809
+ const diary2 = new PersistentDecisionDebrief(dbPath);
810
+ const entriesAfter = diary2.getByDeployment(result.id);
811
+ expect(entriesAfter).toHaveLength(entriesBefore.length);
812
+
813
+ // Verify key decision types from the pipeline are present
814
+ const types = entriesAfter.map((e) => e.decisionType);
815
+ expect(types).toContain("artifact-analysis");
816
+ expect(types).toContain("pipeline-plan");
817
+ expect(types).toContain("configuration-resolved");
818
+ expect(types).toContain("health-check");
819
+ expect(types).toContain("deployment-completion");
820
+
821
+ diary2.close();
822
+ });
823
+
824
+ it("cross-dimension queries work correctly with real agent data", async () => {
825
+ healthChecker.willReturn(HEALTHY, HEALTHY);
826
+
827
+ // Two deployments for different partitions
828
+ const partA = partitionStore.create("Acme Corp");
829
+ const partB = partitionStore.create("Beta Inc");
830
+ const envA = environmentStore.create("prod-a");
831
+ const envB = environmentStore.create("prod-b");
832
+ const artA = seedArtifact(artifactStore, "app-a");
833
+ const artB = seedArtifact(artifactStore, "app-b");
834
+
835
+ const result1 = await agent.triggerDeployment({
836
+ artifactId: artA.id, artifactVersionId: "1.0.0",
837
+ partitionId: partA.id, environmentId: envA.id, triggeredBy: "user",
838
+ });
839
+ const result2 = await agent.triggerDeployment({
840
+ artifactId: artB.id, artifactVersionId: "1.0.0",
841
+ partitionId: partB.id, environmentId: envB.id, triggeredBy: "user",
842
+ });
843
+
844
+ // By deployment
845
+ const acmeEntries = diary.getByDeployment(result1.id);
846
+ const betaEntries = diary.getByDeployment(result2.id);
847
+ expect(acmeEntries.length).toBeGreaterThanOrEqual(5);
848
+ expect(betaEntries.length).toBeGreaterThanOrEqual(5);
849
+
850
+ // By partition — all entries for each partition
851
+ const acmePartitionEntries = diary.getByPartition(partA.id);
852
+ const betaPartitionEntries = diary.getByPartition(partB.id);
853
+ expect(acmePartitionEntries.length).toBeGreaterThanOrEqual(acmeEntries.length);
854
+ expect(betaPartitionEntries.length).toBeGreaterThanOrEqual(betaEntries.length);
855
+
856
+ // By type — across both deployments
857
+ const plans = diary.getByType("pipeline-plan");
858
+ expect(plans).toHaveLength(2);
859
+
860
+ // By time range — all entries fall within the test window
861
+ const allRecent = diary.getRecent(100);
862
+ const earliest = allRecent[allRecent.length - 1].timestamp;
863
+ const latest = allRecent[0].timestamp;
864
+ const rangeEntries = diary.getByTimeRange(
865
+ new Date(earliest.getTime() - 1),
866
+ new Date(latest.getTime() + 1),
867
+ );
868
+ expect(rangeEntries).toHaveLength(allRecent.length);
869
+ });
870
+ });
871
+
872
+ // ---------------------------------------------------------------------------
873
+ // Test suite: Human-readable formatting
874
+ // ---------------------------------------------------------------------------
875
+
876
+ describe("Decision Diary — human-readable format", () => {
877
+ it("formatDebriefEntry produces readable output with all fields", () => {
878
+ const entry: DebriefEntry = {
879
+ id: "abc-123-def-456",
880
+ timestamp: new Date("2026-02-23T14:30:05.000Z"),
881
+ partitionId: "partition-acme",
882
+ deploymentId: "deploy-789",
883
+ agent: "command",
884
+ decisionType: "health-check",
885
+ decision: "Pre-flight health check passed",
886
+ reasoning:
887
+ 'Target environment "production" is reachable and healthy (response time: 5ms). Proceeding with deployment.',
888
+ context: { serviceId: "web-app/production", responseTimeMs: 5 },
889
+ };
890
+
891
+ const formatted = formatDebriefEntry(entry);
892
+
893
+ expect(formatted).toContain("HEALTH-CHECK");
894
+ expect(formatted).toContain("partition-acme");
895
+ expect(formatted).toContain("deploy-7"); // truncated ID
896
+ expect(formatted).toContain("command");
897
+ expect(formatted).toContain("Pre-flight health check passed");
898
+ expect(formatted).toContain("production");
899
+ expect(formatted).toContain("response time: 5ms");
900
+ expect(formatted).toContain("serviceId=web-app/production");
901
+ });
902
+
903
+ it("formatDebriefEntry handles system-level entries (null partition)", () => {
904
+ const entry: DebriefEntry = {
905
+ id: "sys-001",
906
+ timestamp: new Date("2026-02-23T12:00:00.000Z"),
907
+ partitionId: null,
908
+ deploymentId: null,
909
+ agent: "command",
910
+ decisionType: "system",
911
+ decision: "Command initialized with demo data",
912
+ reasoning: "Seeded one partition and two environments.",
913
+ context: {},
914
+ };
915
+
916
+ const formatted = formatDebriefEntry(entry);
917
+ expect(formatted).toContain("system");
918
+ expect(formatted).toContain("n/a");
919
+ expect(formatted).toContain("SYSTEM");
920
+ });
921
+
922
+ it("formatDebriefEntries produces separator-delimited output", () => {
923
+ const entries: DebriefEntry[] = [
924
+ {
925
+ id: "e1",
926
+ timestamp: new Date("2026-02-23T14:00:00.000Z"),
927
+ partitionId: "t1",
928
+ deploymentId: "d1",
929
+ agent: "command",
930
+ decisionType: "pipeline-plan",
931
+ decision: "Entry one",
932
+ reasoning: "First reasoning.",
933
+ context: {},
934
+ },
935
+ {
936
+ id: "e2",
937
+ timestamp: new Date("2026-02-23T14:01:00.000Z"),
938
+ partitionId: "t1",
939
+ deploymentId: "d1",
940
+ agent: "command",
941
+ decisionType: "deployment-completion",
942
+ decision: "Entry two",
943
+ reasoning: "Second reasoning.",
944
+ context: {},
945
+ },
946
+ ];
947
+
948
+ const formatted = formatDebriefEntries(entries);
949
+ expect(formatted).toContain("---");
950
+ expect(formatted).toContain("Entry one");
951
+ expect(formatted).toContain("Entry two");
952
+ });
953
+
954
+ it("formatDebriefEntries handles empty list", () => {
955
+ expect(formatDebriefEntries([])).toBe("No debrief entries found.");
956
+ });
957
+ });