diff --git a/agentic-organization/apps/workers/README.md b/agentic-organization/apps/workers/README.md new file mode 100644 index 0000000000..93b2cfc4bd --- /dev/null +++ b/agentic-organization/apps/workers/README.md @@ -0,0 +1,65 @@ +# Agentic Organization Workers App + +`apps/workers` is the first runtime-host shell for Agentic +Organization. It is intentionally small and NodeNext-first so the +process boundary can be tested before NestJS, real NATS clients, +CockroachDB connection pools, Kubernetes manifests, or process +supervisors are introduced. + +## Responsibility + +The app composes existing packages. It does not own business rules. + +Current duties: + +- parse typed runtime configuration from process environment values that + Kubernetes can later provide through ConfigMaps and Secrets; +- compose the runtime through a single app-level factory so concrete + adapters stay outside domain and package code; +- run the package-level Organization worker cycle; +- run the NATS JetStream consumer adapter cycle; +- pass configured NATS batch size, stream name, and durable consumer + name into the adapter boundary; +- emit worker-cycle and NATS-consumer batch telemetry records; +- return a healthy/degraded runtime result that makes failures visible + without starving the other loop. + +## Boundary + +`apps/workers` may bind adapter packages and process configuration. +Packages must not import this app. + +The app currently receives ports that tests can fake: + +- `OrganizationWorkerHost`; +- `NatsJetStreamEventConsumer`; +- `WorkerRuntimeTelemetrySink`. + +Future concrete process wiring can bind these ports to CockroachDB, +NATS, OTLP/logging, health checks, readiness checks, and graceful +shutdown without changing runtime rule evaluation. + +## Environment + +The worker app owns process configuration. Packages receive typed +config and ports; they do not read environment variables. + +Required runtime values: + +- `AGENTIC_ORG_ENV`; +- `AGENTIC_ORG_ID`; +- `NATS_STREAM`; +- `NATS_DURABLE`; +- `NATS_INBOUND_BATCH_SIZE`. + +URLs, credentials, and other sensitive adapter settings belong in later +adapter config bound by the composition root. They should come from +Kubernetes Secrets or ExternalSecrets in the full AI cluster, not from +domain packages. + +## Composition Root + +`composeWorkerRuntime` is the app-level seam where already-constructed +ports are connected to the runtime. Today tests provide fake ports. The +next production slice should construct real CockroachDB, NATS, and +telemetry adapters here while preserving the same package contracts. diff --git a/agentic-organization/apps/workers/src/composition.ts b/agentic-organization/apps/workers/src/composition.ts new file mode 100644 index 0000000000..4347648f2a --- /dev/null +++ b/agentic-organization/apps/workers/src/composition.ts @@ -0,0 +1,28 @@ +import type { NatsJetStreamEventConsumer } from "../../../packages/messaging-nats/src/index.ts"; +import type { OrganizationWorkerHost } from "../../../packages/workers/src/index.ts"; +import { + createWorkerRuntime, + type WorkerRuntime, + type WorkerRuntimeConfig, + type WorkerRuntimeTelemetrySink, +} from "./worker-runtime.ts"; + +export type WorkerRuntimePorts = { + organizationWorkerHost: OrganizationWorkerHost; + natsEventConsumer: NatsJetStreamEventConsumer; + telemetrySink: WorkerRuntimeTelemetrySink; +}; + +export type ComposeWorkerRuntimeInput = { + config: WorkerRuntimeConfig; + ports: WorkerRuntimePorts; +}; + +export function composeWorkerRuntime(input: ComposeWorkerRuntimeInput): WorkerRuntime { + return createWorkerRuntime({ + config: input.config, + organizationWorkerHost: input.ports.organizationWorkerHost, + natsEventConsumer: input.ports.natsEventConsumer, + telemetrySink: input.ports.telemetrySink, + }); +} diff --git a/agentic-organization/apps/workers/src/config.ts b/agentic-organization/apps/workers/src/config.ts new file mode 100644 index 0000000000..ee25b75feb --- /dev/null +++ b/agentic-organization/apps/workers/src/config.ts @@ -0,0 +1,71 @@ +import { WorkerRuntimeConfigError, WorkerRuntimeConfigErrorCode, type WorkerRuntimeConfig } from "./worker-runtime.ts"; + +const decimalIntegerPattern = /^[0-9]+$/; + +export const WorkerProcessEnvName = { + AgenticOrgEnv: "AGENTIC_ORG_ENV", + AgenticOrgId: "AGENTIC_ORG_ID", + NatsDurable: "NATS_DURABLE", + NatsInboundBatchSize: "NATS_INBOUND_BATCH_SIZE", + NatsStream: "NATS_STREAM", +} as const; + +export type WorkerProcessEnvName = (typeof WorkerProcessEnvName)[keyof typeof WorkerProcessEnvName]; + +export type WorkerProcessEnvironment = Partial>; + +export function parseWorkerRuntimeConfigFromEnv(env: WorkerProcessEnvironment): WorkerRuntimeConfig { + return { + environment: readRequiredEnvValue( + env, + WorkerProcessEnvName.AgenticOrgEnv, + WorkerRuntimeConfigErrorCode.MissingEnvironment, + ), + organizationId: readRequiredEnvValue( + env, + WorkerProcessEnvName.AgenticOrgId, + WorkerRuntimeConfigErrorCode.MissingOrganizationId, + ), + natsStreamName: readRequiredEnvValue( + env, + WorkerProcessEnvName.NatsStream, + WorkerRuntimeConfigErrorCode.MissingNatsStreamName, + ), + natsDurableName: readRequiredEnvValue( + env, + WorkerProcessEnvName.NatsDurable, + WorkerRuntimeConfigErrorCode.MissingNatsDurableName, + ), + natsInboundBatchSize: parseNatsInboundBatchSize(env[WorkerProcessEnvName.NatsInboundBatchSize]), + }; +} + +function readRequiredEnvValue( + env: WorkerProcessEnvironment, + name: WorkerProcessEnvName, + errorCode: WorkerRuntimeConfigErrorCode, +): string { + const value = env[name]; + + if (value === undefined || value.trim().length === 0) { + throw new WorkerRuntimeConfigError(errorCode); + } + + return value.trim(); +} + +function parseNatsInboundBatchSize(value: string | undefined): number { + const trimmedValue = value?.trim(); + + if (trimmedValue === undefined || trimmedValue.length === 0 || !decimalIntegerPattern.test(trimmedValue)) { + throw new WorkerRuntimeConfigError(WorkerRuntimeConfigErrorCode.InvalidNatsInboundBatchSize); + } + + const parsedValue = Number(trimmedValue); + + if (!Number.isSafeInteger(parsedValue) || parsedValue < 1) { + throw new WorkerRuntimeConfigError(WorkerRuntimeConfigErrorCode.InvalidNatsInboundBatchSize); + } + + return parsedValue; +} diff --git a/agentic-organization/apps/workers/src/index.ts b/agentic-organization/apps/workers/src/index.ts new file mode 100644 index 0000000000..bd7d45dbca --- /dev/null +++ b/agentic-organization/apps/workers/src/index.ts @@ -0,0 +1,18 @@ +export { WorkerProcessEnvName, parseWorkerRuntimeConfigFromEnv, type WorkerProcessEnvironment } from "./config.ts"; +export { composeWorkerRuntime, type ComposeWorkerRuntimeInput, type WorkerRuntimePorts } from "./composition.ts"; +export { + WorkerRuntimeConfigError, + WorkerRuntimeConfigErrorCode, + WorkerRuntimeFailureStage, + WorkerRuntimeStatus, + WorkerRuntimeTelemetryEventName, + createWorkerRuntime, + type CreateWorkerRuntimeInput, + type WorkerRuntime, + type WorkerRuntimeConfig, + type WorkerRuntimeFailure, + type WorkerRuntimeRunResult, + type WorkerRuntimeTelemetryAttributes, + type WorkerRuntimeTelemetryRecord, + type WorkerRuntimeTelemetrySink, +} from "./worker-runtime.ts"; diff --git a/agentic-organization/apps/workers/src/worker-runtime.ts b/agentic-organization/apps/workers/src/worker-runtime.ts new file mode 100644 index 0000000000..3c726a70dc --- /dev/null +++ b/agentic-organization/apps/workers/src/worker-runtime.ts @@ -0,0 +1,279 @@ +import { + buildNatsConsumerBatchAttributes, + buildWorkerCycleAttributes, + type NatsConsumerBatchAttributes, + type WorkerCycleAttributes, +} from "../../../packages/observability/src/index.ts"; +import { OutboxPublishOutcomeStatus } from "../../../packages/messaging/src/index.ts"; +import type { + NatsJetStreamConsumeBatchResult, + NatsJetStreamEventConsumer, +} from "../../../packages/messaging-nats/src/index.ts"; +import { + WorkerCycleStatus, + type OrganizationWorkerHost, + type WorkerCycleResult, +} from "../../../packages/workers/src/index.ts"; + +export const WorkerRuntimeStatus = { + Degraded: "degraded", + Healthy: "healthy", +} as const; + +export type WorkerRuntimeStatus = (typeof WorkerRuntimeStatus)[keyof typeof WorkerRuntimeStatus]; + +export const WorkerRuntimeFailureStage = { + NatsConsumer: "nats_consumer", + OrganizationWorker: "organization_worker", + Telemetry: "telemetry", +} as const; + +export type WorkerRuntimeFailureStage = (typeof WorkerRuntimeFailureStage)[keyof typeof WorkerRuntimeFailureStage]; + +export const WorkerRuntimeConfigErrorCode = { + InvalidNatsInboundBatchSize: "invalid_nats_inbound_batch_size", + MissingEnvironment: "missing_environment", + MissingNatsDurableName: "missing_nats_durable_name", + MissingNatsStreamName: "missing_nats_stream_name", + MissingOrganizationId: "missing_organization_id", +} as const; + +export type WorkerRuntimeConfigErrorCode = + (typeof WorkerRuntimeConfigErrorCode)[keyof typeof WorkerRuntimeConfigErrorCode]; + +export class WorkerRuntimeConfigError extends Error { + readonly code: WorkerRuntimeConfigErrorCode; + + constructor(code: WorkerRuntimeConfigErrorCode) { + super(`invalid worker runtime config: ${code}`); + this.code = code; + } +} + +export const WorkerRuntimeTelemetryEventName = { + NatsConsumerBatchProcessed: "agentic.worker.nats_consumer.batch_processed", + WorkerCycleCompleted: "agentic.worker.cycle.completed", +} as const; + +export type WorkerRuntimeTelemetryEventName = + (typeof WorkerRuntimeTelemetryEventName)[keyof typeof WorkerRuntimeTelemetryEventName]; + +export type WorkerRuntimeTelemetryAttributes = WorkerCycleAttributes | NatsConsumerBatchAttributes; + +export type WorkerRuntimeTelemetryRecord = { + eventName: WorkerRuntimeTelemetryEventName; + attributes: WorkerRuntimeTelemetryAttributes; +}; + +export type WorkerRuntimeTelemetrySink = { + record: (record: WorkerRuntimeTelemetryRecord) => Promise; +}; + +export type WorkerRuntimeConfig = { + environment: string; + natsInboundBatchSize: number; + organizationId: string; + natsStreamName: string; + natsDurableName: string; +}; + +export type WorkerRuntimeFailure = { + stage: WorkerRuntimeFailureStage; + message: string; +}; + +export type WorkerRuntimeRunResult = { + status: WorkerRuntimeStatus; + workerCycle: WorkerCycleResult | undefined; + natsConsumerBatch: NatsJetStreamConsumeBatchResult | undefined; + failures: readonly WorkerRuntimeFailure[]; +}; + +export type WorkerRuntime = { + runOnce: () => Promise; +}; + +export type CreateWorkerRuntimeInput = { + config: WorkerRuntimeConfig; + organizationWorkerHost: OrganizationWorkerHost; + natsEventConsumer: NatsJetStreamEventConsumer; + telemetrySink: WorkerRuntimeTelemetrySink; +}; + +export function createWorkerRuntime(input: CreateWorkerRuntimeInput): WorkerRuntime { + validateWorkerRuntimeConfig(input.config); + + return { + runOnce: async () => { + const failures: WorkerRuntimeFailure[] = []; + const workerCycle = await runOrganizationWorker({ + organizationWorkerHost: input.organizationWorkerHost, + telemetrySink: input.telemetrySink, + failures, + }); + const natsConsumerBatch = await runNatsConsumer({ + config: input.config, + natsEventConsumer: input.natsEventConsumer, + telemetrySink: input.telemetrySink, + failures, + }); + + return { + status: resolveWorkerRuntimeStatus({ + workerCycle, + natsConsumerBatch, + failures, + }), + workerCycle, + natsConsumerBatch, + failures, + }; + }, + }; +} + +function validateWorkerRuntimeConfig(config: WorkerRuntimeConfig): void { + assertNonEmptyConfigValue(config.environment, WorkerRuntimeConfigErrorCode.MissingEnvironment); + assertNonEmptyConfigValue(config.organizationId, WorkerRuntimeConfigErrorCode.MissingOrganizationId); + assertNonEmptyConfigValue(config.natsStreamName, WorkerRuntimeConfigErrorCode.MissingNatsStreamName); + assertNonEmptyConfigValue(config.natsDurableName, WorkerRuntimeConfigErrorCode.MissingNatsDurableName); + + if (!Number.isInteger(config.natsInboundBatchSize) || config.natsInboundBatchSize < 1) { + throw new WorkerRuntimeConfigError(WorkerRuntimeConfigErrorCode.InvalidNatsInboundBatchSize); + } +} + +function assertNonEmptyConfigValue(value: string, code: WorkerRuntimeConfigErrorCode): void { + if (value.trim().length === 0) { + throw new WorkerRuntimeConfigError(code); + } +} + +type RunOrganizationWorkerInput = { + organizationWorkerHost: OrganizationWorkerHost; + telemetrySink: WorkerRuntimeTelemetrySink; + failures: WorkerRuntimeFailure[]; +}; + +async function runOrganizationWorker(input: RunOrganizationWorkerInput): Promise { + try { + const workerCycle = await input.organizationWorkerHost.runOnce(); + await recordTelemetry({ + telemetrySink: input.telemetrySink, + failures: input.failures, + record: { + eventName: WorkerRuntimeTelemetryEventName.WorkerCycleCompleted, + attributes: buildWorkerCycleAttributes({ + status: workerCycle.status, + outboxStatus: workerCycle.outbox?.status ?? OutboxPublishOutcomeStatus.Empty, + inboundPulledCount: workerCycle.inbound.pulledCount, + inboundProcessedCount: workerCycle.inbound.processedCount, + inboundDuplicateCount: workerCycle.inbound.duplicateCount, + inboundPayloadConflictCount: workerCycle.inbound.payloadConflictCount, + inboundFailedCount: workerCycle.inbound.failedCount, + inboundReactionPlanCount: workerCycle.inbound.reactionPlanCount, + failureCount: workerCycle.failures.length, + }), + }, + }); + return workerCycle; + } catch (error) { + input.failures.push({ + stage: WorkerRuntimeFailureStage.OrganizationWorker, + message: extractErrorMessage(error), + }); + return undefined; + } +} + +type RunNatsConsumerInput = { + config: WorkerRuntimeConfig; + natsEventConsumer: NatsJetStreamEventConsumer; + telemetrySink: WorkerRuntimeTelemetrySink; + failures: WorkerRuntimeFailure[]; +}; + +async function runNatsConsumer(input: RunNatsConsumerInput): Promise { + try { + const natsConsumerBatch = await input.natsEventConsumer.processNextBatch({ + batchSize: input.config.natsInboundBatchSize, + }); + await recordTelemetry({ + telemetrySink: input.telemetrySink, + failures: input.failures, + record: { + eventName: WorkerRuntimeTelemetryEventName.NatsConsumerBatchProcessed, + attributes: buildNatsConsumerBatchAttributes({ + streamName: input.config.natsStreamName, + durableName: input.config.natsDurableName, + ...natsConsumerBatch, + }), + }, + }); + return natsConsumerBatch; + } catch (error) { + input.failures.push({ + stage: WorkerRuntimeFailureStage.NatsConsumer, + message: extractErrorMessage(error), + }); + return undefined; + } +} + +type RecordTelemetryInput = { + telemetrySink: WorkerRuntimeTelemetrySink; + failures: WorkerRuntimeFailure[]; + record: WorkerRuntimeTelemetryRecord; +}; + +async function recordTelemetry(input: RecordTelemetryInput): Promise { + try { + await input.telemetrySink.record(input.record); + } catch (error) { + input.failures.push({ + stage: WorkerRuntimeFailureStage.Telemetry, + message: extractErrorMessage(error), + }); + } +} + +type ResolveWorkerRuntimeStatusInput = { + workerCycle: WorkerCycleResult | undefined; + natsConsumerBatch: NatsJetStreamConsumeBatchResult | undefined; + failures: readonly WorkerRuntimeFailure[]; +}; + +function resolveWorkerRuntimeStatus(input: ResolveWorkerRuntimeStatusInput): WorkerRuntimeStatus { + if ( + input.failures.length > 0 || + input.workerCycle?.status === WorkerCycleStatus.Degraded || + isNatsConsumerBatchDegraded(input.natsConsumerBatch) + ) { + return WorkerRuntimeStatus.Degraded; + } + + return WorkerRuntimeStatus.Healthy; +} + +function isNatsConsumerBatchDegraded(batch: NatsJetStreamConsumeBatchResult | undefined): boolean { + if (batch === undefined) { + return true; + } + + return ( + batch.failedCount !== 0 || + batch.deadLetteredCount !== 0 || + batch.invalidCount !== 0 || + batch.payloadConflictCount !== 0 || + batch.negativeAcknowledgedCount !== 0 || + batch.terminatedCount !== 0 + ); +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/agentic-organization/apps/workers/test/worker-composition.test.ts b/agentic-organization/apps/workers/test/worker-composition.test.ts new file mode 100644 index 0000000000..0d2aa035a4 --- /dev/null +++ b/agentic-organization/apps/workers/test/worker-composition.test.ts @@ -0,0 +1,75 @@ +import { equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { OutboxPublishOutcomeStatus } from "../../../packages/messaging/src/index.ts"; +import type { NatsJetStreamConsumeBatchResult } from "../../../packages/messaging-nats/src/index.ts"; +import { WorkerCycleStatus, type WorkerCycleResult } from "../../../packages/workers/src/index.ts"; +import { WorkerRuntimeStatus, composeWorkerRuntime, type WorkerRuntimeTelemetrySink } from "../src/index.ts"; + +describe("worker runtime composition", () => { + test("creates a runnable worker runtime from parsed config and ports", async () => { + const runtime = composeWorkerRuntime({ + config: { + environment: "dev", + organizationId: "org-lfg", + natsStreamName: "agentic-org-events", + natsDurableName: "agentic-org-v0-automation-planner", + natsInboundBatchSize: 25, + }, + ports: { + organizationWorkerHost: { + runOnce: async () => createWorkedWorkerCycle(), + }, + natsEventConsumer: { + processNextBatch: async () => createProcessedNatsBatch(), + }, + telemetrySink: createNoopTelemetrySink(), + }, + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Healthy); + }); +}); + +function createWorkedWorkerCycle(): WorkerCycleResult { + return { + status: WorkerCycleStatus.Worked, + outbox: { + status: OutboxPublishOutcomeStatus.Published, + attemptedCount: 1, + publishedOutboxEventIds: ["outbox-001"], + }, + inbound: { + pulledCount: 0, + processedCount: 0, + duplicateCount: 0, + payloadConflictCount: 0, + failedCount: 0, + reactionPlanCount: 0, + }, + failures: [], + }; +} + +function createProcessedNatsBatch(): NatsJetStreamConsumeBatchResult { + return { + receivedCount: 0, + processedCount: 0, + duplicateCount: 0, + payloadConflictCount: 0, + invalidCount: 0, + failedCount: 0, + acknowledgedCount: 0, + negativeAcknowledgedCount: 0, + terminatedCount: 0, + deadLetteredCount: 0, + }; +} + +function createNoopTelemetrySink(): WorkerRuntimeTelemetrySink { + return { + record: async () => undefined, + }; +} diff --git a/agentic-organization/apps/workers/test/worker-config.test.ts b/agentic-organization/apps/workers/test/worker-config.test.ts new file mode 100644 index 0000000000..c071d2f2be --- /dev/null +++ b/agentic-organization/apps/workers/test/worker-config.test.ts @@ -0,0 +1,79 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + WorkerProcessEnvName, + WorkerRuntimeConfigError, + WorkerRuntimeConfigErrorCode, + parseWorkerRuntimeConfigFromEnv, +} from "../src/index.ts"; + +describe("worker runtime config parsing", () => { + test("parses typed runtime config from process env", () => { + deepEqual( + parseWorkerRuntimeConfigFromEnv({ + [WorkerProcessEnvName.AgenticOrgEnv]: " dev ", + [WorkerProcessEnvName.AgenticOrgId]: " org-lfg ", + [WorkerProcessEnvName.NatsStream]: " agentic-org-events ", + [WorkerProcessEnvName.NatsDurable]: " agentic-org-v0-automation-planner ", + [WorkerProcessEnvName.NatsInboundBatchSize]: "25", + }), + { + environment: "dev", + organizationId: "org-lfg", + natsStreamName: "agentic-org-events", + natsDurableName: "agentic-org-v0-automation-planner", + natsInboundBatchSize: 25, + }, + ); + }); + + test("rejects missing required env values with typed errors", () => { + try { + parseWorkerRuntimeConfigFromEnv({ + [WorkerProcessEnvName.AgenticOrgId]: "org-lfg", + [WorkerProcessEnvName.NatsStream]: "agentic-org-events", + [WorkerProcessEnvName.NatsDurable]: "agentic-org-v0-automation-planner", + [WorkerProcessEnvName.NatsInboundBatchSize]: "25", + }); + throw new Error("expected config parsing to fail"); + } catch (error) { + equal(error instanceof WorkerRuntimeConfigError, true); + equal((error as WorkerRuntimeConfigError).code, WorkerRuntimeConfigErrorCode.MissingEnvironment); + } + }); + + test("rejects invalid numeric env values with typed errors", () => { + try { + parseWorkerRuntimeConfigFromEnv({ + [WorkerProcessEnvName.AgenticOrgEnv]: "dev", + [WorkerProcessEnvName.AgenticOrgId]: "org-lfg", + [WorkerProcessEnvName.NatsStream]: "agentic-org-events", + [WorkerProcessEnvName.NatsDurable]: "agentic-org-v0-automation-planner", + [WorkerProcessEnvName.NatsInboundBatchSize]: "not-a-number", + }); + throw new Error("expected config parsing to fail"); + } catch (error) { + equal(error instanceof WorkerRuntimeConfigError, true); + equal((error as WorkerRuntimeConfigError).code, WorkerRuntimeConfigErrorCode.InvalidNatsInboundBatchSize); + } + }); + + test("rejects non-decimal or unsafe batch sizes with typed errors", () => { + for (const batchSize of ["1.5", "1e3", "9007199254740992"]) { + try { + parseWorkerRuntimeConfigFromEnv({ + [WorkerProcessEnvName.AgenticOrgEnv]: "dev", + [WorkerProcessEnvName.AgenticOrgId]: "org-lfg", + [WorkerProcessEnvName.NatsStream]: "agentic-org-events", + [WorkerProcessEnvName.NatsDurable]: "agentic-org-v0-automation-planner", + [WorkerProcessEnvName.NatsInboundBatchSize]: batchSize, + }); + throw new Error("expected config parsing to fail"); + } catch (error) { + equal(error instanceof WorkerRuntimeConfigError, true); + equal((error as WorkerRuntimeConfigError).code, WorkerRuntimeConfigErrorCode.InvalidNatsInboundBatchSize); + } + } + }); +}); diff --git a/agentic-organization/apps/workers/test/worker-runtime.test.ts b/agentic-organization/apps/workers/test/worker-runtime.test.ts new file mode 100644 index 0000000000..a5b2fbe32f --- /dev/null +++ b/agentic-organization/apps/workers/test/worker-runtime.test.ts @@ -0,0 +1,282 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { OutboxPublishOutcomeStatus } from "../../../packages/messaging/src/index.ts"; +import type { NatsJetStreamConsumeBatchResult } from "../../../packages/messaging-nats/src/index.ts"; +import { WorkerCycleStatus, type WorkerCycleResult } from "../../../packages/workers/src/index.ts"; +import { + WorkerRuntimeFailureStage, + WorkerRuntimeConfigError, + WorkerRuntimeConfigErrorCode, + WorkerRuntimeStatus, + WorkerRuntimeTelemetryEventName, + createWorkerRuntime, + type WorkerRuntimeTelemetrySink, +} from "../src/index.ts"; + +describe("worker runtime composition host", () => { + test("runs worker and NATS consumer loops with configured telemetry", async () => { + const organizationWorkerHost = createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()); + const natsEventConsumer = createRecordingNatsEventConsumer(createProcessedNatsBatch()); + const telemetrySink = createRecordingTelemetrySink(); + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost, + natsEventConsumer, + telemetrySink, + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Healthy); + equal(organizationWorkerHost.runCount, 1); + deepEqual(natsEventConsumer.batchSizes, [50]); + deepEqual(telemetrySink.records, [ + { + eventName: WorkerRuntimeTelemetryEventName.WorkerCycleCompleted, + attributes: { + "agentic.worker.cycle.status": WorkerCycleStatus.Worked, + "agentic.worker.outbox.status": OutboxPublishOutcomeStatus.Published, + "agentic.worker.inbound.pulled_count": 0, + "agentic.worker.inbound.processed_count": 0, + "agentic.worker.inbound.duplicate_count": 0, + "agentic.worker.inbound.payload_conflict_count": 0, + "agentic.worker.inbound.failed_count": 0, + "agentic.worker.inbound.reaction_plan_count": 0, + "agentic.worker.failure_count": 0, + }, + }, + { + eventName: WorkerRuntimeTelemetryEventName.NatsConsumerBatchProcessed, + attributes: { + "messaging.system": "nats", + "messaging.nats.stream": "agentic-org-events", + "messaging.nats.consumer": "agentic-org-v0-automation-planner", + "agentic.nats.consumer.received_count": 2, + "agentic.nats.consumer.processed_count": 2, + "agentic.nats.consumer.duplicate_count": 0, + "agentic.nats.consumer.payload_conflict_count": 0, + "agentic.nats.consumer.invalid_count": 0, + "agentic.nats.consumer.failed_count": 0, + "agentic.nats.consumer.acknowledged_count": 2, + "agentic.nats.consumer.negative_acknowledged_count": 0, + "agentic.nats.consumer.terminated_count": 0, + "agentic.nats.consumer.dead_lettered_count": 0, + }, + }, + ]); + }); + + test("keeps the NATS loop running when the worker loop throws", async () => { + const natsEventConsumer = createRecordingNatsEventConsumer(createProcessedNatsBatch()); + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createFailingOrganizationWorkerHost("outbox loop failed"), + natsEventConsumer, + telemetrySink: createRecordingTelemetrySink(), + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + deepEqual(natsEventConsumer.batchSizes, [50]); + deepEqual(result.failures, [ + { + stage: WorkerRuntimeFailureStage.OrganizationWorker, + message: "outbox loop failed", + }, + ]); + }); + + test("marks the runtime degraded when NATS consumer reports dead letters", async () => { + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer({ + ...createProcessedNatsBatch(), + deadLetteredCount: 1, + terminatedCount: 1, + }), + telemetrySink: createRecordingTelemetrySink(), + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + }); + + test("keeps successful loop results visible when telemetry fails", async () => { + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createFailingTelemetrySink("telemetry sink unavailable"), + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + equal(result.workerCycle?.status, WorkerCycleStatus.Worked); + equal(result.natsConsumerBatch?.processedCount, 2); + deepEqual(result.failures, [ + { + stage: WorkerRuntimeFailureStage.Telemetry, + message: "telemetry sink unavailable", + }, + { + stage: WorkerRuntimeFailureStage.Telemetry, + message: "telemetry sink unavailable", + }, + ]); + }); + + test("marks the runtime degraded when NATS consumer reports non-happy counters", async () => { + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer({ + ...createProcessedNatsBatch(), + invalidCount: 1, + }), + telemetrySink: createRecordingTelemetrySink(), + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + }); + + test("rejects invalid process config before loops can start", () => { + try { + createWorkerRuntime({ + config: { + ...createRuntimeConfig(), + natsInboundBatchSize: 0, + }, + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createRecordingTelemetrySink(), + }); + throw new Error("expected worker runtime config validation to fail"); + } catch (error) { + equal(error instanceof WorkerRuntimeConfigError, true); + equal((error as WorkerRuntimeConfigError).code, WorkerRuntimeConfigErrorCode.InvalidNatsInboundBatchSize); + } + }); +}); + +function createRuntimeConfig(): { + environment: string; + natsInboundBatchSize: number; + organizationId: string; + natsStreamName: string; + natsDurableName: string; +} { + return { + environment: "test", + natsInboundBatchSize: 50, + organizationId: "org-lfg", + natsStreamName: "agentic-org-events", + natsDurableName: "agentic-org-v0-automation-planner", + }; +} + +function createWorkedWorkerCycle(): WorkerCycleResult { + return { + status: WorkerCycleStatus.Worked, + outbox: { + status: OutboxPublishOutcomeStatus.Published, + attemptedCount: 1, + publishedOutboxEventIds: ["outbox-001"], + }, + inbound: { + pulledCount: 0, + processedCount: 0, + duplicateCount: 0, + payloadConflictCount: 0, + failedCount: 0, + reactionPlanCount: 0, + }, + failures: [], + }; +} + +function createProcessedNatsBatch(): NatsJetStreamConsumeBatchResult { + return { + receivedCount: 2, + processedCount: 2, + duplicateCount: 0, + payloadConflictCount: 0, + invalidCount: 0, + failedCount: 0, + acknowledgedCount: 2, + negativeAcknowledgedCount: 0, + terminatedCount: 0, + deadLetteredCount: 0, + }; +} + +function createRecordingOrganizationWorkerHost(result: WorkerCycleResult): { + runCount: number; + runOnce: () => Promise; +} { + return { + runCount: 0, + runOnce: async function runOnce() { + this.runCount += 1; + return result; + }, + }; +} + +function createFailingOrganizationWorkerHost(message: string): { + runOnce: () => Promise; +} { + return { + runOnce: async () => { + throw new Error(message); + }, + }; +} + +function createRecordingNatsEventConsumer(result: NatsJetStreamConsumeBatchResult): { + batchSizes: number[]; + processNextBatch: (input: { batchSize: number }) => Promise; +} { + const batchSizes: number[] = []; + + return { + batchSizes, + processNextBatch: async (input) => { + batchSizes.push(input.batchSize); + return result; + }, + }; +} + +function createRecordingTelemetrySink(): WorkerRuntimeTelemetrySink & { + records: { + eventName: WorkerRuntimeTelemetryEventName; + attributes: Record; + }[]; +} { + const records: { + eventName: WorkerRuntimeTelemetryEventName; + attributes: Record; + }[] = []; + + return { + records, + record: async (record) => { + records.push(record); + }, + }; +} + +function createFailingTelemetrySink(message: string): WorkerRuntimeTelemetrySink { + return { + record: async () => { + throw new Error(message); + }, + }; +} diff --git a/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md b/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md index 9f8e615eba..84826c6391 100644 --- a/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md +++ b/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md @@ -29,13 +29,33 @@ send_supervisor_signal -> chain-of-command signal -> audit event -> outbox event with canonical event envelope + -> command outcome persisted through one state-store operation -> outbox publisher -> NATS JetStream event publisher adapter + -> NATS JetStream event consumer adapter -> NATS subject contract + -> event ingestion processor + -> inbox receipt / consumer dedupe + -> event-processing outcome persisted through one store operation + -> persisted reaction plans + -> worker host cycle summary + -> apps/workers runtime summary -> LGTM span attributes -> supervisor triage reaction plan ``` +## Checkpoint Boundary + +The implemented slice does not yet create discussion anchors, graph +nodes, hat assignments, hat tokens, policy decisions, prompt-flow runs, +Hermes runs, or reviewer gates. Those remain V0 follow-on commands. + +Capability-request-shaped inputs should continue to enter through +`send_supervisor_signal`. The target supervisor triage step decides +whether to create a `CapabilityRequest` work item, route to security, +open a discussion, assign implementation work, answer directly, or +escalate. + ## Packages | Package | Implemented first | @@ -45,11 +65,18 @@ send_supervisor_signal | `@agentic-org/state` | generic state-store/outbox-source ports plus the in-memory Organization state-store factory fake | | `@agentic-org/state-cockroach` | first replaceable durable SQL implementation of the state-store/outbox-source ports, backed by CockroachDB | | `@agentic-org/messaging` | stable `agentic-org....` subject builder, outbox publisher, event publisher port, and typed domain resolver | -| `@agentic-org/messaging-nats` | NATS JetStream event publisher adapter contract with canonical JSON payloads, headers, and message IDs | -| `@agentic-org/observability` | OpenTelemetry/LGTM span attribute projection | +| `@agentic-org/messaging-nats` | NATS JetStream publisher and consumer adapter contracts with canonical JSON payloads, headers, message IDs, ack/nack, termination, and DLQ policy | +| `@agentic-org/observability` | OpenTelemetry/LGTM span attribute projection for event envelopes and NATS consumer batch summaries | | `@agentic-org/runtime` | first rule that plans triage for the target supervisor when a chain signal is sent | +| `@agentic-org/workers` | process-boundary run-once worker host that composes outbox publishing and inbound event ingestion through ports | | `@agentic-org/governance` | package dependency-boundary checks that prevent application code from importing concrete state/runtime adapters | +## Apps + +| App | Implemented first | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/workers` | NodeNext runtime-host shell that parses process config, composes worker ports, runs the package worker cycle, runs the NATS consumer cycle, emits telemetry records, and reports healthy/degraded state | + ## NodeNext Runtime Decision Agentic Organization now has a local `package.json` and @@ -99,6 +126,14 @@ Hermes runs, MCP calls, and UI evidence. - The command pipeline receives state-store factories and command handlers through ports instead of constructing in-memory adapters or branching on command types. +- Command handlers return typed effects; the command pipeline persists + the supervisor signal, audit events, outbox events, and idempotency + record through one `recordCommandOutcome` port. Handlers do not write + piecemeal state. +- Command outcome persistence returns generic committed, replayed, or + idempotency-conflict results. Durable adapters own idempotency race + handling and return those generic outcomes without exposing duplicate + key or vendor errors to application code. - State-store and outbox-source ports are async from the beginning so durable SQL, NATS-backed workers, and other real adapters do not inherit a fake synchronous shape. @@ -108,11 +143,70 @@ Hermes runs, MCP calls, and UI evidence. - A governance test enforces that the Cockroach state adapter does not import messaging, NATS, or JetStream. Durable state can be swapped without dragging transport concerns into the repository layer. +- A governance test enforces package source layout: production code + lives under `packages//src`, tests live under + `packages//test`, and `*.test.ts` files are rejected from + production source trees. - The outbox publisher claims unpublished events, publishes each event through an `EventPublisher` port, and marks rows published only after the publish succeeds. - The NATS adapter publishes canonical JSON envelopes with typed headers and event IDs as message IDs for idempotent JetStream publication. +- The NATS consumer adapter decodes canonical JSON envelopes, preserves + the traceable event boundary into the runtime ingestion processor, + acknowledges processed and duplicate messages, terminates and + dead-letters invalid envelopes or payload conflicts, and + negative-acknowledges transient ingestion failures. If dead-letter + publication or source-message termination fails, it records the + failure, negative-acknowledges the source message, and continues the + batch. +- The event ingestion processor accepts decoded canonical envelopes, + dedupes them by event ID plus consumer name, evaluates automation + rules once, rejects same-event payload hash conflicts, and persists + reaction plans through a store boundary that durable adapters can make + transactional. +- Event ingestion treats only completed receipts as duplicates. If a + same-payload receipt exists without `processedAt` and `result`, the + processor re-evaluates the event and records the full outcome so old + orphan receipts do not suppress automation. +- The Cockroach adapter now declares inbox receipt and reaction plan + tables plus a SQL-backed event-ingestion store. This is still behind a + generic state port; live NATS consumers are not hardcoded into the + adapter. +- The Cockroach command and event-ingestion adapters expose + adapter-local transaction batch seams. Application and runtime code + still see generic outcome ports; Cockroach-specific transaction + mechanics stay in `@agentic-org/state-cockroach`. +- The Cockroach command adapter records the idempotency row before + effect rows inside the command transaction batch, so a duplicate key + aborts before supervisor signal, audit, or outbox rows are submitted. +- The Cockroach command adapter claims the idempotency key before + inserting effects. If it loses the race, it returns replay or + idempotency conflict through the generic `CommandStateStore` result + and does not insert command effects. +- The Cockroach event-ingestion adapter normalizes SQL `NULL` + completion fields to pending receipts and claims the pending receipt + before inserting reaction plans. If the claim reports duplicate or + payload conflict, the adapter returns that generic outcome without + inserting reaction plans. +- The Cockroach event-ingestion adapter also requires the final + processed-receipt update to return the claimed receipt. If that + completion check fails after reaction plans were prepared, the + transaction rolls back and the adapter returns a generic duplicate + outcome. +- Governance now checks that runtime code, like application code, cannot + import vendor adapters or vendor clients directly. Vendor packages must + implement generic Organization ports consumed by application/runtime + packages. +- The worker host now runs one bounded outbox cycle plus one bounded + inbound-ingestion cycle through explicit ports, then returns an + idle/worked/degraded summary suitable for future logs, metrics, and UI + workflow visibility. If one lane fails, the other lane still runs and + the failure is returned as typed cycle data. +- A governance test enforces that worker source does not import the + Cockroach adapter, NATS adapter, NestJS, NATS, Dapr, Temporal, + Drizzle, or Postgres clients. Worker code remains a process boundary, + not a concrete infrastructure host. - Duplicate commands with the same idempotency key and request hash replay the stored result. - Duplicate commands with the same idempotency key and a different @@ -121,14 +215,47 @@ Hermes runs, MCP calls, and UI evidence. - Event envelopes reject missing command trace fields. - The first automation rule produces a supervisor triage plan, not an unreviewed side effect. +- Observability now exposes NATS consumer batch attributes for received, + processed, duplicate, payload-conflict, invalid, failed, + acknowledged, negative-acknowledged, terminated, and dead-lettered + counts. +- `apps/workers` now composes the package worker cycle and NATS consumer + cycle behind process configuration and telemetry ports. If one cycle + throws, the other still runs and the runtime result is degraded with a + typed failure stage. +- `apps/workers` keeps successful worker/NATS cycle results visible even + when telemetry recording fails. Telemetry sink failures degrade the + runtime through a dedicated failure stage instead of erasing completed + work. +- `apps/workers` validates required process config before any loop can + start: environment, Organization ID, NATS stream, durable consumer, + and positive NATS inbound batch size. +- `apps/workers` parses required runtime values from typed environment + names: `AGENTIC_ORG_ENV`, `AGENTIC_ORG_ID`, `NATS_STREAM`, + `NATS_DURABLE`, and `NATS_INBOUND_BATCH_SIZE`. String values are + trimmed, and NATS inbound batch size must be a safe positive decimal + integer. +- `apps/workers` treats any non-happy NATS consumer counter as degraded: + failed, dead-lettered, invalid, payload-conflict, + negative-acknowledged, or terminated messages. +- `apps/workers` exposes an app-level composition factory that receives + typed config plus already-constructed ports. Future real CockroachDB, + NATS, and telemetry adapters bind at this app seam instead of leaking + process or secret concerns into reusable packages. ## Next Slice -The next slice should add inbox/consumer dedupe before automation starts -performing side effects from NATS events. After that, wire the outbox -publisher into a worker host and add a transactional durable-state -adapter integration test using CockroachDB as the first cluster-backed -implementation once a local/dev connection is available. +The next slice should add policy and hat-authority checks before real +API, MCP, Hermes, or worker command entrypoints can call the command +pipeline. After that, add the first real process adapter factories below +`apps/workers`: concrete NATS pull/publish client construction, durable +CockroachDB outbox/inbox adapter construction, and a telemetry sink that +can later send structured logs and metrics into the full-ai-cluster LGTM +stack. Keep URLs, credentials, and connection pools in app adapter config +fed by Kubernetes Secret or ExternalSecret values, never in domain +packages. Add a durable-state integration test using CockroachDB as the +first cluster-backed implementation once a local/dev connection is +available. Do not make the next slice a pile of bespoke request commands. Build the generic supervisor triage lifecycle first, then let specialized diff --git a/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md b/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md new file mode 100644 index 0000000000..be6b34ee01 --- /dev/null +++ b/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md @@ -0,0 +1,244 @@ +# North Star Alignment Checkpoint + +## Status + +Current checkpoint after the first executable TypeScript slices and +subagent review. + +## Verdict + +Agentic Organization is directionally aligned with the north star: + +- the primary executable primitive is `send_supervisor_signal`; +- hats are modeled as time-bounded authority, skill, policy, and + communication roles rather than agent identity; +- Organization DB owns business intent, while cluster substrates enforce + or project runtime state; +- work, discussions, decisions, runs, evidence, and memory must stay + anchored to project, initiative, task, gate, incident, release, policy, + or context-gap work; +- the runtime is event-driven through durable state, outbox, NATS + publication, inbox dedupe, reaction plans, workers, and telemetry; +- the design keeps agents able to expand tools, prompt flows, workflows, + and lifecycles through governed organizational work instead of a fixed + list of one-off commands. + +The main risk is convergence. The doc set is broad enough that older +sections still describe future products as if they are current V0 +entrypoints. V0 must remain smaller and sharper. + +## Canonical V0 Product Contract + +The current V0 contract is: + +```text +hat communication brief + -> send_supervisor_signal + -> supervisor triage plan + -> anchored work item and context + -> gate decision + -> hat assignment and scoped runtime authority + -> scheduled prompt-flow run + -> Hermes run binding + -> evidence submission + -> reviewer decision + -> outcome review + -> follow-up work when gaps are found +``` + +Capability requests, credential requests, missing-tool reports, +workflow gaps, security asks, memory gaps, blockers, questions, and +process-improvement ideas are not separate first primitives. They enter +through supervisor-chain communication, then become specialized work only +after the responsible hat triages them. + +## Alignment Confirmed + +### Supervisor Chain + +`SUPERVISOR_CHAIN_COMMUNICATION.md`, `FIRST_IMPLEMENTATION_SLICE.md`, +`V0_SCHEMA_AND_COMMANDS.md`, and OpenSpec all point at +`send_supervisor_signal` as the generic coordination primitive. + +### Hat Model + +`CLUSTER_NATIVE_HAT_SYSTEM.md`, `V0_POLICY_AND_RUNTIME_BOUNDARIES.md`, +and `V0_SCHEMA_AND_COMMANDS.md` preserve hats as scoped, time-bounded +roles with authority, skills, RBAC/policy, succession, and supervisor +graph position. + +### Work Anchors + +`AGENT_NATIVE_KNOWLEDGE_GRAPH.md`, `WORK_AND_RELEASE_MANAGEMENT_OS.md`, +and `UI_AND_OBSERVABILITY_CONCEPTS.md` reject unanchored discussions. +Meetings, one-on-ones, broadcasts, votes, review comments, reports, and +team threads must reference work before they can affect state. + +### Cluster Substrate Position + +`AI_CLUSTER_SCAFFOLD_CONTEXT.md`, `CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md`, +`TECHNICAL_CA_PACKAGE_ARCHITECTURE.md`, and `V0_EXECUTABLE_CONTRACT.md` +correctly place Agentic Organization as a TypeScript consumer workload +on `full-ai-cluster`, not a parallel substrate. + +### Implementation Direction + +The current packages prove the right spine: + +- command handler registry; +- idempotency check and atomic command-outcome persistence port; +- supervisor signal handler; +- audit/outbox envelope; +- NATS subject and publisher/consumer adapters; +- inbox dedupe, orphan-receipt recovery, and reaction plans; +- worker host and app composition shell; +- telemetry attributes and workflow visibility records; +- package-boundary governance. + +## Drift To Correct + +### Capability Request Language + +Some older docs still describe agents as directly submitting capability +requests. Those sections must be normalized to: + +```text +agent observes gap + -> send_supervisor_signal + -> supervisor triage + -> optional CapabilityRequest work item + -> department/security/architecture routing + -> implementation/review/activation/outcome review +``` + +Capability request remains a valid work item type. It is not the first +communication primitive. + +### State Name Divergence + +Work item states are named differently across Work OS, V0 schema, UI +concepts, and implementation. Before adding more commands, create one +state reconciliation table that maps: + +- conceptual Work OS state; +- V0 enum; +- UI column; +- event name; +- owner package; +- allowed transitions; +- gate owner. + +### Discussion Anchor Gap + +The docs say V0 work should include discussion anchors and graph nodes. +The current implementation only writes the supervisor signal, audit +event, outbox event, idempotency record, inbox receipts, and reaction +plans. The next V0 command slice must either implement discussion-anchor +creation or explicitly stage it as the next command after +`send_supervisor_signal`. + +### Transaction Boundary Progress + +The command pipeline now persists supervisor signal state, audit events, +outbox events, and idempotency records through one +`recordCommandOutcome` port. Command handlers return typed effects +instead of writing piecemeal state. + +The event ingestion path already used a single +`recordEventProcessingOutcome` port and now treats unfinished receipts +as recoverable rather than duplicate. Cockroach command and +event-ingestion adapters now expose transaction-batch executor seams so +the app/runtime layers remain database-generic while durable adapters +can commit outcome batches atomically. + +The remaining gap is integration-level proof against a real CockroachDB +transaction. The current tests prove the batch boundary and runtime +recovery behavior; a future local/dev-cluster integration test should +prove actual rollback behavior with the real adapter binding. + +### Policy And Hat Authority Gap + +`send_supervisor_signal` does not yet validate actor hat authority, +source level, target supervisor, or active hat assignment. Before API, +MCP, Hermes, or worker hosts accept real agent commands, the application +boundary needs a policy/hat-authority port and tests for unauthorized +source hats, invalid target supervisors, expired/revoked hats, and +missing assignments. + +### Command Surface Closure + +The command pipeline and command result are still shaped around the +first command. Before adding `triage_supervisor_signal`, +`reserve_hat`, or `decide_gate`, make the pipeline generic over +registered command/result contracts or return a generic command outcome +with typed artifacts and events. + +### Raw Chat Tool Names + +Tool inventory language still includes broad names such as +`send_message`, `open_thread`, and `open_team_chat`. These should be +defined as anchored wrappers, not raw chat authority. All communication +paths must validate or create a `discussion_anchor` before opening a +conversation. + +### UAG Is Not Yet Canonical + +Prompt flows correctly point toward Universal Action Grammar, but UAG v0 +needs a typed registry: action names, target kinds, action modes, +reversibility, observation status, evidence requirements, and replay +semantics. + +## Cluster Integration Gaps + +### Hat-System Projection + +Agentic Organization needs a `k8s-hats` package that can read Hat, +HatBinding, HatSwap, and HatPolicy CRDs, then project them into +Organization signals. The CRD subject model currently differs from +Agentic Organization NATS subjects, so a translator or dual-subject +contract is required. + +### Identity Mapping + +Organization events use `agentId` and `hatAssignmentId`; hat-system +bindings use SPIFFE wearer identity. V0 needs a canonical mapping from +Organization agent/session/hat assignment to SPIFFE identity. + +### Hindsight Memory Attribution + +Docs correctly separate Hindsight memory from Organization graph facts, +but no memory package exists yet. V0 needs a memory attribution contract +for agent ID, hat ID, project, initiative, task, prompt-flow run, and +outcome review. + +### Hermes/OZ Runtime + +`launch_hermes_run` is still a documented boundary, not an executable +adapter. V0 needs a narrow Hermes runtime port before real cloud/runtime +integration. + +### LGTM Export + +The observability helpers are useful but not fully wired to cluster +export. V0 needs concrete OTLP/log/metric adapter config, service +labels, dashboard ownership, and alertable degraded-worker signals. + +## Checkpoint Priorities + +1. Normalize capability-request language across docs so + supervisor-chain communication is the only first primitive. +2. Add a V0 state reconciliation table before expanding command enums or + UI boards. +3. Add policy/hat-authority checks before exposing command handlers to + API, MCP, Hermes, or workers. +4. Add `triage_supervisor_signal` as the next real command slice. +5. Add discussion-anchor enforcement and graph retrieval OpenSpec + scenarios, then implement the minimal anchor command. +6. Add real CockroachDB transaction integration coverage for command + outcomes and event-ingestion outcomes once a dev connection is + available. +7. Define UAG v0 as a typed package contract before adding prompt-flow + execution. +8. Build one substrate integration at a time, starting with hat-system + projection because identity, authority, CRDs, NATS subjects, and + policy meet there. diff --git a/agentic-organization/docs/README.md b/agentic-organization/docs/README.md index 2dc8721ccd..04dde60ac7 100644 --- a/agentic-organization/docs/README.md +++ b/agentic-organization/docs/README.md @@ -26,6 +26,7 @@ Current documents: - [Implementation Readiness Checklist](./IMPLEMENTATION_READINESS_CHECKLIST.md) - the decisions and contracts that should be defined before scaffolding the first implementation slice. - [Implementation Governance](./IMPLEMENTATION_GOVERNANCE.md) - the current-state, OpenSpec, authority, idempotency, telemetry, security, and quality rules for implementation work. - [First Implementation Slice](./FIRST_IMPLEMENTATION_SLICE.md) - the NodeNext TypeScript package slice proving command, state, audit, outbox, NATS subject, telemetry, and reaction-plan contracts. +- [North Star Alignment Checkpoint](./NORTH_STAR_ALIGNMENT_CHECKPOINT.md) - current alignment verdict, drift list, and next priorities against the Agentic Organization north star. - [V0 Executable Contract](./V0_EXECUTABLE_CONTRACT.md) - the smallest end-to-end runtime slice, grounded against the current `full-ai-cluster` substrate. - [V0 Schema and Commands](./V0_SCHEMA_AND_COMMANDS.md) - the CockroachDB-backed state groups, enums, command contract, outbox model, and TypeScript-facing runtime events for the first implementation. - [V0 Policy and Runtime Boundaries](./V0_POLICY_AND_RUNTIME_BOUNDARIES.md) - the hat policy matrix, MCP preflight checks, cluster runtime boundaries, failure rules, and ArgoCD integration shape. @@ -40,6 +41,27 @@ The intent is to keep the architecture document focused on what the Organization These documents are reference substrate, not a mandate to implement every concept at once. The first implementation should choose the smallest end-to-end slice from [Implementation Readiness Checklist](./IMPLEMENTATION_READINESS_CHECKLIST.md), ship it, and prune or revise the reference docs as the concrete system teaches us. +The current V0 product contract is: + +```text +hat communication brief + -> send_supervisor_signal + -> supervisor triage plan + -> anchored work item and context + -> gate decision + -> hat assignment and scoped runtime authority + -> scheduled prompt-flow run + -> Hermes run binding + -> evidence submission + -> reviewer decision + -> outcome review +``` + +Capability requests, credential requests, workflow gaps, memory gaps, +questions, and blockers enter through supervisor-chain communication +first. They become specialized work only after the responsible hat +triages them. + ## Placement These docs live at `agentic-organization/docs/` as the documentation root for the Agentic Organization subsystem. Runtime code can live under the Agentic Organization product tree, but cluster deployment should land as a `full-ai-cluster/k8s/applications/agentic-organization/` ArgoCD workload. Agentic Organization runs on the `full-ai-cluster` substrate; it is not a second cluster substrate. diff --git a/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md b/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md index 5462e2b839..4b624496f4 100644 --- a/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md +++ b/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md @@ -116,6 +116,10 @@ Rules: - Cross-package imports use public exports only. - No controller, worker entrypoint, Temporal workflow, Dapr actor, or MCP route contains business rules. +- Production source and test source are separated. Package + implementation code lives in `packages//src`; package tests live + in `packages//test`. Governance checks should reject `*.test.ts` + files inside production source trees. ## Package Layers @@ -162,21 +166,22 @@ calling them. ### Layer 3: State, Messaging, and Runtime Adapters -| Package | Owns | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `@agentic-org/state` | generic state-store, outbox-source, inbox, idempotency, transaction, and lease ports | -| `@agentic-org/state-cockroach` | first replaceable durable SQL implementation of state-store and outbox-source ports | -| `@agentic-org/messaging` | NATS envelope builder, subject builder, JetStream publisher, consumer, DLQ, replay contracts | -| `@agentic-org/messaging-nats` | NATS JetStream implementation of the event publisher port, canonical JSON, headers, message IDs | -| `@agentic-org/workflows-temporal` | Temporal workflow and activity contracts, task queues, workflow clients | -| `@agentic-org/actors-dapr` | Dapr actor interfaces, actor implementations, reminders, actor state projection | -| `@agentic-org/mcp` | MCP schemas, tool registry, preflight checks, policy-checked tool handlers | -| `@agentic-org/hermes` | Hermes session adapter, run adapter, callback contract, run context builder | -| `@agentic-org/memory` | Hindsight adapter, hat-scoped recall/retain/reflect, memory attribution, memory health | -| `@agentic-org/k8s-hats` | generated or checked Hat, HatBinding, HatSwap, HatPolicy types, informers, projection decoding | -| `@agentic-org/openziti` | OpenZiti transport adapter, identity/config access, connectivity checks | -| `@agentic-org/credential-proxy` | credential request adapter, scoped credential use, audit hooks | -| `@agentic-org/adapters-agentic-services` | temporary wrappers around reused `agentic-services` primitives | +| Package | Owns | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `@agentic-org/state` | generic state-store, outbox-source, inbox, idempotency, transaction, and lease ports | +| `@agentic-org/state-cockroach` | first replaceable durable SQL implementation of state-store and outbox-source ports | +| `@agentic-org/messaging` | NATS envelope builder, subject builder, JetStream publisher, consumer, DLQ, replay contracts | +| `@agentic-org/messaging-nats` | NATS JetStream implementation of publisher and consumer ports, canonical JSON, headers, ack/nack, and DLQ | +| `@agentic-org/workers` | small worker process boundary that composes outbox publishing, inbound ingestion, and schedulers through ports | +| `@agentic-org/workflows-temporal` | Temporal workflow and activity contracts, task queues, workflow clients | +| `@agentic-org/actors-dapr` | Dapr actor interfaces, actor implementations, reminders, actor state projection | +| `@agentic-org/mcp` | MCP schemas, tool registry, preflight checks, policy-checked tool handlers | +| `@agentic-org/hermes` | Hermes session adapter, run adapter, callback contract, run context builder | +| `@agentic-org/memory` | Hindsight adapter, hat-scoped recall/retain/reflect, memory attribution, memory health | +| `@agentic-org/k8s-hats` | generated or checked Hat, HatBinding, HatSwap, HatPolicy types, informers, projection decoding | +| `@agentic-org/openziti` | OpenZiti transport adapter, identity/config access, connectivity checks | +| `@agentic-org/credential-proxy` | credential request adapter, scoped credential use, audit hooks | +| `@agentic-org/adapters-agentic-services` | temporary wrappers around reused `agentic-services` primitives | Adapters are replaceable. The Organization should be able to run a V0 slice with in-process fakes, then swap in Temporal, Dapr, Hermes, @@ -234,6 +239,14 @@ HatSystemPort -> KubernetesHatSystemAdapter or ReadOnlyFakeHatSystemAdapter ``` Business services should depend on ports, not concrete adapters. +Every vendor-specific implementation must sit behind a generic +Organization interface exported by a non-vendor package. For example, +application code sees `CommandStateStore`, runtime code sees +`EventIngestionStore`, and messaging code sees `EventPublisher`; it +must not see CockroachDB, NATS, OpenZiti, Hindsight, Hermes, Temporal, +Dapr, Kubernetes, or provider-specific clients directly. Vendor +packages may define private executor seams for their own composition, +but those seams are not application contracts. The command pipeline must also depend on a handler registry and a state-store factory supplied by the composition layer. It must not @@ -247,6 +260,47 @@ inbox, and lease adapters must be able to perform real I/O without changing command-handler contracts. CockroachDB is the first durable SQL adapter in the cluster, not an application-layer dependency. +Command handlers must return typed effects, not write state directly. +The command pipeline owns idempotency lookup and calls one command +outcome port that records the business state, audit events, outbox +events, and idempotency record together. This keeps the application +layer closed to concrete database transactions while still giving +durable adapters one atomic commit boundary for a command result. +Durable command adapters should reserve the idempotency record before +effect rows inside that transaction so an idempotency race aborts before +supervisor signal, audit, or outbox state becomes visible. +The command outcome port returns generic committed, replayed, or +idempotency-conflict results. A vendor adapter may use SQL constraints, +transaction callbacks, CTEs, or other local mechanics to detect races, +but application code only receives the generic outcome. + +The first worker boundary follows the same rule. `@agentic-org/workers` +does not create NATS clients, Cockroach clients, Nest modules, Temporal +workers, or Dapr actors. It receives an outbox publisher, an inbound +event source, and an event-ingestion processor through ports, runs one +bounded cycle, and returns an idle/worked/degraded summary. A failure in +one lane is captured as typed cycle data while the other lane still gets +a chance to run. `apps/workers` will later bind those ports to real +cluster adapters and attach process concerns such as health checks, +metrics, structured logs, readiness, and graceful shutdown. + +`apps/workers` now exists as the first NodeNext runtime-host shell. It +does not introduce NestJS yet. It composes the package-level worker host +and the NATS consumer adapter, parses typed process environment values +into runtime config, records telemetry through a sink port, and reports +healthy/degraded status. Its current required environment contract is +`AGENTIC_ORG_ENV`, `AGENTIC_ORG_ID`, `NATS_STREAM`, `NATS_DURABLE`, and +`NATS_INBOUND_BATCH_SIZE`. Concrete NATS clients, CockroachDB pools, +readiness endpoints, structured logging, and shutdown hooks still belong +to later process-adapter wiring. + +The `apps/workers` composition root receives typed config plus +already-constructed ports. This is the only place the worker process +should know which concrete adapter implementation is being used. Domain, +application, runtime, worker, and observability packages must stay free +of process environment, Kubernetes Secret, ExternalSecret, connection +pool, and client-construction details. + ## SOLID Rules ### Single Responsibility @@ -471,6 +525,66 @@ deterministic `idempotencyKey`. External side effects must either be natively idempotent or wrapped by a command that stores the external request/result. +The first executable runtime slice implements this as an event ingestion +processor before a live NATS consumer exists. A transport adapter decodes +the canonical envelope, calls the processor, and the processor checks +the inbox receipt, evaluates rules, and persists the receipt plus +reaction plans through one store operation. Durable adapters should +implement that operation transactionally so a saved receipt cannot +silently suppress a reaction plan that failed to persist. The processor +also compares payload hashes for repeated `eventId + consumerName` +pairs; conflicting payloads are not treated as normal duplicates. + +The processor treats only completed inbox receipts as duplicates. A +receipt with a matching payload hash but without completion fields is a +recoverable pending/orphan state: the rule processor may re-evaluate the +event and call the same outcome store operation to complete the receipt +and persist reaction plans. Durable adapters should still make this rare +by committing receipt, reaction plans, and processed marker in one +transaction. + +Cockroach-specific transaction mechanics stay inside +`@agentic-org/state-cockroach`. Application and runtime packages see +outcome ports. The Cockroach adapter receives transaction-batch SQL +executor seams for command outcomes and event-ingestion outcomes, and a +real process adapter must bind those seams to an actual CockroachDB +transaction before production traffic uses the adapter. +The event-ingestion Cockroach adapter must normalize SQL `NULL` +completion fields to omitted receipt fields and claim the pending +receipt at the start of the transaction. Reaction-plan inserts and the +processed marker must be conditional on that claim. If another consumer +already completed the receipt, the adapter returns a duplicate outcome +through the generic `EventIngestionStore` result without inserting +reaction plans. + +The processed marker must also prove the claim was still held by +returning the marked receipt. If the final mark no longer matches a +pending receipt after reaction plans were prepared, the adapter must +abort the transaction so those reaction plans roll back, then return a +generic duplicate outcome. Runtime code must not receive Cockroach +update-count details or transaction objects. + +A worker host composes that ingestion processor with the outbox +publisher but stays below the NestJS process layer. This creates a +testable boundary where replayable inbound sources and live transport +consumers can both feed the same rule processor without changing rule +evaluation or reaction-plan persistence. The worker-host source port is +replayable pull only; live NATS ack, nack, checkpoint, backoff, and DLQ +behavior remains owned by the transport adapter and `apps/workers` +process host. + +The first NATS consumer adapter is now the transport-policy boundary. It +decodes canonical JSON envelopes and calls the runtime ingestion +processor, but it owns JetStream-style decisions: ack processed and +duplicate messages, terminate plus dead-letter invalid envelopes and +payload conflicts, and negative-acknowledge transient ingestion +failures. If dead-letter publishing or source-message termination +fails, it records the failure, negative-acknowledges the source message +for retry, and continues the fetched batch so one broken DLQ path cannot +starve unrelated messages. This keeps runtime rules deterministic and +transport-neutral while still making live NATS behavior testable before +a Nest worker process exists. + ### Stream and Consumer Manifests Every stream and durable consumer should declare: @@ -608,13 +722,19 @@ Secrets/ExternalSecrets, but the domain package should never see those values. The Nest composition layer binds configuration into adapter ports. +The current `apps/workers` NodeNext host applies this rule before NestJS +is introduced: non-secret operational values are parsed from typed env +names, while URLs, credentials, and client construction remain reserved +for process adapter factories supplied by the composition root. + Minimum runtime environment contract: ```text AGENTIC_ORG_ENV AGENTIC_ORG_ID -COCKROACH_URL -NATS_URL +NATS_STREAM +NATS_DURABLE +NATS_INBOUND_BATCH_SIZE TEMPORAL_ADDRESS HINDSIGHT_URL HERMES_URL @@ -623,11 +743,12 @@ OTEL_EXPORTER_OTLP_ENDPOINT HAT_SYSTEM_NAMESPACE ``` -Secrets such as database credentials, NATS credentials, OpenZiti -credentials, LLM provider keys, and credential-proxy tokens must come -from Vault through External Secrets or another approved cluster secret -path. They should not live in plain Kubernetes manifests and should not -be baked into the Agentic Organization image. +Adapter-specific URLs and secrets such as CockroachDB URLs, NATS URLs, +database credentials, NATS credentials, OpenZiti credentials, LLM +provider keys, and credential-proxy tokens must come from Vault through +External Secrets or another approved cluster secret path. They should +not live in plain Kubernetes manifests and should not be baked into the +Agentic Organization image. ### ArgoCD Sync Wave @@ -748,6 +869,10 @@ package should standardize: - workflow visibility records that project command/event context into UI- and agent-readable health, stage, trace, scope, aggregate, and weak-point indicator fields. +- NATS consumer batch attributes for stream, durable consumer, received, + processed, duplicate, payload-conflict, invalid, failed, + acknowledged, negative-acknowledged, terminated, and dead-lettered + counts. Every runtime host should be inspectable from either direction: @@ -766,6 +891,20 @@ tools, policy denials, harness failures, and telemetry gaps, then route fixes through the same command, review, and security lifecycle as any other work. +The first `apps/workers` runtime projects both package worker-cycle +counts and NATS consumer batch counts through telemetry sink ports. The +runtime treats package degraded status, thrown loop failures, telemetry +sink failures, dead-lettered NATS messages, invalid NATS messages, +payload-conflict NATS messages, negative acknowledgements, terminated +messages, and failed NATS messages as degraded state so weak points can +surface before the process is connected to real cluster telemetry. +Telemetry failures must not erase successful worker or NATS cycle +results; they are captured as their own typed failure stage. The +composition root is therefore the future bridge from these records into +the full-ai-cluster LGTM stack: structured logs to Loki, traces to Tempo +through Alloy, metrics to Prometheus/Mimir, and dashboard projections in +Grafana. + ## V0 Build Sequence 1. Create package skeletons for: @@ -802,11 +941,17 @@ other work. hat-system. 6. Add NATS outbox publisher and one consumer after command tests pass. 7. Add inbox/consumer dedupe before any NATS-driven automation performs - side effects. + side effects. The first package-level processor and Cockroach adapter + now exist; the first package-level worker host composes the outbox and + inbound-ingestion loops through ports, and the NATS consumer adapter + owns live ack/nack/DLQ policy. 8. Add the first rule catalog and reaction executor for ready work, review staffing, QA staffing, blocker escalation, and late run incidents. -9. Add the NestJS API and worker hosts. +9. Add runtime hosts. The first NodeNext `apps/workers` host now parses + typed process config and composes the worker and NATS consumer loops + through ports; NestJS API and richer worker process wiring are still + pending. 10. Add UI projections for work board, review center, and evidence timeline. 11. Add real cluster adapters one at a time. diff --git a/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md b/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md index 957f6b87c3..845543bdb5 100644 --- a/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md +++ b/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md @@ -54,16 +54,16 @@ V0 does not need: - autonomous creation of new tools, workflows, or credential proxy endpoints. -V0 should still model those future paths as supervisor-chain signals and -capability requests, so the Organization can later route them through -its own lifecycle. +V0 should still model those future paths as supervisor-chain signals. +Capability request inputs enter through that signal path and become +specialized work only after supervisor triage. ## First Vertical Slice The first executable slice is: ```text -supervisor-chain signal or capability request +supervisor-chain signal -> anchored work item, discussion anchor, and context pack -> one readiness or review gate -> hat assignment @@ -89,15 +89,15 @@ This is the smallest useful loop because it proves: Keep the first hat set small: -| Hat | V0 reason | -| ------------------- | ----------------------------------------------------------------------------------- | -| Director | accepts or rejects escalated supervisor signals or capability requests for V0 scope | -| Engineering Manager | grooms the work item, selects schedule, assigns implementer and reviewer hats | -| Implementer | executes the prompt flow and submits evidence | -| Code Reviewer | reviews the evidence and blocks self-approval | -| Memory Curator | reviews memory writes or flags memory gaps when the run ends | -| Platform Operator | handles runtime failure, pod/session issues, and integration health | -| Security Reviewer | required only when the request needs a new credential or external tool scope | +| Hat | V0 reason | +| ------------------- | ------------------------------------------------------------------------------------------------- | +| Director | accepts or rejects escalated supervisor signals, including capability-request inputs for V0 scope | +| Engineering Manager | grooms the work item, selects schedule, assigns implementer and reviewer hats | +| Implementer | executes the prompt flow and submits evidence | +| Code Reviewer | reviews the evidence and blocks self-approval | +| Memory Curator | reviews memory writes or flags memory gaps when the run ends | +| Platform Operator | handles runtime failure, pod/session issues, and integration health | +| Security Reviewer | required only when the request needs a new credential or external tool scope | The Executive Board, TPM, Product Owner, Architect, QA Reviewer, Hat Designer, and department directors remain first-class in the reference diff --git a/agentic-organization/package.json b/agentic-organization/package.json index 0fd10e913a..e650e5ee88 100644 --- a/agentic-organization/package.json +++ b/agentic-organization/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "node --experimental-strip-types --test packages/**/*.test.ts", + "test": "node --experimental-strip-types --test packages/*/test/**/*.test.ts apps/*/test/**/*.test.ts", "typecheck": "npx --yes -p typescript@6.0.3 tsc -p tsconfig.json" }, "engines": { diff --git a/agentic-organization/packages/README.md b/agentic-organization/packages/README.md index b3d8a6ed20..47fd012c5d 100644 --- a/agentic-organization/packages/README.md +++ b/agentic-organization/packages/README.md @@ -14,9 +14,10 @@ host, or Kubernetes deployment is introduced. | `state` | generic state-store and outbox-source ports plus the in-memory Organization state-store factory fake | | `state-cockroach` | first replaceable durable SQL adapter for the state-store/outbox-source ports, backed by CockroachDB | | `messaging` | NATS subject contract, outbox publisher port, event publisher port, and domain resolver without a live NATS dependency | -| `messaging-nats` | NATS JetStream event publisher adapter contract with canonical JSON payloads, headers, and message IDs | -| `observability` | LGTM/OpenTelemetry attribute projection from Agentic event envelopes | +| `messaging-nats` | NATS JetStream publisher and consumer adapter contracts with canonical JSON, headers, ack/nack, and DLQ policy | +| `observability` | LGTM/OpenTelemetry attribute projection from Agentic event envelopes and NATS consumer batch summaries | | `runtime` | first event-to-automation reaction rule | +| `workers` | process-boundary worker host that composes outbox publishing and inbound ingestion through ports only | | `governance` | package dependency-boundary checks that keep core packages SOLID and adapter-free | ## Slice Rule @@ -31,7 +32,12 @@ supervisor-chain signal command -> outbox event -> outbox publisher -> NATS JetStream event publisher adapter + -> NATS JetStream event consumer adapter -> NATS subject / telemetry contract + -> event ingestion processor + -> inbox receipt / consumer dedupe + -> persisted reaction plans + -> worker host run-once cycle -> automation reaction plan ``` @@ -49,6 +55,11 @@ package implements the current in-memory factory. Command routing uses a handler registry so new commands add handlers instead of editing a central `switch` or `if` dispatcher. +Production source and test source are separated by package. Application +code lives under `packages//src`; tests live under +`packages//test`. The governance package enforces that `*.test.ts` +files do not land in production `src` trees. + `CommandStateStore` and `OutboxEventSource` are async even when backed by in-memory fakes. Durable adapters must not be squeezed into a synchronous toy shape. @@ -61,6 +72,39 @@ package implements that publisher port and is the only package in this slice that knows about NATS headers, message IDs, and JSON transport payloads. State adapters must not import messaging adapters. +The NATS consumer adapter owns live transport policy. It fetches a +bounded batch from a pull-consumer port, decodes canonical event +envelopes, calls the runtime event-ingestion processor, and then chooses +the transport action. Processed and duplicate messages are acknowledged. +Invalid envelopes and same-event payload conflicts are terminated and +published to a dead-letter port. Transient ingestion failures are +negative-acknowledged for retry. Runtime rule evaluation does not know +about ack, nack, termination, backoff, or DLQ mechanics. + +The event ingestion processor owns the generic consume loop after a +transport adapter has decoded a canonical event envelope. It checks an +inbox receipt before evaluating rules, records the receipt and generated +reaction plans through one store operation, and returns duplicate +without re-running rules when the same event reaches the same consumer +again. If the same event ID reaches the same consumer with a different +payload hash, the processor returns a payload-conflict outcome instead +of hiding the drift. Live NATS consumers will bind to this processor +later. + +The worker host composes the outbox publisher and inbound event +ingestion processor behind a small run-once boundary. It does not know +about live NATS clients, CockroachDB clients, NestJS modules, Temporal, +or Dapr. Future runtime processes should provide concrete ports from the +composition layer and use the returned idle/worked/degraded cycle +summary for logs, metrics, health checks, and UI-visible workflow +telemetry. A failed outbox or inbound lane is reported as degraded +instead of hiding the failure or starving the other lane. + +`InboundEventSource` is intentionally only a replayable pull port in +this package. Live NATS ack, nack, checkpoint, backoff, and DLQ behavior +belongs in the NATS consumer adapter so transport policy does not leak +into runtime rule evaluation. + ## Validation Run the package tests from `agentic-organization/`: @@ -73,7 +117,7 @@ The test command uses Node's built-in test runner and TypeScript type stripping: ```text -node --experimental-strip-types --test packages/**/*.test.ts +node --experimental-strip-types --test packages/*/test/**/*.test.ts apps/*/test/**/*.test.ts ``` This is a deliberate NodeNext starting point so the package contracts diff --git a/agentic-organization/packages/application/src/command-handler-registry.ts b/agentic-organization/packages/application/src/command-handler-registry.ts index ff3c19918a..3f960c1e49 100644 --- a/agentic-organization/packages/application/src/command-handler-registry.ts +++ b/agentic-organization/packages/application/src/command-handler-registry.ts @@ -1,17 +1,19 @@ -import type { Clock, CommandStateStore, IdGenerator } from "./ports.ts"; +import type { Clock, CommandEffects, IdGenerator } from "./ports.ts"; export type TypedCommand = { type: string; }; -export type CommandExecutionContext = Clock & - IdGenerator & { - store: CommandStateStore; - }; +export type CommandHandlerOutcome = { + result: Result; + effects: CommandEffects; +}; + +export type CommandExecutionContext = Clock & IdGenerator; export type CommandHandler = { commandType: Command["type"]; - execute: (command: Command, context: CommandExecutionContext) => Promise; + execute: (command: Command, context: CommandExecutionContext) => Promise>; }; export type CommandHandlerRegistry = { diff --git a/agentic-organization/packages/application/src/command-pipeline.test.ts b/agentic-organization/packages/application/src/command-pipeline.test.ts deleted file mode 100644 index 48fa57e79a..0000000000 --- a/agentic-organization/packages/application/src/command-pipeline.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { deepEqual, equal } from "node:assert/strict"; -import { describe, test } from "node:test"; - -import { CommandType, SupervisorChainLevel, SupervisorSignalToolType } from "../../domain/src/index.ts"; -import { createInMemoryOrganizationStoreFactory } from "../../state/src/index.ts"; -import { createCommandHandlerRegistry } from "./command-handler-registry.ts"; -import { CommandErrorCode, CommandResultStatus, type CommandResult } from "./command-result.ts"; -import { createCommandPipeline, type PipelineCommand } from "./command-pipeline.ts"; -import { createSendSupervisorSignalHandler } from "./handlers/send-supervisor-signal.ts"; - -const command: PipelineCommand = { - commandId: "cmd-supervisor-signal-001", - type: CommandType.SendSupervisorSignal, - idempotencyKey: "idem-supervisor-signal-001", - requestHash: "hash-supervisor-signal-001", - correlationId: "corr-supervisor-signal-001", - causationId: "cause-team-work-001", - traceId: "trace-supervisor-signal-001", - organizationId: "org-lfg", - projectId: "project-agentic-org", - teamId: "team-runtime", - sourceLevel: SupervisorChainLevel.TeamMember, - targetLevel: SupervisorChainLevel.Manager, - targetHatAssignmentId: "hat-assignment-em-001", - actor: { - agentId: "agent-developer-001", - hatAssignmentId: "hat-assignment-dev-001", - }, - toolType: SupervisorSignalToolType.ReportBlocker, - title: "Blocked on scoped NATS publisher", - message: "The team cannot validate the outbox worker until a supervisor routes a scoped NATS publisher decision.", - relatedWorkItemId: "work-outbox-001", -}; - -describe("command pipeline idempotency", () => { - test("replaying the same idempotency key returns the stored result", async () => { - const stateStoreFactory = createInMemoryOrganizationStoreFactory(); - const pipeline = createCommandPipeline({ - stateStoreFactory, - handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), - now: () => "2026-05-25T20:00:00.000Z", - createId: (prefix) => `${prefix}-001`, - }); - - const firstResult = await pipeline.execute(command); - const replayResult = await pipeline.execute(command); - - equal(firstResult.status, CommandResultStatus.Accepted); - equal(replayResult.status, CommandResultStatus.Accepted); - deepEqual(replayResult.idempotency, { - replayed: true, - }); - equal(firstResult.supervisorSignal !== undefined, true); - equal(replayResult.supervisorSignal !== undefined, true); - equal(replayResult.supervisorSignal?.supervisorSignalId, firstResult.supervisorSignal?.supervisorSignalId); - equal(stateStoreFactory.snapshot.supervisorSignals.length, 1); - equal(stateStoreFactory.snapshot.workItems.length, 0); - equal(stateStoreFactory.snapshot.auditEvents.length, 1); - equal(stateStoreFactory.snapshot.outboxEvents.length, 1); - }); - - test("rejects conflicting reuse of the same idempotency key", async () => { - const stateStoreFactory = createInMemoryOrganizationStoreFactory(); - const pipeline = createCommandPipeline({ - stateStoreFactory, - handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), - now: () => "2026-05-25T20:00:00.000Z", - createId: (prefix) => `${prefix}-001`, - }); - - const firstResult = await pipeline.execute(command); - const conflictResult = await pipeline.execute({ - ...command, - requestHash: "hash-supervisor-signal-conflict", - title: "Different supervisor signal", - }); - - equal(firstResult.status, CommandResultStatus.Accepted); - equal(conflictResult.status, CommandResultStatus.Rejected); - equal(conflictResult.error?.code, CommandErrorCode.IdempotencyConflict); - equal(stateStoreFactory.snapshot.supervisorSignals.length, 1); - equal(stateStoreFactory.snapshot.outboxEvents.length, 1); - }); -}); diff --git a/agentic-organization/packages/application/src/command-pipeline.ts b/agentic-organization/packages/application/src/command-pipeline.ts index 15483b7e26..9657abc565 100644 --- a/agentic-organization/packages/application/src/command-pipeline.ts +++ b/agentic-organization/packages/application/src/command-pipeline.ts @@ -1,7 +1,14 @@ import type { CommandHandlerRegistry } from "./command-handler-registry.ts"; import { CommandErrorCode, CommandResultStatus, type CommandResult } from "./command-result.ts"; import type { SendSupervisorSignalCommand } from "./handlers/send-supervisor-signal.ts"; -import type { Clock, CommandStateStore, CommandStateStoreFactory, IdGenerator } from "./ports.ts"; +import { + CommandOutcomePersistenceStatus, + type Clock, + type CommandEffects, + type CommandStateStore, + type CommandStateStoreFactory, + type IdGenerator, +} from "./ports.ts"; export type PipelineCommand = SendSupervisorSignalCommand; @@ -40,50 +47,78 @@ async function executeCommand( } if (existingRecord) { + return createIdempotencyConflictResult(); + } + + const outcome = await dispatchCommand(command, dependencies); + + const persistenceResult = await store.recordCommandOutcome({ + idempotencyRecord: { + idempotencyKey: command.idempotencyKey, + requestHash: command.requestHash, + result: outcome.result, + }, + effects: outcome.result.status === CommandResultStatus.Accepted ? outcome.effects : createEmptyCommandEffects(), + }); + + if (persistenceResult.status === CommandOutcomePersistenceStatus.Replayed) { return { - status: CommandResultStatus.Rejected, + ...persistenceResult.result, idempotency: { - replayed: false, - }, - error: { - code: CommandErrorCode.IdempotencyConflict, - message: "idempotency key was reused with a different request hash", + replayed: true, }, }; } - const result = await dispatchCommand(command, store, dependencies); - await store.saveIdempotencyRecord({ - idempotencyKey: command.idempotencyKey, - requestHash: command.requestHash, - result, - }); + if (persistenceResult.status === CommandOutcomePersistenceStatus.IdempotencyConflict) { + return createIdempotencyConflictResult(); + } - return result; + return outcome.result; } async function dispatchCommand( command: PipelineCommand, - store: CommandStateStore, dependencies: CommandPipelineDependencies, -): Promise { +): Promise<{ result: CommandResult; effects: CommandEffects }> { const handler = dependencies.handlerRegistry.resolveHandler(command.type); if (handler !== undefined) { - return await handler.execute(command, { - ...dependencies, - store, - }); + return await handler.execute(command, dependencies); } + return { + result: { + status: CommandResultStatus.Rejected, + idempotency: { + replayed: false, + }, + error: { + code: CommandErrorCode.UnsupportedCommand, + message: "unsupported command type", + }, + }, + effects: createEmptyCommandEffects(), + }; +} + +function createIdempotencyConflictResult(): CommandResult { return { status: CommandResultStatus.Rejected, idempotency: { replayed: false, }, error: { - code: CommandErrorCode.UnsupportedCommand, - message: "unsupported command type", + code: CommandErrorCode.IdempotencyConflict, + message: "idempotency key was reused with a different request hash", }, }; } + +function createEmptyCommandEffects(): CommandEffects { + return { + supervisorSignals: [], + auditEvents: [], + outboxEvents: [], + }; +} diff --git a/agentic-organization/packages/application/src/handlers/send-supervisor-signal.ts b/agentic-organization/packages/application/src/handlers/send-supervisor-signal.ts index 05660f5029..8ffe2c2b8b 100644 --- a/agentic-organization/packages/application/src/handlers/send-supervisor-signal.ts +++ b/agentic-organization/packages/application/src/handlers/send-supervisor-signal.ts @@ -11,9 +11,9 @@ import { type SupervisorSignalToolType, } from "../../../domain/src/index.ts"; import { createAgenticEventEnvelope } from "../../../domain/src/index.ts"; -import type { CommandHandler } from "../command-handler-registry.ts"; +import type { CommandHandler, CommandHandlerOutcome } from "../command-handler-registry.ts"; import { CommandResultStatus, type CommandResult } from "../command-result.ts"; -import type { Clock, CommandStateStore, IdGenerator } from "../ports.ts"; +import type { Clock, IdGenerator } from "../ports.ts"; export const IdPrefix = { SupervisorSignal: "supervisor-signal", @@ -45,10 +45,7 @@ export type SendSupervisorSignalCommand = { relatedWorkItemId: string; }; -export type SendSupervisorSignalDependencies = Clock & - IdGenerator & { - store: CommandStateStore; - }; +export type SendSupervisorSignalDependencies = Clock & IdGenerator; export function createSendSupervisorSignalHandler(): CommandHandler { return { @@ -60,7 +57,7 @@ export function createSendSupervisorSignalHandler(): CommandHandler { +): Promise> { const occurredAt = dependencies.now(); const supervisorSignal: SupervisorSignal = { supervisorSignalId: dependencies.createId(IdPrefix.SupervisorSignal), @@ -123,15 +120,18 @@ export async function sendSupervisorSignal( }), }; - await dependencies.store.appendSupervisorSignal(supervisorSignal); - await dependencies.store.appendAuditEvent(auditEvent); - await dependencies.store.appendOutboxEvent(outboxEvent); - return { - status: CommandResultStatus.Accepted, - supervisorSignal, - idempotency: { - replayed: false, + result: { + status: CommandResultStatus.Accepted, + supervisorSignal, + idempotency: { + replayed: false, + }, + }, + effects: { + supervisorSignals: [supervisorSignal], + auditEvents: [auditEvent], + outboxEvents: [outboxEvent], }, }; } diff --git a/agentic-organization/packages/application/src/index.ts b/agentic-organization/packages/application/src/index.ts index 52603c3c9e..efb497c57b 100644 --- a/agentic-organization/packages/application/src/index.ts +++ b/agentic-organization/packages/application/src/index.ts @@ -2,6 +2,7 @@ export { createCommandHandlerRegistry, type CommandExecutionContext, type CommandHandler, + type CommandHandlerOutcome, type CommandHandlerRegistry, type TypedCommand, } from "./command-handler-registry.ts"; @@ -19,13 +20,13 @@ export { type SendSupervisorSignalCommand, type SendSupervisorSignalDependencies, } from "./handlers/send-supervisor-signal.ts"; +export { CommandOutcomePersistenceStatus } from "./ports.ts"; export type { - AuditEventStore, Clock, + CommandEffects, CommandStateStore, CommandStateStoreFactory, - IdempotencyRecordStore, IdGenerator, - OutboxEventStore, - SupervisorSignalStore, + RecordCommandOutcomeInput, + RecordCommandOutcomeResult, } from "./ports.ts"; diff --git a/agentic-organization/packages/application/src/ports.ts b/agentic-organization/packages/application/src/ports.ts index 2f8778b841..61c052c7a9 100644 --- a/agentic-organization/packages/application/src/ports.ts +++ b/agentic-organization/packages/application/src/ports.ts @@ -8,28 +8,45 @@ export type IdGenerator = { createId: (prefix: string) => string; }; -export type IdempotencyRecordStore = { - findIdempotencyRecord: (idempotencyKey: string) => Promise | undefined>; - saveIdempotencyRecord: (record: IdempotencyRecord) => Promise; -}; - -export type SupervisorSignalStore = { - appendSupervisorSignal: (supervisorSignal: SupervisorSignal) => Promise; +export type CommandEffects = { + supervisorSignals: readonly SupervisorSignal[]; + auditEvents: readonly AuditEvent[]; + outboxEvents: readonly OutboxEvent[]; }; -export type AuditEventStore = { - appendAuditEvent: (auditEvent: AuditEvent) => Promise; +export type RecordCommandOutcomeInput = { + idempotencyRecord: IdempotencyRecord; + effects: CommandEffects; }; -export type OutboxEventStore = { - appendOutboxEvent: (outboxEvent: OutboxEvent) => Promise; +export const CommandOutcomePersistenceStatus = { + Committed: "committed", + IdempotencyConflict: "idempotency_conflict", + Replayed: "replayed", +} as const; + +export type CommandOutcomePersistenceStatus = + (typeof CommandOutcomePersistenceStatus)[keyof typeof CommandOutcomePersistenceStatus]; + +export type RecordCommandOutcomeResult = + | { + status: typeof CommandOutcomePersistenceStatus.Committed; + result: Result; + } + | { + status: typeof CommandOutcomePersistenceStatus.Replayed; + result: Result; + } + | { + status: typeof CommandOutcomePersistenceStatus.IdempotencyConflict; + existingRequestHash?: string; + }; + +export type CommandStateStore = { + findIdempotencyRecord: (idempotencyKey: string) => Promise | undefined>; + recordCommandOutcome: (input: RecordCommandOutcomeInput) => Promise>; }; -export type CommandStateStore = IdempotencyRecordStore & - SupervisorSignalStore & - AuditEventStore & - OutboxEventStore; - export type CommandStateStoreFactory = { createCommandStateStore: () => CommandStateStore; }; diff --git a/agentic-organization/packages/application/test/command-pipeline.test.ts b/agentic-organization/packages/application/test/command-pipeline.test.ts new file mode 100644 index 0000000000..5480f52cd6 --- /dev/null +++ b/agentic-organization/packages/application/test/command-pipeline.test.ts @@ -0,0 +1,254 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { CommandType, SupervisorChainLevel, SupervisorSignalToolType } from "../../domain/src/index.ts"; +import { createInMemoryOrganizationStoreFactory } from "../../state/src/index.ts"; +import { createCommandHandlerRegistry } from "../src/command-handler-registry.ts"; +import { CommandErrorCode, CommandResultStatus, type CommandResult } from "../src/command-result.ts"; +import { createCommandPipeline, type PipelineCommand } from "../src/command-pipeline.ts"; +import { createSendSupervisorSignalHandler } from "../src/handlers/send-supervisor-signal.ts"; +import { + CommandOutcomePersistenceStatus, + type CommandStateStore, + type CommandStateStoreFactory, + type RecordCommandOutcomeInput, + type RecordCommandOutcomeResult, +} from "../src/ports.ts"; + +const command: PipelineCommand = { + commandId: "cmd-supervisor-signal-001", + type: CommandType.SendSupervisorSignal, + idempotencyKey: "idem-supervisor-signal-001", + requestHash: "hash-supervisor-signal-001", + correlationId: "corr-supervisor-signal-001", + causationId: "cause-team-work-001", + traceId: "trace-supervisor-signal-001", + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + sourceLevel: SupervisorChainLevel.TeamMember, + targetLevel: SupervisorChainLevel.Manager, + targetHatAssignmentId: "hat-assignment-em-001", + actor: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + toolType: SupervisorSignalToolType.ReportBlocker, + title: "Blocked on scoped NATS publisher", + message: "The team cannot validate the outbox worker until a supervisor routes a scoped NATS publisher decision.", + relatedWorkItemId: "work-outbox-001", +}; + +describe("command pipeline idempotency", () => { + test("replaying the same idempotency key returns the stored result", async () => { + const stateStoreFactory = createInMemoryOrganizationStoreFactory(); + const pipeline = createCommandPipeline({ + stateStoreFactory, + handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), + now: () => "2026-05-25T20:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + const firstResult = await pipeline.execute(command); + const replayResult = await pipeline.execute(command); + + equal(firstResult.status, CommandResultStatus.Accepted); + equal(replayResult.status, CommandResultStatus.Accepted); + deepEqual(replayResult.idempotency, { + replayed: true, + }); + equal(firstResult.supervisorSignal !== undefined, true); + equal(replayResult.supervisorSignal !== undefined, true); + equal(replayResult.supervisorSignal?.supervisorSignalId, firstResult.supervisorSignal?.supervisorSignalId); + equal(stateStoreFactory.snapshot.supervisorSignals.length, 1); + equal(stateStoreFactory.snapshot.workItems.length, 0); + equal(stateStoreFactory.snapshot.auditEvents.length, 1); + equal(stateStoreFactory.snapshot.outboxEvents.length, 1); + }); + + test("rejects conflicting reuse of the same idempotency key", async () => { + const stateStoreFactory = createInMemoryOrganizationStoreFactory(); + const pipeline = createCommandPipeline({ + stateStoreFactory, + handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), + now: () => "2026-05-25T20:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + const firstResult = await pipeline.execute(command); + const conflictResult = await pipeline.execute({ + ...command, + requestHash: "hash-supervisor-signal-conflict", + title: "Different supervisor signal", + }); + + equal(firstResult.status, CommandResultStatus.Accepted); + equal(conflictResult.status, CommandResultStatus.Rejected); + equal(conflictResult.error?.code, CommandErrorCode.IdempotencyConflict); + equal(stateStoreFactory.snapshot.supervisorSignals.length, 1); + equal(stateStoreFactory.snapshot.outboxEvents.length, 1); + }); + + test("records command effects and idempotency through one outcome port", async () => { + const stateStoreFactory = createRecordingCommandStateStoreFactory(); + const pipeline = createCommandPipeline({ + stateStoreFactory, + handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), + now: () => "2026-05-25T20:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + const result = await pipeline.execute(command); + + equal(result.status, CommandResultStatus.Accepted); + equal(stateStoreFactory.recordedOutcomes.length, 1); + equal(stateStoreFactory.recordedOutcomes[0]?.idempotencyRecord.idempotencyKey, command.idempotencyKey); + equal(stateStoreFactory.recordedOutcomes[0]?.effects.supervisorSignals.length, 1); + equal(stateStoreFactory.recordedOutcomes[0]?.effects.auditEvents.length, 1); + equal(stateStoreFactory.recordedOutcomes[0]?.effects.outboxEvents.length, 1); + }); + + test("returns replay when outcome persistence loses a same-request idempotency race", async () => { + const stateStoreFactory = createOutcomeResultCommandStateStoreFactory({ + status: CommandOutcomePersistenceStatus.Replayed, + result: { + status: CommandResultStatus.Accepted, + idempotency: { + replayed: false, + }, + }, + }); + const pipeline = createCommandPipeline({ + stateStoreFactory, + handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), + now: () => "2026-05-25T20:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + const result = await pipeline.execute(command); + + equal(result.status, CommandResultStatus.Accepted); + deepEqual(result.idempotency, { + replayed: true, + }); + equal(stateStoreFactory.recordedOutcomes.length, 1); + }); + + test("returns idempotency conflict when outcome persistence loses a different-request race", async () => { + const stateStoreFactory = createOutcomeResultCommandStateStoreFactory({ + status: CommandOutcomePersistenceStatus.IdempotencyConflict, + existingRequestHash: "hash-other-request", + }); + const pipeline = createCommandPipeline({ + stateStoreFactory, + handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), + now: () => "2026-05-25T20:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + const result = await pipeline.execute(command); + + equal(result.status, CommandResultStatus.Rejected); + equal(result.error?.code, CommandErrorCode.IdempotencyConflict); + equal(stateStoreFactory.recordedOutcomes.length, 1); + }); + + test("does not perform piecemeal command writes when outcome recording fails", async () => { + const stateStoreFactory = createFailingOutcomeCommandStateStoreFactory("transaction unavailable"); + const pipeline = createCommandPipeline({ + stateStoreFactory, + handlerRegistry: createCommandHandlerRegistry([createSendSupervisorSignalHandler()]), + now: () => "2026-05-25T20:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + try { + await pipeline.execute(command); + throw new Error("expected command outcome recording to fail"); + } catch (error) { + equal(error instanceof Error, true); + equal((error as Error).message, "transaction unavailable"); + } + + equal(stateStoreFactory.appendCallCount, 0); + equal(stateStoreFactory.recordCallCount, 1); + }); +}); + +type RecordingCommandStateStoreFactory = CommandStateStoreFactory & { + recordedOutcomes: RecordCommandOutcomeInput[]; +}; + +function createRecordingCommandStateStoreFactory(): RecordingCommandStateStoreFactory { + const recordedOutcomes: RecordCommandOutcomeInput[] = []; + + return { + recordedOutcomes, + createCommandStateStore: () => ({ + findIdempotencyRecord: async () => undefined, + recordCommandOutcome: async (input) => { + recordedOutcomes.push(input); + + return { + status: CommandOutcomePersistenceStatus.Committed, + result: input.idempotencyRecord.result, + }; + }, + }), + }; +} + +function createOutcomeResultCommandStateStoreFactory( + outcomeResult: RecordCommandOutcomeResult, +): RecordingCommandStateStoreFactory { + const recordedOutcomes: RecordCommandOutcomeInput[] = []; + + return { + recordedOutcomes, + createCommandStateStore: () => ({ + findIdempotencyRecord: async () => undefined, + recordCommandOutcome: async (input) => { + recordedOutcomes.push(input); + return outcomeResult; + }, + }), + }; +} + +type FailingOutcomeCommandStateStoreFactory = CommandStateStoreFactory & { + readonly appendCallCount: number; + readonly recordCallCount: number; +}; + +function createFailingOutcomeCommandStateStoreFactory( + message: string, +): FailingOutcomeCommandStateStoreFactory { + let appendCallCount = 0; + let recordCallCount = 0; + + return { + get appendCallCount() { + return appendCallCount; + }, + get recordCallCount() { + return recordCallCount; + }, + createCommandStateStore: () => + ({ + findIdempotencyRecord: async () => undefined, + recordCommandOutcome: async () => { + recordCallCount += 1; + throw new Error(message); + }, + appendSupervisorSignal: async () => { + appendCallCount += 1; + }, + appendAuditEvent: async () => { + appendCallCount += 1; + }, + appendOutboxEvent: async () => { + appendCallCount += 1; + }, + }) as CommandStateStore, + }; +} diff --git a/agentic-organization/packages/application/src/handlers/send-supervisor-signal.test.ts b/agentic-organization/packages/application/test/send-supervisor-signal.test.ts similarity index 75% rename from agentic-organization/packages/application/src/handlers/send-supervisor-signal.test.ts rename to agentic-organization/packages/application/test/send-supervisor-signal.test.ts index 2c9068c182..60f80826ea 100644 --- a/agentic-organization/packages/application/src/handlers/send-supervisor-signal.test.ts +++ b/agentic-organization/packages/application/test/send-supervisor-signal.test.ts @@ -8,10 +8,9 @@ import { SupervisorChainLevel, SupervisorSignalStatus, SupervisorSignalToolType, -} from "../../../domain/src/index.ts"; -import { createInMemoryOrganizationStoreFactory } from "../../../state/src/index.ts"; -import { CommandResultStatus, type CommandResult } from "../command-result.ts"; -import { sendSupervisorSignal, type SendSupervisorSignalCommand } from "./send-supervisor-signal.ts"; +} from "../../domain/src/index.ts"; +import { CommandResultStatus, type CommandResult } from "../src/command-result.ts"; +import { sendSupervisorSignal, type SendSupervisorSignalCommand } from "../src/handlers/send-supervisor-signal.ts"; const command: SendSupervisorSignalCommand = { commandId: "cmd-supervisor-signal-001", @@ -38,24 +37,20 @@ const command: SendSupervisorSignalCommand = { }; describe("send supervisor signal handler", () => { - test("persists chain communication, audit event, and outbox event atomically", async () => { - const stateStoreFactory = createInMemoryOrganizationStoreFactory(); - const store = stateStoreFactory.createCommandStateStore(); - - const result = await sendSupervisorSignal(command, { - store, + test("returns chain communication, audit event, and outbox event effects", async () => { + const outcome = await sendSupervisorSignal(command, { now: () => "2026-05-25T20:00:00.000Z", createId: (prefix) => `${prefix}-001`, }); + const result = outcome.result as CommandResult; equal(result.status, CommandResultStatus.Accepted); ok(result.supervisorSignal); equal(result.supervisorSignal.status, SupervisorSignalStatus.Sent); - equal(stateStoreFactory.snapshot.supervisorSignals.length, 1); - equal(stateStoreFactory.snapshot.workItems.length, 0); - equal(stateStoreFactory.snapshot.auditEvents.length, 1); - equal(stateStoreFactory.snapshot.outboxEvents.length, 1); - deepEqual(stateStoreFactory.snapshot.outboxEvents[0]?.envelope, { + deepEqual(outcome.effects.supervisorSignals, [result.supervisorSignal]); + equal(outcome.effects.auditEvents.length, 1); + equal(outcome.effects.outboxEvents.length, 1); + deepEqual(outcome.effects.outboxEvents[0]?.envelope, { eventId: "evt-001", eventType: AgenticEventType.SupervisorSignalSent, schemaVersion: "agentic.org.event.v1", diff --git a/agentic-organization/packages/domain/src/index.ts b/agentic-organization/packages/domain/src/index.ts index f4821b78a8..676446defe 100644 --- a/agentic-organization/packages/domain/src/index.ts +++ b/agentic-organization/packages/domain/src/index.ts @@ -13,6 +13,13 @@ export { type CreateAgenticEventEnvelopeInput, } from "./event-envelope.ts"; export { WorkItemState, assertWorkItemTransition, createInitialWorkItemState } from "./work-item-state-machine.ts"; +export { + ReactionPlanActionType, + ReactionPlanReason, + ReactionPlanStatus, + RequiredHat, + type ReactionPlanAction, +} from "./reaction-plan.ts"; export { SupervisorChainLevel, SupervisorSignalStatus, diff --git a/agentic-organization/packages/domain/src/reaction-plan.ts b/agentic-organization/packages/domain/src/reaction-plan.ts new file mode 100644 index 0000000000..902ba67b0f --- /dev/null +++ b/agentic-organization/packages/domain/src/reaction-plan.ts @@ -0,0 +1,44 @@ +import type { SupervisorChainLevel } from "./supervisor-communication.ts"; + +export const ReactionPlanActionType = { + CreateSupervisorTriage: "create_supervisor_triage", + RequestReviewGate: "request_review_gate", +} as const; + +export type ReactionPlanActionType = (typeof ReactionPlanActionType)[keyof typeof ReactionPlanActionType]; + +export const RequiredHat = { + CSuite: "c_suite", + Director: "director", + EngineeringManager: "engineering_manager", + ExecutiveBoard: "executive_board", + Reviewer: "reviewer", +} as const; + +export type RequiredHat = (typeof RequiredHat)[keyof typeof RequiredHat]; + +export const ReactionPlanReason = { + SupervisorSignalNeedsTriage: "supervisor signal needs triage", + WorkItemEnteredReadyState: "work item entered ready state", +} as const; + +export type ReactionPlanReason = (typeof ReactionPlanReason)[keyof typeof ReactionPlanReason]; + +export const ReactionPlanStatus = { + Planned: "planned", +} as const; + +export type ReactionPlanStatus = (typeof ReactionPlanStatus)[keyof typeof ReactionPlanStatus]; + +export type ReactionPlanAction = { + actionType: ReactionPlanActionType; + triggerEventId: string; + organizationId: string; + projectId: string; + teamId?: string; + workItemId: string; + supervisorSignalId?: string; + targetLevel?: SupervisorChainLevel; + requiredHat: RequiredHat; + reason: ReactionPlanReason; +}; diff --git a/agentic-organization/packages/domain/src/event-envelope.test.ts b/agentic-organization/packages/domain/test/event-envelope.test.ts similarity index 98% rename from agentic-organization/packages/domain/src/event-envelope.test.ts rename to agentic-organization/packages/domain/test/event-envelope.test.ts index 8bea403c2b..08403cc21e 100644 --- a/agentic-organization/packages/domain/src/event-envelope.test.ts +++ b/agentic-organization/packages/domain/test/event-envelope.test.ts @@ -6,7 +6,7 @@ import { AgenticEventType, createAgenticEventEnvelope, type CommandTrace, -} from "./event-envelope.ts"; +} from "../src/event-envelope.ts"; const commandTrace: CommandTrace = { commandId: "cmd-capability-001", diff --git a/agentic-organization/packages/domain/src/hat-communication-brief.test.ts b/agentic-organization/packages/domain/test/hat-communication-brief.test.ts similarity index 94% rename from agentic-organization/packages/domain/src/hat-communication-brief.test.ts rename to agentic-organization/packages/domain/test/hat-communication-brief.test.ts index 093a9449fa..705142b3a6 100644 --- a/agentic-organization/packages/domain/src/hat-communication-brief.test.ts +++ b/agentic-organization/packages/domain/test/hat-communication-brief.test.ts @@ -1,8 +1,8 @@ import { deepEqual, equal } from "node:assert/strict"; import { describe, test } from "node:test"; -import { DefaultTeamMemberSupervisorTools, buildHatCommunicationBrief } from "./hat-communication-brief.ts"; -import { SupervisorChainLevel, SupervisorSignalToolType } from "./supervisor-communication.ts"; +import { DefaultTeamMemberSupervisorTools, buildHatCommunicationBrief } from "../src/hat-communication-brief.ts"; +import { SupervisorChainLevel, SupervisorSignalToolType } from "../src/supervisor-communication.ts"; describe("hat communication brief", () => { test("explains duty, supervisor line, and efficient upward tools", () => { diff --git a/agentic-organization/packages/domain/src/work-item-state-machine.test.ts b/agentic-organization/packages/domain/test/work-item-state-machine.test.ts similarity index 92% rename from agentic-organization/packages/domain/src/work-item-state-machine.test.ts rename to agentic-organization/packages/domain/test/work-item-state-machine.test.ts index d7a7987f3b..7799f79e57 100644 --- a/agentic-organization/packages/domain/src/work-item-state-machine.test.ts +++ b/agentic-organization/packages/domain/test/work-item-state-machine.test.ts @@ -1,7 +1,7 @@ import { equal, throws } from "node:assert/strict"; import { describe, test } from "node:test"; -import { WorkItemState, assertWorkItemTransition, createInitialWorkItemState } from "./work-item-state-machine.ts"; +import { WorkItemState, assertWorkItemTransition, createInitialWorkItemState } from "../src/work-item-state-machine.ts"; describe("work item state machine", () => { test("new work starts in the typed new state", () => { diff --git a/agentic-organization/packages/governance/src/index.ts b/agentic-organization/packages/governance/src/index.ts index 4e0ff163ae..dfb46ca73c 100644 --- a/agentic-organization/packages/governance/src/index.ts +++ b/agentic-organization/packages/governance/src/index.ts @@ -1,7 +1,12 @@ export { PackageBoundaryRule, + PackageSourceLayoutViolationReason, validatePackageDependencyBoundaries, + validatePackageSourceLayout, type PackageDependencyBoundaryRule, type PackageDependencyBoundaryViolation, + type PackageSourceLayoutRule, + type PackageSourceLayoutViolation, type ValidatePackageDependencyBoundariesInput, + type ValidatePackageSourceLayoutInput, } from "./package-dependency-boundaries.ts"; diff --git a/agentic-organization/packages/governance/src/package-dependency-boundaries.test.ts b/agentic-organization/packages/governance/src/package-dependency-boundaries.test.ts deleted file mode 100644 index 10a3f3c892..0000000000 --- a/agentic-organization/packages/governance/src/package-dependency-boundaries.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { equal } from "node:assert/strict"; -import { describe, test } from "node:test"; - -import { PackageBoundaryRule, validatePackageDependencyBoundaries } from "./package-dependency-boundaries.ts"; - -describe("package dependency boundaries", () => { - test("keeps application independent from state and runtime adapters", async () => { - const violations = await validatePackageDependencyBoundaries({ - rootDirectory: new URL("../..", import.meta.url), - rules: [ - { - packageName: PackageBoundaryRule.Application, - sourceGlob: "application/src/**/*.ts", - forbiddenImportFragments: [ - "../../state", - "../../../state", - "state-cockroach", - "nestjs", - "@nestjs", - "nats", - "dapr", - "temporal", - "drizzle", - "pg", - "postgres", - ], - }, - { - packageName: PackageBoundaryRule.Messaging, - sourceGlob: "messaging/src/**/*.ts", - forbiddenImportFragments: [ - "../messaging-nats", - "../../messaging-nats", - "nats", - "drizzle", - "pg", - "postgres", - ], - }, - { - packageName: PackageBoundaryRule.StateAdapter, - sourceGlob: "state-cockroach/src/**/*.ts", - forbiddenImportFragments: ["../../messaging", "../messaging", "nats", "jetstream"], - }, - ], - }); - - equal(violations.length, 0, violations.map((violation) => violation.message).join("\n")); - }); -}); diff --git a/agentic-organization/packages/governance/src/package-dependency-boundaries.ts b/agentic-organization/packages/governance/src/package-dependency-boundaries.ts index eb7a82d16c..c928093f31 100644 --- a/agentic-organization/packages/governance/src/package-dependency-boundaries.ts +++ b/agentic-organization/packages/governance/src/package-dependency-boundaries.ts @@ -4,8 +4,13 @@ import { fileURLToPath } from "node:url"; export const PackageBoundaryRule = { Application: "application", + ApplicationHost: "application_host", Messaging: "messaging", + Packages: "packages", + ProductionSource: "production_source", + Runtime: "runtime", StateAdapter: "state_adapter", + Workers: "workers", } as const; export type PackageBoundaryRule = (typeof PackageBoundaryRule)[keyof typeof PackageBoundaryRule]; @@ -23,11 +28,37 @@ export type PackageDependencyBoundaryViolation = { message: string; }; +export const PackageSourceLayoutViolationReason = { + TestFileInProductionSource: "test_file_in_production_source", +} as const; + +export type PackageSourceLayoutViolationReason = + (typeof PackageSourceLayoutViolationReason)[keyof typeof PackageSourceLayoutViolationReason]; + +export type PackageSourceLayoutRule = { + packageName: PackageBoundaryRule; + sourceGlob: string; + forbiddenFileSuffix: string; + reason: PackageSourceLayoutViolationReason; +}; + +export type PackageSourceLayoutViolation = { + packageName: PackageBoundaryRule; + filePath: string; + reason: PackageSourceLayoutViolationReason; + message: string; +}; + export type ValidatePackageDependencyBoundariesInput = { rootDirectory: URL; rules: readonly PackageDependencyBoundaryRule[]; }; +export type ValidatePackageSourceLayoutInput = { + rootDirectory: URL; + rules: readonly PackageSourceLayoutRule[]; +}; + const TypeScriptSourceExtension = ".ts"; const TestSourceExtension = ".test.ts"; const RecursiveTypeScriptGlobSuffix = "/**/*.ts"; @@ -65,14 +96,46 @@ export async function validatePackageDependencyBoundaries( return violations; } +export async function validatePackageSourceLayout( + input: ValidatePackageSourceLayoutInput, +): Promise { + const rootDirectoryPath = fileURLToPath(input.rootDirectory); + const violations: PackageSourceLayoutViolation[] = []; + + for (const rule of input.rules) { + const sourceFiles = await findFiles(rootDirectoryPath, rule.sourceGlob); + + for (const sourceFile of sourceFiles) { + if (!sourceFile.endsWith(rule.forbiddenFileSuffix)) { + continue; + } + + violations.push({ + packageName: rule.packageName, + filePath: normalizePath(relative(rootDirectoryPath, sourceFile)), + reason: rule.reason, + message: `${rule.packageName} has forbidden source-layout file ${normalizePath( + relative(rootDirectoryPath, sourceFile), + )}`, + }); + } + } + + return violations; +} + async function findSourceFiles(rootDirectoryPath: string, sourceGlob: string): Promise { + const sourceFiles = await findFiles(rootDirectoryPath, sourceGlob); + return sourceFiles.filter((sourceFile) => !sourceFile.endsWith(TestSourceExtension)); +} + +async function findFiles(rootDirectoryPath: string, sourceGlob: string): Promise { if (!sourceGlob.endsWith(RecursiveTypeScriptGlobSuffix)) { throw new Error(`unsupported source glob: ${sourceGlob}`); } const sourceRoot = join(rootDirectoryPath, sourceGlob.slice(0, -RecursiveTypeScriptGlobSuffix.length)); - const sourceFiles = await collectTypeScriptSourceFiles(sourceRoot); - return sourceFiles.filter((sourceFile) => !sourceFile.endsWith(TestSourceExtension)); + return collectTypeScriptSourceFiles(sourceRoot); } async function collectTypeScriptSourceFiles(directoryPath: string): Promise { diff --git a/agentic-organization/packages/governance/test/package-dependency-boundaries.test.ts b/agentic-organization/packages/governance/test/package-dependency-boundaries.test.ts new file mode 100644 index 0000000000..639e4c0adc --- /dev/null +++ b/agentic-organization/packages/governance/test/package-dependency-boundaries.test.ts @@ -0,0 +1,239 @@ +import { equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + PackageBoundaryRule, + PackageSourceLayoutViolationReason, + validatePackageDependencyBoundaries, + validatePackageSourceLayout, +} from "../src/package-dependency-boundaries.ts"; + +const packagesRootDirectory = new URL("../..", import.meta.url); +const agenticOrganizationRootDirectory = new URL("../../..", import.meta.url); + +describe("package dependency boundaries", () => { + test("keeps application independent from state and runtime adapters", async () => { + const violations = await validatePackageDependencyBoundaries({ + rootDirectory: packagesRootDirectory, + rules: [ + { + packageName: PackageBoundaryRule.Application, + sourceGlob: "application/src/**/*.ts", + forbiddenImportFragments: [ + "../../state", + "../../../state", + "state-cockroach", + "cockroach", + "nestjs", + "@nestjs", + "nats", + "dapr", + "temporal", + "drizzle", + "pg", + "postgres", + ], + }, + { + packageName: PackageBoundaryRule.Messaging, + sourceGlob: "messaging/src/**/*.ts", + forbiddenImportFragments: ["../messaging-nats", "../../messaging-nats", "nats"], + }, + { + packageName: PackageBoundaryRule.Runtime, + sourceGlob: "runtime/src/**/*.ts", + forbiddenImportFragments: [ + "../../state-cockroach", + "../state-cockroach", + "../../messaging-nats", + "../messaging-nats", + "cockroach", + "nestjs", + "@nestjs", + "nats", + "jetstream", + "dapr", + "temporal", + "drizzle", + "pg", + "postgres", + ], + }, + { + packageName: PackageBoundaryRule.StateAdapter, + sourceGlob: "state-cockroach/src/**/*.ts", + forbiddenImportFragments: [ + "../../messaging", + "../messaging", + "../../runtime", + "../runtime", + "nats", + "jetstream", + ], + }, + { + packageName: PackageBoundaryRule.Workers, + sourceGlob: "workers/src/**/*.ts", + forbiddenImportFragments: [ + "../../state-cockroach", + "../state-cockroach", + "../../messaging-nats", + "../messaging-nats", + "nestjs", + "@nestjs", + "nats", + "jetstream", + "dapr", + "temporal", + "drizzle", + "pg", + "postgres", + ], + }, + ], + }); + + equal(violations.length, 0, violations.map((violation) => violation.message).join("\n")); + }); + + test("keeps tests out of production source directories", async () => { + const violations = await validatePackageSourceLayout({ + rootDirectory: packagesRootDirectory, + rules: [ + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "application/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "domain/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "governance/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "messaging/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "messaging-nats/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "observability/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "runtime/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "state/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "state-cockroach/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + { + packageName: PackageBoundaryRule.ProductionSource, + sourceGlob: "workers/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + ], + }); + + equal(violations.length, 0, violations.map((violation) => violation.message).join("\n")); + }); + + test("keeps package code independent from app hosts", async () => { + const violations = await validatePackageDependencyBoundaries({ + rootDirectory: packagesRootDirectory, + rules: [ + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "application/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "domain/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "messaging/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "messaging-nats/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "observability/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "runtime/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "state/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "state-cockroach/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + { + packageName: PackageBoundaryRule.Packages, + sourceGlob: "workers/src/**/*.ts", + forbiddenImportFragments: ["apps/"], + }, + ], + }); + + equal(violations.length, 0, violations.map((violation) => violation.message).join("\n")); + }); + + test("keeps app tests out of production source directories", async () => { + const violations = await validatePackageSourceLayout({ + rootDirectory: agenticOrganizationRootDirectory, + rules: [ + { + packageName: PackageBoundaryRule.ApplicationHost, + sourceGlob: "apps/workers/src/**/*.ts", + forbiddenFileSuffix: ".test.ts", + reason: PackageSourceLayoutViolationReason.TestFileInProductionSource, + }, + ], + }); + + equal(violations.length, 0, violations.map((violation) => violation.message).join("\n")); + }); +}); diff --git a/agentic-organization/packages/messaging-nats/src/index.ts b/agentic-organization/packages/messaging-nats/src/index.ts index 9bc7e8466b..ee81e3d166 100644 --- a/agentic-organization/packages/messaging-nats/src/index.ts +++ b/agentic-organization/packages/messaging-nats/src/index.ts @@ -5,3 +5,17 @@ export { type NatsJetStreamClient, type NatsJetStreamMessage, } from "./nats-jetstream-event-publisher.ts"; +export { + NatsDeadLetterReason, + NatsInboundMessageAckAction, + createNatsJetStreamEventConsumer, + type CreateNatsJetStreamEventConsumerInput, + type FetchNatsJetStreamBatchInput, + type NatsDeadLetterMessage, + type NatsDeadLetterPublisher, + type NatsJetStreamConsumeBatchResult, + type NatsJetStreamEventConsumer, + type NatsJetStreamInboundMessage, + type NatsJetStreamPullConsumer, + type ProcessNatsJetStreamBatchInput, +} from "./nats-jetstream-event-consumer.ts"; diff --git a/agentic-organization/packages/messaging-nats/src/nats-jetstream-event-consumer.ts b/agentic-organization/packages/messaging-nats/src/nats-jetstream-event-consumer.ts new file mode 100644 index 0000000000..434886e3a1 --- /dev/null +++ b/agentic-organization/packages/messaging-nats/src/nats-jetstream-event-consumer.ts @@ -0,0 +1,324 @@ +import { + AgenticAggregateType, + AgenticEventType, + EventSchemaVersion, + createAgenticEventEnvelope, + type AgenticEventEnvelope, +} from "../../domain/src/index.ts"; +import type { EventIngestionProcessor } from "../../runtime/src/index.ts"; +import { EventIngestionOutcomeStatus } from "../../state/src/index.ts"; + +export const NatsInboundMessageAckAction = { + Acknowledge: "acknowledge", + NegativeAcknowledge: "negative_acknowledge", + Terminate: "terminate", +} as const; + +export type NatsInboundMessageAckAction = + (typeof NatsInboundMessageAckAction)[keyof typeof NatsInboundMessageAckAction]; + +export const NatsDeadLetterReason = { + InvalidEnvelope: "invalid_envelope", + PayloadConflict: "payload_conflict", +} as const; + +export type NatsDeadLetterReason = (typeof NatsDeadLetterReason)[keyof typeof NatsDeadLetterReason]; + +export type FetchNatsJetStreamBatchInput = { + batchSize: number; +}; + +export type NatsJetStreamInboundMessage = { + subject: string; + payload: string; + headers: Record; + acknowledge: () => Promise; + negativeAcknowledge: () => Promise; + terminate: () => Promise; +}; + +export type NatsJetStreamPullConsumer = { + fetchNextBatch: (input: FetchNatsJetStreamBatchInput) => Promise; +}; + +export type NatsDeadLetterMessage = { + sourceSubject: string; + payload: string; + headers: Record; + reason: NatsDeadLetterReason; +}; + +export type NatsDeadLetterPublisher = { + publish: (message: NatsDeadLetterMessage) => Promise; +}; + +export type ProcessNatsJetStreamBatchInput = { + batchSize: number; +}; + +export type NatsJetStreamConsumeBatchResult = { + receivedCount: number; + processedCount: number; + duplicateCount: number; + payloadConflictCount: number; + invalidCount: number; + failedCount: number; + acknowledgedCount: number; + negativeAcknowledgedCount: number; + terminatedCount: number; + deadLetteredCount: number; +}; + +export type NatsJetStreamEventConsumer = { + processNextBatch: (input: ProcessNatsJetStreamBatchInput) => Promise; +}; + +export type CreateNatsJetStreamEventConsumerInput = { + pullConsumer: NatsJetStreamPullConsumer; + eventIngestionProcessor: EventIngestionProcessor; + deadLetterPublisher: NatsDeadLetterPublisher; +}; + +export function createNatsJetStreamEventConsumer( + input: CreateNatsJetStreamEventConsumerInput, +): NatsJetStreamEventConsumer { + return { + processNextBatch: async ({ batchSize }) => { + const messages = await input.pullConsumer.fetchNextBatch({ + batchSize, + }); + const result = createEmptyConsumeBatchResult(messages.length); + + for (const message of messages) { + await processMessage({ + message, + eventIngestionProcessor: input.eventIngestionProcessor, + deadLetterPublisher: input.deadLetterPublisher, + result, + }); + } + + return result; + }, + }; +} + +type ProcessMessageInput = { + message: NatsJetStreamInboundMessage; + eventIngestionProcessor: EventIngestionProcessor; + deadLetterPublisher: NatsDeadLetterPublisher; + result: NatsJetStreamConsumeBatchResult; +}; + +async function processMessage(input: ProcessMessageInput): Promise { + const envelope = decodeCanonicalEventEnvelope(input.message.payload); + + if (envelope === undefined) { + input.result.invalidCount += 1; + await terminateWithDeadLetter({ + message: input.message, + deadLetterPublisher: input.deadLetterPublisher, + result: input.result, + reason: NatsDeadLetterReason.InvalidEnvelope, + }); + return; + } + + try { + const ingestionResult = await input.eventIngestionProcessor.ingest({ + envelope, + }); + + if (ingestionResult.status === EventIngestionOutcomeStatus.PayloadConflict) { + input.result.payloadConflictCount += 1; + await terminateWithDeadLetter({ + message: input.message, + deadLetterPublisher: input.deadLetterPublisher, + result: input.result, + reason: NatsDeadLetterReason.PayloadConflict, + }); + return; + } + + if (ingestionResult.status === EventIngestionOutcomeStatus.Duplicate) { + input.result.duplicateCount += 1; + } + + if (ingestionResult.status === EventIngestionOutcomeStatus.Processed) { + input.result.processedCount += 1; + } + + await input.message.acknowledge(); + input.result.acknowledgedCount += 1; + } catch { + input.result.failedCount += 1; + await negativeAcknowledgeFailedMessage({ + message: input.message, + result: input.result, + }); + } +} + +type TerminateWithDeadLetterInput = { + message: NatsJetStreamInboundMessage; + deadLetterPublisher: NatsDeadLetterPublisher; + result: NatsJetStreamConsumeBatchResult; + reason: NatsDeadLetterReason; +}; + +async function terminateWithDeadLetter(input: TerminateWithDeadLetterInput): Promise { + try { + await input.deadLetterPublisher.publish({ + sourceSubject: input.message.subject, + payload: input.message.payload, + headers: input.message.headers, + reason: input.reason, + }); + input.result.deadLetteredCount += 1; + await input.message.terminate(); + input.result.terminatedCount += 1; + } catch { + input.result.failedCount += 1; + await negativeAcknowledgeFailedMessage({ + message: input.message, + result: input.result, + }); + } +} + +type NegativeAcknowledgeFailedMessageInput = { + message: NatsJetStreamInboundMessage; + result: NatsJetStreamConsumeBatchResult; +}; + +async function negativeAcknowledgeFailedMessage(input: NegativeAcknowledgeFailedMessageInput): Promise { + try { + await input.message.negativeAcknowledge(); + input.result.negativeAcknowledgedCount += 1; + } catch { + input.result.failedCount += 1; + } +} + +function decodeCanonicalEventEnvelope(payload: string): AgenticEventEnvelope | undefined { + const parsed = parseJsonRecord(payload); + + if (parsed === undefined || !isCanonicalEventEnvelopeRecord(parsed)) { + return undefined; + } + + try { + return createAgenticEventEnvelope({ + eventId: parsed.eventId, + eventType: parsed.eventType, + schemaVersion: parsed.schemaVersion, + occurredAt: parsed.occurredAt, + actor: parsed.actor, + scope: parsed.scope, + aggregate: parsed.aggregate, + trace: parsed.trace, + replay: parsed.replay, + payload: parsed.payload, + }); + } catch { + return undefined; + } +} + +function parseJsonRecord(payload: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(payload); + + if (isRecord(parsed)) { + return parsed; + } + + return undefined; + } catch { + return undefined; + } +} + +function isCanonicalEventEnvelopeRecord(value: Record): value is AgenticEventEnvelope { + return ( + typeof value.eventId === "string" && + isAgenticEventType(value.eventType) && + value.schemaVersion === EventSchemaVersion.AgenticOrgEventV1 && + typeof value.occurredAt === "string" && + isActor(value.actor) && + isScope(value.scope) && + isAggregate(value.aggregate) && + isTrace(value.trace) && + isReplay(value.replay) + ); +} + +function isActor(value: unknown): value is AgenticEventEnvelope["actor"] { + return isRecord(value) && typeof value.agentId === "string" && typeof value.hatAssignmentId === "string"; +} + +function isScope(value: unknown): value is AgenticEventEnvelope["scope"] { + return ( + isRecord(value) && + typeof value.organizationId === "string" && + typeof value.projectId === "string" && + typeof value.workItemId === "string" && + isOptionalString(value.initiativeId) && + isOptionalString(value.teamId) + ); +} + +function isAggregate(value: unknown): value is AgenticEventEnvelope["aggregate"] { + return ( + isRecord(value) && + typeof value.aggregateId === "string" && + isAgenticAggregateType(value.aggregateType) && + typeof value.aggregateVersion === "number" + ); +} + +function isTrace(value: unknown): value is AgenticEventEnvelope["trace"] { + return ( + isRecord(value) && + typeof value.commandId === "string" && + typeof value.correlationId === "string" && + typeof value.causationId === "string" && + typeof value.traceId === "string" && + typeof value.idempotencyKey === "string" + ); +} + +function isReplay(value: unknown): value is AgenticEventEnvelope["replay"] { + return isRecord(value) && typeof value.isReplay === "boolean"; +} + +function isAgenticEventType(value: unknown): value is AgenticEventType { + return Object.values(AgenticEventType).includes(value as AgenticEventType); +} + +function isAgenticAggregateType(value: unknown): value is AgenticAggregateType { + return Object.values(AgenticAggregateType).includes(value as AgenticAggregateType); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function createEmptyConsumeBatchResult(receivedCount: number): NatsJetStreamConsumeBatchResult { + return { + receivedCount, + processedCount: 0, + duplicateCount: 0, + payloadConflictCount: 0, + invalidCount: 0, + failedCount: 0, + acknowledgedCount: 0, + negativeAcknowledgedCount: 0, + terminatedCount: 0, + deadLetteredCount: 0, + }; +} diff --git a/agentic-organization/packages/messaging-nats/test/nats-jetstream-event-consumer.test.ts b/agentic-organization/packages/messaging-nats/test/nats-jetstream-event-consumer.test.ts new file mode 100644 index 0000000000..219c96f1a7 --- /dev/null +++ b/agentic-organization/packages/messaging-nats/test/nats-jetstream-event-consumer.test.ts @@ -0,0 +1,367 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + AgenticAggregateType, + AgenticEventType, + createAgenticEventEnvelope, + type AgenticEventEnvelope, +} from "../../domain/src/index.ts"; +import type { EventIngestionProcessor } from "../../runtime/src/index.ts"; +import { EventIngestionOutcomeStatus } from "../../state/src/index.ts"; +import { + NatsDeadLetterReason, + NatsInboundMessageAckAction, + createNatsJetStreamEventConsumer, + type NatsJetStreamInboundMessage, + type NatsJetStreamPullConsumer, +} from "../src/index.ts"; + +describe("NATS JetStream event consumer", () => { + test("decodes canonical event envelopes and acknowledges processed messages", async () => { + const envelope = createEnvelope(); + const message = createRecordingInboundMessage({ + payload: JSON.stringify(envelope), + }); + const pullConsumer = createRecordingPullConsumer([message]); + const eventIngestionProcessor = createRecordingEventIngestionProcessor(EventIngestionOutcomeStatus.Processed); + const deadLetterPublisher = createRecordingDeadLetterPublisher(); + const consumer = createNatsJetStreamEventConsumer({ + pullConsumer, + eventIngestionProcessor, + deadLetterPublisher, + }); + + const result = await consumer.processNextBatch({ + batchSize: 10, + }); + + deepEqual(pullConsumer.batchSizes, [10]); + deepEqual(eventIngestionProcessor.eventIds, ["evt-nats-001"]); + deepEqual(eventIngestionProcessor.traceFields, [ + { + eventId: "evt-nats-001", + traceId: "trace-nats-001", + correlationId: "corr-nats-001", + idempotencyKey: "idem-nats-001", + }, + ]); + deepEqual(message.ackActions, [NatsInboundMessageAckAction.Acknowledge]); + equal(deadLetterPublisher.messages.length, 0); + deepEqual(result, { + receivedCount: 1, + processedCount: 1, + duplicateCount: 0, + payloadConflictCount: 0, + invalidCount: 0, + failedCount: 0, + acknowledgedCount: 1, + negativeAcknowledgedCount: 0, + terminatedCount: 0, + deadLetteredCount: 0, + }); + }); + + test("terminates and dead-letters invalid payloads without calling runtime ingestion", async () => { + const message = createRecordingInboundMessage({ + payload: "{not-json", + }); + const eventIngestionProcessor = createRecordingEventIngestionProcessor(EventIngestionOutcomeStatus.Processed); + const deadLetterPublisher = createRecordingDeadLetterPublisher(); + const consumer = createNatsJetStreamEventConsumer({ + pullConsumer: createRecordingPullConsumer([message]), + eventIngestionProcessor, + deadLetterPublisher, + }); + + const result = await consumer.processNextBatch({ + batchSize: 10, + }); + + deepEqual(eventIngestionProcessor.eventIds, []); + deepEqual(message.ackActions, [NatsInboundMessageAckAction.Terminate]); + equal(result.invalidCount, 1); + equal(result.deadLetteredCount, 1); + deepEqual(deadLetterPublisher.messages, [ + { + sourceSubject: "agentic-org.local.org-lfg.supervisor_signal.supervisor_signal.sent", + payload: "{not-json", + headers: { + "Nats-Msg-Event-Id": "evt-nats-001", + }, + reason: NatsDeadLetterReason.InvalidEnvelope, + }, + ]); + }); + + test("terminates and dead-letters payload conflicts", async () => { + const envelope = createEnvelope(); + const message = createRecordingInboundMessage({ + payload: JSON.stringify(envelope), + }); + const deadLetterPublisher = createRecordingDeadLetterPublisher(); + const consumer = createNatsJetStreamEventConsumer({ + pullConsumer: createRecordingPullConsumer([message]), + eventIngestionProcessor: createRecordingEventIngestionProcessor(EventIngestionOutcomeStatus.PayloadConflict), + deadLetterPublisher, + }); + + const result = await consumer.processNextBatch({ + batchSize: 10, + }); + + deepEqual(message.ackActions, [NatsInboundMessageAckAction.Terminate]); + equal(result.payloadConflictCount, 1); + equal(result.deadLetteredCount, 1); + deepEqual(deadLetterPublisher.messages, [ + { + sourceSubject: "agentic-org.local.org-lfg.supervisor_signal.supervisor_signal.sent", + payload: JSON.stringify(envelope), + headers: { + "Nats-Msg-Event-Id": "evt-nats-001", + }, + reason: NatsDeadLetterReason.PayloadConflict, + }, + ]); + }); + + test("negative-acknowledges invalid payloads when dead-letter publishing fails", async () => { + const invalidMessage = createRecordingInboundMessage({ + payload: "{not-json", + }); + const validMessage = createRecordingInboundMessage({ + payload: JSON.stringify(createEnvelope()), + }); + const eventIngestionProcessor = createRecordingEventIngestionProcessor(EventIngestionOutcomeStatus.Processed); + const consumer = createNatsJetStreamEventConsumer({ + pullConsumer: createRecordingPullConsumer([invalidMessage, validMessage]), + eventIngestionProcessor, + deadLetterPublisher: createFailingDeadLetterPublisher("dlq unavailable"), + }); + + const result = await consumer.processNextBatch({ + batchSize: 10, + }); + + deepEqual(invalidMessage.ackActions, [NatsInboundMessageAckAction.NegativeAcknowledge]); + deepEqual(validMessage.ackActions, [NatsInboundMessageAckAction.Acknowledge]); + deepEqual(eventIngestionProcessor.eventIds, ["evt-nats-001"]); + equal(result.receivedCount, 2); + equal(result.invalidCount, 1); + equal(result.processedCount, 1); + equal(result.failedCount, 1); + equal(result.deadLetteredCount, 0); + equal(result.terminatedCount, 0); + equal(result.negativeAcknowledgedCount, 1); + equal(result.acknowledgedCount, 1); + }); + + test("negative-acknowledges payload conflicts when message termination fails", async () => { + const envelope = createEnvelope(); + const message = createRecordingInboundMessage({ + payload: JSON.stringify(envelope), + failTerminate: true, + }); + const deadLetterPublisher = createRecordingDeadLetterPublisher(); + const consumer = createNatsJetStreamEventConsumer({ + pullConsumer: createRecordingPullConsumer([message]), + eventIngestionProcessor: createRecordingEventIngestionProcessor(EventIngestionOutcomeStatus.PayloadConflict), + deadLetterPublisher, + }); + + const result = await consumer.processNextBatch({ + batchSize: 10, + }); + + deepEqual(message.ackActions, [ + NatsInboundMessageAckAction.Terminate, + NatsInboundMessageAckAction.NegativeAcknowledge, + ]); + equal(result.payloadConflictCount, 1); + equal(result.failedCount, 1); + equal(result.deadLetteredCount, 1); + equal(result.terminatedCount, 0); + equal(result.negativeAcknowledgedCount, 1); + equal(deadLetterPublisher.messages.length, 1); + }); + + test("negative-acknowledges transient ingestion failures", async () => { + const message = createRecordingInboundMessage({ + payload: JSON.stringify(createEnvelope()), + }); + const consumer = createNatsJetStreamEventConsumer({ + pullConsumer: createRecordingPullConsumer([message]), + eventIngestionProcessor: createFailingEventIngestionProcessor("store unavailable"), + deadLetterPublisher: createRecordingDeadLetterPublisher(), + }); + + const result = await consumer.processNextBatch({ + batchSize: 10, + }); + + deepEqual(message.ackActions, [NatsInboundMessageAckAction.NegativeAcknowledge]); + equal(result.failedCount, 1); + equal(result.negativeAcknowledgedCount, 1); + }); +}); + +function createEnvelope(): AgenticEventEnvelope { + return createAgenticEventEnvelope({ + eventId: "evt-nats-001", + eventType: AgenticEventType.SupervisorSignalSent, + occurredAt: "2026-05-25T20:00:00.000Z", + actor: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + scope: { + organizationId: "org-lfg", + projectId: "project-agentic-org", + workItemId: "work-nats-001", + }, + aggregate: { + aggregateId: "supervisor-signal-001", + aggregateType: AgenticAggregateType.SupervisorSignal, + aggregateVersion: 1, + }, + trace: { + commandId: "cmd-nats-001", + correlationId: "corr-nats-001", + causationId: "cause-nats-001", + traceId: "trace-nats-001", + idempotencyKey: "idem-nats-001", + }, + payload: { + title: "Blocked on NATS inbound adapter", + }, + }); +} + +function createRecordingInboundMessage(input: { + payload: string; + failTerminate?: boolean; +}): NatsJetStreamInboundMessage & { + ackActions: NatsInboundMessageAckAction[]; +} { + const ackActions: NatsInboundMessageAckAction[] = []; + + return { + subject: "agentic-org.local.org-lfg.supervisor_signal.supervisor_signal.sent", + payload: input.payload, + headers: { + "Nats-Msg-Event-Id": "evt-nats-001", + }, + ackActions, + acknowledge: async () => { + ackActions.push(NatsInboundMessageAckAction.Acknowledge); + }, + negativeAcknowledge: async () => { + ackActions.push(NatsInboundMessageAckAction.NegativeAcknowledge); + }, + terminate: async () => { + ackActions.push(NatsInboundMessageAckAction.Terminate); + + if (input.failTerminate === true) { + throw new Error("terminate unavailable"); + } + }, + }; +} + +function createRecordingPullConsumer(messages: readonly NatsJetStreamInboundMessage[]): NatsJetStreamPullConsumer & { + batchSizes: number[]; +} { + const batchSizes: number[] = []; + + return { + batchSizes, + fetchNextBatch: async (input) => { + batchSizes.push(input.batchSize); + return messages; + }, + }; +} + +function createRecordingEventIngestionProcessor(status: EventIngestionOutcomeStatus): EventIngestionProcessor & { + eventIds: string[]; + traceFields: { + eventId: string; + traceId: string; + correlationId: string; + idempotencyKey: string; + }[]; +} { + const eventIds: string[] = []; + const traceFields: { + eventId: string; + traceId: string; + correlationId: string; + idempotencyKey: string; + }[] = []; + + return { + eventIds, + traceFields, + ingest: async (input) => { + eventIds.push(input.envelope.eventId); + traceFields.push({ + eventId: input.envelope.eventId, + traceId: input.envelope.trace.traceId, + correlationId: input.envelope.trace.correlationId, + idempotencyKey: input.envelope.trace.idempotencyKey, + }); + + return { + status, + reactionPlans: [], + }; + }, + }; +} + +function createFailingEventIngestionProcessor(message: string): EventIngestionProcessor { + return { + ingest: async () => { + throw new Error(message); + }, + }; +} + +function createRecordingDeadLetterPublisher(): { + messages: { + sourceSubject: string; + payload: string; + headers: Record; + reason: NatsDeadLetterReason; + }[]; + publish: (input: { + sourceSubject: string; + payload: string; + headers: Record; + reason: NatsDeadLetterReason; + }) => Promise; +} { + const messages: { + sourceSubject: string; + payload: string; + headers: Record; + reason: NatsDeadLetterReason; + }[] = []; + + return { + messages, + publish: async (input) => { + messages.push(input); + }, + }; +} + +function createFailingDeadLetterPublisher(message: string): { + publish: () => Promise; +} { + return { + publish: async () => { + throw new Error(message); + }, + }; +} diff --git a/agentic-organization/packages/messaging-nats/src/nats-jetstream-event-publisher.test.ts b/agentic-organization/packages/messaging-nats/test/nats-jetstream-event-publisher.test.ts similarity index 92% rename from agentic-organization/packages/messaging-nats/src/nats-jetstream-event-publisher.test.ts rename to agentic-organization/packages/messaging-nats/test/nats-jetstream-event-publisher.test.ts index 8a56b42b06..8408a37349 100644 --- a/agentic-organization/packages/messaging-nats/src/nats-jetstream-event-publisher.test.ts +++ b/agentic-organization/packages/messaging-nats/test/nats-jetstream-event-publisher.test.ts @@ -11,7 +11,7 @@ import { createNatsJetStreamEventPublisher, NatsHeaderName, type NatsJetStreamClient, -} from "./nats-jetstream-event-publisher.ts"; +} from "../src/nats-jetstream-event-publisher.ts"; describe("NATS JetStream event publisher", () => { test("publishes canonical JSON with idempotent headers and message ID", async () => { @@ -22,13 +22,13 @@ describe("NATS JetStream event publisher", () => { const outboxEvent = createOutboxEvent(); await publisher.publish({ - subject: "agentic-org.local.org-lfg.work.supervisor_signal.sent", + subject: "agentic-org.local.org-lfg.supervisor_signal.supervisor_signal.sent", outboxEvent, }); equal(client.messages.length, 1); deepEqual(client.messages[0], { - subject: "agentic-org.local.org-lfg.work.supervisor_signal.sent", + subject: "agentic-org.local.org-lfg.supervisor_signal.supervisor_signal.sent", payload: JSON.stringify(outboxEvent.envelope), messageId: "evt-001", headers: { diff --git a/agentic-organization/packages/messaging/src/outbox-publisher.test.ts b/agentic-organization/packages/messaging/test/outbox-publisher.test.ts similarity index 94% rename from agentic-organization/packages/messaging/src/outbox-publisher.test.ts rename to agentic-organization/packages/messaging/test/outbox-publisher.test.ts index b1beb32893..eb647fd683 100644 --- a/agentic-organization/packages/messaging/src/outbox-publisher.test.ts +++ b/agentic-organization/packages/messaging/test/outbox-publisher.test.ts @@ -15,7 +15,7 @@ import { resolveAgenticMessagingDomain, type EventPublication, type EventPublisher, -} from "./outbox-publisher.ts"; +} from "../src/outbox-publisher.ts"; describe("outbox publisher", () => { test("resolves event domains through typed mappings", () => { @@ -23,14 +23,8 @@ describe("outbox publisher", () => { resolveAgenticMessagingDomain(AgenticEventType.SupervisorSignalSent), AgenticMessagingDomain.SupervisorSignal, ); - deepEqual( - resolveAgenticMessagingDomain(AgenticEventType.WorkItemChanged), - AgenticMessagingDomain.WorkItem, - ); - deepEqual( - resolveAgenticMessagingDomain(AgenticEventType.WorkItemStateChanged), - AgenticMessagingDomain.WorkItem, - ); + deepEqual(resolveAgenticMessagingDomain(AgenticEventType.WorkItemChanged), AgenticMessagingDomain.WorkItem); + deepEqual(resolveAgenticMessagingDomain(AgenticEventType.WorkItemStateChanged), AgenticMessagingDomain.WorkItem); }); test("publishes unpublished outbox events and marks them published", async () => { diff --git a/agentic-organization/packages/messaging/src/subject-builder.test.ts b/agentic-organization/packages/messaging/test/subject-builder.test.ts similarity index 92% rename from agentic-organization/packages/messaging/src/subject-builder.test.ts rename to agentic-organization/packages/messaging/test/subject-builder.test.ts index fb26ad23ea..e028a757d0 100644 --- a/agentic-organization/packages/messaging/src/subject-builder.test.ts +++ b/agentic-organization/packages/messaging/test/subject-builder.test.ts @@ -2,7 +2,7 @@ import { equal } from "node:assert/strict"; import { describe, test } from "node:test"; import { AgenticEventType } from "../../domain/src/index.ts"; -import { buildAgenticEventSubject } from "./subject-builder.ts"; +import { buildAgenticEventSubject } from "../src/subject-builder.ts"; describe("agentic event NATS subjects", () => { test("uses a stable organization-scoped subject shape", () => { diff --git a/agentic-organization/packages/observability/src/index.ts b/agentic-organization/packages/observability/src/index.ts index 354ec11a1f..3a16effc1c 100644 --- a/agentic-organization/packages/observability/src/index.ts +++ b/agentic-organization/packages/observability/src/index.ts @@ -5,6 +5,19 @@ export { type AgenticSpanAttributes, type BuildAgenticSpanAttributesInput, } from "./span-attributes.ts"; +export { + NatsConsumerAttributeKey, + buildNatsConsumerBatchAttributes, + type BuildNatsConsumerBatchAttributesInput, + type NatsConsumerBatchAttributes, + type NatsConsumerBatchCounts, +} from "./nats-consumer-attributes.ts"; +export { + WorkerCycleAttributeKey, + buildWorkerCycleAttributes, + type BuildWorkerCycleAttributesInput, + type WorkerCycleAttributes, +} from "./worker-cycle-attributes.ts"; export { VisibilityHealth, WeakPointIndicatorType, diff --git a/agentic-organization/packages/observability/src/nats-consumer-attributes.ts b/agentic-organization/packages/observability/src/nats-consumer-attributes.ts new file mode 100644 index 0000000000..dd85b32e59 --- /dev/null +++ b/agentic-organization/packages/observability/src/nats-consumer-attributes.ts @@ -0,0 +1,59 @@ +import { MessagingSystemName } from "./span-attributes.ts"; + +export const NatsConsumerAttributeKey = { + MessagingSystem: "messaging.system", + StreamName: "messaging.nats.stream", + ConsumerName: "messaging.nats.consumer", + ReceivedCount: "agentic.nats.consumer.received_count", + ProcessedCount: "agentic.nats.consumer.processed_count", + DuplicateCount: "agentic.nats.consumer.duplicate_count", + PayloadConflictCount: "agentic.nats.consumer.payload_conflict_count", + InvalidCount: "agentic.nats.consumer.invalid_count", + FailedCount: "agentic.nats.consumer.failed_count", + AcknowledgedCount: "agentic.nats.consumer.acknowledged_count", + NegativeAcknowledgedCount: "agentic.nats.consumer.negative_acknowledged_count", + TerminatedCount: "agentic.nats.consumer.terminated_count", + DeadLetteredCount: "agentic.nats.consumer.dead_lettered_count", +} as const; + +export type NatsConsumerAttributeKey = (typeof NatsConsumerAttributeKey)[keyof typeof NatsConsumerAttributeKey]; + +export type NatsConsumerBatchCounts = { + receivedCount: number; + processedCount: number; + duplicateCount: number; + payloadConflictCount: number; + invalidCount: number; + failedCount: number; + acknowledgedCount: number; + negativeAcknowledgedCount: number; + terminatedCount: number; + deadLetteredCount: number; +}; + +export type BuildNatsConsumerBatchAttributesInput = NatsConsumerBatchCounts & { + streamName: string; + durableName: string; +}; + +export type NatsConsumerBatchAttributes = Record; + +export function buildNatsConsumerBatchAttributes( + input: BuildNatsConsumerBatchAttributesInput, +): NatsConsumerBatchAttributes { + return { + [NatsConsumerAttributeKey.MessagingSystem]: MessagingSystemName.Nats, + [NatsConsumerAttributeKey.StreamName]: input.streamName, + [NatsConsumerAttributeKey.ConsumerName]: input.durableName, + [NatsConsumerAttributeKey.ReceivedCount]: input.receivedCount, + [NatsConsumerAttributeKey.ProcessedCount]: input.processedCount, + [NatsConsumerAttributeKey.DuplicateCount]: input.duplicateCount, + [NatsConsumerAttributeKey.PayloadConflictCount]: input.payloadConflictCount, + [NatsConsumerAttributeKey.InvalidCount]: input.invalidCount, + [NatsConsumerAttributeKey.FailedCount]: input.failedCount, + [NatsConsumerAttributeKey.AcknowledgedCount]: input.acknowledgedCount, + [NatsConsumerAttributeKey.NegativeAcknowledgedCount]: input.negativeAcknowledgedCount, + [NatsConsumerAttributeKey.TerminatedCount]: input.terminatedCount, + [NatsConsumerAttributeKey.DeadLetteredCount]: input.deadLetteredCount, + }; +} diff --git a/agentic-organization/packages/observability/src/worker-cycle-attributes.ts b/agentic-organization/packages/observability/src/worker-cycle-attributes.ts new file mode 100644 index 0000000000..ec18407a82 --- /dev/null +++ b/agentic-organization/packages/observability/src/worker-cycle-attributes.ts @@ -0,0 +1,41 @@ +export const WorkerCycleAttributeKey = { + Status: "agentic.worker.cycle.status", + OutboxStatus: "agentic.worker.outbox.status", + InboundPulledCount: "agentic.worker.inbound.pulled_count", + InboundProcessedCount: "agentic.worker.inbound.processed_count", + InboundDuplicateCount: "agentic.worker.inbound.duplicate_count", + InboundPayloadConflictCount: "agentic.worker.inbound.payload_conflict_count", + InboundFailedCount: "agentic.worker.inbound.failed_count", + InboundReactionPlanCount: "agentic.worker.inbound.reaction_plan_count", + FailureCount: "agentic.worker.failure_count", +} as const; + +export type WorkerCycleAttributeKey = (typeof WorkerCycleAttributeKey)[keyof typeof WorkerCycleAttributeKey]; + +export type BuildWorkerCycleAttributesInput = { + status: string; + outboxStatus: string; + inboundPulledCount: number; + inboundProcessedCount: number; + inboundDuplicateCount: number; + inboundPayloadConflictCount: number; + inboundFailedCount: number; + inboundReactionPlanCount: number; + failureCount: number; +}; + +export type WorkerCycleAttributes = Record; + +export function buildWorkerCycleAttributes(input: BuildWorkerCycleAttributesInput): WorkerCycleAttributes { + return { + [WorkerCycleAttributeKey.Status]: input.status, + [WorkerCycleAttributeKey.OutboxStatus]: input.outboxStatus, + [WorkerCycleAttributeKey.InboundPulledCount]: input.inboundPulledCount, + [WorkerCycleAttributeKey.InboundProcessedCount]: input.inboundProcessedCount, + [WorkerCycleAttributeKey.InboundDuplicateCount]: input.inboundDuplicateCount, + [WorkerCycleAttributeKey.InboundPayloadConflictCount]: input.inboundPayloadConflictCount, + [WorkerCycleAttributeKey.InboundFailedCount]: input.inboundFailedCount, + [WorkerCycleAttributeKey.InboundReactionPlanCount]: input.inboundReactionPlanCount, + [WorkerCycleAttributeKey.FailureCount]: input.failureCount, + }; +} diff --git a/agentic-organization/packages/observability/test/nats-consumer-attributes.test.ts b/agentic-organization/packages/observability/test/nats-consumer-attributes.test.ts new file mode 100644 index 0000000000..d64de48558 --- /dev/null +++ b/agentic-organization/packages/observability/test/nats-consumer-attributes.test.ts @@ -0,0 +1,40 @@ +import { deepEqual } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { buildNatsConsumerBatchAttributes } from "../src/index.ts"; + +describe("NATS consumer batch observability attributes", () => { + test("projects inbound consumer counts into LGTM-friendly attributes", () => { + deepEqual( + buildNatsConsumerBatchAttributes({ + durableName: "agentic-org-v0-automation-planner", + streamName: "agentic-org-events", + receivedCount: 6, + processedCount: 2, + duplicateCount: 1, + payloadConflictCount: 1, + invalidCount: 1, + failedCount: 1, + acknowledgedCount: 3, + negativeAcknowledgedCount: 1, + terminatedCount: 2, + deadLetteredCount: 2, + }), + { + "messaging.system": "nats", + "messaging.nats.stream": "agentic-org-events", + "messaging.nats.consumer": "agentic-org-v0-automation-planner", + "agentic.nats.consumer.received_count": 6, + "agentic.nats.consumer.processed_count": 2, + "agentic.nats.consumer.duplicate_count": 1, + "agentic.nats.consumer.payload_conflict_count": 1, + "agentic.nats.consumer.invalid_count": 1, + "agentic.nats.consumer.failed_count": 1, + "agentic.nats.consumer.acknowledged_count": 3, + "agentic.nats.consumer.negative_acknowledged_count": 1, + "agentic.nats.consumer.terminated_count": 2, + "agentic.nats.consumer.dead_lettered_count": 2, + }, + ); + }); +}); diff --git a/agentic-organization/packages/observability/src/span-attributes.test.ts b/agentic-organization/packages/observability/test/span-attributes.test.ts similarity index 98% rename from agentic-organization/packages/observability/src/span-attributes.test.ts rename to agentic-organization/packages/observability/test/span-attributes.test.ts index a144a6c426..4481a0bb4a 100644 --- a/agentic-organization/packages/observability/src/span-attributes.test.ts +++ b/agentic-organization/packages/observability/test/span-attributes.test.ts @@ -7,7 +7,7 @@ import { WorkItemState, createAgenticEventEnvelope, } from "../../domain/src/index.ts"; -import { MessagingSystemName, buildAgenticSpanAttributes } from "./span-attributes.ts"; +import { MessagingSystemName, buildAgenticSpanAttributes } from "../src/span-attributes.ts"; describe("agentic observability span attributes", () => { test("projects event context into LGTM-friendly OpenTelemetry attributes", () => { diff --git a/agentic-organization/packages/observability/src/workflow-visibility.test.ts b/agentic-organization/packages/observability/test/workflow-visibility.test.ts similarity index 99% rename from agentic-organization/packages/observability/src/workflow-visibility.test.ts rename to agentic-organization/packages/observability/test/workflow-visibility.test.ts index 395d1f09d1..2217bb3b5f 100644 --- a/agentic-organization/packages/observability/src/workflow-visibility.test.ts +++ b/agentic-organization/packages/observability/test/workflow-visibility.test.ts @@ -14,7 +14,7 @@ import { WeakPointIndicatorType, WorkflowObservationKind, buildWorkflowVisibilityRecord, -} from "./workflow-visibility.ts"; +} from "../src/workflow-visibility.ts"; describe("workflow visibility records", () => { test("builds a plug-in visibility record for agent self-monitoring", () => { diff --git a/agentic-organization/packages/runtime/src/event-ingestion.ts b/agentic-organization/packages/runtime/src/event-ingestion.ts new file mode 100644 index 0000000000..3836e2185d --- /dev/null +++ b/agentic-organization/packages/runtime/src/event-ingestion.ts @@ -0,0 +1,97 @@ +import type { AgenticEventEnvelope } from "../../domain/src/index.ts"; +import { ReactionPlanStatus } from "../../domain/src/index.ts"; +import { + EventIngestionOutcomeStatus, + type EventIngestionStore, + type InboundEventConsumerName, + type InboxReceiptLookup, + type InboxReceiptRecord, + type ReactionPlanRecord, +} from "../../state/src/index.ts"; +import type { ReactionPlanAction } from "./reaction-plan.ts"; + +export type EventRuleEvaluator = (envelope: AgenticEventEnvelope) => readonly ReactionPlanAction[]; + +export type EventPayloadHashCalculator = (envelope: AgenticEventEnvelope) => string; + +export type CreateEventIngestionProcessorInput = { + store: EventIngestionStore; + evaluateRules: EventRuleEvaluator; + consumerName: InboundEventConsumerName; + calculatePayloadHash: EventPayloadHashCalculator; + now: () => string; + createId: (prefix: string) => string; +}; + +export type IngestEventInput = { + envelope: AgenticEventEnvelope; +}; + +export type EventIngestionResult = { + status: EventIngestionOutcomeStatus; + reactionPlans: readonly ReactionPlanRecord[]; +}; + +export type EventIngestionProcessor = { + ingest: (input: IngestEventInput) => Promise; +}; + +export function createEventIngestionProcessor(input: CreateEventIngestionProcessorInput): EventIngestionProcessor { + return { + ingest: async ({ envelope }) => { + const lookup: InboxReceiptLookup = { + eventId: envelope.eventId, + consumerName: input.consumerName, + }; + const existingReceipt = await input.store.findInboxReceipt(lookup); + const payloadHash = input.calculatePayloadHash(envelope); + + if (existingReceipt !== undefined) { + if (existingReceipt.payloadHash !== payloadHash) { + return { + status: EventIngestionOutcomeStatus.PayloadConflict, + reactionPlans: [], + }; + } + + if (isCompletedReceipt(existingReceipt)) { + return { + status: EventIngestionOutcomeStatus.Duplicate, + reactionPlans: [], + }; + } + } + + const observedAt = input.now(); + const receipt: InboxReceiptRecord = { + ...lookup, + firstSeenAt: existingReceipt?.firstSeenAt ?? observedAt, + payloadHash, + }; + + const reactionPlans = input.evaluateRules(envelope).map((action) => ({ + reactionPlanId: input.createId("reaction-plan"), + consumerName: input.consumerName, + createdAt: observedAt, + status: ReactionPlanStatus.Planned, + action, + })); + + const persistenceResult = await input.store.recordEventProcessingOutcome({ + receipt, + reactionPlans, + processedAt: observedAt, + result: EventIngestionOutcomeStatus.Processed, + }); + + return { + status: persistenceResult.status, + reactionPlans: persistenceResult.reactionPlans, + }; + }, + }; +} + +function isCompletedReceipt(receipt: InboxReceiptRecord): boolean { + return receipt.processedAt !== undefined && receipt.result !== undefined; +} diff --git a/agentic-organization/packages/runtime/src/index.ts b/agentic-organization/packages/runtime/src/index.ts index d531ba46e4..fba7a3e308 100644 --- a/agentic-organization/packages/runtime/src/index.ts +++ b/agentic-organization/packages/runtime/src/index.ts @@ -1,3 +1,12 @@ +export { + createEventIngestionProcessor, + type CreateEventIngestionProcessorInput, + type EventIngestionProcessor, + type EventIngestionResult, + type EventPayloadHashCalculator, + type EventRuleEvaluator, + type IngestEventInput, +} from "./event-ingestion.ts"; export { ReactionPlanActionType, ReactionPlanReason, diff --git a/agentic-organization/packages/runtime/src/reaction-plan.ts b/agentic-organization/packages/runtime/src/reaction-plan.ts index 091324ca45..b8ce268d7c 100644 --- a/agentic-organization/packages/runtime/src/reaction-plan.ts +++ b/agentic-organization/packages/runtime/src/reaction-plan.ts @@ -1,41 +1,14 @@ -import { AgenticEventType, SupervisorChainLevel, type AgenticEventEnvelope } from "../../domain/src/index.ts"; - -export const ReactionPlanActionType = { - CreateSupervisorTriage: "create_supervisor_triage", - RequestReviewGate: "request_review_gate", -} as const; - -export type ReactionPlanActionType = (typeof ReactionPlanActionType)[keyof typeof ReactionPlanActionType]; - -export const RequiredHat = { - CSuite: "c_suite", - Director: "director", - EngineeringManager: "engineering_manager", - ExecutiveBoard: "executive_board", - Reviewer: "reviewer", -} as const; - -export type RequiredHat = (typeof RequiredHat)[keyof typeof RequiredHat]; - -export const ReactionPlanReason = { - SupervisorSignalNeedsTriage: "supervisor signal needs triage", - WorkItemEnteredReadyState: "work item entered ready state", -} as const; - -export type ReactionPlanReason = (typeof ReactionPlanReason)[keyof typeof ReactionPlanReason]; - -export type ReactionPlanAction = { - actionType: ReactionPlanActionType; - triggerEventId: string; - organizationId: string; - projectId: string; - teamId?: string; - workItemId: string; - supervisorSignalId?: string; - targetLevel?: SupervisorChainLevel; - requiredHat: RequiredHat; - reason: ReactionPlanReason; -}; +import { + AgenticEventType, + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + SupervisorChainLevel, + type AgenticEventEnvelope, + type ReactionPlanAction, +} from "../../domain/src/index.ts"; + +export { ReactionPlanActionType, ReactionPlanReason, RequiredHat, type ReactionPlanAction }; type SupervisorSignalSentPayload = { targetHatAssignmentId: string; diff --git a/agentic-organization/packages/runtime/src/event-automation.test.ts b/agentic-organization/packages/runtime/test/event-automation.test.ts similarity index 94% rename from agentic-organization/packages/runtime/src/event-automation.test.ts rename to agentic-organization/packages/runtime/test/event-automation.test.ts index 79f15dd8fc..db552cf760 100644 --- a/agentic-organization/packages/runtime/src/event-automation.test.ts +++ b/agentic-organization/packages/runtime/test/event-automation.test.ts @@ -9,7 +9,12 @@ import { SupervisorSignalToolType, createAgenticEventEnvelope, } from "../../domain/src/index.ts"; -import { ReactionPlanActionType, ReactionPlanReason, RequiredHat, evaluateV0AutomationRules } from "./reaction-plan.ts"; +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + evaluateV0AutomationRules, +} from "../src/reaction-plan.ts"; describe("v0 event automation rules", () => { test("plans target-supervisor triage when a hat sends an upward signal", () => { diff --git a/agentic-organization/packages/runtime/test/event-ingestion.test.ts b/agentic-organization/packages/runtime/test/event-ingestion.test.ts new file mode 100644 index 0000000000..a77d2eabc9 --- /dev/null +++ b/agentic-organization/packages/runtime/test/event-ingestion.test.ts @@ -0,0 +1,216 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + AgenticAggregateType, + AgenticEventType, + ReactionPlanStatus, + SupervisorChainLevel, + SupervisorSignalStatus, + SupervisorSignalToolType, + createAgenticEventEnvelope, +} from "../../domain/src/index.ts"; +import { + EventIngestionOutcomeStatus, + type EventIngestionStore, + InboundEventConsumerName, + type RecordEventProcessingOutcomeInput, + createInMemoryEventIngestionStore, +} from "../../state/src/index.ts"; +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + createEventIngestionProcessor, + evaluateV0AutomationRules, +} from "../src/index.ts"; + +describe("event ingestion processor", () => { + test("records an inbox receipt and persists reaction plans for a new event", async () => { + const store = createInMemoryEventIngestionStore(); + const processor = createEventIngestionProcessor({ + store, + evaluateRules: evaluateV0AutomationRules, + consumerName: InboundEventConsumerName.V0AutomationPlanner, + calculatePayloadHash: (envelope) => `hash-${envelope.eventId}`, + now: () => "2026-05-25T22:00:00.000Z", + createId: (prefix) => `${prefix}-001`, + }); + + const result = await processor.ingest({ + envelope: createSupervisorSignalEnvelope(), + }); + + equal(result.status, EventIngestionOutcomeStatus.Processed); + equal(result.reactionPlans.length, 1); + deepEqual(store.snapshot.inboxReceipts, [ + { + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + firstSeenAt: "2026-05-25T22:00:00.000Z", + processedAt: "2026-05-25T22:00:00.000Z", + payloadHash: "hash-evt-supervisor-signal-001", + result: EventIngestionOutcomeStatus.Processed, + }, + ]); + deepEqual(store.snapshot.reactionPlans, [ + { + reactionPlanId: "reaction-plan-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + createdAt: "2026-05-25T22:00:00.000Z", + status: ReactionPlanStatus.Planned, + action: { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-supervisor-signal-001", + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + workItemId: "work-outbox-001", + supervisorSignalId: "supervisor-signal-001", + targetLevel: SupervisorChainLevel.Manager, + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + }, + }, + ]); + }); + + test("dedupes replayed events before rule evaluation side effects", async () => { + const store = createInMemoryEventIngestionStore(); + let evaluationCount = 0; + const processor = createEventIngestionProcessor({ + store, + evaluateRules: (envelope) => { + evaluationCount += 1; + return evaluateV0AutomationRules(envelope); + }, + consumerName: InboundEventConsumerName.V0AutomationPlanner, + calculatePayloadHash: (eventEnvelope) => `hash-${eventEnvelope.eventId}`, + now: () => "2026-05-25T22:00:00.000Z", + createId: (prefix) => `${prefix}-${evaluationCount}`, + }); + + const envelope = createSupervisorSignalEnvelope(); + const firstResult = await processor.ingest({ + envelope, + }); + const replayResult = await processor.ingest({ + envelope, + }); + + equal(firstResult.status, EventIngestionOutcomeStatus.Processed); + equal(replayResult.status, EventIngestionOutcomeStatus.Duplicate); + equal(evaluationCount, 1); + equal(store.snapshot.inboxReceipts.length, 1); + equal(store.snapshot.reactionPlans.length, 1); + }); + + test("rejects same event ID with a different payload hash", async () => { + const store = createInMemoryEventIngestionStore(); + let evaluationCount = 0; + const processor = createEventIngestionProcessor({ + store, + evaluateRules: (envelope) => { + evaluationCount += 1; + return evaluateV0AutomationRules(envelope); + }, + consumerName: InboundEventConsumerName.V0AutomationPlanner, + calculatePayloadHash: (eventEnvelope) => `hash-${eventEnvelope.payload.title}`, + now: () => "2026-05-25T22:00:00.000Z", + createId: (prefix) => `${prefix}-${evaluationCount}`, + }); + + const firstResult = await processor.ingest({ + envelope: createSupervisorSignalEnvelope(), + }); + const conflictResult = await processor.ingest({ + envelope: createSupervisorSignalEnvelope("Different payload"), + }); + + equal(firstResult.status, EventIngestionOutcomeStatus.Processed); + equal(conflictResult.status, EventIngestionOutcomeStatus.PayloadConflict); + equal(evaluationCount, 1); + equal(store.snapshot.inboxReceipts.length, 1); + equal(store.snapshot.reactionPlans.length, 1); + }); + + test("retries an unprocessed inbox receipt instead of treating it as duplicate", async () => { + let evaluationCount = 0; + let recordedOutcome: RecordEventProcessingOutcomeInput | undefined; + const store: EventIngestionStore = { + findInboxReceipt: async () => ({ + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + firstSeenAt: "2026-05-25T21:59:00.000Z", + payloadHash: "hash-evt-supervisor-signal-001", + }), + recordEventProcessingOutcome: async (input) => { + recordedOutcome = input; + + return { + status: input.result, + reactionPlans: input.reactionPlans, + }; + }, + }; + const processor = createEventIngestionProcessor({ + store, + evaluateRules: (envelope) => { + evaluationCount += 1; + return evaluateV0AutomationRules(envelope); + }, + consumerName: InboundEventConsumerName.V0AutomationPlanner, + calculatePayloadHash: (eventEnvelope) => `hash-${eventEnvelope.eventId}`, + now: () => "2026-05-25T22:00:00.000Z", + createId: (prefix) => `${prefix}-${evaluationCount}`, + }); + + const result = await processor.ingest({ + envelope: createSupervisorSignalEnvelope(), + }); + + equal(result.status, EventIngestionOutcomeStatus.Processed); + equal(evaluationCount, 1); + equal(recordedOutcome?.result, EventIngestionOutcomeStatus.Processed); + equal(recordedOutcome?.receipt.firstSeenAt, "2026-05-25T21:59:00.000Z"); + equal(recordedOutcome?.reactionPlans.length, 1); + }); +}); + +function createSupervisorSignalEnvelope(title = "Blocked on scoped NATS publisher") { + return createAgenticEventEnvelope({ + eventId: "evt-supervisor-signal-001", + eventType: AgenticEventType.SupervisorSignalSent, + occurredAt: "2026-05-25T20:00:00.000Z", + actor: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + scope: { + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + workItemId: "work-outbox-001", + }, + aggregate: { + aggregateId: "supervisor-signal-001", + aggregateType: AgenticAggregateType.SupervisorSignal, + aggregateVersion: 1, + }, + trace: { + commandId: "cmd-supervisor-signal-001", + correlationId: "corr-supervisor-signal-001", + causationId: "cause-team-work-001", + traceId: "trace-supervisor-signal-001", + idempotencyKey: "idem-supervisor-signal-001", + }, + payload: { + sourceLevel: SupervisorChainLevel.TeamMember, + targetLevel: SupervisorChainLevel.Manager, + targetHatAssignmentId: "hat-assignment-em-001", + toolType: SupervisorSignalToolType.ReportBlocker, + status: SupervisorSignalStatus.Sent, + title, + }, + }); +} diff --git a/agentic-organization/packages/state-cockroach/migrations/0001_agentic_org_core_state.sql b/agentic-organization/packages/state-cockroach/migrations/0001_agentic_org_core_state.sql index 6cbc38010b..df24b2470c 100644 --- a/agentic-organization/packages/state-cockroach/migrations/0001_agentic_org_core_state.sql +++ b/agentic-organization/packages/state-cockroach/migrations/0001_agentic_org_core_state.sql @@ -52,6 +52,28 @@ CREATE TABLE IF NOT EXISTS agentic_org_outbox_events ( published_at TIMESTAMPTZ ); +CREATE TABLE IF NOT EXISTS agentic_org_inbox_receipts ( + event_id STRING NOT NULL, + consumer_name STRING NOT NULL, + first_seen_at TIMESTAMPTZ NOT NULL, + processed_at TIMESTAMPTZ, + payload_hash STRING NOT NULL, + result STRING, + PRIMARY KEY (event_id, consumer_name) +); + +CREATE TABLE IF NOT EXISTS agentic_org_reaction_plans ( + reaction_plan_id STRING PRIMARY KEY, + consumer_name STRING NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + status STRING NOT NULL, + trigger_event_id STRING NOT NULL, + organization_id STRING NOT NULL, + project_id STRING NOT NULL, + work_item_id STRING NOT NULL, + action_json JSONB NOT NULL +); + CREATE TABLE IF NOT EXISTS agentic_org_idempotency_records ( idempotency_key STRING PRIMARY KEY, request_hash STRING NOT NULL, diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.test.ts b/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.test.ts deleted file mode 100644 index 74508ce2dd..0000000000 --- a/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { deepEqual, equal } from "node:assert/strict"; -import { describe, test } from "node:test"; - -import { CommandResultStatus, type CommandResult } from "../../application/src/index.ts"; -import { - AgenticAggregateType, - AgenticEventType, - SupervisorChainLevel, - SupervisorSignalStatus, - SupervisorSignalToolType, -} from "../../domain/src/index.ts"; -import { - CockroachCommandStateStoreStatement, - createCockroachCommandStateStoreFactory, - type CockroachSqlExecutor, -} from "./cockroach-command-state-store.ts"; - -describe("cockroach command state store", () => { - test("implements command-state-store operations behind a SQL executor", async () => { - const executor = createRecordingExecutor(); - const factory = createCockroachCommandStateStoreFactory({ - executor, - }); - const store = factory.createCommandStateStore(); - - equal(await store.findIdempotencyRecord("idem-001"), undefined); - - await store.appendSupervisorSignal({ - supervisorSignalId: "supervisor-signal-001", - organizationId: "org-lfg", - projectId: "project-agentic-org", - teamId: "team-runtime", - sourceLevel: SupervisorChainLevel.TeamMember, - targetLevel: SupervisorChainLevel.Manager, - targetHatAssignmentId: "hat-assignment-em-001", - sender: { - agentId: "agent-developer-001", - hatAssignmentId: "hat-assignment-dev-001", - }, - toolType: SupervisorSignalToolType.ReportBlocker, - status: SupervisorSignalStatus.Sent, - title: "Blocked on scoped NATS publisher", - message: "Need a scoped publisher decision.", - relatedWorkItemId: "work-outbox-001", - createdAt: "2026-05-25T20:00:00.000Z", - }); - - await store.appendAuditEvent({ - auditEventId: "audit-001", - eventName: AgenticEventType.SupervisorSignalSent, - aggregateId: "supervisor-signal-001", - actor: { - agentId: "agent-developer-001", - hatAssignmentId: "hat-assignment-dev-001", - }, - occurredAt: "2026-05-25T20:00:00.000Z", - }); - - await store.appendOutboxEvent({ - outboxEventId: "outbox-001", - envelope: { - eventId: "evt-001", - eventType: AgenticEventType.SupervisorSignalSent, - schemaVersion: "agentic.org.event.v1", - occurredAt: "2026-05-25T20:00:00.000Z", - actor: { - agentId: "agent-developer-001", - hatAssignmentId: "hat-assignment-dev-001", - }, - scope: { - organizationId: "org-lfg", - projectId: "project-agentic-org", - teamId: "team-runtime", - workItemId: "work-outbox-001", - }, - aggregate: { - aggregateId: "supervisor-signal-001", - aggregateType: AgenticAggregateType.SupervisorSignal, - aggregateVersion: 1, - }, - trace: { - commandId: "cmd-001", - correlationId: "corr-001", - causationId: "cause-001", - traceId: "trace-001", - idempotencyKey: "idem-001", - }, - replay: { - isReplay: false, - }, - payload: { - title: "Blocked on scoped NATS publisher", - }, - }, - }); - - await store.saveIdempotencyRecord({ - idempotencyKey: "idem-001", - requestHash: "hash-001", - result: { - status: CommandResultStatus.Accepted, - idempotency: { - replayed: false, - }, - }, - }); - - deepEqual( - executor.statements.map((statement) => statement.name), - [ - CockroachCommandStateStoreStatement.FindIdempotencyRecord, - CockroachCommandStateStoreStatement.InsertSupervisorSignal, - CockroachCommandStateStoreStatement.InsertAuditEvent, - CockroachCommandStateStoreStatement.InsertOutboxEvent, - CockroachCommandStateStoreStatement.UpsertIdempotencyRecord, - ], - ); - }); -}); - -type RecordingCockroachSqlExecutor = CockroachSqlExecutor & { - statements: { name: CockroachCommandStateStoreStatement; parameters: readonly unknown[] }[]; -}; - -function createRecordingExecutor(): RecordingCockroachSqlExecutor { - const statements: { name: CockroachCommandStateStoreStatement; parameters: readonly unknown[] }[] = []; - - return { - statements, - execute: async (statement) => { - statements.push(statement); - return { - rows: [], - }; - }, - }; -} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.ts b/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.ts index 93403076a6..abfe60dc44 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.ts +++ b/agentic-organization/packages/state-cockroach/src/cockroach-command-state-store.ts @@ -1,9 +1,13 @@ -import type { CommandStateStore, CommandStateStoreFactory } from "../../application/src/ports.ts"; +import { + CommandOutcomePersistenceStatus, + type CommandStateStore, + type CommandStateStoreFactory, +} from "../../application/src/ports.ts"; import { CockroachTableName } from "./cockroach-schema.ts"; export const CockroachCommandStateStoreStatement = { FindIdempotencyRecord: "find_idempotency_record", - UpsertIdempotencyRecord: "upsert_idempotency_record", + ClaimIdempotencyRecord: "claim_idempotency_record", InsertSupervisorSignal: "insert_supervisor_signal", InsertAuditEvent: "insert_audit_event", InsertOutboxEvent: "insert_outbox_event", @@ -22,8 +26,15 @@ export type CockroachSqlResult> = { rows: readonly Row[]; }; +export type CockroachSqlTransactionExecutor = { + execute: >(statement: CockroachSqlStatement) => Promise>; +}; + export type CockroachSqlExecutor = { execute: >(statement: CockroachSqlStatement) => Promise>; + executeTransaction: ( + operation: (executor: CockroachSqlTransactionExecutor) => Promise, + ) => Promise; }; export type CreateCockroachCommandStateStoreFactoryInput = { @@ -58,88 +69,167 @@ function createCockroachCommandStateStore(executor: CockroachSqlExecutor result: row.result_json as Result, }; }, - saveIdempotencyRecord: async (record) => { - await executor.execute({ - name: CockroachCommandStateStoreStatement.UpsertIdempotencyRecord, - sql: CockroachCommandStateStoreSql.UpsertIdempotencyRecord, - parameters: [record.idempotencyKey, record.requestHash, record.result], - }); - }, - appendSupervisorSignal: async (supervisorSignal) => { - await executor.execute({ - name: CockroachCommandStateStoreStatement.InsertSupervisorSignal, - sql: CockroachCommandStateStoreSql.InsertSupervisorSignal, - parameters: [ - supervisorSignal.supervisorSignalId, - supervisorSignal.organizationId, - supervisorSignal.projectId, - supervisorSignal.teamId, - supervisorSignal.sourceLevel, - supervisorSignal.targetLevel, - supervisorSignal.targetHatAssignmentId, - supervisorSignal.sender.agentId, - supervisorSignal.sender.hatAssignmentId, - supervisorSignal.toolType, - supervisorSignal.status, - supervisorSignal.title, - supervisorSignal.message, - supervisorSignal.relatedWorkItemId, - supervisorSignal.createdAt, - ], - }); - }, - appendAuditEvent: async (auditEvent) => { - await executor.execute({ - name: CockroachCommandStateStoreStatement.InsertAuditEvent, - sql: CockroachCommandStateStoreSql.InsertAuditEvent, - parameters: [ - auditEvent.auditEventId, - auditEvent.eventName, - auditEvent.aggregateId, - auditEvent.actor.agentId, - auditEvent.actor.hatAssignmentId, - auditEvent.occurredAt, - ], - }); - }, - appendOutboxEvent: async (outboxEvent) => { - await executor.execute({ - name: CockroachCommandStateStoreStatement.InsertOutboxEvent, - sql: CockroachCommandStateStoreSql.InsertOutboxEvent, - parameters: [ - outboxEvent.outboxEventId, - outboxEvent.envelope.eventId, - outboxEvent.envelope.eventType, - outboxEvent.envelope.scope.organizationId, - outboxEvent.envelope.scope.projectId, - outboxEvent.envelope.scope.workItemId, - outboxEvent.envelope.trace.traceId, - outboxEvent.envelope.trace.correlationId, - outboxEvent.envelope, - ], + recordCommandOutcome: async (outcome) => { + return await executor.executeTransaction(async (transaction) => { + const claimResult = await transaction.execute>({ + name: CockroachCommandStateStoreStatement.ClaimIdempotencyRecord, + sql: CockroachCommandStateStoreSql.ClaimIdempotencyRecord, + parameters: [ + outcome.idempotencyRecord.idempotencyKey, + outcome.idempotencyRecord.requestHash, + outcome.idempotencyRecord.result, + ], + }); + const claim = claimResult.rows[0]; + const claimStatus = claim?.persistence_status ?? CommandOutcomePersistenceStatus.IdempotencyConflict; + + if (claimStatus === CommandOutcomePersistenceStatus.Replayed) { + return { + status: CommandOutcomePersistenceStatus.Replayed, + result: claim?.result_json as Result, + }; + } + + if (claimStatus === CommandOutcomePersistenceStatus.IdempotencyConflict) { + if (claim?.request_hash === undefined || claim.request_hash === null) { + return { + status: CommandOutcomePersistenceStatus.IdempotencyConflict, + }; + } + + return { + status: CommandOutcomePersistenceStatus.IdempotencyConflict, + existingRequestHash: claim.request_hash, + }; + } + + for (const supervisorSignal of outcome.effects.supervisorSignals) { + await transaction.execute(createInsertSupervisorSignalStatement(supervisorSignal)); + } + + for (const auditEvent of outcome.effects.auditEvents) { + await transaction.execute(createInsertAuditEventStatement(auditEvent)); + } + + for (const outboxEvent of outcome.effects.outboxEvents) { + await transaction.execute(createInsertOutboxEventStatement(outboxEvent)); + } + + return { + status: CommandOutcomePersistenceStatus.Committed, + result: outcome.idempotencyRecord.result, + }; }); }, }; } +type CommandStateStoreResult = Parameters["recordCommandOutcome"]>[0]; + +function createInsertSupervisorSignalStatement( + supervisorSignal: CommandStateStoreResult["effects"]["supervisorSignals"][number], +): CockroachSqlStatement { + return { + name: CockroachCommandStateStoreStatement.InsertSupervisorSignal, + sql: CockroachCommandStateStoreSql.InsertSupervisorSignal, + parameters: [ + supervisorSignal.supervisorSignalId, + supervisorSignal.organizationId, + supervisorSignal.projectId, + supervisorSignal.teamId, + supervisorSignal.sourceLevel, + supervisorSignal.targetLevel, + supervisorSignal.targetHatAssignmentId, + supervisorSignal.sender.agentId, + supervisorSignal.sender.hatAssignmentId, + supervisorSignal.toolType, + supervisorSignal.status, + supervisorSignal.title, + supervisorSignal.message, + supervisorSignal.relatedWorkItemId, + supervisorSignal.createdAt, + ], + }; +} + +function createInsertAuditEventStatement( + auditEvent: CommandStateStoreResult["effects"]["auditEvents"][number], +): CockroachSqlStatement { + return { + name: CockroachCommandStateStoreStatement.InsertAuditEvent, + sql: CockroachCommandStateStoreSql.InsertAuditEvent, + parameters: [ + auditEvent.auditEventId, + auditEvent.eventName, + auditEvent.aggregateId, + auditEvent.actor.agentId, + auditEvent.actor.hatAssignmentId, + auditEvent.occurredAt, + ], + }; +} + +function createInsertOutboxEventStatement( + outboxEvent: CommandStateStoreResult["effects"]["outboxEvents"][number], +): CockroachSqlStatement { + return { + name: CockroachCommandStateStoreStatement.InsertOutboxEvent, + sql: CockroachCommandStateStoreSql.InsertOutboxEvent, + parameters: [ + outboxEvent.outboxEventId, + outboxEvent.envelope.eventId, + outboxEvent.envelope.eventType, + outboxEvent.envelope.scope.organizationId, + outboxEvent.envelope.scope.projectId, + outboxEvent.envelope.scope.workItemId, + outboxEvent.envelope.trace.traceId, + outboxEvent.envelope.trace.correlationId, + outboxEvent.envelope, + ], + }; +} + type IdempotencyRecordRow = { idempotency_key: string; request_hash: string; result_json: unknown; }; +type CockroachIdempotencyClaimRow = { + persistence_status: CommandOutcomePersistenceStatus; + request_hash?: string | null; + result_json?: Result | null; +}; + const CockroachCommandStateStoreSql = { FindIdempotencyRecord: ` SELECT idempotency_key, request_hash, result_json FROM ${CockroachTableName.IdempotencyRecords} WHERE idempotency_key = $1 `, - UpsertIdempotencyRecord: ` - UPSERT INTO ${CockroachTableName.IdempotencyRecords} ( - idempotency_key, - request_hash, - result_json - ) VALUES ($1, $2, $3) + ClaimIdempotencyRecord: ` + WITH claimed_record AS ( + INSERT INTO ${CockroachTableName.IdempotencyRecords} ( + idempotency_key, + request_hash, + result_json + ) VALUES ($1, $2, $3) + ON CONFLICT (idempotency_key) DO NOTHING + RETURNING request_hash, result_json + ), + existing_record AS ( + SELECT request_hash, result_json + FROM ${CockroachTableName.IdempotencyRecords} + WHERE idempotency_key = $1 + ) + SELECT + CASE + WHEN EXISTS (SELECT 1 FROM claimed_record) THEN '${CommandOutcomePersistenceStatus.Committed}' + WHEN EXISTS (SELECT 1 FROM existing_record WHERE request_hash = $2) THEN '${CommandOutcomePersistenceStatus.Replayed}' + ELSE '${CommandOutcomePersistenceStatus.IdempotencyConflict}' + END AS persistence_status, + (SELECT request_hash FROM existing_record) AS request_hash, + (SELECT result_json FROM existing_record) AS result_json `, InsertSupervisorSignal: ` INSERT INTO ${CockroachTableName.SupervisorSignals} ( diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-event-ingestion-store.ts b/agentic-organization/packages/state-cockroach/src/cockroach-event-ingestion-store.ts new file mode 100644 index 0000000000..577955cc37 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-event-ingestion-store.ts @@ -0,0 +1,236 @@ +import { + EventIngestionOutcomeStatus, + type EventIngestionStore, + type InboxReceiptRecord, + type ReactionPlanRecord, +} from "../../state/src/index.ts"; +import { CockroachTableName } from "./cockroach-schema.ts"; + +export const CockroachEventIngestionStoreStatement = { + FindInboxReceipt: "find_inbox_receipt", + ClaimPendingInboxReceipt: "claim_pending_inbox_receipt", + InsertReactionPlan: "insert_reaction_plan", + MarkInboxReceiptProcessed: "mark_inbox_receipt_processed", +} as const; + +export type CockroachEventIngestionStoreStatement = + (typeof CockroachEventIngestionStoreStatement)[keyof typeof CockroachEventIngestionStoreStatement]; + +export type CockroachEventIngestionSqlStatement = { + name: CockroachEventIngestionStoreStatement; + sql: string; + parameters: readonly unknown[]; +}; + +export type CockroachEventIngestionSqlResult> = { + rows: readonly Row[]; +}; + +export type CockroachEventIngestionTransactionExecutor = { + execute: >( + statement: CockroachEventIngestionSqlStatement, + ) => Promise>; +}; + +export type CockroachEventIngestionSqlExecutor = { + execute: >( + statement: CockroachEventIngestionSqlStatement, + ) => Promise>; + executeTransaction: ( + operation: (executor: CockroachEventIngestionTransactionExecutor) => Promise, + ) => Promise; +}; + +export type CreateCockroachEventIngestionStoreInput = { + executor: CockroachEventIngestionSqlExecutor; +}; + +export function createCockroachEventIngestionStore( + input: CreateCockroachEventIngestionStoreInput, +): EventIngestionStore { + return { + findInboxReceipt: async (lookup) => { + const result = await input.executor.execute({ + name: CockroachEventIngestionStoreStatement.FindInboxReceipt, + sql: CockroachEventIngestionStoreSql.FindInboxReceipt, + parameters: [lookup.eventId, lookup.consumerName], + }); + const row = result.rows[0]; + + if (row === undefined) { + return undefined; + } + + return { + eventId: row.event_id, + consumerName: row.consumer_name, + firstSeenAt: row.first_seen_at, + payloadHash: row.payload_hash, + ...(row.processed_at == null ? {} : { processedAt: row.processed_at }), + ...(row.result == null ? {} : { result: row.result }), + }; + }, + recordEventProcessingOutcome: async (outcome) => { + const receipt = outcome.receipt; + + try { + return await input.executor.executeTransaction(async (transaction) => { + const claimResult = await transaction.execute({ + name: CockroachEventIngestionStoreStatement.ClaimPendingInboxReceipt, + sql: CockroachEventIngestionStoreSql.ClaimPendingInboxReceipt, + parameters: [receipt.eventId, receipt.consumerName, receipt.firstSeenAt, receipt.payloadHash], + }); + const claimStatus = claimResult.rows[0]?.claim_status ?? EventIngestionOutcomeStatus.PayloadConflict; + + if (claimStatus !== EventIngestionOutcomeStatus.Processed) { + return { + status: claimStatus, + reactionPlans: [], + }; + } + + for (const reactionPlan of outcome.reactionPlans) { + await transaction.execute(createInsertReactionPlanStatement(reactionPlan)); + } + + const markResult = await transaction.execute({ + name: CockroachEventIngestionStoreStatement.MarkInboxReceiptProcessed, + sql: CockroachEventIngestionStoreSql.MarkInboxReceiptProcessed, + parameters: [ + receipt.eventId, + receipt.consumerName, + outcome.processedAt, + outcome.result, + receipt.payloadHash, + ], + }); + + if (markResult.rows.length !== 1) { + throw new CockroachInboxReceiptCompletionLostError(); + } + + return { + status: outcome.result, + reactionPlans: outcome.reactionPlans, + }; + }); + } catch (error) { + if (error instanceof CockroachInboxReceiptCompletionLostError) { + return { + status: EventIngestionOutcomeStatus.Duplicate, + reactionPlans: [], + }; + } + + throw error; + } + }, + }; +} + +function createInsertReactionPlanStatement(reactionPlan: ReactionPlanRecord): CockroachEventIngestionSqlStatement { + return { + name: CockroachEventIngestionStoreStatement.InsertReactionPlan, + sql: CockroachEventIngestionStoreSql.InsertReactionPlan, + parameters: [ + reactionPlan.reactionPlanId, + reactionPlan.consumerName, + reactionPlan.createdAt, + reactionPlan.status, + reactionPlan.action.triggerEventId, + reactionPlan.action.organizationId, + reactionPlan.action.projectId, + reactionPlan.action.workItemId, + reactionPlan.action, + ], + }; +} + +type InboxReceiptRow = { + event_id: string; + consumer_name: InboxReceiptRecord["consumerName"]; + first_seen_at: string; + processed_at?: string | null; + payload_hash: string; + result?: InboxReceiptRecord["result"] | null; +}; + +type CockroachReceiptClaimRow = { + claim_status: EventIngestionOutcomeStatus; +}; + +type CockroachMarkedInboxReceiptRow = { + event_id: string; +}; + +class CockroachInboxReceiptCompletionLostError extends Error { + constructor() { + super("inbox receipt completion lost its claim"); + } +} + +const CockroachEventIngestionStoreSql = { + FindInboxReceipt: ` + SELECT event_id, consumer_name, first_seen_at, processed_at, payload_hash, result + FROM ${CockroachTableName.InboxReceipts} + WHERE event_id = $1 + AND consumer_name = $2 + `, + ClaimPendingInboxReceipt: ` + WITH claimed_receipt AS ( + INSERT INTO ${CockroachTableName.InboxReceipts} ( + event_id, + consumer_name, + first_seen_at, + payload_hash + ) VALUES ($1, $2, $3, $4) + ON CONFLICT (event_id, consumer_name) DO UPDATE + SET payload_hash = excluded.payload_hash + WHERE ${CockroachTableName.InboxReceipts}.payload_hash = excluded.payload_hash + AND ${CockroachTableName.InboxReceipts}.processed_at IS NULL + AND ${CockroachTableName.InboxReceipts}.result IS NULL + RETURNING event_id + ) + SELECT + CASE + WHEN EXISTS (SELECT 1 FROM claimed_receipt) THEN '${EventIngestionOutcomeStatus.Processed}' + WHEN EXISTS ( + SELECT 1 + FROM ${CockroachTableName.InboxReceipts} + WHERE event_id = $1 + AND consumer_name = $2 + AND payload_hash = $4 + AND processed_at IS NOT NULL + AND result IS NOT NULL + ) THEN '${EventIngestionOutcomeStatus.Duplicate}' + ELSE '${EventIngestionOutcomeStatus.PayloadConflict}' + END AS claim_status + `, + InsertReactionPlan: ` + INSERT INTO ${CockroachTableName.ReactionPlans} ( + reaction_plan_id, + consumer_name, + created_at, + status, + trigger_event_id, + organization_id, + project_id, + work_item_id, + action_json + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + MarkInboxReceiptProcessed: ` + UPDATE ${CockroachTableName.InboxReceipts} + SET + processed_at = $3, + result = $4 + WHERE event_id = $1 + AND consumer_name = $2 + AND payload_hash = $5 + AND processed_at IS NULL + AND result IS NULL + RETURNING event_id + `, +} as const; + +export type { ReactionPlanRecord }; diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts b/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts index b1e0381d8d..042d2106cb 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts +++ b/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts @@ -9,7 +9,9 @@ export const CockroachTableName = { WorkItems: "agentic_org_work_items", SupervisorSignals: "agentic_org_supervisor_signals", AuditEvents: "agentic_org_audit_events", + InboxReceipts: "agentic_org_inbox_receipts", OutboxEvents: "agentic_org_outbox_events", + ReactionPlans: "agentic_org_reaction_plans", IdempotencyRecords: "agentic_org_idempotency_records", } as const; @@ -28,6 +30,8 @@ export function createCockroachCoreStateMigration(): CockroachSchemaMigration { createSupervisorSignalsTableSql(), createAuditEventsTableSql(), createOutboxEventsTableSql(), + createInboxReceiptsTableSql(), + createReactionPlansTableSql(), createIdempotencyRecordsTableSql(), ].join("\n\n"), }; @@ -107,3 +111,31 @@ CREATE TABLE IF NOT EXISTS ${CockroachTableName.IdempotencyRecords} ( result_json JSONB NOT NULL );`.trim(); } + +function createInboxReceiptsTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.InboxReceipts} ( + event_id STRING NOT NULL, + consumer_name STRING NOT NULL, + first_seen_at TIMESTAMPTZ NOT NULL, + processed_at TIMESTAMPTZ, + payload_hash STRING NOT NULL, + result STRING, + PRIMARY KEY (event_id, consumer_name) +);`.trim(); +} + +function createReactionPlansTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.ReactionPlans} ( + reaction_plan_id STRING PRIMARY KEY, + consumer_name STRING NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + status STRING NOT NULL, + trigger_event_id STRING NOT NULL, + organization_id STRING NOT NULL, + project_id STRING NOT NULL, + work_item_id STRING NOT NULL, + action_json JSONB NOT NULL +);`.trim(); +} diff --git a/agentic-organization/packages/state-cockroach/src/index.ts b/agentic-organization/packages/state-cockroach/src/index.ts index bf795c6d1b..f31bcadcbe 100644 --- a/agentic-organization/packages/state-cockroach/src/index.ts +++ b/agentic-organization/packages/state-cockroach/src/index.ts @@ -14,6 +14,14 @@ export { type CockroachOutboxSqlStatement, type CreateCockroachOutboxEventSourceInput, } from "./cockroach-outbox-event-source.ts"; +export { + CockroachEventIngestionStoreStatement, + createCockroachEventIngestionStore, + type CockroachEventIngestionSqlExecutor, + type CockroachEventIngestionSqlResult, + type CockroachEventIngestionSqlStatement, + type CreateCockroachEventIngestionStoreInput, +} from "./cockroach-event-ingestion-store.ts"; export { CockroachCoreStateMigrationName, CockroachTableName, diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-command-state-store.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-command-state-store.test.ts new file mode 100644 index 0000000000..10ba197230 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-command-state-store.test.ts @@ -0,0 +1,238 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + CommandOutcomePersistenceStatus, + CommandResultStatus, + type CommandResult, + type RecordCommandOutcomeInput, +} from "../../application/src/index.ts"; +import { + AgenticAggregateType, + AgenticEventType, + SupervisorChainLevel, + SupervisorSignalStatus, + SupervisorSignalToolType, +} from "../../domain/src/index.ts"; +import { + CockroachCommandStateStoreStatement, + createCockroachCommandStateStoreFactory, + type CockroachSqlExecutor, +} from "../src/cockroach-command-state-store.ts"; + +describe("cockroach command state store", () => { + test("records command outcome in one transaction batch", async () => { + const executor = createRecordingExecutor(); + const factory = createCockroachCommandStateStoreFactory({ + executor, + }); + const store = factory.createCommandStateStore(); + + equal(await store.findIdempotencyRecord("idem-001"), undefined); + + const result = await store.recordCommandOutcome(createCommandOutcome()); + + equal(result.status, CommandOutcomePersistenceStatus.Committed); + deepEqual( + executor.statements.map((statement) => statement.name), + [ + CockroachCommandStateStoreStatement.FindIdempotencyRecord, + CockroachCommandStateStoreStatement.ClaimIdempotencyRecord, + CockroachCommandStateStoreStatement.InsertSupervisorSignal, + CockroachCommandStateStoreStatement.InsertAuditEvent, + CockroachCommandStateStoreStatement.InsertOutboxEvent, + ], + ); + deepEqual( + executor.transactionStatements.map((statement) => statement.name), + [ + CockroachCommandStateStoreStatement.ClaimIdempotencyRecord, + CockroachCommandStateStoreStatement.InsertSupervisorSignal, + CockroachCommandStateStoreStatement.InsertAuditEvent, + CockroachCommandStateStoreStatement.InsertOutboxEvent, + ], + ); + equal(executor.transactionStatements[0]?.sql.includes("INSERT INTO"), true); + equal(executor.transactionStatements[0]?.sql.includes("UPSERT"), false); + }); + + test("does not insert effects when idempotency claim replays or conflicts", async () => { + const replayExecutor = createRecordingExecutor({ + claimStatus: CommandOutcomePersistenceStatus.Replayed, + }); + const replayStore = createCockroachCommandStateStoreFactory({ + executor: replayExecutor, + }).createCommandStateStore(); + + const replayResult = await replayStore.recordCommandOutcome(createCommandOutcome()); + + equal(replayResult.status, CommandOutcomePersistenceStatus.Replayed); + deepEqual( + replayExecutor.transactionStatements.map((statement) => statement.name), + [CockroachCommandStateStoreStatement.ClaimIdempotencyRecord], + ); + + const conflictExecutor = createRecordingExecutor({ + claimStatus: CommandOutcomePersistenceStatus.IdempotencyConflict, + }); + const conflictStore = createCockroachCommandStateStoreFactory({ + executor: conflictExecutor, + }).createCommandStateStore(); + + const conflictResult = await conflictStore.recordCommandOutcome(createCommandOutcome()); + + equal(conflictResult.status, CommandOutcomePersistenceStatus.IdempotencyConflict); + deepEqual( + conflictExecutor.transactionStatements.map((statement) => statement.name), + [CockroachCommandStateStoreStatement.ClaimIdempotencyRecord], + ); + }); +}); + +type RecordingCockroachSqlExecutor = CockroachSqlExecutor & { + statements: { name: CockroachCommandStateStoreStatement; sql: string; parameters: readonly unknown[] }[]; + transactionStatements: { name: CockroachCommandStateStoreStatement; sql: string; parameters: readonly unknown[] }[]; +}; + +function createRecordingExecutor( + input: { claimStatus?: CommandOutcomePersistenceStatus } = {}, +): RecordingCockroachSqlExecutor { + const statements: { name: CockroachCommandStateStoreStatement; sql: string; parameters: readonly unknown[] }[] = []; + const transactionStatements: { + name: CockroachCommandStateStoreStatement; + sql: string; + parameters: readonly unknown[]; + }[] = []; + + return { + statements, + transactionStatements, + execute: async (statement) => { + statements.push(statement); + return { + rows: [], + }; + }, + executeTransaction: async (operation) => + await operation({ + execute: async >(statement: { + name: CockroachCommandStateStoreStatement; + sql: string; + parameters: readonly unknown[]; + }) => { + transactionStatements.push(statement); + statements.push(statement); + + if (statement.name === CockroachCommandStateStoreStatement.ClaimIdempotencyRecord) { + return { + rows: [ + { + persistence_status: input.claimStatus ?? CommandOutcomePersistenceStatus.Committed, + request_hash: "hash-001", + result_json: { + status: CommandResultStatus.Accepted, + idempotency: { + replayed: false, + }, + }, + }, + ] as readonly unknown[] as readonly Row[], + }; + } + + return { + rows: [], + }; + }, + }), + }; +} + +function createCommandOutcome(): RecordCommandOutcomeInput { + return { + idempotencyRecord: { + idempotencyKey: "idem-001", + requestHash: "hash-001", + result: { + status: CommandResultStatus.Accepted, + idempotency: { + replayed: false, + }, + }, + }, + effects: { + supervisorSignals: [ + { + supervisorSignalId: "supervisor-signal-001", + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + sourceLevel: SupervisorChainLevel.TeamMember, + targetLevel: SupervisorChainLevel.Manager, + targetHatAssignmentId: "hat-assignment-em-001", + sender: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + toolType: SupervisorSignalToolType.ReportBlocker, + status: SupervisorSignalStatus.Sent, + title: "Blocked on scoped NATS publisher", + message: "Need a scoped publisher decision.", + relatedWorkItemId: "work-outbox-001", + createdAt: "2026-05-25T20:00:00.000Z", + }, + ], + auditEvents: [ + { + auditEventId: "audit-001", + eventName: AgenticEventType.SupervisorSignalSent, + aggregateId: "supervisor-signal-001", + actor: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + occurredAt: "2026-05-25T20:00:00.000Z", + }, + ], + outboxEvents: [ + { + outboxEventId: "outbox-001", + envelope: { + eventId: "evt-001", + eventType: AgenticEventType.SupervisorSignalSent, + schemaVersion: "agentic.org.event.v1", + occurredAt: "2026-05-25T20:00:00.000Z", + actor: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + scope: { + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + workItemId: "work-outbox-001", + }, + aggregate: { + aggregateId: "supervisor-signal-001", + aggregateType: AgenticAggregateType.SupervisorSignal, + aggregateVersion: 1, + }, + trace: { + commandId: "cmd-001", + correlationId: "corr-001", + causationId: "cause-001", + traceId: "trace-001", + idempotencyKey: "idem-001", + }, + replay: { + isReplay: false, + }, + payload: { + title: "Blocked on scoped NATS publisher", + }, + }, + }, + ], + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-event-ingestion-store.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-event-ingestion-store.test.ts new file mode 100644 index 0000000000..2e882b3d5a --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-event-ingestion-store.test.ts @@ -0,0 +1,246 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { ReactionPlanActionType, ReactionPlanReason, ReactionPlanStatus, RequiredHat } from "../../domain/src/index.ts"; +import { + EventIngestionOutcomeStatus, + InboundEventConsumerName, + type ReactionPlanRecord, +} from "../../state/src/index.ts"; +import { + CockroachEventIngestionStoreStatement, + createCockroachEventIngestionStore, + type CockroachEventIngestionSqlExecutor, + type CockroachEventIngestionSqlStatement, +} from "../src/cockroach-event-ingestion-store.ts"; + +describe("cockroach event ingestion store", () => { + test("implements inbox receipt and reaction plan persistence behind a SQL executor", async () => { + const executor = createRecordingExecutor(); + const store = createCockroachEventIngestionStore({ + executor, + }); + + equal( + await store.findInboxReceipt({ + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + }), + undefined, + ); + const result = await store.recordEventProcessingOutcome({ + receipt: { + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + firstSeenAt: "2026-05-25T22:00:00.000Z", + payloadHash: "hash-evt-supervisor-signal-001", + }, + reactionPlans: [createReactionPlanRecord()], + processedAt: "2026-05-25T22:00:00.000Z", + result: EventIngestionOutcomeStatus.Processed, + }); + + equal(result.status, EventIngestionOutcomeStatus.Processed); + equal(result.reactionPlans.length, 1); + deepEqual( + executor.statements.map((statement) => statement.name), + [ + CockroachEventIngestionStoreStatement.FindInboxReceipt, + CockroachEventIngestionStoreStatement.ClaimPendingInboxReceipt, + CockroachEventIngestionStoreStatement.InsertReactionPlan, + CockroachEventIngestionStoreStatement.MarkInboxReceiptProcessed, + ], + ); + deepEqual( + executor.transactionStatements.map((statement) => statement.name), + [ + CockroachEventIngestionStoreStatement.ClaimPendingInboxReceipt, + CockroachEventIngestionStoreStatement.InsertReactionPlan, + CockroachEventIngestionStoreStatement.MarkInboxReceiptProcessed, + ], + ); + equal(executor.transactionStatements[0]?.sql.includes("ON CONFLICT"), true); + equal(executor.transactionStatements[0]?.sql.includes("processed_at IS NULL"), true); + }); + + test("does not insert reaction plans when the inbox receipt claim loses the race", async () => { + const executor = createRecordingExecutor({ + claimStatus: EventIngestionOutcomeStatus.Duplicate, + }); + const store = createCockroachEventIngestionStore({ + executor, + }); + + const result = await store.recordEventProcessingOutcome({ + receipt: { + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + firstSeenAt: "2026-05-25T22:00:00.000Z", + payloadHash: "hash-evt-supervisor-signal-001", + }, + reactionPlans: [createReactionPlanRecord()], + processedAt: "2026-05-25T22:00:00.000Z", + result: EventIngestionOutcomeStatus.Processed, + }); + + equal(result.status, EventIngestionOutcomeStatus.Duplicate); + deepEqual(result.reactionPlans, []); + deepEqual( + executor.transactionStatements.map((statement) => statement.name), + [CockroachEventIngestionStoreStatement.ClaimPendingInboxReceipt], + ); + }); + + test("returns duplicate when the processed receipt mark loses the race", async () => { + const executor = createRecordingExecutor({ + markProcessedRowCount: 0, + }); + const store = createCockroachEventIngestionStore({ + executor, + }); + + const result = await store.recordEventProcessingOutcome({ + receipt: { + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + firstSeenAt: "2026-05-25T22:00:00.000Z", + payloadHash: "hash-evt-supervisor-signal-001", + }, + reactionPlans: [createReactionPlanRecord()], + processedAt: "2026-05-25T22:00:00.000Z", + result: EventIngestionOutcomeStatus.Processed, + }); + + equal(result.status, EventIngestionOutcomeStatus.Duplicate); + deepEqual(result.reactionPlans, []); + deepEqual( + executor.transactionStatements.map((statement) => statement.name), + [ + CockroachEventIngestionStoreStatement.ClaimPendingInboxReceipt, + CockroachEventIngestionStoreStatement.InsertReactionPlan, + CockroachEventIngestionStoreStatement.MarkInboxReceiptProcessed, + ], + ); + equal(executor.rolledBackTransactionCount, 1); + }); + + test("normalizes SQL null receipt completion fields to pending receipt fields", async () => { + const executor = createRecordingExecutor({ + rows: [ + { + event_id: "evt-supervisor-signal-001", + consumer_name: InboundEventConsumerName.V0AutomationPlanner, + first_seen_at: "2026-05-25T21:59:00.000Z", + processed_at: null, + payload_hash: "hash-evt-supervisor-signal-001", + result: null, + }, + ], + }); + const store = createCockroachEventIngestionStore({ + executor, + }); + + const receipt = await store.findInboxReceipt({ + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + }); + + deepEqual(receipt, { + eventId: "evt-supervisor-signal-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + firstSeenAt: "2026-05-25T21:59:00.000Z", + payloadHash: "hash-evt-supervisor-signal-001", + }); + }); +}); + +type RecordingCockroachEventIngestionSqlExecutor = CockroachEventIngestionSqlExecutor & { + statements: CockroachEventIngestionSqlStatement[]; + transactionStatements: CockroachEventIngestionSqlStatement[]; + rolledBackTransactionCount: number; +}; + +function createRecordingExecutor( + input: { + rows?: readonly unknown[]; + claimStatus?: EventIngestionOutcomeStatus; + markProcessedRowCount?: number; + } = {}, +): RecordingCockroachEventIngestionSqlExecutor { + const statements: CockroachEventIngestionSqlStatement[] = []; + const transactionStatements: CockroachEventIngestionSqlStatement[] = []; + let rolledBackTransactionCount = 0; + + return { + statements, + transactionStatements, + get rolledBackTransactionCount() { + return rolledBackTransactionCount; + }, + execute: async >(statement: CockroachEventIngestionSqlStatement) => { + statements.push(statement); + + return { + rows: (input.rows ?? []) as readonly Row[], + }; + }, + executeTransaction: async (operation) => { + try { + return await operation({ + execute: async >(statement: CockroachEventIngestionSqlStatement) => { + transactionStatements.push(statement); + statements.push(statement); + + if (statement.name === CockroachEventIngestionStoreStatement.ClaimPendingInboxReceipt) { + return { + rows: [ + { + claim_status: input.claimStatus ?? EventIngestionOutcomeStatus.Processed, + }, + ] as readonly unknown[] as readonly Row[], + }; + } + + if (statement.name === CockroachEventIngestionStoreStatement.MarkInboxReceiptProcessed) { + const rowCount = input.markProcessedRowCount ?? 1; + + return { + rows: Array.from({ length: rowCount }, () => ({ + event_id: "evt-supervisor-signal-001", + })) as readonly unknown[] as readonly Row[], + }; + } + + return { + rows: [], + }; + }, + }); + } catch (error) { + rolledBackTransactionCount += 1; + throw error; + } + }, + }; +} + +function createReactionPlanRecord(): ReactionPlanRecord { + return { + reactionPlanId: "reaction-plan-001", + consumerName: InboundEventConsumerName.V0AutomationPlanner, + createdAt: "2026-05-25T22:00:00.000Z", + status: ReactionPlanStatus.Planned, + action: { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-supervisor-signal-001", + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + workItemId: "work-outbox-001", + supervisorSignalId: "supervisor-signal-001", + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-outbox-event-source.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-outbox-event-source.test.ts similarity index 98% rename from agentic-organization/packages/state-cockroach/src/cockroach-outbox-event-source.test.ts rename to agentic-organization/packages/state-cockroach/test/cockroach-outbox-event-source.test.ts index b332ad205f..470432d87a 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-outbox-event-source.test.ts +++ b/agentic-organization/packages/state-cockroach/test/cockroach-outbox-event-source.test.ts @@ -7,7 +7,7 @@ import { createCockroachOutboxEventSource, type CockroachOutboxSqlExecutor, type CockroachOutboxSqlStatement, -} from "./cockroach-outbox-event-source.ts"; +} from "../src/cockroach-outbox-event-source.ts"; describe("cockroach outbox event source", () => { test("claims unpublished outbox events and marks them published", async () => { diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-schema.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts similarity index 76% rename from agentic-organization/packages/state-cockroach/src/cockroach-schema.test.ts rename to agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts index 904162ba15..25342193b6 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-schema.test.ts +++ b/agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts @@ -5,7 +5,7 @@ import { CockroachCoreStateMigrationName, CockroachTableName, createCockroachCoreStateMigration, -} from "./cockroach-schema.ts"; +} from "../src/cockroach-schema.ts"; describe("cockroach core state schema", () => { test("declares the first authoritative state, audit, outbox, and idempotency tables", () => { @@ -16,12 +16,17 @@ describe("cockroach core state schema", () => { ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.SupervisorSignals}`)); ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.AuditEvents}`)); ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.OutboxEvents}`)); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.InboxReceipts}`)); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.ReactionPlans}`)); ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.IdempotencyRecords}`)); ok(migration.sql.includes("trace_id STRING NOT NULL")); ok(migration.sql.includes("correlation_id STRING NOT NULL")); ok(migration.sql.includes("envelope_json JSONB NOT NULL")); ok(migration.sql.includes("claimed_at TIMESTAMPTZ")); ok(migration.sql.includes("claim_expires_at TIMESTAMPTZ")); + ok(migration.sql.includes("PRIMARY KEY (event_id, consumer_name)")); + ok(migration.sql.includes("status STRING NOT NULL")); + ok(migration.sql.includes("action_json JSONB NOT NULL")); ok(migration.sql.includes("result_json JSONB NOT NULL")); }); }); diff --git a/agentic-organization/packages/state/src/event-ingestion-store.ts b/agentic-organization/packages/state/src/event-ingestion-store.ts new file mode 100644 index 0000000000..cc70669694 --- /dev/null +++ b/agentic-organization/packages/state/src/event-ingestion-store.ts @@ -0,0 +1,96 @@ +import type { ReactionPlanAction, ReactionPlanStatus } from "../../domain/src/index.ts"; + +export const InboundEventConsumerName = { + V0AutomationPlanner: "v0_automation_planner", +} as const; + +export type InboundEventConsumerName = (typeof InboundEventConsumerName)[keyof typeof InboundEventConsumerName]; + +export const EventIngestionOutcomeStatus = { + Duplicate: "duplicate", + PayloadConflict: "payload_conflict", + Processed: "processed", +} as const; + +export type EventIngestionOutcomeStatus = + (typeof EventIngestionOutcomeStatus)[keyof typeof EventIngestionOutcomeStatus]; + +export type InboxReceiptLookup = { + eventId: string; + consumerName: InboundEventConsumerName; +}; + +export type InboxReceiptRecord = InboxReceiptLookup & { + firstSeenAt: string; + payloadHash: string; + processedAt?: string; + result?: EventIngestionOutcomeStatus; +}; + +export type ReactionPlanRecord = { + reactionPlanId: string; + consumerName: InboundEventConsumerName; + createdAt: string; + status: ReactionPlanStatus; + action: ReactionPlanAction; +}; + +export type RecordEventProcessingOutcomeInput = { + receipt: InboxReceiptRecord; + reactionPlans: readonly ReactionPlanRecord[]; + processedAt: string; + result: EventIngestionOutcomeStatus; +}; + +export type RecordEventProcessingOutcomeResult = { + status: EventIngestionOutcomeStatus; + reactionPlans: readonly ReactionPlanRecord[]; +}; + +export type EventIngestionStore = { + findInboxReceipt: (lookup: InboxReceiptLookup) => Promise; + recordEventProcessingOutcome: ( + input: RecordEventProcessingOutcomeInput, + ) => Promise; +}; + +export type InMemoryEventIngestionStoreSnapshot = { + readonly inboxReceipts: readonly InboxReceiptRecord[]; + readonly reactionPlans: readonly ReactionPlanRecord[]; +}; + +export type InMemoryEventIngestionStore = EventIngestionStore & { + readonly snapshot: InMemoryEventIngestionStoreSnapshot; +}; + +export function createInMemoryEventIngestionStore(): InMemoryEventIngestionStore { + const inboxReceipts = new Map(); + const reactionPlans: ReactionPlanRecord[] = []; + + return { + get snapshot() { + return { + inboxReceipts: [...inboxReceipts.values()], + reactionPlans, + }; + }, + findInboxReceipt: async (lookup) => inboxReceipts.get(createInboxReceiptKey(lookup)), + recordEventProcessingOutcome: async (input) => { + inboxReceipts.set(createInboxReceiptKey(input.receipt), { + ...input.receipt, + processedAt: input.processedAt, + result: input.result, + }); + reactionPlans.push(...input.reactionPlans); + + return { + status: input.result, + reactionPlans: input.reactionPlans, + }; + }, + }; +} + +function createInboxReceiptKey(input: InboxReceiptLookup): string { + return `${input.consumerName}:${input.eventId}`; +} diff --git a/agentic-organization/packages/state/src/in-memory-organization-store.ts b/agentic-organization/packages/state/src/in-memory-organization-store.ts index 42ee7adaa5..6e5738dad2 100644 --- a/agentic-organization/packages/state/src/in-memory-organization-store.ts +++ b/agentic-organization/packages/state/src/in-memory-organization-store.ts @@ -1,4 +1,8 @@ -import type { CommandStateStore, CommandStateStoreFactory } from "../../application/src/ports.ts"; +import { + CommandOutcomePersistenceStatus, + type CommandStateStore, + type CommandStateStoreFactory, +} from "../../application/src/ports.ts"; import type { AuditEvent, DiscussionAnchor, @@ -60,17 +64,32 @@ function createCommandStateStore( ): CommandStateStore { return { findIdempotencyRecord: async (idempotencyKey) => snapshot.idempotencyRecords.get(idempotencyKey), - saveIdempotencyRecord: async (record) => { - snapshot.idempotencyRecords.set(record.idempotencyKey, record); - }, - appendSupervisorSignal: async (supervisorSignal) => { - snapshot.supervisorSignals.push(supervisorSignal); - }, - appendAuditEvent: async (auditEvent) => { - snapshot.auditEvents.push(auditEvent); - }, - appendOutboxEvent: async (outboxEvent) => { - snapshot.outboxEvents.push(outboxEvent); + recordCommandOutcome: async (input) => { + const existingRecord = snapshot.idempotencyRecords.get(input.idempotencyRecord.idempotencyKey); + + if (existingRecord?.requestHash === input.idempotencyRecord.requestHash) { + return { + status: CommandOutcomePersistenceStatus.Replayed, + result: existingRecord.result, + }; + } + + if (existingRecord !== undefined) { + return { + status: CommandOutcomePersistenceStatus.IdempotencyConflict, + existingRequestHash: existingRecord.requestHash, + }; + } + + snapshot.idempotencyRecords.set(input.idempotencyRecord.idempotencyKey, input.idempotencyRecord); + snapshot.supervisorSignals.push(...input.effects.supervisorSignals); + snapshot.auditEvents.push(...input.effects.auditEvents); + snapshot.outboxEvents.push(...input.effects.outboxEvents); + + return { + status: CommandOutcomePersistenceStatus.Committed, + result: input.idempotencyRecord.result, + }; }, }; } diff --git a/agentic-organization/packages/state/src/index.ts b/agentic-organization/packages/state/src/index.ts index 1b1611aa83..c91de4b605 100644 --- a/agentic-organization/packages/state/src/index.ts +++ b/agentic-organization/packages/state/src/index.ts @@ -3,6 +3,18 @@ export { type InMemoryOrganizationStoreFactory, type InMemoryOrganizationStoreSnapshot, } from "./in-memory-organization-store.ts"; +export { + EventIngestionOutcomeStatus, + InboundEventConsumerName, + createInMemoryEventIngestionStore, + type EventIngestionStore, + type InboxReceiptLookup, + type InboxReceiptRecord, + type InMemoryEventIngestionStore, + type InMemoryEventIngestionStoreSnapshot, + type ReactionPlanRecord, + type RecordEventProcessingOutcomeInput, +} from "./event-ingestion-store.ts"; export type { ClaimUnpublishedOutboxEventsInput, MarkOutboxEventPublishedInput, diff --git a/agentic-organization/packages/workers/src/index.ts b/agentic-organization/packages/workers/src/index.ts new file mode 100644 index 0000000000..5d60c85835 --- /dev/null +++ b/agentic-organization/packages/workers/src/index.ts @@ -0,0 +1,12 @@ +export { + WorkerCycleStatus, + WorkerLane, + createOrganizationWorkerHost, + type CreateOrganizationWorkerHostInput, + type InboundEventSource, + type OrganizationWorkerHost, + type PullInboundEventsInput, + type WorkerCycleResult, + type WorkerInboundCycleSummary, + type WorkerPortFailure, +} from "./worker-host.ts"; diff --git a/agentic-organization/packages/workers/src/worker-host.ts b/agentic-organization/packages/workers/src/worker-host.ts new file mode 100644 index 0000000000..f73c20d1fb --- /dev/null +++ b/agentic-organization/packages/workers/src/worker-host.ts @@ -0,0 +1,218 @@ +import type { AgenticEventEnvelope } from "../../domain/src/index.ts"; +import { + OutboxPublishOutcomeStatus, + type OutboxPublishBatchResult, + type OutboxPublisher, +} from "../../messaging/src/index.ts"; +import type { EventIngestionProcessor } from "../../runtime/src/index.ts"; +import { EventIngestionOutcomeStatus } from "../../state/src/index.ts"; + +export const WorkerCycleStatus = { + Degraded: "degraded", + Idle: "idle", + Worked: "worked", +} as const; + +export type WorkerCycleStatus = (typeof WorkerCycleStatus)[keyof typeof WorkerCycleStatus]; + +export const WorkerLane = { + Inbound: "inbound", + Outbox: "outbox", +} as const; + +export type WorkerLane = (typeof WorkerLane)[keyof typeof WorkerLane]; + +export type PullInboundEventsInput = { + batchSize: number; +}; + +export type InboundEventSource = { + pullNextBatch: (input: PullInboundEventsInput) => Promise; +}; + +export type WorkerInboundCycleSummary = { + pulledCount: number; + processedCount: number; + duplicateCount: number; + payloadConflictCount: number; + failedCount: number; + reactionPlanCount: number; +}; + +export type WorkerPortFailure = { + lane: WorkerLane; + message: string; +}; + +export type WorkerCycleResult = { + status: WorkerCycleStatus; + outbox: OutboxPublishBatchResult | undefined; + inbound: WorkerInboundCycleSummary; + failures: readonly WorkerPortFailure[]; +}; + +export type OrganizationWorkerHost = { + runOnce: () => Promise; +}; + +export type CreateOrganizationWorkerHostInput = { + outboxPublisher: OutboxPublisher; + inboundEventSource: InboundEventSource; + eventIngestionProcessor: EventIngestionProcessor; + outboxBatchSize: number; + inboundBatchSize: number; +}; + +export function createOrganizationWorkerHost(input: CreateOrganizationWorkerHostInput): OrganizationWorkerHost { + return { + runOnce: async () => { + const failures: WorkerPortFailure[] = []; + const outbox = await publishOutboxBatch({ + outboxPublisher: input.outboxPublisher, + batchSize: input.outboxBatchSize, + failures, + }); + const inbound = await processInboundBatch({ + inboundEventSource: input.inboundEventSource, + batchSize: input.inboundBatchSize, + eventIngestionProcessor: input.eventIngestionProcessor, + failures, + }); + + return { + status: resolveWorkerCycleStatus({ + outbox, + inbound, + failures, + }), + outbox, + inbound, + failures, + }; + }, + }; +} + +type PublishOutboxBatchInput = { + outboxPublisher: OutboxPublisher; + batchSize: number; + failures: WorkerPortFailure[]; +}; + +async function publishOutboxBatch(input: PublishOutboxBatchInput): Promise { + try { + return await input.outboxPublisher.publishNextBatch({ + batchSize: input.batchSize, + }); + } catch (error) { + input.failures.push({ + lane: WorkerLane.Outbox, + message: extractErrorMessage(error), + }); + return undefined; + } +} + +type ProcessInboundBatchInput = { + inboundEventSource: InboundEventSource; + batchSize: number; + eventIngestionProcessor: EventIngestionProcessor; + failures: WorkerPortFailure[]; +}; + +async function processInboundBatch(input: ProcessInboundBatchInput): Promise { + try { + const envelopes = await input.inboundEventSource.pullNextBatch({ + batchSize: input.batchSize, + }); + + return await ingestInboundBatch({ + envelopes, + eventIngestionProcessor: input.eventIngestionProcessor, + failures: input.failures, + }); + } catch (error) { + input.failures.push({ + lane: WorkerLane.Inbound, + message: extractErrorMessage(error), + }); + return createEmptyInboundCycleSummary(0); + } +} + +type IngestInboundBatchInput = { + envelopes: readonly AgenticEventEnvelope[]; + eventIngestionProcessor: EventIngestionProcessor; + failures: WorkerPortFailure[]; +}; + +async function ingestInboundBatch(input: IngestInboundBatchInput): Promise { + const summary = createEmptyInboundCycleSummary(input.envelopes.length); + + for (const envelope of input.envelopes) { + try { + const result = await input.eventIngestionProcessor.ingest({ + envelope, + }); + + if (result.status === EventIngestionOutcomeStatus.Processed) { + summary.processedCount += 1; + } + + if (result.status === EventIngestionOutcomeStatus.Duplicate) { + summary.duplicateCount += 1; + } + + if (result.status === EventIngestionOutcomeStatus.PayloadConflict) { + summary.payloadConflictCount += 1; + } + + summary.reactionPlanCount += result.reactionPlans.length; + } catch (error) { + summary.failedCount += 1; + input.failures.push({ + lane: WorkerLane.Inbound, + message: extractErrorMessage(error), + }); + } + } + + return summary; +} + +function createEmptyInboundCycleSummary(pulledCount: number): WorkerInboundCycleSummary { + return { + pulledCount, + processedCount: 0, + duplicateCount: 0, + payloadConflictCount: 0, + failedCount: 0, + reactionPlanCount: 0, + }; +} + +type ResolveWorkerCycleStatusInput = { + outbox: OutboxPublishBatchResult | undefined; + inbound: WorkerInboundCycleSummary; + failures: readonly WorkerPortFailure[]; +}; + +function resolveWorkerCycleStatus(input: ResolveWorkerCycleStatusInput): WorkerCycleStatus { + if (input.failures.length > 0) { + return WorkerCycleStatus.Degraded; + } + + if (input.outbox?.status === OutboxPublishOutcomeStatus.Published || input.inbound.pulledCount > 0) { + return WorkerCycleStatus.Worked; + } + + return WorkerCycleStatus.Idle; +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/agentic-organization/packages/workers/test/worker-host.test.ts b/agentic-organization/packages/workers/test/worker-host.test.ts new file mode 100644 index 0000000000..f5c7015a17 --- /dev/null +++ b/agentic-organization/packages/workers/test/worker-host.test.ts @@ -0,0 +1,389 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + AgenticAggregateType, + AgenticEventType, + SupervisorChainLevel, + createAgenticEventEnvelope, + type AgenticEventEnvelope, + type OutboxEvent, +} from "../../domain/src/index.ts"; +import { + OutboxPublishOutcomeStatus, + createOutboxPublisher, + resolveAgenticMessagingDomain, + type EventPublication, + type OutboxPublishBatchResult, + type OutboxPublisher, +} from "../../messaging/src/index.ts"; +import { createEventIngestionProcessor, evaluateV0AutomationRules } from "../../runtime/src/index.ts"; +import { + EventIngestionOutcomeStatus, + InboundEventConsumerName, + createInMemoryEventIngestionStore, + type ReactionPlanRecord, +} from "../../state/src/index.ts"; +import { WorkerCycleStatus, WorkerLane, createOrganizationWorkerHost, type InboundEventSource } from "../src/index.ts"; + +describe("organization worker host", () => { + test("runs one bounded outbox and inbound ingestion cycle", async () => { + const inboundEnvelope = createInboundEnvelope(); + const outboxPublisher = createRecordingOutboxPublisher({ + status: OutboxPublishOutcomeStatus.Published, + attemptedCount: 1, + publishedOutboxEventIds: ["outbox-001"], + }); + const inboundEventSource = createRecordingInboundEventSource([inboundEnvelope]); + const eventIngestionProcessor = createRecordingEventIngestionProcessor(); + const workerHost = createOrganizationWorkerHost({ + outboxPublisher, + inboundEventSource, + eventIngestionProcessor, + outboxBatchSize: 25, + inboundBatchSize: 10, + }); + + const result = await workerHost.runOnce(); + + deepEqual(outboxPublisher.batchSizes, [25]); + deepEqual(inboundEventSource.batchSizes, [10]); + deepEqual(eventIngestionProcessor.ingestedEventIds, ["evt-inbound-001"]); + deepEqual(result, { + status: WorkerCycleStatus.Worked, + outbox: { + status: OutboxPublishOutcomeStatus.Published, + attemptedCount: 1, + publishedOutboxEventIds: ["outbox-001"], + }, + inbound: { + pulledCount: 1, + processedCount: 1, + duplicateCount: 0, + payloadConflictCount: 0, + failedCount: 0, + reactionPlanCount: 0, + }, + failures: [], + }); + }); + + test("composes outbox publishing and inbound ingestion without live NATS", async () => { + const publishedEvents: EventPublication[] = []; + const eventIngestionStore = createInMemoryEventIngestionStore(); + const outboxEvent = createOutboxEvent(createInboundEnvelope("evt-composed-001", createSupervisorSignalPayload())); + const workerHost = createOrganizationWorkerHost({ + outboxPublisher: createOutboxPublisher({ + outboxSource: createSingleEventOutboxSource(outboxEvent), + eventPublisher: { + publish: async (publication) => { + publishedEvents.push(publication); + }, + }, + environment: "test", + resolveDomain: resolveAgenticMessagingDomain, + now: () => "2026-05-25T20:05:00.000Z", + }), + inboundEventSource: { + pullNextBatch: async () => publishedEvents.map((publication) => publication.outboxEvent.envelope), + }, + eventIngestionProcessor: createEventIngestionProcessor({ + store: eventIngestionStore, + evaluateRules: evaluateV0AutomationRules, + consumerName: InboundEventConsumerName.V0AutomationPlanner, + calculatePayloadHash: (envelope) => JSON.stringify(envelope.payload), + now: () => "2026-05-25T20:06:00.000Z", + createId: (prefix) => `${prefix}-001`, + }), + outboxBatchSize: 25, + inboundBatchSize: 10, + }); + + const result = await workerHost.runOnce(); + + equal(result.status, WorkerCycleStatus.Worked); + equal(publishedEvents.length, 1); + equal(result.outbox?.status, OutboxPublishOutcomeStatus.Published); + equal(result.inbound.processedCount, 1); + equal(result.inbound.reactionPlanCount, 1); + equal(eventIngestionStore.snapshot.inboxReceipts.length, 1); + equal(eventIngestionStore.snapshot.reactionPlans.length, 1); + }); + + test("reports idle when no outbox or inbound work is available", async () => { + const workerHost = createOrganizationWorkerHost({ + outboxPublisher: createRecordingOutboxPublisher({ + status: OutboxPublishOutcomeStatus.Empty, + attemptedCount: 0, + publishedOutboxEventIds: [], + }), + inboundEventSource: createRecordingInboundEventSource([]), + eventIngestionProcessor: createRecordingEventIngestionProcessor(), + outboxBatchSize: 25, + inboundBatchSize: 10, + }); + + const result = await workerHost.runOnce(); + + equal(result.status, WorkerCycleStatus.Idle); + equal(result.inbound.pulledCount, 0); + equal(result.outbox?.status, OutboxPublishOutcomeStatus.Empty); + }); + + test("summarizes duplicate and payload-conflict inbound outcomes without hiding them", async () => { + const eventIngestionProcessor = createRecordingEventIngestionProcessor([ + EventIngestionOutcomeStatus.Duplicate, + EventIngestionOutcomeStatus.PayloadConflict, + ]); + const workerHost = createOrganizationWorkerHost({ + outboxPublisher: createRecordingOutboxPublisher({ + status: OutboxPublishOutcomeStatus.Empty, + attemptedCount: 0, + publishedOutboxEventIds: [], + }), + inboundEventSource: createRecordingInboundEventSource([ + createInboundEnvelope("evt-duplicate-001"), + createInboundEnvelope("evt-conflict-001"), + ]), + eventIngestionProcessor, + outboxBatchSize: 25, + inboundBatchSize: 10, + }); + + const result = await workerHost.runOnce(); + + deepEqual(eventIngestionProcessor.ingestedEventIds, ["evt-duplicate-001", "evt-conflict-001"]); + deepEqual(result.inbound, { + pulledCount: 2, + processedCount: 0, + duplicateCount: 1, + payloadConflictCount: 1, + failedCount: 0, + reactionPlanCount: 0, + }); + equal(result.status, WorkerCycleStatus.Worked); + }); + + test("continues inbound ingestion when the outbox lane fails", async () => { + const inboundEnvelope = createInboundEnvelope("evt-after-outbox-failure-001"); + const eventIngestionProcessor = createRecordingEventIngestionProcessor(); + const workerHost = createOrganizationWorkerHost({ + outboxPublisher: createFailingOutboxPublisher("outbox unavailable"), + inboundEventSource: createRecordingInboundEventSource([inboundEnvelope]), + eventIngestionProcessor, + outboxBatchSize: 25, + inboundBatchSize: 10, + }); + + const result = await workerHost.runOnce(); + + equal(result.status, WorkerCycleStatus.Degraded); + deepEqual(eventIngestionProcessor.ingestedEventIds, ["evt-after-outbox-failure-001"]); + deepEqual(result.inbound, { + pulledCount: 1, + processedCount: 1, + duplicateCount: 0, + payloadConflictCount: 0, + failedCount: 0, + reactionPlanCount: 0, + }); + deepEqual(result.failures, [ + { + lane: WorkerLane.Outbox, + message: "outbox unavailable", + }, + ]); + }); + + test("continues the inbound batch when one event fails ingestion", async () => { + const eventIngestionProcessor = createRecordingEventIngestionProcessor( + [EventIngestionOutcomeStatus.Processed], + "ingestion failed", + ); + const workerHost = createOrganizationWorkerHost({ + outboxPublisher: createRecordingOutboxPublisher({ + status: OutboxPublishOutcomeStatus.Empty, + attemptedCount: 0, + publishedOutboxEventIds: [], + }), + inboundEventSource: createRecordingInboundEventSource([ + createInboundEnvelope("evt-ingest-ok-001"), + createInboundEnvelope("evt-ingest-fails-001"), + createInboundEnvelope("evt-ingest-ok-002"), + ]), + eventIngestionProcessor, + outboxBatchSize: 25, + inboundBatchSize: 10, + }); + + const result = await workerHost.runOnce(); + + equal(result.status, WorkerCycleStatus.Degraded); + deepEqual(eventIngestionProcessor.ingestedEventIds, [ + "evt-ingest-ok-001", + "evt-ingest-fails-001", + "evt-ingest-ok-002", + ]); + deepEqual(result.inbound, { + pulledCount: 3, + processedCount: 2, + duplicateCount: 0, + payloadConflictCount: 0, + failedCount: 1, + reactionPlanCount: 0, + }); + deepEqual(result.failures, [ + { + lane: WorkerLane.Inbound, + message: "ingestion failed", + }, + ]); + }); +}); + +type RecordingOutboxPublisher = OutboxPublisher & { + batchSizes: number[]; +}; + +function createRecordingOutboxPublisher(result: OutboxPublishBatchResult): RecordingOutboxPublisher { + const batchSizes: number[] = []; + + return { + batchSizes, + publishNextBatch: async (input) => { + batchSizes.push(input.batchSize); + return result; + }, + }; +} + +function createFailingOutboxPublisher(message: string): OutboxPublisher { + return { + publishNextBatch: async () => { + throw new Error(message); + }, + }; +} + +type RecordingInboundEventSource = InboundEventSource & { + batchSizes: number[]; +}; + +function createRecordingInboundEventSource(envelopes: readonly AgenticEventEnvelope[]): RecordingInboundEventSource { + const batchSizes: number[] = []; + + return { + batchSizes, + pullNextBatch: async (input) => { + batchSizes.push(input.batchSize); + return envelopes; + }, + }; +} + +type RecordingEventIngestionProcessor = { + ingestedEventIds: string[]; + ingest: (input: { envelope: AgenticEventEnvelope }) => Promise<{ + status: EventIngestionOutcomeStatus; + reactionPlans: readonly ReactionPlanRecord[]; + }>; +}; + +function createRecordingEventIngestionProcessor( + statuses: readonly EventIngestionOutcomeStatus[] = [EventIngestionOutcomeStatus.Processed], + failureMessage?: string, +): RecordingEventIngestionProcessor { + const ingestedEventIds: string[] = []; + let currentIndex = 0; + + return { + ingestedEventIds, + ingest: async (input) => { + ingestedEventIds.push(input.envelope.eventId); + if (failureMessage !== undefined && currentIndex === 1) { + currentIndex += 1; + throw new Error(failureMessage); + } + + const status = statuses[currentIndex] ?? EventIngestionOutcomeStatus.Processed; + currentIndex += 1; + + return { + status, + reactionPlans: [], + }; + }, + }; +} + +function createSingleEventOutboxSource(outboxEvent: OutboxEvent): { + claimUnpublishedOutboxEvents: () => Promise; + markOutboxEventPublished: () => Promise; +} { + let claimed = false; + + return { + claimUnpublishedOutboxEvents: async () => { + if (claimed) { + return []; + } + + claimed = true; + return [outboxEvent]; + }, + markOutboxEventPublished: async () => { + outboxEvent.publishedAt = "2026-05-25T20:05:00.000Z"; + }, + }; +} + +function createOutboxEvent(envelope: AgenticEventEnvelope): OutboxEvent { + return { + outboxEventId: "outbox-composed-001", + envelope, + }; +} + +function createSupervisorSignalPayload(): Record { + return { + targetHatAssignmentId: "hat-assignment-manager-001", + targetLevel: SupervisorChainLevel.Manager, + title: "Blocked on scoped NATS publisher", + }; +} + +function createInboundEnvelope( + eventId = "evt-inbound-001", + payload: Record = { + title: "Blocked on scoped NATS publisher", + }, +): AgenticEventEnvelope { + return createAgenticEventEnvelope({ + eventId, + eventType: AgenticEventType.SupervisorSignalSent, + occurredAt: "2026-05-25T20:00:00.000Z", + actor: { + agentId: "agent-developer-001", + hatAssignmentId: "hat-assignment-dev-001", + }, + scope: { + organizationId: "org-lfg", + projectId: "project-agentic-org", + teamId: "team-runtime", + workItemId: "work-outbox-001", + }, + aggregate: { + aggregateId: "supervisor-signal-001", + aggregateType: AgenticAggregateType.SupervisorSignal, + aggregateVersion: 1, + }, + trace: { + commandId: "cmd-supervisor-signal-001", + correlationId: "corr-supervisor-signal-001", + causationId: "cause-team-work-001", + traceId: "trace-supervisor-signal-001", + idempotencyKey: "idem-supervisor-signal-001", + }, + payload, + }); +} diff --git a/agentic-organization/tsconfig.json b/agentic-organization/tsconfig.json index db930028ea..657668a740 100644 --- a/agentic-organization/tsconfig.json +++ b/agentic-organization/tsconfig.json @@ -16,5 +16,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["packages/**/*.ts"] + "include": ["packages/**/*.ts", "apps/**/*.ts"] } diff --git a/openspec/specs/agentic-organization/spec.md b/openspec/specs/agentic-organization/spec.md index a3d752c6bc..85298f2726 100644 --- a/openspec/specs/agentic-organization/spec.md +++ b/openspec/specs/agentic-organization/spec.md @@ -37,6 +37,14 @@ Organization state only by calling Organization commands. and idempotency records together - **AND** the adapter does not mutate authoritative state directly +#### Scenario: Command handler returns effects instead of writing state + +- **WHEN** a command handler accepts a valid command +- **THEN** it returns the command result plus typed command effects +- **AND** the handler does not call state append operations directly +- **AND** the command pipeline records the result and effects through a + single command outcome port + #### Scenario: Command pipeline is composed from ports - **WHEN** a runtime host creates a command pipeline @@ -57,7 +65,18 @@ Organization state only by calling Organization commands. Temporal, Drizzle, Postgres, or other runtime clients - **AND** a violation fails the test suite before the boundary can drift - **AND** state adapter source files are checked for forbidden imports - of messaging, NATS, JetStream, or other event transport clients + of runtime implementation packages, messaging, NATS, JetStream, or + other event transport clients + +#### Scenario: Tests are kept out of production source trees + +- **WHEN** package source-layout governance tests run +- **THEN** production source directories are scanned for `*.test.ts` + files +- **AND** every package keeps implementation code under + `packages//src` +- **AND** every package keeps tests under `packages//test` +- **AND** a test file inside a production source tree fails the suite ### Requirement: Commands are idempotent @@ -82,6 +101,17 @@ command boundary. - **AND** CockroachDB is treated as the first replaceable durable adapter for the cluster, not as the application model +#### Scenario: Vendor-specific adapters stay behind generic ports + +- **WHEN** application, runtime, worker, or messaging package source is + inspected +- **THEN** it does not import vendor-specific adapter packages or vendor + clients directly +- **AND** vendor packages implement generic Organization ports exposed by + non-vendor packages +- **AND** vendor-specific executor or transaction seams are not used as + application contracts + #### Scenario: Matching replay - **WHEN** a command is submitted twice with the same idempotency key @@ -97,6 +127,16 @@ command boundary. - **THEN** the command is rejected with a typed idempotency conflict - **AND** no new authoritative state is created +#### Scenario: Command outcome persistence fails + +- **WHEN** a new command produces supervisor-signal, audit, and outbox + effects +- **AND** the command outcome store cannot persist the full outcome +- **THEN** no piecemeal command writes are performed by the application + layer +- **AND** durable adapters are responsible for committing or rolling + back the full command outcome atomically + ### Requirement: Events carry traceable envelopes Organization domain events MUST carry a canonical envelope with command, @@ -176,6 +216,250 @@ and a concrete event-publisher adapter. correlation ID, causation ID, trace ID, idempotency key, and outbox event ID +#### Scenario: NATS adapter consumes a valid event + +- **WHEN** the NATS JetStream consumer adapter fetches a message with a + canonical event envelope +- **THEN** it decodes the envelope and sends it to the event ingestion + processor +- **AND** a processed event is acknowledged +- **AND** a duplicate event is acknowledged without treating it as a + transport failure + +#### Scenario: NATS adapter handles invalid or conflicting events + +- **WHEN** the NATS JetStream consumer adapter receives an invalid + envelope +- **THEN** runtime ingestion is not called +- **AND** the message is terminated and published to the dead-letter + port with an invalid-envelope reason +- **WHEN** runtime ingestion reports a payload conflict +- **THEN** the message is terminated and published to the dead-letter + port with a payload-conflict reason + +#### Scenario: NATS adapter retries transient ingestion failures + +- **WHEN** the event ingestion processor throws while handling a valid + envelope +- **THEN** the NATS JetStream consumer adapter negative-acknowledges the + message for retry +- **AND** the runtime rule processor does not know about NATS ack, nack, + termination, backoff, or DLQ mechanics + +#### Scenario: NATS adapter falls back when dead-letter handling fails + +- **WHEN** the NATS JetStream consumer adapter receives an invalid + envelope or payload-conflict result +- **AND** publishing to the dead-letter port or terminating the source + message fails +- **THEN** the adapter records the failure and negative-acknowledges the + source message for retry +- **AND** later messages in the fetched batch can still be processed + +### Requirement: Inbound events are deduped before automation + +Organization event consumers MUST record inbox receipts before +automation side effects and MUST persist reaction plans instead of +executing privileged work directly. + +#### Scenario: New event is ingested by an automation consumer + +- **WHEN** a decoded canonical event envelope reaches the runtime event + ingestion processor +- **THEN** the processor checks for an inbox receipt by event ID and + consumer name +- **AND** a missing receipt allows rule evaluation +- **AND** the processor records the inbox receipt and generated reaction + plans through one store operation +- **AND** the reaction plans preserve the triggering event ID, target + scope, required hat, action type, and reason + +#### Scenario: Duplicate event is ingested by an automation consumer + +- **WHEN** the same event ID reaches the same consumer again after the + original receipt has a completed result +- **THEN** the processor returns a duplicate outcome +- **AND** no automation rules are re-evaluated +- **AND** no duplicate reaction plans are created + +#### Scenario: Unprocessed receipt is retried by an automation consumer + +- **WHEN** the same event ID and payload hash reaches the same consumer + but the existing receipt has no completed result +- **THEN** the processor treats the receipt as recoverable pending work +- **AND** automation rules are re-evaluated +- **AND** the receipt and generated reaction plans are recorded through + the normal event-processing outcome port + +#### Scenario: Conflicting event payload is ingested by an automation consumer + +- **WHEN** the same event ID reaches the same consumer with a different + payload hash +- **THEN** the processor returns a payload-conflict outcome +- **AND** no automation rules are re-evaluated +- **AND** no duplicate reaction plans are created + +#### Scenario: Durable state schema supports inbound event dedupe + +- **WHEN** the durable state migration contract is loaded +- **THEN** it declares inbox receipt storage keyed by event ID and + consumer name +- **AND** it declares reaction plan storage for generated automation + plans +- **AND** reaction plans include a persisted status + +#### Scenario: Durable event-ingestion adapter uses one transaction boundary + +- **WHEN** a durable event-ingestion adapter records an event-processing + outcome +- **THEN** the inbox receipt, generated reaction plans, and processed + marker are submitted inside one transaction boundary +- **AND** the processed marker must return the claimed receipt before the + adapter reports the outcome as processed +- **AND** runtime rule processors do not receive database transaction + objects + +#### Scenario: Durable event-ingestion adapter loses receipt claim race + +- **WHEN** a durable event-ingestion adapter attempts to record reaction + plans after another consumer has already completed the same receipt +- **THEN** the adapter returns a duplicate event-processing outcome + through the generic event-ingestion port +- **AND** it does not insert reaction plans +- **AND** it does not mark the completed receipt again + +#### Scenario: Durable event-ingestion adapter loses completion race + +- **WHEN** a durable event-ingestion adapter claims a pending receipt but + the final processed marker no longer matches a pending receipt +- **THEN** the adapter rolls back generated reaction plans +- **AND** it returns a duplicate event-processing outcome through the + generic event-ingestion port +- **AND** runtime code does not receive database transaction objects or + vendor-specific update-count errors + +#### Scenario: Durable command adapter uses one transaction boundary + +- **WHEN** a durable command adapter records a command outcome +- **THEN** the idempotency record, command state, audit events, and + outbox events are submitted inside one transaction boundary +- **AND** the idempotency record is reserved before effect rows are + submitted inside that boundary +- **AND** application handlers do not receive database transaction + objects + +#### Scenario: Durable command adapter loses idempotency claim race + +- **WHEN** a durable command adapter attempts to record a command + outcome after another transaction has already claimed the same + idempotency key +- **THEN** it returns a generic replay or idempotency-conflict result + through the command outcome port +- **AND** it does not insert duplicate supervisor signal, audit event, or + outbox rows +- **AND** application code does not receive vendor-specific duplicate + key errors or transaction objects + +### Requirement: Worker process boundary composes event loops through ports + +Organization worker code MUST remain a small composition boundary until +live infrastructure adapters are bound by a runtime host. + +#### Scenario: Worker runs one bounded cycle + +- **WHEN** the worker host is asked to run once +- **THEN** it publishes at most one bounded outbox batch through an + outbox publisher port +- **AND** it pulls at most one bounded inbound batch through an inbound + event source port +- **AND** it sends each decoded event envelope through the event + ingestion processor +- **AND** it returns a cycle summary with outbox status, inbound pulled + count, processed count, duplicate count, payload-conflict count, + failed count, reaction-plan count, and failure details +- **AND** it reports idle only when no outbox events are published and + no inbound events are pulled + +#### Scenario: Worker reports degraded lanes without starving other lanes + +- **WHEN** the outbox lane fails during a worker cycle +- **THEN** the worker still attempts the inbound ingestion lane +- **AND** the cycle result reports a degraded status with the outbox + failure message +- **AND** inbound processing counts remain visible +- **WHEN** one inbound event fails during ingestion +- **THEN** later inbound events in the same batch are still attempted +- **AND** the cycle result reports failed inbound count and failure + details + +#### Scenario: Worker source remains adapter-free + +- **WHEN** package dependency-boundary tests inspect worker source +- **THEN** worker source is checked for forbidden imports of the + Cockroach adapter, NATS adapter, NestJS, NATS, Dapr, Temporal, + Drizzle, Postgres, or other concrete runtime clients +- **AND** concrete process concerns are left for `apps/workers` or + adapter packages + +#### Scenario: Workers app composes process loops + +- **WHEN** the `apps/workers` runtime host is asked to run once +- **THEN** it runs the package-level Organization worker cycle +- **AND** it runs the NATS consumer adapter cycle with the configured + inbound batch size +- **AND** it records worker-cycle telemetry and NATS-consumer batch + telemetry through a telemetry sink port +- **AND** it reports healthy only when both loops complete without + degraded worker status, NATS failures, or dead letters + +#### Scenario: Workers app parses process environment + +- **WHEN** the `apps/workers` runtime host parses process environment + values +- **THEN** it reads `AGENTIC_ORG_ENV`, `AGENTIC_ORG_ID`, `NATS_STREAM`, + `NATS_DURABLE`, and `NATS_INBOUND_BATCH_SIZE` through typed env names +- **AND** it returns typed runtime configuration for the composition root +- **AND** packages do not read process environment values directly +- **AND** URLs, credentials, and connection pools remain process adapter + concerns supplied through Kubernetes Secret or ExternalSecret backed + configuration later + +#### Scenario: Workers app composes adapter ports + +- **WHEN** the `apps/workers` composition root is created +- **THEN** it receives typed runtime config plus already-constructed + worker, NATS consumer, and telemetry ports +- **AND** the composition root returns a runnable worker runtime without + leaking concrete adapter construction into package code + +#### Scenario: Workers app rejects invalid process config + +- **WHEN** the `apps/workers` runtime host is created with missing + environment, missing Organization ID, missing NATS stream, missing + durable consumer, or non-positive NATS inbound batch size +- **THEN** runtime creation fails with a typed configuration error before + any worker loop can start + +#### Scenario: Workers app keeps loops visible when one loop fails + +- **WHEN** the package-level worker cycle throws +- **THEN** the `apps/workers` runtime host still attempts the NATS + consumer cycle +- **AND** the runtime result reports degraded status with a typed + organization-worker failure stage +- **WHEN** telemetry recording throws after a worker or NATS consumer + cycle succeeds +- **THEN** the successful cycle result remains visible in the runtime + result +- **AND** the runtime result reports degraded status with a typed + telemetry failure stage +- **WHEN** the NATS consumer cycle reports failed or dead-lettered + messages +- **THEN** the runtime result reports degraded status without hiding the + batch counts +- **AND** invalid, payload-conflict, negative-acknowledged, or + terminated NATS messages also make the runtime result degraded + ### Requirement: Telemetry is complete at the event boundary Organization packages MUST expose OpenTelemetry-compatible attributes @@ -188,6 +472,15 @@ for the full trace chain before live LGTM ingestion is wired. causation, trace, idempotency, actor, hat assignment, organization, project, work item, aggregate, and NATS destination fields +#### Scenario: NATS consumer batch is projected to telemetry + +- **WHEN** a NATS consumer batch result is projected to telemetry +- **THEN** the attributes include messaging system, stream, durable + consumer, received count, processed count, duplicate count, + payload-conflict count, invalid count, failed count, acknowledged + count, negative-acknowledged count, terminated count, and + dead-lettered count + ### Requirement: Workflow visibility records expose weak points Organization packages MUST project meaningful workflow movement into a