From a00bb64cc6e1eb03632822dc4a009d7dfb06b0cb Mon Sep 17 00:00:00 2001 From: Hareet Date: Wed, 10 Jun 2026 06:13:59 -0600 Subject: [PATCH 01/24] =?UTF-8?q?feat(#66):=20phase-1=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20readiness=20poller,=20env=20override/scripts,=20pro?= =?UTF-8?q?vision=20real=20path=20(h-gated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/cht-agent-net.override.yml | 29 ++++++++++ scripts/test-env-down.sh | 13 +++++ scripts/test-env-up.sh | 27 ++++++++++ src/agents/test-environment-agent.ts | 35 ++++++++++--- src/types/index.ts | 18 +++++++ src/utils/cht-readiness.ts | 48 +++++++++++++++++ test/agents/test-environment-agent.spec.ts | 57 +++++++++++++++++--- test/utils/cht-readiness.spec.ts | 61 ++++++++++++++++++++++ 8 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 docker/cht-agent-net.override.yml create mode 100755 scripts/test-env-down.sh create mode 100755 scripts/test-env-up.sh create mode 100644 src/utils/cht-readiness.ts create mode 100644 test/utils/cht-readiness.spec.ts diff --git a/docker/cht-agent-net.override.yml b/docker/cht-agent-net.override.yml new file mode 100644 index 00000000..1e6c9dc3 --- /dev/null +++ b/docker/cht-agent-net.override.yml @@ -0,0 +1,29 @@ +# cht-agent-net.override.yml +# +# Layers onto cht-core's local-build compose (or the docker-helper stack) so the +# running CHT instance joins the shared `cht-agent-net` network that the +# dockerized cht-agent is also attached to. This lets the agent reach the +# instance at https://nginx with no host-port juggling and — importantly — with +# NO Docker access (the agent only polls /api/v2/monitoring). +# +# HUMAN-run only (the cht-agent never runs Docker): +# docker compose -f cht-couchdb.yml -f cht-core.yml -f cht-agent-net.override.yml up -d +# +# ⚠️ VALIDATE before relying on this: the service name (`nginx`) and the stack's +# internal network name (`cht-net` below) must match what `npm run local-images` +# generates in `local-build/` for your cht-core version. Confirm with: +# docker compose ... config # see resolved service/network names +# docker network inspect cht-agent-net # confirm nginx is attached +# If the internal network differs, update the first entry under nginx.networks. + +networks: + cht-agent-net: + external: true + +services: + # nginx is the HTTPS entry point the agent reaches. Keep it on the stack's + # internal network AND join the shared cht-agent-net. + nginx: + networks: + - cht-net + - cht-agent-net diff --git a/scripts/test-env-down.sh b/scripts/test-env-down.sh new file mode 100755 index 00000000..12293d37 --- /dev/null +++ b/scripts/test-env-down.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Human-run teardown for the Test Environment Layer. +# Usage: scripts/test-env-down.sh +set -euo pipefail + +CHT_CORE_PATH="${1:?Usage: test-env-down.sh }" +OVERRIDE="$(cd "$(dirname "$0")/.." && pwd)/docker/cht-agent-net.override.yml" + +cd "$CHT_CORE_PATH/local-build" +docker compose -f cht-couchdb.yml -f cht-core.yml -f "$OVERRIDE" down -v + +echo "CHT environment torn down (-v removed volumes for a clean slate)." diff --git a/scripts/test-env-up.sh b/scripts/test-env-up.sh new file mode 100755 index 00000000..9bc234dd --- /dev/null +++ b/scripts/test-env-up.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# Human-run bring-up for the Test Environment Layer (Model A — rebuild on change). +# The cht-agent NEVER runs this or any Docker command itself; the agent's +# provision() only polls /api/v2/monitoring and waits for this to finish. +# +# Usage: scripts/test-env-up.sh +# +# Env overrides: CHT_AGENT_NET (default cht-agent-net), COUCHDB_PASSWORD (default password) +set -euo pipefail + +CHT_CORE_PATH="${1:?Usage: test-env-up.sh }" +NETWORK="${CHT_AGENT_NET:-cht-agent-net}" +OVERRIDE="$(cd "$(dirname "$0")/.." && pwd)/docker/cht-agent-net.override.yml" + +# 1. Shared network the cht-agent and CHT both join (idempotent). +docker network create "$NETWORK" 2>/dev/null || true + +# 2. Build images from local code. +( cd "$CHT_CORE_PATH" && npm run local-images ) + +# 3. Start the stack, joined to the shared network via the override. +cd "$CHT_CORE_PATH/local-build" +COUCHDB_PASSWORD="${COUCHDB_PASSWORD:-password}" docker compose \ + -f cht-couchdb.yml -f cht-core.yml -f "$OVERRIDE" up -d + +echo "CHT starting on network '$NETWORK'. The agent will poll /api/v2/monitoring until healthy." diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index bbf0c5dd..2d14d676 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -18,9 +18,18 @@ import { TestDataResult, } from '../types'; import { MOCK_TEST_ENV_DATA } from './test-environment-agent.mock-data'; +import { waitForReady } from '../utils/cht-readiness'; const NOT_IMPLEMENTED = 'Docker orchestration not yet implemented'; +// Real-path defaults: the human brings CHT up on cht-agent-net; the agent +// reaches it at the nginx service hostname with the cht-docker-compose.sh creds. +const DEFAULT_ENV_URL = 'https://nginx'; +const DEFAULT_NETWORK = 'cht-agent-net'; +const DEFAULT_AUTH = { user: 'medic', password: 'password' }; +// Humans may take minutes to run local-images + compose up. +const DEFAULT_PROVISION_WAIT_MS = 300_000; + export class TestEnvironmentAgent { private readonly useMockDocker: boolean; @@ -37,21 +46,33 @@ export class TestEnvironmentAgent { throw new Error('provision requires either chtCorePath or version'); } - if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); - } - const source = options.chtCorePath ? `local code (${options.chtCorePath})` : `published version ${options.version}`; + const network = options.network ?? DEFAULT_NETWORK; console.log('\n[Test Environment Agent] Provisioning environment...'); console.log(`[Test Environment Agent] Source: ${source}`); + if (!this.useMockDocker) { + const url = options.url ?? DEFAULT_ENV_URL; + const auth = options.auth ?? DEFAULT_AUTH; + + // The agent runs no Docker — the human brings the environment up. + console.log('[Test Environment Agent] HUMAN GATE — bring the env up (the agent runs no Docker):'); + console.log(` scripts/test-env-up.sh ${options.chtCorePath ?? ''} # build + start on ${network}`); + console.log(`[Test Environment Agent] Polling ${url}/api/v2/monitoring until healthy...`); + + await waitForReady(url, { maxWaitMs: DEFAULT_PROVISION_WAIT_MS, ...options.readiness }); + + console.log(`[Test Environment Agent] Ready at ${url} (network: ${network})`); + return { url, auth: { ...auth }, network, chtCorePath: options.chtCorePath, source: 'docker' }; + } + const handle: EnvironmentHandle = { - url: MOCK_TEST_ENV_DATA.url, - auth: { ...MOCK_TEST_ENV_DATA.auth }, - network: options.network ?? MOCK_TEST_ENV_DATA.network, + url: options.url ?? MOCK_TEST_ENV_DATA.url, + auth: { ...(options.auth ?? MOCK_TEST_ENV_DATA.auth) }, + network, chtCorePath: options.chtCorePath, source: 'mock', }; diff --git a/src/types/index.ts b/src/types/index.ts index 48e903d2..382d4726 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -774,6 +774,18 @@ export interface DiscoveredConfig { forms: string[]; } +/** + * Tuning for readiness polling (see src/utils/cht-readiness.ts) + */ +export interface ReadinessOptions { + /** Maximum total time to wait before giving up (ms). */ + maxWaitMs?: number; + /** Initial delay between polls (ms). */ + initialDelayMs?: number; + /** Upper bound on the exponential backoff delay (ms). */ + maxDelayMs?: number; +} + /** * Inputs to provision an environment (need a local code path OR a published version) */ @@ -781,6 +793,12 @@ export interface ProvisionOptions { chtCorePath?: string; version?: string; network?: string; + /** Target URL of the running instance (default: https://nginx on cht-agent-net). */ + url?: string; + /** Credentials for the instance (default: medic/password — cht-docker-compose.sh defaults). */ + auth?: { user: string; password: string }; + /** Readiness polling tuning (the human may take minutes to bring the env up). */ + readiness?: ReadinessOptions; } /** diff --git a/src/utils/cht-readiness.ts b/src/utils/cht-readiness.ts new file mode 100644 index 00000000..21e11f46 --- /dev/null +++ b/src/utils/cht-readiness.ts @@ -0,0 +1,48 @@ +/** + * CHT readiness polling for the Test Environment Layer. + * + * The agent never runs Docker — the human brings the environment up. This util + * polls the CHT monitoring endpoint until the instance reports healthy, or + * rejects after a timeout with a clear "is the environment up?" message. + * + * See: designs/layer_recommendations/test-environment-layer.md + */ + +import { ReadinessOptions } from '../types'; + +const MONITORING_PATH = '/api/v2/monitoring'; + +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Poll `${url}/api/v2/monitoring` until it responds OK, using exponential + * backoff. Resolves once healthy; rejects if it never becomes ready in time. + */ +export const waitForReady = async (url: string, options: ReadinessOptions = {}): Promise => { + const maxWaitMs = options.maxWaitMs ?? 120_000; + const maxDelayMs = options.maxDelayMs ?? 15_000; + let delay = options.initialDelayMs ?? 2_000; + + const start = Date.now(); + let lastError = 'no response'; + + while (Date.now() - start < maxWaitMs) { + try { + const response = await fetch(`${url}${MONITORING_PATH}`); + if (response.ok) { + return; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : 'unreachable'; + } + + await sleep(delay); + delay = Math.min(delay * 1.5, maxDelayMs); + } + + throw new Error( + `CHT did not become ready at ${url} within ${maxWaitMs}ms (last: ${lastError}). ` + + 'Is the environment up? Bring it up (human-gated) and retry.' + ); +}; diff --git a/test/agents/test-environment-agent.spec.ts b/test/agents/test-environment-agent.spec.ts index 03d25bed..9b0cd6f8 100644 --- a/test/agents/test-environment-agent.spec.ts +++ b/test/agents/test-environment-agent.spec.ts @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import * as sinon from 'sinon'; import { TestEnvironmentAgent } from '../../src/agents/test-environment-agent'; import { DiscoveredConfig, EnvironmentHandle, ProvisionOptions, ResetTier } from '../../src/types'; @@ -71,15 +72,55 @@ describe('TestEnvironmentAgent', () => { } }); - it('should throw not-implemented in real mode', async () => { - const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + describe('real mode (useMockDocker: false)', () => { + let fetchStub: sinon.SinonStub; - try { - await realAgent.provision({ chtCorePath: '/workspace/cht-core' }); - expect.fail('Should have thrown an error'); - } catch (error) { - expect((error as Error).message).to.include('not yet implemented'); - } + beforeEach(() => { + fetchStub = sinon.stub(globalThis, 'fetch' as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns a docker handle once the environment is healthy', async () => { + fetchStub.resolves({ ok: true, status: 200 }); + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + const handle = await realAgent.provision({ chtCorePath: '/workspace/cht-core' }); + + expect(handle.source).to.equal('docker'); + expect(handle.url).to.equal('https://nginx'); + expect(handle.auth).to.deep.equal({ user: 'medic', password: 'password' }); + expect(handle.network).to.equal('cht-agent-net'); + expect(handle.chtCorePath).to.equal('/workspace/cht-core'); + }); + + it('rejects if the environment never becomes ready', async () => { + fetchStub.resolves({ ok: false, status: 503 }); + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + try { + await realAgent.provision({ + chtCorePath: '/workspace/cht-core', + readiness: { maxWaitMs: 30, initialDelayMs: 0, maxDelayMs: 0 }, + }); + expect.fail('expected provision to reject'); + } catch (error) { + expect((error as Error).message).to.include('did not become ready'); + } + }); + + it('validates input before polling', async () => { + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + try { + await realAgent.provision({}); + expect.fail('expected provision to reject'); + } catch (error) { + expect((error as Error).message).to.include('requires either chtCorePath or version'); + } + }); }); }); diff --git a/test/utils/cht-readiness.spec.ts b/test/utils/cht-readiness.spec.ts new file mode 100644 index 00000000..bc9e3e86 --- /dev/null +++ b/test/utils/cht-readiness.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { waitForReady } from '../../src/utils/cht-readiness'; + +describe('cht-readiness', () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + // `fetch` is a Node global; stub it the same way the MCP client spec does. + fetchStub = sinon.stub(globalThis, 'fetch' as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + // Zero delays keep the backoff loop fast in tests. + const fast = { initialDelayMs: 0, maxDelayMs: 0 }; + + describe('waitForReady', () => { + it('resolves once monitoring responds OK', async () => { + fetchStub.resolves({ ok: true, status: 200 }); + + await waitForReady('https://nginx', fast); + + expect(fetchStub.calledOnce).to.equal(true); + expect(fetchStub.firstCall.args[0]).to.equal('https://nginx/api/v2/monitoring'); + }); + + it('retries until healthy (503, 503, then 200)', async () => { + fetchStub.onCall(0).resolves({ ok: false, status: 503 }); + fetchStub.onCall(1).resolves({ ok: false, status: 503 }); + fetchStub.onCall(2).resolves({ ok: true, status: 200 }); + + await waitForReady('https://nginx', fast); + + expect(fetchStub.callCount).to.equal(3); + }); + + it('retries when fetch rejects (connection refused), then succeeds', async () => { + fetchStub.onCall(0).rejects(new Error('ECONNREFUSED')); + fetchStub.onCall(1).resolves({ ok: true, status: 200 }); + + await waitForReady('https://nginx', fast); + + expect(fetchStub.callCount).to.equal(2); + }); + + it('rejects with a clear message if never ready before timeout', async () => { + fetchStub.resolves({ ok: false, status: 503 }); + + try { + await waitForReady('https://nginx', { initialDelayMs: 0, maxDelayMs: 0, maxWaitMs: 30 }); + expect.fail('expected waitForReady to reject'); + } catch (error) { + expect((error as Error).message).to.include('did not become ready'); + expect((error as Error).message).to.include('https://nginx'); + } + }); + }); +}); From 7bbc0784b4598d891cb8450b6588a7ff72ea2f23 Mon Sep 17 00:00:00 2001 From: Hareet Date: Wed, 10 Jun 2026 06:54:04 -0600 Subject: [PATCH 02/24] =?UTF-8?q?refactor(#66):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20fetch=20timeout,=20bounded=20readiness=20wait,=20do?= =?UTF-8?q?c=20+=20test=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/test-environment-agent.ts | 18 +++++++++++++----- src/types/index.ts | 2 ++ src/utils/cht-readiness.ts | 12 ++++++++++-- test/utils/cht-readiness.spec.ts | 6 ++++-- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index 2d14d676..900b63a0 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -4,8 +4,9 @@ * Deterministic provisioning orchestrator for the Test Environment Layer * (QA Supervisor). It provisions a live CHT instance, applies a config, * discovers the deployed config, and seeds conforming test data. No LLM is - * involved. This file implements mock mode only; the real Docker / cht-conf / - * CouchDB orchestration lands in #66. + * involved. provision()'s real path (human-gated bring-up + readiness polling) + * is implemented; applyConfig/discoverConfig/prepareTestData/reset/teardown + * real paths are not yet implemented (later #66 phases). * * See: designs/layer_recommendations/test-environment-layer.md */ @@ -59,14 +60,21 @@ export class TestEnvironmentAgent { const auth = options.auth ?? DEFAULT_AUTH; // The agent runs no Docker — the human brings the environment up. - console.log('[Test Environment Agent] HUMAN GATE — bring the env up (the agent runs no Docker):'); - console.log(` scripts/test-env-up.sh ${options.chtCorePath ?? ''} # build + start on ${network}`); + const target = options.chtCorePath ?? ''; + console.log('[Test Environment Agent] HUMAN GATE — bring the env up (agent runs no Docker):'); + console.log(` scripts/test-env-up.sh ${target} # build + start on ${network}`); console.log(`[Test Environment Agent] Polling ${url}/api/v2/monitoring until healthy...`); await waitForReady(url, { maxWaitMs: DEFAULT_PROVISION_WAIT_MS, ...options.readiness }); console.log(`[Test Environment Agent] Ready at ${url} (network: ${network})`); - return { url, auth: { ...auth }, network, chtCorePath: options.chtCorePath, source: 'docker' }; + return { + url, + auth: { ...auth }, + network, + chtCorePath: options.chtCorePath, + source: 'docker', + }; } const handle: EnvironmentHandle = { diff --git a/src/types/index.ts b/src/types/index.ts index 382d4726..2262bf27 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -784,6 +784,8 @@ export interface ReadinessOptions { initialDelayMs?: number; /** Upper bound on the exponential backoff delay (ms). */ maxDelayMs?: number; + /** Per-request timeout so a hung connection can't block past maxWaitMs (ms). */ + requestTimeoutMs?: number; } /** diff --git a/src/utils/cht-readiness.ts b/src/utils/cht-readiness.ts index 21e11f46..75889d09 100644 --- a/src/utils/cht-readiness.ts +++ b/src/utils/cht-readiness.ts @@ -21,6 +21,7 @@ const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(r export const waitForReady = async (url: string, options: ReadinessOptions = {}): Promise => { const maxWaitMs = options.maxWaitMs ?? 120_000; const maxDelayMs = options.maxDelayMs ?? 15_000; + const requestTimeoutMs = options.requestTimeoutMs ?? 10_000; let delay = options.initialDelayMs ?? 2_000; const start = Date.now(); @@ -28,7 +29,10 @@ export const waitForReady = async (url: string, options: ReadinessOptions = {}): while (Date.now() - start < maxWaitMs) { try { - const response = await fetch(`${url}${MONITORING_PATH}`); + // Bound each request so a hung connection can't block past maxWaitMs. + const response = await fetch(`${url}${MONITORING_PATH}`, { + signal: AbortSignal.timeout(requestTimeoutMs), + }); if (response.ok) { return; } @@ -37,7 +41,11 @@ export const waitForReady = async (url: string, options: ReadinessOptions = {}): lastError = error instanceof Error ? error.message : 'unreachable'; } - await sleep(delay); + const remaining = maxWaitMs - (Date.now() - start); + if (remaining <= 0) { + break; + } + await sleep(Math.min(delay, remaining)); delay = Math.min(delay * 1.5, maxDelayMs); } diff --git a/test/utils/cht-readiness.spec.ts b/test/utils/cht-readiness.spec.ts index bc9e3e86..ecd88120 100644 --- a/test/utils/cht-readiness.spec.ts +++ b/test/utils/cht-readiness.spec.ts @@ -32,8 +32,9 @@ describe('cht-readiness', () => { fetchStub.onCall(1).resolves({ ok: false, status: 503 }); fetchStub.onCall(2).resolves({ ok: true, status: 200 }); - await waitForReady('https://nginx', fast); + const result = await waitForReady('https://nginx', fast); + expect(result).to.equal(undefined); expect(fetchStub.callCount).to.equal(3); }); @@ -41,8 +42,9 @@ describe('cht-readiness', () => { fetchStub.onCall(0).rejects(new Error('ECONNREFUSED')); fetchStub.onCall(1).resolves({ ok: true, status: 200 }); - await waitForReady('https://nginx', fast); + const result = await waitForReady('https://nginx', fast); + expect(result).to.equal(undefined); expect(fetchStub.callCount).to.equal(2); }); From 1e877fe87af09f1b222308899e1bcb99e2385bb7 Mon Sep 17 00:00:00 2001 From: Hareet Date: Mon, 15 Jun 2026 04:17:24 -0600 Subject: [PATCH 03/24] chore: save point in test environment setup --- src/agents/test-environment-agent.ts | 23 ++++++++++++-- test/agents/test-environment-agent.spec.ts | 37 +++++++++++++--------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index 900b63a0..fa86b6af 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -150,7 +150,22 @@ export class TestEnvironmentAgent { */ async reset(handle: EnvironmentHandle, tier: ResetTier): Promise { if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); + // couchdb-tier wipe/reseed operates on the docs prepareTestData seeds, + // so it lands with test-data prep in a later phase. + if (tier === 'couchdb') { + throw new Error('couchdb-tier reset requires seeded-data tracking (lands in #66 Phase 3)'); + } + + // restart/full are Docker operations — human-gated, the agent runs none. + const target = handle.chtCorePath ?? ''; + console.log(`[Test Environment Agent] HUMAN GATE — reset (${tier}); the agent runs no Docker:`); + if (tier === 'restart') { + console.log(` (in ${target}/local-build) docker compose restart`); + } else { + console.log(` scripts/test-env-down.sh ${target} && scripts/test-env-up.sh ${target}`); + } + console.log('[Test Environment Agent] Re-confirm health with provision()/waitForReady after.'); + return; } console.log(`[Test Environment Agent] Reset (${tier}) -> ${handle.url}`); @@ -162,7 +177,11 @@ export class TestEnvironmentAgent { */ async teardown(handle: EnvironmentHandle): Promise { if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); + // Teardown is `docker compose down -v` — human-gated, the agent runs none. + const target = handle.chtCorePath ?? ''; + console.log('[Test Environment Agent] HUMAN GATE — teardown (the agent runs no Docker):'); + console.log(` scripts/test-env-down.sh ${target} # docker compose down -v`); + return; } console.log(`[Test Environment Agent] Teardown -> ${handle.url}`); diff --git a/test/agents/test-environment-agent.spec.ts b/test/agents/test-environment-agent.spec.ts index 9b0cd6f8..0037cab0 100644 --- a/test/agents/test-environment-agent.spec.ts +++ b/test/agents/test-environment-agent.spec.ts @@ -272,21 +272,32 @@ describe('TestEnvironmentAgent', () => { }); }); - it('should throw not-implemented in real mode', async () => { + describe('real mode (useMockDocker: false)', () => { const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); - const handle: EnvironmentHandle = { + const dockerHandle: EnvironmentHandle = { url: 'https://nginx', auth: { user: 'medic', password: 'password' }, network: 'cht-agent-net', + chtCorePath: '/workspace/cht-core', source: 'docker', }; - try { - await realAgent.reset(handle, 'full'); - expect.fail('Should have thrown an error'); - } catch (error) { - expect((error as Error).message).to.include('not yet implemented'); - } + it('resolves the restart tier (human-gated, agent runs no Docker)', async () => { + expect(await realAgent.reset(dockerHandle, 'restart')).to.equal(undefined); + }); + + it('resolves the full tier (human-gated, agent runs no Docker)', async () => { + expect(await realAgent.reset(dockerHandle, 'full')).to.equal(undefined); + }); + + it('defers the couchdb tier to a later phase', async () => { + try { + await realAgent.reset(dockerHandle, 'couchdb'); + expect.fail('expected reset to reject'); + } catch (error) { + expect((error as Error).message).to.include('Phase 3'); + } + }); }); }); @@ -297,21 +308,17 @@ describe('TestEnvironmentAgent', () => { expect(await agent.teardown(handle)).to.equal(undefined); }); - it('should throw not-implemented in real mode', async () => { + it('resolves in real mode (prints the human teardown gate)', async () => { const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); const handle: EnvironmentHandle = { url: 'https://nginx', auth: { user: 'medic', password: 'password' }, network: 'cht-agent-net', + chtCorePath: '/workspace/cht-core', source: 'docker', }; - try { - await realAgent.teardown(handle); - expect.fail('Should have thrown an error'); - } catch (error) { - expect((error as Error).message).to.include('not yet implemented'); - } + expect(await realAgent.teardown(handle)).to.equal(undefined); }); }); }); From da80b9858f44b9b0093dd0bd6d2d80bb2e0e1908 Mon Sep 17 00:00:00 2001 From: Hareet Date: Sat, 27 Jun 2026 19:27:24 -0600 Subject: [PATCH 04/24] feat(#66): shape applyConfig for the cht-conf validate loop (action selector + result) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bake the Phase-2 API shape into applyConfig so #134 PR5's cht-conf diagnose→fix→validate loop plugs in without reshaping the type later. Real Docker path still throws NOT_IMPLEMENTED (Phase 2); only the type shape and mock change here. - ConfigUploadAction buckets (app-settings/app-forms/contact-forms/ resources) map to their cht-conf verbs; `actions` narrows which run so the loop re-uploads only the artifact it changed. - `artifact` targets a single form (cht-conf --forms=). - ConfigActionStatus uploaded|skipped|failed — skipped is cht-conf's hash-check no-op, which a boolean couldn't express and verify needs. - applyConfig returns ConfigApplyResult (was void); accepts a bare configPath string for back-compat. 573 tests pass, build + lint clean (Node 22). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-environment-agent.mock-data.ts | 24 ++++- src/agents/test-environment-agent.ts | 52 +++++++++-- src/types/index.ts | 65 ++++++++++++++ test/agents/test-environment-agent.spec.ts | 90 ++++++++++++++++++- 4 files changed, 220 insertions(+), 11 deletions(-) diff --git a/src/agents/test-environment-agent.mock-data.ts b/src/agents/test-environment-agent.mock-data.ts index 0481e3be..b0045ce8 100644 --- a/src/agents/test-environment-agent.mock-data.ts +++ b/src/agents/test-environment-agent.mock-data.ts @@ -1,4 +1,4 @@ -import { DiscoveredConfig, TestDataResult } from '../types'; +import { ConfigActionResult, ConfigUploadAction, DiscoveredConfig, TestDataResult } from '../types'; /** * Raw deterministic fixtures for the Test Environment Agent's mock mode. @@ -15,6 +15,28 @@ export type MockTestEnvData = { testData: TestDataResult; }; +/** + * The cht-conf verbs each upload bucket runs (real-path Phase 2 shells out to + * these; mock mode reports them as the would-run commands). Single source for + * both the fixture and the real implementation. + */ +export const CONFIG_ACTION_COMMANDS: Record = { + 'app-settings': ['compile-app-settings', 'upload-app-settings'], + 'app-forms': ['convert-app-forms', 'upload-app-forms'], + 'contact-forms': ['convert-contact-forms', 'upload-contact-forms'], + resources: ['upload-resources', 'upload-branding', 'upload-custom-translations'], +}; + +/** + * Build a successful (uploaded), warning-free mock result for a single bucket. + */ +export const mockConfigActionResult = (action: ConfigUploadAction): ConfigActionResult => ({ + action, + status: 'uploaded', + commands: [...CONFIG_ACTION_COMMANDS[action]], + warnings: [], +}); + export const MOCK_TEST_ENV_DATA: MockTestEnvData = { url: 'https://nginx', // matches the defaults set by cht-core's scripts/docker-helper/cht-docker-compose.sh diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index fa86b6af..78effe83 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -12,13 +12,16 @@ */ import { + ApplyConfigOptions, + ConfigApplyResult, + ConfigUploadAction, DiscoveredConfig, EnvironmentHandle, ProvisionOptions, ResetTier, TestDataResult, } from '../types'; -import { MOCK_TEST_ENV_DATA } from './test-environment-agent.mock-data'; +import { MOCK_TEST_ENV_DATA, mockConfigActionResult } from './test-environment-agent.mock-data'; import { waitForReady } from '../utils/cht-readiness'; const NOT_IMPLEMENTED = 'Docker orchestration not yet implemented'; @@ -31,6 +34,15 @@ const DEFAULT_AUTH = { user: 'medic', password: 'password' }; // Humans may take minutes to run local-images + compose up. const DEFAULT_PROVISION_WAIT_MS = 300_000; +// Default config project (cht-core in-repo) and the full cht-conf upload set. +const DEFAULT_CONFIG_PATH = 'config/default'; +const DEFAULT_CONFIG_ACTIONS: ConfigUploadAction[] = [ + 'app-settings', + 'app-forms', + 'contact-forms', + 'resources', +]; + export class TestEnvironmentAgent { private readonly useMockDocker: boolean; @@ -90,16 +102,44 @@ export class TestEnvironmentAgent { } /** - * Apply (compile + upload) a config project from the working copy to the - * instance. Defaults to cht-core's in-repo `config/default` project. + * Apply (compile + upload) a config project to the instance via cht-conf. + * Defaults to cht-core's in-repo `config/default`; cht-conf tickets pass the + * mounted deployment config (CHT_CONF_PATH). `actions` selects which cht-conf + * upload buckets run — settings, app forms, contact forms, resources — so the + * cht-conf validate loop can re-upload only the artifact it changed. + * + * Accepts a bare path string (back-compat) or an options object. Returns a + * ConfigApplyResult the verify step / QA Supervisor asserts on. */ - async applyConfig(handle: EnvironmentHandle, configPath = 'config/default'): Promise { + async applyConfig( + handle: EnvironmentHandle, + options: string | ApplyConfigOptions = {} + ): Promise { + const opts: ApplyConfigOptions = typeof options === 'string' ? { configPath: options } : options; + const configPath = opts.configPath ?? DEFAULT_CONFIG_PATH; + const actions = opts.actions ?? DEFAULT_CONFIG_ACTIONS; + const artifact = opts.artifact; + if (!this.useMockDocker) { + // Real path (#66 Phase 2): shell cht-conf over HTTP for each bucket + // (compile/convert + upload, optionally `--forms=`), aggregate + // per-action status (uploaded/skipped/failed), never push. throw new Error(NOT_IMPLEMENTED); } - console.log(`[Test Environment Agent] Applying config: ${configPath} -> ${handle.url}`); - console.log('[Test Environment Agent] (mock) config applied'); + const scope = artifact ? `${actions.join(', ')}; artifact=${artifact}` : actions.join(', '); + console.log( + `[Test Environment Agent] Applying config: ${configPath} (${scope}) -> ${handle.url}` + ); + const results = actions.map((action) => mockConfigActionResult(action)); + console.log(`[Test Environment Agent] (mock) config applied — ${results.length} action(s)`); + return { + configPath, + ...(artifact ? { artifact } : {}), + actions: results, + succeeded: results.every((result) => result.status !== 'failed'), + warnings: results.flatMap((result) => result.warnings), + }; } /** diff --git a/src/types/index.ts b/src/types/index.ts index 2262bf27..19859423 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -774,6 +774,71 @@ export interface DiscoveredConfig { forms: string[]; } +/** + * A bucket of cht-conf upload work, mapping to the underlying cht-conf verbs + * (see designs/cht-conf-agent-extension.md §7.2 step 4). The agent drives these + * over HTTP against the instance; it never edits the config itself. + * - `app-settings` compile-app-settings + upload-app-settings + * - `app-forms` convert-app-forms + upload-app-forms + * - `contact-forms` convert-contact-forms + upload-contact-forms + * - `resources` upload-resources + upload-branding + upload-custom-translations + */ +export type ConfigUploadAction = 'app-settings' | 'app-forms' | 'contact-forms' | 'resources'; + +/** + * Inputs to applyConfig. `configPath` defaults to cht-core's in-repo + * `config/default`; for cht-conf tickets it points at the mounted deployment + * config (CHT_CONF_PATH). `actions` narrows which cht-conf uploads run — omit + * to run the full set (the cht-core default-config flow). + */ +export interface ApplyConfigOptions { + /** Path to the cht-conf project to compile + upload (default: config/default). */ + configPath?: string; + /** Which cht-conf upload buckets to run (default: all four). */ + actions?: ConfigUploadAction[]; + /** + * Restrict an upload to a single artifact by name (e.g. a form id like + * `pregnancy`) so the validate loop re-uploads only the one form it changed, + * matching cht-conf's `--forms=` targeting. Omit to upload the whole + * bucket. (Real-path targeting lands in #66 Phase 2.) + */ + artifact?: string; +} + +/** + * Per-action outcome of a cht-conf upload. `uploaded` = the artifact changed and + * was pushed; `skipped` = cht-conf's hash check found it identical to the + * instance (no-op, not a failure); `failed` = the verb errored. The verify step + * needs this three-way distinction — a boolean can't tell "skipped" from "ran". + */ +export type ConfigActionStatus = 'uploaded' | 'skipped' | 'failed'; + +/** + * Outcome of a single cht-conf upload bucket within applyConfig. + */ +export interface ConfigActionResult { + action: ConfigUploadAction; + status: ConfigActionStatus; + /** The cht-conf verbs this bucket ran (for evidence/logging). */ + commands: string[]; + warnings: string[]; +} + +/** + * Result of applying a config to the instance. The verify step (and the QA + * Supervisor) asserts on this rather than re-deriving success from logs. + */ +export interface ConfigApplyResult { + configPath: string; + /** Single artifact targeted, if the apply was narrowed to one. */ + artifact?: string; + /** Per-bucket outcome, in the order the buckets ran. */ + actions: ConfigActionResult[]; + /** True unless some action failed (a skipped/no-change action is not a failure). */ + succeeded: boolean; + warnings: string[]; +} + /** * Tuning for readiness polling (see src/utils/cht-readiness.ts) */ diff --git a/test/agents/test-environment-agent.spec.ts b/test/agents/test-environment-agent.spec.ts index 0037cab0..2460ce72 100644 --- a/test/agents/test-environment-agent.spec.ts +++ b/test/agents/test-environment-agent.spec.ts @@ -125,16 +125,98 @@ describe('TestEnvironmentAgent', () => { }); describe('applyConfig', () => { - it('should resolve in mock mode with the default config path', async () => { + it('should default to config/default and run all four upload buckets', async () => { const handle = await provisionMock(); - expect(await agent.applyConfig(handle)).to.equal(undefined); + const result = await agent.applyConfig(handle); + + expect(result.configPath).to.equal('config/default'); + expect(result.succeeded).to.equal(true); + expect(result.warnings).to.deep.equal([]); + expect(result.actions.map((action) => action.action)).to.deep.equal([ + 'app-settings', + 'app-forms', + 'contact-forms', + 'resources', + ]); }); - it('should resolve in mock mode with an explicit config path', async () => { + it('should accept a bare config path string (back-compat)', async () => { const handle = await provisionMock(); - expect(await agent.applyConfig(handle, 'config/standard')).to.equal(undefined); + const result = await agent.applyConfig(handle, 'config/standard'); + + expect(result.configPath).to.equal('config/standard'); + expect(result.actions).to.have.lengthOf(4); + }); + + it('should run only the selected actions when actions are narrowed', async () => { + const handle = await provisionMock(); + + const result = await agent.applyConfig(handle, { + configPath: '/mnt/cht-conf-project', + actions: ['app-forms'], + }); + + expect(result.configPath).to.equal('/mnt/cht-conf-project'); + expect(result.actions).to.have.lengthOf(1); + expect(result.actions[0].action).to.equal('app-forms'); + }); + + it('should report the underlying cht-conf commands for each action', async () => { + const handle = await provisionMock(); + + const result = await agent.applyConfig(handle, { actions: ['app-forms'] }); + + expect(result.actions[0].commands).to.deep.equal(['convert-app-forms', 'upload-app-forms']); + }); + + it('should report an uploaded status per action in mock mode', async () => { + const handle = await provisionMock(); + + const result = await agent.applyConfig(handle, { actions: ['app-settings'] }); + + expect(result.actions[0].status).to.equal('uploaded'); + }); + + it('should carry the targeted artifact onto the result when narrowed to one', async () => { + const handle = await provisionMock(); + + const result = await agent.applyConfig(handle, { + actions: ['app-forms'], + artifact: 'pregnancy', + }); + + expect(result.artifact).to.equal('pregnancy'); + }); + + it('should omit the artifact field when no single artifact is targeted', async () => { + const handle = await provisionMock(); + + const result = await agent.applyConfig(handle, { actions: ['app-forms'] }); + + expect(result.artifact).to.equal(undefined); + }); + + it('should return an empty action list when actions is an empty array', async () => { + const handle = await provisionMock(); + + const result = await agent.applyConfig(handle, { actions: [] }); + + expect(result.actions).to.deep.equal([]); + expect(result.succeeded).to.equal(true); + }); + + it('should return an isolated copy (mutation does not leak to later calls)', async () => { + const handle = await provisionMock(); + + const first = await agent.applyConfig(handle, { actions: ['app-forms'] }); + first.actions[0].commands.push('tampered'); + first.actions[0].warnings.push('tampered'); + + const second = await agent.applyConfig(handle, { actions: ['app-forms'] }); + expect(second.actions[0].commands).to.deep.equal(['convert-app-forms', 'upload-app-forms']); + expect(second.actions[0].warnings).to.deep.equal([]); }); it('should throw not-implemented in real mode', async () => { From 38a8ba4f804f13282a9eafcf19123acd36303153 Mon Sep 17 00:00:00 2001 From: Hareet Date: Sat, 27 Jun 2026 20:49:48 -0600 Subject: [PATCH 05/24] =?UTF-8?q?feat(#66):=20phase-2=20applyConfig=20real?= =?UTF-8?q?=20path=20=E2=80=94=20cht-conf=20runner=20(per-bucket=20spawn,?= =?UTF-8?q?=20status=20parsing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement applyConfig's !useMockDocker path. A new cht-conf-runner isolates child_process: it spawns `cht` per upload bucket (no shell, explicit argv), classifies the result as uploaded/skipped/failed, and never logs credentials. - runBucket() spawns cht-conf with the autonomous-safe flags (--force, --skip-*-check, --accept-self-signed-certs); creds ride the --url arg. - classifyChtConfOutput() parses status per line, skip-detected first — real cht-conf "no changes" lines contain the word "uploaded", so naive matching misclassifies them; verified against the cht-conf source strings. - minimalEnv() passes a PATH/HOME allow-list, not the agent's full env, so the child (which runs config-project code) can't read LLM provider keys. - agent loops buckets sequentially; one failure flips succeeded:false without aborting the rest. Mock path unchanged; both paths share toApplyResult(). 591 tests pass, build + lint clean, coverage 96.66%/86.1% (Node 22). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-environment-agent.mock-data.ts | 13 +- src/agents/test-environment-agent.ts | 57 +++-- src/types/index.ts | 26 ++- src/utils/cht-conf-runner.ts | 176 ++++++++++++++ test/agents/test-environment-agent.spec.ts | 83 ++++++- test/utils/cht-conf-runner.spec.ts | 219 ++++++++++++++++++ 6 files changed, 534 insertions(+), 40 deletions(-) create mode 100644 src/utils/cht-conf-runner.ts create mode 100644 test/utils/cht-conf-runner.spec.ts diff --git a/src/agents/test-environment-agent.mock-data.ts b/src/agents/test-environment-agent.mock-data.ts index b0045ce8..ef9504bf 100644 --- a/src/agents/test-environment-agent.mock-data.ts +++ b/src/agents/test-environment-agent.mock-data.ts @@ -1,4 +1,5 @@ import { ConfigActionResult, ConfigUploadAction, DiscoveredConfig, TestDataResult } from '../types'; +import { CONFIG_ACTION_COMMANDS } from '../utils/cht-conf-runner'; /** * Raw deterministic fixtures for the Test Environment Agent's mock mode. @@ -15,18 +16,6 @@ export type MockTestEnvData = { testData: TestDataResult; }; -/** - * The cht-conf verbs each upload bucket runs (real-path Phase 2 shells out to - * these; mock mode reports them as the would-run commands). Single source for - * both the fixture and the real implementation. - */ -export const CONFIG_ACTION_COMMANDS: Record = { - 'app-settings': ['compile-app-settings', 'upload-app-settings'], - 'app-forms': ['convert-app-forms', 'upload-app-forms'], - 'contact-forms': ['convert-contact-forms', 'upload-contact-forms'], - resources: ['upload-resources', 'upload-branding', 'upload-custom-translations'], -}; - /** * Build a successful (uploaded), warning-free mock result for a single bucket. */ diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index 78effe83..d405f06f 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -13,6 +13,7 @@ import { ApplyConfigOptions, + ConfigActionResult, ConfigApplyResult, ConfigUploadAction, DiscoveredConfig, @@ -23,6 +24,7 @@ import { } from '../types'; import { MOCK_TEST_ENV_DATA, mockConfigActionResult } from './test-environment-agent.mock-data'; import { waitForReady } from '../utils/cht-readiness'; +import { runBucket } from '../utils/cht-conf-runner'; const NOT_IMPLEMENTED = 'Docker orchestration not yet implemented'; @@ -43,6 +45,33 @@ const DEFAULT_CONFIG_ACTIONS: ConfigUploadAction[] = [ 'resources', ]; +/** + * Build the cht-conf instance URL with embedded credentials + * (https://user:pass@host). Only the runner sees this; logs use handle.url. + */ +const credentialedUrl = (handle: EnvironmentHandle): string => { + const url = new URL(handle.url); + url.username = encodeURIComponent(handle.auth.user); + url.password = encodeURIComponent(handle.auth.password); + return url.toString(); +}; + +/** + * Aggregate per-bucket results into the ConfigApplyResult envelope. Shared by + * the mock and real paths so both return an identical shape. + */ +const toApplyResult = ( + configPath: string, + artifact: string | undefined, + results: ConfigActionResult[] +): ConfigApplyResult => ({ + configPath, + ...(artifact ? { artifact } : {}), + actions: results, + succeeded: results.every((result) => result.status !== 'failed'), + warnings: results.flatMap((result) => result.warnings), +}); + export class TestEnvironmentAgent { private readonly useMockDocker: boolean; @@ -120,26 +149,24 @@ export class TestEnvironmentAgent { const actions = opts.actions ?? DEFAULT_CONFIG_ACTIONS; const artifact = opts.artifact; + const scope = artifact ? `${actions.join(', ')}; artifact=${artifact}` : actions.join(', '); + console.log(`[Test Environment Agent] Applying config: ${configPath} (${scope}) -> ${handle.url}`); + if (!this.useMockDocker) { - // Real path (#66 Phase 2): shell cht-conf over HTTP for each bucket - // (compile/convert + upload, optionally `--forms=`), aggregate - // per-action status (uploaded/skipped/failed), never push. - throw new Error(NOT_IMPLEMENTED); + // Real path: one cht-conf invocation per bucket against the running + // instance (the agent runs no Docker — cht-conf talks over HTTP). Buckets + // run independently so one failure doesn't abort the rest; never push. + const instanceUrl = credentialedUrl(handle); + const results: ConfigActionResult[] = []; + for (const action of actions) { + results.push(await runBucket({ action, instanceUrl, configPath, artifact })); + } + return toApplyResult(configPath, artifact, results); } - const scope = artifact ? `${actions.join(', ')}; artifact=${artifact}` : actions.join(', '); - console.log( - `[Test Environment Agent] Applying config: ${configPath} (${scope}) -> ${handle.url}` - ); const results = actions.map((action) => mockConfigActionResult(action)); console.log(`[Test Environment Agent] (mock) config applied — ${results.length} action(s)`); - return { - configPath, - ...(artifact ? { artifact } : {}), - actions: results, - succeeded: results.every((result) => result.status !== 'failed'), - warnings: results.flatMap((result) => result.warnings), - }; + return toApplyResult(configPath, artifact, results); } /** diff --git a/src/types/index.ts b/src/types/index.ts index 19859423..1dab1e15 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -798,9 +798,10 @@ export interface ApplyConfigOptions { actions?: ConfigUploadAction[]; /** * Restrict an upload to a single artifact by name (e.g. a form id like - * `pregnancy`) so the validate loop re-uploads only the one form it changed, - * matching cht-conf's `--forms=` targeting. Omit to upload the whole - * bucket. (Real-path targeting lands in #66 Phase 2.) + * `pregnancy`) so the validate loop re-uploads only the one form it changed. + * Passed as a positional form filter to the form-upload verbs (cht-conf's + * args-form-filter); ignored with a warning for the settings/resources + * buckets. Omit to upload the whole bucket. */ artifact?: string; } @@ -839,6 +840,25 @@ export interface ConfigApplyResult { warnings: string[]; } +/** + * Inputs to a single cht-conf bucket invocation (see src/utils/cht-conf-runner.ts). + * The runner builds the `cht` argv from these; the agent never embeds credentials + * in a log line. + */ +export interface ChtConfRunOptions { + action: ConfigUploadAction; + /** The instance URL WITH embedded credentials (https://user:pass@host). */ + instanceUrl: string; + /** Project folder passed to cht-conf `--source`. */ + configPath: string; + /** Optional single-form filter (positional arg on the form-upload verbs). */ + artifact?: string; + /** Override the cht-conf binary (default: `cht`); lets tests stub a fake script. */ + bin?: string; + /** Per-bucket timeout in ms before the process is killed and marked failed. */ + timeoutMs?: number; +} + /** * Tuning for readiness polling (see src/utils/cht-readiness.ts) */ diff --git a/src/utils/cht-conf-runner.ts b/src/utils/cht-conf-runner.ts new file mode 100644 index 00000000..ddf45706 --- /dev/null +++ b/src/utils/cht-conf-runner.ts @@ -0,0 +1,176 @@ +/** + * cht-conf runner for the Test Environment Layer. + * + * Isolates the `child_process` calls to cht-conf (the way cht-readiness.ts + * isolates `fetch`), so the Test Environment Agent stays orchestration-only. + * The agent NEVER runs Docker — cht-conf talks to the already-running instance + * over HTTP, so spawning it is allowed (and the sandbox allow-list sanctions + * `Bash(cht:*)`). + * + * One invocation per upload bucket: the bucket's verbs are passed as ordered + * actions in a single `cht` process (cht-conf runs named actions in sequence). + * + * See: designs/layer_recommendations/test-environment-layer.md, + * designs/cht-conf-agent-extension.md §7.2, + * docs/handoffs/66-phase2-applyconfig-implementation.md + */ + +import { spawn } from 'node:child_process'; +import { ChtConfRunOptions, ConfigActionResult, ConfigActionStatus, ConfigUploadAction } from '../types'; + +/** + * The cht-conf verbs each upload bucket runs, in order. Single source of truth + * for both the real runner and the mock fixture. + */ +export const CONFIG_ACTION_COMMANDS: Record = { + 'app-settings': ['compile-app-settings', 'upload-app-settings'], + 'app-forms': ['convert-app-forms', 'upload-app-forms'], + 'contact-forms': ['convert-contact-forms', 'upload-contact-forms'], + resources: ['upload-resources', 'upload-branding', 'upload-custom-translations'], +}; + +/** + * Flags that make cht-conf safe to run autonomously. Without these it BLOCKS on + * stdin (git-status prompt, both-changed form-conflict prompt, etc.) and the + * agent hangs. `--force` skips all confirmations (overwrite on conflict); the + * env is a throwaway test instance with a self-signed cert. + */ +const AUTONOMOUS_FLAGS = [ + '--force', + '--skip-git-check', + '--skip-version-check', + '--skip-dependency-check', + '--skip-translation-check', + '--accept-self-signed-certs', + '--verbose', +]; + +const DEFAULT_BIN = 'cht'; +const DEFAULT_TIMEOUT_MS = 180_000; + +/** Buckets whose verbs accept a positional single-form filter. */ +const FORM_BUCKETS: ConfigUploadAction[] = ['app-forms', 'contact-forms']; + +/** + * cht-conf executes code from the `--source` project (app-settings build, + * nools, post-processing), so the child gets a minimal env — NOT the agent's + * full process.env, which holds LLM provider keys (ANTHROPIC_API_KEY etc.). The + * instance URL + creds are passed as the `--url` arg, not via env, so cht-conf + * needs nothing secret here. Least-privilege: a malicious config repo can't read + * keys that were never handed to it. + */ +const CHT_CONF_ENV_ALLOWLIST = ['PATH', 'HOME', 'NODE_PATH', 'TMPDIR', 'LANG', 'LC_ALL']; + +const minimalEnv = (): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = {}; + for (const key of CHT_CONF_ENV_ALLOWLIST) { + if (process.env[key] !== undefined) { + env[key] = process.env[key]; + } + } + return env; +}; + +/** + * Build the cht-conf argv for a bucket (no credentials are logged; the URL with + * embedded creds lives only in the argv passed to spawn). Exported for testing. + */ +export const buildChtConfArgs = (options: ChtConfRunOptions): string[] => { + const verbs = CONFIG_ACTION_COMMANDS[options.action]; + const args = [`--url=${options.instanceUrl}`, `--source=${options.configPath}`, ...AUTONOMOUS_FLAGS, ...verbs]; + // A single-form filter is a positional arg consumed by the form-upload verbs. + if (options.artifact && FORM_BUCKETS.includes(options.action)) { + args.push(options.artifact); + } + return args; +}; + +// cht-conf logs a "no changes" line per artifact when its hash matches the +// instance. CRUCIALLY these skip lines contain the word "uploaded" (e.g. +// "Form x not uploaded, no changes"), so a naive /uploaded/ match misfires — +// the skip phrasing must be detected FIRST, per line. Verified against +// cht-conf src (upload-forms.js, upload-app-settings.js, +// upload-custom-translations.js, upload-configuration-docs.js). +const SKIP_LINE = /no changes|not updated|already up to date|nothing to upload/; +// Positive upload signals that never appear in a skip line. Note the negative +// lookahead: "not uploaded" / "not updated" must NOT count as an upload. +const UPLOAD_LINE = /(? { + if (exitCode !== 0) { + return 'failed'; + } + const lines = output.toLowerCase().split('\n'); + const uploadedAny = lines.some((line) => !SKIP_LINE.test(line) && UPLOAD_LINE.test(line)); + if (uploadedAny) { + return 'uploaded'; + } + const skippedAny = lines.some((line) => SKIP_LINE.test(line)); + return skippedAny ? 'skipped' : 'uploaded'; +}; + +/** + * Run one cht-conf upload bucket against the instance. Resolves with the + * per-bucket result (never rejects — a non-zero exit or spawn error becomes + * `status: 'failed'` so the caller can aggregate without try/catch per bucket). + */ +export const runBucket = (options: ChtConfRunOptions): Promise => { + const bin = options.bin ?? DEFAULT_BIN; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const verbs = CONFIG_ACTION_COMMANDS[options.action]; + const args = buildChtConfArgs(options); + const warnings: string[] = []; + + if (options.artifact && !FORM_BUCKETS.includes(options.action)) { + warnings.push(`artifact targeting ignored for the ${options.action} bucket`); + } + + // Log the verbs, never the cred-embedded URL. + console.log(`[cht-conf] ${options.action}: ${verbs.join(' ')} (--source=${options.configPath})`); + + return new Promise((resolve) => { + const result = (status: ConfigActionStatus): ConfigActionResult => ({ + action: options.action, + status, + commands: [...verbs], + warnings: [...warnings], + }); + + const proc = spawn(bin, args, { env: minimalEnv(), stdio: ['ignore', 'pipe', 'pipe'] }); + const chunks: string[] = []; + let settled = false; + + const finish = (status: ConfigActionStatus): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutId); + resolve(result(status)); + }; + + const timeoutId = setTimeout(() => { + proc.kill('SIGTERM'); + warnings.push(`cht-conf ${options.action} timed out after ${timeoutMs}ms`); + finish('failed'); + }, timeoutMs); + + proc.stdout?.on('data', (data) => chunks.push(data.toString())); + proc.stderr?.on('data', (data) => chunks.push(data.toString())); + + proc.on('error', (error) => { + warnings.push(`cht-conf ${options.action} failed to start: ${error.message}`); + finish('failed'); + }); + + proc.on('close', (code) => { + finish(classifyChtConfOutput(chunks.join(''), code)); + }); + }); +}; diff --git a/test/agents/test-environment-agent.spec.ts b/test/agents/test-environment-agent.spec.ts index 2460ce72..e11c8c3e 100644 --- a/test/agents/test-environment-agent.spec.ts +++ b/test/agents/test-environment-agent.spec.ts @@ -1,7 +1,15 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { TestEnvironmentAgent } from '../../src/agents/test-environment-agent'; -import { DiscoveredConfig, EnvironmentHandle, ProvisionOptions, ResetTier } from '../../src/types'; +import * as chtConfRunner from '../../src/utils/cht-conf-runner'; +import { + ConfigActionResult, + ConfigUploadAction, + DiscoveredConfig, + EnvironmentHandle, + ProvisionOptions, + ResetTier, +} from '../../src/types'; describe('TestEnvironmentAgent', () => { let agent: TestEnvironmentAgent; @@ -219,21 +227,76 @@ describe('TestEnvironmentAgent', () => { expect(second.actions[0].warnings).to.deep.equal([]); }); - it('should throw not-implemented in real mode', async () => { - const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); - const handle: EnvironmentHandle = { + describe('real mode (useMockDocker: false)', () => { + let runBucketStub: sinon.SinonStub; + const dockerHandle: EnvironmentHandle = { url: 'https://nginx', auth: { user: 'medic', password: 'password' }, network: 'cht-agent-net', source: 'docker', }; + const uploaded = (action: ConfigUploadAction): ConfigActionResult => ({ + action, + status: 'uploaded', + commands: [], + warnings: [], + }); - try { - await realAgent.applyConfig(handle); - expect.fail('Should have thrown an error'); - } catch (error) { - expect((error as Error).message).to.include('not yet implemented'); - } + beforeEach(() => { + runBucketStub = sinon + .stub(chtConfRunner, 'runBucket') + .callsFake((opts) => Promise.resolve(uploaded(opts.action))); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('runs cht-conf per action against the instance and aggregates success', async () => { + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + const result = await realAgent.applyConfig(dockerHandle, { actions: ['app-settings', 'app-forms'] }); + + expect(runBucketStub.callCount).to.equal(2); + expect(result.succeeded).to.equal(true); + expect(result.actions.map((a) => a.action)).to.deep.equal(['app-settings', 'app-forms']); + }); + + it('passes the credentialed instance URL and configPath to the runner', async () => { + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + await realAgent.applyConfig(dockerHandle, { configPath: '/mnt/conf', actions: ['app-forms'] }); + + const passed = runBucketStub.firstCall.args[0]; + expect(passed.instanceUrl).to.equal('https://medic:password@nginx/'); + expect(passed.configPath).to.equal('/mnt/conf'); + expect(passed.action).to.equal('app-forms'); + }); + + it('threads the targeted artifact through to the runner', async () => { + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + await realAgent.applyConfig(dockerHandle, { actions: ['app-forms'], artifact: 'pregnancy' }); + + expect(runBucketStub.firstCall.args[0].artifact).to.equal('pregnancy'); + }); + + it('reports succeeded:false when a bucket fails, without aborting the rest', async () => { + runBucketStub.restore(); + runBucketStub = sinon.stub(chtConfRunner, 'runBucket').callsFake((opts) => { + const status = opts.action === 'app-forms' ? 'failed' : 'uploaded'; + return Promise.resolve({ action: opts.action, status, commands: [], warnings: [] }); + }); + const realAgent = new TestEnvironmentAgent({ useMockDocker: false }); + + const result = await realAgent.applyConfig(dockerHandle, { + actions: ['app-settings', 'app-forms', 'resources'], + }); + + expect(runBucketStub.callCount).to.equal(3); + expect(result.succeeded).to.equal(false); + expect(result.actions.map((a) => a.status)).to.deep.equal(['uploaded', 'failed', 'uploaded']); + }); }); }); diff --git a/test/utils/cht-conf-runner.spec.ts b/test/utils/cht-conf-runner.spec.ts new file mode 100644 index 00000000..eec925a3 --- /dev/null +++ b/test/utils/cht-conf-runner.spec.ts @@ -0,0 +1,219 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { EventEmitter } from 'node:events'; +import { buildChtConfArgs, classifyChtConfOutput } from '../../src/utils/cht-conf-runner'; +import { ChtConfRunOptions, ConfigActionResult } from '../../src/types'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const proxyquire = require('proxyquire').noCallThru(); + +// A fake child process: an EventEmitter with stdout/stderr emitters and a kill spy. +const makeFakeProc = () => { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: sinon.SinonSpy; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = sinon.spy(); + return proc; +}; + +const baseOpts = (overrides: Partial = {}): ChtConfRunOptions => ({ + action: 'app-forms', + instanceUrl: 'https://medic:password@nginx/', + configPath: '/mnt/conf', + ...overrides, +}); + +type SpawnLog = { cmd: string; args: string[]; opts: Record }; + +// Load the runner with a stubbed spawn that returns `proc` and records the call. +const loadRunner = (proc: EventEmitter) => { + const spawnLog: SpawnLog[] = []; + const spawnStub = sinon.stub().callsFake((cmd: string, args: string[], opts: Record) => { + spawnLog.push({ cmd, args, opts }); + return proc; + }); + const mod = proxyquire('../../src/utils/cht-conf-runner', { + 'node:child_process': { spawn: spawnStub }, + }); + return { runBucket: mod.runBucket as (o: ChtConfRunOptions) => Promise, spawnLog }; +}; + +describe('cht-conf-runner', () => { + describe('buildChtConfArgs', () => { + it('includes the url, source, autonomous-safe flags, and the bucket verbs', () => { + const args = buildChtConfArgs(baseOpts()); + + expect(args).to.include('--url=https://medic:password@nginx/'); + expect(args).to.include('--source=/mnt/conf'); + expect(args).to.include('--force'); + expect(args).to.include('--accept-self-signed-certs'); + expect(args).to.include('convert-app-forms'); + expect(args).to.include('upload-app-forms'); + }); + + it('appends the artifact as a positional filter for form buckets', () => { + const args = buildChtConfArgs(baseOpts({ action: 'app-forms', artifact: 'pregnancy' })); + + expect(args[args.length - 1]).to.equal('pregnancy'); + }); + + it('does not append the artifact for non-form buckets', () => { + const args = buildChtConfArgs(baseOpts({ action: 'app-settings', artifact: 'pregnancy' })); + + expect(args).to.not.include('pregnancy'); + }); + }); + + describe('classifyChtConfOutput', () => { + // Literal cht-conf log strings (verified against the installed cht-conf src: + // upload-forms.js, upload-app-settings.js, upload-custom-translations.js, + // upload-configuration-docs.js). The skip strings all contain "uploaded"/ + // "updated", so naive substring matching misclassifies them — these guard + // that regression. + const UPLOADED = { + form: 'Form forms/app/pregnancy.xml uploaded', + settings: 'Settings updated successfully', + translation: 'Translation translations/messages-en.properties uploaded', + config: 'Configuration upload complete!', + }; + const SKIPPED = { + form: 'Form forms/app/pregnancy.xml not uploaded, no changes', + settings: 'Settings not updated - no changes detected', + translation: 'Translation translations/messages-en.properties not uploaded as no changes were found', + config: 'Configuration not uploaded as no changes found', + }; + + it('returns failed for a non-zero exit code regardless of output', () => { + expect(classifyChtConfOutput(UPLOADED.form, 1)).to.equal('failed'); + }); + + it('classifies each bucket-type real upload line as uploaded', () => { + expect(classifyChtConfOutput(UPLOADED.form, 0)).to.equal('uploaded'); + expect(classifyChtConfOutput(UPLOADED.settings, 0)).to.equal('uploaded'); + expect(classifyChtConfOutput(UPLOADED.translation, 0)).to.equal('uploaded'); + expect(classifyChtConfOutput(UPLOADED.config, 0)).to.equal('uploaded'); + }); + + it('classifies each bucket-type real "no changes" line as skipped (despite the word "uploaded")', () => { + expect(classifyChtConfOutput(SKIPPED.form, 0)).to.equal('skipped'); + expect(classifyChtConfOutput(SKIPPED.settings, 0)).to.equal('skipped'); + expect(classifyChtConfOutput(SKIPPED.translation, 0)).to.equal('skipped'); + expect(classifyChtConfOutput(SKIPPED.config, 0)).to.equal('skipped'); + }); + + it('returns uploaded for a multi-form bucket where some uploaded and some skipped', () => { + const output = [SKIPPED.form, UPLOADED.form].join('\n'); + + expect(classifyChtConfOutput(output, 0)).to.equal('uploaded'); + }); + + it('defaults to uploaded on a clean exit with ambiguous output', () => { + expect(classifyChtConfOutput('done', 0)).to.equal('uploaded'); + }); + }); + + describe('runBucket', () => { + afterEach(() => { + sinon.restore(); + }); + + it('spawns the configured bin with no shell and resolves uploaded on clean exit', async () => { + const proc = makeFakeProc(); + const { runBucket, spawnLog } = loadRunner(proc); + + const promise = runBucket(baseOpts({ bin: 'fake-cht' })); + proc.stdout.emit('data', Buffer.from('Form forms/app/pregnancy.xml uploaded')); + proc.emit('close', 0); + + const result = await promise; + expect(spawnLog).to.have.length(1); + expect(spawnLog[0].cmd).to.equal('fake-cht'); + expect(spawnLog[0].opts).to.not.have.property('shell'); + expect(result.status).to.equal('uploaded'); + expect(result.commands).to.deep.equal(['convert-app-forms', 'upload-app-forms']); + }); + + it('does not pass secret-bearing env vars to the cht-conf child', async () => { + const proc = makeFakeProc(); + const { runBucket, spawnLog } = loadRunner(proc); + const prior = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-should-not-leak'; + + const promise = runBucket(baseOpts()); + proc.emit('close', 0); + await promise; + + const childEnv = spawnLog[0].opts.env as NodeJS.ProcessEnv; + expect(childEnv).to.not.have.property('ANTHROPIC_API_KEY'); + expect(childEnv).to.have.property('PATH'); + if (prior === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = prior; + } + }); + + it('resolves skipped on cht-conf\'s real "not uploaded, no changes" line', async () => { + const proc = makeFakeProc(); + const { runBucket } = loadRunner(proc); + + const promise = runBucket(baseOpts()); + proc.stdout.emit('data', Buffer.from('Form forms/app/pregnancy.xml not uploaded, no changes')); + proc.emit('close', 0); + + const result = await promise; + expect(result.status).to.equal('skipped'); + }); + + it('kills the process and resolves failed when the bucket times out', async () => { + const proc = makeFakeProc(); + const { runBucket } = loadRunner(proc); + + // Tiny timeout, and the fake proc never emits 'close' → the timer fires. + const result = await runBucket(baseOpts({ timeoutMs: 5 })); + + expect(proc.kill.calledWith('SIGTERM')).to.equal(true); + expect(result.status).to.equal('failed'); + expect(result.warnings.join(' ')).to.include('timed out'); + }); + + it('resolves failed on a non-zero exit', async () => { + const proc = makeFakeProc(); + const { runBucket } = loadRunner(proc); + + const promise = runBucket(baseOpts()); + proc.stderr.emit('data', Buffer.from('boom')); + proc.emit('close', 2); + + const result = await promise; + expect(result.status).to.equal('failed'); + }); + + it('resolves failed (not throws) when the process fails to start', async () => { + const proc = makeFakeProc(); + const { runBucket } = loadRunner(proc); + + const promise = runBucket(baseOpts()); + proc.emit('error', new Error('ENOENT')); + + const result = await promise; + expect(result.status).to.equal('failed'); + expect(result.warnings.join(' ')).to.include('failed to start'); + }); + + it('warns and ignores artifact targeting for non-form buckets', async () => { + const proc = makeFakeProc(); + const { runBucket } = loadRunner(proc); + + const promise = runBucket(baseOpts({ action: 'app-settings', artifact: 'pregnancy' })); + proc.emit('close', 0); + + const result = await promise; + expect(result.warnings.join(' ')).to.include('artifact targeting ignored'); + }); + }); +}); From 4dfface98186a9a4c4005e57b54978afee4bca2f Mon Sep 17 00:00:00 2001 From: Hareet Date: Wed, 1 Jul 2026 19:00:43 -0600 Subject: [PATCH 06/24] docs(#66): phase 2 implementation handoff and phase 3 scope --- .../66-phase2-applyconfig-implementation.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/handoffs/66-phase2-applyconfig-implementation.md diff --git a/docs/handoffs/66-phase2-applyconfig-implementation.md b/docs/handoffs/66-phase2-applyconfig-implementation.md new file mode 100644 index 00000000..ff2cd7d1 --- /dev/null +++ b/docs/handoffs/66-phase2-applyconfig-implementation.md @@ -0,0 +1,120 @@ +# #66 Phase 2 — real `applyConfig` implementation plan + +> Phase 1 (provision/reset/teardown real paths) + the Phase-2 *shape* (action selector, +> `ConfigApplyResult`, `ConfigActionStatus`) already landed (`f954cb9`). This plan fills the +> `applyConfig` `!useMockDocker` branch that currently throws `NOT_IMPLEMENTED`. Same +> human-gated, no-Docker invariants as Phase 1. This is the artifact-upload half of the +> cht-conf validate loop (#134 PR5 step 4); test-data seeding (`prepareTestData`) is Phase 3. + +## 0. Verified contract (cht-conf CLI — `medic/cht-conf` wiki, 2026-06-27) + +- **Binary:** `cht` (cht-conf is baked into the agent image globally per #114; agent + allow-list already sanctions `Bash(cht:*)`). Invoke via `child_process` (recommendation + doc lists every config verb as `child_process` — this is NOT Docker, so it's allowed). +- **Instance target:** `cht --url= […]` where `` embeds creds, + e.g. `https://medic:password@nginx`. `--source=` points at the project dir (the + mounted `CHT_CONF_PATH`, or cht-core's in-repo `config/default`). Named actions run in the + given order (doc example: `cht --instance=example validate-app-forms upload-app-forms`). +- **MUST pass for autonomous use (cht-conf prompts on stdin otherwise → the agent hangs):** + - `--force` — skip ALL confirmation prompts (incl. the "both changed → view diff / overwrite + / abort" form-conflict prompt and check-git's keyInYN). Doc labels it "DANGEROUS"; for a + throwaway test instance it's correct. + - `--skip-git-check` (we're on a local branch with a hardened RO .git/config), `--skip-version-check`, + `--skip-dependency-check`, `--skip-translation-check`. + - `--accept-self-signed-certs` — the env is `https://nginx` with a self-signed cert. + - Consider `--verbose` so stdout carries per-form "skip (no changes)" / "uploaded" lines we + classify on (see status mapping). Do NOT pass `--silent` (we need the lines). +- **Status semantics (confirmed, drives `ConfigActionStatus`):** upload verbs do hash-based + change detection. "If local and remote versions are identical → **skip (no changes)**"; + "both changed → prompt" (suppressed by `--force` → overwrite = uploaded). cht-conf exits 0 + for both upload and skip, so **`skipped` vs `uploaded` is parsed from stdout, not exit code**; + **`failed` = non-zero exit** (or a thrown spawn error). Exit code alone is insufficient. +- **Form targeting (`artifact` field):** `args-form-filter` restricts an upload to named forms + via positional args after the action — `cht … upload-app-forms ` (e.g. `pregnancy`). + Maps `ApplyConfigOptions.artifact` → the positional form filter on the `app-forms` / + `contact-forms` buckets. (No-op for `app-settings`/`resources`; document that.) +- **Bucket → verb mapping (already in `CONFIG_ACTION_COMMANDS`, verified against the doc):** + - `app-settings` → `compile-app-settings`, `upload-app-settings` + - `app-forms` → `convert-app-forms`, `upload-app-forms` (+ optional `validate-app-forms`) + - `contact-forms` → `convert-contact-forms`, `upload-contact-forms` + - `resources` → `upload-resources`, `upload-branding`, `upload-custom-translations` + +## 1. Design decisions to lock before coding + +1. **One `cht` invocation per bucket, not per verb.** Pass the bucket's verbs as ordered + actions in a single `cht --url=… --source=… …` call. Rationale: cht-conf + runs named actions in sequence in one process; per-verb spawns would re-pay startup and + lose the convert→upload ordering guarantee. `ConfigActionResult.commands` already records + the verb list for evidence. +2. **No `--force` blast radius beyond the bucket.** `--force` is scoped to the single bucket + invocation; we still run buckets independently so one bucket's failure doesn't abort the + rest (collect per-bucket status, set top-level `succeeded = no bucket failed`). +3. **`artifact` targeting only applies to form buckets.** When `artifact` is set and the + bucket is `app-forms`/`contact-forms`, append it as the positional form filter to the + `upload-*-forms` verb (and the matching `convert`). For `app-settings`/`resources`, ignore + it and emit a warning into `ConfigActionResult.warnings` ("artifact targeting ignored for + "). +4. **Credentials come from the handle, never logged.** Build the URL from + `handle.url` + `handle.auth` → `https://:@`. **Redact creds in every + log line** (log `handle.url`, never the cred-embedded URL). Mirrors the sandbox stance. +5. **Status parsing is a small pure helper** (`classifyChtConfOutput(stdout, exitCode)`), + unit-tested in isolation against captured cht-conf output fixtures — don't bury the regex + in the spawn callback. `failed` if exit≠0; else `skipped` if every form line says skipped + / "no changes" and nothing uploaded; else `uploaded`. +6. **Timeout + no shell.** Use `spawn(executablePath, args, { shell: false })` like + `src/llm/providers/claude-cli.ts` (explicit arg array, no string interpolation into a + shell → no injection via `artifact`/`configPath`). Bound each bucket with a generous + timeout (form conversion + upload can take a minute); kill + mark `failed` on timeout. +7. **Where the binary lives.** Resolve `cht` from PATH (image installs it globally). Add a + `chtConfBin` override (constructor option or env `CHT_CONF_BIN`) for tests/local runs, + defaulting to `'cht'` — lets the spec stub a fake script without a global install. + +## 2. Files to touch + +- `src/utils/cht-conf-runner.ts` *(new)* — the spawn wrapper + `classifyChtConfOutput`. + Keep ALL `child_process` here (mirrors how `cht-readiness.ts` isolates `fetch`); the agent + stays orchestration-only. Exports `runBucket(opts): Promise`. +- `src/agents/test-environment-agent.ts` — replace the `applyConfig` real-path throw with a + loop over `actions` calling `runBucket`, aggregating into `ConfigApplyResult`. Build the + cred URL here from the handle. Keep mock path untouched. +- `src/types/index.ts` — likely no change (shape already baked). Maybe add a + `ChtConfRunOptions` interface for the runner. +- `test/utils/cht-conf-runner.spec.ts` *(new)* — unit-test `classifyChtConfOutput` against + fixtures (uploaded / skipped / failed / mixed) and `runBucket` with a **stubbed spawn** + (fake `cht` script or sinon stub on the runner's spawn) → assert args (incl. the + autonomous-safe flags, cred URL, positional artifact), status mapping, timeout → failed. +- `test/agents/test-environment-agent.spec.ts` — add real-mode `applyConfig` tests that stub + the runner (NOT spawn) → assert it builds the cred URL from the handle, calls `runBucket` + per action, aggregates `succeeded`/`warnings`, and that a failed bucket flips top-level + `succeeded:false` without aborting the others. **Keep the existing "throws" test only until + the real path lands, then replace it** (it currently asserts NOT_IMPLEMENTED). + +## 3. Conventions (same as Phase 1 — SonarCloud gate) + +- No `any` in `src/`; `strict` + `noUnusedParameters` (prefix unused with `_`). +- Every test ≥1 assertion; no nested template literals (extract to a local first). +- `[Test Environment Agent]` / `[cht-conf]` console prefixes; redact creds. +- Node 22 to build/test (`~/.nvm/versions/node/v22.18.0/bin`). +- Agent + spec land together; user drives all git; no `git add .`. + +## 4. Manual test (closed loop, needs the imported sandbox + a running env) + +1. Human brings up CHT (`scripts/test-env-up.sh `); agent `provision()` (real) waits ready. +2. `applyConfig(handle, { configPath: '', actions: ['app-forms'], artifact: 'pregnancy' })` + → confirm it runs `cht --url=… --source=… convert-app-forms upload-app-forms pregnancy` + with the autonomous-safe flags, and the report shows `status: uploaded`. +3. Re-run unchanged → `status: skipped` (hash no-change). Break the form → `status: failed`. +4. Confirm NO stdin prompt ever blocks (the `--force`/`--skip-*` set holds) and no push. + +## 5. Dependency / sequencing notes + +- §7.4 of `designs/cht-conf-agent-extension.md`: the env image must carry the **xlsx→XForm + toolchain** for `convert-app-forms`. #114 Dockerfile bakes `cht-conf` + `python3` (cht-conf + bundles medic-pyxform on install) — **verify `convert-app-forms` actually runs in-image** + before claiming the loop works; if pyxform is missing, that's a Dockerfile follow-up on #114. +- This is the upload half; the cht-conf validate loop also needs `prepareTestData` (Phase 3: + `csv-to-docs` + `upload-docs` / `create-users`) and `reset('couchdb')`. PR5's full loop is + gated on Phase 3 too — applyConfig alone proves "fix uploads", not "symptom reproduced". +- `discoverConfig` real path (read `/api/v1/settings`, parse into `DiscoveredConfig`) is a + natural companion to land in the same phase — it's pure `fetch`, no cht-conf, and the + apply→verify step re-reads settings to confirm the change took. From 485e6725ec26773853fd52c0f6dec354e2448190 Mon Sep 17 00:00:00 2001 From: CHT Agent Date: Sat, 18 Jul 2026 01:26:09 +0000 Subject: [PATCH 07/24] =?UTF-8?q?feat(#66):=20phase-2=20parity=20uplift=20?= =?UTF-8?q?=E2=80=94=20cht-conf=20runner=20to=20workbench=20parity=20minus?= =?UTF-8?q?=20excisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the Phase-2 cht-conf runner up to the workbench lineage's shape (the port source of truth), excising the cht-conf-extension-only pieces so this PR stays test-environment-layer scoped. Parity uplift (from the workbench src/utils/cht-conf-runner.ts): - Split the spawn wrapper into two layers: runChtConf() runs one `cht` process for an ordered verb list and returns the raw ChtConfExecResult; runBucket() wraps it for the config-upload buckets and classifies the status. The generic layer is what the Phase-3 test-data verbs reuse. - resolveChtConfBin() adds the CHT_CONF_BIN seam so the agent can run a deployment-pinned cht-conf instead of the image's global `cht`. - Add the `app-settings-only` bucket (upload-app-settings, no compile) for a pre-compiled, deployment-recovered app_settings.json. - Artifact form-filter now rides after a literal `--` separator (cht-conf reads only cmdArgs['--'] into args-form-filter; a bare positional throws "Unsupported action(s)"). - minimalEnv() env allow-list and per-line skip-first classification preserved. Excisions (cht-conf-extension, deferred to that PR): - No offline-convert block (runOfflineConvert, createConvertSandbox, CONVERT_VERBS, SANDBOX_EXCLUDES, OfflineConvertOptions). - No skipValidate handling; ChtConfExecOptions.instanceUrl is REQUIRED (offline URL-less convert was the only caller that omitted it). Also fix the stale agent header comment (applyConfig is implemented now). Types add ChtConfExecOptions/ChtConfExecResult and the app-settings-only union member. Runner spec ported to parity minus the offline-convert describes. 1045 tests pass, build + lint clean (Node 22). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/test-environment-agent.ts | 6 +- src/types/index.ts | 66 ++++++++++- src/utils/cht-conf-runner.ts | 149 ++++++++++++++++++------ test/utils/cht-conf-runner.spec.ts | 165 ++++++++++++++++++++++++++- 4 files changed, 334 insertions(+), 52 deletions(-) diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index d405f06f..a55c0ea9 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -4,9 +4,9 @@ * Deterministic provisioning orchestrator for the Test Environment Layer * (QA Supervisor). It provisions a live CHT instance, applies a config, * discovers the deployed config, and seeds conforming test data. No LLM is - * involved. provision()'s real path (human-gated bring-up + readiness polling) - * is implemented; applyConfig/discoverConfig/prepareTestData/reset/teardown - * real paths are not yet implemented (later #66 phases). + * involved. Real paths implemented: provision (human-gated bring-up + readiness + * polling) and applyConfig (cht-conf upload buckets). discoverConfig / + * prepareTestData / the couchdb-tier reset real paths land in #66 phase 3. * * See: designs/layer_recommendations/test-environment-layer.md */ diff --git a/src/types/index.ts b/src/types/index.ts index 1dab1e15..85e871cb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -778,12 +778,21 @@ export interface DiscoveredConfig { * A bucket of cht-conf upload work, mapping to the underlying cht-conf verbs * (see designs/cht-conf-agent-extension.md §7.2 step 4). The agent drives these * over HTTP against the instance; it never edits the config itself. - * - `app-settings` compile-app-settings + upload-app-settings - * - `app-forms` convert-app-forms + upload-app-forms - * - `contact-forms` convert-contact-forms + upload-contact-forms - * - `resources` upload-resources + upload-branding + upload-custom-translations - */ -export type ConfigUploadAction = 'app-settings' | 'app-forms' | 'contact-forms' | 'resources'; + * - `app-settings` compile-app-settings + upload-app-settings + * - `app-settings-only` upload-app-settings (NO compile) — for a deployment- + * recovered, pre-compiled app_settings.json where + * recompiling would clobber the minified contact-summary / + * tasks / targets + * - `app-forms` convert-app-forms + upload-app-forms + * - `contact-forms` convert-contact-forms + upload-contact-forms + * - `resources` upload-resources + upload-branding + upload-custom-translations + */ +export type ConfigUploadAction = + | 'app-settings' + | 'app-settings-only' + | 'app-forms' + | 'contact-forms' + | 'resources'; /** * Inputs to applyConfig. `configPath` defaults to cht-core's in-repo @@ -859,6 +868,51 @@ export interface ChtConfRunOptions { timeoutMs?: number; } +/** + * Inputs to a generic cht-conf invocation (the low-level runner under + * runBucket, also driving the test-data verbs csv-to-docs / upload-docs / + * create-users). Verbs run in order inside ONE `cht` process. + */ +export interface ChtConfExecOptions { + /** cht-conf actions to run, in order (e.g. ['csv-to-docs', 'upload-docs']). */ + verbs: string[]; + /** The instance URL WITH embedded credentials (https://user:pass@host). */ + instanceUrl: string; + /** Project folder passed to cht-conf `--source`. */ + configPath: string; + /** Positional args appended after the verbs (e.g. a form filter). */ + extraArgs?: string[]; + /** + * Working directory for the spawned process. cht-conf writes report files + * (upload-docs..log.json) into its cwd, so data runs point this at the + * data project to keep droppings out of the repo. Defaults to the agent cwd. + */ + cwd?: string; + /** Log label ("[cht-conf]