diff --git a/.gitignore b/.gitignore index 69fc375..4c6799c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ infra/node_modules/ infra/dist/ infra/cdk.out/ infra/coverage +infra/constants/event-statuses.ts # DeepEval diff --git a/infra/constants/event-statuses.ts b/infra/constants/event-statuses.ts deleted file mode 100644 index c00ebc9..0000000 --- a/infra/constants/event-statuses.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Event status constants used by Step Functions workflow - * - * The Step Function polls DynamoDB for these statuses to determine when - * the agent background task has completed or failed. - * - * Note: Other event statuses (SESSION_INITIATED, AGENT_INVOCATION_STARTED, etc.) - * are defined in Python at src/shared/event_statuses.py and used by Lambda functions. - */ - -export const EventStatus = { - /** - * Agent background task completed successfully (Step Function polls for this) - */ - AGENT_BACKGROUND_TASK_COMPLETED: 'AGENT_BACKGROUND_TASK_COMPLETED', - - /** - * Agent background task failed (Step Function polls for this) - */ - AGENT_BACKGROUND_TASK_FAILED: 'AGENT_BACKGROUND_TASK_FAILED', -} as const; - -export type EventStatusType = (typeof EventStatus)[keyof typeof EventStatus]; diff --git a/infra/package.json b/infra/package.json index 1ebf91c..8d5e44c 100644 --- a/infra/package.json +++ b/infra/package.json @@ -2,10 +2,11 @@ "name": "infra", "private": true, "scripts": { - "prebuild": "npm run package-agent && npm run prepare-lambda", + "prebuild": "npm run generate-event-statuses && npm run package-agent && npm run prepare-lambda", "build": "tsc -p tsconfig.json", "watch": "tsc -w", "clean": "rm -rf cdk.out dist", + "generate-event-statuses": "python3 ../scripts/generate-event-statuses.py constants/event-statuses.ts", "prepare-lambda": "rm -rf dist/build-lambda && mkdir -p dist/build-lambda/src && cp ../infra/lambda/*.py dist/build-lambda/ && cp ../src/__init__.py dist/build-lambda/src/ && cp -r ../src/shared dist/build-lambda/src/", "package-agent": "rm -rf dist/build-agent && bash ../scripts/build-deployment-package.sh", "synth": "npm run build && cdk synth", diff --git a/scripts/generate-event-statuses.py b/scripts/generate-event-statuses.py new file mode 100644 index 0000000..cbc8a2a --- /dev/null +++ b/scripts/generate-event-statuses.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Generate infra/constants/event-statuses.ts from src/shared/event_statuses.py. + +Usage: generate-event-statuses.py + +src/shared/event_statuses.py is the single source of truth for event status +strings. This script mirrors it into a TypeScript module so the Step +Functions workflow (infra/lib/workflow.ts) can't drift from the Python +values that actually get written to DynamoDB. +""" + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from src.shared.event_statuses import EventStatus # noqa: E402 + +HEADER = """// GENERATED FILE — do not edit by hand. +// Source of truth: src/shared/event_statuses.py +// Regenerated automatically by `npm run generate-event-statuses` (part of `npm run build`). +""" + + +def render(statuses: dict[str, str]) -> str: + entries = "\n".join(f" {name}: '{value}'," for name, value in statuses.items()) + return ( + f"{HEADER}\n" + f"export const EventStatus = {{\n{entries}\n}} as const;\n\n" + f"export type EventStatusType = (typeof EventStatus)[keyof typeof EventStatus];\n" + ) + + +def main() -> None: + output_path = Path(sys.argv[1]) + statuses = {name: value for name, value in vars(EventStatus).items() if not name.startswith("_")} + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(render(statuses)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_generate_event_statuses.py b/tests/test_generate_event_statuses.py new file mode 100644 index 0000000..5c79f05 --- /dev/null +++ b/tests/test_generate_event_statuses.py @@ -0,0 +1,44 @@ +"""Unit tests for scripts/generate-event-statuses.py.""" + +import importlib.util +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "scripts" / "generate-event-statuses.py" + +_spec = importlib.util.spec_from_file_location("generate_event_statuses", SCRIPT_PATH) +generate_event_statuses = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(generate_event_statuses) + + +def test_render_mirrors_provided_statuses(): + output = generate_event_statuses.render({"FOO": "FOO", "BAR": "BAR_VALUE"}) + + assert "FOO: 'FOO'," in output + assert "BAR: 'BAR_VALUE'," in output + assert "export const EventStatus" in output + assert "as const" in output + assert "export type EventStatusType" in output + + +def test_render_preserves_insertion_order(): + output = generate_event_statuses.render({"A": "1", "B": "2", "C": "3"}) + + assert output.index("A: '1'") < output.index("B: '2'") < output.index("C: '3'") + + +def test_main_mirrors_every_real_event_status(tmp_path, monkeypatch): + from src.shared.event_statuses import EventStatus + + output_path = tmp_path / "event-statuses.ts" + monkeypatch.setattr("sys.argv", ["generate-event-statuses.py", str(output_path)]) + + generate_event_statuses.main() + + content = output_path.read_text() + expected = {name: value for name, value in vars(EventStatus).items() if not name.startswith("_")} + + assert expected, "EventStatus should not be empty" + for name, value in expected.items(): + assert f"{name}: '{value}'," in content + assert "GENERATED FILE" in content