@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.
- package/dist/agent/debrief-retention.d.ts +12 -0
- package/dist/agent/debrief-retention.d.ts.map +1 -0
- package/dist/agent/debrief-retention.js +27 -0
- package/dist/agent/debrief-retention.js.map +1 -0
- package/dist/agent/envoy-client.d.ts +216 -0
- package/dist/agent/envoy-client.d.ts.map +1 -0
- package/dist/agent/envoy-client.js +266 -0
- package/dist/agent/envoy-client.js.map +1 -0
- package/dist/agent/envoy-registry.d.ts +102 -0
- package/dist/agent/envoy-registry.d.ts.map +1 -0
- package/dist/agent/envoy-registry.js +319 -0
- package/dist/agent/envoy-registry.js.map +1 -0
- package/dist/agent/health-checker.d.ts +39 -0
- package/dist/agent/health-checker.d.ts.map +1 -0
- package/dist/agent/health-checker.js +49 -0
- package/dist/agent/health-checker.js.map +1 -0
- package/dist/agent/mcp-client-manager.d.ts +36 -0
- package/dist/agent/mcp-client-manager.d.ts.map +1 -0
- package/dist/agent/mcp-client-manager.js +106 -0
- package/dist/agent/mcp-client-manager.js.map +1 -0
- package/dist/agent/stale-deployment-detector.d.ts +15 -0
- package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
- package/dist/agent/stale-deployment-detector.js +50 -0
- package/dist/agent/stale-deployment-detector.js.map +1 -0
- package/dist/agent/step-runner.d.ts +31 -0
- package/dist/agent/step-runner.d.ts.map +1 -0
- package/dist/agent/step-runner.js +80 -0
- package/dist/agent/step-runner.js.map +1 -0
- package/dist/agent/synth-agent.d.ts +168 -0
- package/dist/agent/synth-agent.d.ts.map +1 -0
- package/dist/agent/synth-agent.js +1195 -0
- package/dist/agent/synth-agent.js.map +1 -0
- package/dist/api/agent.d.ts +36 -0
- package/dist/api/agent.d.ts.map +1 -0
- package/dist/api/agent.js +867 -0
- package/dist/api/agent.js.map +1 -0
- package/dist/api/api-keys.d.ts +4 -0
- package/dist/api/api-keys.d.ts.map +1 -0
- package/dist/api/api-keys.js +118 -0
- package/dist/api/api-keys.js.map +1 -0
- package/dist/api/artifacts.d.ts +5 -0
- package/dist/api/artifacts.d.ts.map +1 -0
- package/dist/api/artifacts.js +142 -0
- package/dist/api/artifacts.js.map +1 -0
- package/dist/api/auth.d.ts +4 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +280 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/deployments.d.ts +11 -0
- package/dist/api/deployments.d.ts.map +1 -0
- package/dist/api/deployments.js +1098 -0
- package/dist/api/deployments.js.map +1 -0
- package/dist/api/environments.d.ts +5 -0
- package/dist/api/environments.d.ts.map +1 -0
- package/dist/api/environments.js +69 -0
- package/dist/api/environments.js.map +1 -0
- package/dist/api/envoy-reports.d.ts +17 -0
- package/dist/api/envoy-reports.d.ts.map +1 -0
- package/dist/api/envoy-reports.js +138 -0
- package/dist/api/envoy-reports.js.map +1 -0
- package/dist/api/envoys.d.ts +5 -0
- package/dist/api/envoys.d.ts.map +1 -0
- package/dist/api/envoys.js +192 -0
- package/dist/api/envoys.js.map +1 -0
- package/dist/api/fleet.d.ts +11 -0
- package/dist/api/fleet.d.ts.map +1 -0
- package/dist/api/fleet.js +394 -0
- package/dist/api/fleet.js.map +1 -0
- package/dist/api/graph.d.ts +8 -0
- package/dist/api/graph.d.ts.map +1 -0
- package/dist/api/graph.js +355 -0
- package/dist/api/graph.js.map +1 -0
- package/dist/api/health.d.ts +20 -0
- package/dist/api/health.d.ts.map +1 -0
- package/dist/api/health.js +248 -0
- package/dist/api/health.js.map +1 -0
- package/dist/api/idp-schemas.d.ts +41 -0
- package/dist/api/idp-schemas.d.ts.map +1 -0
- package/dist/api/idp-schemas.js +17 -0
- package/dist/api/idp-schemas.js.map +1 -0
- package/dist/api/idp.d.ts +6 -0
- package/dist/api/idp.d.ts.map +1 -0
- package/dist/api/idp.js +620 -0
- package/dist/api/idp.js.map +1 -0
- package/dist/api/intake.d.ts +10 -0
- package/dist/api/intake.d.ts.map +1 -0
- package/dist/api/intake.js +418 -0
- package/dist/api/intake.js.map +1 -0
- package/dist/api/partitions.d.ts +5 -0
- package/dist/api/partitions.d.ts.map +1 -0
- package/dist/api/partitions.js +113 -0
- package/dist/api/partitions.js.map +1 -0
- package/dist/api/progress-event-store.d.ts +62 -0
- package/dist/api/progress-event-store.d.ts.map +1 -0
- package/dist/api/progress-event-store.js +118 -0
- package/dist/api/progress-event-store.js.map +1 -0
- package/dist/api/schemas.d.ts +1000 -0
- package/dist/api/schemas.d.ts.map +1 -0
- package/dist/api/schemas.js +328 -0
- package/dist/api/schemas.js.map +1 -0
- package/dist/api/security-boundaries.d.ts +4 -0
- package/dist/api/security-boundaries.d.ts.map +1 -0
- package/dist/api/security-boundaries.js +32 -0
- package/dist/api/security-boundaries.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +99 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/system.d.ts +75 -0
- package/dist/api/system.d.ts.map +1 -0
- package/dist/api/system.js +558 -0
- package/dist/api/system.js.map +1 -0
- package/dist/api/telemetry.d.ts +4 -0
- package/dist/api/telemetry.d.ts.map +1 -0
- package/dist/api/telemetry.js +24 -0
- package/dist/api/telemetry.js.map +1 -0
- package/dist/api/users.d.ts +4 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/api/users.js +173 -0
- package/dist/api/users.js.map +1 -0
- package/dist/archive-unpacker.d.ts +24 -0
- package/dist/archive-unpacker.d.ts.map +1 -0
- package/dist/archive-unpacker.js +239 -0
- package/dist/archive-unpacker.js.map +1 -0
- package/dist/artifact-analyzer.d.ts +59 -0
- package/dist/artifact-analyzer.d.ts.map +1 -0
- package/dist/artifact-analyzer.js +334 -0
- package/dist/artifact-analyzer.js.map +1 -0
- package/dist/auth/idp/index.d.ts +9 -0
- package/dist/auth/idp/index.d.ts.map +1 -0
- package/dist/auth/idp/index.js +5 -0
- package/dist/auth/idp/index.js.map +1 -0
- package/dist/auth/idp/ldap.d.ts +56 -0
- package/dist/auth/idp/ldap.d.ts.map +1 -0
- package/dist/auth/idp/ldap.js +276 -0
- package/dist/auth/idp/ldap.js.map +1 -0
- package/dist/auth/idp/oidc.d.ts +27 -0
- package/dist/auth/idp/oidc.d.ts.map +1 -0
- package/dist/auth/idp/oidc.js +97 -0
- package/dist/auth/idp/oidc.js.map +1 -0
- package/dist/auth/idp/role-mapping.d.ts +9 -0
- package/dist/auth/idp/role-mapping.d.ts.map +1 -0
- package/dist/auth/idp/role-mapping.js +16 -0
- package/dist/auth/idp/role-mapping.js.map +1 -0
- package/dist/auth/idp/saml.d.ts +40 -0
- package/dist/auth/idp/saml.d.ts.map +1 -0
- package/dist/auth/idp/saml.js +117 -0
- package/dist/auth/idp/saml.js.map +1 -0
- package/dist/auth/idp/types.d.ts +23 -0
- package/dist/auth/idp/types.d.ts.map +1 -0
- package/dist/auth/idp/types.js +2 -0
- package/dist/auth/idp/types.js.map +1 -0
- package/dist/fleet/fleet-executor.d.ts +35 -0
- package/dist/fleet/fleet-executor.d.ts.map +1 -0
- package/dist/fleet/fleet-executor.js +228 -0
- package/dist/fleet/fleet-executor.js.map +1 -0
- package/dist/fleet/fleet-store.d.ts +13 -0
- package/dist/fleet/fleet-store.d.ts.map +1 -0
- package/dist/fleet/fleet-store.js +13 -0
- package/dist/fleet/fleet-store.js.map +1 -0
- package/dist/fleet/index.d.ts +5 -0
- package/dist/fleet/index.d.ts.map +1 -0
- package/dist/fleet/index.js +4 -0
- package/dist/fleet/index.js.map +1 -0
- package/dist/fleet/representative-selector.d.ts +15 -0
- package/dist/fleet/representative-selector.d.ts.map +1 -0
- package/dist/fleet/representative-selector.js +71 -0
- package/dist/fleet/representative-selector.js.map +1 -0
- package/dist/graph/graph-executor.d.ts +36 -0
- package/dist/graph/graph-executor.d.ts.map +1 -0
- package/dist/graph/graph-executor.js +348 -0
- package/dist/graph/graph-executor.js.map +1 -0
- package/dist/graph/graph-inference.d.ts +22 -0
- package/dist/graph/graph-inference.d.ts.map +1 -0
- package/dist/graph/graph-inference.js +149 -0
- package/dist/graph/graph-inference.js.map +1 -0
- package/dist/graph/graph-store.d.ts +12 -0
- package/dist/graph/graph-store.d.ts.map +1 -0
- package/dist/graph/graph-store.js +61 -0
- package/dist/graph/graph-store.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +4 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/index.d.ts +6 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +5 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/intake-processor.d.ts +17 -0
- package/dist/intake/intake-processor.d.ts.map +1 -0
- package/dist/intake/intake-processor.js +99 -0
- package/dist/intake/intake-processor.js.map +1 -0
- package/dist/intake/intake-store.d.ts +7 -0
- package/dist/intake/intake-store.d.ts.map +1 -0
- package/dist/intake/intake-store.js +7 -0
- package/dist/intake/intake-store.js.map +1 -0
- package/dist/intake/registry-poller.d.ts +41 -0
- package/dist/intake/registry-poller.d.ts.map +1 -0
- package/dist/intake/registry-poller.js +202 -0
- package/dist/intake/registry-poller.js.map +1 -0
- package/dist/intake/webhook-handlers.d.ts +37 -0
- package/dist/intake/webhook-handlers.d.ts.map +1 -0
- package/dist/intake/webhook-handlers.js +268 -0
- package/dist/intake/webhook-handlers.js.map +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +88 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/middleware/auth.d.ts +29 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +76 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/permissions.d.ts +13 -0
- package/dist/middleware/permissions.d.ts.map +1 -0
- package/dist/middleware/permissions.js +32 -0
- package/dist/middleware/permissions.js.map +1 -0
- package/dist/pattern-store.d.ts +104 -0
- package/dist/pattern-store.d.ts.map +1 -0
- package/dist/pattern-store.js +299 -0
- package/dist/pattern-store.js.map +1 -0
- package/package.json +54 -0
- package/src/agent/debrief-retention.ts +44 -0
- package/src/agent/envoy-client.ts +474 -0
- package/src/agent/envoy-registry.ts +384 -0
- package/src/agent/health-checker.ts +70 -0
- package/src/agent/mcp-client-manager.ts +131 -0
- package/src/agent/stale-deployment-detector.ts +79 -0
- package/src/agent/step-runner.ts +124 -0
- package/src/agent/synth-agent.ts +1567 -0
- package/src/api/agent.ts +1075 -0
- package/src/api/api-keys.ts +129 -0
- package/src/api/artifacts.ts +194 -0
- package/src/api/auth.ts +320 -0
- package/src/api/deployments.ts +1347 -0
- package/src/api/environments.ts +97 -0
- package/src/api/envoy-reports.ts +159 -0
- package/src/api/envoys.ts +237 -0
- package/src/api/fleet.ts +510 -0
- package/src/api/graph.ts +516 -0
- package/src/api/health.ts +311 -0
- package/src/api/idp-schemas.ts +19 -0
- package/src/api/idp.ts +735 -0
- package/src/api/intake.ts +537 -0
- package/src/api/partitions.ts +147 -0
- package/src/api/progress-event-store.ts +153 -0
- package/src/api/schemas.ts +376 -0
- package/src/api/security-boundaries.ts +54 -0
- package/src/api/settings.ts +118 -0
- package/src/api/system.ts +704 -0
- package/src/api/telemetry.ts +32 -0
- package/src/api/users.ts +210 -0
- package/src/archive-unpacker.ts +271 -0
- package/src/artifact-analyzer.ts +438 -0
- package/src/auth/idp/index.ts +8 -0
- package/src/auth/idp/ldap.ts +340 -0
- package/src/auth/idp/oidc.ts +117 -0
- package/src/auth/idp/role-mapping.ts +22 -0
- package/src/auth/idp/saml.ts +148 -0
- package/src/auth/idp/types.ts +22 -0
- package/src/fleet/fleet-executor.ts +309 -0
- package/src/fleet/fleet-store.ts +13 -0
- package/src/fleet/index.ts +4 -0
- package/src/fleet/representative-selector.ts +83 -0
- package/src/graph/graph-executor.ts +446 -0
- package/src/graph/graph-inference.ts +184 -0
- package/src/graph/graph-store.ts +75 -0
- package/src/graph/index.ts +4 -0
- package/src/index.ts +916 -0
- package/src/intake/index.ts +5 -0
- package/src/intake/intake-processor.ts +111 -0
- package/src/intake/intake-store.ts +7 -0
- package/src/intake/registry-poller.ts +230 -0
- package/src/intake/webhook-handlers.ts +328 -0
- package/src/logger.ts +19 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/server.ts +34 -0
- package/src/mcp/tools.ts +117 -0
- package/src/middleware/auth.ts +103 -0
- package/src/middleware/permissions.ts +35 -0
- package/src/pattern-store.ts +409 -0
- package/tests/agent-mode.test.ts +536 -0
- package/tests/api-handlers.test.ts +1245 -0
- package/tests/archive-unpacker.test.ts +179 -0
- package/tests/artifact-analyzer.test.ts +240 -0
- package/tests/auth-middleware.test.ts +189 -0
- package/tests/decision-diary.test.ts +957 -0
- package/tests/diary-reader.test.ts +782 -0
- package/tests/envoy-client.test.ts +342 -0
- package/tests/envoy-reports.test.ts +156 -0
- package/tests/mcp-tools.test.ts +213 -0
- package/tests/orchestration.test.ts +536 -0
- package/tests/partition-deletion.test.ts +143 -0
- package/tests/partition-isolation.test.ts +830 -0
- package/tests/pattern-store.test.ts +371 -0
- package/tests/rbac-enforcement.test.ts +409 -0
- package/tests/ssrf-validation.test.ts +56 -0
- package/tests/stale-deployment.test.ts +85 -0
- package/tests/step-runner.test.ts +308 -0
- package/tests/ui-journey.test.ts +330 -0
- package/tsconfig.json +11 -0
- 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
|
+
});
|