@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,830 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DecisionDebrief,
|
|
4
|
+
PartitionManager,
|
|
5
|
+
PartitionStore,
|
|
6
|
+
EnvironmentStore,
|
|
7
|
+
ArtifactStore,
|
|
8
|
+
} from "@synth-deploy/core";
|
|
9
|
+
import type { Environment, DebriefEntry } from "@synth-deploy/core";
|
|
10
|
+
import {
|
|
11
|
+
SynthAgent,
|
|
12
|
+
InMemoryDeploymentStore,
|
|
13
|
+
} from "../src/agent/synth-agent.js";
|
|
14
|
+
import type {
|
|
15
|
+
ServiceHealthChecker,
|
|
16
|
+
HealthCheckResult,
|
|
17
|
+
} from "../src/agent/health-checker.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Test helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
class MockHealthChecker implements ServiceHealthChecker {
|
|
24
|
+
private responses: HealthCheckResult[] = [];
|
|
25
|
+
async check(): Promise<HealthCheckResult> {
|
|
26
|
+
const next = this.responses.shift();
|
|
27
|
+
if (next) return next;
|
|
28
|
+
return { reachable: true, responseTimeMs: 1, error: null };
|
|
29
|
+
}
|
|
30
|
+
willReturn(...results: HealthCheckResult[]): void {
|
|
31
|
+
this.responses.push(...results);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HEALTHY: HealthCheckResult = {
|
|
36
|
+
reachable: true,
|
|
37
|
+
responseTimeMs: 1,
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const CONN_REFUSED: HealthCheckResult = {
|
|
42
|
+
reachable: false,
|
|
43
|
+
responseTimeMs: null,
|
|
44
|
+
error: "ECONNREFUSED: Connection refused",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function makeEnvironment(overrides: Partial<Environment> = {}): Environment {
|
|
48
|
+
return {
|
|
49
|
+
id: "env-prod",
|
|
50
|
+
name: "production",
|
|
51
|
+
variables: {},
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findDecisions(entries: DebriefEntry[], substr: string): DebriefEntry[] {
|
|
57
|
+
return entries.filter((e) =>
|
|
58
|
+
e.decision.toLowerCase().includes(substr.toLowerCase()),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Shared stores for SynthAgent
|
|
63
|
+
let artifactStore: ArtifactStore;
|
|
64
|
+
let envStore: EnvironmentStore;
|
|
65
|
+
let partStore: PartitionStore;
|
|
66
|
+
|
|
67
|
+
/** Insert a partition into the store with a specific ID (bypassing UUID generation). */
|
|
68
|
+
function forceInsertPartition(id: string, name: string, variables: Record<string, string>) {
|
|
69
|
+
if (partStore.get(id)) return;
|
|
70
|
+
(partStore as any).partitions.set(id, { id, name, variables, createdAt: new Date() });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Insert an environment into the store with a specific ID. */
|
|
74
|
+
function forceInsertEnvironment(id: string, name: string, variables: Record<string, string>) {
|
|
75
|
+
if (envStore.get(id)) return;
|
|
76
|
+
(envStore as any).environments.set(id, { id, name, variables });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Seed a minimal artifact and return it. */
|
|
80
|
+
function getOrCreateArtifact() {
|
|
81
|
+
const existing = artifactStore.list();
|
|
82
|
+
if (existing.length > 0) return existing[0];
|
|
83
|
+
return artifactStore.create({
|
|
84
|
+
name: "web-app",
|
|
85
|
+
type: "nodejs",
|
|
86
|
+
analysis: {
|
|
87
|
+
summary: "Test artifact",
|
|
88
|
+
dependencies: [],
|
|
89
|
+
configurationExpectations: {},
|
|
90
|
+
deploymentIntent: "rolling",
|
|
91
|
+
confidence: 0.9,
|
|
92
|
+
},
|
|
93
|
+
annotations: [],
|
|
94
|
+
learningHistory: [],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Deploy via SynthAgent using a PartitionContainer (from PartitionManager).
|
|
100
|
+
* Ensures all entities are registered in the agent's stores before triggering.
|
|
101
|
+
*/
|
|
102
|
+
async function testDeployWithPartition(
|
|
103
|
+
agent: SynthAgent,
|
|
104
|
+
partitionLike: { id: string; toPartition: () => { id: string; name: string; variables: Record<string, string> } },
|
|
105
|
+
env: Environment,
|
|
106
|
+
version = "1.0.0",
|
|
107
|
+
variables?: Record<string, string>,
|
|
108
|
+
) {
|
|
109
|
+
const partition = partitionLike.toPartition();
|
|
110
|
+
|
|
111
|
+
// Sync PartitionManager's partition into the agent's PartitionStore
|
|
112
|
+
forceInsertPartition(partition.id, partition.name, partition.variables);
|
|
113
|
+
if (partStore.get(partition.id)) {
|
|
114
|
+
partStore.setVariables(partition.id, partition.variables);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sync environment into the agent's EnvironmentStore
|
|
118
|
+
forceInsertEnvironment(env.id, env.name, env.variables);
|
|
119
|
+
|
|
120
|
+
const artifact = getOrCreateArtifact();
|
|
121
|
+
|
|
122
|
+
const trigger = {
|
|
123
|
+
artifactId: artifact.id,
|
|
124
|
+
artifactVersionId: version,
|
|
125
|
+
partitionId: partition.id,
|
|
126
|
+
environmentId: env.id,
|
|
127
|
+
triggeredBy: "user" as const,
|
|
128
|
+
...(variables ? { variables } : {}),
|
|
129
|
+
};
|
|
130
|
+
return agent.triggerDeployment(trigger);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Tests
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe("Partition Isolation", () => {
|
|
138
|
+
let diary: DecisionDebrief;
|
|
139
|
+
let deployments: InMemoryDeploymentStore;
|
|
140
|
+
let healthChecker: MockHealthChecker;
|
|
141
|
+
let agent: SynthAgent;
|
|
142
|
+
let manager: PartitionManager;
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
diary = new DecisionDebrief();
|
|
146
|
+
deployments = new InMemoryDeploymentStore();
|
|
147
|
+
healthChecker = new MockHealthChecker();
|
|
148
|
+
artifactStore = new ArtifactStore();
|
|
149
|
+
envStore = new EnvironmentStore();
|
|
150
|
+
partStore = new PartitionStore();
|
|
151
|
+
agent = new SynthAgent(
|
|
152
|
+
diary, deployments, artifactStore, envStore, partStore,
|
|
153
|
+
healthChecker, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
|
|
154
|
+
);
|
|
155
|
+
manager = new PartitionManager(deployments, diary);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// -----------------------------------------------------------------------
|
|
159
|
+
// 1. Variable isolation — setting vars on A does not touch B
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
describe("variable isolation", () => {
|
|
163
|
+
it("setting variables on Partition A has zero effect on Partition B", () => {
|
|
164
|
+
const partitionA = manager.createPartition("Acme Corp", {
|
|
165
|
+
DB_HOST: "acme-db",
|
|
166
|
+
LOG_LEVEL: "warn",
|
|
167
|
+
});
|
|
168
|
+
const partitionB = manager.createPartition("Beta Inc", {
|
|
169
|
+
DB_HOST: "beta-db",
|
|
170
|
+
LOG_LEVEL: "info",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Mutate A's variables
|
|
174
|
+
partitionA.setVariables({ DB_HOST: "acme-db-v2", NEW_VAR: "only-acme" });
|
|
175
|
+
|
|
176
|
+
// B is completely unaffected
|
|
177
|
+
expect(partitionB.getVariables()).toEqual({
|
|
178
|
+
DB_HOST: "beta-db",
|
|
179
|
+
LOG_LEVEL: "info",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// A has the updated values
|
|
183
|
+
expect(partitionA.getVariables()).toEqual({
|
|
184
|
+
DB_HOST: "acme-db-v2",
|
|
185
|
+
LOG_LEVEL: "warn",
|
|
186
|
+
NEW_VAR: "only-acme",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("getVariables returns a copy — external mutation cannot corrupt internal state", () => {
|
|
191
|
+
const partition = manager.createPartition("Acme Corp", { DB_HOST: "acme-db" });
|
|
192
|
+
|
|
193
|
+
const vars = partition.getVariables();
|
|
194
|
+
vars.DB_HOST = "CORRUPTED";
|
|
195
|
+
vars.INJECTED = "malicious";
|
|
196
|
+
|
|
197
|
+
// Internal state is untouched
|
|
198
|
+
expect(partition.getVariables()).toEqual({ DB_HOST: "acme-db" });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// -----------------------------------------------------------------------
|
|
203
|
+
// 2. Deployment visibility — A's deployments invisible to B
|
|
204
|
+
// -----------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("deployment visibility isolation", () => {
|
|
207
|
+
it("Partition A deployments are invisible to Partition B", async () => {
|
|
208
|
+
const partitionA = manager.createPartition("Acme Corp");
|
|
209
|
+
const partitionB = manager.createPartition("Beta Inc");
|
|
210
|
+
const env = makeEnvironment();
|
|
211
|
+
|
|
212
|
+
// Deploy to Partition A
|
|
213
|
+
const resultA = await testDeployWithPartition(agent, partitionA, env);
|
|
214
|
+
expect(resultA.status).toBe("succeeded");
|
|
215
|
+
|
|
216
|
+
// Partition A sees its deployment
|
|
217
|
+
expect(partitionA.getDeployments()).toHaveLength(1);
|
|
218
|
+
expect(partitionA.getDeployments()[0].id).toBe(resultA.id);
|
|
219
|
+
|
|
220
|
+
// Partition B sees nothing
|
|
221
|
+
expect(partitionB.getDeployments()).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("Partition B cannot access Partition A deployment by ID", async () => {
|
|
225
|
+
const partitionA = manager.createPartition("Acme Corp");
|
|
226
|
+
const partitionB = manager.createPartition("Beta Inc");
|
|
227
|
+
const env = makeEnvironment();
|
|
228
|
+
|
|
229
|
+
const resultA = await testDeployWithPartition(agent, partitionA, env);
|
|
230
|
+
|
|
231
|
+
// Partition A can access by ID
|
|
232
|
+
expect(partitionA.getDeployment(resultA.id)).toBeDefined();
|
|
233
|
+
expect(partitionA.getDeployment(resultA.id)!.id).toBe(resultA.id);
|
|
234
|
+
|
|
235
|
+
// Partition B cannot access A's deployment — returns undefined
|
|
236
|
+
expect(partitionB.getDeployment(resultA.id)).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("multiple deployments across partitions stay fully partitioned", async () => {
|
|
240
|
+
const partitionA = manager.createPartition("Acme Corp");
|
|
241
|
+
const partitionB = manager.createPartition("Beta Inc");
|
|
242
|
+
const env = makeEnvironment();
|
|
243
|
+
|
|
244
|
+
// Deploy 3 times to A, 2 times to B
|
|
245
|
+
for (let i = 0; i < 3; i++) {
|
|
246
|
+
await testDeployWithPartition(agent, partitionA, env, `a-${i}`);
|
|
247
|
+
}
|
|
248
|
+
for (let i = 0; i < 2; i++) {
|
|
249
|
+
await testDeployWithPartition(agent, partitionB, env, `b-${i}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
expect(partitionA.getDeployments()).toHaveLength(3);
|
|
253
|
+
expect(partitionB.getDeployments()).toHaveLength(2);
|
|
254
|
+
|
|
255
|
+
// Every deployment in A belongs to A
|
|
256
|
+
for (const d of partitionA.getDeployments()) {
|
|
257
|
+
expect(d.partitionId).toBe(partitionA.id);
|
|
258
|
+
}
|
|
259
|
+
// Every deployment in B belongs to B
|
|
260
|
+
for (const d of partitionB.getDeployments()) {
|
|
261
|
+
expect(d.partitionId).toBe(partitionB.id);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// -----------------------------------------------------------------------
|
|
267
|
+
// 3. Diary isolation — A's diary entries invisible to B
|
|
268
|
+
// -----------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe("diary entry isolation", () => {
|
|
271
|
+
it("Partition A diary entries are invisible to Partition B", async () => {
|
|
272
|
+
const partitionA = manager.createPartition("Acme Corp");
|
|
273
|
+
const partitionB = manager.createPartition("Beta Inc");
|
|
274
|
+
const env = makeEnvironment();
|
|
275
|
+
|
|
276
|
+
await testDeployWithPartition(agent, partitionA, env);
|
|
277
|
+
|
|
278
|
+
// A has diary entries
|
|
279
|
+
const entriesA = partitionA.getDebriefEntries();
|
|
280
|
+
expect(entriesA.length).toBeGreaterThan(0);
|
|
281
|
+
|
|
282
|
+
// B has none
|
|
283
|
+
expect(partitionB.getDebriefEntries()).toHaveLength(0);
|
|
284
|
+
|
|
285
|
+
// Every entry in A is tagged with A's partitionId
|
|
286
|
+
for (const entry of entriesA) {
|
|
287
|
+
expect(entry.partitionId).toBe(partitionA.id);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// -----------------------------------------------------------------------
|
|
293
|
+
// 4. Error containment — failure in A doesn't affect B
|
|
294
|
+
// -----------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
describe("error containment", () => {
|
|
297
|
+
it("deployment failure on Partition A does not prevent Partition B deployment", async () => {
|
|
298
|
+
const partitionA = manager.createPartition("Acme Corp");
|
|
299
|
+
const partitionB = manager.createPartition("Beta Inc");
|
|
300
|
+
const env = makeEnvironment();
|
|
301
|
+
|
|
302
|
+
// Partition A: deployment fails (health check fails)
|
|
303
|
+
healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
|
|
304
|
+
const resultA = await testDeployWithPartition(agent, partitionA, env);
|
|
305
|
+
expect(resultA.status).toBe("failed");
|
|
306
|
+
|
|
307
|
+
// Partition B: deployment succeeds — A's failure had no effect
|
|
308
|
+
const resultB = await testDeployWithPartition(agent, partitionB, env);
|
|
309
|
+
expect(resultB.status).toBe("succeeded");
|
|
310
|
+
|
|
311
|
+
// Each partition sees only their own result
|
|
312
|
+
expect(partitionA.getDeployments()).toHaveLength(1);
|
|
313
|
+
expect(partitionA.getDeployments()[0].status).toBe("failed");
|
|
314
|
+
|
|
315
|
+
expect(partitionB.getDeployments()).toHaveLength(1);
|
|
316
|
+
expect(partitionB.getDeployments()[0].status).toBe("succeeded");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("A's failure diary entries don't leak into B's diary", async () => {
|
|
320
|
+
const partitionA = manager.createPartition("Acme Corp");
|
|
321
|
+
const partitionB = manager.createPartition("Beta Inc");
|
|
322
|
+
const env = makeEnvironment();
|
|
323
|
+
|
|
324
|
+
// A fails
|
|
325
|
+
healthChecker.willReturn(CONN_REFUSED, CONN_REFUSED);
|
|
326
|
+
await testDeployWithPartition(agent, partitionA, env);
|
|
327
|
+
|
|
328
|
+
// B succeeds
|
|
329
|
+
await testDeployWithPartition(agent, partitionB, env);
|
|
330
|
+
|
|
331
|
+
// A has failure entries
|
|
332
|
+
const failEntries = findDecisions(
|
|
333
|
+
partitionA.getDebriefEntries(),
|
|
334
|
+
"failed",
|
|
335
|
+
);
|
|
336
|
+
expect(failEntries.length).toBeGreaterThan(0);
|
|
337
|
+
|
|
338
|
+
// B has zero failure entries
|
|
339
|
+
const bFailEntries = findDecisions(
|
|
340
|
+
partitionB.getDebriefEntries(),
|
|
341
|
+
"failed",
|
|
342
|
+
);
|
|
343
|
+
expect(bFailEntries).toHaveLength(0);
|
|
344
|
+
|
|
345
|
+
// B only has success-path entries
|
|
346
|
+
const bSuccess = findDecisions(
|
|
347
|
+
partitionB.getDebriefEntries(),
|
|
348
|
+
"Marking deployment of",
|
|
349
|
+
);
|
|
350
|
+
expect(bSuccess).toHaveLength(1);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// -----------------------------------------------------------------------
|
|
355
|
+
// 5. PartitionManager access control
|
|
356
|
+
// -----------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe("manager access control", () => {
|
|
359
|
+
it("getPartition returns undefined for non-existent partition", () => {
|
|
360
|
+
expect(manager.getPartition("does-not-exist")).toBeUndefined();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("listPartitions exposes metadata only — not data access paths", () => {
|
|
364
|
+
manager.createPartition("Acme Corp", { SECRET: "s3cret" });
|
|
365
|
+
manager.createPartition("Beta Inc");
|
|
366
|
+
|
|
367
|
+
const list = manager.listPartitions();
|
|
368
|
+
expect(list).toHaveLength(2);
|
|
369
|
+
|
|
370
|
+
// List contains id and name only
|
|
371
|
+
for (const item of list) {
|
|
372
|
+
expect(Object.keys(item)).toEqual(["id", "name"]);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Variable precedence resolution
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
describe("Variable Precedence Resolution", () => {
|
|
383
|
+
let diary: DecisionDebrief;
|
|
384
|
+
let deployments: InMemoryDeploymentStore;
|
|
385
|
+
let manager: PartitionManager;
|
|
386
|
+
|
|
387
|
+
beforeEach(() => {
|
|
388
|
+
diary = new DecisionDebrief();
|
|
389
|
+
deployments = new InMemoryDeploymentStore();
|
|
390
|
+
manager = new PartitionManager(deployments, diary);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("partition-level values override environment defaults", () => {
|
|
394
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
395
|
+
LOG_LEVEL: "error",
|
|
396
|
+
DB_HOST: "acme-db",
|
|
397
|
+
});
|
|
398
|
+
const env = makeEnvironment({
|
|
399
|
+
variables: { LOG_LEVEL: "warn", APP_ENV: "production" },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const { resolved, precedenceLog } = partition.resolveVariables(env);
|
|
403
|
+
|
|
404
|
+
expect(resolved.LOG_LEVEL).toBe("error"); // partition wins
|
|
405
|
+
expect(resolved.APP_ENV).toBe("production"); // env only
|
|
406
|
+
expect(resolved.DB_HOST).toBe("acme-db"); // partition only
|
|
407
|
+
|
|
408
|
+
// Precedence log records the override
|
|
409
|
+
const logOverride = precedenceLog.find((e) => e.variable === "LOG_LEVEL");
|
|
410
|
+
expect(logOverride).toBeDefined();
|
|
411
|
+
expect(logOverride!.source).toBe("partition");
|
|
412
|
+
expect(logOverride!.resolvedValue).toBe("error");
|
|
413
|
+
expect(logOverride!.overrode).toEqual({
|
|
414
|
+
value: "warn",
|
|
415
|
+
source: "environment",
|
|
416
|
+
});
|
|
417
|
+
expect(logOverride!.reason).toContain("overrides");
|
|
418
|
+
expect(logOverride!.reason).toContain("environment");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("trigger overrides both partition and environment", () => {
|
|
422
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
423
|
+
LOG_LEVEL: "error",
|
|
424
|
+
});
|
|
425
|
+
const env = makeEnvironment({
|
|
426
|
+
variables: { LOG_LEVEL: "warn", APP_ENV: "production" },
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const { resolved, precedenceLog } = partition.resolveVariables(env, {
|
|
430
|
+
LOG_LEVEL: "debug",
|
|
431
|
+
APP_ENV: "staging",
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(resolved.LOG_LEVEL).toBe("debug"); // trigger > partition
|
|
435
|
+
expect(resolved.APP_ENV).toBe("staging"); // trigger > environment
|
|
436
|
+
|
|
437
|
+
// LOG_LEVEL trigger overrode partition
|
|
438
|
+
const logLevel = precedenceLog.find((e) => e.variable === "LOG_LEVEL");
|
|
439
|
+
expect(logLevel!.source).toBe("trigger");
|
|
440
|
+
expect(logLevel!.overrode).toEqual({ value: "error", source: "partition" });
|
|
441
|
+
|
|
442
|
+
// APP_ENV trigger overrode environment
|
|
443
|
+
const appEnv = precedenceLog.find((e) => e.variable === "APP_ENV");
|
|
444
|
+
expect(appEnv!.source).toBe("trigger");
|
|
445
|
+
expect(appEnv!.overrode).toEqual({
|
|
446
|
+
value: "production",
|
|
447
|
+
source: "environment",
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("full three-layer resolution: trigger > partition > environment", () => {
|
|
452
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
453
|
+
DB_HOST: "acme-db",
|
|
454
|
+
LOG_LEVEL: "error",
|
|
455
|
+
});
|
|
456
|
+
const env = makeEnvironment({
|
|
457
|
+
variables: {
|
|
458
|
+
APP_ENV: "production",
|
|
459
|
+
LOG_LEVEL: "warn",
|
|
460
|
+
REGION: "us-east-1",
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const { resolved, precedenceLog } = partition.resolveVariables(env, {
|
|
465
|
+
LOG_LEVEL: "debug",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Full merged result
|
|
469
|
+
expect(resolved).toEqual({
|
|
470
|
+
APP_ENV: "production", // env only
|
|
471
|
+
LOG_LEVEL: "debug", // trigger > partition > env
|
|
472
|
+
REGION: "us-east-1", // env only
|
|
473
|
+
DB_HOST: "acme-db", // partition only
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Every variable has a log entry
|
|
477
|
+
expect(precedenceLog).toHaveLength(4);
|
|
478
|
+
|
|
479
|
+
// LOG_LEVEL: trigger wins over partition
|
|
480
|
+
const logLevel = precedenceLog.find((e) => e.variable === "LOG_LEVEL")!;
|
|
481
|
+
expect(logLevel.source).toBe("trigger");
|
|
482
|
+
expect(logLevel.overrode!.source).toBe("partition");
|
|
483
|
+
expect(logLevel.overrode!.value).toBe("error");
|
|
484
|
+
|
|
485
|
+
// REGION: environment default, no override
|
|
486
|
+
const region = precedenceLog.find((e) => e.variable === "REGION")!;
|
|
487
|
+
expect(region.source).toBe("environment");
|
|
488
|
+
expect(region.overrode).toBeNull();
|
|
489
|
+
expect(region.reason).toContain("no higher-level override");
|
|
490
|
+
|
|
491
|
+
// DB_HOST: partition-only
|
|
492
|
+
const dbHost = precedenceLog.find((e) => e.variable === "DB_HOST")!;
|
|
493
|
+
expect(dbHost.source).toBe("partition");
|
|
494
|
+
expect(dbHost.overrode).toBeNull();
|
|
495
|
+
expect(dbHost.reason).toContain("not defined at environment level");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("non-conflicting variables from all levels merge correctly", () => {
|
|
499
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
500
|
+
PARTITION_ONLY: "t-val",
|
|
501
|
+
});
|
|
502
|
+
const env = makeEnvironment({
|
|
503
|
+
variables: { ENV_ONLY: "e-val" },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const { resolved, precedenceLog } = partition.resolveVariables(env, {
|
|
507
|
+
TRIGGER_ONLY: "tr-val",
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(resolved).toEqual({
|
|
511
|
+
ENV_ONLY: "e-val",
|
|
512
|
+
PARTITION_ONLY: "t-val",
|
|
513
|
+
TRIGGER_ONLY: "tr-val",
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// No overrides — all variables come from distinct levels
|
|
517
|
+
for (const entry of precedenceLog) {
|
|
518
|
+
expect(entry.overrode).toBeNull();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("same value at multiple levels is not reported as an override", () => {
|
|
523
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
524
|
+
APP_ENV: "production",
|
|
525
|
+
});
|
|
526
|
+
const env = makeEnvironment({
|
|
527
|
+
variables: { APP_ENV: "production" },
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const { resolved, precedenceLog } = partition.resolveVariables(env);
|
|
531
|
+
|
|
532
|
+
expect(resolved.APP_ENV).toBe("production");
|
|
533
|
+
|
|
534
|
+
// Partition "wins" by precedence but value is the same — no override reported
|
|
535
|
+
const appEnv = precedenceLog.find((e) => e.variable === "APP_ENV")!;
|
|
536
|
+
expect(appEnv.source).toBe("partition");
|
|
537
|
+
expect(appEnv.overrode).toBeNull();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("precedence log entries have plain-language reason for every conflict", () => {
|
|
541
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
542
|
+
DB_HOST: "acme-db",
|
|
543
|
+
LOG_LEVEL: "error",
|
|
544
|
+
});
|
|
545
|
+
const env = makeEnvironment({
|
|
546
|
+
variables: { DB_HOST: "default-db", LOG_LEVEL: "warn", REGION: "us-east-1" },
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const { precedenceLog } = partition.resolveVariables(env, {
|
|
550
|
+
LOG_LEVEL: "debug",
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Every entry has a non-empty reason
|
|
554
|
+
for (const entry of precedenceLog) {
|
|
555
|
+
expect(entry.reason.length).toBeGreaterThan(0);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Overrides explain what was overridden
|
|
559
|
+
const dbHost = precedenceLog.find((e) => e.variable === "DB_HOST")!;
|
|
560
|
+
expect(dbHost.reason).toContain("overrides");
|
|
561
|
+
expect(dbHost.reason).toContain("environment");
|
|
562
|
+
|
|
563
|
+
const logLevel = precedenceLog.find((e) => e.variable === "LOG_LEVEL")!;
|
|
564
|
+
expect(logLevel.reason).toContain("takes precedence");
|
|
565
|
+
expect(logLevel.reason).toContain("partition");
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// Variable precedence integrated with SynthAgent + Decision Diary
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
describe("Precedence Recording in Decision Diary", () => {
|
|
574
|
+
let diary: DecisionDebrief;
|
|
575
|
+
let deployments: InMemoryDeploymentStore;
|
|
576
|
+
let healthChecker: MockHealthChecker;
|
|
577
|
+
let agent: SynthAgent;
|
|
578
|
+
let manager: PartitionManager;
|
|
579
|
+
|
|
580
|
+
beforeEach(() => {
|
|
581
|
+
diary = new DecisionDebrief();
|
|
582
|
+
deployments = new InMemoryDeploymentStore();
|
|
583
|
+
healthChecker = new MockHealthChecker();
|
|
584
|
+
artifactStore = new ArtifactStore();
|
|
585
|
+
envStore = new EnvironmentStore();
|
|
586
|
+
partStore = new PartitionStore();
|
|
587
|
+
agent = new SynthAgent(
|
|
588
|
+
diary, deployments, artifactStore, envStore, partStore,
|
|
589
|
+
healthChecker, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
|
|
590
|
+
);
|
|
591
|
+
manager = new PartitionManager(deployments, diary);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("SynthAgent records variable conflicts to the diary with full reasoning", async () => {
|
|
595
|
+
const partition = manager.createPartition("Acme Corp", {
|
|
596
|
+
LOG_LEVEL: "error",
|
|
597
|
+
});
|
|
598
|
+
const env = makeEnvironment({
|
|
599
|
+
variables: { LOG_LEVEL: "warn", APP_ENV: "production" },
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const result = await testDeployWithPartition(
|
|
603
|
+
agent,
|
|
604
|
+
partition,
|
|
605
|
+
env,
|
|
606
|
+
"1.0.0",
|
|
607
|
+
{ LOG_LEVEL: "debug" },
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
expect(result.status).toBe("succeeded");
|
|
611
|
+
|
|
612
|
+
// The agent's diary entries record the conflict resolution
|
|
613
|
+
const entries = partition.getDebriefEntries();
|
|
614
|
+
const configEntries = findDecisions(entries, "Accepted configuration");
|
|
615
|
+
expect(configEntries).toHaveLength(1);
|
|
616
|
+
expect(configEntries[0].reasoning).toContain("precedence");
|
|
617
|
+
expect(configEntries[0].reasoning).toContain("conflict");
|
|
618
|
+
|
|
619
|
+
// The standard override was recorded
|
|
620
|
+
const overrideEntries = findDecisions(entries, "precedence rules");
|
|
621
|
+
expect(overrideEntries.length).toBeGreaterThan(0);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// Scale: 50 partitions
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
describe("Scale: 50 Partitions", () => {
|
|
630
|
+
let diary: DecisionDebrief;
|
|
631
|
+
let deployments: InMemoryDeploymentStore;
|
|
632
|
+
let healthChecker: MockHealthChecker;
|
|
633
|
+
let agent: SynthAgent;
|
|
634
|
+
let manager: PartitionManager;
|
|
635
|
+
|
|
636
|
+
beforeEach(() => {
|
|
637
|
+
diary = new DecisionDebrief();
|
|
638
|
+
deployments = new InMemoryDeploymentStore();
|
|
639
|
+
healthChecker = new MockHealthChecker();
|
|
640
|
+
artifactStore = new ArtifactStore();
|
|
641
|
+
envStore = new EnvironmentStore();
|
|
642
|
+
partStore = new PartitionStore();
|
|
643
|
+
agent = new SynthAgent(
|
|
644
|
+
diary, deployments, artifactStore, envStore, partStore,
|
|
645
|
+
healthChecker, { healthCheckBackoffMs: 1, executionDelayMs: 1 },
|
|
646
|
+
);
|
|
647
|
+
manager = new PartitionManager(deployments, diary);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("creates 50 partitions without performance degradation", () => {
|
|
651
|
+
const start = performance.now();
|
|
652
|
+
|
|
653
|
+
const partitions = Array.from({ length: 50 }, (_, i) =>
|
|
654
|
+
manager.createPartition(`Partition-${i}`, {
|
|
655
|
+
DB_HOST: `db-${i}.internal`,
|
|
656
|
+
APP_ENV: "production",
|
|
657
|
+
PARTITION_ID: `t-${i}`,
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
const elapsed = performance.now() - start;
|
|
662
|
+
|
|
663
|
+
expect(manager.size).toBe(50);
|
|
664
|
+
expect(partitions).toHaveLength(50);
|
|
665
|
+
|
|
666
|
+
// Creation of 50 partitions should complete in well under 1 second
|
|
667
|
+
expect(elapsed).toBeLessThan(1000);
|
|
668
|
+
|
|
669
|
+
// Each partition has unique id
|
|
670
|
+
const ids = new Set(partitions.map((t) => t.id));
|
|
671
|
+
expect(ids.size).toBe(50);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("50 partitions maintain full isolation across deployments", async () => {
|
|
675
|
+
const env = makeEnvironment({
|
|
676
|
+
variables: { APP_ENV: "production", LOG_LEVEL: "warn" },
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Create 50 partitions with distinct variables
|
|
680
|
+
const partitions = Array.from({ length: 50 }, (_, i) =>
|
|
681
|
+
manager.createPartition(`Partition-${i}`, {
|
|
682
|
+
DB_HOST: `db-${i}.internal`,
|
|
683
|
+
PARTITION_MARKER: `marker-${i}`,
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// Deploy to all 50 partitions
|
|
688
|
+
const start = performance.now();
|
|
689
|
+
const results = await Promise.all(
|
|
690
|
+
partitions.map((partition) =>
|
|
691
|
+
testDeployWithPartition(agent, partition, env),
|
|
692
|
+
),
|
|
693
|
+
);
|
|
694
|
+
const elapsed = performance.now() - start;
|
|
695
|
+
|
|
696
|
+
// All 50 succeed
|
|
697
|
+
for (const result of results) {
|
|
698
|
+
expect(result.status).toBe("succeeded");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 50 deployments in under 5 seconds (generous bound)
|
|
702
|
+
expect(elapsed).toBeLessThan(5000);
|
|
703
|
+
|
|
704
|
+
// Isolation: each partition sees exactly 1 deployment
|
|
705
|
+
for (let i = 0; i < 50; i++) {
|
|
706
|
+
const partitionDeployments = partitions[i].getDeployments();
|
|
707
|
+
expect(partitionDeployments).toHaveLength(1);
|
|
708
|
+
expect(partitionDeployments[0].partitionId).toBe(partitions[i].id);
|
|
709
|
+
|
|
710
|
+
// Variables resolved with this partition's specific values
|
|
711
|
+
expect(partitionDeployments[0].variables.DB_HOST).toBe(
|
|
712
|
+
`db-${i}.internal`,
|
|
713
|
+
);
|
|
714
|
+
expect(partitionDeployments[0].variables.PARTITION_MARKER).toBe(
|
|
715
|
+
`marker-${i}`,
|
|
716
|
+
);
|
|
717
|
+
expect(partitionDeployments[0].variables.APP_ENV).toBe("production");
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("partition lookup is O(1) — constant time regardless of count", () => {
|
|
722
|
+
// Create 50 partitions
|
|
723
|
+
const partitions = Array.from({ length: 50 }, (_, i) =>
|
|
724
|
+
manager.createPartition(`Partition-${i}`),
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
// Lookup the 1st, 25th, and 50th partition — all should be fast
|
|
728
|
+
const ids = [partitions[0].id, partitions[24].id, partitions[49].id];
|
|
729
|
+
const times: number[] = [];
|
|
730
|
+
|
|
731
|
+
for (const id of ids) {
|
|
732
|
+
const start = performance.now();
|
|
733
|
+
for (let i = 0; i < 1000; i++) {
|
|
734
|
+
manager.getPartition(id);
|
|
735
|
+
}
|
|
736
|
+
times.push(performance.now() - start);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// All 1000 lookups per partition should be sub-millisecond territory
|
|
740
|
+
// (generous bound: 50ms for 1000 lookups)
|
|
741
|
+
for (const t of times) {
|
|
742
|
+
expect(t).toBeLessThan(50);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// No dramatic variance — first and last should be within 10x of each other
|
|
746
|
+
const ratio = Math.max(...times) / Math.min(...times);
|
|
747
|
+
expect(ratio).toBeLessThan(10);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("diary entries stay partitioned across all 50 partitions", async () => {
|
|
751
|
+
const env = makeEnvironment();
|
|
752
|
+
|
|
753
|
+
const partitions = Array.from({ length: 50 }, (_, i) =>
|
|
754
|
+
manager.createPartition(`Partition-${i}`),
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
// Deploy to all 50
|
|
758
|
+
await Promise.all(
|
|
759
|
+
partitions.map((partition) =>
|
|
760
|
+
testDeployWithPartition(agent, partition, env),
|
|
761
|
+
),
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// The full diary has entries for all 50
|
|
765
|
+
const allEntries = diary.getRecent(10000);
|
|
766
|
+
expect(allEntries.length).toBeGreaterThan(0);
|
|
767
|
+
|
|
768
|
+
// But each container only sees its own
|
|
769
|
+
for (let i = 0; i < 50; i++) {
|
|
770
|
+
const entries = partitions[i].getDebriefEntries();
|
|
771
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
772
|
+
for (const entry of entries) {
|
|
773
|
+
expect(entry.partitionId).toBe(partitions[i].id);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// No partition sees another partition's entries
|
|
778
|
+
for (let i = 0; i < 50; i++) {
|
|
779
|
+
const myEntries = partitions[i].getDebriefEntries();
|
|
780
|
+
for (const entry of myEntries) {
|
|
781
|
+
// This entry should NOT appear in any other partition's view
|
|
782
|
+
for (let j = 0; j < 50; j++) {
|
|
783
|
+
if (j === i) continue;
|
|
784
|
+
const otherEntries = partitions[j].getDebriefEntries();
|
|
785
|
+
const leaked = otherEntries.find((e) => e.id === entry.id);
|
|
786
|
+
expect(leaked).toBeUndefined();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("variable resolution at scale — 50 partitions with different overrides", () => {
|
|
793
|
+
const env = makeEnvironment({
|
|
794
|
+
variables: {
|
|
795
|
+
APP_ENV: "production",
|
|
796
|
+
LOG_LEVEL: "warn",
|
|
797
|
+
REGION: "us-east-1",
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const partitions = Array.from({ length: 50 }, (_, i) =>
|
|
802
|
+
manager.createPartition(`Partition-${i}`, {
|
|
803
|
+
LOG_LEVEL: i % 2 === 0 ? "error" : "info",
|
|
804
|
+
DB_HOST: `db-${i}.internal`,
|
|
805
|
+
}),
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
const start = performance.now();
|
|
809
|
+
|
|
810
|
+
for (let i = 0; i < 50; i++) {
|
|
811
|
+
const { resolved, precedenceLog } = partitions[i].resolveVariables(env);
|
|
812
|
+
|
|
813
|
+
// Correct precedence applied
|
|
814
|
+
expect(resolved.APP_ENV).toBe("production"); // env
|
|
815
|
+
expect(resolved.REGION).toBe("us-east-1"); // env
|
|
816
|
+
expect(resolved.DB_HOST).toBe(`db-${i}.internal`); // partition
|
|
817
|
+
expect(resolved.LOG_LEVEL).toBe(i % 2 === 0 ? "error" : "info"); // partition overrides env
|
|
818
|
+
|
|
819
|
+
// LOG_LEVEL override recorded
|
|
820
|
+
const logEntry = precedenceLog.find((e) => e.variable === "LOG_LEVEL")!;
|
|
821
|
+
expect(logEntry.source).toBe("partition");
|
|
822
|
+
expect(logEntry.overrode).toEqual({ value: "warn", source: "environment" });
|
|
823
|
+
expect(logEntry.reason).toContain("overrides");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const elapsed = performance.now() - start;
|
|
827
|
+
// 50 resolutions in under 500ms
|
|
828
|
+
expect(elapsed).toBeLessThan(500);
|
|
829
|
+
});
|
|
830
|
+
});
|