diff --git a/apps/backend-agent-controller/src/migrations/1766800000000_AutonomousTicketAutomation.ts b/apps/backend-agent-controller/src/migrations/1766800000000_AutonomousTicketAutomation.ts new file mode 100644 index 00000000..457a41f6 --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1766800000000_AutonomousTicketAutomation.ts @@ -0,0 +1,286 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Client/agent autonomy, ticket automation config, leases, runs, and run steps for autonomous prototyping. + */ +export class AutonomousTicketAutomation1766800000000 implements MigrationInterface { + name = 'AutonomousTicketAutomation1766800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "statistics_interaction_kind_enum" ADD VALUE IF NOT EXISTS 'autonomous_ticket_run' + `); + await queryRunner.query(` + ALTER TYPE "statistics_interaction_kind_enum" ADD VALUE IF NOT EXISTS 'autonomous_ticket_run_turn' + `); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "ticket_automation_run_status_enum" AS ENUM ( + 'pending', 'running', 'succeeded', 'failed', 'timed_out', 'escalated', 'cancelled' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "ticket_automation_run_phase_enum" AS ENUM ( + 'pre_improve', 'workspace_prep', 'agent_loop', 'verify', 'finalize' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "ticket_automation_lease_status_enum" AS ENUM ('active', 'released', 'expired'); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + + await queryRunner.createTable( + new Table({ + name: 'client_agent_autonomy', + columns: [ + { name: 'client_id', type: 'uuid', isNullable: false, isPrimary: true }, + { name: 'agent_id', type: 'uuid', isNullable: false, isPrimary: true }, + { name: 'enabled', type: 'boolean', default: false, isNullable: false }, + { name: 'pre_improve_ticket', type: 'boolean', default: false, isNullable: false }, + { name: 'max_runtime_ms', type: 'int', default: 3600000, isNullable: false }, + { name: 'max_iterations', type: 'int', default: 20, isNullable: false }, + { name: 'token_budget_limit', type: 'int', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + { name: 'updated_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'client_agent_autonomy', + new TableForeignKey({ + columnNames: ['client_id'], + referencedTableName: 'clients', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'ticket_automation', + columns: [ + { name: 'ticket_id', type: 'uuid', isPrimary: true }, + { name: 'eligible', type: 'boolean', default: false, isNullable: false }, + { name: 'allowed_agent_ids', type: 'jsonb', isNullable: false, default: "'[]'" }, + { name: 'verifier_profile', type: 'jsonb', isNullable: true }, + { name: 'requires_approval', type: 'boolean', default: false, isNullable: false }, + { name: 'approved_at', type: 'timestamptz', isNullable: true }, + { name: 'approved_by_user_id', type: 'uuid', isNullable: true }, + { name: 'approval_baseline_ticket_updated_at', type: 'timestamptz', isNullable: true }, + { name: 'default_branch_override', type: 'varchar', length: '256', isNullable: true }, + { name: 'next_retry_at', type: 'timestamptz', isNullable: true }, + { name: 'consecutive_failure_count', type: 'int', default: 0, isNullable: false }, + { name: 'created_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + { name: 'updated_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'ticket_automation', + new TableForeignKey({ + columnNames: ['ticket_id'], + referencedTableName: 'tickets', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'ticket_automation', + new TableForeignKey({ + columnNames: ['approved_by_user_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'ticket_automation_run', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'ticket_id', type: 'uuid', isNullable: false }, + { name: 'client_id', type: 'uuid', isNullable: false }, + { name: 'agent_id', type: 'uuid', isNullable: false }, + { + name: 'status', + type: 'enum', + enum: ['pending', 'running', 'succeeded', 'failed', 'timed_out', 'escalated', 'cancelled'], + enumName: 'ticket_automation_run_status_enum', + isNullable: false, + }, + { + name: 'phase', + type: 'enum', + enum: ['pre_improve', 'workspace_prep', 'agent_loop', 'verify', 'finalize'], + enumName: 'ticket_automation_run_phase_enum', + isNullable: false, + }, + { name: 'ticket_status_before', type: 'varchar', length: '32', isNullable: false }, + { name: 'branch_name', type: 'varchar', length: '512', isNullable: true }, + { name: 'base_branch', type: 'varchar', length: '256', isNullable: true }, + { name: 'base_sha', type: 'varchar', length: '64', isNullable: true }, + { name: 'started_at', type: 'timestamptz', isNullable: false }, + { name: 'finished_at', type: 'timestamptz', isNullable: true }, + { name: 'updated_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + { name: 'iteration_count', type: 'int', default: 0, isNullable: false }, + { name: 'completion_marker_seen', type: 'boolean', default: false, isNullable: false }, + { name: 'verification_passed', type: 'boolean', isNullable: true }, + { name: 'failure_code', type: 'varchar', length: '64', isNullable: true }, + { name: 'summary', type: 'jsonb', isNullable: true }, + { name: 'cancel_requested_at', type: 'timestamptz', isNullable: true }, + { name: 'cancelled_by_user_id', type: 'uuid', isNullable: true }, + { name: 'cancellation_reason', type: 'varchar', length: '64', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'ticket_automation_run', + new TableForeignKey({ + columnNames: ['ticket_id'], + referencedTableName: 'tickets', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'ticket_automation_run', + new TableForeignKey({ + columnNames: ['client_id'], + referencedTableName: 'clients', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'ticket_automation_run', + new TableForeignKey({ + columnNames: ['cancelled_by_user_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + await queryRunner.createIndex( + 'ticket_automation_run', + new TableIndex({ + name: 'IDX_ticket_automation_run_ticket_started', + columnNames: ['ticket_id', 'started_at'], + }), + ); + await queryRunner.createIndex( + 'ticket_automation_run', + new TableIndex({ name: 'IDX_ticket_automation_run_client_status', columnNames: ['client_id', 'status'] }), + ); + await queryRunner.createIndex( + 'ticket_automation_run', + new TableIndex({ name: 'IDX_ticket_automation_run_agent_status', columnNames: ['agent_id', 'status'] }), + ); + + await queryRunner.createTable( + new Table({ + name: 'ticket_automation_lease', + columns: [ + { name: 'ticket_id', type: 'uuid', isPrimary: true }, + { name: 'holder_agent_id', type: 'uuid', isNullable: false }, + { name: 'run_id', type: 'uuid', isNullable: false }, + { name: 'lease_version', type: 'int', default: 0, isNullable: false }, + { name: 'expires_at', type: 'timestamptz', isNullable: false }, + { + name: 'status', + type: 'enum', + enum: ['active', 'released', 'expired'], + enumName: 'ticket_automation_lease_status_enum', + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + { name: 'updated_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'ticket_automation_lease', + new TableForeignKey({ + columnNames: ['ticket_id'], + referencedTableName: 'tickets', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'ticket_automation_lease', + new TableForeignKey({ + columnNames: ['run_id'], + referencedTableName: 'ticket_automation_run', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'ticket_automation_run_step', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'run_id', type: 'uuid', isNullable: false }, + { name: 'step_index', type: 'int', isNullable: false }, + { name: 'phase', type: 'varchar', length: '32', isNullable: false }, + { name: 'kind', type: 'varchar', length: '64', isNullable: false }, + { name: 'payload', type: 'jsonb', isNullable: true }, + { name: 'excerpt', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'CURRENT_TIMESTAMP', isNullable: false }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'ticket_automation_run_step', + new TableForeignKey({ + columnNames: ['run_id'], + referencedTableName: 'ticket_automation_run', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createIndex( + 'ticket_automation_run_step', + new TableIndex({ + name: 'IDX_ticket_automation_run_step_run_index', + columnNames: ['run_id', 'step_index'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('ticket_automation_run_step', true); + await queryRunner.dropTable('ticket_automation_lease', true); + await queryRunner.dropTable('ticket_automation_run', true); + await queryRunner.dropTable('ticket_automation', true); + await queryRunner.dropTable('client_agent_autonomy', true); + await queryRunner.query(`DROP TYPE IF EXISTS "ticket_automation_lease_status_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "ticket_automation_run_phase_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "ticket_automation_run_status_enum"`); + } +} diff --git a/apps/backend-agent-controller/src/migrations/1766900000000_AddPreferredChatAgentIdToTickets.ts b/apps/backend-agent-controller/src/migrations/1766900000000_AddPreferredChatAgentIdToTickets.ts new file mode 100644 index 00000000..a7d87dfd --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1766900000000_AddPreferredChatAgentIdToTickets.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persists which workspace agent the user prefers for chat/AI when viewing this ticket. + */ +export class AddPreferredChatAgentIdToTickets1766900000000 implements MigrationInterface { + name = 'AddPreferredChatAgentIdToTickets1766900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "tickets" + ADD COLUMN IF NOT EXISTS "preferred_chat_agent_id" uuid NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "tickets" DROP COLUMN IF EXISTS "preferred_chat_agent_id" + `); + } +} diff --git a/apps/backend-agent-controller/src/migrations/1767000000000_AddAutonomousTicketCommitMessageInteractionKind.ts b/apps/backend-agent-controller/src/migrations/1767000000000_AddAutonomousTicketCommitMessageInteractionKind.ts new file mode 100644 index 00000000..e30f886c --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1767000000000_AddAutonomousTicketCommitMessageInteractionKind.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds autonomous_ticket_commit_message to statistics_interaction_kind_enum for commit-subject AI I/O metrics. + */ +export class AddAutonomousTicketCommitMessageInteractionKind1767000000000 implements MigrationInterface { + name = 'AddAutonomousTicketCommitMessageInteractionKind1767000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "statistics_interaction_kind_enum" ADD VALUE IF NOT EXISTS 'autonomous_ticket_commit_message' + `); + } + + public async down(): Promise { + // PostgreSQL does not support removing enum values safely; leave enum value in place. + } +} diff --git a/apps/backend-agent-controller/src/typeorm.config.ts b/apps/backend-agent-controller/src/typeorm.config.ts index 3c8072bb..b082c0d0 100644 --- a/apps/backend-agent-controller/src/typeorm.config.ts +++ b/apps/backend-agent-controller/src/typeorm.config.ts @@ -9,7 +9,12 @@ import { StatisticsEntityEventEntity, StatisticsProvisioningReferenceEntity, StatisticsUserEntity, + ClientAgentAutonomyEntity, TicketActivityEntity, + TicketAutomationLeaseEntity, + TicketAutomationRunEntity, + TicketAutomationRunStepEntity, + TicketAutomationEntity, TicketBodyGenerationSessionEntity, TicketCommentEntity, TicketEntity, @@ -51,6 +56,11 @@ export const typeormConfig: DataSourceOptions = { TicketCommentEntity, TicketActivityEntity, TicketBodyGenerationSessionEntity, + TicketAutomationEntity, + TicketAutomationRunEntity, + TicketAutomationLeaseEntity, + TicketAutomationRunStepEntity, + ClientAgentAutonomyEntity, ], migrations: [ 'src/migrations/*.js', diff --git a/apps/backend-agent-manager/project.json b/apps/backend-agent-manager/project.json index e44d1a69..fb4bd8b4 100644 --- a/apps/backend-agent-manager/project.json +++ b/apps/backend-agent-manager/project.json @@ -213,13 +213,7 @@ "defaultConfiguration": "test" }, "start-containers": { - "dependsOn": [ - "api-container-image", - "worker-container-image", - "vnc-container-image", - "ssh-container-image", - "agi-container-image" - ], + "dependsOn": ["api-container-image"], "cache": false, "executor": "nx:run-commands", "options": { diff --git a/apps/frontend-agent-console/src/i18n/messages.de.xlf b/apps/frontend-agent-console/src/i18n/messages.de.xlf index b09ed940..c5eea7e8 100644 --- a/apps/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.de.xlf @@ -162,6 +162,70 @@ Next Weiter + + User message + Nutzernachricht + + + Assistant reply + Assistentenantwort + + + Incoming + Eingehend + + + Outgoing + Ausgehend + + + User + Benutzer + + + Client + Mandant + + + Agent + Agent + + + Client user + Mandantenbenutzer + + + Provisioning reference + Bereitstellungsreferenz + + + Created + Erstellt + + + Updated + Aktualisiert + + + Deleted + Gelöscht + + + Drop + Abbruch + + + Flag + Markierung + + + No filter drops or flags + Keine Filterabbrüche oder -markierungen + + + Filter drops and flags by type + Filterabbrüche und -markierungen nach Typ + Agenstra Agenstra @@ -318,6 +382,14 @@ Delete Environment Umgebung löschen + + Prototype autonomy + Prototyp-Autonomie + + + Prototype autonomy + Prototyp-Autonomie + Chat Chat @@ -490,6 +562,18 @@ Auto Auto + + Response + Antwort + + + Streaming + Streaming + + + Full response + Vollständige Antwort + Connect to socket to send messages Mit Socket verbinden, um Nachrichten zu senden @@ -764,6 +848,46 @@ Tickets Tickets + + Loading… + Wird geladen… + + + Control whether this agent may run autonomous ticket prototyping, including scope and safety limits. + Steuern Sie, ob dieser Agent autonomes Ticket-Prototyping ausführen darf, einschließlich Umfang und Sicherheitsgrenzen. + + + Autonomy enabled + Autonomie aktiviert + + + Pre-improve ticket before run + Ticket vor dem Lauf verbessern + + + Max runtime (ms) + Maximale Laufzeit (ms) + + + Max iterations + Maximale Iterationen + + + Token budget limit (optional) + Token-Budget-Limit (optional) + + + Leave empty for no limit + Leer lassen für kein Limit + + + Save + Speichern + + + Reset + Zurücksetzen + Tickets Tickets @@ -1020,6 +1144,58 @@ Prototype prompt generated Prototyp-Prompt erzeugt + + Automation claimed + Automatisierung beansprucht + + + Automation started + Automatisierung gestartet + + + Automation succeeded + Automatisierung erfolgreich + + + Automation failed + Automatisierung fehlgeschlagen + + + Automation timed out + Automatisierung: Zeitüberschreitung + + + Automation escalated + Automatisierung eskaliert + + + Automation requeued + Automatisierung erneut eingereiht + + + Automation approval invalidated + Automatisierungsfreigabe ungültig + + + Automation cancelled + Automatisierung abgebrochen + + + Automation approved + Automatisierung freigegeben + + + Automated runs eligibility changed + Berechtigung für automatisierte Läufe geändert + + + Automation approval requirement changed + Anforderung an die Freigabe für Automatisierung geändert + + + Automation settings updated + Automationseinstellungen aktualisiert + Unknown activity Unbekannte Aktivität @@ -1064,6 +1240,10 @@ Search tickets Tickets durchsuchen + + Autonomous prototyping enabled + Autonomes Prototyping aktiviert + No matching tickets Keine passenden Tickets @@ -1088,6 +1268,218 @@ Open subtask Unteraufgabe öffnen + + Autonomous prototyping + Autonomes Prototyping + + + Dismiss + Ausblenden + + + Loading automation settings… + Automatisierungseinstellungen werden geladen… + + + Eligible for automated runs + Für automatisierte Läufe zugelassen + + + Require approval before running + Freigabe vor dem Ausführen erforderlich + + + Approved for automation + Für Automatisierung freigegeben + + + Configuration changes may require approval before runs execute. + Konfigurationsänderungen können eine Freigabe erfordern, bevor Läufe ausgeführt werden. + + + Allowed agents + Zulässige Agenten + + + If none are selected, every agent with prototype autonomy enabled for this workspace may run this ticket. Select specific agents to restrict who can pick it up. + Wenn keine ausgewählt sind, darf jeder Agent mit aktiviertem Prototyp-Autonomie für diesen Workspace dieses Ticket ausführen. Wählen Sie bestimmte Agenten aus, um einzuschränken, wer es übernehmen kann. + + + Loading agents… + Agenten werden geladen… + + + No agents in this workspace. + Keine Agenten in diesem Workspace. + + + No agents have prototype autonomy enabled for this workspace. Enable it for an agent in chat under Prototype autonomy. Only those agents can run autonomous ticket work. + Für diesen Workspace ist bei keinem Agenten die Prototyp-Autonomie aktiviert. Aktivieren Sie sie im Chat unter „Prototyp-Autonomie“. Nur diese Agenten können autonome Ticket-Arbeit ausführen. + + + Allowed agents not listed above—uncheck to remove (e.g. autonomy off or agent removed) + Zulässige Agenten, die oben nicht aufgeführt sind – zum Entfernen abwählen (z. B. Autonomie aus oder Agent entfernt) + + + Default branch override + Standard-Branch-Überschreibung + + + Optional — leave empty to use repository default + Optional — leer lassen, um den Repository-Standard zu verwenden + + + Verifier commands + Verifizierer-Befehle + + + Add command + Befehl hinzufügen + + + Command + Befehl + + + Command + Befehl + + + Working directory + Arbeitsverzeichnis + + + cwd (optional) + Arbeitsverzeichnis (optional) + + + Remove + Entfernen + + + Saving automation settings… + Automationseinstellungen werden gespeichert… + + + Approve + Freigeben + + + Consecutive failures: + Aufeinanderfolgende Fehler: + + + Next retry: + Nächster Wiederholungsversuch: + + + Runs + Läufe + + + Refresh runs + Läufe aktualisieren + + + No runs yet. + Noch keine Läufe. + + + Verify: passed + Prüfung: bestanden + + + Verify: failed + Prüfung: fehlgeschlagen + + + Show trace + Ablauf anzeigen + + + Hide trace + Ablauf ausblenden + + + Matches Agent for chat / AI — change the chat agent to allow unchecking. + Entspricht „Agent für Chat / KI“ – ändern Sie den Chat-Agenten, um die Markierung aufheben zu können. + + + Open details + Details öffnen + + + Cancel run + Lauf abbrechen + + + Automation trace + Automatisierungsablauf + + + Loading steps… + Schritte werden geladen… + + + No steps recorded for this run. + Für diesen Lauf wurden keine Schritte aufgezeichnet. + + + Automation run + Automatisierungslauf + + + Status + Status + + + Phase + Phase + + + Branch + Branch + + + Agent + Agent + + + Iterations + Iterationen + + + Failure + Fehler + + + Cancellation + Abbruch + + + Summary (JSON) + Zusammenfassung (JSON) + + + Steps + Schritte + + + Excerpt + Auszug + + + Payload (JSON) + Nutzdaten (JSON) + + + Open a run from the ticket detail panel. + Öffnen Sie einen Lauf im Ticket-Detailfenster. + + + Cancel run + Lauf abbrechen + Ticket hierarchy Ticket-Hierarchie @@ -2790,6 +3182,138 @@ Close Schließen + + Approval invalidated + Freigabe ungültig geworden + + + Lease expired + Lease abgelaufen + + + System shutdown + Systemabschaltung + + + User request + Benutzeranfrage + + + No completion marker + Kein Abschluss-Marker + + + Agent provider error + Agent-Provider-Fehler + + + Approval missing + Freigabe fehlt + + + Budget exceeded + Budget überschritten + + + Git commit failed + Git-Commit fehlgeschlagen + + + Human escalation + Manuelle Eskalation + + + Lease contention + Lease-Konflikt + + + Completion marker without verify profile + Abschluss-Marker ohne Verifizierungsprofil + + + Orchestrator stale + Orchestrator veraltet + + + Git push failed + Git-Push fehlgeschlagen + + + Verify command failed + Verifizierungsbefehl fehlgeschlagen + + + Branch already exists + Branch existiert bereits + + + Dirty workspace + Arbeitsverzeichnis nicht sauber + + + Agent loop + Agent-Schleife + + + Finalize + Abschluss + + + Pre-improve + Vorab-Verbesserung + + + Verify + Verifizierung + + + Workspace prep + Workspace-Vorbereitung + + + Cancelled + Abgebrochen + + + Escalated + Eskaliert + + + Failed + Fehlgeschlagen + + + Pending + Ausstehend + + + Running + Laufend + + + Succeeded + Erfolgreich + + + Timed out + Zeitüberschreitung + + + Agent turn + Agent-Zug + + + Git commit + Git-Commit + + + Git push + Git-Push + + + Repository setup + Repository-Einrichtung + diff --git a/apps/frontend-agent-console/src/i18n/messages.xlf b/apps/frontend-agent-console/src/i18n/messages.xlf index c10f7439..c2b42077 100644 --- a/apps/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.xlf @@ -122,6 +122,54 @@ No data yet. Apply filters and load statistics. + + User message + + + Assistant reply + + + Incoming + + + Outgoing + + + User + + + Client + + + Agent + + + Client user + + + Provisioning reference + + + Created + + + Updated + + + Deleted + + + Drop + + + Flag + + + No filter drops or flags + + + Filter drops and flags by type + Agenstra @@ -209,6 +257,12 @@ Delete Environment + + Prototype autonomy + + + Prototype autonomy + Chat @@ -302,6 +356,15 @@ Auto + + Response + + + Streaming + + + Full response + Connect to socket to send messages @@ -1309,6 +1372,36 @@ New Terminal + + Loading… + + + Control whether this agent may run autonomous ticket prototyping, including scope and safety limits. + + + Autonomy enabled + + + Pre-improve ticket before run + + + Max runtime (ms) + + + Max iterations + + + Token budget limit (optional) + + + Leave empty for no limit + + + Save + + + Reset + Tickets @@ -1324,6 +1417,18 @@ Add + + Loading workspaces… + + + You don't have any workspaces yet. + + + Create one from Spaces, or open Spaces if a workspace was shared with you. + + + Go to Spaces + Select a space from @@ -1333,6 +1438,9 @@ to load tickets. + + Choose workspace… + Loading tickets… @@ -1342,6 +1450,9 @@ Search tickets + + Autonomous prototyping enabled + Drop tickets here @@ -1396,6 +1507,114 @@ Open subtask + + Autonomous prototyping + + + Dismiss + + + Loading automation settings… + + + Eligible for automated runs + + + Require approval before running + + + Approved for automation + + + Configuration changes may require approval before runs execute. + + + Allowed agents + + + If none are selected, every agent with prototype autonomy enabled for this workspace may run this ticket. Select specific agents to restrict who can pick it up. + + + Loading agents… + + + No agents in this workspace. + + + No agents have prototype autonomy enabled for this workspace. Enable it for an agent in chat under Prototype autonomy. Only those agents can run autonomous ticket work. + + + Allowed agents not listed above—uncheck to remove (e.g. autonomy off or agent removed) + + + Default branch override + + + Optional — leave empty to use repository default + + + Verifier commands + + + Add command + + + Command + + + Command + + + Working directory + + + cwd (optional) + + + Remove + + + Saving automation settings… + + + Approve + + + Consecutive failures: + + + Next retry: + + + Runs + + + Refresh runs + + + No runs yet. + + + Verify: passed + + + Verify: failed + + + Open details + + + Cancel run + + + Automation trace + + + Loading steps… + + + No steps recorded for this run. + Comments @@ -1423,6 +1642,48 @@ Close + + Automation run + + + Status + + + Phase + + + Branch + + + Agent + + + Iterations + + + Failure + + + Cancellation + + + Summary (JSON) + + + Steps + + + Excerpt + + + Payload (JSON) + + + Open a run from the ticket detail panel. + + + Cancel run + Delete ticket @@ -1495,21 +1756,6 @@ Search workspaces - - Loading workspaces… - - - You don't have any workspaces yet. - - - Create one from Spaces, or open Spaces if a workspace was shared with you. - - - Go to Spaces - - - Choose workspace… - No matching workspaces @@ -1588,9 +1834,57 @@ Prototype prompt generated + + Automation claimed + + + Automation started + + + Automation succeeded + + + Automation failed + + + Automation timed out + + + Automation escalated + + + Automation requeued + + + Automation approval invalidated + + + Automation cancelled + + + Automation approved + + + Automated runs eligibility changed + + + Automation approval requirement changed + + + Automation settings updated + Unknown activity + + Hide trace + + + Show trace + + + Matches Agent for chat / AI — change the chat agent to allow unchecking. + Select a space before creating a ticket. @@ -2093,6 +2387,105 @@ Delete + + Approval invalidated + + + Lease expired + + + System shutdown + + + User request + + + No completion marker + + + Agent provider error + + + Approval missing + + + Budget exceeded + + + Git commit failed + + + Human escalation + + + Lease contention + + + Completion marker without verify profile + + + Orchestrator stale + + + Git push failed + + + Verify command failed + + + Branch already exists + + + Dirty workspace + + + Agent loop + + + Finalize + + + Pre-improve + + + Verify + + + Workspace prep + + + Cancelled + + + Escalated + + + Failed + + + Pending + + + Running + + + Succeeded + + + Timed out + + + Agent turn + + + Git commit + + + Git push + + + Repository setup + diff --git a/apps/frontend-agent-console/src/styles.scss b/apps/frontend-agent-console/src/styles.scss index 4950906e..c68fba31 100644 --- a/apps/frontend-agent-console/src/styles.scss +++ b/apps/frontend-agent-console/src/styles.scss @@ -79,6 +79,28 @@ $danger-border-subtle: #f5d0d2; $light-border-subtle: $gray-200; $dark-border-subtle: #b9bcc4; +$badge-font-size: 0.75rem; +$badge-font-weight: 400; +$badge-padding-y: 0.25rem; +$badge-padding-x: 0.5rem; +$badge-border-radius: 0.375rem; +$badge-line-height: 1.5; + +@mixin agenstra-badge-text-bg-tint($color-name) { + .badge { + &.bg-#{$color-name}, + &.text-bg-#{$color-name} { + background-color: color-mix(in srgb, var(--bs-#{$color-name}) 24%, var(--bs-tertiary-bg)) !important; + color: var(--bs-#{$color-name}-text-emphasis) !important; + line-height: $badge-line-height; + } + } +} +$agenstra-badge-text-bg-colors: primary, secondary, success, info, warning, danger, light, dark; +@each $color-name in $agenstra-badge-text-bg-colors { + @include agenstra-badge-text-bg-tint($color-name); +} + @import 'bootstrap/scss/bootstrap'; // Extra --bs-* variables not generated by Bootstrap (used by portal components) @@ -129,3 +151,7 @@ $dark-border-subtle: #b9bcc4; background-color: #692eee !important; } } + +.mh-0 { + min-height: 0 !important; +} diff --git a/apps/frontend-billing-console/src/styles.scss b/apps/frontend-billing-console/src/styles.scss index d33bd0af..2e7d1712 100644 --- a/apps/frontend-billing-console/src/styles.scss +++ b/apps/frontend-billing-console/src/styles.scss @@ -79,6 +79,28 @@ $danger-border-subtle: #f5d0d2; $light-border-subtle: $gray-200; $dark-border-subtle: #b9bcc4; +$badge-font-size: 0.75rem; +$badge-font-weight: 400; +$badge-padding-y: 0.25rem; +$badge-padding-x: 0.5rem; +$badge-border-radius: 0.375rem; +$badge-line-height: 1.5; + +@mixin agenstra-badge-text-bg-tint($color-name) { + .badge { + &.bg-#{$color-name}, + &.text-bg-#{$color-name} { + background-color: color-mix(in srgb, var(--bs-#{$color-name}) 24%, var(--bs-tertiary-bg)) !important; + color: var(--bs-#{$color-name}-text-emphasis) !important; + line-height: $badge-line-height; + } + } +} +$agenstra-badge-text-bg-colors: primary, secondary, success, info, warning, danger, light, dark; +@each $color-name in $agenstra-badge-text-bg-colors { + @include agenstra-badge-text-bg-tint($color-name); +} + @import 'bootstrap/scss/bootstrap'; // Extra --bs-* variables not generated by Bootstrap (used by portal components) diff --git a/apps/frontend-docs/src/styles.scss b/apps/frontend-docs/src/styles.scss index 7ab25d6b..dda78fa8 100644 --- a/apps/frontend-docs/src/styles.scss +++ b/apps/frontend-docs/src/styles.scss @@ -81,6 +81,28 @@ $dark-border-subtle: #b9bcc4; $font-family-base: 'Roboto', sans-serif; +$badge-font-size: 0.75rem; +$badge-font-weight: 400; +$badge-padding-y: 0.25rem; +$badge-padding-x: 0.5rem; +$badge-border-radius: 0.375rem; +$badge-line-height: 1.5; + +@mixin agenstra-badge-text-bg-tint($color-name) { + .badge { + &.bg-#{$color-name}, + &.text-bg-#{$color-name} { + background-color: color-mix(in srgb, var(--bs-#{$color-name}) 24%, var(--bs-tertiary-bg)) !important; + color: var(--bs-#{$color-name}-text-emphasis) !important; + line-height: $badge-line-height; + } + } +} +$agenstra-badge-text-bg-colors: primary, secondary, success, info, warning, danger, light, dark; +@each $color-name in $agenstra-badge-text-bg-colors { + @include agenstra-badge-text-bg-tint($color-name); +} + @import 'bootstrap/scss/bootstrap'; // Extra --bs-* variables not generated by Bootstrap (used by portal components) diff --git a/apps/frontend-portal/src/styles.scss b/apps/frontend-portal/src/styles.scss index 096d828f..db11a32d 100644 --- a/apps/frontend-portal/src/styles.scss +++ b/apps/frontend-portal/src/styles.scss @@ -82,6 +82,28 @@ $danger-border-subtle: #f5d0d2; $light-border-subtle: $gray-200; $dark-border-subtle: #b9bcc4; +$badge-font-size: 0.75rem; +$badge-font-weight: 400; +$badge-padding-y: 0.25rem; +$badge-padding-x: 0.5rem; +$badge-border-radius: 0.375rem; +$badge-line-height: 1.5; + +@mixin agenstra-badge-text-bg-tint($color-name) { + .badge { + &.bg-#{$color-name}, + &.text-bg-#{$color-name} { + background-color: color-mix(in srgb, var(--bs-#{$color-name}) 24%, var(--bs-tertiary-bg)) !important; + color: var(--bs-#{$color-name}-text-emphasis) !important; + line-height: $badge-line-height; + } + } +} +$agenstra-badge-text-bg-colors: primary, secondary, success, info, warning, danger, light, dark; +@each $color-name in $agenstra-badge-text-bg-colors { + @include agenstra-badge-text-bg-tint($color-name); +} + @import 'bootstrap/scss/bootstrap'; // Extra --bs-* variables not generated by Bootstrap (used by portal components) diff --git a/libs/domains/framework/backend/feature-agent-controller/README.md b/libs/domains/framework/backend/feature-agent-controller/README.md index 18ed9eb9..089af7ff 100644 --- a/libs/domains/framework/backend/feature-agent-controller/README.md +++ b/libs/domains/framework/backend/feature-agent-controller/README.md @@ -485,7 +485,7 @@ See the [AsyncAPI specification](./spec/asyncapi.yaml) for complete event docume The agent-controller collects persistent statistics for analytics and future REST API exposure: -- **Chat I/O** - Word and character counts for user input and agent output (includes **prompt enhancement** via `forward` event `enhanceChat`; rows use `interaction_kind` to distinguish normal chat from enhancement) +- **Chat I/O** - Word and character counts for user input and agent output (includes **prompt enhancement** via `forward` event `enhanceChat`; rows use `interaction_kind` to distinguish normal chat from enhancement, **ticket body generation**, and **autonomous ticket runs** via `autonomous_ticket_run` / `autonomous_ticket_run_turn`) - **Filter drops** - Messages dropped by content filters (profanity, PII, etc.) with filter type and direction - **Entity lifecycle** - Creation, update, and deletion of clients, agents, users, client-user relationships, and provisioning references @@ -493,6 +493,8 @@ Data is stored in shadow tables (`statistics_*`) with no secrets. See [Statistic **Prompt enhancement (magic wand)** does not create rows in `agent_messages` or show up as normal chat bubbles; the controller records draft and enhanced text metrics in `statistics_chat_io` with `interaction_kind = prompt_enhancement`. Filter parity with normal chat applies on the agent-manager side. +**Autonomous ticket prototyping** records each background `RemoteAgentsSessionService` turn under `autonomous_ticket_run_turn` (orchestrator-level summaries may use `autonomous_ticket_run` when wired). See `autonomous-ticket-automation.md` and `docs/sequence-autonomous-ticket.mmd`. + ### WebSocket Authentication The gateway authenticates with remote agent-manager services using: diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd new file mode 100644 index 00000000..2c90139d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd @@ -0,0 +1,30 @@ +sequenceDiagram + participant Sched as AutonomousTicketScheduler + participant Orch as AutonomousRunOrchestrator + participant DB as Postgres + participant Vcs as ClientAgentVcsProxy + participant Chat as RemoteAgentsSession + participant Mgr as AgentManagerGateway + participant Prov as AgentProvider + + Sched->>Orch: processBatch(batchSize) + Orch->>DB: find eligible tickets + autonomy + Orch->>DB: pessimistic lease + create run + Orch->>Vcs: prepareCleanWorkspace(baseBranch) + Orch->>Vcs: createBranch(automation/…) + loop maxIterations + Orch->>Chat: sendChatSync(sync, ephemeral) + Chat->>Mgr: chat + Mgr->>Prov: sendMessage(continue, resumeSessionSuffix) + Prov-->>Mgr: NDJSON / text + Mgr-->>Chat: chatMessage + Chat-->>Orch: assistant text + Orch->>DB: append run step + end + Orch->>Vcs: runVerifierCommands + Orch->>Vcs: getStatus; if dirty: stage all + Orch->>Chat: sendChatSync (conventional commit subject; autonomous_ticket_commit_message) + Orch->>Vcs: commit (fallback message if AI invalid or timed out) + Orch->>Vcs: push (current branch to origin; fails run on error) + Orch->>DB: finalize run + ticket prototype / restore + Orch->>DB: release lease diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml index c8e2d0ec..f0f10240 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml @@ -11,11 +11,15 @@ info: the requested client (global admin, client creator, or client_users entry) can set that client context. Unauthorized setClient attempts emit an `error` event with message "You do not have access to this client". + HTTP REST endpoints for prototype autonomy, environment variables, and agent CRUD enforce additional + workspace-manager rules; see OpenAPI spec (ClientResponseDto.canManageWorkspaceConfiguration and 403 descriptions). servers: local: host: localhost:8081 protocol: ws - description: Socket.IO server (namespace /clients). Pass Authorization header in handshake. + description: | + Socket.IO server (namespace from env WEBSOCKET_NAMESPACE, default "clients"; port from env WEBSOCKET_PORT, default 8081). + Pass Authorization header in handshake (or handshake.auth.Authorization for browser WebSockets). channels: clients/setClient: address: clients/setClient @@ -54,7 +58,31 @@ channels: messages: forwardCommand: $ref: '#/components/messages/Forward' - description: Forward an arbitrary event to the selected client's agents namespace. When `agentId` is provided, the gateway automatically logs in the agent using stored credentials before forwarding the event. To restore chat history for an agent, forward a "login" event with `agentId` (the payload is automatically overridden with credentials from the database, triggering login and subsequent chat history restoration). Supported events include "chat", "enhanceChat" (prompt improvement; payload `{ message, correlationId, model? }`; response `chatEnhanceResult` unicast from agent-manager, proxied to this socket only), "generateTicketBody" (ticket body from title; payload `{ title, correlationId, model? }`; response `ticketBodyResult` unicast, same envelope shape as `chatEnhanceResult`), "fileUpdate", "logout", "createTerminal", "terminalInput", "closeTerminal", etc. The remote agent-manager gateway will process the event and may emit response events (e.g., "chatMessage", "chatEnhanceResult", "ticketBodyResult", "fileUpdateNotification", "terminalCreated", "terminalOutput", "terminalClosed", "containerStats") which are automatically forwarded back to the client. The "containerStats" event payload (defined in the agent-manager AsyncAPI) includes container status (running/stopped) and, when running, container statistics. + description: | + Forward an arbitrary event to the selected client's agents namespace. When `agentId` is provided, + the gateway automatically logs in the agent using stored credentials before forwarding the event. + To restore chat history for an agent, forward a "login" event with `agentId` (the payload is + automatically overridden with credentials from the database, triggering login and subsequent + chat history restoration). Supported events include "chat", "enhanceChat" (prompt improvement; + payload `{ message, correlationId, model? }`; response `chatEnhanceResult` unicast from + agent-manager, proxied to this socket only), "generateTicketBody" (ticket body from title; + payload `{ title, correlationId, model? }`; response `ticketBodyResult` unicast, same envelope + shape as `chatEnhanceResult`), "fileUpdate", "logout", "createTerminal", "terminalInput", + "closeTerminal", etc. The remote agent-manager gateway will process the event and may emit + response events (e.g., "chatMessage", "chatEnhanceResult", "ticketBodyResult", + "fileUpdateNotification", "terminalCreated", "terminalOutput", "terminalClosed", "containerStats") + which are automatically forwarded back to the client. The "containerStats" event payload + (defined in the agent-manager AsyncAPI) includes container status (running/stopped) and, when + running, container statistics. For autonomous ticket runs, proxied `chat` may include + `responseMode: "single" | "stream" | "sync"`, `ephemeral: true`, `continue`, and `resumeSessionSuffix`; + the controller also uses isolated suffixes (e.g. `-ticket-auto-loop`, `-ticket-auto-commit-msg`) for + implementation turns vs. post-verify conventional-commit subject generation; statistics on the controller + side record the latter under interaction kind `autonomous_ticket_commit_message`. + After a successful run, the controller also completes the workflow over HTTP by proxying agent-manager VCS + `commit` and `push` (see controller OpenAPI under `/clients/{clientId}/agents/{agentId}/vcs/...`). + agent-manager passes these to `sendMessage` and skips `agent_messages` persistence when `ephemeral` is true. + When `ephemeral` is true, chatMessage / chatEvent traffic for that turn is emitted only to the requesting + socket (not broadcast to other viewers on the same agent). clients/forwardAck: address: clients/forwardAck messages: @@ -200,7 +228,7 @@ components: When event is "closeTerminal", the payload should contain {sessionId: string}. When event is "enhanceChat", the payload should contain { message: string, correlationId: string, model?: string }. When event is "generateTicketBody", the payload should contain { title: string, correlationId: string, model?: string }. - When event is "chat", the payload may include { correlationId?: string, responseMode?: \"single\"|\"stream\" } to enable streaming/tool events. + When event is "chat", the payload may include { message, correlationId?, model?, responseMode?: "single"|"stream"|"sync", ephemeral?: boolean, continue?: boolean, resumeSessionSuffix?: string }. Response events forwarded from the agent-manager include "containerStats" (payload has status.running and optional stats). agentId: type: string diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml index a304f719..be9e8caa 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -344,6 +344,7 @@ paths: type: boolean responses: '200': + description: Ticket content: application/json: schema: @@ -360,6 +361,7 @@ paths: $ref: '#/components/schemas/UpdateTicketDto' responses: '200': + description: Ticket updated content: application/json: schema: @@ -385,6 +387,7 @@ paths: format: uuid responses: '200': + description: Prototype prompt content: application/json: schema: @@ -402,6 +405,7 @@ paths: operationId: listTicketComments responses: '200': + description: Ticket comments content: application/json: schema: @@ -419,6 +423,7 @@ paths: $ref: '#/components/schemas/CreateTicketCommentDto' responses: '201': + description: Comment added content: application/json: schema: @@ -446,6 +451,7 @@ paths: default: 0 responses: '200': + description: Ticket activity content: application/json: schema: @@ -470,6 +476,7 @@ paths: $ref: '#/components/schemas/StartBodyGenerationSessionDto' responses: '200': + description: AI body generation session started content: application/json: schema: @@ -493,10 +500,225 @@ paths: $ref: '#/components/schemas/ApplyGeneratedBodyDto' responses: '200': + description: AI-generated ticket body applied content: application/json: schema: $ref: '#/components/schemas/TicketResponseDto' + /tickets/{ticketId}/automation: + get: + summary: Get ticket automation configuration + operationId: getTicketAutomation + parameters: + - in: path + name: ticketId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Automation row for the ticket + content: + application/json: + schema: + $ref: '#/components/schemas/TicketAutomationResponseDto' + patch: + summary: Update ticket automation configuration + description: > + Applies partial updates. Only fields present in the body are considered; unchanged values do not + persist a new row or emit activity. Activity rows use action types such as AUTOMATION_ELIGIBILITY_CHANGED, + AUTOMATION_APPROVAL_REQUIREMENT_CHANGED, AUTOMATION_SETTINGS_UPDATED, and AUTOMATION_APPROVAL_INVALIDATED + only when prior approval was meaningful (requiresApproval was true and approval existed), except that + turning off the approval requirement alone does not emit AUTOMATION_APPROVAL_INVALIDATED. + operationId: patchTicketAutomation + parameters: + - in: path + name: ticketId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTicketAutomationDto' + responses: + '200': + description: Updated automation configuration + content: + application/json: + schema: + $ref: '#/components/schemas/TicketAutomationResponseDto' + /tickets/{ticketId}/automation/approve: + post: + summary: Approve automation run (when requiresApproval is true) + operationId: approveTicketAutomation + parameters: + - in: path + name: ticketId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Approval recorded + content: + application/json: + schema: + $ref: '#/components/schemas/TicketAutomationResponseDto' + /tickets/{ticketId}/automation/runs: + get: + summary: List automation runs for a ticket + operationId: listTicketAutomationRuns + parameters: + - in: path + name: ticketId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Run history + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TicketAutomationRunResponseDto' + /tickets/{ticketId}/automation/runs/{runId}: + get: + summary: Get automation run detail including steps + operationId: getTicketAutomationRun + parameters: + - in: path + name: ticketId + required: true + schema: + type: string + format: uuid + - in: path + name: runId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Run detail + content: + application/json: + schema: + $ref: '#/components/schemas/TicketAutomationRunResponseDto' + /tickets/{ticketId}/automation/runs/{runId}/cancel: + post: + summary: Cancel a pending or running automation run + operationId: cancelTicketAutomationRun + parameters: + - in: path + name: ticketId + required: true + schema: + type: string + format: uuid + - in: path + name: runId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Run cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/TicketAutomationRunResponseDto' + /clients/{id}/agent-autonomy/enabled-agent-ids: + get: + summary: List agent IDs with prototype autonomy enabled for this client + description: | + Only agents returned here can be picked by the autonomous ticket scheduler for this workspace + (intersection with each ticket's allowed-agent list). Matches `client_agent_autonomy.enabled = true`. + operationId: listEnabledAutonomyAgentIds + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: UUIDs of agents with autonomy enabled + content: + application/json: + schema: + type: object + required: + - agentIds + properties: + agentIds: + type: array + items: + type: string + format: uuid + /clients/{id}/agents/{agentId}/autonomy: + get: + summary: Get per-agent autonomy settings + operationId: getClientAgentAutonomy + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Autonomy configuration + put: + summary: Create or update per-agent autonomy settings + description: | + Requires workspace management rights (see ClientResponseDto.canManageWorkspaceConfiguration). + Interactive user context required; API key auth may manage when applicable. + operationId: upsertClientAgentAutonomy + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Saved autonomy configuration + '403': + description: | + No client access, or workspace management rights required (plain client member cannot update autonomy). /clients: get: summary: List clients @@ -578,8 +800,9 @@ paths: post: summary: Update client description: | - Updates a client. Only accessible if the user has access to the client. - Access rules are the same as GET /clients/{id}. + Updates a client. Requires workspace management rights: global admin, workspace creator, + client_users role `admin`, API key authentication, or api-key server mode (not plain client_users `user`). + See `canManageWorkspaceConfiguration` on ClientResponseDto. operationId: updateClient parameters: - in: path @@ -602,12 +825,13 @@ paths: schema: $ref: '#/components/schemas/ClientResponseDto' '403': - description: User does not have access to this client + description: | + Forbidden — no access to this client, or caller lacks workspace management rights + (same rule as POST for updates). delete: summary: Delete client description: | - Deletes a client. Only accessible if the user has access to the client. - Access rules are the same as GET /clients/{id}. + Deletes a client. Same workspace management requirement as POST /clients/{id} (not available to plain client_users `user`). operationId: deleteClient parameters: - in: path @@ -620,7 +844,8 @@ paths: '204': description: Deleted '403': - description: User does not have access to this client + description: | + Forbidden — no access to this client, or caller lacks workspace management rights. /clients/{id}/users: get: summary: List users associated with a client @@ -755,6 +980,7 @@ paths: description: User does not have access to this client post: summary: Create agent for a client + description: Requires workspace management rights (see ClientResponseDto.canManageWorkspaceConfiguration). operationId: createClientAgent parameters: - in: path @@ -778,7 +1004,7 @@ paths: schema: $ref: '#/components/schemas/CreateAgentResponseDto' '403': - description: User does not have access to this client + description: No client access, or workspace management rights required /clients/{id}/agents/{agentId}: get: summary: Get agent by id for a client @@ -809,6 +1035,7 @@ paths: description: User does not have access to this client post: summary: Update agent for a client + description: Requires workspace management rights (see ClientResponseDto.canManageWorkspaceConfiguration). operationId: updateClientAgent parameters: - in: path @@ -839,9 +1066,10 @@ paths: schema: $ref: '#/components/schemas/AgentResponseDto' '403': - description: User does not have access to this client + description: No client access, or workspace management rights required delete: summary: Delete agent for a client + description: Requires workspace management rights. operationId: deleteClientAgent parameters: - in: path @@ -862,7 +1090,7 @@ paths: '204': description: Deleted '403': - description: User does not have access to this client + description: No client access, or workspace management rights required /clients/{id}/agents/{agentId}/models: get: summary: List models for an agent (proxied) @@ -1264,6 +1492,7 @@ paths: description: Client or agent not found post: summary: Create environment variable for an agent (proxied) + description: Requires workspace management rights. operationId: createClientAgentEnvironmentVariable parameters: - in: path @@ -1295,10 +1524,13 @@ paths: $ref: '#/components/schemas/EnvironmentVariableResponseDto' '400': description: Invalid request + '403': + description: No client access, or workspace management rights required '404': description: Client or agent not found delete: summary: Delete all environment variables for an agent (proxied) + description: Requires workspace management rights. operationId: deleteAllClientAgentEnvironmentVariables parameters: - in: path @@ -1327,7 +1559,7 @@ paths: deletedCount: type: integer '403': - description: User does not have access to this client + description: No client access, or workspace management rights required '404': description: Client or agent not found /clients/{id}/agents/{agentId}/environment/count: @@ -1367,6 +1599,7 @@ paths: /clients/{id}/agents/{agentId}/environment/{envVarId}: put: summary: Update environment variable for an agent (proxied) + description: Requires workspace management rights. operationId: updateClientAgentEnvironmentVariable parameters: - in: path @@ -1406,11 +1639,12 @@ paths: '400': description: Invalid request '403': - description: User does not have access to this client + description: No client access, or workspace management rights required '404': description: Client, agent, or environment variable not found delete: summary: Delete environment variable for an agent (proxied) + description: Requires workspace management rights. operationId: deleteClientAgentEnvironmentVariable parameters: - in: path @@ -1438,7 +1672,7 @@ paths: '204': description: Environment variable deleted successfully '403': - description: User does not have access to this client + description: No client access, or workspace management rights required '404': description: Client, agent, or environment variable not found /clients/{id}/agents/{agentId}/vcs/status: @@ -1830,6 +2064,67 @@ paths: description: User does not have access to this client '404': description: Client or agent not found + /clients/{id}/agents/{agentId}/vcs/workspace/prepare-clean: + post: + summary: Prepare clean workspace (fetch, reset --hard, clean) — proxied + operationId: prepareCleanClientAgentWorkspace + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [baseBranch] + properties: + baseBranch: + type: string + responses: + '204': + description: Workspace reset + '403': + description: Forbidden + '404': + description: Not found + /clients/{id}/agents/{agentId}/automation/verify-commands: + post: + summary: Run verifier shell commands in agent container (proxied) + operationId: runClientAgentVerifierCommands + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Command results with exit codes /clients/{id}/agents/{agentId}/vcs/rebase: post: summary: Rebase current branch (proxied) @@ -3163,6 +3458,7 @@ components: endpoint, authenticationType, isAutoProvisioned, + canManageWorkspaceConfiguration, createdAt, updatedAt, ] @@ -3190,6 +3486,12 @@ components: isAutoProvisioned: type: boolean description: Indicates whether the client was auto-provisioned via server provisioning. Used to determine if server info endpoints should be called. + canManageWorkspaceConfiguration: + type: boolean + description: | + True if the current caller may change prototype autonomy, agent environment variables, agent CRUD, + and workspace (client) update/delete. Requires global admin, workspace creator, client_users admin, + API key authentication, or api-key server mode. createdAt: type: string format: date-time @@ -4104,9 +4406,47 @@ components: TicketActorType: type: string enum: [human, ai, system] + TicketActionType: + type: string + description: Ticket activity row discriminator; stored as varchar(64) in the database. + enum: + - CREATED + - FIELD_UPDATED + - STATUS_CHANGED + - PRIORITY_CHANGED + - WORKSPACE_MOVED + - PARENT_CHANGED + - DELETED + - COMMENT_ADDED + - CONTENT_APPLIED_FROM_AI + - BODY_GENERATION_STARTED + - PROTOTYPE_PROMPT_GENERATED + - AUTOMATION_CLAIMED + - AUTOMATION_STARTED + - AUTOMATION_SUCCEEDED + - AUTOMATION_FAILED + - AUTOMATION_TIMED_OUT + - AUTOMATION_ESCALATED + - AUTOMATION_REQUEUED + - AUTOMATION_APPROVAL_INVALIDATED + - AUTOMATION_CANCELLED + - AUTOMATION_APPROVED + - AUTOMATION_ELIGIBILITY_CHANGED + - AUTOMATION_APPROVAL_REQUIREMENT_CHANGED + - AUTOMATION_SETTINGS_UPDATED TicketResponseDto: type: object - required: [id, clientId, title, priority, status, createdAt, updatedAt] + required: + [ + id, + clientId, + title, + priority, + status, + automationEligible, + createdAt, + updatedAt, + ] properties: id: type: string @@ -4117,12 +4457,10 @@ components: parentId: type: string format: uuid - nullable: true title: type: string content: type: string - nullable: true priority: $ref: '#/components/schemas/TicketPriority' status: @@ -4130,10 +4468,15 @@ components: createdByUserId: type: string format: uuid - nullable: true createdByEmail: type: string - nullable: true + preferredChatAgentId: + type: string + format: uuid + description: Preferred workspace agent for chat/AI when viewing this ticket (agent-manager agent id). + automationEligible: + type: boolean + description: Whether autonomous prototyping is enabled for this ticket (ticket_automation.eligible). createdAt: type: string format: date-time @@ -4154,7 +4497,6 @@ components: parentId: type: string format: uuid - nullable: true title: type: string content: @@ -4172,7 +4514,6 @@ components: parentId: type: string format: uuid - nullable: true title: type: string content: @@ -4181,6 +4522,10 @@ components: $ref: '#/components/schemas/TicketPriority' status: $ref: '#/components/schemas/TicketStatus' + preferredChatAgentId: + type: string + format: uuid + description: Persist preferred workspace agent for chat/AI; send null to clear. TicketCommentResponseDto: type: object required: [id, ticketId, body, createdAt] @@ -4194,10 +4539,8 @@ components: authorUserId: type: string format: uuid - nullable: true authorEmail: type: string - nullable: true body: type: string createdAt: @@ -4227,15 +4570,227 @@ components: actorUserId: type: string format: uuid - nullable: true actorEmail: type: string - nullable: true actionType: + $ref: '#/components/schemas/TicketActionType' + payload: + type: object + additionalProperties: true + description: > + Shape depends on actionType (e.g. automation eligibility changes include { eligible: boolean }; + AUTOMATION_SETTINGS_UPDATED includes { fields: string[] }; + AUTOMATION_APPROVAL_INVALIDATED may include { reason: string, fields: string[] }). + VerifierCommandEntryDto: + type: object + required: [cmd] + properties: + cmd: + type: string + maxLength: 2048 + cwd: + type: string + maxLength: 2048 + TicketVerifierProfileDto: + type: object + required: [commands] + properties: + commands: + type: array + maxItems: 32 + items: + $ref: '#/components/schemas/VerifierCommandEntryDto' + UpdateTicketAutomationDto: + type: object + properties: + eligible: + type: boolean + allowedAgentIds: + type: array + items: + type: string + format: uuid + verifierProfile: + $ref: '#/components/schemas/TicketVerifierProfileDto' + requiresApproval: + type: boolean + defaultBranchOverride: + type: string + maxLength: 256 + TicketAutomationResponseDto: + type: object + required: + - ticketId + - eligible + - allowedAgentIds + - verifierProfile + - requiresApproval + - approvedAt + - approvedByUserId + - approvalBaselineTicketUpdatedAt + - defaultBranchOverride + - nextRetryAt + - consecutiveFailureCount + - createdAt + - updatedAt + properties: + ticketId: + type: string + format: uuid + eligible: + type: boolean + allowedAgentIds: + type: array + items: + type: string + format: uuid + verifierProfile: + oneOf: + - $ref: '#/components/schemas/TicketVerifierProfileDto' + - type: 'null' + requiresApproval: + type: boolean + approvedAt: + type: string + format: date-time + approvedByUserId: + type: string + format: uuid + approvalBaselineTicketUpdatedAt: + type: string + format: date-time + defaultBranchOverride: + type: string + nextRetryAt: + type: string + format: date-time + consecutiveFailureCount: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + TicketAutomationRunStatus: + type: string + enum: + [pending, running, succeeded, failed, timed_out, escalated, cancelled] + TicketAutomationRunPhase: + type: string + enum: [pre_improve, workspace_prep, agent_loop, verify, finalize] + TicketAutomationCancellationReason: + type: string + enum: [user_request, approval_invalidated, lease_expired, system_shutdown] + TicketAutomationRunStepResponseDto: + type: object + required: [id, stepIndex, phase, kind, payload, excerpt, createdAt] + properties: + id: + type: string + format: uuid + stepIndex: + type: integer + phase: + type: string + kind: type: string payload: type: object additionalProperties: true + excerpt: + type: string + createdAt: + type: string + format: date-time + TicketAutomationRunResponseDto: + type: object + required: + - id + - ticketId + - clientId + - agentId + - status + - phase + - ticketStatusBefore + - branchName + - baseBranch + - baseSha + - startedAt + - finishedAt + - updatedAt + - iterationCount + - completionMarkerSeen + - verificationPassed + - failureCode + - summary + - cancelRequestedAt + - cancelledByUserId + - cancellationReason + properties: + id: + type: string + format: uuid + ticketId: + type: string + format: uuid + clientId: + type: string + format: uuid + agentId: + type: string + format: uuid + status: + $ref: '#/components/schemas/TicketAutomationRunStatus' + phase: + $ref: '#/components/schemas/TicketAutomationRunPhase' + ticketStatusBefore: + type: string + branchName: + type: string + baseBranch: + type: string + baseSha: + type: string + startedAt: + type: string + format: date-time + finishedAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + iterationCount: + type: integer + completionMarkerSeen: + type: boolean + verificationPassed: + type: boolean + failureCode: + type: string + description: > + Present when a run ends unsuccessfully. Includes values such as verify_command_failed, + commit_failed (git commit after successful verification), push_failed (git push after that commit), + agent_no_completion_marker, marker_without_verify, agent_provider_error, vcs_dirty_workspace, + and others defined by the controller. + summary: + type: object + additionalProperties: true + cancelRequestedAt: + type: string + format: date-time + cancelledByUserId: + type: string + format: uuid + cancellationReason: + oneOf: + - $ref: '#/components/schemas/TicketAutomationCancellationReason' + - type: 'null' + steps: + type: array + items: + $ref: '#/components/schemas/TicketAutomationRunStepResponseDto' PrototypePromptResponseDto: type: object required: [prompt] diff --git a/libs/domains/framework/backend/feature-agent-controller/src/index.ts b/libs/domains/framework/backend/feature-agent-controller/src/index.ts index b18fa03f..58a06650 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/index.ts @@ -67,7 +67,13 @@ export * from './lib/dto/client-response.dto'; export * from './lib/dto/create-client-response.dto'; export * from './lib/dto/create-client.dto'; export * from './lib/dto/update-client.dto'; +export * from './lib/entities/client-agent-autonomy.entity'; export * from './lib/entities/provisioning-reference.entity'; +export * from './lib/entities/ticket-automation-lease.entity'; +export * from './lib/entities/ticket-automation-run-step.entity'; +export * from './lib/entities/ticket-automation-run.entity'; +export * from './lib/entities/ticket-automation.entity'; +export * from './lib/entities/ticket-automation.enums'; export * from './lib/entities/ticket-activity.entity'; export * from './lib/entities/ticket-body-generation-session.entity'; export * from './lib/entities/ticket-comment.entity'; @@ -82,13 +88,22 @@ export * from './lib/entities/statistics-client.entity'; export * from './lib/entities/statistics-entity-event.entity'; export * from './lib/entities/statistics-provisioning-reference.entity'; export * from './lib/entities/statistics-user.entity'; +export * from './lib/controllers/client-agent-autonomy.controller'; +export * from './lib/controllers/clients-agent-automation-proxy.controller'; +export * from './lib/controllers/ticket-automation.controller'; export * from './lib/controllers/tickets.controller'; export * from './lib/modules/clients.module'; export * from './lib/modules/identity-statistics-bridge.module'; export * from './lib/modules/statistics.module'; export * from './lib/repositories/clients.repository'; export * from './lib/repositories/statistics.repository'; +export * from './lib/dto/ticket-automation'; +export * from './lib/services/autonomous-run-orchestrator.service'; +export * from './lib/services/autonomous-ticket.scheduler'; +export * from './lib/services/client-agent-autonomy.service'; export * from './lib/services/client-agent-proxy.service'; +export * from './lib/services/remote-agents-session.service'; +export * from './lib/services/ticket-automation.service'; export * from './lib/services/clients.service'; export * from './lib/services/tickets.service'; export * from './lib/services/statistics.service'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts new file mode 100644 index 00000000..f0c59131 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Param, ParseUUIDPipe, Req } from '@nestjs/common'; +import { type RequestWithUser } from '@forepath/identity/backend'; +import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.service'; + +@Controller('clients/:clientId/agent-autonomy') +export class ClientAgentAutonomyDirectoryController { + constructor(private readonly clientAgentAutonomyService: ClientAgentAutonomyService) {} + + @Get('enabled-agent-ids') + async listEnabledAgentIds(@Param('clientId', ParseUUIDPipe) clientId: string, @Req() req?: RequestWithUser) { + return await this.clientAgentAutonomyService.listEnabledAgentIds(clientId, req); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts new file mode 100644 index 00000000..2a3f633a --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Req } from '@nestjs/common'; +import { type RequestWithUser } from '@forepath/identity/backend'; +import { UpsertClientAgentAutonomyDto } from '../dto/ticket-automation'; +import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.service'; + +@Controller('clients/:clientId/agents/:agentId/autonomy') +export class ClientAgentAutonomyController { + constructor(private readonly clientAgentAutonomyService: ClientAgentAutonomyService) {} + + @Get() + async get( + @Param('clientId', ParseUUIDPipe) clientId: string, + @Param('agentId', ParseUUIDPipe) agentId: string, + @Req() req?: RequestWithUser, + ) { + return await this.clientAgentAutonomyService.get(clientId, agentId, req); + } + + @Put() + async put( + @Param('clientId', ParseUUIDPipe) clientId: string, + @Param('agentId', ParseUUIDPipe) agentId: string, + @Body() dto: UpsertClientAgentAutonomyDto, + @Req() req?: RequestWithUser, + ) { + return await this.clientAgentAutonomyService.upsert(clientId, agentId, dto, req); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts new file mode 100644 index 00000000..baa7be0b --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-agent-automation-proxy.controller.ts @@ -0,0 +1,31 @@ +import { + RunVerifierCommandsDto, + RunVerifierCommandsResponseDto, +} from '@forepath/framework/backend/feature-agent-manager'; +import { Body, Controller, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common'; +import { ClientUsersRepository, ensureClientAccess, type RequestWithUser } from '@forepath/identity/backend'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.service'; + +/** + * Proxies ticket-automation verification HTTP calls to the client's agent-manager. + */ +@Controller('clients/:clientId/agents/:agentId/automation') +export class ClientsAgentAutomationProxyController { + constructor( + private readonly clientAgentVcsProxyService: ClientAgentVcsProxyService, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + @Post('verify-commands') + async verifyCommands( + @Param('clientId', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() body: RunVerifierCommandsDto, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + return await this.clientAgentVcsProxyService.runVerifierCommands(clientId, agentId, body); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts index fe201719..dae64a60 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-vcs.controller.ts @@ -4,6 +4,7 @@ import { GitBranchDto, GitDiffDto, GitStatusDto, + PrepareCleanWorkspaceDto, PushOptionsDto, RebaseDto, ResolveConflictDto, @@ -186,6 +187,18 @@ export class ClientsVcsController { * @param clientId - The UUID of the client * @param agentId - The UUID of the agent */ + @Post('workspace/prepare-clean') + @HttpCode(HttpStatus.NO_CONTENT) + async prepareCleanWorkspace( + @Param('clientId', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() body: PrepareCleanWorkspaceDto, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + await this.clientAgentVcsProxyService.prepareCleanWorkspace(clientId, agentId, body); + } + @Post('fetch') @HttpCode(HttpStatus.NO_CONTENT) async fetch( diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index 78735cef..d539a13b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -23,6 +23,7 @@ import { ClientUsersRepository, ClientUsersService, UserRole, + WORKSPACE_MANAGEMENT_FORBIDDEN_MESSAGE, } from '@forepath/identity/backend'; import { ClientResponseDto } from '../dto/client-response.dto'; import { CreateClientResponseDto } from '../dto/create-client-response.dto'; @@ -59,6 +60,7 @@ describe('ClientsController', () => { endpoint: 'https://example.com/api', authenticationType: AuthenticationType.API_KEY, isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], @@ -265,7 +267,7 @@ describe('ClientsController', () => { expect(result).toEqual(mockCreateClientResponse); expect(result.apiKey).toBeDefined(); - expect(service.create).toHaveBeenCalledWith(createDto, undefined); + expect(service.create).toHaveBeenCalledWith(createDto, undefined, undefined, true); }); it('should create new client with Keycloak credentials for KEYCLOAK type', async () => { @@ -290,7 +292,7 @@ describe('ClientsController', () => { expect(result).toEqual(responseWithoutApiKey); expect(result.apiKey).toBeUndefined(); - expect(service.create).toHaveBeenCalledWith(createDto, undefined); + expect(service.create).toHaveBeenCalledWith(createDto, undefined, undefined, true); }); }); @@ -400,6 +402,22 @@ describe('ClientsController', () => { expect(result).toEqual(mockCreateAgentResponse); expect(proxyService.createClientAgent).toHaveBeenCalledWith('client-uuid', createDto, undefined); }); + + it('should reject when user is plain workspace member', async () => { + const createDto: CreateAgentDto = { name: 'New Agent', description: 'd' }; + const mockReq = { apiKeyAuthenticated: false, user: { id: 'user-1', roles: ['user'] } } as any; + clientsRepository.findById.mockResolvedValue({ id: 'client-uuid', userId: 'owner-id' } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue({ + userId: 'user-1', + clientId: 'client-uuid', + role: ClientUserRole.USER, + } as any); + + await expect(controller.createClientAgent('client-uuid', createDto, mockReq)).rejects.toMatchObject({ + response: { message: WORKSPACE_MANAGEMENT_FORBIDDEN_MESSAGE }, + }); + expect(proxyService.createClientAgent).not.toHaveBeenCalled(); + }); }); describe('updateClientAgent', () => { @@ -835,7 +853,7 @@ describe('ClientsController', () => { const result = await controller.provisionServer(provisionDto); expect(result).toEqual(mockResponse); - expect(provisioningService.provisionServer).toHaveBeenCalledWith(provisionDto, undefined); + expect(provisioningService.provisionServer).toHaveBeenCalledWith(provisionDto, undefined, undefined, false); }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index 11ba4227..2d0073dd 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -38,6 +38,7 @@ import { ClientUsersRepository, ClientUsersService, ensureClientAccess, + ensureWorkspaceManagementAccess, getUserFromRequest, type RequestWithUser, UserRole, @@ -112,7 +113,7 @@ export class ClientsController { @Req() req?: RequestWithUser, ): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); - return await this.clientsService.create(createClientDto, userInfo.userId); + return await this.clientsService.create(createClientDto, userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth); } /** @@ -221,7 +222,7 @@ export class ClientsController { @Body() updateAgentDto: UpdateAgentDto, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); return await this.clientAgentProxyService.updateClientAgent(id, agentId, updateAgentDto, userInfo.userId); } @@ -241,7 +242,7 @@ export class ClientsController { @Body() createAgentDto: CreateAgentDto, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); return await this.clientAgentProxyService.createClientAgent(id, createAgentDto, userInfo.userId); } @@ -260,7 +261,7 @@ export class ClientsController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); await this.clientAgentProxyService.deleteClientAgent(id, agentId, userInfo.userId); } @@ -372,6 +373,7 @@ export class ClientsController { @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Req() req?: RequestWithUser, ): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); // Check if client has provisioning - if so, delete the server from the provider try { @@ -627,7 +629,12 @@ export class ClientsController { @Req() req?: RequestWithUser, ): Promise { const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); - return await this.provisioningService.provisionServer(provisionServerDto, userInfo.userId); + return await this.provisioningService.provisionServer( + provisionServerDto, + userInfo.userId, + userInfo.userRole, + userInfo.isApiKeyAuth, + ); } /** @@ -722,7 +729,7 @@ export class ClientsController { @Body() createDto: CreateEnvironmentVariableDto, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); return await this.clientAgentEnvironmentVariablesProxyService.createEnvironmentVariable(id, agentId, createDto); } @@ -744,7 +751,7 @@ export class ClientsController { @Body() updateDto: UpdateEnvironmentVariableDto, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); return await this.clientAgentEnvironmentVariablesProxyService.updateEnvironmentVariable( id, agentId, @@ -769,7 +776,7 @@ export class ClientsController { @Param('envVarId', new ParseUUIDPipe({ version: '4' })) envVarId: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); await this.clientAgentEnvironmentVariablesProxyService.deleteEnvironmentVariable(id, agentId, envVarId); } @@ -788,7 +795,7 @@ export class ClientsController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Req() req?: RequestWithUser, ): Promise<{ deletedCount: number }> { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); return await this.clientAgentEnvironmentVariablesProxyService.deleteAllEnvironmentVariables(id, agentId); } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts new file mode 100644 index 00000000..8dd1f9a2 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common'; +import { type RequestWithUser } from '@forepath/identity/backend'; +import { UpdateTicketAutomationDto } from '../dto/ticket-automation'; +import { TicketAutomationService } from '../services/ticket-automation.service'; + +@Controller('tickets/:ticketId/automation') +export class TicketAutomationController { + constructor(private readonly ticketAutomationService: TicketAutomationService) {} + + @Get() + async get(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { + return await this.ticketAutomationService.getAutomation(ticketId, req); + } + + @Patch() + async patch( + @Param('ticketId', ParseUUIDPipe) ticketId: string, + @Body() dto: UpdateTicketAutomationDto, + @Req() req?: RequestWithUser, + ) { + return await this.ticketAutomationService.patchAutomation(ticketId, dto, req); + } + + @Post('approve') + @HttpCode(HttpStatus.OK) + async approve(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { + return await this.ticketAutomationService.approve(ticketId, req); + } + + @Get('runs') + async listRuns(@Param('ticketId', ParseUUIDPipe) ticketId: string, @Req() req?: RequestWithUser) { + return await this.ticketAutomationService.listRuns(ticketId, req); + } + + @Get('runs/:runId') + async getRun( + @Param('ticketId', ParseUUIDPipe) ticketId: string, + @Param('runId', ParseUUIDPipe) runId: string, + @Req() req?: RequestWithUser, + ) { + return await this.ticketAutomationService.getRun(ticketId, runId, req); + } + + @Post('runs/:runId/cancel') + @HttpCode(HttpStatus.OK) + async cancelRun( + @Param('ticketId', ParseUUIDPipe) ticketId: string, + @Param('runId', ParseUUIDPipe) runId: string, + @Req() req?: RequestWithUser, + ) { + return await this.ticketAutomationService.cancelRun(ticketId, runId, req); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/client-response.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/client-response.dto.ts index ce33a8e9..d1459fe2 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/client-response.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/client-response.dto.ts @@ -13,6 +13,8 @@ export class ClientResponseDto { authenticationType!: AuthenticationType; config?: ConfigResponseDto; isAutoProvisioned!: boolean; + /** True if the current viewer may change autonomy, env vars, agents, and workspace (client) settings. */ + canManageWorkspaceConfiguration!: boolean; createdAt!: Date; updatedAt!: Date; } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/client-agent-autonomy.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/client-agent-autonomy.dto.ts new file mode 100644 index 00000000..2e9b66a2 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/client-agent-autonomy.dto.ts @@ -0,0 +1,43 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class UpsertClientAgentAutonomyDto { + @IsBoolean() + enabled!: boolean; + + @IsBoolean() + preImproveTicket!: boolean; + + @IsInt() + @Min(60_000) + @Max(86_400_000) + maxRuntimeMs!: number; + + @IsInt() + @Min(1) + @Max(500) + maxIterations!: number; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + tokenBudgetLimit?: number | null; +} + +/** Response for listing which agents may run autonomous ticket work for a client. */ +export class EnabledAutonomyAgentIdsResponseDto { + agentIds!: string[]; +} + +export class ClientAgentAutonomyResponseDto { + clientId!: string; + agentId!: string; + enabled!: boolean; + preImproveTicket!: boolean; + maxRuntimeMs!: number; + maxIterations!: number; + tokenBudgetLimit!: number | null; + createdAt!: Date; + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/index.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/index.ts new file mode 100644 index 00000000..74e2a7b8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/index.ts @@ -0,0 +1,4 @@ +export * from './client-agent-autonomy.dto'; +export * from './ticket-automation-response.dto'; +export * from './ticket-automation-run-response.dto'; +export * from './update-ticket-automation.dto'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts new file mode 100644 index 00000000..ebbef8ee --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts @@ -0,0 +1,17 @@ +import type { TicketVerifierProfileJson } from '../../entities/ticket-automation.entity'; + +export class TicketAutomationResponseDto { + ticketId!: string; + eligible!: boolean; + allowedAgentIds!: string[]; + verifierProfile!: TicketVerifierProfileJson | null; + requiresApproval!: boolean; + approvedAt!: Date | null; + approvedByUserId!: string | null; + approvalBaselineTicketUpdatedAt!: Date | null; + defaultBranchOverride!: string | null; + nextRetryAt!: Date | null; + consecutiveFailureCount!: number; + createdAt!: Date; + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts new file mode 100644 index 00000000..efbec561 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts @@ -0,0 +1,36 @@ +import type { TicketAutomationRunPhase, TicketAutomationRunStatus } from '../../entities/ticket-automation.enums'; + +export class TicketAutomationRunStepResponseDto { + id!: string; + stepIndex!: number; + phase!: string; + kind!: string; + payload!: Record | null; + excerpt!: string | null; + createdAt!: Date; +} + +export class TicketAutomationRunResponseDto { + id!: string; + ticketId!: string; + clientId!: string; + agentId!: string; + status!: TicketAutomationRunStatus; + phase!: TicketAutomationRunPhase; + ticketStatusBefore!: string; + branchName!: string | null; + baseBranch!: string | null; + baseSha!: string | null; + startedAt!: Date; + finishedAt!: Date | null; + updatedAt!: Date; + iterationCount!: number; + completionMarkerSeen!: boolean; + verificationPassed!: boolean | null; + failureCode!: string | null; + summary!: Record | null; + cancelRequestedAt!: Date | null; + cancelledByUserId!: string | null; + cancellationReason!: string | null; + steps?: TicketAutomationRunStepResponseDto[]; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts new file mode 100644 index 00000000..9d83feb6 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts @@ -0,0 +1,56 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; + +/** Single verifier command; mirrors {@link parseAndValidateVerifierProfile} bounds. */ +export class VerifierCommandEntryDto { + @IsString() + @MaxLength(2048) + cmd!: string; + + @IsOptional() + @IsString() + @MaxLength(2048) + cwd?: string; +} + +export class TicketVerifierProfileDto { + @IsArray() + @ArrayMaxSize(32) + @ValidateNested({ each: true }) + @Type(() => VerifierCommandEntryDto) + commands!: VerifierCommandEntryDto[]; +} + +export class UpdateTicketAutomationDto { + @IsOptional() + @IsBoolean() + eligible?: boolean; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + allowedAgentIds?: string[]; + + @IsOptional() + @ValidateNested() + @Type(() => TicketVerifierProfileDto) + verifierProfile?: TicketVerifierProfileDto; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsString() + @MaxLength(256) + defaultBranchOverride?: string | null; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts index 68f66f9f..b4780a59 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts @@ -11,6 +11,10 @@ export class TicketResponseDto { status!: TicketStatus; createdByUserId?: string | null; createdByEmail?: string | null; + /** Preferred workspace agent for chat/AI when viewing this ticket. */ + preferredChatAgentId?: string | null; + /** True when autonomous prototyping is enabled for this ticket (`ticket_automation.eligible`). */ + automationEligible!: boolean; createdAt!: Date; updatedAt!: Date; children?: TicketResponseDto[]; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts index 5ad3ba97..fb27707e 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { IsEnum, IsOptional, IsString, IsUUID, MaxLength, ValidateIf } from 'class-validator'; import { TicketPriority, TicketStatus } from '../../entities/ticket.enums'; export class UpdateTicketDto { @@ -26,4 +26,10 @@ export class UpdateTicketDto { @IsOptional() @IsEnum(TicketStatus) status?: TicketStatus; + + /** Persist which workspace agent to use for chat/AI on this ticket; set `null` to clear. */ + @IsOptional() + @ValidateIf((_, v) => v !== null && v !== undefined) + @IsUUID('4') + preferredChatAgentId?: string | null; } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.spec.ts new file mode 100644 index 00000000..800e9e7b --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.spec.ts @@ -0,0 +1,37 @@ +import { ClientAgentAutonomyEntity } from './client-agent-autonomy.entity'; + +describe('ClientAgentAutonomyEntity', () => { + it('should create an instance', () => { + const entity = new ClientAgentAutonomyEntity(); + expect(entity).toBeDefined(); + }); + + it('should have composite primary key fields and autonomy settings', () => { + const entity = new ClientAgentAutonomyEntity(); + entity.clientId = 'client-uuid'; + entity.agentId = 'agent-uuid'; + entity.enabled = true; + entity.preImproveTicket = false; + entity.maxRuntimeMs = 3_600_000; + entity.maxIterations = 20; + entity.tokenBudgetLimit = 500_000; + entity.createdAt = new Date('2024-01-01'); + entity.updatedAt = new Date('2024-01-02'); + + expect(entity.clientId).toBe('client-uuid'); + expect(entity.agentId).toBe('agent-uuid'); + expect(entity.enabled).toBe(true); + expect(entity.preImproveTicket).toBe(false); + expect(entity.maxRuntimeMs).toBe(3_600_000); + expect(entity.maxIterations).toBe(20); + expect(entity.tokenBudgetLimit).toBe(500_000); + expect(entity.createdAt).toBeInstanceOf(Date); + expect(entity.updatedAt).toBeInstanceOf(Date); + }); + + it('should allow null token budget', () => { + const entity = new ClientAgentAutonomyEntity(); + entity.tokenBudgetLimit = null; + expect(entity.tokenBudgetLimit).toBeNull(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.ts new file mode 100644 index 00000000..67fa7bb2 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.ts @@ -0,0 +1,39 @@ +import { ClientEntity } from '@forepath/identity/backend'; +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +/** + * Per (client, agent) autonomy limits for autonomous ticket runs. + */ +@Entity('client_agent_autonomy') +export class ClientAgentAutonomyEntity { + @PrimaryColumn('uuid', { name: 'client_id' }) + clientId!: string; + + @ManyToOne(() => ClientEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'client_id' }) + client?: ClientEntity; + + @PrimaryColumn('uuid', { name: 'agent_id' }) + agentId!: string; + + @Column({ type: 'boolean', name: 'enabled', default: false }) + enabled!: boolean; + + @Column({ type: 'boolean', name: 'pre_improve_ticket', default: false }) + preImproveTicket!: boolean; + + @Column({ type: 'int', name: 'max_runtime_ms', default: 3_600_000 }) + maxRuntimeMs!: number; + + @Column({ type: 'int', name: 'max_iterations', default: 20 }) + maxIterations!: number; + + @Column({ type: 'int', name: 'token_budget_limit', nullable: true }) + tokenBudgetLimit?: number | null; + + @Column({ type: 'timestamptz', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts index 3020e803..dadbfb3c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts @@ -47,4 +47,28 @@ describe('StatisticsChatIoEntity', () => { expect(entity.direction).toBe(ChatDirection.OUTPUT); expect(entity.direction).toBe('output'); }); + + it('should support prompt enhancement and ticket body generation interaction kinds', () => { + const enhancement = new StatisticsChatIoEntity(); + enhancement.interactionKind = StatisticsInteractionKind.PROMPT_ENHANCEMENT; + expect(enhancement.interactionKind).toBe('prompt_enhancement'); + + const ticketBody = new StatisticsChatIoEntity(); + ticketBody.interactionKind = StatisticsInteractionKind.TICKET_BODY_GENERATION; + expect(ticketBody.interactionKind).toBe('ticket_body_generation'); + }); + + it('should support autonomous ticket run interaction kinds', () => { + const run = new StatisticsChatIoEntity(); + run.interactionKind = StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN; + expect(run.interactionKind).toBe('autonomous_ticket_run'); + + const turn = new StatisticsChatIoEntity(); + turn.interactionKind = StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN; + expect(turn.interactionKind).toBe('autonomous_ticket_run_turn'); + + const commitMsg = new StatisticsChatIoEntity(); + commitMsg.interactionKind = StatisticsInteractionKind.AUTONOMOUS_TICKET_COMMIT_MESSAGE; + expect(commitMsg.interactionKind).toBe('autonomous_ticket_commit_message'); + }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts index 9f24d395..e63f884a 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts @@ -12,6 +12,10 @@ export enum StatisticsInteractionKind { CHAT = 'chat', PROMPT_ENHANCEMENT = 'prompt_enhancement', TICKET_BODY_GENERATION = 'ticket_body_generation', + AUTONOMOUS_TICKET_RUN = 'autonomous_ticket_run', + AUTONOMOUS_TICKET_RUN_TURN = 'autonomous_ticket_run_turn', + /** Ephemeral remote chat used only to propose a Conventional Commits subject before `git commit`. */ + AUTONOMOUS_TICKET_COMMIT_MESSAGE = 'autonomous_ticket_commit_message', } /** diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-lease.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-lease.entity.spec.ts new file mode 100644 index 00000000..dd6d018e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-lease.entity.spec.ts @@ -0,0 +1,28 @@ +import { TicketAutomationLeaseEntity } from './ticket-automation-lease.entity'; +import { TicketAutomationLeaseStatus } from './ticket-automation.enums'; + +describe('TicketAutomationLeaseEntity', () => { + it('should create an instance', () => { + const entity = new TicketAutomationLeaseEntity(); + expect(entity).toBeDefined(); + }); + + it('should map lease columns', () => { + const entity = new TicketAutomationLeaseEntity(); + entity.ticketId = 'ticket-uuid'; + entity.holderAgentId = 'agent-uuid'; + entity.runId = 'run-uuid'; + entity.leaseVersion = 1; + entity.expiresAt = new Date('2026-01-01'); + entity.status = TicketAutomationLeaseStatus.ACTIVE; + entity.createdAt = new Date(); + entity.updatedAt = new Date(); + + expect(entity.ticketId).toBe('ticket-uuid'); + expect(entity.holderAgentId).toBe('agent-uuid'); + expect(entity.runId).toBe('run-uuid'); + expect(entity.leaseVersion).toBe(1); + expect(entity.status).toBe(TicketAutomationLeaseStatus.ACTIVE); + expect(entity.status).toBe('active'); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-lease.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-lease.entity.ts new file mode 100644 index 00000000..46b2f1df --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-lease.entity.ts @@ -0,0 +1,44 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { TicketAutomationLeaseStatus } from './ticket-automation.enums'; +import { TicketAutomationRunEntity } from './ticket-automation-run.entity'; +import { TicketEntity } from './ticket.entity'; + +@Entity('ticket_automation_lease') +export class TicketAutomationLeaseEntity { + @PrimaryColumn('uuid', { name: 'ticket_id' }) + ticketId!: string; + + @ManyToOne(() => TicketEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'ticket_id' }) + ticket?: TicketEntity; + + @Column({ type: 'uuid', name: 'holder_agent_id' }) + holderAgentId!: string; + + @Column({ type: 'uuid', name: 'run_id' }) + runId!: string; + + @ManyToOne(() => TicketAutomationRunEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'run_id' }) + run?: TicketAutomationRunEntity; + + @Column({ type: 'int', name: 'lease_version', default: 0 }) + leaseVersion!: number; + + @Column({ type: 'timestamptz', name: 'expires_at' }) + expiresAt!: Date; + + @Column({ + type: 'enum', + enum: TicketAutomationLeaseStatus, + enumName: 'ticket_automation_lease_status_enum', + name: 'status', + }) + status!: TicketAutomationLeaseStatus; + + @Column({ type: 'timestamptz', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run-step.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run-step.entity.spec.ts new file mode 100644 index 00000000..0253f727 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run-step.entity.spec.ts @@ -0,0 +1,27 @@ +import { TicketAutomationRunStepEntity } from './ticket-automation-run-step.entity'; + +describe('TicketAutomationRunStepEntity', () => { + it('should create an instance', () => { + const entity = new TicketAutomationRunStepEntity(); + expect(entity).toBeDefined(); + }); + + it('should map step audit fields', () => { + const entity = new TicketAutomationRunStepEntity(); + entity.id = 'step-uuid'; + entity.runId = 'run-uuid'; + entity.stepIndex = 0; + entity.phase = 'agent_loop'; + entity.kind = 'chat_turn'; + entity.payload = { correlationId: 'c1' }; + entity.excerpt = 'partial output'; + entity.createdAt = new Date(); + + expect(entity.runId).toBe('run-uuid'); + expect(entity.stepIndex).toBe(0); + expect(entity.phase).toBe('agent_loop'); + expect(entity.kind).toBe('chat_turn'); + expect(entity.payload).toEqual({ correlationId: 'c1' }); + expect(entity.excerpt).toBe('partial output'); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run-step.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run-step.entity.ts new file mode 100644 index 00000000..0e58a5bb --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run-step.entity.ts @@ -0,0 +1,34 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { TicketAutomationRunEntity } from './ticket-automation-run.entity'; + +@Entity('ticket_automation_run_step') +@Index('IDX_ticket_automation_run_step_run_index', ['runId', 'stepIndex'], { unique: true }) +export class TicketAutomationRunStepEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'run_id' }) + runId!: string; + + @ManyToOne(() => TicketAutomationRunEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'run_id' }) + run?: TicketAutomationRunEntity; + + @Column({ type: 'int', name: 'step_index' }) + stepIndex!: number; + + @Column({ type: 'varchar', name: 'phase', length: 32 }) + phase!: string; + + @Column({ type: 'varchar', name: 'kind', length: 64 }) + kind!: string; + + @Column({ type: 'jsonb', name: 'payload', nullable: true }) + payload?: Record | null; + + @Column({ type: 'text', name: 'excerpt', nullable: true }) + excerpt?: string | null; + + @Column({ type: 'timestamptz', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts new file mode 100644 index 00000000..ad31bc1e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts @@ -0,0 +1,39 @@ +import { TicketAutomationRunPhase, TicketAutomationRunStatus } from './ticket-automation.enums'; +import { TicketAutomationRunEntity } from './ticket-automation-run.entity'; + +describe('TicketAutomationRunEntity', () => { + it('should create an instance', () => { + const entity = new TicketAutomationRunEntity(); + expect(entity).toBeDefined(); + }); + + it('should map run lifecycle fields', () => { + const entity = new TicketAutomationRunEntity(); + entity.id = 'run-uuid'; + entity.ticketId = 'ticket-uuid'; + entity.clientId = 'client-uuid'; + entity.agentId = 'agent-uuid'; + entity.status = TicketAutomationRunStatus.RUNNING; + entity.phase = TicketAutomationRunPhase.AGENT_LOOP; + entity.ticketStatusBefore = 'in_progress'; + entity.branchName = 'automation/run-1'; + entity.baseBranch = 'main'; + entity.baseSha = 'abc123'; + entity.startedAt = new Date(); + entity.finishedAt = null; + entity.updatedAt = new Date(); + entity.iterationCount = 3; + entity.completionMarkerSeen = false; + entity.verificationPassed = null; + entity.failureCode = null; + entity.summary = { note: 'ok' }; + entity.cancelRequestedAt = null; + entity.cancelledByUserId = null; + entity.cancellationReason = null; + + expect(entity.status).toBe(TicketAutomationRunStatus.RUNNING); + expect(entity.phase).toBe(TicketAutomationRunPhase.AGENT_LOOP); + expect(entity.iterationCount).toBe(3); + expect(entity.summary).toEqual({ note: 'ok' }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts new file mode 100644 index 00000000..5c7fd19d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts @@ -0,0 +1,86 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { TicketAutomationRunPhase, TicketAutomationRunStatus } from './ticket-automation.enums'; +import { TicketEntity } from './ticket.entity'; + +@Entity('ticket_automation_run') +@Index('IDX_ticket_automation_run_ticket_started', ['ticketId', 'startedAt']) +@Index('IDX_ticket_automation_run_client_status', ['clientId', 'status']) +@Index('IDX_ticket_automation_run_agent_status', ['agentId', 'status']) +export class TicketAutomationRunEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'ticket_id' }) + ticketId!: string; + + @ManyToOne(() => TicketEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'ticket_id' }) + ticket?: TicketEntity; + + @Column({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @Column({ type: 'uuid', name: 'agent_id' }) + agentId!: string; + + @Column({ + type: 'enum', + enum: TicketAutomationRunStatus, + enumName: 'ticket_automation_run_status_enum', + name: 'status', + }) + status!: TicketAutomationRunStatus; + + @Column({ + type: 'enum', + enum: TicketAutomationRunPhase, + enumName: 'ticket_automation_run_phase_enum', + name: 'phase', + }) + phase!: TicketAutomationRunPhase; + + @Column({ type: 'varchar', name: 'ticket_status_before', length: 32 }) + ticketStatusBefore!: string; + + @Column({ type: 'varchar', name: 'branch_name', length: 512, nullable: true }) + branchName?: string | null; + + @Column({ type: 'varchar', name: 'base_branch', length: 256, nullable: true }) + baseBranch?: string | null; + + @Column({ type: 'varchar', name: 'base_sha', length: 64, nullable: true }) + baseSha?: string | null; + + @Column({ type: 'timestamptz', name: 'started_at' }) + startedAt!: Date; + + @Column({ type: 'timestamptz', name: 'finished_at', nullable: true }) + finishedAt?: Date | null; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; + + @Column({ type: 'int', name: 'iteration_count', default: 0 }) + iterationCount!: number; + + @Column({ type: 'boolean', name: 'completion_marker_seen', default: false }) + completionMarkerSeen!: boolean; + + @Column({ type: 'boolean', name: 'verification_passed', nullable: true }) + verificationPassed?: boolean | null; + + @Column({ type: 'varchar', name: 'failure_code', length: 64, nullable: true }) + failureCode?: string | null; + + @Column({ type: 'jsonb', name: 'summary', nullable: true }) + summary?: Record | null; + + @Column({ type: 'timestamptz', name: 'cancel_requested_at', nullable: true }) + cancelRequestedAt?: Date | null; + + @Column({ type: 'uuid', name: 'cancelled_by_user_id', nullable: true }) + cancelledByUserId?: string | null; + + @Column({ type: 'varchar', name: 'cancellation_reason', length: 64, nullable: true }) + cancellationReason?: string | null; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts new file mode 100644 index 00000000..a37e63d8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts @@ -0,0 +1,37 @@ +import { TicketAutomationEntity, TicketVerifierProfileJson } from './ticket-automation.entity'; + +describe('TicketAutomationEntity', () => { + it('should create an instance', () => { + const entity = new TicketAutomationEntity(); + expect(entity).toBeDefined(); + }); + + it('should map ticket automation columns', () => { + const verifierProfile: TicketVerifierProfileJson = { + commands: [{ cmd: 'npm test', cwd: '/app' }], + }; + const entity = new TicketAutomationEntity(); + entity.ticketId = 'ticket-uuid'; + entity.eligible = true; + entity.allowedAgentIds = ['agent-uuid']; + entity.verifierProfile = verifierProfile; + entity.requiresApproval = true; + entity.approvedAt = new Date(); + entity.approvedByUserId = 'user-uuid'; + entity.approvalBaselineTicketUpdatedAt = new Date(); + entity.defaultBranchOverride = 'develop'; + entity.nextRetryAt = null; + entity.consecutiveFailureCount = 0; + entity.createdAt = new Date(); + entity.updatedAt = new Date(); + + expect(entity.ticketId).toBe('ticket-uuid'); + expect(entity.eligible).toBe(true); + expect(entity.allowedAgentIds).toEqual(['agent-uuid']); + expect(entity.verifierProfile).toEqual(verifierProfile); + expect(entity.requiresApproval).toBe(true); + expect(entity.approvedByUserId).toBe('user-uuid'); + expect(entity.defaultBranchOverride).toBe('develop'); + expect(entity.nextRetryAt).toBeNull(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts new file mode 100644 index 00000000..dc3cb258 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts @@ -0,0 +1,57 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { TicketEntity } from './ticket.entity'; + +/** JSON shape for verifier profile stored in DB (validated on write). */ +export type TicketVerifierProfileJson = { + commands: Array<{ cmd: string; cwd?: string }>; +}; + +@Entity('ticket_automation') +export class TicketAutomationEntity { + @PrimaryColumn('uuid', { name: 'ticket_id' }) + ticketId!: string; + + @OneToOne(() => TicketEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'ticket_id' }) + ticket?: TicketEntity; + + @Column({ type: 'boolean', name: 'eligible', default: false }) + eligible!: boolean; + + /** + * When empty, any agent with prototype autonomy enabled for this client may run the ticket. + * When non-empty, only listed agent ids are eligible (still require autonomy.enabled). + */ + @Column({ type: 'jsonb', name: 'allowed_agent_ids', default: () => "'[]'" }) + allowedAgentIds!: string[]; + + @Column({ type: 'jsonb', name: 'verifier_profile', nullable: true }) + verifierProfile?: TicketVerifierProfileJson | null; + + @Column({ type: 'boolean', name: 'requires_approval', default: false }) + requiresApproval!: boolean; + + @Column({ type: 'timestamptz', name: 'approved_at', nullable: true }) + approvedAt?: Date | null; + + @Column({ type: 'uuid', name: 'approved_by_user_id', nullable: true }) + approvedByUserId?: string | null; + + @Column({ type: 'timestamptz', name: 'approval_baseline_ticket_updated_at', nullable: true }) + approvalBaselineTicketUpdatedAt?: Date | null; + + @Column({ type: 'varchar', name: 'default_branch_override', length: 256, nullable: true }) + defaultBranchOverride?: string | null; + + @Column({ type: 'timestamptz', name: 'next_retry_at', nullable: true }) + nextRetryAt?: Date | null; + + @Column({ type: 'int', name: 'consecutive_failure_count', default: 0 }) + consecutiveFailureCount!: number; + + @Column({ type: 'timestamptz', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts new file mode 100644 index 00000000..dfe99022 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts @@ -0,0 +1,50 @@ +/** Terminal and non-terminal states for a ticket automation run. */ +export enum TicketAutomationRunStatus { + PENDING = 'pending', + RUNNING = 'running', + SUCCEEDED = 'succeeded', + FAILED = 'failed', + TIMED_OUT = 'timed_out', + ESCALATED = 'escalated', + CANCELLED = 'cancelled', +} + +export enum TicketAutomationRunPhase { + PRE_IMPROVE = 'pre_improve', + WORKSPACE_PREP = 'workspace_prep', + AGENT_LOOP = 'agent_loop', + VERIFY = 'verify', + FINALIZE = 'finalize', +} + +export enum TicketAutomationLeaseStatus { + ACTIVE = 'active', + RELEASED = 'released', + EXPIRED = 'expired', +} + +/** Machine-oriented failure labels stored on `ticket_automation_run.failure_code`. */ +export enum TicketAutomationFailureCode { + APPROVAL_MISSING = 'approval_missing', + LEASE_CONTENTION = 'lease_contention', + VCS_DIRTY_WORKSPACE = 'vcs_dirty_workspace', + VCS_BRANCH_EXISTS = 'vcs_branch_exists', + AGENT_PROVIDER_ERROR = 'agent_provider_error', + AGENT_NO_COMPLETION_MARKER = 'agent_no_completion_marker', + MARKER_WITHOUT_VERIFY = 'marker_without_verify', + VERIFY_COMMAND_FAILED = 'verify_command_failed', + /** Git commit after successful verification failed (e.g. empty index, hook, or I/O). */ + COMMIT_FAILED = 'commit_failed', + /** `git push` after a successful automation commit failed (auth, remote, or hook). */ + PUSH_FAILED = 'push_failed', + BUDGET_EXCEEDED = 'budget_exceeded', + HUMAN_ESCALATION = 'human_escalation', + ORCHESTRATOR_STALE = 'orchestrator_stale', +} + +export enum TicketAutomationCancellationReason { + USER_REQUEST = 'user_request', + APPROVAL_INVALIDATED = 'approval_invalidated', + LEASE_EXPIRED = 'lease_expired', + SYSTEM_SHUTDOWN = 'system_shutdown', +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts index cd0e8400..50b0b740 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts @@ -62,6 +62,10 @@ export class TicketEntity { @JoinColumn({ name: 'created_by_user_id' }) createdByUser?: UserEntity | null; + /** Workspace agent the user last chose for chat/AI on this ticket (agent-manager agent UUID). */ + @Column({ type: 'uuid', nullable: true, name: 'preferred_chat_agent_id' }) + preferredChatAgentId?: string | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts index e3d89c55..3181dd91 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket.enums.ts @@ -33,4 +33,17 @@ export enum TicketActionType { CONTENT_APPLIED_FROM_AI = 'CONTENT_APPLIED_FROM_AI', BODY_GENERATION_STARTED = 'BODY_GENERATION_STARTED', PROTOTYPE_PROMPT_GENERATED = 'PROTOTYPE_PROMPT_GENERATED', + AUTOMATION_CLAIMED = 'AUTOMATION_CLAIMED', + AUTOMATION_STARTED = 'AUTOMATION_STARTED', + AUTOMATION_SUCCEEDED = 'AUTOMATION_SUCCEEDED', + AUTOMATION_FAILED = 'AUTOMATION_FAILED', + AUTOMATION_TIMED_OUT = 'AUTOMATION_TIMED_OUT', + AUTOMATION_ESCALATED = 'AUTOMATION_ESCALATED', + AUTOMATION_REQUEUED = 'AUTOMATION_REQUEUED', + AUTOMATION_APPROVAL_INVALIDATED = 'AUTOMATION_APPROVAL_INVALIDATED', + AUTOMATION_CANCELLED = 'AUTOMATION_CANCELLED', + AUTOMATION_APPROVED = 'AUTOMATION_APPROVED', + AUTOMATION_ELIGIBILITY_CHANGED = 'AUTOMATION_ELIGIBILITY_CHANGED', + AUTOMATION_APPROVAL_REQUIREMENT_CHANGED = 'AUTOMATION_APPROVAL_REQUIREMENT_CHANGED', + AUTOMATION_SETTINGS_UPDATED = 'AUTOMATION_SETTINGS_UPDATED', } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index 4b23da61..8563c9e1 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -27,12 +27,18 @@ import { StatisticsUserEntity } from '../entities/statistics-user.entity'; import { TicketActivityEntity } from '../entities/ticket-activity.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; +import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease.entity'; +import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketEntity } from '../entities/ticket.entity'; import { ClientsGateway } from '../gateways/clients.gateway'; import { ClientsRepository } from '../repositories/clients.repository'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { AutonomousTicketScheduler } from '../services/autonomous-ticket.scheduler'; import { ClientsModule } from './clients.module'; describe('ClientsModule', () => { @@ -62,6 +68,7 @@ describe('ClientsModule', () => { }; return fn(em); }), + query: jest.fn().mockResolvedValue([]), }, createQueryBuilder: jest.fn().mockReturnValue({ andWhere: jest.fn().mockReturnThis(), @@ -123,8 +130,24 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(TicketBodyGenerationSessionEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(TicketAutomationEntity)) + .useValue(mockRepository) + .overrideProvider(getRepositoryToken(TicketAutomationRunEntity)) + .useValue(mockTicketRepository) + .overrideProvider(getRepositoryToken(TicketAutomationLeaseEntity)) + .useValue(mockRepository) + .overrideProvider(getRepositoryToken(TicketAutomationRunStepEntity)) + .useValue(mockRepository) + .overrideProvider(getRepositoryToken(ClientAgentAutonomyEntity)) + .useValue(mockRepository) .overrideProvider(UsersRepository) - .useValue(mockRepository); + .useValue(mockRepository) + .overrideProvider(AutonomousTicketScheduler) + .useValue({ + onModuleInit: jest.fn(), + onModuleDestroy: jest.fn(), + tick: jest.fn().mockResolvedValue(undefined), + }); // Mock Keycloak providers if auth method is keycloak if (authMethod === 'keycloak') { diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts index 8ac34052..2faeb2a9 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -16,16 +16,25 @@ import { import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { KeycloakConnectModule } from 'nest-keycloak-connect'; +import { ClientAgentAutonomyDirectoryController } from '../controllers/client-agent-autonomy-directory.controller'; +import { ClientAgentAutonomyController } from '../controllers/client-agent-autonomy.controller'; +import { ClientsAgentAutomationProxyController } from '../controllers/clients-agent-automation-proxy.controller'; import { ClientStatisticsController } from '../controllers/client-statistics.controller'; import { ClientsDeploymentsController } from '../controllers/clients-deployments.controller'; import { ClientsVcsController } from '../controllers/clients-vcs.controller'; import { ClientsController } from '../controllers/clients.controller'; import { StatisticsController } from '../controllers/statistics.controller'; +import { TicketAutomationController } from '../controllers/ticket-automation.controller'; import { TicketsController } from '../controllers/tickets.controller'; +import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; import { ProvisioningReferenceEntity } from '../entities/provisioning-reference.entity'; import { TicketActivityEntity } from '../entities/ticket-activity.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; +import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease.entity'; +import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketEntity } from '../entities/ticket.entity'; import { ClientsGateway } from '../gateways/clients.gateway'; import { DigitalOceanProvider } from '../providers/digital-ocean.provider'; @@ -37,8 +46,13 @@ import { ClientAgentDeploymentsProxyService } from '../services/client-agent-dep import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; +import { AutonomousRunOrchestratorService } from '../services/autonomous-run-orchestrator.service'; +import { AutonomousTicketScheduler } from '../services/autonomous-ticket.scheduler'; +import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.service'; import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { RemoteAgentsSessionService } from '../services/remote-agents-session.service'; +import { TicketAutomationService } from '../services/ticket-automation.service'; import { TicketsService } from '../services/tickets.service'; import { ProvisioningService } from '../services/provisioning.service'; import { StatisticsAgentSyncService } from '../services/statistics-agent-sync.service'; @@ -62,6 +76,11 @@ const authMethod = getAuthenticationMethod(); TicketCommentEntity, TicketActivityEntity, TicketBodyGenerationSessionEntity, + TicketAutomationEntity, + TicketAutomationRunEntity, + TicketAutomationLeaseEntity, + TicketAutomationRunStepEntity, + ClientAgentAutonomyEntity, ]), StatisticsModule, // Import KeycloakConnectModule conditionally to make KEYCLOAK_INSTANCE available to SocketAuthService @@ -74,10 +93,19 @@ const authMethod = getAuthenticationMethod(); ClientStatisticsController, StatisticsController, TicketsController, + TicketAutomationController, + ClientAgentAutonomyController, + ClientAgentAutonomyDirectoryController, + ClientsAgentAutomationProxyController, ], providers: [ ClientsService, TicketsService, + TicketAutomationService, + ClientAgentAutonomyService, + RemoteAgentsSessionService, + AutonomousRunOrchestratorService, + AutonomousTicketScheduler, ClientsRepository, ClientUsersRepository, ClientUsersService, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts new file mode 100644 index 00000000..6ce69354 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts @@ -0,0 +1,1024 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease.entity'; +import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; +import { TicketEntity } from '../entities/ticket.entity'; +import { TicketAutomationFailureCode } from '../entities/ticket-automation.enums'; +import { TicketActionType, TicketStatus } from '../entities/ticket.enums'; +import { AGENSTRA_AUTOMATION_COMPLETE } from '../utils/automation-completion.constants'; +import { AutonomousRunOrchestratorService } from './autonomous-run-orchestrator.service'; +import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { RemoteAgentsSessionService } from './remote-agents-session.service'; + +describe('AutonomousRunOrchestratorService', () => { + const ticketId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const clientId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const agentId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const runId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + + const defaultGitStatusDirty = { + isClean: false, + currentBranch: 'automation/test', + hasUnpushedCommits: false, + aheadCount: 0, + behindCount: 0, + files: [{ path: 'f', status: 'M', type: 'unstaged' as const }], + }; + + function makeTransactionMock() { + return jest.fn(async (fn: (em: unknown) => Promise) => { + const em = { + getRepository: (entity: unknown) => { + if (entity === TicketAutomationLeaseEntity) { + return { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn().mockImplementation((row: { id?: string }) => Promise.resolve({ ...row, id: 'lease-1' })), + create: (x: unknown) => x, + }; + } + if (entity === TicketAutomationRunEntity) { + return { + save: jest.fn().mockImplementation((row: { id?: string }) => Promise.resolve({ ...row, id: runId })), + create: (x: unknown) => x, + }; + } + return {}; + }, + }; + return fn(em); + }); + } + + it('processBatch completes when no candidates', async () => { + const ticketRepo = { + manager: { query: jest.fn().mockResolvedValue([]) }, + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: {} }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: {} }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: {} }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: {} }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: {} }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: {} }, + { provide: RemoteAgentsSessionService, useValue: { sendChatSync: jest.fn() } }, + { provide: ClientAgentVcsProxyService, useValue: {} }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + expect(ticketRepo.manager.query).toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch completes a run when agent returns completion marker and verifier passes', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + title: 'Test ticket', + status: TicketStatus.TODO, + }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + + const remoteChat = { + sendChatSync: jest + .fn() + .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) + .mockResolvedValueOnce('feat(automation): implement ticket'), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ results: [{ exitCode: 0, cmd: 'echo ok' }] }), + getStatus: jest.fn().mockResolvedValue(defaultGitStatusDirty), + stageFiles: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue(undefined), + push: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(remoteChat.sendChatSync).toHaveBeenCalledTimes(2); + expect(vcsProxy.runVerifierCommands).toHaveBeenCalledWith(clientId, agentId, { + commands: [{ cmd: 'echo ok' }], + timeoutMs: 120_000, + }); + expect(vcsProxy.commit).toHaveBeenCalledWith(clientId, agentId, { + message: 'feat(automation): implement ticket', + }); + expect(vcsProxy.push).toHaveBeenCalledWith(clientId, agentId, {}); + expect(ticketRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: ticketId, status: TicketStatus.PROTOTYPE }), + ); + expect(leaseRepo.update).toHaveBeenCalled(); + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ actionType: TicketActionType.AUTOMATION_SUCCEEDED, payload: { runId } }), + ); + expect(automationRepo.update).toHaveBeenCalledWith({ ticketId }, { consecutiveFailureCount: 0, nextRetryAt: null }); + await module.close(); + }); + + it('processBatch completes a run when allowedAgentIds is empty (any autonomy-enabled agent)', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + title: 'Test ticket', + status: TicketStatus.TODO, + }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + + const remoteChat = { + sendChatSync: jest + .fn() + .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) + .mockResolvedValueOnce('feat(automation): implement ticket'), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ results: [{ exitCode: 0, cmd: 'echo ok' }] }), + getStatus: jest.fn().mockResolvedValue(defaultGitStatusDirty), + stageFiles: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue(undefined), + push: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(remoteChat.sendChatSync).toHaveBeenCalledTimes(2); + expect(vcsProxy.push).toHaveBeenCalledWith(clientId, agentId, {}); + await module.close(); + }); + + it('processBatch skips starting a run when agent is not in automation allowed list', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + status: TicketStatus.TODO, + }), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: ['99999999-9999-4999-8999-999999999999'], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + preImproveTicket: false, + }), + }; + const transaction = jest.fn(); + const runRepo = { + manager: { transaction }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: {} }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: {} }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: {} }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: { sendChatSync: jest.fn() } }, + { provide: ClientAgentVcsProxyService, useValue: {} }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(transaction).not.toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch fails run with timed_out when agent never emits completion marker', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ id: ticketId, clientId, status: TicketStatus.TODO }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + consecutiveFailureCount: 0, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 2, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + findOne: jest.fn().mockResolvedValue({ + id: runId, + ticketId, + ticketStatusBefore: TicketStatus.TODO, + branchName: 'automation/dddddddd', + iterationCount: 2, + }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + find: jest.fn().mockResolvedValue([{ kind: 'agent_turn', excerpt: 'x' }]), + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest.fn().mockResolvedValue('still working, no marker yet'), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(remoteChat.sendChatSync).toHaveBeenCalledTimes(2); + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: TicketActionType.AUTOMATION_TIMED_OUT, + payload: { runId, code: TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER }, + }), + ); + expect(leaseRepo.update).toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch fails run when verifier command exits non-zero', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ id: ticketId, clientId, status: TicketStatus.TODO }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'npm test' }] }, + consecutiveFailureCount: 0, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 5, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + findOne: jest.fn().mockResolvedValue({ + id: runId, + ticketId, + ticketStatusBefore: TicketStatus.TODO, + branchName: 'automation/dddddddd', + iterationCount: 1, + }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + find: jest.fn().mockResolvedValue([]), + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest.fn().mockResolvedValue(`ok\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ + results: [{ cmd: 'npm test', exitCode: 1, output: 'fail' }], + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(vcsProxy.runVerifierCommands).toHaveBeenCalled(); + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: TicketActionType.AUTOMATION_FAILED, + payload: { runId, code: TicketAutomationFailureCode.VERIFY_COMMAND_FAILED }, + }), + ); + expect(leaseRepo.update).toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch fails run with provider error when VCS throws and releases lease', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ id: ticketId, clientId, status: TicketStatus.TODO }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + consecutiveFailureCount: 0, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 5, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + findOne: jest.fn().mockResolvedValue({ + id: runId, + ticketId, + ticketStatusBefore: TicketStatus.TODO, + branchName: null, + iterationCount: 0, + }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + find: jest.fn().mockResolvedValue([]), + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { sendChatSync: jest.fn() }; + const vcsProxy = { + getBranches: jest.fn().mockRejectedValue(new Error('remote VCS down')), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(remoteChat.sendChatSync).not.toHaveBeenCalled(); + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: TicketActionType.AUTOMATION_FAILED, + payload: { runId, code: TicketAutomationFailureCode.AGENT_PROVIDER_ERROR }, + }), + ); + expect(leaseRepo.update).toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch fails run when completion marker is seen but verifier profile has no commands', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ id: ticketId, clientId, status: TicketStatus.TODO }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [] }, + consecutiveFailureCount: 0, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 5, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + findOne: jest.fn().mockResolvedValue({ + id: runId, + ticketId, + ticketStatusBefore: TicketStatus.TODO, + branchName: 'automation/dddddddd', + iterationCount: 1, + }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + find: jest.fn().mockResolvedValue([]), + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest.fn().mockResolvedValue(`done\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: TicketActionType.AUTOMATION_FAILED, + payload: { runId, code: TicketAutomationFailureCode.MARKER_WITHOUT_VERIFY }, + }), + ); + expect(leaseRepo.update).toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch sends pre-improve message before implementation when preImproveTicket is enabled', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + title: 'Test ticket', + status: TicketStatus.TODO, + }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 5, + preImproveTicket: true, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest + .fn() + .mockResolvedValueOnce('pre-improve done') + .mockResolvedValueOnce(`done\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) + .mockResolvedValueOnce('feat(automation): after pre-improve'), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ results: [{ exitCode: 0, cmd: 'echo ok' }] }), + getStatus: jest.fn().mockResolvedValue(defaultGitStatusDirty), + stageFiles: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue(undefined), + push: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(remoteChat.sendChatSync).toHaveBeenCalledTimes(3); + expect(remoteChat.sendChatSync.mock.calls[0][0].message).toContain('Improve ticket clarity only'); + expect(remoteChat.sendChatSync.mock.calls[1][0].message).toContain('Implement the ticket'); + expect(vcsProxy.push).toHaveBeenCalledWith(clientId, agentId, {}); + await module.close(); + }); + + it('processBatch succeeds without git commit when working tree is clean after verify', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + title: 'Test ticket', + status: TicketStatus.TODO, + }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest.fn().mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ results: [{ exitCode: 0, cmd: 'echo ok' }] }), + getStatus: jest.fn().mockResolvedValue({ + isClean: true, + currentBranch: 'main', + hasUnpushedCommits: false, + aheadCount: 0, + behindCount: 0, + files: [], + }), + stageFiles: jest.fn(), + commit: jest.fn(), + push: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(remoteChat.sendChatSync).toHaveBeenCalledTimes(1); + expect(vcsProxy.commit).not.toHaveBeenCalled(); + expect(vcsProxy.stageFiles).not.toHaveBeenCalled(); + expect(vcsProxy.push).not.toHaveBeenCalled(); + await module.close(); + }); + + it('processBatch fails run when git commit throws after verify', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + title: 'Test ticket', + status: TicketStatus.TODO, + }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + consecutiveFailureCount: 0, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + findOne: jest.fn().mockResolvedValue({ + id: runId, + ticketId, + ticketStatusBefore: TicketStatus.TODO, + branchName: 'automation/dddddddd', + iterationCount: 1, + }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + find: jest.fn().mockResolvedValue([]), + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest + .fn() + .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) + .mockResolvedValueOnce('feat(automation): ok'), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ results: [{ exitCode: 0, cmd: 'echo ok' }] }), + getStatus: jest.fn().mockResolvedValue(defaultGitStatusDirty), + stageFiles: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockRejectedValue(new Error('commit rejected')), + push: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(vcsProxy.push).not.toHaveBeenCalled(); + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: TicketActionType.AUTOMATION_FAILED, + payload: { runId, code: TicketAutomationFailureCode.COMMIT_FAILED }, + }), + ); + await module.close(); + }); + + it('processBatch fails run when git push throws after commit', async () => { + const ticketRepo = { + manager: { + query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), + }, + findOne: jest.fn().mockResolvedValue({ + id: ticketId, + clientId, + title: 'Test ticket', + status: TicketStatus.TODO, + }), + save: jest.fn().mockImplementation((t) => Promise.resolve(t)), + }; + const automationRepo = { + findOne: jest.fn().mockResolvedValue({ + ticketId, + allowedAgentIds: [agentId], + verifierProfile: { commands: [{ cmd: 'echo ok' }] }, + consecutiveFailureCount: 0, + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const autonomyRepo = { + findOne: jest.fn().mockResolvedValue({ + clientId, + agentId, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + preImproveTicket: false, + }), + }; + const runRepo = { + manager: { transaction: makeTransactionMock() }, + update: jest.fn().mockResolvedValue({ affected: 1 }), + findOne: jest.fn().mockResolvedValue({ + id: runId, + ticketId, + ticketStatusBefore: TicketStatus.TODO, + branchName: 'automation/dddddddd', + iterationCount: 1, + }), + }; + const stepRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + find: jest.fn().mockResolvedValue([]), + }; + const activityRepo = { + save: jest.fn().mockImplementation((row: unknown) => Promise.resolve(row)), + create: (x: unknown) => x, + }; + const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + + const remoteChat = { + sendChatSync: jest + .fn() + .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) + .mockResolvedValueOnce('feat(automation): ok'), + }; + const vcsProxy = { + getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), + prepareCleanWorkspace: jest.fn().mockResolvedValue(undefined), + createBranch: jest.fn().mockResolvedValue(undefined), + runVerifierCommands: jest.fn().mockResolvedValue({ results: [{ exitCode: 0, cmd: 'echo ok' }] }), + getStatus: jest.fn().mockResolvedValue(defaultGitStatusDirty), + stageFiles: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue(undefined), + push: jest.fn().mockRejectedValue(new Error('push rejected')), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutonomousRunOrchestratorService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: autonomyRepo }, + { provide: RemoteAgentsSessionService, useValue: remoteChat }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + ], + }).compile(); + const orchestrator = module.get(AutonomousRunOrchestratorService); + await orchestrator.processBatch(3); + + expect(vcsProxy.push).toHaveBeenCalledWith(clientId, agentId, {}); + expect(activityRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + actionType: TicketActionType.AUTOMATION_FAILED, + payload: { runId, code: TicketAutomationFailureCode.PUSH_FAILED }, + }), + ); + await module.close(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts new file mode 100644 index 00000000..79b4728c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts @@ -0,0 +1,487 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease.entity'; +import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; +import { + TicketAutomationFailureCode, + TicketAutomationLeaseStatus, + TicketAutomationRunPhase, + TicketAutomationRunStatus, +} from '../entities/ticket-automation.enums'; +import { TicketEntity } from '../entities/ticket.entity'; +import { TicketActionType, TicketActorType, TicketStatus } from '../entities/ticket.enums'; +import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; +import { AGENSTRA_AUTOMATION_COMPLETE } from '../utils/automation-completion.constants'; +import { + buildAutonomousCommitMessagePrompt, + buildFallbackAutonomousCommitMessage, + sanitizeConventionalCommitSubject, +} from '../utils/autonomous-commit-message.utils'; +import { routeAutomationFailure } from '../utils/automation-failure-routing'; +import { isUsablePartialPrototype } from '../utils/automation-usable-partial'; +import { buildAutonomousTicketRunPreamble } from '../utils/tickets-prototype-prompt.utils'; +import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { RemoteAgentsSessionService } from './remote-agents-session.service'; + +interface RunnableCandidate { + ticket_id: string; + client_id: string; + agent_id: string; +} + +@Injectable() +export class AutonomousRunOrchestratorService { + private readonly logger = new Logger(AutonomousRunOrchestratorService.name); + + constructor( + @InjectRepository(TicketEntity) + private readonly ticketRepo: Repository, + @InjectRepository(TicketAutomationEntity) + private readonly automationRepo: Repository, + @InjectRepository(TicketAutomationRunEntity) + private readonly runRepo: Repository, + @InjectRepository(TicketAutomationLeaseEntity) + private readonly leaseRepo: Repository, + @InjectRepository(TicketAutomationRunStepEntity) + private readonly stepRepo: Repository, + @InjectRepository(TicketActivityEntity) + private readonly activityRepo: Repository, + @InjectRepository(ClientAgentAutonomyEntity) + private readonly autonomyRepo: Repository, + private readonly remoteChat: RemoteAgentsSessionService, + private readonly vcsProxy: ClientAgentVcsProxyService, + ) {} + + async processBatch(batchSize: number): Promise { + const candidates = await this.findCandidates(batchSize); + for (const c of candidates) { + try { + await this.tryStartRun(c); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + this.logger.warn(`Orchestrator skip ticket ${c.ticket_id}: ${message}`, stack); + } + } + } + + private async findCandidates(limit: number): Promise { + return await this.ticketRepo.manager.query( + ` + SELECT t.id AS ticket_id, t.client_id, caa.agent_id + FROM tickets t + INNER JOIN ticket_automation ta ON ta.ticket_id = t.id + INNER JOIN client_agent_autonomy caa ON caa.client_id = t.client_id AND caa.enabled = true + WHERE ta.eligible = true + AND t.status IN ('todo', 'in_progress') + AND ( + COALESCE(jsonb_array_length(COALESCE(ta.allowed_agent_ids, '[]'::jsonb)), 0) = 0 + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(ta.allowed_agent_ids) e(e) + WHERE e.e = caa.agent_id::text + ) + ) + AND ( + ta.requires_approval = false + OR ( + ta.approved_at IS NOT NULL + AND ta.approval_baseline_ticket_updated_at IS NOT NULL + AND t.updated_at <= ta.approval_baseline_ticket_updated_at + ) + ) + AND (ta.next_retry_at IS NULL OR ta.next_retry_at <= NOW()) + AND NOT EXISTS ( + SELECT 1 FROM ticket_automation_run r + WHERE r.ticket_id = t.id AND r.status = 'running' + ) + AND NOT EXISTS ( + SELECT 1 FROM ticket_automation_lease l + WHERE l.ticket_id = t.id AND l.status = 'active' AND l.expires_at > NOW() + ) + ORDER BY t.updated_at ASC + LIMIT $1 + `, + [limit], + ); + } + + private async tryStartRun(c: RunnableCandidate): Promise { + const ticket = await this.ticketRepo.findOne({ where: { id: c.ticket_id } }); + const automation = await this.automationRepo.findOne({ where: { ticketId: c.ticket_id } }); + const autonomy = await this.autonomyRepo.findOne({ + where: { clientId: c.client_id, agentId: c.agent_id }, + }); + if (!ticket || !automation || !autonomy) { + return; + } + const allowedAgentIds = automation.allowedAgentIds ?? []; + if (allowedAgentIds.length > 0 && !allowedAgentIds.includes(c.agent_id)) { + return; + } + + const run = await this.createRunAndLeaseInTransaction(ticket, c.agent_id, autonomy); + if (!run) { + return; + } + + await this.appendSystemActivity(ticket.id, TicketActionType.AUTOMATION_STARTED, { runId: run.id }); + + try { + await this.executeRunWorkflow(run, ticket, automation, autonomy, c.agent_id); + } catch (error: unknown) { + this.logOrchestratorError(run.id, error); + await this.failRun(run.id, TicketAutomationFailureCode.AGENT_PROVIDER_ERROR); + } + } + + private async createRunAndLeaseInTransaction( + ticket: TicketEntity, + agentId: string, + autonomy: ClientAgentAutonomyEntity, + ): Promise { + return await this.runRepo.manager.transaction(async (em) => { + const leaseRepo = em.getRepository(TicketAutomationLeaseEntity); + const existing = await leaseRepo.findOne({ + where: { ticketId: ticket.id, status: TicketAutomationLeaseStatus.ACTIVE }, + lock: { mode: 'pessimistic_write' }, + }); + if (existing && existing.expiresAt > new Date()) { + return null; + } + + const runRepo = em.getRepository(TicketAutomationRunEntity); + const r = await runRepo.save( + runRepo.create({ + ticketId: ticket.id, + clientId: ticket.clientId, + agentId, + status: TicketAutomationRunStatus.RUNNING, + phase: TicketAutomationRunPhase.WORKSPACE_PREP, + ticketStatusBefore: ticket.status, + startedAt: new Date(), + iterationCount: 0, + completionMarkerSeen: false, + }), + ); + + await leaseRepo.save( + leaseRepo.create({ + ticketId: ticket.id, + holderAgentId: agentId, + runId: r.id, + leaseVersion: 0, + expiresAt: new Date(Date.now() + autonomy.maxRuntimeMs), + status: TicketAutomationLeaseStatus.ACTIVE, + }), + ); + return r; + }); + } + + /** + * Workspace prep, agent loop, verification, and successful finalization (no broad catch — errors propagate). + */ + private async executeRunWorkflow( + run: TicketAutomationRunEntity, + ticket: TicketEntity, + automation: TicketAutomationEntity, + autonomy: ClientAgentAutonomyEntity, + agentId: string, + ): Promise { + const branches = await this.vcsProxy.getBranches(ticket.clientId, agentId); + const baseBranch = + automation.defaultBranchOverride?.trim() || + branches.find((b) => b.name === 'main')?.name || + branches.find((b) => b.name === 'master')?.name || + branches.find((b) => !b.isRemote)?.name || + 'main'; + + await this.vcsProxy.prepareCleanWorkspace(ticket.clientId, agentId, { baseBranch }); + let stepIdx = 0; + await this.persistStep(run.id, stepIdx++, TicketAutomationRunPhase.WORKSPACE_PREP, 'vcs_prepare', { + baseBranch, + }); + + const branchName = `automation/${run.id.slice(0, 8)}`; + await this.vcsProxy.createBranch(ticket.clientId, agentId, { + name: branchName, + baseBranch, + }); + await this.runRepo.update(run.id, { branchName, baseBranch }); + + if (autonomy.preImproveTicket) { + await this.remoteChat.sendChatSync({ + clientId: ticket.clientId, + agentId, + message: `${buildAutonomousTicketRunPreamble()}Improve ticket clarity only; do not implement code yet.`, + correlationId: `${run.id}:pre-improve`, + continue: false, + resumeSessionSuffix: '-ticket-auto-pre', + statisticsInteractionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, + }); + } + + let iteration = 0; + let sawMarker = false; + while (iteration < autonomy.maxIterations) { + iteration += 1; + const text = await this.remoteChat.sendChatSync({ + clientId: ticket.clientId, + agentId, + message: + iteration === 1 + ? `${buildAutonomousTicketRunPreamble()}Implement the ticket in the repository.` + : 'Continue the implementation until the completion marker is present.', + correlationId: `${run.id}:turn:${iteration}`, + continue: iteration > 1, + resumeSessionSuffix: '-ticket-auto-loop', + statisticsInteractionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, + }); + await this.persistStep( + run.id, + stepIdx++, + TicketAutomationRunPhase.AGENT_LOOP, + 'agent_turn', + { iteration }, + text.slice(0, 2000), + ); + await this.runRepo.update(run.id, { iterationCount: iteration, phase: TicketAutomationRunPhase.AGENT_LOOP }); + if (text.includes(AGENSTRA_AUTOMATION_COMPLETE)) { + sawMarker = true; + await this.runRepo.update(run.id, { completionMarkerSeen: true }); + break; + } + } + + if (!sawMarker) { + await this.failRun(run.id, TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER); + return; + } + + const profile = automation.verifierProfile; + if (!profile?.commands?.length) { + await this.failRun(run.id, TicketAutomationFailureCode.MARKER_WITHOUT_VERIFY); + return; + } + + await this.runRepo.update(run.id, { phase: TicketAutomationRunPhase.VERIFY }); + const verify = await this.vcsProxy.runVerifierCommands(ticket.clientId, agentId, { + commands: profile.commands, + timeoutMs: 120_000, + }); + const failed = verify.results.find((r) => r.exitCode !== 0); + if (failed) { + await this.runRepo.update(run.id, { verificationPassed: false }); + await this.failRun(run.id, TicketAutomationFailureCode.VERIFY_COMMAND_FAILED); + return; + } + await this.runRepo.update(run.id, { verificationPassed: true }); + + const finalized = await this.finalizeGitCommitStep(run, ticket, agentId, stepIdx); + if (!finalized.ok) { + return; + } + stepIdx = finalized.nextStepIndex; + + ticket.status = TicketStatus.PROTOTYPE; + await this.ticketRepo.save(ticket); + await this.runRepo.update(run.id, { + status: TicketAutomationRunStatus.SUCCEEDED, + phase: TicketAutomationRunPhase.FINALIZE, + finishedAt: new Date(), + }); + await this.releaseLease(ticket.id); + await this.appendSystemActivity(ticket.id, TicketActionType.AUTOMATION_SUCCEEDED, { runId: run.id }); + await this.automationRepo.update({ ticketId: ticket.id }, { consecutiveFailureCount: 0, nextRetryAt: null }); + } + + /** + * After verification, stage all changes, propose a Conventional Commits subject via ephemeral remote chat + * (or fallback), then `git commit` and `git push`. Skips commit/push when the working tree is already clean. + */ + private async finalizeGitCommitStep( + run: TicketAutomationRunEntity, + ticket: TicketEntity, + agentId: string, + stepIdx: number, + ): Promise<{ ok: true; nextStepIndex: number } | { ok: false }> { + await this.runRepo.update(run.id, { phase: TicketAutomationRunPhase.FINALIZE }); + const status = await this.vcsProxy.getStatus(ticket.clientId, agentId); + if (status.isClean) { + this.logger.log(`Run ${run.id}: working tree clean, no git commit`); + await this.persistStep(run.id, stepIdx++, TicketAutomationRunPhase.FINALIZE, 'git_commit', { + skipped: true, + reason: 'clean', + }); + return { ok: true, nextStepIndex: stepIdx }; + } + + await this.vcsProxy.stageFiles(ticket.clientId, agentId, { files: [] }); + const { message, source } = await this.resolveCommitMessageWithAiFallback(run, ticket, agentId); + + try { + await this.vcsProxy.commit(ticket.clientId, agentId, { message }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`Run ${run.id}: git commit failed: ${msg}`); + await this.failRun(run.id, TicketAutomationFailureCode.COMMIT_FAILED); + return { ok: false }; + } + + await this.persistStep(run.id, stepIdx++, TicketAutomationRunPhase.FINALIZE, 'git_commit', { + message, + messageSource: source, + }); + + try { + await this.vcsProxy.push(ticket.clientId, agentId, {}); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`Run ${run.id}: git push failed: ${msg}`); + await this.failRun(run.id, TicketAutomationFailureCode.PUSH_FAILED); + return { ok: false }; + } + + await this.persistStep(run.id, stepIdx++, TicketAutomationRunPhase.FINALIZE, 'git_push', {}); + return { ok: true, nextStepIndex: stepIdx }; + } + + private async resolveCommitMessageWithAiFallback( + run: TicketAutomationRunEntity, + ticket: TicketEntity, + agentId: string, + ): Promise<{ message: string; source: 'ai' | 'fallback' }> { + const timeoutMs = parseInt(process.env.REMOTE_AGENT_COMMIT_MESSAGE_TIMEOUT_MS || '120000', 10); + try { + const raw = await this.remoteChat.sendChatSync({ + clientId: ticket.clientId, + agentId, + message: buildAutonomousCommitMessagePrompt(ticket, run.branchName ?? ''), + correlationId: `${run.id}:commit-msg`, + continue: false, + resumeSessionSuffix: '-ticket-auto-commit-msg', + statisticsInteractionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_COMMIT_MESSAGE, + chatTimeoutMs: timeoutMs, + }); + const sanitized = sanitizeConventionalCommitSubject(raw); + if (sanitized) { + return { message: sanitized, source: 'ai' }; + } + this.logger.warn(`Run ${run.id}: commit message AI output failed validation, using fallback`); + } catch (e: unknown) { + this.logger.warn( + `Run ${run.id}: commit message generation failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + return { message: buildFallbackAutonomousCommitMessage(ticket), source: 'fallback' }; + } + + private logOrchestratorError(runId: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + this.logger.error(`Run ${runId} failed: ${message}`, stack); + } + + private async failRun(runId: string, code: TicketAutomationFailureCode): Promise { + const run = await this.runRepo.findOne({ where: { id: runId } }); + if (!run) { + return; + } + const ticket = await this.ticketRepo.findOne({ where: { id: run.ticketId } }); + if (!ticket) { + return; + } + const steps = await this.stepRepo.find({ where: { runId } }); + const route = routeAutomationFailure(code); + const runTerminal = + code === TicketAutomationFailureCode.HUMAN_ESCALATION + ? TicketAutomationRunStatus.ESCALATED + : code === TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER + ? TicketAutomationRunStatus.TIMED_OUT + : TicketAutomationRunStatus.FAILED; + + let nextStatus: TicketStatus; + if (route.ticketStatus === 'unchanged') { + nextStatus = run.ticketStatusBefore as TicketStatus; + } else if (route.ticketStatus === TicketStatus.IN_PROGRESS) { + const usable = isUsablePartialPrototype({ + branchName: run.branchName, + hasNonZeroDiffAgainstMergeBase: !!run.branchName, + iterationCount: run.iterationCount, + hasAgentStepWithNonEmptyExcerpt: steps.some((s) => s.kind === 'agent_turn' && !!s.excerpt?.trim()), + }); + nextStatus = usable ? TicketStatus.IN_PROGRESS : TicketStatus.TODO; + } else { + nextStatus = route.ticketStatus as TicketStatus; + } + + ticket.status = nextStatus; + await this.ticketRepo.save(ticket); + await this.runRepo.update(runId, { + status: runTerminal, + failureCode: code, + finishedAt: new Date(), + }); + await this.releaseLease(run.ticketId); + let activity = TicketActionType.AUTOMATION_FAILED; + if (runTerminal === TicketAutomationRunStatus.TIMED_OUT) { + activity = TicketActionType.AUTOMATION_TIMED_OUT; + } else if (runTerminal === TicketAutomationRunStatus.ESCALATED) { + activity = TicketActionType.AUTOMATION_ESCALATED; + } + await this.appendSystemActivity(run.ticketId, activity, { runId, code }); + + const auto = await this.automationRepo.findOne({ where: { ticketId: run.ticketId } }); + await this.automationRepo.update( + { ticketId: run.ticketId }, + { + consecutiveFailureCount: (auto?.consecutiveFailureCount ?? 0) + 1, + nextRetryAt: route.requeue ? new Date(Date.now() + 60_000) : null, + }, + ); + } + + private async releaseLease(ticketId: string): Promise { + await this.leaseRepo.update( + { ticketId, status: TicketAutomationLeaseStatus.ACTIVE }, + { status: TicketAutomationLeaseStatus.RELEASED }, + ); + } + + private async persistStep( + runId: string, + index: number, + phase: TicketAutomationRunPhase, + kind: string, + payload: Record, + excerpt?: string, + ): Promise { + await this.stepRepo.save( + this.stepRepo.create({ + runId, + stepIndex: index, + phase, + kind, + payload, + excerpt: excerpt ?? null, + }), + ); + } + + private async appendSystemActivity(ticketId: string, action: TicketActionType, payload: Record) { + await this.activityRepo.save( + this.activityRepo.create({ + ticketId, + actorType: TicketActorType.SYSTEM, + actorUserId: null, + actionType: action, + payload, + }), + ); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-ticket.scheduler.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-ticket.scheduler.spec.ts new file mode 100644 index 00000000..7d388fb5 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-ticket.scheduler.spec.ts @@ -0,0 +1,50 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AutonomousRunOrchestratorService } from './autonomous-run-orchestrator.service'; +import { AutonomousTicketScheduler } from './autonomous-ticket.scheduler'; + +describe('AutonomousTicketScheduler', () => { + it('invokes orchestrator on tick', async () => { + const orchestrator = { processBatch: jest.fn().mockResolvedValue(undefined) }; + const module: TestingModule = await Test.createTestingModule({ + providers: [AutonomousTicketScheduler, { provide: AutonomousRunOrchestratorService, useValue: orchestrator }], + }).compile(); + const scheduler = module.get(AutonomousTicketScheduler); + await scheduler.tick(); + expect(orchestrator.processBatch).toHaveBeenCalled(); + await module.close(); + }); + + it('does not start a second tick while the first is still in flight', async () => { + let releaseBatch: () => void; + const batchGate = new Promise((resolve) => { + releaseBatch = resolve; + }); + const orchestrator = { + processBatch: jest.fn().mockImplementation(() => batchGate), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [AutonomousTicketScheduler, { provide: AutonomousRunOrchestratorService, useValue: orchestrator }], + }).compile(); + const scheduler = module.get(AutonomousTicketScheduler); + const first = scheduler.tick(); + const second = scheduler.tick(); + expect(orchestrator.processBatch).toHaveBeenCalledTimes(1); + releaseBatch!(); + await first; + await second; + expect(orchestrator.processBatch).toHaveBeenCalledTimes(1); + await module.close(); + }); + + it('runs another tick after the previous one finished', async () => { + const orchestrator = { processBatch: jest.fn().mockResolvedValue(undefined) }; + const module: TestingModule = await Test.createTestingModule({ + providers: [AutonomousTicketScheduler, { provide: AutonomousRunOrchestratorService, useValue: orchestrator }], + }).compile(); + const scheduler = module.get(AutonomousTicketScheduler); + await scheduler.tick(); + await scheduler.tick(); + expect(orchestrator.processBatch).toHaveBeenCalledTimes(2); + await module.close(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-ticket.scheduler.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-ticket.scheduler.ts new file mode 100644 index 00000000..adec7acb --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-ticket.scheduler.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { AutonomousRunOrchestratorService } from './autonomous-run-orchestrator.service'; + +@Injectable() +export class AutonomousTicketScheduler implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AutonomousTicketScheduler.name); + private intervalHandle?: NodeJS.Timeout; + /** Avoid overlapping ticks when a batch outlasts the interval. */ + private tickInFlight = false; + + private readonly intervalMs = parseInt(process.env.AUTONOMOUS_TICKET_SCHEDULER_INTERVAL_MS ?? '60000', 10); + private readonly batchSize = parseInt(process.env.AUTONOMOUS_TICKET_SCHEDULER_BATCH_SIZE ?? '5', 10); + + constructor(private readonly orchestrator: AutonomousRunOrchestratorService) {} + + onModuleInit(): void { + this.logger.log(`Autonomous ticket scheduler every ${this.intervalMs}ms, batch ${this.batchSize}`); + this.intervalHandle = setInterval(() => { + void this.tick(); + }, this.intervalMs); + } + + onModuleDestroy(): void { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + } + } + + async tick(): Promise { + if (this.tickInFlight) { + return; + } + this.tickInFlight = true; + try { + await this.orchestrator.processBatch(this.batchSize); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + this.logger.error(`Autonomous ticket scheduler tick failed: ${message}`, stack); + } finally { + this.tickInFlight = false; + } + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.spec.ts new file mode 100644 index 00000000..c1646e0e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ClientUsersRepository } from '@forepath/identity/backend'; +import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentAutonomyService } from './client-agent-autonomy.service'; + +jest.mock('@forepath/identity/backend', () => { + const actual = jest.requireActual('@forepath/identity/backend'); + return { + ...actual, + ensureClientAccess: jest.fn().mockResolvedValue(undefined), + ensureWorkspaceManagementAccess: jest.fn().mockResolvedValue(undefined), + getUserFromRequest: jest.fn().mockReturnValue({ userId: 'user-1', userRole: 'admin', isApiKeyAuth: false }), + }; +}); + +describe('ClientAgentAutonomyService', () => { + it('upsert persists autonomy fields', async () => { + const repo = { + create: jest.fn((row: unknown) => row), + save: jest.fn((row: unknown) => Promise.resolve(row)), + findOne: jest.fn(), + find: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientAgentAutonomyService, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: repo }, + { provide: ClientsRepository, useValue: {} }, + { provide: ClientUsersRepository, useValue: {} }, + ], + }).compile(); + const service = module.get(ClientAgentAutonomyService); + const clientId = '00000000-0000-4000-8000-000000000010'; + const agentId = '00000000-0000-4000-8000-000000000020'; + await service.upsert(clientId, agentId, { + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 120_000, + maxIterations: 5, + }); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + clientId, + agentId, + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 120_000, + maxIterations: 5, + }), + ); + await module.close(); + }); + + it('listEnabledAgentIds returns enabled agent ids', async () => { + const repo = { + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn().mockResolvedValue([{ agentId: 'a1' }, { agentId: 'a2' }]), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientAgentAutonomyService, + { provide: getRepositoryToken(ClientAgentAutonomyEntity), useValue: repo }, + { provide: ClientsRepository, useValue: {} }, + { provide: ClientUsersRepository, useValue: {} }, + ], + }).compile(); + const service = module.get(ClientAgentAutonomyService); + const res = await service.listEnabledAgentIds('00000000-0000-4000-8000-000000000010', undefined); + expect(res).toEqual({ agentIds: ['a1', 'a2'] }); + expect(repo.find).toHaveBeenCalledWith({ + where: { clientId: '00000000-0000-4000-8000-000000000010', enabled: true }, + select: ['agentId'], + order: { agentId: 'ASC' }, + }); + await module.close(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts new file mode 100644 index 00000000..d7fa2825 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts @@ -0,0 +1,97 @@ +import { + ClientUsersRepository, + ensureClientAccess, + ensureWorkspaceManagementAccess, + getUserFromRequest, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + ClientAgentAutonomyResponseDto, + EnabledAutonomyAgentIdsResponseDto, + UpsertClientAgentAutonomyDto, +} from '../dto/ticket-automation'; +import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { ClientsRepository } from '../repositories/clients.repository'; + +@Injectable() +export class ClientAgentAutonomyService { + constructor( + @InjectRepository(ClientAgentAutonomyEntity) + private readonly repo: Repository, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + private async assertAccess(clientId: string, req?: RequestWithUser): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + } + + private async assertWorkspaceManagement(clientId: string, req?: RequestWithUser): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + } + + private map(row: ClientAgentAutonomyEntity): ClientAgentAutonomyResponseDto { + return { + clientId: row.clientId, + agentId: row.agentId, + enabled: row.enabled, + preImproveTicket: row.preImproveTicket, + maxRuntimeMs: row.maxRuntimeMs, + maxIterations: row.maxIterations, + tokenBudgetLimit: row.tokenBudgetLimit ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async get(clientId: string, agentId: string, req?: RequestWithUser): Promise { + await this.assertAccess(clientId, req); + const row = await this.repo.findOne({ where: { clientId, agentId } }); + if (!row) { + throw new NotFoundException('Autonomy settings not found for this client and agent'); + } + return this.map(row); + } + + /** + * Agent IDs with `enabled` autonomy for this client. The autonomous run scheduler only considers + * tickets whose allowed-agent list intersects this set (see `AutonomousRunOrchestratorService`). + */ + async listEnabledAgentIds(clientId: string, req?: RequestWithUser): Promise { + await this.assertAccess(clientId, req); + const rows = await this.repo.find({ + where: { clientId, enabled: true }, + select: ['agentId'], + order: { agentId: 'ASC' }, + }); + return { agentIds: rows.map((r) => r.agentId) }; + } + + async upsert( + clientId: string, + agentId: string, + dto: UpsertClientAgentAutonomyDto, + req?: RequestWithUser, + ): Promise { + await this.assertWorkspaceManagement(clientId, req); + const info = getUserFromRequest(req || ({} as RequestWithUser)); + if (!info.userId) { + throw new BadRequestException('Interactive user required to edit autonomy settings'); + } + const row = await this.repo.save( + this.repo.create({ + clientId, + agentId, + enabled: dto.enabled, + preImproveTicket: dto.preImproveTicket, + maxRuntimeMs: dto.maxRuntimeMs, + maxIterations: dto.maxIterations, + tokenBudgetLimit: dto.tokenBudgetLimit ?? null, + }), + ); + return this.map(row); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts new file mode 100644 index 00000000..28b114b9 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts @@ -0,0 +1,269 @@ +import { + GitBranchDto, + GitStatusDto, + PrepareCleanWorkspaceDto, + RunVerifierCommandsDto, + RunVerifierCommandsResponseDto, +} from '@forepath/framework/backend/feature-agent-manager'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthenticationType, ClientEntity } from '@forepath/identity/backend'; +import axios, { AxiosError } from 'axios'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { ClientsService } from './clients.service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('ClientAgentVcsProxyService', () => { + let service: ClientAgentVcsProxyService; + let clientsService: jest.Mocked; + let clientsRepository: jest.Mocked; + + const mockClientId = 'test-client-uuid'; + const mockAgentId = 'test-agent-uuid'; + + const mockClientEntity: ClientEntity = { + id: mockClientId, + name: 'Test Client', + description: 'Test Description', + endpoint: 'https://example.com', + authenticationType: AuthenticationType.API_KEY, + apiKey: 'test-api-key', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const mockClientsService = { + getAccessToken: jest.fn(), + }; + + const mockClientsRepository = { + findByIdOrThrow: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientAgentVcsProxyService, + { provide: ClientsService, useValue: mockClientsService }, + { provide: ClientsRepository, useValue: mockClientsRepository }, + ], + }).compile(); + + service = module.get(ClientAgentVcsProxyService); + clientsService = module.get(ClientsService); + clientsRepository = module.get(ClientsRepository); + + jest.clearAllMocks(); + }); + + describe('getStatus', () => { + it('should proxy GET /status with API_KEY auth', async () => { + const status: GitStatusDto = { + currentBranch: 'main', + isClean: true, + hasUnpushedCommits: false, + aheadCount: 0, + behindCount: 0, + files: [], + }; + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 200, data: status } as any); + + const result = await service.getStatus(mockClientId, mockAgentId); + + expect(result).toEqual(status); + expect(clientsRepository.findByIdOrThrow).toHaveBeenCalledWith(mockClientId); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `https://example.com/api/agents/${mockAgentId}/vcs/status`, + headers: expect.objectContaining({ + Authorization: 'Bearer test-api-key', + }), + }), + ); + }); + + it('should use KEYCLOAK token when configured', async () => { + const keycloakClient = { + ...mockClientEntity, + authenticationType: AuthenticationType.KEYCLOAK, + apiKey: undefined, + }; + clientsRepository.findByIdOrThrow.mockResolvedValue(keycloakClient); + clientsService.getAccessToken.mockResolvedValue('jwt-token'); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: { + currentBranch: 'main', + isClean: true, + hasUnpushedCommits: false, + aheadCount: 0, + behindCount: 0, + files: [], + }, + } as any); + + await service.getStatus(mockClientId, mockAgentId); + + expect(clientsService.getAccessToken).toHaveBeenCalledWith(mockClientId); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer jwt-token' }), + }), + ); + }); + + it('should throw NotFoundException when remote returns 404', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 404, + data: { message: 'Agent not found' }, + } as any); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when remote returns 400', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 400, + data: { message: 'Dirty tree' }, + } as any); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when API key is missing for API_KEY client', async () => { + const noKey = { ...mockClientEntity, apiKey: undefined }; + clientsRepository.findByIdOrThrow.mockResolvedValue(noKey); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + + it('should map axios network error to BadRequestException', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + const err = new AxiosError('Network Error'); + err.request = {}; + mockedAxios.request.mockRejectedValue(err); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + }); + }); + + describe('getFileDiff', () => { + it('should pass path as query param', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: { + path: 'src/a.ts', + originalContent: '', + modifiedContent: '', + encoding: 'utf-8', + isBinary: false, + }, + } as any); + + await service.getFileDiff(mockClientId, mockAgentId, 'src/a.ts'); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `https://example.com/api/agents/${mockAgentId}/vcs/diff`, + params: { path: 'src/a.ts' }, + }), + ); + }); + }); + + describe('switchBranch', () => { + it('should encode branch name in URL path', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + + await service.switchBranch(mockClientId, mockAgentId, 'feature/foo bar'); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: `https://example.com/api/agents/${mockAgentId}/vcs/branches/${encodeURIComponent('feature/foo bar')}/switch`, + }), + ); + }); + }); + + describe('prepareCleanWorkspace', () => { + it('should POST to vcs workspace/prepare-clean', async () => { + const body: PrepareCleanWorkspaceDto = { baseBranch: 'main' }; + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + + await service.prepareCleanWorkspace(mockClientId, mockAgentId, body); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: `https://example.com/api/agents/${mockAgentId}/vcs/workspace/prepare-clean`, + data: body, + }), + ); + }); + }); + + describe('runVerifierCommands', () => { + it('should POST to automation verify-commands', async () => { + const body: RunVerifierCommandsDto = { + commands: [{ cmd: 'npm test' }], + timeoutMs: 60_000, + }; + const response: RunVerifierCommandsResponseDto = { + results: [{ cmd: 'npm test', exitCode: 0, output: 'ok' }], + }; + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 200, data: response } as any); + + const result = await service.runVerifierCommands(mockClientId, mockAgentId, body); + + expect(result).toEqual(response); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: `https://example.com/api/agents/${mockAgentId}/automation/verify-commands`, + data: body, + }), + ); + }); + }); + + describe('getBranches', () => { + it('should proxy GET /branches', async () => { + const branches: GitBranchDto[] = [ + { + name: 'main', + ref: 'refs/heads/main', + isCurrent: true, + isRemote: false, + commit: 'abc1234', + message: 'init', + }, + ]; + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 200, data: branches } as any); + + const result = await service.getBranches(mockClientId, mockAgentId); + + expect(result).toEqual(branches); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `https://example.com/api/agents/${mockAgentId}/vcs/branches`, + }), + ); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts index bd099654..7d486e18 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts @@ -5,8 +5,11 @@ import { GitBranchDto, GitDiffDto, GitStatusDto, + PrepareCleanWorkspaceDto, RebaseDto, ResolveConflictDto, + RunVerifierCommandsDto, + RunVerifierCommandsResponseDto, StageFilesDto, UnstageFilesDto, } from '@forepath/framework/backend/feature-agent-manager'; @@ -57,11 +60,9 @@ export class ClientAgentVcsProxyService { * @param agentId - The UUID of the agent * @returns The base URL for agent VCS API requests */ - private buildAgentVcsApiUrl(endpoint: string, agentId: string): string { - // Remove trailing slash if present + private buildAgentManagerResourceUrl(endpoint: string, agentId: string, resource: 'vcs' | 'automation'): string { const baseUrl = endpoint.replace(/\/$/, ''); - // Ensure /api/agents/{agentId}/vcs path - return `${baseUrl}/api/agents/${agentId}/vcs`; + return `${baseUrl}/api/agents/${agentId}/${resource}`; } /** @@ -73,10 +74,15 @@ export class ClientAgentVcsProxyService { * @throws NotFoundException if client or agent is not found * @throws BadRequestException if request fails */ - private async makeRequest(clientId: string, agentId: string, config: AxiosRequestConfig): Promise { + private async makeRequest( + clientId: string, + agentId: string, + config: AxiosRequestConfig, + resource: 'vcs' | 'automation' = 'vcs', + ): Promise { const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); const authHeader = await this.getAuthHeader(clientId); - const baseUrl = this.buildAgentVcsApiUrl(clientEntity.endpoint, agentId); + const baseUrl = this.buildAgentManagerResourceUrl(clientEntity.endpoint, agentId, resource); try { this.logger.debug( @@ -333,4 +339,35 @@ export class ClientAgentVcsProxyService { data: resolveConflictDto, }); } + + /** + * Fetch, checkout base branch, hard reset to upstream, and clean (proxied to client agent-manager). + */ + async prepareCleanWorkspace(clientId: string, agentId: string, body: PrepareCleanWorkspaceDto): Promise { + await this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/workspace/prepare-clean', + data: body, + }); + } + + /** + * Run verifier shell commands in the agent container (proxied). + */ + async runVerifierCommands( + clientId: string, + agentId: string, + body: RunVerifierCommandsDto, + ): Promise { + return await this.makeRequest( + clientId, + agentId, + { + method: 'POST', + url: '/verify-commands', + data: body, + }, + 'automation', + ); + } } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts index 137e0d6e..f0d2a239 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.spec.ts @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthenticationType, ClientEntity, + ClientUserRole, ClientUsersRepository, KeycloakTokenService, UserRole, @@ -386,6 +387,27 @@ describe('ClientsService', () => { expect(repository.findAll).toHaveBeenCalledWith(10, 0); }); + + it('should set canManageWorkspaceConfiguration to false for plain client_users user', async () => { + const clientForList: ClientEntity = { ...mockClient, userId: 'other-creator' }; + mockRepository.findAll.mockResolvedValue([clientForList]); + mockRepository.findById.mockResolvedValue(clientForList); + clientUsersRepository.findByUserId.mockResolvedValue([ + { clientId: clientForList.id, userId: 'member-1', role: ClientUserRole.USER } as any, + ]); + mockClientUsersRepository.findUserClientAccess.mockResolvedValue({ + userId: 'member-1', + clientId: clientForList.id, + role: ClientUserRole.USER, + } as any); + clientAgentProxyService.getClientConfig.mockResolvedValue(undefined); + provisioningReferencesRepository.findByClientId.mockResolvedValue(null); + + const result = await service.findAll(10, 0, 'member-1', UserRole.USER, false); + + expect(result).toHaveLength(1); + expect(result[0].canManageWorkspaceConfiguration).toBe(false); + }); }); describe('findOne', () => { diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.ts index 037c5d02..513bbc14 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/clients.service.ts @@ -5,9 +5,11 @@ import { CreateClientResponseDto } from '../dto/create-client-response.dto'; import { CreateClientDto } from '../dto/create-client.dto'; import { UpdateClientDto } from '../dto/update-client.dto'; import { + assertWorkspaceManagementAccessForUser, AuthenticationType, + canManageWorkspaceConfiguration, + checkClientAccess, ClientEntity, - ClientUserRole, ClientUsersRepository, KeycloakTokenService, UserRole, @@ -62,44 +64,6 @@ export class ClientsService { return authMethod === 'api-key' || (authMethod === undefined && !!process.env.STATIC_API_KEY); } - /** - * Check if a user has access to a client. - * @param userId - The UUID of the user - * @param userRole - The role of the user (from users table) - * @param client - The client entity - * @param isApiKeyAuth - Whether the request is authenticated via API key - * @returns Object with access status and client user role if applicable - */ - private async checkClientAccess( - userId: string, - userRole: UserRole, - client: ClientEntity, - isApiKeyAuth: boolean, - ): Promise<{ hasAccess: boolean; clientUserRole?: ClientUserRole }> { - // API key mode: always grant access - if (isApiKeyAuth || this.isApiKeyMode()) { - return { hasAccess: true }; - } - - // Global admin: always has access - if (userRole === UserRole.ADMIN) { - return { hasAccess: true }; - } - - // Creator: always has access - if (client.userId === userId) { - return { hasAccess: true }; - } - - // Check many-to-many relationship - const clientUser = await this.clientUsersRepository.findUserClientAccess(userId, client.id); - if (clientUser) { - return { hasAccess: true, clientUserRole: clientUser.role }; - } - - return { hasAccess: false }; - } - /** * Create a new client with API key (provided or auto-generated if needed). * @param createClientDto - Data transfer object for creating a client @@ -107,7 +71,12 @@ export class ClientsService { * @returns The created client response DTO with API key (if applicable) * @throws BadRequestException if client name already exists */ - async create(createClientDto: CreateClientDto, userId?: string): Promise { + async create( + createClientDto: CreateClientDto, + userId?: string, + userRole?: UserRole, + isApiKeyAuth = false, + ): Promise { // Check if client with the same name already exists const existingClient = await this.clientsRepository.findByName(createClientDto.name); if (existingClient) { @@ -143,7 +112,7 @@ export class ClientsService { userId: userId ?? null, }); - const response = await this.mapToResponseDto(client); + const response = await this.mapToResponseDto(client, { userId, userRole, isApiKeyAuth }); this.statisticsService .recordEntityCreated(StatisticsEntityType.CLIENT, client.id, {}, userId ?? undefined) .catch(() => undefined); @@ -172,9 +141,10 @@ export class ClientsService { // In api-key mode, return all clients if (isApiKeyAuth || this.isApiKeyMode()) { const clients = await this.clientsRepository.findAll(limit, offset); + const viewer = { userId, userRole, isApiKeyAuth: true }; return Promise.all( clients.map(async (client) => { - const dto = await this.mapToResponseDto(client); + const dto = await this.mapToResponseDto(client, viewer); try { dto.config = await this.clientAgentProxyService.getClientConfig(client.id); } catch (error) { @@ -193,9 +163,10 @@ export class ClientsService { // Global admin: return all clients if (userRole === UserRole.ADMIN) { const clients = await this.clientsRepository.findAll(limit, offset); + const viewer = { userId, userRole, isApiKeyAuth: false }; return Promise.all( clients.map(async (client) => { - const dto = await this.mapToResponseDto(client); + const dto = await this.mapToResponseDto(client, viewer); try { dto.config = await this.clientAgentProxyService.getClientConfig(client.id); } catch (error) { @@ -220,9 +191,10 @@ export class ClientsService { // Apply pagination const paginatedClients = accessibleClients.slice(offset, offset + limit); + const viewer = { userId, userRole, isApiKeyAuth: false }; return Promise.all( paginatedClients.map(async (client) => { - const dto = await this.mapToResponseDto(client); + const dto = await this.mapToResponseDto(client, viewer); try { dto.config = await this.clientAgentProxyService.getClientConfig(client.id); } catch (error) { @@ -248,13 +220,20 @@ export class ClientsService { // Check access permissions if (!isApiKeyAuth && !this.isApiKeyMode() && userId && userRole) { - const access = await this.checkClientAccess(userId, userRole, client, isApiKeyAuth); + const access = await checkClientAccess( + this.clientsRepository, + this.clientUsersRepository, + id, + userId, + userRole, + isApiKeyAuth, + ); if (!access.hasAccess) { throw new ForbiddenException('You do not have access to this client'); } } - const dto = await this.mapToResponseDto(client); + const dto = await this.mapToResponseDto(client, { userId, userRole, isApiKeyAuth }); // Fetch config from agent-manager, but don't fail if request fails try { dto.config = await this.clientAgentProxyService.getClientConfig(id); @@ -286,12 +265,16 @@ export class ClientsService { ): Promise { const client = await this.clientsRepository.findByIdOrThrow(id); - // Check access permissions + // Check access permissions (workspace managers only) if (!isApiKeyAuth && !this.isApiKeyMode() && userId && userRole) { - const access = await this.checkClientAccess(userId, userRole, client, isApiKeyAuth); - if (!access.hasAccess) { - throw new ForbiddenException('You do not have access to this client'); - } + await assertWorkspaceManagementAccessForUser( + this.clientsRepository, + this.clientUsersRepository, + id, + userId, + userRole, + isApiKeyAuth, + ); } // If name is being updated, check for conflicts if (updateClientDto.name) { @@ -361,7 +344,7 @@ export class ClientsService { this.statisticsService .recordEntityUpdated(StatisticsEntityType.CLIENT, id, {}, userId ?? undefined) .catch(() => undefined); - const dto = await this.mapToResponseDto(updatedClient); + const dto = await this.mapToResponseDto(updatedClient, { userId, userRole, isApiKeyAuth }); // Fetch config from agent-manager, but don't fail if request fails try { dto.config = await this.clientAgentProxyService.getClientConfig(id); @@ -383,12 +366,16 @@ export class ClientsService { async remove(id: string, userId?: string, userRole?: UserRole, isApiKeyAuth = false): Promise { const client = await this.clientsRepository.findByIdOrThrow(id); - // Check access permissions + // Check access permissions (workspace managers only) if (!isApiKeyAuth && !this.isApiKeyMode() && userId && userRole) { - const access = await this.checkClientAccess(userId, userRole, client, isApiKeyAuth); - if (!access.hasAccess) { - throw new ForbiddenException('You do not have access to this client'); - } + await assertWorkspaceManagementAccessForUser( + this.clientsRepository, + this.clientUsersRepository, + id, + userId, + userRole, + isApiKeyAuth, + ); } // Clear token cache if it's a Keycloak client @@ -471,13 +458,23 @@ export class ClientsService { * Map client entity to response DTO. * Excludes sensitive information like API key. * @param client - The client entity to map + * @param viewer - Current viewer; used to compute {@link ClientResponseDto.canManageWorkspaceConfiguration} * @returns The client response DTO */ - private async mapToResponseDto(client: ClientEntity): Promise { + private async mapToResponseDto( + client: ClientEntity, + viewer: { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean } = { + userId: undefined, + userRole: undefined, + isApiKeyAuth: false, + }, + ): Promise { // Check if client was auto-provisioned by checking for provisioning reference const provisioningReference = await this.provisioningReferencesRepository.findByClientId(client.id); const isAutoProvisioned = provisioningReference !== null; + const canManageWorkspaceConfiguration = await this.computeCanManageWorkspaceConfiguration(client.id, viewer); + return { id: client.id, name: client.name, @@ -485,8 +482,33 @@ export class ClientsService { endpoint: client.endpoint, authenticationType: client.authenticationType, isAutoProvisioned, + canManageWorkspaceConfiguration, createdAt: client.createdAt, updatedAt: client.updatedAt, }; } + + private async computeCanManageWorkspaceConfiguration( + clientId: string, + viewer: { userId?: string; userRole?: UserRole; isApiKeyAuth: boolean }, + ): Promise { + if (viewer.isApiKeyAuth || this.isApiKeyMode()) { + return true; + } + if (!viewer.userId || !viewer.userRole) { + return false; + } + const access = await checkClientAccess( + this.clientsRepository, + this.clientUsersRepository, + clientId, + viewer.userId, + viewer.userRole, + false, + ); + return canManageWorkspaceConfiguration( + { userId: viewer.userId, userRole: viewer.userRole, isApiKeyAuth: false }, + access, + ); + } } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts index 2f558b37..0ba131b7 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.spec.ts @@ -193,6 +193,8 @@ describe('ProvisioningService', () => { agentWsPort: 8080, }), undefined, // userId + undefined, // userRole + false, // isApiKeyAuth ); expect(mockProvisioningReferencesRepository.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -240,7 +242,9 @@ describe('ProvisioningService', () => { keycloakClientSecret: 'keycloak-client-secret', keycloakRealm: 'test-realm', }), - undefined, // userId + undefined, + undefined, + false, ); }); @@ -262,7 +266,9 @@ describe('ProvisioningService', () => { expect.objectContaining({ apiKey: 'custom-api-key-123', }), - undefined, // userId + undefined, + undefined, + false, ); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts index 14527e8c..8715106c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/provisioning.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { AuthenticationType } from '@forepath/identity/backend'; +import { AuthenticationType, UserRole } from '@forepath/identity/backend'; import { StatisticsEntityType } from '../entities/statistics-entity-event.entity'; import { randomBytes } from 'crypto'; import { ProvisionServerDto } from '../dto/provision-server.dto'; @@ -294,11 +294,15 @@ DOCKER_COMPOSE_EOF * Provision a new server and create a client. * @param provisionServerDto - Provisioning options * @param userId - The UUID of the user creating the client (optional, for api-key mode) + * @param userRole - Global user role when authenticated interactively + * @param isApiKeyAuth - True when the request used API key authentication * @returns Provisioned server response with client information */ async provisionServer( provisionServerDto: ProvisionServerDto, userId?: string, + userRole?: UserRole, + isApiKeyAuth = false, ): Promise { // Get the provider if (!this.provisioningProviderFactory.hasProvider(provisionServerDto.providerType)) { @@ -373,6 +377,8 @@ DOCKER_COMPOSE_EOF agentWsPort: provisionServerDto.agentWsPort || 8443, }, userId, + userRole, + isApiKeyAuth, ); // Create provisioning reference diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts new file mode 100644 index 00000000..8ad9bda1 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts @@ -0,0 +1,196 @@ +import { ClientAgentCredentialsRepository } from '@forepath/identity/backend'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { AuthenticationType } from '@forepath/identity/backend'; +import type { Socket as ClientSocket } from 'socket.io-client'; +import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientsService } from './clients.service'; +import { StatisticsService } from './statistics.service'; + +export interface RemoteChatSyncParams { + clientId: string; + agentId: string; + message: string; + correlationId: string; + continue?: boolean; + resumeSessionSuffix?: string; + /** When set, records statistics under this kind instead of default chat. */ + statisticsInteractionKind?: StatisticsInteractionKind; + /** Overrides `REMOTE_AGENT_CHAT_TIMEOUT_MS` for the agent response wait (e.g. shorter commit-message generation). */ + chatTimeoutMs?: number; +} + +/** + * Short-lived Socket.IO client to the client's agent-manager namespace for synchronous `chat` turns. + * Extracts the credential + URL wiring from {@link ClientsGateway} without coupling to UI sockets. + */ +@Injectable() +export class RemoteAgentsSessionService { + private readonly logger = new Logger(RemoteAgentsSessionService.name); + + constructor( + private readonly clientsRepository: ClientsRepository, + private readonly clientsService: ClientsService, + private readonly clientAgentCredentialsRepository: ClientAgentCredentialsRepository, + private readonly statisticsService: StatisticsService, + ) {} + + private buildAgentsWsUrl(endpoint: string, overridePort?: number): string { + const url = new URL(endpoint); + const effectivePort = (overridePort && String(overridePort)) || process.env.CLIENTS_REMOTE_WS_PORT || '8080'; + const protocol = url.protocol === 'https:' ? 'https' : 'http'; + const host = url.hostname; + return `${protocol}://${host}:${effectivePort}/agents`; + } + + private async getAuthHeader(clientId: string): Promise { + const client = await this.clientsRepository.findByIdOrThrow(clientId); + if (client.authenticationType === AuthenticationType.API_KEY) { + if (!client.apiKey) { + throw new BadRequestException('API key not configured for client'); + } + return `Bearer ${client.apiKey}`; + } + if (client.authenticationType === AuthenticationType.KEYCLOAK) { + const token = await this.clientsService.getAccessToken(clientId); + return `Bearer ${token}`; + } + throw new BadRequestException('Unsupported authentication type'); + } + + private extractAgentText(payload: unknown): string { + if (!payload || typeof payload !== 'object') { + return ''; + } + const envelope = payload as { success?: boolean; data?: { from?: string; response?: unknown } }; + if (!envelope.success || !envelope.data || envelope.data.from !== 'agent') { + return ''; + } + const r = envelope.data.response; + if (typeof r === 'string') { + return r; + } + if (r && typeof r === 'object' && 'result' in (r as object)) { + const res = (r as { result?: unknown }).result; + return typeof res === 'string' ? res : JSON.stringify(res); + } + return JSON.stringify(r); + } + + /** + * Connects to the remote agents gateway, logs in, sends one non-streaming `chat`, returns aggregated agent text. + */ + async sendChatSync(params: RemoteChatSyncParams): Promise { + const client = await this.clientsRepository.findByIdOrThrow(params.clientId); + const authHeader = await this.getAuthHeader(params.clientId); + const remoteUrl = this.buildAgentsWsUrl(client.endpoint, client.agentWsPort); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { io } = require('socket.io-client'); + const remote: ClientSocket = io(remoteUrl, { + transports: ['websocket'], + extraHeaders: { Authorization: authHeader }, + rejectUnauthorized: false, + reconnection: false, + }); + + const creds = await this.clientAgentCredentialsRepository.findByClientAndAgent(params.clientId, params.agentId); + if (!creds?.password) { + throw new BadRequestException('No stored credentials for this agent'); + } + + const chatTimeoutMs = params.chatTimeoutMs ?? parseInt(process.env.REMOTE_AGENT_CHAT_TIMEOUT_MS || '600000', 10); + + try { + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new BadRequestException('Remote socket connect timeout')), 15000); + remote.once('connect', () => { + clearTimeout(t); + resolve(); + }); + remote.once('connect_error', (err: Error) => { + clearTimeout(t); + reject(err); + }); + }); + + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new BadRequestException('Remote login timeout')), 10000); + remote.once('loginSuccess', () => { + clearTimeout(t); + resolve(); + }); + remote.once('loginError', (err: unknown) => { + clearTimeout(t); + const msg = (err as { error?: { message?: string } })?.error?.message ?? 'login failed'; + reject(new BadRequestException(msg)); + }); + remote.emit('login', { agentId: params.agentId, password: creds.password }); + }); + + const wordCount = params.message.trim().split(/\s+/).filter(Boolean).length; + const charCount = params.message.length; + const kind = params.statisticsInteractionKind ?? StatisticsInteractionKind.CHAT; + await this.statisticsService.recordChatInput( + params.clientId, + params.agentId, + wordCount, + charCount, + undefined, + kind, + ); + + const output = await new Promise((resolve, reject) => { + let settled = false; + const t = setTimeout(() => { + if (!settled) { + settled = true; + remote.off('chatMessage', onChatMessage); + reject(new BadRequestException('Timed out waiting for agent chat response')); + } + }, chatTimeoutMs); + + const onChatMessage = (msg: unknown) => { + const text = this.extractAgentText(msg); + if (text && !settled) { + settled = true; + clearTimeout(t); + remote.off('chatMessage', onChatMessage); + resolve(text); + } + }; + + remote.on('chatMessage', onChatMessage); + remote.emit('chat', { + message: params.message, + correlationId: params.correlationId, + responseMode: 'sync', + ephemeral: true, + continue: params.continue ?? false, + resumeSessionSuffix: params.resumeSessionSuffix, + }); + }); + + const outWords = output.trim().split(/\s+/).filter(Boolean).length; + await this.statisticsService.recordChatOutput( + params.clientId, + params.agentId, + outWords, + output.length, + undefined, + kind, + ); + + return output; + } catch (error: unknown) { + this.logger.warn(`sendChatSync failed: ${(error as Error).message}`); + throw error instanceof BadRequestException ? error : new BadRequestException('Remote chat failed'); + } finally { + try { + remote.removeAllListeners(); + remote.disconnect(); + } catch { + // ignore + } + } + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts index 158546ea..5cb6a401 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { StatisticsEntityEventType, StatisticsEntityType } from '../entities/statistics-entity-event.entity'; import { StatisticsRepository } from '../repositories/statistics.repository'; import { StatisticsQueryService } from './statistics-query.service'; @@ -235,6 +236,25 @@ describe('StatisticsQueryService', () => { }); }); + describe('getClientChatIo interactionKind filter', () => { + it('accepts autonomous_ticket_run_turn', async () => { + mockRepository.findStatisticsClientIdsByOriginalIds.mockResolvedValue(['sc1']); + mockRepository.queryChatIo.mockResolvedValue({ rows: [], total: 0 }); + + await service.getClientChatIo('client-1', { + limit: 10, + offset: 0, + interactionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, + }); + + expect(repository.queryChatIo).toHaveBeenCalledWith( + expect.objectContaining({ + interactionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, + }), + ); + }); + }); + describe('getEntityEvents', () => { it('should pass accessible client IDs to repository', async () => { mockRepository.findStatisticsClientIdsByOriginalIds.mockResolvedValue(['sc1']); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts index 0f3115f1..959aa327 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts @@ -34,14 +34,21 @@ function normalizeToEndOfDay(value: string | undefined): string | undefined { return value; } +const ALLOWED_INTERACTION_KINDS: ReadonlySet = new Set([ + StatisticsInteractionKind.CHAT, + StatisticsInteractionKind.PROMPT_ENHANCEMENT, + StatisticsInteractionKind.TICKET_BODY_GENERATION, + StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN, + StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, + StatisticsInteractionKind.AUTONOMOUS_TICKET_COMMIT_MESSAGE, +]); + function parseStatisticsInteractionKind(value: string | undefined): StatisticsInteractionKind | undefined { if (!value) return undefined; - if (value === StatisticsInteractionKind.CHAT || value === StatisticsInteractionKind.PROMPT_ENHANCEMENT) { - return value; + if (ALLOWED_INTERACTION_KINDS.has(value)) { + return value as StatisticsInteractionKind; } - throw new BadRequestException( - `interactionKind must be "${StatisticsInteractionKind.CHAT}" or "${StatisticsInteractionKind.PROMPT_ENHANCEMENT}"`, - ); + throw new BadRequestException(`interactionKind must be one of: ${[...ALLOWED_INTERACTION_KINDS].join(', ')}`); } @Injectable() diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts new file mode 100644 index 00000000..6901eb01 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts @@ -0,0 +1,179 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ClientUsersRepository } from '@forepath/identity/backend'; +import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease.entity'; +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; +import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; +import { TicketEntity } from '../entities/ticket.entity'; +import { TicketActionType, TicketStatus } from '../entities/ticket.enums'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { TicketAutomationService } from './ticket-automation.service'; + +jest.mock('@forepath/identity/backend', () => { + const actual = jest.requireActual('@forepath/identity/backend'); + return { + ...actual, + ensureClientAccess: jest.fn().mockResolvedValue(undefined), + getUserFromRequest: jest.fn().mockReturnValue({ userId: 'user-1', userRole: 'admin', isApiKeyAuth: false }), + }; +}); + +describe('TicketAutomationService', () => { + let service: TicketAutomationService; + const automationRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn((x: unknown) => x), + update: jest.fn(), + }; + const runRepo = { find: jest.fn(), findOne: jest.fn(), save: jest.fn(), update: jest.fn() }; + const leaseRepo = { findOne: jest.fn(), update: jest.fn() }; + const stepRepo = { find: jest.fn() }; + const ticketRepo = { findOne: jest.fn(), save: jest.fn() }; + const activityRepo = { save: jest.fn(), create: jest.fn((x: unknown) => x) }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TicketAutomationService, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, + { provide: getRepositoryToken(TicketAutomationRunEntity), useValue: runRepo }, + { provide: getRepositoryToken(TicketAutomationLeaseEntity), useValue: leaseRepo }, + { provide: getRepositoryToken(TicketAutomationRunStepEntity), useValue: stepRepo }, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: ClientsRepository, useValue: {} }, + { provide: ClientUsersRepository, useValue: {} }, + ], + }).compile(); + service = module.get(TicketAutomationService); + }); + + it('throws when ticket not found on getAutomation', async () => { + ticketRepo.findOne.mockResolvedValue(null); + await expect(service.getAutomation('00000000-0000-4000-8000-000000000001', undefined)).rejects.toThrow( + NotFoundException, + ); + }); + + it('returns automation dto when row exists', async () => { + const tid = '00000000-0000-4000-8000-000000000002'; + ticketRepo.findOne.mockResolvedValue({ + id: tid, + clientId: 'c1', + status: TicketStatus.TODO, + }); + automationRepo.findOne.mockResolvedValueOnce(null); + automationRepo.save.mockImplementation(async (row: TicketAutomationEntity) => + Promise.resolve({ + ...row, + allowedAgentIds: row.allowedAgentIds ?? [], + requiresApproval: row.requiresApproval ?? false, + consecutiveFailureCount: 0, + createdAt: new Date('2020-01-01'), + updatedAt: new Date('2020-01-01'), + }), + ); + const dto = await service.getAutomation(tid, undefined); + expect(dto.ticketId).toBe(tid); + expect(dto.eligible).toBe(false); + }); + + describe('patchAutomation', () => { + const tid = '00000000-0000-4000-8000-000000000010'; + + function baseTicket() { + return { id: tid, clientId: 'c1', status: TicketStatus.TODO }; + } + + function baseAutomation(overrides: Partial> = {}) { + return { + ticketId: tid, + eligible: false, + allowedAgentIds: [] as string[], + verifierProfile: null as { commands: Array<{ cmd: string; cwd?: string }> } | null, + requiresApproval: false, + approvedAt: null as Date | null, + approvedByUserId: null as string | null, + approvalBaselineTicketUpdatedAt: null as Date | null, + defaultBranchOverride: null as string | null, + consecutiveFailureCount: 0, + createdAt: new Date('2020-01-01'), + updatedAt: new Date('2020-01-01'), + ...overrides, + }; + } + + beforeEach(() => { + ticketRepo.findOne.mockResolvedValue(baseTicket()); + runRepo.find.mockResolvedValue([]); + automationRepo.save.mockImplementation(async (row: object) => Promise.resolve({ ...(row as object) })); + }); + + it('logs eligibility change without approval invalidated when approval is not required', async () => { + automationRepo.findOne.mockResolvedValue(baseAutomation({ eligible: false, requiresApproval: false })); + await service.patchAutomation(tid, { eligible: true }, undefined); + const types = activityRepo.save.mock.calls.map((c) => (c[0] as { actionType: string }).actionType); + expect(types).toEqual([TicketActionType.AUTOMATION_ELIGIBILITY_CHANGED]); + }); + + it('logs approval requirement change only when turning off requirement (no approval invalidated)', async () => { + const approvedAt = new Date('2024-06-01'); + automationRepo.findOne.mockResolvedValue( + baseAutomation({ + requiresApproval: true, + approvedAt, + approvedByUserId: 'user-1', + }), + ); + await service.patchAutomation(tid, { requiresApproval: false }, undefined); + const types = activityRepo.save.mock.calls.map((c) => (c[0] as { actionType: string }).actionType); + expect(types).toEqual([TicketActionType.AUTOMATION_APPROVAL_REQUIREMENT_CHANGED]); + }); + + it('logs approval invalidated when prior approval existed and an eligibility change voids it', async () => { + const approvedAt = new Date('2024-06-01'); + automationRepo.findOne.mockResolvedValue( + baseAutomation({ + eligible: true, + requiresApproval: true, + approvedAt, + approvedByUserId: 'user-1', + }), + ); + await service.patchAutomation(tid, { eligible: false }, undefined); + const types = activityRepo.save.mock.calls.map((c) => (c[0] as { actionType: string }).actionType); + expect(types).toEqual([ + TicketActionType.AUTOMATION_ELIGIBILITY_CHANGED, + TicketActionType.AUTOMATION_APPROVAL_INVALIDATED, + ]); + }); + + it('logs settings updated when allowed agents change', async () => { + automationRepo.findOne.mockResolvedValue(baseAutomation({ allowedAgentIds: [] })); + await service.patchAutomation(tid, { allowedAgentIds: ['00000000-0000-4000-8000-0000000000aa'] }, undefined); + const types = activityRepo.save.mock.calls.map((c) => (c[0] as { actionType: string }).actionType); + expect(types).toEqual([TicketActionType.AUTOMATION_SETTINGS_UPDATED]); + }); + + it('does not save or log when patch is a no-op', async () => { + automationRepo.findOne.mockResolvedValue( + baseAutomation({ eligible: true, allowedAgentIds: ['00000000-0000-4000-8000-0000000000aa'] }), + ); + await service.patchAutomation( + tid, + { + eligible: true, + allowedAgentIds: ['00000000-0000-4000-8000-0000000000aa'], + }, + undefined, + ); + expect(automationRepo.save).not.toHaveBeenCalled(); + expect(activityRepo.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts new file mode 100644 index 00000000..c7bf322e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts @@ -0,0 +1,433 @@ +import { + ClientUsersRepository, + ensureClientAccess, + getUserFromRequest, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + ClientAgentAutonomyResponseDto, + TicketAutomationResponseDto, + TicketAutomationRunResponseDto, + TicketAutomationRunStepResponseDto, + UpdateTicketAutomationDto, +} from '../dto/ticket-automation'; +import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease.entity'; +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; +import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; +import { + TicketAutomationCancellationReason, + TicketAutomationLeaseStatus, + TicketAutomationRunStatus, +} from '../entities/ticket-automation.enums'; +import { TicketEntity } from '../entities/ticket.entity'; +import { TicketActionType, TicketActorType, TicketStatus } from '../entities/ticket.enums'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { parseAndValidateVerifierProfile } from '../utils/verifier-profile.validation'; + +const APPROVAL_RELEVANT_AUTOMATION_FIELDS = new Set([ + 'eligible', + 'allowedAgentIds', + 'verifierProfile', + 'requiresApproval', + 'defaultBranchOverride', +]); + +function sortUuidList(ids: string[]): string[] { + return [...ids].sort(); +} + +function normalizeDefaultBranch(value: string | null | undefined): string | null { + if (value === null || value === undefined) { + return null; + } + const t = value.trim(); + return t === '' ? null : t; +} + +export const TICKET_APPROVAL_INVALIDATION_FIELDS = new Set([ + 'title', + 'content', + 'priority', + 'status', + 'parentId', + 'clientId', +]); + +@Injectable() +export class TicketAutomationService { + constructor( + @InjectRepository(TicketAutomationEntity) + private readonly automationRepo: Repository, + @InjectRepository(TicketAutomationRunEntity) + private readonly runRepo: Repository, + @InjectRepository(TicketAutomationLeaseEntity) + private readonly leaseRepo: Repository, + @InjectRepository(TicketAutomationRunStepEntity) + private readonly stepRepo: Repository, + @InjectRepository(TicketEntity) + private readonly ticketRepo: Repository, + @InjectRepository(TicketActivityEntity) + private readonly activityRepo: Repository, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + private async assertTicketAccess(ticketId: string, req?: RequestWithUser): Promise { + const ticket = await this.ticketRepo.findOne({ where: { id: ticketId } }); + if (!ticket) { + throw new NotFoundException(`Ticket with ID ${ticketId} not found`); + } + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, ticket.clientId, req); + return ticket; + } + + private resolveActor(req?: RequestWithUser): { actorType: TicketActorType; actorUserId?: string | null } { + const info = getUserFromRequest(req || ({} as RequestWithUser)); + if (info.userId) { + return { actorType: TicketActorType.HUMAN, actorUserId: info.userId }; + } + return { actorType: TicketActorType.SYSTEM, actorUserId: null }; + } + + async ensureRow(ticketId: string): Promise { + let row = await this.automationRepo.findOne({ where: { ticketId } }); + if (!row) { + row = await this.automationRepo.save( + this.automationRepo.create({ + ticketId, + eligible: false, + allowedAgentIds: [], + requiresApproval: false, + }), + ); + } + return row; + } + + private mapAutomation(row: TicketAutomationEntity): TicketAutomationResponseDto { + return { + ticketId: row.ticketId, + eligible: row.eligible, + allowedAgentIds: row.allowedAgentIds ?? [], + verifierProfile: row.verifierProfile ?? null, + requiresApproval: row.requiresApproval, + approvedAt: row.approvedAt ?? null, + approvedByUserId: row.approvedByUserId ?? null, + approvalBaselineTicketUpdatedAt: row.approvalBaselineTicketUpdatedAt ?? null, + defaultBranchOverride: row.defaultBranchOverride ?? null, + nextRetryAt: row.nextRetryAt ?? null, + consecutiveFailureCount: row.consecutiveFailureCount, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async getAutomation(ticketId: string, req?: RequestWithUser): Promise { + await this.assertTicketAccess(ticketId, req); + const row = await this.ensureRow(ticketId); + return this.mapAutomation(row); + } + + async patchAutomation( + ticketId: string, + dto: UpdateTicketAutomationDto, + req?: RequestWithUser, + ): Promise { + await this.assertTicketAccess(ticketId, req); + const row = await this.ensureRow(ticketId); + + const prevEligible = row.eligible; + const prevRequiresApproval = row.requiresApproval; + const prevApprovedAt = row.approvedAt; + const prevAllowedSorted = sortUuidList(row.allowedAgentIds ?? []); + const prevVerifierJson = JSON.stringify(parseAndValidateVerifierProfile(row.verifierProfile ?? { commands: [] })); + const prevDefaultBranch = normalizeDefaultBranch(row.defaultBranchOverride); + + const actuallyChanged: string[] = []; + + if (dto.eligible !== undefined && dto.eligible !== prevEligible) { + row.eligible = dto.eligible; + actuallyChanged.push('eligible'); + } + if (dto.requiresApproval !== undefined && dto.requiresApproval !== prevRequiresApproval) { + row.requiresApproval = dto.requiresApproval; + actuallyChanged.push('requiresApproval'); + } + if (dto.allowedAgentIds !== undefined) { + const nextSorted = sortUuidList(dto.allowedAgentIds); + if (JSON.stringify(nextSorted) !== JSON.stringify(prevAllowedSorted)) { + row.allowedAgentIds = dto.allowedAgentIds; + actuallyChanged.push('allowedAgentIds'); + } + } + if (dto.verifierProfile !== undefined) { + const parsed = parseAndValidateVerifierProfile(dto.verifierProfile); + const nextJson = JSON.stringify(parsed); + if (nextJson !== prevVerifierJson) { + row.verifierProfile = parsed; + actuallyChanged.push('verifierProfile'); + } + } + if (dto.defaultBranchOverride !== undefined) { + const nextBranch = normalizeDefaultBranch(dto.defaultBranchOverride); + if (nextBranch !== prevDefaultBranch) { + row.defaultBranchOverride = nextBranch; + actuallyChanged.push('defaultBranchOverride'); + } + } + + if (actuallyChanged.length === 0) { + return this.mapAutomation(row); + } + + const shouldInvalidateState = actuallyChanged.some((k) => APPROVAL_RELEVANT_AUTOMATION_FIELDS.has(k)); + if (shouldInvalidateState) { + row.approvedAt = null; + row.approvedByUserId = null; + row.approvalBaselineTicketUpdatedAt = null; + } + + const saved = await this.automationRepo.save(row); + + const hadMeaningfulApproval = prevRequiresApproval === true && prevApprovedAt != null; + const onlyDisabledApprovalRequirement = + actuallyChanged.length === 1 && actuallyChanged[0] === 'requiresApproval' && saved.requiresApproval === false; + + const shouldLogApprovalInvalidated = + hadMeaningfulApproval && shouldInvalidateState && !onlyDisabledApprovalRequirement; + + if (actuallyChanged.includes('eligible')) { + await this.appendActivity( + ticketId, + TicketActionType.AUTOMATION_ELIGIBILITY_CHANGED, + { eligible: saved.eligible }, + req, + ); + } + if (actuallyChanged.includes('requiresApproval')) { + await this.appendActivity( + ticketId, + TicketActionType.AUTOMATION_APPROVAL_REQUIREMENT_CHANGED, + { requiresApproval: saved.requiresApproval }, + req, + ); + } + const settingsDetailFields = actuallyChanged.filter( + (k) => k === 'allowedAgentIds' || k === 'verifierProfile' || k === 'defaultBranchOverride', + ); + if (settingsDetailFields.length > 0) { + await this.appendActivity( + ticketId, + TicketActionType.AUTOMATION_SETTINGS_UPDATED, + { fields: settingsDetailFields }, + req, + ); + } + + if (shouldLogApprovalInvalidated) { + await this.appendActivity( + ticketId, + TicketActionType.AUTOMATION_APPROVAL_INVALIDATED, + { + reason: 'automation_settings_changed', + fields: actuallyChanged, + }, + req, + ); + } + + await this.cancelRunningIfApprovalLost(ticketId, req, shouldLogApprovalInvalidated); + + return this.mapAutomation(saved); + } + + private async appendActivity( + ticketId: string, + action: TicketActionType, + payload: Record, + req?: RequestWithUser, + ) { + const actor = this.resolveActor(req); + await this.activityRepo.save( + this.activityRepo.create({ + ticketId, + actorType: actor.actorType, + actorUserId: actor.actorUserId ?? null, + actionType: action, + payload, + }), + ); + } + + /** + * When ticket fields change, clear automation approval if it was granted. + */ + async invalidateAfterTicketFieldChanges( + ticketId: string, + changedKeys: string[], + req?: RequestWithUser, + ): Promise { + if (!changedKeys.some((k) => TICKET_APPROVAL_INVALIDATION_FIELDS.has(k))) { + return; + } + const row = await this.automationRepo.findOne({ where: { ticketId } }); + if (!row?.approvedAt) { + return; + } + row.approvedAt = null; + row.approvedByUserId = null; + row.approvalBaselineTicketUpdatedAt = null; + await this.automationRepo.save(row); + await this.appendActivity( + ticketId, + TicketActionType.AUTOMATION_APPROVAL_INVALIDATED, + { + reason: 'ticket_updated', + fields: changedKeys.filter((k) => TICKET_APPROVAL_INVALIDATION_FIELDS.has(k)), + }, + req, + ); + await this.cancelRunningIfApprovalLost(ticketId, req, true); + } + + private async cancelRunningIfApprovalLost( + ticketId: string, + req: RequestWithUser | undefined, + didInvalidate: boolean, + ): Promise { + if (!didInvalidate) { + return; + } + const running = await this.runRepo.find({ + where: { ticketId, status: TicketAutomationRunStatus.RUNNING }, + }); + for (const r of running) { + await this.cancelRun(ticketId, r.id, req, TicketAutomationCancellationReason.APPROVAL_INVALIDATED); + } + } + + async approve(ticketId: string, req?: RequestWithUser): Promise { + const ticket = await this.assertTicketAccess(ticketId, req); + const info = getUserFromRequest(req || ({} as RequestWithUser)); + if (!info.userId) { + throw new ForbiddenException('Only interactive users can approve automation'); + } + const row = await this.ensureRow(ticketId); + if (!row.requiresApproval) { + throw new BadRequestException('This ticket does not require approval'); + } + row.approvedAt = new Date(); + row.approvedByUserId = info.userId; + row.approvalBaselineTicketUpdatedAt = ticket.updatedAt; + const saved = await this.automationRepo.save(row); + await this.appendActivity(ticketId, TicketActionType.AUTOMATION_APPROVED, { approvedByUserId: info.userId }, req); + return this.mapAutomation(saved); + } + + async listRuns(ticketId: string, req?: RequestWithUser): Promise { + await this.assertTicketAccess(ticketId, req); + const rows = await this.runRepo.find({ + where: { ticketId }, + order: { startedAt: 'DESC' }, + }); + return rows.map((r) => this.mapRun(r)); + } + + async getRun(ticketId: string, runId: string, req?: RequestWithUser): Promise { + await this.assertTicketAccess(ticketId, req); + const run = await this.runRepo.findOne({ where: { id: runId, ticketId } }); + if (!run) { + throw new NotFoundException('Run not found'); + } + const steps = await this.stepRepo.find({ + where: { runId }, + order: { stepIndex: 'ASC' }, + }); + const dto = this.mapRun(run); + dto.steps = steps.map((s) => this.mapStep(s)); + return dto; + } + + async cancelRun( + ticketId: string, + runId: string, + req?: RequestWithUser, + reason: TicketAutomationCancellationReason = TicketAutomationCancellationReason.USER_REQUEST, + ): Promise { + const ticket = await this.assertTicketAccess(ticketId, req); + const info = getUserFromRequest(req || ({} as RequestWithUser)); + const run = await this.runRepo.findOne({ where: { id: runId, ticketId } }); + if (!run) { + throw new NotFoundException('Run not found'); + } + if (run.status !== TicketAutomationRunStatus.PENDING && run.status !== TicketAutomationRunStatus.RUNNING) { + return this.mapRun(run); + } + if (reason === TicketAutomationCancellationReason.USER_REQUEST && !info.userId) { + throw new ForbiddenException('User context required to cancel'); + } + run.status = TicketAutomationRunStatus.CANCELLED; + run.finishedAt = new Date(); + run.cancellationReason = reason; + run.cancelRequestedAt = new Date(); + run.cancelledByUserId = info.userId ?? null; + await this.runRepo.save(run); + + const lease = await this.leaseRepo.findOne({ where: { ticketId } }); + if (lease && lease.status === TicketAutomationLeaseStatus.ACTIVE) { + lease.status = TicketAutomationLeaseStatus.RELEASED; + await this.leaseRepo.save(lease); + } + + if (run.ticketStatusBefore) { + ticket.status = run.ticketStatusBefore as TicketStatus; + await this.ticketRepo.save(ticket); + } + + await this.appendActivity(ticketId, TicketActionType.AUTOMATION_CANCELLED, { runId, reason }, req); + return this.mapRun(run); + } + + private mapRun(r: TicketAutomationRunEntity): TicketAutomationRunResponseDto { + return { + id: r.id, + ticketId: r.ticketId, + clientId: r.clientId, + agentId: r.agentId, + status: r.status, + phase: r.phase, + ticketStatusBefore: r.ticketStatusBefore, + branchName: r.branchName ?? null, + baseBranch: r.baseBranch ?? null, + baseSha: r.baseSha ?? null, + startedAt: r.startedAt, + finishedAt: r.finishedAt ?? null, + updatedAt: r.updatedAt, + iterationCount: r.iterationCount, + completionMarkerSeen: r.completionMarkerSeen, + verificationPassed: r.verificationPassed ?? null, + failureCode: r.failureCode ?? null, + summary: r.summary ?? null, + cancelRequestedAt: r.cancelRequestedAt ?? null, + cancelledByUserId: r.cancelledByUserId ?? null, + cancellationReason: r.cancellationReason ?? null, + }; + } + + private mapStep(s: TicketAutomationRunStepEntity): TicketAutomationRunStepResponseDto { + return { + id: s.id, + stepIndex: s.stepIndex, + phase: s.phase, + kind: s.kind, + payload: s.payload ?? null, + excerpt: s.excerpt ?? null, + createdAt: s.createdAt, + }; + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts new file mode 100644 index 00000000..31b79c2c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.spec.ts @@ -0,0 +1,151 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ClientUsersRepository, UsersRepository } from '@forepath/identity/backend'; +import { TicketActivityEntity } from '../entities/ticket-activity.entity'; +import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; +import { TicketCommentEntity } from '../entities/ticket-comment.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; +import { TicketEntity } from '../entities/ticket.entity'; +import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientsService } from './clients.service'; +import { TicketAutomationService } from './ticket-automation.service'; +import { TicketsService } from './tickets.service'; + +jest.mock('@forepath/identity/backend', () => { + const actual = jest.requireActual('@forepath/identity/backend'); + return { + ...actual, + ensureClientAccess: jest.fn().mockResolvedValue(undefined), + getUserFromRequest: jest.fn().mockReturnValue({ userId: 'user-1', userRole: 'admin', isApiKeyAuth: false }), + }; +}); + +describe('TicketsService', () => { + let service: TicketsService; + + const ticketId = '00000000-0000-4000-8000-000000000001'; + const clientId = '00000000-0000-4000-8000-0000000000c1'; + const agentA = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const agentB = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + + let ticket: TicketEntity; + + const commentRepo = {}; + const bodySessionRepo = {}; + + const activityRepo = { + save: jest.fn(), + create: jest.fn((x: unknown) => x), + }; + + const ticketRepo = { + findOne: jest.fn(), + count: jest.fn(), + manager: { + transaction: jest.fn(async (fn: (em: unknown) => Promise) => { + const em = { + getRepository: (entity: unknown) => { + if (entity === TicketEntity) { + return { save: jest.fn().mockResolvedValue(undefined) }; + } + if (entity === TicketActivityEntity) { + return activityRepo; + } + throw new Error(`Unexpected repository for ${String(entity)}`); + }, + }; + await fn(em); + }), + }, + }; + + const ticketAutomationService = { + invalidateAfterTicketFieldChanges: jest.fn().mockResolvedValue(undefined), + }; + + const ticketAutomationRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + }; + + const usersRepository = { + findById: jest.fn().mockResolvedValue(null), + }; + + const clientsService = {}; + + beforeEach(async () => { + jest.clearAllMocks(); + ticket = { + id: ticketId, + clientId, + parentId: null, + title: 'Example', + content: null, + priority: TicketPriority.MEDIUM, + status: TicketStatus.DRAFT, + createdByUserId: null, + preferredChatAgentId: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + } as unknown as TicketEntity; + ticketRepo.findOne.mockResolvedValue(ticket); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TicketsService, + { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, + { provide: getRepositoryToken(TicketCommentEntity), useValue: commentRepo }, + { provide: getRepositoryToken(TicketActivityEntity), useValue: activityRepo }, + { provide: getRepositoryToken(TicketBodyGenerationSessionEntity), useValue: bodySessionRepo }, + { provide: getRepositoryToken(TicketAutomationEntity), useValue: ticketAutomationRepo }, + { provide: ClientsRepository, useValue: {} }, + { provide: ClientUsersRepository, useValue: {} }, + { provide: UsersRepository, useValue: usersRepository }, + { provide: ClientsService, useValue: clientsService }, + { provide: TicketAutomationService, useValue: ticketAutomationService }, + ], + }).compile(); + + service = module.get(TicketsService); + }); + + describe('update preferredChatAgentId', () => { + it('persists and returns preferredChatAgentId', async () => { + const dto = await service.update(ticketId, { preferredChatAgentId: agentA }, undefined); + expect(dto.preferredChatAgentId).toBe(agentA); + expect(ticket.preferredChatAgentId).toBe(agentA); + expect(ticketRepo.manager.transaction).toHaveBeenCalled(); + expect(activityRepo.save).toHaveBeenCalled(); + }); + + it('clears preferredChatAgentId when set to null', async () => { + ticket.preferredChatAgentId = agentA; + const dto = await service.update(ticketId, { preferredChatAgentId: null }, undefined); + expect(dto.preferredChatAgentId).toBeNull(); + expect(ticket.preferredChatAgentId).toBeNull(); + }); + + it('skips transaction when value unchanged', async () => { + ticket.preferredChatAgentId = agentB; + const dto = await service.update(ticketId, { preferredChatAgentId: agentB }, undefined); + expect(dto.preferredChatAgentId).toBe(agentB); + expect(ticketRepo.manager.transaction).not.toHaveBeenCalled(); + }); + }); + + describe('automationEligible on ticket response', () => { + it('returns false when no ticket_automation row exists', async () => { + const dto = await service.update(ticketId, { preferredChatAgentId: agentB }, undefined); + expect(dto.automationEligible).toBe(false); + expect(ticketAutomationRepo.findOne).toHaveBeenCalled(); + }); + + it('returns eligible from ticket_automation when present', async () => { + ticketAutomationRepo.findOne.mockResolvedValueOnce({ eligible: true }); + const dto = await service.update(ticketId, { preferredChatAgentId: agentB }, undefined); + expect(dto.automationEligible).toBe(true); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts index e34749bc..933f6c26 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/tickets.service.ts @@ -13,7 +13,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ApplyGeneratedBodyDto, CreateTicketCommentDto, @@ -29,6 +29,7 @@ import { import { TicketActivityEntity } from '../entities/ticket-activity.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; +import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketEntity } from '../entities/ticket.entity'; import { TicketActionType, TicketActorType, TicketPriority, TicketStatus } from '../entities/ticket.enums'; import { ClientsRepository } from '../repositories/clients.repository'; @@ -39,6 +40,7 @@ import { type TicketPromptNode, } from '../utils/tickets-prototype-prompt.utils'; import { ClientsService } from './clients.service'; +import { TicketAutomationService, TICKET_APPROVAL_INVALIDATION_FIELDS } from './ticket-automation.service'; const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000; @@ -59,10 +61,13 @@ export class TicketsService { private readonly activityRepo: Repository, @InjectRepository(TicketBodyGenerationSessionEntity) private readonly bodySessionRepo: Repository, + @InjectRepository(TicketAutomationEntity) + private readonly ticketAutomationRepo: Repository, private readonly clientsRepository: ClientsRepository, private readonly clientUsersRepository: ClientUsersRepository, private readonly usersRepository: UsersRepository, private readonly clientsService: ClientsService, + private readonly ticketAutomationService: TicketAutomationService, ) {} private isApiKeyMode(): boolean { @@ -129,12 +134,14 @@ export class TicketsService { } qb.orderBy('t.updated_at', 'DESC'); const rows = await qb.getMany(); - return Promise.all(rows.map((row) => this.mapTicket(row))); + const eligMap = await this.loadAutomationEligibleByTicketIds(rows.map((r) => r.id)); + return Promise.all(rows.map((row) => this.mapTicket(row, eligMap.get(row.id) ?? false))); } async findOne(id: string, includeDescendants: boolean, req?: RequestWithUser): Promise { const ticket = await this.assertTicketReadable(id, req); - const dto = await this.mapTicket(ticket); + const eligMap = await this.loadAutomationEligibleByTicketIds([ticket.id]); + const dto = await this.mapTicket(ticket, eligMap.get(ticket.id) ?? false); if (includeDescendants) { dto.children = await this.loadDescendantTree(id, req); } @@ -148,6 +155,7 @@ export class TicketsService { where: { clientId: root.clientId }, order: { createdAt: 'ASC' }, }); + const eligMap = await this.loadAutomationEligibleByTicketIds(all.map((t) => t.id)); const byParent = new Map(); for (const t of all) { const p = t.parentId ?? null; @@ -160,7 +168,7 @@ export class TicketsService { const kids = byParent.get(parentId) ?? []; const out: TicketResponseDto[] = []; for (const k of kids) { - const d = await this.mapTicket(k); + const d = await this.mapTicket(k, eligMap.get(k.id) ?? false); d.children = await build(k.id); out.push(d); } @@ -255,6 +263,15 @@ export class TicketsService { ticket.clientId = dto.clientId; } + if (dto.preferredChatAgentId !== undefined) { + const newPref = dto.preferredChatAgentId; + const oldPref = ticket.preferredChatAgentId ?? null; + if (newPref !== oldPref) { + changes.preferredChatAgentId = { old: oldPref, new: newPref }; + ticket.preferredChatAgentId = newPref; + } + } + if (dto.parentId !== undefined) { const newParentId = dto.parentId; if (newParentId === ticket.id) { @@ -297,6 +314,11 @@ export class TicketsService { ); }); + const changedKeys = Object.keys(changes); + if (changedKeys.some((k) => TICKET_APPROVAL_INVALIDATION_FIELDS.has(k))) { + await this.ticketAutomationService.invalidateAfterTicketFieldChanges(id, changedKeys, req); + } + const refreshed = await this.loadTicketOrThrow(id); return this.mapTicket(refreshed); } @@ -560,12 +582,37 @@ export class TicketsService { return this.mapTicket(await this.loadTicketOrThrow(ticketId)); } - private async mapTicket(row: TicketEntity): Promise { + private async loadAutomationEligibleByTicketIds(ticketIds: string[]): Promise> { + const map = new Map(); + if (ticketIds.length === 0) { + return map; + } + const rows = await this.ticketAutomationRepo.find({ + where: { ticketId: In(ticketIds) }, + select: ['ticketId', 'eligible'], + }); + for (const r of rows) { + map.set(r.ticketId, r.eligible); + } + return map; + } + + private async mapTicket(row: TicketEntity, automationEligible?: boolean): Promise { let createdByEmail: string | null = null; if (row.createdByUserId) { const u = await this.usersRepository.findById(row.createdByUserId); createdByEmail = u?.email ?? null; } + let eligible = false; + if (automationEligible !== undefined) { + eligible = automationEligible; + } else { + const autoRow = await this.ticketAutomationRepo.findOne({ + where: { ticketId: row.id }, + select: ['eligible'], + }); + eligible = autoRow?.eligible ?? false; + } return { id: row.id, clientId: row.clientId, @@ -576,6 +623,8 @@ export class TicketsService { status: row.status, createdByUserId: row.createdByUserId, createdByEmail, + preferredChatAgentId: row.preferredChatAgentId ?? null, + automationEligible: eligible, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts new file mode 100644 index 00000000..03099a18 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts @@ -0,0 +1,5 @@ +/** + * Agents must emit this exact marker in assistant text before verification runs. + * Keep in sync with automation preamble in `tickets-prototype-prompt.utils.ts`. + */ +export const AGENSTRA_AUTOMATION_COMPLETE = 'AGENSTRA_AUTOMATION_COMPLETE'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts new file mode 100644 index 00000000..67bf60f3 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts @@ -0,0 +1,81 @@ +import { TicketAutomationFailureCode } from '../entities/ticket-automation.enums'; +import { TicketStatus } from '../entities/ticket.enums'; +import { AutomationFailureRoute, routeAutomationFailure } from './automation-failure-routing'; + +describe('routeAutomationFailure', () => { + const expectedByCode: Record = { + [TicketAutomationFailureCode.APPROVAL_MISSING]: { + runStatus: 'cancelled', + ticketStatus: 'unchanged', + requeue: false, + }, + [TicketAutomationFailureCode.LEASE_CONTENTION]: { + runStatus: 'none', + ticketStatus: 'unchanged', + requeue: true, + }, + [TicketAutomationFailureCode.VCS_DIRTY_WORKSPACE]: { + runStatus: 'failed', + ticketStatus: TicketStatus.TODO, + requeue: true, + }, + [TicketAutomationFailureCode.VCS_BRANCH_EXISTS]: { + runStatus: 'failed', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: false, + }, + [TicketAutomationFailureCode.AGENT_PROVIDER_ERROR]: { + runStatus: 'failed', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: true, + }, + [TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER]: { + runStatus: 'timed_out', + ticketStatus: TicketStatus.TODO, + requeue: true, + }, + [TicketAutomationFailureCode.MARKER_WITHOUT_VERIFY]: { + runStatus: 'failed', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: true, + }, + [TicketAutomationFailureCode.VERIFY_COMMAND_FAILED]: { + runStatus: 'failed', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: true, + }, + [TicketAutomationFailureCode.COMMIT_FAILED]: { + runStatus: 'failed', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: true, + }, + [TicketAutomationFailureCode.PUSH_FAILED]: { + runStatus: 'failed', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: true, + }, + [TicketAutomationFailureCode.BUDGET_EXCEEDED]: { + runStatus: 'timed_out', + ticketStatus: TicketStatus.TODO, + requeue: true, + }, + [TicketAutomationFailureCode.HUMAN_ESCALATION]: { + runStatus: 'escalated', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: false, + }, + [TicketAutomationFailureCode.ORCHESTRATOR_STALE]: { + runStatus: 'timed_out', + ticketStatus: TicketStatus.IN_PROGRESS, + requeue: true, + }, + }; + + it('maps every TicketAutomationFailureCode to the policy table', () => { + const codes = Object.values(TicketAutomationFailureCode); + expect(codes.length).toBeGreaterThan(0); + for (const code of codes) { + expect(routeAutomationFailure(code)).toEqual(expectedByCode[code]); + } + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts new file mode 100644 index 00000000..7240a3cc --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts @@ -0,0 +1,45 @@ +import { TicketAutomationFailureCode } from '../entities/ticket-automation.enums'; +import { TicketStatus } from '../entities/ticket.enums'; + +export type AutomationRunTerminalStatus = 'succeeded' | 'failed' | 'timed_out' | 'escalated' | 'cancelled' | 'none'; + +export interface AutomationFailureRoute { + runStatus: AutomationRunTerminalStatus; + ticketStatus: TicketStatus | 'unchanged'; + requeue: boolean; +} + +/** + * Deterministic routing from `failure_code` to run terminal status, ticket workspace state, and scheduler requeue. + * Adjust cells here when product policy changes; keep unit tests in sync. + */ +export function routeAutomationFailure(code: TicketAutomationFailureCode): AutomationFailureRoute { + switch (code) { + case TicketAutomationFailureCode.APPROVAL_MISSING: + return { runStatus: 'cancelled', ticketStatus: 'unchanged', requeue: false }; + case TicketAutomationFailureCode.LEASE_CONTENTION: + return { runStatus: 'none', ticketStatus: 'unchanged', requeue: true }; + case TicketAutomationFailureCode.VCS_DIRTY_WORKSPACE: + return { runStatus: 'failed', ticketStatus: TicketStatus.TODO, requeue: true }; + case TicketAutomationFailureCode.VCS_BRANCH_EXISTS: + return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: false }; + case TicketAutomationFailureCode.AGENT_PROVIDER_ERROR: + return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + case TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER: + return { runStatus: 'timed_out', ticketStatus: TicketStatus.TODO, requeue: true }; + case TicketAutomationFailureCode.MARKER_WITHOUT_VERIFY: + return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + case TicketAutomationFailureCode.VERIFY_COMMAND_FAILED: + return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + case TicketAutomationFailureCode.COMMIT_FAILED: + return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + case TicketAutomationFailureCode.PUSH_FAILED: + return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + case TicketAutomationFailureCode.BUDGET_EXCEEDED: + return { runStatus: 'timed_out', ticketStatus: TicketStatus.TODO, requeue: true }; + case TicketAutomationFailureCode.HUMAN_ESCALATION: + return { runStatus: 'escalated', ticketStatus: TicketStatus.IN_PROGRESS, requeue: false }; + case TicketAutomationFailureCode.ORCHESTRATOR_STALE: + return { runStatus: 'timed_out', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-usable-partial.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-usable-partial.spec.ts new file mode 100644 index 00000000..b00aa3b9 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-usable-partial.spec.ts @@ -0,0 +1,69 @@ +import { isUsablePartialPrototype } from './automation-usable-partial'; + +describe('isUsablePartialPrototype (Stricter A)', () => { + it('returns false when branch is missing', () => { + expect( + isUsablePartialPrototype({ + branchName: null, + hasNonZeroDiffAgainstMergeBase: true, + iterationCount: 2, + hasAgentStepWithNonEmptyExcerpt: true, + }), + ).toBe(false); + }); + + it('returns false when no diff against merge base', () => { + expect( + isUsablePartialPrototype({ + branchName: 'automation/abc', + hasNonZeroDiffAgainstMergeBase: false, + iterationCount: 2, + hasAgentStepWithNonEmptyExcerpt: true, + }), + ).toBe(false); + }); + + it('returns false when iteration count is zero', () => { + expect( + isUsablePartialPrototype({ + branchName: 'automation/abc', + hasNonZeroDiffAgainstMergeBase: true, + iterationCount: 0, + hasAgentStepWithNonEmptyExcerpt: true, + }), + ).toBe(false); + }); + + it('returns true when all stricter conditions hold', () => { + expect( + isUsablePartialPrototype({ + branchName: 'automation/abc', + hasNonZeroDiffAgainstMergeBase: true, + iterationCount: 1, + hasAgentStepWithNonEmptyExcerpt: true, + }), + ).toBe(true); + }); + + it('returns false when branch and diff exist but no agent excerpt was persisted', () => { + expect( + isUsablePartialPrototype({ + branchName: 'automation/abc', + hasNonZeroDiffAgainstMergeBase: true, + iterationCount: 2, + hasAgentStepWithNonEmptyExcerpt: false, + }), + ).toBe(false); + }); + + it('returns false when branchName is only whitespace', () => { + expect( + isUsablePartialPrototype({ + branchName: ' ', + hasNonZeroDiffAgainstMergeBase: true, + iterationCount: 1, + hasAgentStepWithNonEmptyExcerpt: true, + }), + ).toBe(false); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-usable-partial.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-usable-partial.ts new file mode 100644 index 00000000..5e572e79 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/automation-usable-partial.ts @@ -0,0 +1,29 @@ +/** + * **Chosen rule (Stricter A):** a run counts as having a usable partial prototype only when all hold: + * - `branchName` is set + * - VCS reports a non-zero change vs merge-base between `baseBranch` and branch tip + * - `iterationCount >= 1` + * - At least one persisted agent-loop step has a non-empty `excerpt` + * + * **Looser B** (branch-only) is intentionally not used so failed runs do not flood `in_progress` + * when the branch exists but no work was produced. + */ +export interface UsablePartialPrototypeInput { + branchName: string | null | undefined; + hasNonZeroDiffAgainstMergeBase: boolean; + iterationCount: number; + hasAgentStepWithNonEmptyExcerpt: boolean; +} + +export function isUsablePartialPrototype(input: UsablePartialPrototypeInput): boolean { + if (!input.branchName?.trim()) { + return false; + } + if (!input.hasNonZeroDiffAgainstMergeBase) { + return false; + } + if (input.iterationCount < 1) { + return false; + } + return input.hasAgentStepWithNonEmptyExcerpt; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/autonomous-commit-message.utils.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/autonomous-commit-message.utils.spec.ts new file mode 100644 index 00000000..ab389c33 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/autonomous-commit-message.utils.spec.ts @@ -0,0 +1,61 @@ +import { + buildAutonomousCommitMessagePrompt, + buildFallbackAutonomousCommitMessage, + isPlausibleConventionalSubject, + sanitizeConventionalCommitSubject, +} from './autonomous-commit-message.utils'; + +describe('autonomous-commit-message.utils', () => { + describe('buildAutonomousCommitMessagePrompt', () => { + it('includes ticket id, title, and branch', () => { + const p = buildAutonomousCommitMessagePrompt( + { id: '00000000-0000-4000-8000-000000000001', title: 'Add login' }, + 'automation/abcd1234', + ); + expect(p).toContain('00000000-0000-4000-8000-000000000001'); + expect(p).toContain('Add login'); + expect(p).toContain('automation/abcd1234'); + expect(p).toContain('Conventional Commits'); + }); + }); + + describe('sanitizeConventionalCommitSubject', () => { + it('accepts a valid conventional subject', () => { + expect(sanitizeConventionalCommitSubject('feat(automation): add user prefs')).toBe( + 'feat(automation): add user prefs', + ); + }); + + it('takes the first line only', () => { + expect(sanitizeConventionalCommitSubject('feat: foo\nextra')).toBe('feat: foo'); + }); + + it('rejects empty or non-conventional text', () => { + expect(sanitizeConventionalCommitSubject('')).toBeNull(); + expect(sanitizeConventionalCommitSubject('just some text')).toBeNull(); + expect(sanitizeConventionalCommitSubject('```\nfeat: x')).toBeNull(); + }); + + it('strips simple surrounding quotes', () => { + expect(sanitizeConventionalCommitSubject('"fix: handle edge case"')).toBe('fix: handle edge case'); + }); + }); + + describe('isPlausibleConventionalSubject', () => { + it('matches common types', () => { + expect(isPlausibleConventionalSubject('feat(scope): thing')).toBe(true); + expect(isPlausibleConventionalSubject('fix: thing')).toBe(true); + expect(isPlausibleConventionalSubject('chore: bump')).toBe(true); + }); + }); + + describe('buildFallbackAutonomousCommitMessage', () => { + it('prefixes feat(automation) with truncated title', () => { + expect(buildFallbackAutonomousCommitMessage({ title: 'Hello' })).toBe('feat(automation): Hello'); + }); + + it('handles empty title', () => { + expect(buildFallbackAutonomousCommitMessage({ title: '' })).toBe('feat(automation): prototype run'); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/autonomous-commit-message.utils.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/autonomous-commit-message.utils.ts new file mode 100644 index 00000000..7ebf2ef2 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/autonomous-commit-message.utils.ts @@ -0,0 +1,57 @@ +import type { TicketEntity } from '../entities/ticket.entity'; + +/** + * Prompt for a one-line Conventional Commits subject via the same remote `chat` sync path as other + * autonomous ticket background turns (ephemeral session, isolated resume suffix). + */ +export function buildAutonomousCommitMessagePrompt( + ticket: Pick, + branchName: string, +): string { + const title = (ticket.title ?? '').replace(/\s+/g, ' ').trim(); + return [ + 'You help write git commit messages.', + 'Reply with exactly ONE line only: a Conventional Commits subject (format: type(scope): description, or type: description).', + 'Types: feat, fix, chore, docs, style, refactor, test, or perf. Use scope "automation" when unsure.', + 'Max 120 characters. No quotes, markdown, code fences, or explanation.', + '', + `Ticket ID: ${ticket.id}`, + `Ticket title: ${title}`, + `Branch: ${branchName || '(unknown)'}`, + '', + 'Summarize the implemented work in that single subject line.', + ].join('\n'); +} + +/** Loose Conventional Commits subject check (type, optional scope, colon, non-empty description). */ +export function isPlausibleConventionalSubject(s: string): boolean { + return /^(feat|fix|chore|docs|style|refactor|test|perf|build|ci)(\([^)]*\))?!?:\s+.+$/i.test(s.trim()); +} + +/** + * Takes raw model output; returns a single-line subject or null if unusable. + */ +export function sanitizeConventionalCommitSubject(raw: string): string | null { + const lines = raw + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const first = lines[0] ?? ''; + const s = first.replace(/^[`"'“”]+|[`"'“”]+$/g, '').trim(); + if (s.startsWith('```')) { + return null; + } + if (s.length < 5 || s.length > 200) { + return null; + } + if (!isPlausibleConventionalSubject(s)) { + return null; + } + return s; +} + +export function buildFallbackAutonomousCommitMessage(ticket: Pick): string { + const t = (ticket.title ?? '').replace(/\s+/g, ' ').trim(); + const truncated = t.length > 100 ? `${t.slice(0, 97)}...` : t; + return `feat(automation): ${truncated || 'prototype run'}`; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts index 21ce3c83..c9ab58b7 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts @@ -1,5 +1,6 @@ import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; -import { buildPrototypePrompt } from './tickets-prototype-prompt.utils'; +import { AGENSTRA_AUTOMATION_COMPLETE } from './automation-completion.constants'; +import { buildAutonomousTicketRunPreamble, buildPrototypePrompt } from './tickets-prototype-prompt.utils'; describe('tickets-prototype-prompt.utils', () => { it('includes nested children in prompt text', () => { @@ -25,4 +26,9 @@ describe('tickets-prototype-prompt.utils', () => { expect(out).toContain('[c1]'); expect(out).toContain('Child'); }); + + it('includes automation completion marker in autonomous preamble', () => { + const preamble = buildAutonomousTicketRunPreamble(); + expect(preamble).toContain(AGENSTRA_AUTOMATION_COMPLETE); + }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts index 8198ae00..911e3434 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts @@ -1,4 +1,5 @@ import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; +import { AGENSTRA_AUTOMATION_COMPLETE } from './automation-completion.constants'; export interface TicketPromptNode { id: string; @@ -27,3 +28,15 @@ export function buildPrototypePrompt(root: TicketPromptNode, depth = 0): string export function buildPrototypePromptPreamble(): string { return `You are helping implement a scoped piece of work. The prompt may include parent tickets for broader scope, then the selected ticket with every nested subtask (title, status, priority, content). Use this hierarchy to produce a concrete prototype or implementation plan as requested by the user.\n\n`; } + +/** + * Preamble for autonomous ticket runs: instructs the agent to emit the completion marker when done. + */ +export function buildAutonomousTicketRunPreamble(): string { + return ( + buildPrototypePromptPreamble() + + `When the scoped prototype work is complete and ready for verification, you MUST include the exact line ` + + `${AGENSTRA_AUTOMATION_COMPLETE} in your reply (on its own line is best). ` + + `Do not claim completion without that marker.\n\n` + ); +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/verifier-profile.validation.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/verifier-profile.validation.spec.ts new file mode 100644 index 00000000..9d162c62 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/verifier-profile.validation.spec.ts @@ -0,0 +1,83 @@ +import { BadRequestException } from '@nestjs/common'; +import { parseAndValidateVerifierProfile } from './verifier-profile.validation'; + +describe('parseAndValidateVerifierProfile', () => { + const originalAllowlist = process.env.AUTOMATION_VERIFY_CMD_PREFIX_ALLOWLIST; + + afterEach(() => { + if (originalAllowlist === undefined) { + delete process.env.AUTOMATION_VERIFY_CMD_PREFIX_ALLOWLIST; + } else { + process.env.AUTOMATION_VERIFY_CMD_PREFIX_ALLOWLIST = originalAllowlist; + } + }); + + it('returns empty commands for null', () => { + expect(parseAndValidateVerifierProfile(null)).toEqual({ commands: [] }); + }); + + it('accepts valid commands', () => { + expect( + parseAndValidateVerifierProfile({ + commands: [{ cmd: 'npm test' }, { cmd: 'npm run lint', cwd: 'repository' }], + }), + ).toEqual({ + commands: [{ cmd: 'npm test' }, { cmd: 'npm run lint', cwd: 'repository' }], + }); + }); + + it('rejects newlines in cmd', () => { + expect(() => + parseAndValidateVerifierProfile({ + commands: [{ cmd: 'evil\nrm' }], + }), + ).toThrow(BadRequestException); + }); + + it('rejects verifierProfile that is not a plain object', () => { + expect(() => parseAndValidateVerifierProfile([])).toThrow(BadRequestException); + expect(() => parseAndValidateVerifierProfile('x')).toThrow(BadRequestException); + }); + + it('rejects when commands is not an array', () => { + expect(() => parseAndValidateVerifierProfile({ commands: 'npm test' } as unknown as object)).toThrow( + BadRequestException, + ); + }); + + it('rejects more than 32 commands', () => { + const commands = Array.from({ length: 33 }, (_, i) => ({ cmd: `echo ${i}` })); + expect(() => parseAndValidateVerifierProfile({ commands })).toThrow(BadRequestException); + }); + + it('rejects cmd longer than 2048 characters', () => { + const cmd = `echo ${'x'.repeat(2048)}`; + expect(() => parseAndValidateVerifierProfile({ commands: [{ cmd }] })).toThrow(BadRequestException); + }); + + it('rejects newlines in cwd', () => { + expect(() => + parseAndValidateVerifierProfile({ + commands: [{ cmd: 'npm test', cwd: 'repo\n../../' }], + }), + ).toThrow(BadRequestException); + }); + + it('rejects non-object command entries', () => { + expect(() => + parseAndValidateVerifierProfile({ + commands: ['npm test' as unknown as object], + }), + ).toThrow(BadRequestException); + }); + + it('enforces prefix allowlist when AUTOMATION_VERIFY_CMD_PREFIX_ALLOWLIST is set', () => { + process.env.AUTOMATION_VERIFY_CMD_PREFIX_ALLOWLIST = 'npm,'; + + expect(parseAndValidateVerifierProfile({ commands: [{ cmd: 'npm test' }] })).toEqual({ + commands: [{ cmd: 'npm test' }], + }); + + expect(() => parseAndValidateVerifierProfile({ commands: [{ cmd: 'yarn test' }] })).toThrow(BadRequestException); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/verifier-profile.validation.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/verifier-profile.validation.ts new file mode 100644 index 00000000..25787470 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/verifier-profile.validation.ts @@ -0,0 +1,69 @@ +import { BadRequestException } from '@nestjs/common'; +import type { TicketVerifierProfileJson } from '../entities/ticket-automation.entity'; + +const MAX_CMD_LENGTH = 2048; +const MAX_COMMANDS = 32; +const ALLOWED_PREFIXES = (): string[] => { + const raw = process.env.AUTOMATION_VERIFY_CMD_PREFIX_ALLOWLIST; + if (!raw?.trim()) { + return []; + } + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +}; + +function assertNoShellInjection(cmd: string): void { + if (cmd.includes('\n') || cmd.includes('\r')) { + throw new BadRequestException('Verifier command must not contain newlines'); + } + if (cmd.length > MAX_CMD_LENGTH) { + throw new BadRequestException(`Verifier command exceeds maximum length (${MAX_CMD_LENGTH})`); + } +} + +/** + * Allowlist verifier commands: length bounds, no newlines, optional comma-separated prefix allowlist via env. + */ +export function parseAndValidateVerifierProfile(raw: unknown): TicketVerifierProfileJson { + if (raw === null || raw === undefined) { + return { commands: [] }; + } + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new BadRequestException('verifierProfile must be an object'); + } + const obj = raw as { commands?: unknown }; + if (!Array.isArray(obj.commands)) { + throw new BadRequestException('verifierProfile.commands must be an array'); + } + if (obj.commands.length > MAX_COMMANDS) { + throw new BadRequestException(`verifierProfile.commands must have at most ${MAX_COMMANDS} entries`); + } + const prefixes = ALLOWED_PREFIXES(); + const commands: Array<{ cmd: string; cwd?: string }> = []; + for (const entry of obj.commands) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new BadRequestException('Each verifier command must be an object'); + } + const e = entry as { cmd?: unknown; cwd?: unknown }; + if (typeof e.cmd !== 'string' || !e.cmd.trim()) { + throw new BadRequestException('Each verifier command must include a non-empty cmd string'); + } + const cmd = e.cmd.trim(); + assertNoShellInjection(cmd); + if (prefixes.length > 0 && !prefixes.some((p) => cmd.startsWith(p))) { + throw new BadRequestException('Verifier command does not match configured prefix allowlist'); + } + let cwd: string | undefined; + if (e.cwd !== undefined) { + if (typeof e.cwd !== 'string') { + throw new BadRequestException('verifier cwd must be a string when provided'); + } + cwd = e.cwd.trim(); + assertNoShellInjection(cwd); + } + commands.push(cwd ? { cmd, cwd } : { cmd }); + } + return { commands }; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/README.md b/libs/domains/framework/backend/feature-agent-manager/README.md index 9e822001..1a27173f 100644 --- a/libs/domains/framework/backend/feature-agent-manager/README.md +++ b/libs/domains/framework/backend/feature-agent-manager/README.md @@ -82,7 +82,8 @@ All diagrams are available in the [`docs/`](./docs/) directory: - **[Overview Diagram](./docs/overview.mmd)** - High-level flowchart showing when to use HTTP vs WebSocket protocols and their respective use cases - **[HTTP Sequence Diagram](./docs/sequence-http.mmd)** - Detailed sequence diagram for all HTTP CRUD operations (create, list, get, update, delete) - **[HTTP Environment Variables Sequence Diagram](./docs/sequence-http-environment.mmd)** - Detailed sequence diagram for environment variable operations (create, list, get, update, delete) -- **[HTTP VCS Sequence Diagram](./docs/sequence-http-vcs.mmd)** - Detailed sequence diagram for all VCS (Git) operations (status, branches, diff, stage, commit, push, pull, etc.) +- **[HTTP VCS Sequence Diagram](./docs/sequence-http-vcs.mmd)** - Detailed sequence diagram for all VCS (Git) operations (status, branches, diff, stage, commit, push, pull, workspace prepare-clean, etc.) +- **[HTTP Automation Verification Sequence Diagram](./docs/sequence-http-automation-verify.mmd)** - Sequence diagram for `POST .../automation/verify-commands` (bounded shell in the agent container) - **[WebSocket Auth & Logs Diagram](./docs/sequence-ws-auth-logs.mmd)** - Sequence diagram for WebSocket connection, authentication flow, and container log streaming - **[WebSocket Chat Diagram](./docs/sequence-ws-chat.mmd)** - Sequence diagram for WebSocket chat message flow and disconnection handling - **[Lifecycle Diagram](./docs/lifecycle.mmd)** - End-to-end sequence diagram showing the complete agent lifecycle from creation through deletion diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/agent-events.md b/libs/domains/framework/backend/feature-agent-manager/docs/agent-events.md deleted file mode 100644 index 3b202043..00000000 --- a/libs/domains/framework/backend/feature-agent-manager/docs/agent-events.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent events (streaming, tools, questions) - -The agent-manager websocket gateway emits **two** parallel streams for chat: - -- **`chatMessage`**: legacy transcript events (user message + final assistant message), used for history restore and backwards compatibility. -- **`chatEvent`**: structured event stream for **streaming deltas**, **tool call lifecycles**, and **questions back to the user**. - -This design allows existing clients/providers to keep working while enabling OpenCode-style UX where tools and questions are rendered explicitly. - -## `chatEvent` envelope - -Each `chatEvent` event carries a `SuccessResponse` payload (see `spec/asyncapi.yaml`). - -Fields: - -- **`eventId`**: UUID for the event. -- **`agentId`**: agent UUID the event belongs to. -- **`correlationId`**: groups events that belong to the same user request. -- **`sequence`**: monotonic integer scoped to `correlationId` (enables deterministic ordering). -- **`timestamp`**: ISO timestamp for the event. -- **`kind`**: one of: - - `userMessage` - - `thinking` (placeholder after the user message until deltas/tools arrive) - - `assistantDelta` - - `assistantMessage` - - `toolCall` - - `toolResult` - - `question` - - `status` - - `error` -- **`payload`**: kind-specific payload (JSON object). - -## Persistence - -Transcript messages are persisted to `agent_messages` as before. - -Structured events are optionally persisted to **`agent_message_events`**: - -- Stored: `userMessage`, `thinking`, `assistantMessage`, `toolCall`, `toolResult`, `question`, `status`, `error` -- Skipped by default: `assistantDelta` (high volume) - -## Provider support - -Providers expose capabilities via `getCapabilities()`: - -- Providers like `cursor` and `opencode` can support streaming and structured events. -- Providers like `openclaw` intentionally do **not** support chat and should keep capabilities disabled. - -## Mermaid - -See `docs/agent-events.mmd` for a per-request event lifecycle diagram. diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd index d331d60f..bdc607d2 100644 --- a/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd +++ b/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd @@ -32,6 +32,13 @@ flowchart TB ENV_USE --> ENV6["🗑️ Delete All Variables
DELETE /api/agents/:id/environment
Returns: Deleted Count"] end + subgraph AUTO_HTTP["🧪 HTTP - Automation helpers (/api/agents/:id)"] + direction TB + AUTO_HTTP_USE["Use Case: Clean tree & verifier runs
Ticket automation support"] + AUTO_HTTP_USE --> AUTO1["🧹 Prepare clean workspace
POST .../vcs/workspace/prepare-clean
Returns: 204"] + AUTO_HTTP_USE --> AUTO2["✅ Run verifier commands
POST .../automation/verify-commands
Returns: exit codes + output"] + end + subgraph WS["🔶 WebSocket Gateway (namespace: /agents)"] direction TB WS_USE["Use Case: Real-time Communication
Bidirectional Messages
Persistent Connection"] @@ -42,20 +49,24 @@ flowchart TB Start([Agent Manager Feature]) --> HTTP Start --> FILES Start --> ENV + Start --> AUTO_HTTP Start --> WS HTTP -.->|"Stateless
Request-Response"| HTTP_USE FILES -.->|"Stateless
Request-Response"| FILES_USE ENV -.->|"Stateless
Request-Response"| ENV_USE + AUTO_HTTP -.->|"Stateless
Request-Response"| AUTO_HTTP_USE WS -.->|"Stateful
Persistent Connection"| WS_USE style HTTP fill:#e6f0ff,stroke:#4a90e2,stroke-width:3px style FILES fill:#e6ffe6,stroke:#4a9e2,stroke-width:3px style ENV fill:#ffe6f0,stroke:#e24a9e,stroke-width:3px + style AUTO_HTTP fill:#eef6ff,stroke:#5c6bc0,stroke-width:3px style WS fill:#fff4e6,stroke:#ff8c42,stroke-width:3px style HTTP_USE fill:#d6e9ff,stroke:#4a90e2 style FILES_USE fill:#d6ffd6,stroke:#4a9e2 style ENV_USE fill:#ffd6f0,stroke:#e24a9e + style AUTO_HTTP_USE fill:#e3eafc,stroke:#5c6bc0 style WS_USE fill:#ffe6cc,stroke:#ff8c42 style HTTP1 fill:#f0f7ff,stroke:#4a90e2 style HTTP2 fill:#f0f7ff,stroke:#4a90e2 @@ -74,5 +85,7 @@ flowchart TB style ENV4 fill:#fff0f7,stroke:#e24a9e style ENV5 fill:#fff0f7,stroke:#e24a9e style ENV6 fill:#fff0f7,stroke:#e24a9e + style AUTO1 fill:#f5f8ff,stroke:#5c6bc0 + style AUTO2 fill:#f5f8ff,stroke:#5c6bc0 style WS1 fill:#fff9f0,stroke:#ff8c42 style WS2 fill:#fff9f0,stroke:#ff8c42 diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-automation-verify.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-automation-verify.mmd new file mode 100644 index 00000000..91101823 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-automation-verify.mmd @@ -0,0 +1,27 @@ +sequenceDiagram + participant Client as HTTP Client + participant API as AgentsVerificationController
(/api/agents/:id/automation) + participant VerifySvc as AgentsVerificationService + participant AgentService as AgentsService + participant Docker as DockerService + participant Container as Agent Container
(/app) + + Note over Client,Container: HTTP API - Ticket automation verification (bounded shell) + + rect rgb(245, 245, 255) + Note over Client,Container: Run verifier commands + Client->>API: POST /api/agents/{id}/automation/verify-commands
{commands[], timeoutMs?} + API->>VerifySvc: runVerifierCommands(agentId, dto) + VerifySvc->>AgentService: findOne(agentId) + AgentService-->>VerifySvc: agent entity + loop For each command (fail-fast) + VerifySvc->>VerifySvc: assertSafeCommand(cmd) + VerifySvc->>Docker: sendCommandToContainer (wrapped sh script) + Docker->>Container: Execute verifier script + Container-->>Docker: stdout + __EXIT marker + Docker-->>VerifySvc: raw output + VerifySvc->>VerifySvc: parse exit code and truncate output + end + VerifySvc-->>API: RunVerifierCommandsResponseDto + API-->>Client: 200 OK
{results[]} + end diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-vcs.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-vcs.mmd index 2cf6ff20..07881ad5 100644 --- a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-vcs.mmd +++ b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-vcs.mmd @@ -169,3 +169,17 @@ sequenceDiagram VcsService-->>API: void API-->>Client: 204 No Content end + + rect rgb(235, 250, 235) + Note over Client,Container: Prepare clean workspace (orchestration) + Client->>API: POST /api/agents/{id}/vcs/workspace/prepare-clean
{baseBranch} + API->>VcsService: prepareCleanWorkspace(agentId, baseBranch) + VcsService->>AgentService: findOne(agentId) + AgentService-->>VcsService: agent entity + VcsService->>Docker: sendCommandToContainer (git fetch, checkout, reset --hard, clean) + Docker->>Container: Sequential git commands + Container-->>Docker: success + Docker-->>VcsService: success + VcsService-->>API: void + API-->>Client: 204 No Content + end diff --git a/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml index e0c22412..3ca306e7 100644 --- a/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml @@ -2,12 +2,18 @@ asyncapi: 3.0.0 info: title: Agent Gateway version: 1.0.0 - description: WebSocket events for the agents chat gateway (Socket.IO namespace "agents") + description: | + WebSocket events for the agents chat gateway. Socket.IO namespace from env `WEBSOCKET_NAMESPACE` (default `agents`); + listen port from env `WEBSOCKET_PORT` (default 8080). CORS for the gateway from `WEBSOCKET_CORS_ORIGIN` (default `*`). + Connection state recovery uses `SOCKET_MAX_DISCONNECTION_DURATION` ms (default 120000) per NestJS `connectionStateRecovery`. + The REST API runs on a separate HTTP port (`PORT`, default 3000); see the Agent Manager OpenAPI spec. servers: local: host: localhost:8080 protocol: ws - description: Socket.IO server (namespace /agents) + description: | + Socket.IO server; path `/socket.io` with namespace `/agents` (e.g. `ws://localhost:8080/agents` depending on client). + Port defaults to 8080 unless `WEBSOCKET_PORT` is set. channels: agents/login: address: agents/login @@ -32,7 +38,12 @@ channels: messages: chatCommand: $ref: '#/components/messages/Chat' - description: Client sends a chat message (requires prior login) + description: | + Client sends a chat message (requires prior login). Payload may include `responseMode` "single" | "stream" | "sync", + `ephemeral` (skip `agent_messages` persistence for the turn), `continue`, `resumeSessionSuffix`, `correlationId`, `model`. + When `ephemeral` is true, `chatMessage`, `chatEvent`, and `messageFilterResult` for that turn are emitted only to the + requesting socket (not broadcast to other clients on the same agent). Otherwise those events are broadcast to every + socket authenticated to the agent. The gateway forwards options to `sendMessage` as implemented in code. agents/enhanceChat: address: agents/enhanceChat messages: @@ -66,21 +77,28 @@ channels: messages: chatBroadcast: $ref: '#/components/messages/ChatMessage' - description: Server broadcasts chat messages to all connected clients. Also used for chat history restoration after successful login (messages are emitted in chronological order to the authenticated client only). + description: | + Emits standardized chat line envelopes (`success` + `data` + `timestamp`). Normally broadcast to all clients + authenticated to the same agent; for a `chat` request with `ephemeral: true`, user/agent lines for that turn are + unicast to the requesting socket only. After successful login, chat history is replayed as `chatMessage` events in + chronological order to the authenticating client only. agents/chatEvent: address: agents/chatEvent messages: chatEventBroadcast: $ref: '#/components/messages/ChatEvent' description: > - Server broadcasts structured chat events (streaming deltas, tool calls/results, questions, and final assistant messages) - to all connected clients authenticated to the same agent. This is additive and does not replace chatMessage. + Structured chat events (streaming deltas, tool calls/results, questions, final assistant messages). Normally broadcast + to all clients authenticated to the same agent; for an ephemeral chat turn, events for that turn are emitted only to + the requesting socket. Additive; does not replace chatMessage. agents/messageFilterResult: address: agents/messageFilterResult messages: messageFilterResultEvent: $ref: '#/components/messages/MessageFilterResult' - description: Server broadcasts filter result messages to all connected clients authenticated to the same agent. Sent after applying filters to incoming or outgoing messages. + description: > + Filter outcome for incoming or outgoing messages. Follows the same broadcast vs ephemeral unicast rules as chatMessage: + when the related `chat` request had `ephemeral: true`, results for that turn go only to the requesting socket. agents/error: address: agents/error messages: @@ -387,8 +405,21 @@ components: description: Optional client-supplied correlation id for streaming/tool events; if omitted the server will generate one. responseMode: type: string - enum: [single, stream] - description: Optional response mode preference. When stream, the server may emit chatEvent deltas before the final chatMessage. + enum: [single, stream, sync] + description: > + Optional response mode. "stream" may emit chatEvent deltas before the final chatMessage; "sync" uses a + non-streaming path suitable for background automation; "single" is the default when omitted. + ephemeral: + type: boolean + description: > + When true, skips persisting user and assistant messages for this turn and emits chatMessage, chatEvent, and + messageFilterResult for this turn only to the requesting socket (no fan-out to other viewers on the agent). + continue: + type: boolean + description: Optional continuation flag forwarded to the agent sendMessage flow. + resumeSessionSuffix: + type: string + description: Optional suffix to isolate or resume session routing for this chat turn. EnhanceChat: name: EnhanceChat title: Prompt enhancement command diff --git a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml index 2bcc4a1d..4f23a349 100644 --- a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml @@ -9,7 +9,9 @@ info: url: https://forepath.io servers: - url: http://localhost:3000/api - description: Local development + description: | + HTTP API (global prefix `/api`). Listen port from env `PORT` (default 3000). + WebSocket gateway for agents is separate; see the Agent Manager AsyncAPI (port `WEBSOCKET_PORT`, default 8080). security: - bearerAuth: [] paths: @@ -809,6 +811,34 @@ paths: description: Changes fetched successfully '404': description: Agent not found + /agents/{agentId}/vcs/workspace/prepare-clean: + post: + summary: Prepare clean workspace + operationId: prepareCleanWorkspace + description: | + Runs git fetch, checks out the requested base branch, hard-resets the working tree to match + origin for that branch, and removes untracked files. Used by autonomous ticket orchestration + before creating an automation branch. + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PrepareCleanWorkspaceDto' + responses: + '204': + description: Workspace prepared successfully + '400': + description: Invalid base branch or git operation failed + '404': + description: Agent or container not found /agents/{agentId}/vcs/rebase: post: summary: Rebase current branch @@ -857,6 +887,37 @@ paths: description: Invalid conflict resolution strategy '404': description: Agent or file not found + /agents/{agentId}/automation/verify-commands: + post: + summary: Run verifier shell commands in the agent container + operationId: runVerifierCommands + description: | + Executes a bounded list of shell commands sequentially in the agent container (fail-fast). + Commands are validated for safe characters; captured output per command is capped. + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RunVerifierCommandsDto' + responses: + '200': + description: Per-command exit codes and output + content: + application/json: + schema: + $ref: '#/components/schemas/RunVerifierCommandsResponseDto' + '400': + description: Invalid command or execution failed + '404': + description: Agent or container not found /agents/{agentId}/deployments/configuration: get: summary: Get deployment configuration for an agent @@ -1586,6 +1647,56 @@ components: type: string enum: [yours, mine, both] description: Merge strategy - 'yours' (accept incoming), 'mine' (accept current), 'both' (keep both) + PrepareCleanWorkspaceDto: + type: object + required: [baseBranch] + properties: + baseBranch: + type: string + pattern: '^[a-zA-Z0-9/_-]+$' + description: Branch to check out and reset to (must match upstream after fetch) + VerifierShellCommandDto: + type: object + required: [cmd] + properties: + cmd: + type: string + description: Shell command (validated; no shell metacharacters) + cwd: + type: string + description: Optional working directory inside the repository + RunVerifierCommandsDto: + type: object + required: [commands] + properties: + commands: + type: array + maxItems: 32 + items: + $ref: '#/components/schemas/VerifierShellCommandDto' + timeoutMs: + type: integer + minimum: 1000 + maximum: 3600000 + description: Optional timeout per command batch (default 120000) + VerifierCommandResultDto: + type: object + required: [cmd, exitCode, output] + properties: + cmd: + type: string + exitCode: + type: integer + output: + type: string + RunVerifierCommandsResponseDto: + type: object + required: [results] + properties: + results: + type: array + items: + $ref: '#/components/schemas/VerifierCommandResultDto' CreateDeploymentConfigurationDto: type: object required: [providerType, repositoryId, providerToken] diff --git a/libs/domains/framework/backend/feature-agent-manager/src/index.ts b/libs/domains/framework/backend/feature-agent-manager/src/index.ts index 0b3e7ace..780b9489 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/index.ts @@ -16,8 +16,10 @@ export * from './lib/dto/git-branch.dto'; export * from './lib/dto/git-diff.dto'; export * from './lib/dto/git-status.dto'; export * from './lib/dto/move-file.dto'; +export * from './lib/dto/prepare-clean-workspace.dto'; export * from './lib/dto/push-options.dto'; export * from './lib/dto/rebase.dto'; +export * from './lib/dto/run-verifier-commands.dto'; export * from './lib/dto/resolve-conflict.dto'; export * from './lib/dto/stage-files.dto'; export * from './lib/dto/unstage-files.dto'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.spec.ts index 0a0d9514..a04f2f38 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CreateBranchDto } from '../dto/create-branch.dto'; +import { PrepareCleanWorkspaceDto } from '../dto/prepare-clean-workspace.dto'; import { GitBranchDto } from '../dto/git-branch.dto'; import { GitDiffDto } from '../dto/git-diff.dto'; import { GitStatusDto } from '../dto/git-status.dto'; @@ -58,6 +59,7 @@ describe('AgentsVcsController', () => { push: jest.fn(), pull: jest.fn(), fetch: jest.fn(), + prepareCleanWorkspace: jest.fn(), rebase: jest.fn(), createBranch: jest.fn(), switchBranch: jest.fn(), @@ -193,6 +195,17 @@ describe('AgentsVcsController', () => { }); }); + describe('prepareCleanWorkspace', () => { + it('should delegate to service with base branch', async () => { + const body: PrepareCleanWorkspaceDto = { baseBranch: 'main' }; + service.prepareCleanWorkspace.mockResolvedValue(undefined); + + await controller.prepareCleanWorkspace(mockAgentId, body); + + expect(service.prepareCleanWorkspace).toHaveBeenCalledWith(mockAgentId, 'main'); + }); + }); + describe('rebase', () => { it('should rebase branch', async () => { const dto = { branch: 'main' }; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.ts index 7bf5f2bc..2ecf5e3c 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-vcs.controller.ts @@ -4,6 +4,7 @@ import { CreateBranchDto } from '../dto/create-branch.dto'; import { GitBranchDto } from '../dto/git-branch.dto'; import { GitDiffDto } from '../dto/git-diff.dto'; import { GitStatusDto } from '../dto/git-status.dto'; +import { PrepareCleanWorkspaceDto } from '../dto/prepare-clean-workspace.dto'; import { PushOptionsDto } from '../dto/push-options.dto'; import { RebaseDto } from '../dto/rebase.dto'; import { ResolveConflictDto } from '../dto/resolve-conflict.dto'; @@ -131,6 +132,18 @@ export class AgentsVcsController { await this.agentsVcsService.fetch(agentId); } + /** + * Reset working tree to match upstream default branch (fetch, reset --hard, clean). + */ + @Post('workspace/prepare-clean') + @HttpCode(HttpStatus.NO_CONTENT) + async prepareCleanWorkspace( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() body: PrepareCleanWorkspaceDto, + ): Promise { + await this.agentsVcsService.prepareCleanWorkspace(agentId, body.baseBranch); + } + /** * Rebase current branch onto another branch. * @param agentId - The UUID of the agent diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-verification.controller.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-verification.controller.spec.ts new file mode 100644 index 00000000..fdfe84e3 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-verification.controller.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RunVerifierCommandsDto, RunVerifierCommandsResponseDto } from '../dto/run-verifier-commands.dto'; +import { AgentsVerificationService } from '../services/agents-verification.service'; +import { AgentsVerificationController } from './agents-verification.controller'; + +describe('AgentsVerificationController', () => { + let controller: AgentsVerificationController; + let service: jest.Mocked; + + const mockAgentId = 'test-agent-uuid'; + + const mockResponse: RunVerifierCommandsResponseDto = { + results: [{ cmd: 'npm test', exitCode: 0, output: 'ok' }], + }; + + const mockService = { + runVerifierCommands: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AgentsVerificationController], + providers: [ + { + provide: AgentsVerificationService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(AgentsVerificationController); + service = module.get(AgentsVerificationService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('verifyCommands', () => { + it('should return verifier results from service', async () => { + const body: RunVerifierCommandsDto = { + commands: [{ cmd: 'npm test' }], + timeoutMs: 120_000, + }; + service.runVerifierCommands.mockResolvedValue(mockResponse); + + const result = await controller.verifyCommands(mockAgentId, body); + + expect(result).toEqual(mockResponse); + expect(service.runVerifierCommands).toHaveBeenCalledWith(mockAgentId, body); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-verification.controller.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-verification.controller.ts new file mode 100644 index 00000000..2ff94316 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-verification.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { RunVerifierCommandsDto, RunVerifierCommandsResponseDto } from '../dto/run-verifier-commands.dto'; +import { AgentsVerificationService } from '../services/agents-verification.service'; + +/** + * Bounded shell execution inside agent containers for ticket automation verification. + */ +@Controller('agents/:agentId/automation') +export class AgentsVerificationController { + constructor(private readonly agentsVerificationService: AgentsVerificationService) {} + + @Post('verify-commands') + async verifyCommands( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() body: RunVerifierCommandsDto, + ): Promise { + return await this.agentsVerificationService.runVerifierCommands(agentId, body); + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/prepare-clean-workspace.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/prepare-clean-workspace.dto.ts new file mode 100644 index 00000000..2f882f48 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/prepare-clean-workspace.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +/** + * Request body to reset the agent repo to a clean upstream tip (orchestrator-only dangerous ops). + */ +export class PrepareCleanWorkspaceDto { + @IsNotEmpty() + @IsString() + @Matches(/^[a-zA-Z0-9/_-]+$/, { + message: 'baseBranch may only contain letters, digits, /, _, and -', + }) + baseBranch!: string; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/run-verifier-commands.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/run-verifier-commands.dto.ts new file mode 100644 index 00000000..e39c175d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/run-verifier-commands.dto.ts @@ -0,0 +1,35 @@ +import { Type } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsInt, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator'; + +export class VerifierShellCommandDto { + @IsString() + cmd!: string; + + @IsOptional() + @IsString() + cwd?: string; +} + +export class RunVerifierCommandsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VerifierShellCommandDto) + @ArrayMaxSize(32) + commands!: VerifierShellCommandDto[]; + + @IsOptional() + @IsInt() + @Min(1000) + @Max(3_600_000) + timeoutMs?: number; +} + +export class VerifierCommandResultDto { + cmd!: string; + exitCode!: number; + output!: string; +} + +export class RunVerifierCommandsResponseDto { + results!: VerifierCommandResultDto[]; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts index 9b79d4e6..7786b70f 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Server, Socket } from 'socket.io'; import { AgentEntity, ContainerType } from '../entities/agent.entity'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; -import { AgentProvider } from '../providers/agent-provider.interface'; +import { AgentProvider, AgentResponseObject } from '../providers/agent-provider.interface'; import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { ChatFilter, FilterDirection } from '../providers/chat-filter.interface'; import { AgentsRepository } from '../repositories/agents.repository'; @@ -15,6 +15,9 @@ import { AgentsGateway } from './agents.gateway'; interface ChatPayload { message: string; model?: string; + responseMode?: 'stream' | 'sync' | 'single'; + ephemeral?: boolean; + correlationId?: string; } describe('AgentsGateway', () => { @@ -2000,6 +2003,281 @@ describe('AgentsGateway', () => { loggerLogSpy.mockRestore(); }); + describe('ephemeral chat (unicast to requesting socket)', () => { + it('should emit chat traffic only to the requesting socket when ephemeral is true', async () => { + const requesterSocketId = mockSocket.id || 'test-socket-id'; + const otherSocketId = 'other-viewer-socket-id'; + const otherViewerSocket: Partial = { + id: otherSocketId, + emit: jest.fn(), + connected: true, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(requesterSocketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(requesterSocketId, mockSocket); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(otherSocketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(otherSocketId, otherViewerSocket); + + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findById.mockResolvedValue(mockAgent); + + agentMessagesService.getChatHistory.mockResolvedValue([ + { + id: 'msg-1', + agentId: mockAgent.id, + agent: mockAgent, + actor: 'user', + message: 'Previous message', + filtered: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const mockAgentResponseJson = JSON.stringify({ + type: 'result', + subtype: 'success', + is_error: false, + result: 'Automation reply', + }); + const mockParsedResponse = { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Automation reply', + }; + mockAgentProvider.sendMessage.mockResolvedValue(mockAgentResponseJson); + mockAgentProvider.toParseableStrings.mockReturnValue([mockAgentResponseJson]); + mockAgentProvider.toUnifiedResponse.mockReturnValue(mockParsedResponse); + + await gateway.handleChat( + { + message: 'Ephemeral user prompt', + ephemeral: true, + responseMode: 'sync', + correlationId: 'auto-run-corr-1', + }, + mockSocket as Socket, + ); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'chatMessage', + expect.objectContaining({ + success: true, + data: expect.objectContaining({ from: 'user', text: 'Ephemeral user prompt' }), + }), + ); + expect(mockSocket.emit).toHaveBeenCalledWith( + 'chatMessage', + expect.objectContaining({ + success: true, + data: expect.objectContaining({ from: 'agent' }), + }), + ); + + const otherEmits = (otherViewerSocket.emit as jest.Mock).mock.calls; + expect(otherEmits.filter(([event]) => event === 'chatMessage')).toHaveLength(0); + expect(otherEmits.filter(([event]) => event === 'chatEvent')).toHaveLength(0); + expect(otherEmits.filter(([event]) => event === 'messageFilterResult')).toHaveLength(0); + + expect(agentMessagesService.createUserMessage).not.toHaveBeenCalled(); + expect(agentMessagesService.createAgentMessage).not.toHaveBeenCalled(); + expect(mockAgentMessageEventsService.persistEvent).not.toHaveBeenCalled(); + }); + + it('should broadcast chat traffic to all authenticated sockets when ephemeral is false', async () => { + const requesterSocketId = mockSocket.id || 'test-socket-id'; + const otherSocketId = 'other-viewer-socket-id'; + const otherViewerSocket: Partial = { + id: otherSocketId, + emit: jest.fn(), + connected: true, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(requesterSocketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(requesterSocketId, mockSocket); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(otherSocketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(otherSocketId, otherViewerSocket); + + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findById.mockResolvedValue(mockAgent); + + agentMessagesService.getChatHistory.mockResolvedValue([ + { + id: 'msg-1', + agentId: mockAgent.id, + agent: mockAgent, + actor: 'user', + message: 'Previous message', + filtered: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const mockAgentResponseJson = JSON.stringify({ + type: 'result', + subtype: 'success', + is_error: false, + result: 'Hello from agent!', + }); + const mockParsedResponse = { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Hello from agent!', + }; + mockAgentProvider.sendMessage.mockResolvedValue(mockAgentResponseJson); + mockAgentProvider.toParseableStrings.mockReturnValue([mockAgentResponseJson]); + mockAgentProvider.toUnifiedResponse.mockReturnValue(mockParsedResponse); + + await gateway.handleChat({ message: 'Hello, world!' }, mockSocket as Socket); + + const requesterChatMessages = (mockSocket.emit as jest.Mock).mock.calls.filter(([e]) => e === 'chatMessage'); + const otherChatMessages = (otherViewerSocket.emit as jest.Mock).mock.calls.filter(([e]) => e === 'chatMessage'); + + expect(requesterChatMessages.length).toBeGreaterThanOrEqual(2); + expect(otherChatMessages.length).toBe(requesterChatMessages.length); + + expect(mockAgentMessageEventsService.persistEvent).toHaveBeenCalled(); + }); + }); + + describe('streaming mode', () => { + afterEach(() => { + mockAgentProvider.getCapabilities.mockReturnValue({ + supportsChat: true, + supportsStreaming: false, + supportsToolEvents: false, + supportsQuestions: false, + }); + (mockAgentProvider as { sendMessageStream?: unknown }).sendMessageStream = undefined; + mockAgentProvider.toParseableStrings.mockReset(); + mockAgentProvider.toUnifiedResponse.mockReset(); + }); + + it('buildFinalStreamingResponse collapses repeated Cursor result NDJSON into one agenstra_turn result part', () => { + const essay = 'v'.repeat(40); + const streamedUnified = [ + { type: 'tool_call', toolCallId: 'tc', name: 'read', args: { path: '/p' }, status: 'started' }, + { type: 'result', subtype: 'success', result: essay + essay }, + { type: 'result', subtype: 'success', result: essay, duration_ms: 42 }, + { type: 'result', subtype: 'success', result: essay, usage: { outputTokens: 7 } }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const built = (gateway as any).buildFinalStreamingResponse(streamedUnified, '') as { + type: string; + subtype?: string; + parts: Array>; + }; + expect(built).not.toBeNull(); + expect(built.type).toBe('agenstra_turn'); + expect(built.subtype).toBe('success'); + const resultParts = built.parts.filter((p) => p.type === 'result'); + expect(resultParts).toHaveLength(1); + expect(resultParts[0].result).toBe(essay); + expect(resultParts[0].duration_ms).toBe(42); + expect(resultParts[0].usage).toEqual({ outputTokens: 7 }); + }); + + it('buildFinalStreamingResponse removes trailing stream result that repeats materialized delta prose', () => { + const streamedUnified = [ + { type: 'delta', delta: 'Hello ' }, + { type: 'tool_call', id: 't', name: 'x', args: {}, status: 'started' }, + { type: 'delta', delta: 'world' }, + { type: 'result', subtype: 'success', result: 'Hello world' }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const built = (gateway as any).buildFinalStreamingResponse(streamedUnified, '') as { + type: string; + parts: Array>; + }; + expect(built.type).toBe('agenstra_turn'); + expect(built.parts.filter((p) => p.type === 'result')).toEqual([ + { type: 'result', subtype: 'success', result: 'Hello ' }, + { type: 'result', subtype: 'success', result: 'world' }, + ]); + expect(built.parts.some((p) => p.type === 'tool_call')).toBe(true); + }); + + it('persists deduped agenstra_turn when handleChat uses sendMessageStream with doubled Cursor result text', async () => { + const socketId = mockSocket.id || 'test-socket-id'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(socketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(socketId, mockSocket); + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findById.mockResolvedValue(mockAgent); + agentMessagesService.getChatHistory.mockResolvedValue([ + { + id: 'msg-1', + agentId: mockAgent.id, + agent: mockAgent, + actor: 'user', + message: 'Previous', + filtered: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + mockAgentProvider.getCapabilities.mockReturnValue({ + supportsChat: true, + supportsStreaming: true, + supportsToolEvents: false, + supportsQuestions: false, + }); + + const essay = 'q'.repeat(40); + const toolLine = JSON.stringify({ + type: 'tool_call', + toolCallId: 't-read', + name: 'read', + args: { path: '/x' }, + status: 'started', + }); + const doubledResultLine = JSON.stringify({ + type: 'result', + subtype: 'success', + result: essay + essay, + }); + + mockAgentProvider.sendMessageStream = jest.fn().mockImplementation(async function* () { + yield `${toolLine}\n${doubledResultLine}\n`; + }); + + mockAgentProvider.toParseableStrings.mockImplementation((line: string) => [line.trim()]); + mockAgentProvider.toUnifiedResponse.mockImplementation((s: string) => JSON.parse(s) as AgentResponseObject); + + await gateway.handleChat({ message: 'Stream me', responseMode: 'stream' }, mockSocket as Socket); + + expect(mockAgentProvider.sendMessageStream).toHaveBeenCalledWith( + mockAgent.id, + mockAgent.containerId, + 'Stream me', + expect.any(Object), + ); + expect(mockAgentProvider.sendMessage).not.toHaveBeenCalled(); + + const createCalls = agentMessagesService.createAgentMessage.mock.calls; + const payload = createCalls.find( + (c) => typeof c[1] === 'object' && c[1] !== null && (c[1] as { type?: string }).type === 'agenstra_turn', + )?.[1] as { type: string; parts: Array<{ type?: string; result?: unknown }> } | undefined; + expect(payload).toBeDefined(); + const results = payload!.parts.filter((p) => p.type === 'result'); + expect(results).toHaveLength(1); + expect(results[0].result).toBe(essay); + }); + }); + describe('initialization message', () => { it('should send initialization message on first user message when no chat history exists', async () => { const socketId = mockSocket.id || 'test-socket-id'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 493a6369..46a825c7 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -25,10 +25,7 @@ import { AgentMessageEventsService } from '../services/agent-message-events.serv import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentsService } from '../services/agents.service'; import { DockerService } from '../services/docker.service'; -import { - dropRedundantTrailingStreamResultParts, - materializeDeltaPartsIntoInterleavedResults, -} from '../utils/materialize-streaming-deltas-for-transcript'; +import { finalizeStreamingTranscriptParts } from '../utils/materialize-streaming-deltas-for-transcript'; import { buildPromptEnhancementMessage, PROMPT_ENHANCEMENT_RESUME_SESSION_SUFFIX, @@ -48,6 +45,10 @@ interface ChatPayload { message: string; correlationId?: string; responseMode?: AgentResponseMode; + /** When true, do not persist user/agent rows in `agent_messages` (background / autonomous runs). */ + ephemeral?: boolean; + continue?: boolean; + resumeSessionSuffix?: string; } interface EnhanceChatPayload { @@ -365,6 +366,37 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { void this.agentMessageEventsService.persistEvent(agentUuid, event); } + /** + * Ephemeral chat turns (e.g. autonomous ticket runs) only notify the requesting socket so other + * sessions on the same agent do not show that traffic in the console chat. + */ + private emitChatPayloadToViewers( + agentUuid: string, + ephemeral: boolean, + requestSocket: Socket, + event: string, + data: unknown, + ): void { + if (ephemeral) { + requestSocket.emit(event, data); + return; + } + this.broadcastToAgent(agentUuid, event, data); + } + + private emitOrPersistChatEvent( + agentUuid: string, + ephemeral: boolean, + requestSocket: Socket, + envelope: AgentEventEnvelope, + ): void { + if (ephemeral) { + requestSocket.emit('chatEvent', createSuccessResponse(envelope)); + return; + } + this.broadcastChatEvent(agentUuid, envelope); + } + /** * Cursor stream-json emits a final `{ type: "result", ... }` line when the model finishes, but the * Docker exec stream may stay open until the process exits. We persist as soon as we see that frame @@ -395,9 +427,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { if (streamedUnified.length > 0 && (hasStructuredStreamParts || !finalText)) { const streamEmittedResult = streamedUnified.some((p) => String(p.type) === 'result'); - const parts = dropRedundantTrailingStreamResultParts( - materializeDeltaPartsIntoInterleavedResults(streamedUnified), - ); + const parts = finalizeStreamingTranscriptParts(streamedUnified); // Keep prior rule: never duplicate the final NDJSON `result` with a synthetic `aggregatedText` blob. // After materializing deltas, skip synthetic append when any `result` part exists (from stream or flushes). if (finalText && !streamEmittedResult && !parts.some((p) => String(p.type) === 'result')) { @@ -816,7 +846,9 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { const correlationId = typeof data.correlationId === 'string' && data.correlationId.trim() ? data.correlationId.trim() : uuidv4(); - const responseMode: AgentResponseMode = data.responseMode === 'stream' ? 'stream' : 'single'; + const ephemeral = data.ephemeral === true; + const wantsStream = data.responseMode === 'stream'; + const responseMode: AgentResponseMode = wantsStream ? 'stream' : data.responseMode === 'sync' ? 'sync' : 'single'; let sequence = 0; // Create timestamp immediately for consistent message ordering @@ -828,9 +860,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { actor: 'user', }); - // Broadcast filter result for incoming filters - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'messageFilterResult', createSuccessResponse({ direction: 'incoming', @@ -849,17 +882,19 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { const droppedResponseTimestamp = new Date().toISOString(); const fakeUserMessage = `Message was dropped by filter: ${incomingFilterResult.matchedFilter?.reason || 'No reason provided'}`; - // Persist the fake user message - try { - await this.agentMessagesService.createUserMessage(agentUuid, fakeUserMessage, false); - } catch (persistError) { - const err = persistError as { message?: string }; - this.logger.warn(`Failed to persist dropped message response: ${err.message}`); + if (!ephemeral) { + try { + await this.agentMessagesService.createUserMessage(agentUuid, fakeUserMessage, false); + } catch (persistError) { + const err = persistError as { message?: string }; + this.logger.warn(`Failed to persist dropped message response: ${err.message}`); + } } - // Broadcast the fake user message (appears on user side) - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'chatMessage', createSuccessResponse({ from: ChatActor.USER, @@ -868,7 +903,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { }), ); - this.broadcastChatEvent(agentUuid, { + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), kind: 'userMessage', payload: { text: fakeUserMessage }, @@ -880,10 +915,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { // Use modified message if filter provided one, otherwise use original const messageToUse = incomingFilterResult.modifiedMessage ?? message; - // Broadcast user message immediately so UI shows "agent thinking" right away - // This is especially important when agent is instantiating - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'chatMessage', createSuccessResponse({ from: ChatActor.USER, @@ -892,13 +927,13 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { }), ); - this.broadcastChatEvent(agentUuid, { + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), kind: 'userMessage', payload: { text: messageToUse }, }); - this.broadcastChatEvent(agentUuid, { + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), kind: 'thinking', payload: {}, @@ -942,16 +977,18 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { // Persist user message (with filtered flag if filter matched) // Use modified message if filter provided one - try { - await this.agentMessagesService.createUserMessage( - agentUuid, - messageToUse, - incomingFilterResult.status === 'filtered', - ); - } catch (persistError) { - const err = persistError as { message?: string }; - this.logger.warn(`Failed to persist user message: ${err.message}`); - // Continue with message broadcasting even if persistence fails + if (!ephemeral) { + try { + await this.agentMessagesService.createUserMessage( + agentUuid, + messageToUse, + incomingFilterResult.status === 'filtered', + ); + } catch (persistError) { + const err = persistError as { message?: string }; + this.logger.warn(`Failed to persist user message: ${err.message}`); + // Continue with message broadcasting even if persistence fails + } } // Forward message to the agent's container stdin @@ -963,7 +1000,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { try { const provider = this.agentProviderFactory.getProvider(entity.agentType || 'cursor'); const supportsStreaming = - responseMode === 'stream' && provider.getCapabilities().supportsStreaming && provider.sendMessageStream; + wantsStream && + responseMode !== 'sync' && + provider.getCapabilities().supportsStreaming && + provider.sendMessageStream; const agentResponseTimestamp = new Date().toISOString(); @@ -985,10 +1025,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { for (const ev of events) { if (ev.kind === 'assistantDelta') aggregatedText += ev.payload.delta; if (ev.kind === 'assistantMessage') aggregatedText += ev.payload.text; - this.broadcastChatEvent(agentUuid, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); } - if (!streamingTurnPersisted && this.isStreamingTerminalUnifiedResponse(parsed)) { + if (!ephemeral && !streamingTurnPersisted && this.isStreamingTerminalUnifiedResponse(parsed)) { const built = this.buildFinalStreamingResponse(streamedUnified, aggregatedText); if (built) { await this.persistFilteredAgentChatResponse(agentUuid, agentResponseTimestamp, built); @@ -1002,7 +1042,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { for (const ev of events) { if (ev.kind === 'assistantDelta') aggregatedText += ev.payload.delta; if (ev.kind === 'assistantMessage') aggregatedText += ev.payload.text; - this.broadcastChatEvent(agentUuid, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); } } } @@ -1010,6 +1050,8 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { for await (const chunk of provider.sendMessageStream(agent.id, containerId, messageToUse, { model: data.model, + continue: data.continue, + resumeSessionSuffix: data.resumeSessionSuffix, })) { buffered += chunk; const parts = buffered.split('\n'); @@ -1027,7 +1069,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { buffered = ''; } - if (!streamingTurnPersisted) { + if (!ephemeral && !streamingTurnPersisted) { const finalResponse = this.buildFinalStreamingResponse(streamedUnified, aggregatedText); if (finalResponse) { await this.persistFilteredAgentChatResponse(agentUuid, agentResponseTimestamp, finalResponse); @@ -1042,6 +1084,8 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { } else { const agentResponse = await provider.sendMessage(agent.id, containerId, messageToUse, { model: data.model, + continue: data.continue, + resumeSessionSuffix: data.resumeSessionSuffix, }); if (agentResponse && agentResponse.trim()) { @@ -1060,8 +1104,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { actor: 'agent', }); - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'messageFilterResult', createSuccessResponse({ direction: 'outgoing', @@ -1081,15 +1127,19 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { message: `Message was dropped by filter: ${outgoingFilterResult.matchedFilter?.reason || 'No reason provided'}`, }; - try { - await this.agentMessagesService.createAgentMessage(agentUuid, fakeAgentResponse, false); - } catch (persistError) { - const err = persistError as { message?: string }; - this.logger.warn(`Failed to persist dropped message response: ${err.message}`); + if (!ephemeral) { + try { + await this.agentMessagesService.createAgentMessage(agentUuid, fakeAgentResponse, false); + } catch (persistError) { + const err = persistError as { message?: string }; + this.logger.warn(`Failed to persist dropped message response: ${err.message}`); + } } - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'chatMessage', createSuccessResponse({ from: ChatActor.AGENT, @@ -1105,7 +1155,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { fakeAgentResponse, ); for (const ev of events) { - this.broadcastChatEvent(agentUuid, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); } return; @@ -1120,19 +1170,23 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { } } - try { - await this.agentMessagesService.createAgentMessage( - agentUuid, - responseToUse, - outgoingFilterResult.status === 'filtered', - ); - } catch (persistError) { - const err = persistError as { message?: string }; - this.logger.warn(`Failed to persist agent message: ${err.message}`); + if (!ephemeral) { + try { + await this.agentMessagesService.createAgentMessage( + agentUuid, + responseToUse, + outgoingFilterResult.status === 'filtered', + ); + } catch (persistError) { + const err = persistError as { message?: string }; + this.logger.warn(`Failed to persist agent message: ${err.message}`); + } } - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'chatMessage', createSuccessResponse({ from: ChatActor.AGENT, @@ -1143,7 +1197,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { const events = this.agentResponseToChatEvents(agentUuid, correlationId, sequence++, responseToUse); for (const ev of events) { - this.broadcastChatEvent(agentUuid, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); } } catch (parseError) { const parseErr = parseError as { message?: string }; @@ -1154,8 +1208,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { actor: 'agent', }); - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'messageFilterResult', createSuccessResponse({ direction: 'outgoing', @@ -1171,14 +1227,18 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { message: `Message was dropped by filter: ${outgoingFilterResult.matchedFilter?.reason || 'No reason provided'}`, }; - try { - await this.agentMessagesService.createAgentMessage(agentUuid, fakeAgentResponse, false); - } catch (persistError) { - const err = persistError as { message?: string }; - this.logger.warn(`Failed to persist dropped message response: ${err.message}`); + if (!ephemeral) { + try { + await this.agentMessagesService.createAgentMessage(agentUuid, fakeAgentResponse, false); + } catch (persistError) { + const err = persistError as { message?: string }; + this.logger.warn(`Failed to persist dropped message response: ${err.message}`); + } } - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'chatMessage', createSuccessResponse({ from: ChatActor.AGENT, @@ -1194,26 +1254,30 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { fakeAgentResponse, ); for (const ev of events) { - this.broadcastChatEvent(agentUuid, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); } return; } const stringResponseToUse = outgoingFilterResult.modifiedMessage ?? toParse; - try { - await this.agentMessagesService.createAgentMessage( - agentUuid, - stringResponseToUse, - outgoingFilterResult.status === 'filtered', - ); - } catch (persistError) { - const err = persistError as { message?: string }; - this.logger.warn(`Failed to persist agent message: ${err.message}`); + if (!ephemeral) { + try { + await this.agentMessagesService.createAgentMessage( + agentUuid, + stringResponseToUse, + outgoingFilterResult.status === 'filtered', + ); + } catch (persistError) { + const err = persistError as { message?: string }; + this.logger.warn(`Failed to persist agent message: ${err.message}`); + } } - this.broadcastToAgent( + this.emitChatPayloadToViewers( agentUuid, + ephemeral, + socket, 'chatMessage', createSuccessResponse({ from: ChatActor.AGENT, @@ -1229,7 +1293,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { stringResponseToUse, ); for (const ev of events) { - this.broadcastChatEvent(agentUuid, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); } } } diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 9fb86fdd..9992a0de 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AgentsDeploymentsController } from '../controllers/agents-deployments.controller'; import { AgentsEnvironmentVariablesController } from '../controllers/agents-environment-variables.controller'; import { AgentsFilesController } from '../controllers/agents-files.controller'; +import { AgentsVerificationController } from '../controllers/agents-verification.controller'; import { AgentsVcsController } from '../controllers/agents-vcs.controller'; import { AgentsController } from '../controllers/agents.controller'; import { ConfigController } from '../controllers/config.controller'; @@ -35,6 +36,7 @@ import { AgentEnvironmentVariablesService } from '../services/agent-environment- import { AgentFileSystemService } from '../services/agent-file-system.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; +import { AgentsVerificationService } from '../services/agents-verification.service'; import { AgentsVcsService } from '../services/agents-vcs.service'; import { AgentsService } from '../services/agents.service'; import { ConfigService } from '../services/config.service'; @@ -61,6 +63,7 @@ import { PasswordService } from '@forepath/identity/backend'; AgentsController, AgentsFilesController, AgentsVcsController, + AgentsVerificationController, AgentsDeploymentsController, AgentsEnvironmentVariablesController, ConfigController, @@ -73,6 +76,7 @@ import { PasswordService } from '@forepath/identity/backend'; AgentEnvironmentVariablesService, AgentFileSystemService, AgentsVcsService, + AgentsVerificationService, ConfigService, PasswordService, DeploymentsService, diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts index 75fe0cf8..74203b96 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts @@ -1,4 +1,4 @@ -export type AgentResponseMode = 'single' | 'stream'; +export type AgentResponseMode = 'single' | 'stream' | 'sync'; export type AgentEventKind = | 'userMessage' diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts index cd499810..ba13eadb 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts @@ -88,11 +88,14 @@ export class CursorAgentProvider implements AgentProvider { private static readonly MODEL_LINE_SEPARATOR = ' - '; + /** ANSI CSI sequences; ESC from char code to satisfy eslint no-control-regex. */ + private static readonly ANSI_CSI_ESCAPE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*[A-Za-z]`, 'g'); + /** * Strip ANSI CSI escape sequences (e.g. cursor movement / clear line) from CLI output. */ private static stripAnsiSequences(text: string): string { - return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ''); + return text.replace(CursorAgentProvider.ANSI_CSI_ESCAPE, ''); } /** diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts index b7a14fa2..68e866c5 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts @@ -570,4 +570,26 @@ describe('AgentsVcsService', () => { ); }); }); + + describe('prepareCleanWorkspace', () => { + it('should fetch, checkout, reset, and clean', async () => { + agentsService.findOne.mockResolvedValue({} as any); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + dockerService.sendCommandToContainer + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce(''); + + await service.prepareCleanWorkspace(mockAgentId, 'main'); + + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); + }); + + it('should reject invalid branch names', async () => { + agentsService.findOne.mockResolvedValue({} as any); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + await expect(service.prepareCleanWorkspace(mockAgentId, 'bad;branch')).rejects.toThrow(); + }); + }); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts index 9a302844..ed0c248a 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts @@ -1066,4 +1066,39 @@ export class AgentsVcsService { throw new BadRequestException(`Failed to resolve conflict: ${err.message || 'Unknown error'}`); } } + + /** + * Fetch, checkout `baseBranch`, hard-reset to `origin/baseBranch`, and clean untracked files. + * Intended for autonomous ticket orchestration after access checks upstream. + */ + async prepareCleanWorkspace(agentId: string, baseBranch: string): Promise { + await this.agentsService.findOne(agentId); + const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); + + if (!agentEntity.containerId) { + throw new NotFoundException(`Agent ${agentId} has no associated container`); + } + + const b = baseBranch.trim(); + if (!/^[a-zA-Z0-9/_-]+$/.test(b)) { + throw new BadRequestException('Invalid base branch name'); + } + + const containerId = agentEntity.containerId; + const escaped = this.escapePath(b); + + try { + await this.executeGitCommand(containerId, 'fetch origin', this.BASE_PATH, false, true, true); + await this.executeGitCommand(containerId, `checkout ${escaped}`, this.BASE_PATH, false, true, true); + await this.executeGitCommand(containerId, `reset --hard origin/${escaped}`, this.BASE_PATH, false, true, true); + await this.executeGitCommand(containerId, 'clean -fd', this.BASE_PATH, false, true, true); + } catch (error: unknown) { + const err = error as { message?: string }; + if (error instanceof BadRequestException || error instanceof NotFoundException) { + throw error; + } + this.logger.error(`prepareCleanWorkspace failed for agent ${agentId}: ${err.message}`); + throw new BadRequestException(`Failed to prepare clean workspace: ${err.message || 'Unknown error'}`); + } + } } diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts new file mode 100644 index 00000000..fec36e9b --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts @@ -0,0 +1,64 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AgentEntity, ContainerType } from '../entities/agent.entity'; +import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentsVerificationService } from './agents-verification.service'; +import { AgentsService } from './agents.service'; +import { DockerService } from './docker.service'; + +describe('AgentsVerificationService', () => { + let service: AgentsVerificationService; + const agentsService = { findOne: jest.fn() }; + const agentsRepository = { findByIdOrThrow: jest.fn() }; + const dockerService = { sendCommandToContainer: jest.fn() }; + + const agent: AgentEntity = { + id: 'a1', + name: 'A', + description: '', + hashedPassword: 'x', + containerId: 'c1', + volumePath: '/v', + agentType: 'cursor', + containerType: ContainerType.GENERIC, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentsVerificationService, + { provide: AgentsService, useValue: agentsService }, + { provide: AgentsRepository, useValue: agentsRepository }, + { provide: DockerService, useValue: dockerService }, + ], + }).compile(); + service = module.get(AgentsVerificationService); + }); + + it('returns results with exit codes', async () => { + agentsService.findOne.mockResolvedValue({}); + agentsRepository.findByIdOrThrow.mockResolvedValue(agent); + dockerService.sendCommandToContainer.mockResolvedValue('ok\n__EXIT:0\n'); + + const res = await service.runVerifierCommands('a1', { + commands: [{ cmd: 'echo ok' }], + timeoutMs: 5000, + }); + + expect(res.results).toHaveLength(1); + expect(res.results[0].exitCode).toBe(0); + }); + + it('rejects commands with shell metacharacters', async () => { + agentsService.findOne.mockResolvedValue({}); + agentsRepository.findByIdOrThrow.mockResolvedValue(agent); + await expect( + service.runVerifierCommands('a1', { + commands: [{ cmd: 'echo bad; rm -rf /' }], + }), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-verification.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-verification.service.ts new file mode 100644 index 00000000..80c160dd --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-verification.service.ts @@ -0,0 +1,102 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + RunVerifierCommandsDto, + RunVerifierCommandsResponseDto, + VerifierCommandResultDto, +} from '../dto/run-verifier-commands.dto'; +import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentsService } from './agents.service'; +import { DockerService } from './docker.service'; + +const MAX_CMD_LEN = 2048; +const MAX_OUTPUT = 256_000; + +@Injectable() +export class AgentsVerificationService { + private readonly logger = new Logger(AgentsVerificationService.name); + + constructor( + private readonly agentsService: AgentsService, + private readonly agentsRepository: AgentsRepository, + private readonly dockerService: DockerService, + ) {} + + private assertSafeCommand(cmd: string): void { + if (cmd.includes('\n') || cmd.includes('\r')) { + throw new BadRequestException('Verifier command must not contain newlines'); + } + if (cmd.length > MAX_CMD_LEN) { + throw new BadRequestException(`Verifier command exceeds maximum length (${MAX_CMD_LEN})`); + } + if (/[;&|`$<>]/.test(cmd)) { + throw new BadRequestException('Verifier command contains disallowed shell metacharacters'); + } + } + + private truncate(out: string): string { + if (out.length <= MAX_OUTPUT) { + return out; + } + return `${out.slice(0, MAX_OUTPUT)}\n...[truncated]`; + } + + private parseExitMarker(output: string): { text: string; exitCode: number } { + const trimmed = output.trimEnd(); + const match = trimmed.match(/__EXIT:(-?\d+)\s*$/); + if (!match) { + return { text: this.truncate(trimmed), exitCode: -1 }; + } + const idx = trimmed.lastIndexOf(`__EXIT:${match[1]}`); + const text = this.truncate(trimmed.slice(0, idx).trimEnd()); + return { text, exitCode: parseInt(match[1], 10) }; + } + + /** + * Run bounded shell commands inside the agent container (sequential, fail-fast). + */ + async runVerifierCommands(agentId: string, dto: RunVerifierCommandsDto): Promise { + await this.agentsService.findOne(agentId); + const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); + if (!agentEntity.containerId) { + throw new NotFoundException(`Agent ${agentId} has no associated container`); + } + const containerId = agentEntity.containerId; + const timeoutMs = dto.timeoutMs ?? 120_000; + const results: VerifierCommandResultDto[] = []; + + for (const c of dto.commands) { + this.assertSafeCommand(c.cmd); + if (c.cwd) { + this.assertSafeCommand(c.cwd); + } + const cwdPart = c.cwd ? `cd '${c.cwd.replace(/'/g, "'\\''")}' && ` : ''; + const inner = `( ${cwdPart}${c.cmd} ); echo __EXIT:$?\n`; + const b64 = Buffer.from(inner, 'utf8').toString('base64'); + const wrapper = `echo ${b64}|base64 -d > /tmp/agenstra_verify.sh && sh /tmp/agenstra_verify.sh`; + + try { + const raw = await Promise.race([ + this.dockerService.sendCommandToContainer(containerId, `sh -c "${wrapper}"`), + new Promise((_, reject) => + setTimeout(() => reject(new BadRequestException('Verifier command timed out')), timeoutMs), + ), + ]); + const { text, exitCode } = this.parseExitMarker(raw); + results.push({ cmd: c.cmd, exitCode, output: text }); + if (exitCode !== 0) { + break; + } + } catch (error: unknown) { + const msg = (error as Error).message; + this.logger.warn(`Verifier command failed for agent ${agentId}: ${msg}`); + if (error instanceof BadRequestException) { + throw error; + } + results.push({ cmd: c.cmd, exitCode: -1, output: msg }); + break; + } + } + + return { results }; + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.spec.ts index 53052115..c44a79be 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/config.service.spec.ts @@ -22,6 +22,8 @@ describe('ConfigService', () => { sendInitialization: jest.fn(), toParseableStrings: jest.fn(), toUnifiedResponse: jest.fn(), + getModelsListCommand: jest.fn().mockReturnValue(undefined), + toModelsList: jest.fn().mockReturnValue(undefined), }; const mockAgentProviderFactory = { @@ -98,6 +100,8 @@ describe('ConfigService', () => { sendInitialization: jest.fn(), toParseableStrings: jest.fn(), toUnifiedResponse: jest.fn(), + getModelsListCommand: jest.fn().mockReturnValue(undefined), + toModelsList: jest.fn().mockReturnValue(undefined), }; const mockAnthropicProvider = { getType: jest.fn().mockReturnValue('anthropic'), @@ -115,6 +119,8 @@ describe('ConfigService', () => { sendInitialization: jest.fn(), toParseableStrings: jest.fn(), toUnifiedResponse: jest.fn(), + getModelsListCommand: jest.fn().mockReturnValue(undefined), + toModelsList: jest.fn().mockReturnValue(undefined), }; const agentTypes = ['cursor', 'openai', 'anthropic']; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.spec.ts index 07dbdd44..072cb7bd 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.spec.ts @@ -1,5 +1,8 @@ import { + collapseConsecutiveIdenticalResultParts, + collapseRepeatedWholeCopiesInString, dropRedundantTrailingStreamResultParts, + finalizeStreamingTranscriptParts, materializeDeltaPartsIntoInterleavedResults, } from './materialize-streaming-deltas-for-transcript'; @@ -71,3 +74,47 @@ describe('dropRedundantTrailingStreamResultParts', () => { expect(dropRedundantTrailingStreamResultParts(parts)).toEqual(parts); }); }); + +describe('collapseRepeatedWholeCopiesInString', () => { + it('collapses k identical concatenated segments when each segment is long enough', () => { + const unit = 'x'.repeat(40); + const triple = unit + unit + unit; + expect(collapseRepeatedWholeCopiesInString(triple)).toBe(unit); + }); + + it('does not change short strings that happen to repeat as halves', () => { + expect(collapseRepeatedWholeCopiesInString('abab')).toBe('abab'); + }); +}); + +describe('collapseConsecutiveIdenticalResultParts', () => { + it('merges three trailing identical Cursor result frames into one with merged metadata', () => { + const essay = 'y'.repeat(50); + const merged = collapseConsecutiveIdenticalResultParts([ + { type: 'tool_call', id: 't' }, + { type: 'result', subtype: 'success', result: essay }, + { type: 'result', subtype: 'success', result: essay, duration_ms: 12 }, + { type: 'result', subtype: 'success', result: essay, usage: { outputTokens: 1 } }, + ]); + expect(merged).toEqual([ + { type: 'tool_call', id: 't' }, + { type: 'result', subtype: 'success', result: essay, duration_ms: 12, usage: { outputTokens: 1 } }, + ]); + }); +}); + +describe('finalizeStreamingTranscriptParts', () => { + it('dedupes doubled prose inside one result plus duplicate NDJSON result lines', () => { + const unit = 'z'.repeat(40); + const doubledInFrame = unit + unit; + const finalized = finalizeStreamingTranscriptParts([ + { type: 'tool_call', id: 'c' }, + { type: 'result', subtype: 'success', result: doubledInFrame }, + { type: 'result', subtype: 'success', result: unit, duration_ms: 99 }, + ]); + expect(finalized).toEqual([ + { type: 'tool_call', id: 'c' }, + { type: 'result', subtype: 'success', result: unit, duration_ms: 99 }, + ]); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.ts index ccb8c65a..f161cb27 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/materialize-streaming-deltas-for-transcript.ts @@ -88,3 +88,97 @@ export function dropRedundantTrailingStreamResultParts(parts: AgentResponseObjec } return parts; } + +const DEFAULT_MIN_UNIT_FOR_WHOLE_STRING_REPEAT = 32; +const MAX_REPEAT_COPIES_TO_DETECT = 10; + +/** + * Cursor stream-json sometimes repeats the full assistant answer multiple times inside one + * `result` string (concatenated copies). Detect whole-string repetition (k identical segments) and + * keep a single copy. Uses a minimum segment length to avoid false positives on short strings. + */ +export function collapseRepeatedWholeCopiesInString( + s: string, + minUnitLength = DEFAULT_MIN_UNIT_FOR_WHOLE_STRING_REPEAT, +): string { + const n = s.length; + if (n < minUnitLength * 2) { + return s; + } + for (let k = Math.min(MAX_REPEAT_COPIES_TO_DETECT, n); k >= 2; k--) { + if (n % k !== 0) { + continue; + } + const unitLen = n / k; + if (unitLen < minUnitLength) { + continue; + } + const unit = s.slice(0, unitLen); + let allMatch = true; + for (let i = 1; i < k; i++) { + if (s.slice(i * unitLen, (i + 1) * unitLen) !== unit) { + allMatch = false; + break; + } + } + if (allMatch) { + return unit; + } + } + return s; +} + +function normalizeResultPartRepeatedProse(part: AgentResponseObject): AgentResponseObject { + if (String(part.type) !== 'result') { + return part; + } + const raw = extractResultTextBody(part); + if (!raw.trim()) { + return part; + } + const collapsed = collapseRepeatedWholeCopiesInString(raw); + if (collapsed === raw) { + return part; + } + return { ...(part as Record), result: collapsed } as AgentResponseObject; +} + +/** + * Cursor may emit several NDJSON `result` lines with the same prose (and richer metadata on the + * last). `dropRedundantTrailingStreamResultParts` only removes one layer when the trailing body + * equals the *concatenation* of all prior results — multiple identical copies break that check. + * Merge consecutive `result` parts with the same normalized body and keep metadata from the later + * frame. + */ +export function collapseConsecutiveIdenticalResultParts(parts: AgentResponseObject[]): AgentResponseObject[] { + const out: AgentResponseObject[] = []; + for (const p of parts) { + if (String(p.type) !== 'result') { + out.push(p); + continue; + } + const last = out[out.length - 1]; + if (out.length > 0 && String(last.type) === 'result') { + const prevBody = collapseWhitespaceForCompare(extractResultTextBody(last)); + const nextBody = collapseWhitespaceForCompare(extractResultTextBody(p)); + if (prevBody.length > 0 && prevBody === nextBody) { + out[out.length - 1] = { + ...(last as Record), + ...(p as Record), + } as AgentResponseObject; + continue; + } + } + out.push(p); + } + return out; +} + +/** + * Full post-processing for streamed unified frames before building `agenstra_turn.parts`. + */ +export function finalizeStreamingTranscriptParts(streamedUnified: AgentResponseObject[]): AgentResponseObject[] { + const materialized = materializeDeltaPartsIntoInterleavedResults(streamedUnified); + const proseNormalized = materialized.map((p) => normalizeResultPartRepeatedProse(p)); + return dropRedundantTrailingStreamResultParts(collapseConsecutiveIdenticalResultParts(proseNormalized)); +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/README.md b/libs/domains/framework/frontend/data-access-agent-console/README.md index c0f15fa9..6e73293c 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/README.md +++ b/libs/domains/framework/frontend/data-access-agent-console/README.md @@ -2,6 +2,16 @@ This library was generated with [Nx](https://nx.dev). +## Autonomous ticket prototyping (NgRx) + +State for **ticket automation** (per-ticket config, runs, approve, cancel) lives under the `ticketAutomation` feature key. Use `TicketAutomationFacade` to load/patch configuration, list runs, open run detail (with steps), and cancel active runs. HTTP calls go through `TicketsService` (`/tickets/:ticketId/automation/...`). + +**Client agent autonomy** (per client + agent limits and allowlists) uses the `clientAgentAutonomy` feature key and `ClientAgentAutonomyFacade`, backed by `ClientsService` (`PUT/GET /clients/:id/agents/:agentId/autonomy`). + +Facades, reducers, effects, and selectors are registered in `feature-agent-console` route providers (`agent-console.routes.ts`) alongside existing agent-console state. + +See `docs/ticket-automation-state.mmd` for a high-level diagram. + ## Running unit tests Run `nx test framework-frontend-data-access-agent-console` to execute the unit tests. diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/index.ts b/libs/domains/framework/frontend/data-access-agent-console/src/index.ts index 3414830b..0f3d6b78 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/index.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/index.ts @@ -117,6 +117,12 @@ export * from './lib/state/agents/agents.facade'; export * from './lib/state/agents/agents.reducer'; export * from './lib/state/agents/agents.selectors'; export * from './lib/state/agents/agents.types'; +export * from './lib/state/client-agent-autonomy/client-agent-autonomy.actions'; +export * from './lib/state/client-agent-autonomy/client-agent-autonomy.effects'; +export * from './lib/state/client-agent-autonomy/client-agent-autonomy.facade'; +export * from './lib/state/client-agent-autonomy/client-agent-autonomy.reducer'; +export * from './lib/state/client-agent-autonomy/client-agent-autonomy.selectors'; +export * from './lib/state/client-agent-autonomy/client-agent-autonomy.types'; export * from './lib/state/clients/clients.actions'; export * from './lib/state/clients/clients.effects'; export * from './lib/state/clients/clients.facade'; @@ -153,6 +159,12 @@ export * from './lib/state/statistics/statistics.facade'; export * from './lib/state/statistics/statistics.reducer'; export * from './lib/state/statistics/statistics.selectors'; export * from './lib/state/statistics/statistics.types'; +export * from './lib/state/ticket-automation/ticket-automation.actions'; +export * from './lib/state/ticket-automation/ticket-automation.effects'; +export * from './lib/state/ticket-automation/ticket-automation.facade'; +export * from './lib/state/ticket-automation/ticket-automation.reducer'; +export * from './lib/state/ticket-automation/ticket-automation.selectors'; +export * from './lib/state/ticket-automation/ticket-automation.types'; export * from './lib/state/tickets/tickets.actions'; export * from './lib/state/tickets/tickets.effects'; export * from './lib/state/tickets/tickets.facade'; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts index 7f1a8dee..f0188372 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.spec.ts @@ -21,6 +21,7 @@ describe('ClientsService', () => { endpoint: 'https://example.com/api', authenticationType: 'api_key', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], @@ -226,4 +227,55 @@ describe('ClientsService', () => { req.flush(null); }); }); + + describe('client agent autonomy', () => { + const mockAutonomy = { + clientId: 'client-1', + agentId: 'agent-1', + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + tokenBudgetLimit: null as number | null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + it('listEnabledAutonomyAgentIds GETs id list', (done) => { + service.listEnabledAutonomyAgentIds('client-1').subscribe((res) => { + expect(res).toEqual({ agentIds: ['agent-1', 'agent-2'] }); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/clients/client-1/agent-autonomy/enabled-agent-ids`); + expect(req.request.method).toBe('GET'); + req.flush({ agentIds: ['agent-1', 'agent-2'] }); + }); + + it('getClientAgentAutonomy GETs autonomy', (done) => { + service.getClientAgentAutonomy('client-1', 'agent-1').subscribe((row) => { + expect(row).toEqual(mockAutonomy); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/clients/client-1/agents/agent-1/autonomy`); + expect(req.request.method).toBe('GET'); + req.flush(mockAutonomy); + }); + + it('upsertClientAgentAutonomy PUTs dto', (done) => { + const dto = { + enabled: false, + preImproveTicket: true, + maxRuntimeMs: 120_000, + maxIterations: 10, + }; + service.upsertClientAgentAutonomy('client-1', 'agent-1', dto).subscribe((row) => { + expect(row).toEqual(mockAutonomy); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/clients/client-1/agents/agent-1/autonomy`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(dto); + req.flush(mockAutonomy); + }); + }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.ts index 5668310c..78141752 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/clients.service.ts @@ -17,6 +17,10 @@ import type { ServerType, UpdateClientDto, } from '../state/clients/clients.types'; +import type { + ClientAgentAutonomyResponseDto, + UpsertClientAgentAutonomyDto, +} from '../state/client-agent-autonomy/client-agent-autonomy.types'; @Injectable({ providedIn: 'root', @@ -132,4 +136,28 @@ export class ClientsService { removeClientUser(clientId: string, relationshipId: string): Observable { return this.http.delete(`${this.apiUrl}/clients/${clientId}/users/${relationshipId}`); } + + /** + * Agent UUIDs with prototype autonomy enabled for this client (scheduler only considers these agents). + */ + listEnabledAutonomyAgentIds(clientId: string): Observable<{ agentIds: string[] }> { + return this.http.get<{ agentIds: string[] }>(`${this.apiUrl}/clients/${clientId}/agent-autonomy/enabled-agent-ids`); + } + + getClientAgentAutonomy(clientId: string, agentId: string): Observable { + return this.http.get( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/autonomy`, + ); + } + + upsertClientAgentAutonomy( + clientId: string, + agentId: string, + dto: UpsertClientAgentAutonomyDto, + ): Observable { + return this.http.put( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/autonomy`, + dto, + ); + } } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts index 793e7475..4370558c 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts @@ -15,6 +15,7 @@ describe('TicketsService', () => { title: 'Example', priority: 'medium', status: 'draft', + automationEligible: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -105,4 +106,108 @@ describe('TicketsService', () => { req.flush(mockTicket); }); }); + + describe('ticket automation', () => { + const mockAutomation = { + ticketId: 'ticket-1', + eligible: true, + allowedAgentIds: ['agent-1'], + verifierProfile: null, + requiresApproval: false, + approvedAt: null, + approvedByUserId: null, + approvalBaselineTicketUpdatedAt: null, + defaultBranchOverride: null, + nextRetryAt: null, + consecutiveFailureCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + const mockRun = { + id: 'run-1', + ticketId: 'ticket-1', + clientId: 'client-1', + agentId: 'agent-1', + status: 'running' as const, + phase: 'agent_loop' as const, + ticketStatusBefore: 'todo', + branchName: 'automation/x', + baseBranch: 'main', + baseSha: null, + startedAt: '2024-01-01T00:00:00Z', + finishedAt: null, + updatedAt: '2024-01-01T00:00:00Z', + iterationCount: 0, + completionMarkerSeen: false, + verificationPassed: null, + failureCode: null, + summary: null, + cancelRequestedAt: null, + cancelledByUserId: null, + cancellationReason: null, + }; + + it('getTicketAutomation GETs /tickets/:id/automation', (done) => { + service.getTicketAutomation('ticket-1').subscribe((row) => { + expect(row).toEqual(mockAutomation); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/automation`); + expect(req.request.method).toBe('GET'); + req.flush(mockAutomation); + }); + + it('patchTicketAutomation PATCHes body', (done) => { + const dto = { eligible: false }; + service.patchTicketAutomation('ticket-1', dto).subscribe((row) => { + expect(row).toEqual(mockAutomation); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/automation`); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual(dto); + req.flush(mockAutomation); + }); + + it('approveTicketAutomation POSTs approve', (done) => { + service.approveTicketAutomation('ticket-1').subscribe((row) => { + expect(row).toEqual(mockAutomation); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/automation/approve`); + expect(req.request.method).toBe('POST'); + req.flush(mockAutomation); + }); + + it('listTicketAutomationRuns GETs runs', (done) => { + service.listTicketAutomationRuns('ticket-1').subscribe((runs) => { + expect(runs).toEqual([mockRun]); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/automation/runs`); + expect(req.request.method).toBe('GET'); + req.flush([mockRun]); + }); + + it('getTicketAutomationRun GETs run detail', (done) => { + service.getTicketAutomationRun('ticket-1', 'run-1').subscribe((run) => { + expect(run).toEqual(mockRun); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/automation/runs/run-1`); + expect(req.request.method).toBe('GET'); + req.flush(mockRun); + }); + + it('cancelTicketAutomationRun POSTs cancel', (done) => { + service.cancelTicketAutomationRun('ticket-1', 'run-1').subscribe((run) => { + expect(run).toEqual({ ...mockRun, status: 'cancelled' }); + done(); + }); + const req = httpMock.expectOne(`${apiUrl}/tickets/ticket-1/automation/runs/run-1/cancel`); + expect(req.request.method).toBe('POST'); + req.flush({ ...mockRun, status: 'cancelled' }); + }); + }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts index 642500f2..3bcafadf 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.ts @@ -13,6 +13,11 @@ import type { TicketResponseDto, UpdateTicketDto, } from '../state/tickets/tickets.types'; +import type { + TicketAutomationResponseDto, + TicketAutomationRunResponseDto, + UpdateTicketAutomationDto, +} from '../state/ticket-automation/ticket-automation.types'; @Injectable({ providedIn: 'root', @@ -93,4 +98,31 @@ export class TicketsService { content, }); } + + getTicketAutomation(ticketId: string): Observable { + return this.http.get(`${this.apiUrl}/tickets/${ticketId}/automation`); + } + + patchTicketAutomation(ticketId: string, dto: UpdateTicketAutomationDto): Observable { + return this.http.patch(`${this.apiUrl}/tickets/${ticketId}/automation`, dto); + } + + approveTicketAutomation(ticketId: string): Observable { + return this.http.post(`${this.apiUrl}/tickets/${ticketId}/automation/approve`, {}); + } + + listTicketAutomationRuns(ticketId: string): Observable { + return this.http.get(`${this.apiUrl}/tickets/${ticketId}/automation/runs`); + } + + getTicketAutomationRun(ticketId: string, runId: string): Observable { + return this.http.get(`${this.apiUrl}/tickets/${ticketId}/automation/runs/${runId}`); + } + + cancelTicketAutomationRun(ticketId: string, runId: string): Observable { + return this.http.post( + `${this.apiUrl}/tickets/${ticketId}/automation/runs/${runId}/cancel`, + {}, + ); + } } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.actions.ts new file mode 100644 index 00000000..178fa5ef --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.actions.ts @@ -0,0 +1,36 @@ +import { createAction, props } from '@ngrx/store'; +import type { ClientAgentAutonomyResponseDto, UpsertClientAgentAutonomyDto } from './client-agent-autonomy.types'; + +export const loadClientAgentAutonomy = createAction( + '[Client Agent Autonomy] Load', + props<{ clientId: string; agentId: string }>(), +); + +export const loadClientAgentAutonomySuccess = createAction( + '[Client Agent Autonomy] Load Success', + props<{ autonomy: ClientAgentAutonomyResponseDto }>(), +); + +export const loadClientAgentAutonomyFailure = createAction( + '[Client Agent Autonomy] Load Failure', + props<{ error: string }>(), +); + +export const upsertClientAgentAutonomy = createAction( + '[Client Agent Autonomy] Upsert', + props<{ clientId: string; agentId: string; dto: UpsertClientAgentAutonomyDto }>(), +); + +export const upsertClientAgentAutonomySuccess = createAction( + '[Client Agent Autonomy] Upsert Success', + props<{ autonomy: ClientAgentAutonomyResponseDto }>(), +); + +export const upsertClientAgentAutonomyFailure = createAction( + '[Client Agent Autonomy] Upsert Failure', + props<{ error: string }>(), +); + +export const clearClientAgentAutonomyError = createAction('[Client Agent Autonomy] Clear Error'); + +export const clearClientAgentAutonomy = createAction('[Client Agent Autonomy] Clear State'); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.effects.spec.ts new file mode 100644 index 00000000..02bfccf2 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.effects.spec.ts @@ -0,0 +1,143 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import type { Action } from '@ngrx/store'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Observable, of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ClientsService } from '../../services/clients.service'; +import { + loadClientAgentAutonomy, + loadClientAgentAutonomyFailure, + loadClientAgentAutonomySuccess, + upsertClientAgentAutonomy, + upsertClientAgentAutonomyFailure, + upsertClientAgentAutonomySuccess, +} from './client-agent-autonomy.actions'; +import { loadClientAgentAutonomy$, upsertClientAgentAutonomy$ } from './client-agent-autonomy.effects'; +import type { ClientAgentAutonomyResponseDto } from './client-agent-autonomy.types'; + +describe('ClientAgentAutonomyEffects', () => { + let actions$: Observable; + let clientsService: jest.Mocked>; + + const mockAutonomy: ClientAgentAutonomyResponseDto = { + clientId: 'c1', + agentId: 'a1', + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + tokenBudgetLimit: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + clientsService = { + getClientAgentAutonomy: jest.fn(), + upsertClientAgentAutonomy: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [provideMockActions(() => actions$), { provide: ClientsService, useValue: clientsService }], + }); + }); + + it('loadClientAgentAutonomy$ success', (done) => { + clientsService.getClientAgentAutonomy!.mockReturnValue(of(mockAutonomy)); + actions$ = of(loadClientAgentAutonomy({ clientId: 'c1', agentId: 'a1' })); + TestBed.runInInjectionContext(() => { + loadClientAgentAutonomy$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(loadClientAgentAutonomySuccess({ autonomy: mockAutonomy })); + expect(clientsService.getClientAgentAutonomy).toHaveBeenCalledWith('c1', 'a1'); + done(); + }); + }); + }); + + it('loadClientAgentAutonomy$ failure', (done) => { + clientsService.getClientAgentAutonomy!.mockReturnValue(throwError(() => new Error('nope'))); + actions$ = of(loadClientAgentAutonomy({ clientId: 'c1', agentId: 'a1' })); + TestBed.runInInjectionContext(() => { + loadClientAgentAutonomy$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(loadClientAgentAutonomyFailure({ error: 'nope' })); + done(); + }); + }); + }); + + it('loadClientAgentAutonomy$ maps 404 to success with defaults', (done) => { + clientsService.getClientAgentAutonomy!.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not Found' })), + ); + actions$ = of(loadClientAgentAutonomy({ clientId: 'c1', agentId: 'a1' })); + TestBed.runInInjectionContext(() => { + loadClientAgentAutonomy$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual( + loadClientAgentAutonomySuccess({ + autonomy: expect.objectContaining({ + clientId: 'c1', + agentId: 'a1', + enabled: false, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 25, + tokenBudgetLimit: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }), + ); + done(); + }); + }); + }); + + it('upsertClientAgentAutonomy$ success', (done) => { + const dto = { + enabled: false, + preImproveTicket: true, + maxRuntimeMs: 60_000, + maxIterations: 5, + }; + clientsService.upsertClientAgentAutonomy!.mockReturnValue(of(mockAutonomy)); + actions$ = of(upsertClientAgentAutonomy({ clientId: 'c1', agentId: 'a1', dto })); + TestBed.runInInjectionContext(() => { + upsertClientAgentAutonomy$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(upsertClientAgentAutonomySuccess({ autonomy: mockAutonomy })); + done(); + }); + }); + }); + + it('upsertClientAgentAutonomy$ failure', (done) => { + clientsService.upsertClientAgentAutonomy!.mockReturnValue(throwError(() => new Error('bad'))); + actions$ = of( + upsertClientAgentAutonomy({ + clientId: 'c1', + agentId: 'a1', + dto: { + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + }, + }), + ); + TestBed.runInInjectionContext(() => { + upsertClientAgentAutonomy$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(upsertClientAgentAutonomyFailure({ error: 'bad' })); + done(); + }); + }); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.effects.ts new file mode 100644 index 00000000..4b24392d --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.effects.ts @@ -0,0 +1,78 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, of, switchMap } from 'rxjs'; +import { ClientsService } from '../../services/clients.service'; +import { + loadClientAgentAutonomy, + loadClientAgentAutonomyFailure, + loadClientAgentAutonomySuccess, + upsertClientAgentAutonomy, + upsertClientAgentAutonomyFailure, + upsertClientAgentAutonomySuccess, +} from './client-agent-autonomy.actions'; +import type { ClientAgentAutonomyResponseDto } from './client-agent-autonomy.types'; + +/** Missing autonomy row (404) is normal — use defaults so the UI can edit and save without a spurious error. */ +function defaultAutonomyWhenMissing(clientId: string, agentId: string): ClientAgentAutonomyResponseDto { + const now = new Date().toISOString(); + return { + clientId, + agentId, + enabled: false, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 25, + tokenBudgetLimit: null, + createdAt: now, + updatedAt: now, + }; +} + +function normalizeError(error: unknown): string { + if (error instanceof HttpErrorResponse) { + return error.error?.message ?? error.message ?? String(error.status); + } + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'An unexpected error occurred'; +} + +export const loadClientAgentAutonomy$ = createEffect( + (actions$ = inject(Actions), clientsService = inject(ClientsService)) => { + return actions$.pipe( + ofType(loadClientAgentAutonomy), + switchMap(({ clientId, agentId }) => + clientsService.getClientAgentAutonomy(clientId, agentId).pipe( + map((autonomy) => loadClientAgentAutonomySuccess({ autonomy })), + catchError((error) => { + if (error instanceof HttpErrorResponse && error.status === 404) { + return of(loadClientAgentAutonomySuccess({ autonomy: defaultAutonomyWhenMissing(clientId, agentId) })); + } + return of(loadClientAgentAutonomyFailure({ error: normalizeError(error) })); + }), + ), + ), + ); + }, + { functional: true }, +); + +export const upsertClientAgentAutonomy$ = createEffect( + (actions$ = inject(Actions), clientsService = inject(ClientsService)) => { + return actions$.pipe( + ofType(upsertClientAgentAutonomy), + switchMap(({ clientId, agentId, dto }) => + clientsService.upsertClientAgentAutonomy(clientId, agentId, dto).pipe( + map((autonomy) => upsertClientAgentAutonomySuccess({ autonomy })), + catchError((error) => of(upsertClientAgentAutonomyFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.facade.spec.ts new file mode 100644 index 00000000..0974f435 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.facade.spec.ts @@ -0,0 +1,51 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; +import { + clearClientAgentAutonomy, + clearClientAgentAutonomyError, + loadClientAgentAutonomy, + upsertClientAgentAutonomy, +} from './client-agent-autonomy.actions'; +import { ClientAgentAutonomyFacade } from './client-agent-autonomy.facade'; + +describe('ClientAgentAutonomyFacade', () => { + let facade: ClientAgentAutonomyFacade; + let store: jest.Mocked; + + beforeEach(() => { + store = { + select: jest.fn().mockReturnValue(of(null)), + dispatch: jest.fn(), + } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ + providers: [ClientAgentAutonomyFacade, { provide: Store, useValue: store }], + }); + + facade = TestBed.inject(ClientAgentAutonomyFacade); + }); + + it('dispatches load', () => { + facade.load('c1', 'a1'); + expect(store.dispatch).toHaveBeenCalledWith(loadClientAgentAutonomy({ clientId: 'c1', agentId: 'a1' })); + }); + + it('dispatches upsert', () => { + const dto = { + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + }; + facade.upsert('c1', 'a1', dto); + expect(store.dispatch).toHaveBeenCalledWith(upsertClientAgentAutonomy({ clientId: 'c1', agentId: 'a1', dto })); + }); + + it('dispatches clear and clearError', () => { + facade.clearError(); + expect(store.dispatch).toHaveBeenCalledWith(clearClientAgentAutonomyError()); + facade.clear(); + expect(store.dispatch).toHaveBeenCalledWith(clearClientAgentAutonomy()); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.facade.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.facade.ts new file mode 100644 index 00000000..9b712482 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.facade.ts @@ -0,0 +1,46 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + clearClientAgentAutonomy, + clearClientAgentAutonomyError, + loadClientAgentAutonomy, + upsertClientAgentAutonomy, +} from './client-agent-autonomy.actions'; +import { + selectClientAgentAutonomy, + selectClientAgentAutonomyContext, + selectClientAgentAutonomyError, + selectClientAgentAutonomyLoading, + selectClientAgentAutonomySaving, +} from './client-agent-autonomy.selectors'; +import type { ClientAgentAutonomyResponseDto, UpsertClientAgentAutonomyDto } from './client-agent-autonomy.types'; + +@Injectable({ + providedIn: 'root', +}) +export class ClientAgentAutonomyFacade { + private readonly store = inject(Store); + + readonly context$ = this.store.select(selectClientAgentAutonomyContext); + readonly autonomy$: Observable = this.store.select(selectClientAgentAutonomy); + readonly loading$: Observable = this.store.select(selectClientAgentAutonomyLoading); + readonly saving$: Observable = this.store.select(selectClientAgentAutonomySaving); + readonly error$: Observable = this.store.select(selectClientAgentAutonomyError); + + load(clientId: string, agentId: string): void { + this.store.dispatch(loadClientAgentAutonomy({ clientId, agentId })); + } + + upsert(clientId: string, agentId: string, dto: UpsertClientAgentAutonomyDto): void { + this.store.dispatch(upsertClientAgentAutonomy({ clientId, agentId, dto })); + } + + clearError(): void { + this.store.dispatch(clearClientAgentAutonomyError()); + } + + clear(): void { + this.store.dispatch(clearClientAgentAutonomy()); + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.reducer.spec.ts new file mode 100644 index 00000000..5a2575bf --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.reducer.spec.ts @@ -0,0 +1,84 @@ +import { + clearClientAgentAutonomy, + loadClientAgentAutonomy, + loadClientAgentAutonomyFailure, + loadClientAgentAutonomySuccess, + upsertClientAgentAutonomy, + upsertClientAgentAutonomyFailure, + upsertClientAgentAutonomySuccess, +} from './client-agent-autonomy.actions'; +import { + clientAgentAutonomyReducer, + initialClientAgentAutonomyState, + type ClientAgentAutonomyState, +} from './client-agent-autonomy.reducer'; +import type { ClientAgentAutonomyResponseDto } from './client-agent-autonomy.types'; + +describe('clientAgentAutonomyReducer', () => { + const mockAutonomy: ClientAgentAutonomyResponseDto = { + clientId: 'c1', + agentId: 'a1', + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + tokenBudgetLimit: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + it('returns initial for unknown action', () => { + expect(clientAgentAutonomyReducer(undefined, { type: 'UNKNOWN' } as never)).toEqual( + initialClientAgentAutonomyState, + ); + }); + + it('clears autonomy when context changes on load', () => { + const prev: ClientAgentAutonomyState = { + ...initialClientAgentAutonomyState, + clientId: 'c1', + agentId: 'a1', + autonomy: mockAutonomy, + }; + const next = clientAgentAutonomyReducer(prev, loadClientAgentAutonomy({ clientId: 'c2', agentId: 'a1' })); + expect(next.loading).toBe(true); + expect(next.autonomy).toBeNull(); + }); + + it('upsert flow', () => { + let state = clientAgentAutonomyReducer( + initialClientAgentAutonomyState, + upsertClientAgentAutonomy({ + clientId: 'c1', + agentId: 'a1', + dto: { + enabled: true, + preImproveTicket: false, + maxRuntimeMs: 3_600_000, + maxIterations: 20, + }, + }), + ); + expect(state.saving).toBe(true); + state = clientAgentAutonomyReducer(state, upsertClientAgentAutonomySuccess({ autonomy: mockAutonomy })); + expect(state.saving).toBe(false); + expect(state.autonomy).toEqual(mockAutonomy); + }); + + it('records failures', () => { + expect( + clientAgentAutonomyReducer(initialClientAgentAutonomyState, loadClientAgentAutonomyFailure({ error: 'e' })).error, + ).toBe('e'); + expect( + clientAgentAutonomyReducer( + { ...initialClientAgentAutonomyState, saving: true }, + upsertClientAgentAutonomyFailure({ error: 'u' }), + ).error, + ).toBe('u'); + }); + + it('clearClientAgentAutonomy resets', () => { + const prev: ClientAgentAutonomyState = { ...initialClientAgentAutonomyState, autonomy: mockAutonomy }; + expect(clientAgentAutonomyReducer(prev, clearClientAgentAutonomy())).toEqual(initialClientAgentAutonomyState); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.reducer.ts new file mode 100644 index 00000000..eef822b8 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.reducer.ts @@ -0,0 +1,71 @@ +import { createReducer, on } from '@ngrx/store'; +import type { ClientAgentAutonomyResponseDto } from './client-agent-autonomy.types'; +import { + clearClientAgentAutonomy, + clearClientAgentAutonomyError, + loadClientAgentAutonomy, + loadClientAgentAutonomyFailure, + loadClientAgentAutonomySuccess, + upsertClientAgentAutonomy, + upsertClientAgentAutonomyFailure, + upsertClientAgentAutonomySuccess, +} from './client-agent-autonomy.actions'; + +export interface ClientAgentAutonomyState { + clientId: string | null; + agentId: string | null; + autonomy: ClientAgentAutonomyResponseDto | null; + loading: boolean; + saving: boolean; + error: string | null; +} + +export const initialClientAgentAutonomyState: ClientAgentAutonomyState = { + clientId: null, + agentId: null, + autonomy: null, + loading: false, + saving: false, + error: null, +}; + +export const clientAgentAutonomyReducer = createReducer( + initialClientAgentAutonomyState, + on(loadClientAgentAutonomy, (state, { clientId, agentId }) => ({ + ...state, + clientId, + agentId, + loading: true, + error: null, + ...(state.clientId !== clientId || state.agentId !== agentId ? { autonomy: null } : {}), + })), + on(loadClientAgentAutonomySuccess, (state, { autonomy }) => ({ + ...state, + loading: false, + autonomy, + error: null, + })), + on(loadClientAgentAutonomyFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + on(upsertClientAgentAutonomy, (state) => ({ + ...state, + saving: true, + error: null, + })), + on(upsertClientAgentAutonomySuccess, (state, { autonomy }) => ({ + ...state, + saving: false, + autonomy, + error: null, + })), + on(upsertClientAgentAutonomyFailure, (state, { error }) => ({ + ...state, + saving: false, + error, + })), + on(clearClientAgentAutonomyError, (state) => ({ ...state, error: null })), + on(clearClientAgentAutonomy, () => ({ ...initialClientAgentAutonomyState })), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.selectors.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.selectors.spec.ts new file mode 100644 index 00000000..a9165cfa --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.selectors.spec.ts @@ -0,0 +1,39 @@ +import type { ClientAgentAutonomyState } from './client-agent-autonomy.reducer'; +import { + selectClientAgentAutonomy, + selectClientAgentAutonomyContext, + selectClientAgentAutonomyError, + selectClientAgentAutonomyLoading, + selectClientAgentAutonomyState, +} from './client-agent-autonomy.selectors'; + +describe('clientAgentAutonomy selectors', () => { + const mockState: ClientAgentAutonomyState = { + clientId: 'c1', + agentId: 'a1', + autonomy: null, + loading: true, + saving: false, + error: 'x', + }; + + it('selectClientAgentAutonomyState', () => { + expect(selectClientAgentAutonomyState.projector(mockState)).toBe(mockState); + }); + + it('selectClientAgentAutonomyContext', () => { + expect(selectClientAgentAutonomyContext.projector(mockState)).toEqual({ clientId: 'c1', agentId: 'a1' }); + }); + + it('selectClientAgentAutonomyLoading', () => { + expect(selectClientAgentAutonomyLoading.projector(mockState)).toBe(true); + }); + + it('selectClientAgentAutonomyError', () => { + expect(selectClientAgentAutonomyError.projector(mockState)).toBe('x'); + }); + + it('selectClientAgentAutonomy', () => { + expect(selectClientAgentAutonomy.projector(mockState)).toBeNull(); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.selectors.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.selectors.ts new file mode 100644 index 00000000..c0d8b151 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.selectors.ts @@ -0,0 +1,17 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import type { ClientAgentAutonomyState } from './client-agent-autonomy.reducer'; + +export const selectClientAgentAutonomyState = createFeatureSelector('clientAgentAutonomy'); + +export const selectClientAgentAutonomyContext = createSelector(selectClientAgentAutonomyState, (s) => ({ + clientId: s.clientId, + agentId: s.agentId, +})); + +export const selectClientAgentAutonomy = createSelector(selectClientAgentAutonomyState, (s) => s.autonomy); + +export const selectClientAgentAutonomyLoading = createSelector(selectClientAgentAutonomyState, (s) => s.loading); + +export const selectClientAgentAutonomySaving = createSelector(selectClientAgentAutonomyState, (s) => s.saving); + +export const selectClientAgentAutonomyError = createSelector(selectClientAgentAutonomyState, (s) => s.error); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.types.ts new file mode 100644 index 00000000..d5702176 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy/client-agent-autonomy.types.ts @@ -0,0 +1,19 @@ +export interface UpsertClientAgentAutonomyDto { + enabled: boolean; + preImproveTicket: boolean; + maxRuntimeMs: number; + maxIterations: number; + tokenBudgetLimit?: number | null; +} + +export interface ClientAgentAutonomyResponseDto { + clientId: string; + agentId: string; + enabled: boolean; + preImproveTicket: boolean; + maxRuntimeMs: number; + maxIterations: number; + tokenBudgetLimit: number | null; + createdAt: string; + updatedAt: string; +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts index ea434950..5456c9aa 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.effects.spec.ts @@ -70,6 +70,7 @@ describe('ClientsEffects', () => { endpoint: 'https://example.com/api', authenticationType: 'api_key', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts index ba164ace..5537df66 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.facade.spec.ts @@ -27,6 +27,7 @@ describe('ClientsFacade', () => { endpoint: 'https://example.com/api', authenticationType: 'api_key', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], @@ -41,6 +42,7 @@ describe('ClientsFacade', () => { endpoint: 'https://example2.com/api', authenticationType: 'keycloak', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user2/repo2.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts index 1f08b738..e481a8f3 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.reducer.spec.ts @@ -43,6 +43,7 @@ describe('clientsReducer', () => { endpoint: 'https://example.com/api', authenticationType: 'api_key', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], @@ -57,6 +58,7 @@ describe('clientsReducer', () => { endpoint: 'https://example2.com/api', authenticationType: 'keycloak', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user2/repo2.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts index 0e3abf1c..84c2fd63 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.selectors.spec.ts @@ -28,6 +28,7 @@ describe('Clients Selectors', () => { endpoint: 'https://example.com/api', authenticationType: 'api_key', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user/repo.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], @@ -42,6 +43,7 @@ describe('Clients Selectors', () => { endpoint: 'https://example2.com/api', authenticationType: 'keycloak', isAutoProvisioned: false, + canManageWorkspaceConfiguration: true, config: { gitRepositoryUrl: 'https://github.com/user2/repo2.git', agentTypes: [{ type: 'cursor', displayName: 'Cursor' }], diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts index f42b876d..f71ac208 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/clients/clients.types.ts @@ -20,6 +20,8 @@ export interface ClientResponseDto { agentWsPort?: number; config?: ConfigResponseDto; isAutoProvisioned: boolean; + /** True if the current user may change autonomy, env vars, agents, and workspace settings. */ + canManageWorkspaceConfiguration: boolean; createdAt: string; updatedAt: string; } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.actions.ts new file mode 100644 index 00000000..127c847d --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.actions.ts @@ -0,0 +1,91 @@ +import { createAction, props } from '@ngrx/store'; +import type { + TicketAutomationResponseDto, + TicketAutomationRunResponseDto, + UpdateTicketAutomationDto, +} from './ticket-automation.types'; + +export const loadTicketAutomation = createAction('[Ticket Automation] Load Config', props<{ ticketId: string }>()); + +export const loadTicketAutomationSuccess = createAction( + '[Ticket Automation] Load Config Success', + props<{ config: TicketAutomationResponseDto }>(), +); + +export const loadTicketAutomationFailure = createAction( + '[Ticket Automation] Load Config Failure', + props<{ error: string }>(), +); + +export const patchTicketAutomation = createAction( + '[Ticket Automation] Patch Config', + props<{ ticketId: string; dto: UpdateTicketAutomationDto }>(), +); + +export const patchTicketAutomationSuccess = createAction( + '[Ticket Automation] Patch Config Success', + props<{ config: TicketAutomationResponseDto }>(), +); + +export const patchTicketAutomationFailure = createAction( + '[Ticket Automation] Patch Config Failure', + props<{ error: string }>(), +); + +export const approveTicketAutomation = createAction('[Ticket Automation] Approve', props<{ ticketId: string }>()); + +export const approveTicketAutomationSuccess = createAction( + '[Ticket Automation] Approve Success', + props<{ config: TicketAutomationResponseDto }>(), +); + +export const approveTicketAutomationFailure = createAction( + '[Ticket Automation] Approve Failure', + props<{ error: string }>(), +); + +export const loadTicketAutomationRuns = createAction('[Ticket Automation] Load Runs', props<{ ticketId: string }>()); + +export const loadTicketAutomationRunsSuccess = createAction( + '[Ticket Automation] Load Runs Success', + props<{ runs: TicketAutomationRunResponseDto[] }>(), +); + +export const loadTicketAutomationRunsFailure = createAction( + '[Ticket Automation] Load Runs Failure', + props<{ error: string }>(), +); + +export const loadTicketAutomationRunDetail = createAction( + '[Ticket Automation] Load Run Detail', + props<{ ticketId: string; runId: string }>(), +); + +export const loadTicketAutomationRunDetailSuccess = createAction( + '[Ticket Automation] Load Run Detail Success', + props<{ run: TicketAutomationRunResponseDto }>(), +); + +export const loadTicketAutomationRunDetailFailure = createAction( + '[Ticket Automation] Load Run Detail Failure', + props<{ error: string }>(), +); + +export const cancelTicketAutomationRun = createAction( + '[Ticket Automation] Cancel Run', + props<{ ticketId: string; runId: string }>(), +); + +export const cancelTicketAutomationRunSuccess = createAction( + '[Ticket Automation] Cancel Run Success', + props<{ run: TicketAutomationRunResponseDto }>(), +); + +export const cancelTicketAutomationRunFailure = createAction( + '[Ticket Automation] Cancel Run Failure', + props<{ error: string }>(), +); + +export const clearTicketAutomationError = createAction('[Ticket Automation] Clear Error'); + +export const clearTicketAutomation = createAction('[Ticket Automation] Clear State'); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts new file mode 100644 index 00000000..cfcd0a21 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts @@ -0,0 +1,165 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import type { Action } from '@ngrx/store'; +import { Observable, of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { TicketsService } from '../../services/tickets.service'; +import { replaceTicketDetailActivity } from '../tickets/tickets.actions'; +import { + approveTicketAutomation, + approveTicketAutomationFailure, + approveTicketAutomationSuccess, + loadTicketAutomation, + loadTicketAutomationFailure, + loadTicketAutomationSuccess, + patchTicketAutomation, + patchTicketAutomationFailure, + patchTicketAutomationSuccess, +} from './ticket-automation.actions'; +import { + approveTicketAutomation$, + loadTicketAutomation$, + patchTicketAutomation$, + refreshTicketDetailActivityAfterAutomation$, +} from './ticket-automation.effects'; +import type { TicketAutomationResponseDto } from './ticket-automation.types'; + +describe('TicketAutomationEffects', () => { + let actions$: Observable; + let ticketsService: jest.Mocked< + Pick + >; + + const mockConfig: TicketAutomationResponseDto = { + ticketId: 't1', + eligible: false, + allowedAgentIds: [], + verifierProfile: null, + requiresApproval: true, + approvedAt: null, + approvedByUserId: null, + approvalBaselineTicketUpdatedAt: null, + defaultBranchOverride: null, + nextRetryAt: null, + consecutiveFailureCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + ticketsService = { + getTicketAutomation: jest.fn(), + patchTicketAutomation: jest.fn(), + approveTicketAutomation: jest.fn(), + listActivity: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [provideMockActions(() => actions$), { provide: TicketsService, useValue: ticketsService }], + }); + }); + + it('loadTicketAutomation$ maps to success', (done) => { + ticketsService.getTicketAutomation!.mockReturnValue(of(mockConfig)); + actions$ = of(loadTicketAutomation({ ticketId: 't1' })); + TestBed.runInInjectionContext(() => { + loadTicketAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(loadTicketAutomationSuccess({ config: mockConfig })); + expect(ticketsService.getTicketAutomation).toHaveBeenCalledWith('t1'); + done(); + }); + }); + }); + + it('loadTicketAutomation$ maps to failure', (done) => { + ticketsService.getTicketAutomation!.mockReturnValue(throwError(() => new Error('network'))); + actions$ = of(loadTicketAutomation({ ticketId: 't1' })); + TestBed.runInInjectionContext(() => { + loadTicketAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(loadTicketAutomationFailure({ error: 'network' })); + done(); + }); + }); + }); + + it('patchTicketAutomation$ maps to success', (done) => { + ticketsService.patchTicketAutomation!.mockReturnValue(of(mockConfig)); + actions$ = of(patchTicketAutomation({ ticketId: 't1', dto: { eligible: true } })); + TestBed.runInInjectionContext(() => { + patchTicketAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(patchTicketAutomationSuccess({ config: mockConfig })); + done(); + }); + }); + }); + + it('patchTicketAutomation$ maps HttpErrorResponse message', (done) => { + const err = new HttpErrorResponse({ error: { message: 'bad' }, status: 400, statusText: 'Bad' }); + ticketsService.patchTicketAutomation!.mockReturnValue(throwError(() => err)); + actions$ = of(patchTicketAutomation({ ticketId: 't1', dto: {} })); + TestBed.runInInjectionContext(() => { + patchTicketAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(patchTicketAutomationFailure({ error: 'bad' })); + done(); + }); + }); + }); + + it('approveTicketAutomation$ maps to success', (done) => { + ticketsService.approveTicketAutomation!.mockReturnValue(of(mockConfig)); + actions$ = of(approveTicketAutomation({ ticketId: 't1' })); + TestBed.runInInjectionContext(() => { + approveTicketAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(approveTicketAutomationSuccess({ config: mockConfig })); + done(); + }); + }); + }); + + it('approveTicketAutomation$ maps to failure', (done) => { + ticketsService.approveTicketAutomation!.mockReturnValue(throwError(() => new Error('x'))); + actions$ = of(approveTicketAutomation({ ticketId: 't1' })); + TestBed.runInInjectionContext(() => { + approveTicketAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(approveTicketAutomationFailure({ error: 'x' })); + done(); + }); + }); + }); + + it('refreshTicketDetailActivityAfterAutomation$ loads activity after patch success', (done) => { + const activity = [ + { + id: 'a1', + ticketId: 't1', + occurredAt: '2024-01-01T00:00:00Z', + actorType: 'human' as const, + actionType: 'AUTOMATION_APPROVAL_INVALIDATED', + payload: {}, + }, + ]; + ticketsService.listActivity!.mockReturnValue(of(activity)); + actions$ = of(patchTicketAutomationSuccess({ config: mockConfig })); + TestBed.runInInjectionContext(() => { + refreshTicketDetailActivityAfterAutomation$() + .pipe(take(1)) + .subscribe((action) => { + expect(action).toEqual(replaceTicketDetailActivity({ ticketId: 't1', activity })); + expect(ticketsService.listActivity).toHaveBeenCalledWith('t1', 100, 0); + done(); + }); + }); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.ts new file mode 100644 index 00000000..f885e36a --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.ts @@ -0,0 +1,154 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, EMPTY, map, of, switchMap } from 'rxjs'; +import { TicketsService } from '../../services/tickets.service'; +import { replaceTicketDetailActivity } from '../tickets/tickets.actions'; +import { + approveTicketAutomation, + approveTicketAutomationFailure, + approveTicketAutomationSuccess, + cancelTicketAutomationRun, + cancelTicketAutomationRunFailure, + cancelTicketAutomationRunSuccess, + loadTicketAutomation, + loadTicketAutomationFailure, + loadTicketAutomationRunDetail, + loadTicketAutomationRunDetailFailure, + loadTicketAutomationRunDetailSuccess, + loadTicketAutomationRuns, + loadTicketAutomationRunsFailure, + loadTicketAutomationRunsSuccess, + loadTicketAutomationSuccess, + patchTicketAutomation, + patchTicketAutomationFailure, + patchTicketAutomationSuccess, +} from './ticket-automation.actions'; + +function normalizeError(error: unknown): string { + if (error instanceof HttpErrorResponse) { + return error.error?.message ?? error.message ?? String(error.status); + } + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'An unexpected error occurred'; +} + +export const loadTicketAutomation$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(loadTicketAutomation), + switchMap(({ ticketId }) => + ticketsService.getTicketAutomation(ticketId).pipe( + map((config) => loadTicketAutomationSuccess({ config })), + catchError((error) => of(loadTicketAutomationFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const patchTicketAutomation$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(patchTicketAutomation), + switchMap(({ ticketId, dto }) => + ticketsService.patchTicketAutomation(ticketId, dto).pipe( + map((config) => patchTicketAutomationSuccess({ config })), + catchError((error) => of(patchTicketAutomationFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const approveTicketAutomation$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(approveTicketAutomation), + switchMap(({ ticketId }) => + ticketsService.approveTicketAutomation(ticketId).pipe( + map((config) => approveTicketAutomationSuccess({ config })), + catchError((error) => of(approveTicketAutomationFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const loadTicketAutomationRuns$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(loadTicketAutomationRuns), + switchMap(({ ticketId }) => + ticketsService.listTicketAutomationRuns(ticketId).pipe( + map((runs) => loadTicketAutomationRunsSuccess({ runs })), + catchError((error) => of(loadTicketAutomationRunsFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const loadTicketAutomationRunDetail$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(loadTicketAutomationRunDetail), + switchMap(({ ticketId, runId }) => + ticketsService.getTicketAutomationRun(ticketId, runId).pipe( + map((run) => loadTicketAutomationRunDetailSuccess({ run })), + catchError((error) => of(loadTicketAutomationRunDetailFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const cancelTicketAutomationRun$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(cancelTicketAutomationRun), + switchMap(({ ticketId, runId }) => + ticketsService.cancelTicketAutomationRun(ticketId, runId).pipe( + map((run) => cancelTicketAutomationRunSuccess({ run })), + catchError((error) => of(cancelTicketAutomationRunFailure({ error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +/** Keep ticket detail activity in sync after automation mutations (backend appends activity rows). */ +export const refreshTicketDetailActivityAfterAutomation$ = createEffect( + (actions$ = inject(Actions), ticketsService = inject(TicketsService)) => { + return actions$.pipe( + ofType(patchTicketAutomationSuccess, approveTicketAutomationSuccess, cancelTicketAutomationRunSuccess), + switchMap((action) => { + let ticketId: string | null = null; + if (patchTicketAutomationSuccess.type === action.type || approveTicketAutomationSuccess.type === action.type) { + ticketId = action.config.ticketId; + } else if (cancelTicketAutomationRunSuccess.type === action.type) { + ticketId = action.run.ticketId; + } + if (!ticketId) { + return EMPTY; + } + return ticketsService.listActivity(ticketId, 100, 0).pipe( + map((activity) => replaceTicketDetailActivity({ ticketId, activity })), + catchError(() => EMPTY), + ); + }), + ); + }, + { functional: true }, +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.facade.spec.ts new file mode 100644 index 00000000..4188c1c5 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.facade.spec.ts @@ -0,0 +1,70 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; +import { + approveTicketAutomation, + cancelTicketAutomationRun, + clearTicketAutomation, + clearTicketAutomationError, + loadTicketAutomation, + loadTicketAutomationRunDetail, + loadTicketAutomationRuns, + patchTicketAutomation, +} from './ticket-automation.actions'; +import { TicketAutomationFacade } from './ticket-automation.facade'; + +describe('TicketAutomationFacade', () => { + let facade: TicketAutomationFacade; + let store: jest.Mocked; + + beforeEach(() => { + store = { + select: jest.fn().mockReturnValue(of(null)), + dispatch: jest.fn(), + } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ + providers: [TicketAutomationFacade, { provide: Store, useValue: store }], + }); + + facade = TestBed.inject(TicketAutomationFacade); + }); + + it('dispatches loadConfig', () => { + facade.loadConfig('t1'); + expect(store.dispatch).toHaveBeenCalledWith(loadTicketAutomation({ ticketId: 't1' })); + }); + + it('dispatches patchConfig', () => { + const dto = { eligible: true }; + facade.patchConfig('t1', dto); + expect(store.dispatch).toHaveBeenCalledWith(patchTicketAutomation({ ticketId: 't1', dto })); + }); + + it('dispatches approve', () => { + facade.approve('t1'); + expect(store.dispatch).toHaveBeenCalledWith(approveTicketAutomation({ ticketId: 't1' })); + }); + + it('dispatches loadRuns', () => { + facade.loadRuns('t1'); + expect(store.dispatch).toHaveBeenCalledWith(loadTicketAutomationRuns({ ticketId: 't1' })); + }); + + it('dispatches loadRunDetail', () => { + facade.loadRunDetail('t1', 'r1'); + expect(store.dispatch).toHaveBeenCalledWith(loadTicketAutomationRunDetail({ ticketId: 't1', runId: 'r1' })); + }); + + it('dispatches cancelRun', () => { + facade.cancelRun('t1', 'r1'); + expect(store.dispatch).toHaveBeenCalledWith(cancelTicketAutomationRun({ ticketId: 't1', runId: 'r1' })); + }); + + it('dispatches clearError and clear', () => { + facade.clearError(); + expect(store.dispatch).toHaveBeenCalledWith(clearTicketAutomationError()); + facade.clear(); + expect(store.dispatch).toHaveBeenCalledWith(clearTicketAutomation()); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.facade.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.facade.ts new file mode 100644 index 00000000..0b7f5d24 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.facade.ts @@ -0,0 +1,80 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { + approveTicketAutomation, + cancelTicketAutomationRun, + clearTicketAutomation, + clearTicketAutomationError, + loadTicketAutomation, + loadTicketAutomationRunDetail, + loadTicketAutomationRuns, + patchTicketAutomation, +} from './ticket-automation.actions'; +import { + selectTicketAutomationActiveTicketId, + selectTicketAutomationConfig, + selectTicketAutomationError, + selectTicketAutomationLoadingConfig, + selectTicketAutomationLoadingRunDetail, + selectTicketAutomationLoadingRuns, + selectTicketAutomationRunDetail, + selectTicketAutomationRuns, + selectTicketAutomationSaving, +} from './ticket-automation.selectors'; +import type { + TicketAutomationResponseDto, + TicketAutomationRunResponseDto, + UpdateTicketAutomationDto, +} from './ticket-automation.types'; + +@Injectable({ + providedIn: 'root', +}) +export class TicketAutomationFacade { + private readonly store = inject(Store); + + readonly activeTicketId$: Observable = this.store.select(selectTicketAutomationActiveTicketId); + readonly config$: Observable = this.store.select(selectTicketAutomationConfig); + readonly runs$: Observable = this.store.select(selectTicketAutomationRuns); + readonly runDetail$: Observable = this.store.select( + selectTicketAutomationRunDetail, + ); + readonly loadingConfig$: Observable = this.store.select(selectTicketAutomationLoadingConfig); + readonly loadingRuns$: Observable = this.store.select(selectTicketAutomationLoadingRuns); + readonly loadingRunDetail$: Observable = this.store.select(selectTicketAutomationLoadingRunDetail); + readonly saving$: Observable = this.store.select(selectTicketAutomationSaving); + readonly error$: Observable = this.store.select(selectTicketAutomationError); + + loadConfig(ticketId: string): void { + this.store.dispatch(loadTicketAutomation({ ticketId })); + } + + patchConfig(ticketId: string, dto: UpdateTicketAutomationDto): void { + this.store.dispatch(patchTicketAutomation({ ticketId, dto })); + } + + approve(ticketId: string): void { + this.store.dispatch(approveTicketAutomation({ ticketId })); + } + + loadRuns(ticketId: string): void { + this.store.dispatch(loadTicketAutomationRuns({ ticketId })); + } + + loadRunDetail(ticketId: string, runId: string): void { + this.store.dispatch(loadTicketAutomationRunDetail({ ticketId, runId })); + } + + cancelRun(ticketId: string, runId: string): void { + this.store.dispatch(cancelTicketAutomationRun({ ticketId, runId })); + } + + clearError(): void { + this.store.dispatch(clearTicketAutomationError()); + } + + clear(): void { + this.store.dispatch(clearTicketAutomation()); + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts new file mode 100644 index 00000000..9325c8b3 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts @@ -0,0 +1,168 @@ +import { + approveTicketAutomation, + approveTicketAutomationFailure, + approveTicketAutomationSuccess, + cancelTicketAutomationRun, + cancelTicketAutomationRunFailure, + cancelTicketAutomationRunSuccess, + clearTicketAutomation, + loadTicketAutomation, + loadTicketAutomationFailure, + loadTicketAutomationRunDetail, + loadTicketAutomationRunDetailFailure, + loadTicketAutomationRunDetailSuccess, + loadTicketAutomationRuns, + loadTicketAutomationRunsFailure, + loadTicketAutomationRunsSuccess, + loadTicketAutomationSuccess, + patchTicketAutomation, + patchTicketAutomationFailure, + patchTicketAutomationSuccess, +} from './ticket-automation.actions'; +import { + initialTicketAutomationState, + ticketAutomationReducer, + type TicketAutomationState, +} from './ticket-automation.reducer'; +import type { TicketAutomationResponseDto, TicketAutomationRunResponseDto } from './ticket-automation.types'; + +describe('ticketAutomationReducer', () => { + const mockConfig: TicketAutomationResponseDto = { + ticketId: 't1', + eligible: true, + allowedAgentIds: ['a1'], + verifierProfile: null, + requiresApproval: false, + approvedAt: null, + approvedByUserId: null, + approvalBaselineTicketUpdatedAt: null, + defaultBranchOverride: null, + nextRetryAt: null, + consecutiveFailureCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + const mockRun: TicketAutomationRunResponseDto = { + id: 'r1', + ticketId: 't1', + clientId: 'c1', + agentId: 'a1', + status: 'running', + phase: 'agent_loop', + ticketStatusBefore: 'todo', + branchName: 'automation/x', + baseBranch: 'main', + baseSha: null, + startedAt: '2024-01-01T00:00:00Z', + finishedAt: null, + updatedAt: '2024-01-01T00:00:00Z', + iterationCount: 1, + completionMarkerSeen: false, + verificationPassed: null, + failureCode: null, + summary: null, + cancelRequestedAt: null, + cancelledByUserId: null, + cancellationReason: null, + }; + + it('returns initial state for unknown action', () => { + const state = ticketAutomationReducer(undefined, { type: 'UNKNOWN' } as never); + expect(state).toEqual(initialTicketAutomationState); + }); + + it('clears runs and config when loading a different ticket', () => { + const prev: TicketAutomationState = { + ...initialTicketAutomationState, + activeTicketId: 't1', + config: mockConfig, + runs: [mockRun], + }; + const next = ticketAutomationReducer(prev, loadTicketAutomation({ ticketId: 't2' })); + expect(next.activeTicketId).toBe('t2'); + expect(next.loadingConfig).toBe(true); + expect(next.runs).toEqual([]); + expect(next.config).toBeNull(); + expect(next.runDetail).toBeNull(); + }); + + it('merges run into list on detail success and cancel success', () => { + let state = ticketAutomationReducer( + initialTicketAutomationState, + loadTicketAutomationRunsSuccess({ runs: [{ ...mockRun, id: 'r-old' }] }), + ); + const updated = { ...mockRun, id: 'r-old', status: 'cancelled' as const }; + state = ticketAutomationReducer(state, cancelTicketAutomationRunSuccess({ run: updated })); + expect(state.runs).toEqual([updated]); + expect(state.saving).toBe(false); + }); + + it('handles patch and approve success', () => { + let state = ticketAutomationReducer( + initialTicketAutomationState, + patchTicketAutomation({ ticketId: 't1', dto: {} }), + ); + expect(state.saving).toBe(true); + state = ticketAutomationReducer(state, patchTicketAutomationSuccess({ config: mockConfig })); + expect(state.saving).toBe(false); + expect(state.config).toEqual(mockConfig); + state = ticketAutomationReducer(state, approveTicketAutomation({ ticketId: 't1' })); + state = ticketAutomationReducer( + state, + approveTicketAutomationSuccess({ config: { ...mockConfig, approvedAt: 'x' } }), + ); + expect(state.config?.approvedAt).toBe('x'); + }); + + it('clears on clearTicketAutomation', () => { + const prev: TicketAutomationState = { + ...initialTicketAutomationState, + config: mockConfig, + error: 'x', + }; + const next = ticketAutomationReducer(prev, clearTicketAutomation()); + expect(next).toEqual(initialTicketAutomationState); + }); + + it('records failures', () => { + expect( + ticketAutomationReducer(initialTicketAutomationState, loadTicketAutomationFailure({ error: 'e' })).error, + ).toBe('e'); + expect( + ticketAutomationReducer(initialTicketAutomationState, loadTicketAutomationRunsFailure({ error: 'r' })).error, + ).toBe('r'); + expect( + ticketAutomationReducer(initialTicketAutomationState, loadTicketAutomationRunDetailFailure({ error: 'd' })).error, + ).toBe('d'); + expect( + ticketAutomationReducer( + { ...initialTicketAutomationState, saving: true }, + patchTicketAutomationFailure({ error: 'p' }), + ).error, + ).toBe('p'); + expect( + ticketAutomationReducer( + { ...initialTicketAutomationState, saving: true }, + approveTicketAutomationFailure({ error: 'a' }), + ).error, + ).toBe('a'); + expect( + ticketAutomationReducer( + { ...initialTicketAutomationState, saving: true }, + cancelTicketAutomationRunFailure({ error: 'c' }), + ).error, + ).toBe('c'); + }); + + it('sets loading flags for run detail', () => { + let state = ticketAutomationReducer( + initialTicketAutomationState, + loadTicketAutomationRunDetail({ ticketId: 't1', runId: 'r1' }), + ); + expect(state.loadingRunDetail).toBe(true); + state = ticketAutomationReducer(state, loadTicketAutomationRunDetailSuccess({ run: mockRun })); + expect(state.loadingRunDetail).toBe(false); + expect(state.runDetail).toEqual(mockRun); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.ts new file mode 100644 index 00000000..2c511f6f --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.ts @@ -0,0 +1,146 @@ +import { createReducer, on } from '@ngrx/store'; +import type { TicketAutomationResponseDto, TicketAutomationRunResponseDto } from './ticket-automation.types'; +import { + approveTicketAutomation, + approveTicketAutomationFailure, + approveTicketAutomationSuccess, + cancelTicketAutomationRun, + cancelTicketAutomationRunFailure, + cancelTicketAutomationRunSuccess, + clearTicketAutomation, + clearTicketAutomationError, + loadTicketAutomation, + loadTicketAutomationFailure, + loadTicketAutomationRunDetail, + loadTicketAutomationRunDetailFailure, + loadTicketAutomationRunDetailSuccess, + loadTicketAutomationRuns, + loadTicketAutomationRunsFailure, + loadTicketAutomationRunsSuccess, + loadTicketAutomationSuccess, + patchTicketAutomation, + patchTicketAutomationFailure, + patchTicketAutomationSuccess, +} from './ticket-automation.actions'; + +export interface TicketAutomationState { + activeTicketId: string | null; + config: TicketAutomationResponseDto | null; + runs: TicketAutomationRunResponseDto[]; + runDetail: TicketAutomationRunResponseDto | null; + loadingConfig: boolean; + loadingRuns: boolean; + loadingRunDetail: boolean; + saving: boolean; + error: string | null; +} + +export const initialTicketAutomationState: TicketAutomationState = { + activeTicketId: null, + config: null, + runs: [], + runDetail: null, + loadingConfig: false, + loadingRuns: false, + loadingRunDetail: false, + saving: false, + error: null, +}; + +function mergeRunInList( + runs: TicketAutomationRunResponseDto[], + run: TicketAutomationRunResponseDto, +): TicketAutomationRunResponseDto[] { + const idx = runs.findIndex((r) => r.id === run.id); + if (idx < 0) { + return [...runs, run]; + } + const next = [...runs]; + next[idx] = run; + return next; +} + +export const ticketAutomationReducer = createReducer( + initialTicketAutomationState, + on(loadTicketAutomation, (state, { ticketId }) => ({ + ...state, + activeTicketId: ticketId, + loadingConfig: true, + error: null, + ...(state.activeTicketId !== ticketId ? { runs: [], runDetail: null, config: null } : {}), + })), + on(loadTicketAutomationSuccess, (state, { config }) => ({ + ...state, + loadingConfig: false, + config, + error: null, + })), + on(loadTicketAutomationFailure, (state, { error }) => ({ + ...state, + loadingConfig: false, + error, + })), + on(patchTicketAutomation, approveTicketAutomation, cancelTicketAutomationRun, (state) => ({ + ...state, + saving: true, + error: null, + })), + on(patchTicketAutomationSuccess, approveTicketAutomationSuccess, (state, { config }) => ({ + ...state, + saving: false, + config, + error: null, + })), + on( + patchTicketAutomationFailure, + approveTicketAutomationFailure, + cancelTicketAutomationRunFailure, + (state, { error }) => ({ + ...state, + saving: false, + error, + }), + ), + on(loadTicketAutomationRuns, (state) => ({ + ...state, + loadingRuns: true, + error: null, + })), + on(loadTicketAutomationRunsSuccess, (state, { runs }) => ({ + ...state, + loadingRuns: false, + runs, + error: null, + })), + on(loadTicketAutomationRunsFailure, (state, { error }) => ({ + ...state, + loadingRuns: false, + error, + })), + on(loadTicketAutomationRunDetail, (state) => ({ + ...state, + loadingRunDetail: true, + error: null, + })), + on(loadTicketAutomationRunDetailSuccess, (state, { run }) => ({ + ...state, + loadingRunDetail: false, + runDetail: run, + runs: mergeRunInList(state.runs, run), + error: null, + })), + on(loadTicketAutomationRunDetailFailure, (state, { error }) => ({ + ...state, + loadingRunDetail: false, + error, + })), + on(cancelTicketAutomationRunSuccess, (state, { run }) => ({ + ...state, + saving: false, + runs: mergeRunInList(state.runs, run), + runDetail: state.runDetail?.id === run.id ? run : state.runDetail, + error: null, + })), + on(clearTicketAutomationError, (state) => ({ ...state, error: null })), + on(clearTicketAutomation, () => ({ ...initialTicketAutomationState })), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.selectors.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.selectors.spec.ts new file mode 100644 index 00000000..45149b63 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.selectors.spec.ts @@ -0,0 +1,42 @@ +import type { TicketAutomationState } from './ticket-automation.reducer'; +import { + selectTicketAutomationConfig, + selectTicketAutomationError, + selectTicketAutomationLoadingConfig, + selectTicketAutomationRuns, + selectTicketAutomationState, +} from './ticket-automation.selectors'; + +describe('ticketAutomation selectors', () => { + const mockState: TicketAutomationState = { + activeTicketId: 't1', + config: null, + runs: [], + runDetail: null, + loadingConfig: true, + loadingRuns: false, + loadingRunDetail: false, + saving: false, + error: 'err', + }; + + it('selectTicketAutomationState projects slice', () => { + expect(selectTicketAutomationState.projector(mockState)).toBe(mockState); + }); + + it('selectTicketAutomationLoadingConfig', () => { + expect(selectTicketAutomationLoadingConfig.projector(mockState)).toBe(true); + }); + + it('selectTicketAutomationRuns', () => { + expect(selectTicketAutomationRuns.projector(mockState)).toEqual([]); + }); + + it('selectTicketAutomationConfig', () => { + expect(selectTicketAutomationConfig.projector(mockState)).toBeNull(); + }); + + it('selectTicketAutomationError', () => { + expect(selectTicketAutomationError.projector(mockState)).toBe('err'); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.selectors.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.selectors.ts new file mode 100644 index 00000000..5f4a1d1c --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.selectors.ts @@ -0,0 +1,28 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import type { TicketAutomationState } from './ticket-automation.reducer'; + +export const selectTicketAutomationState = createFeatureSelector('ticketAutomation'); + +export const selectTicketAutomationActiveTicketId = createSelector( + selectTicketAutomationState, + (s) => s.activeTicketId, +); + +export const selectTicketAutomationConfig = createSelector(selectTicketAutomationState, (s) => s.config); + +export const selectTicketAutomationRuns = createSelector(selectTicketAutomationState, (s) => s.runs); + +export const selectTicketAutomationRunDetail = createSelector(selectTicketAutomationState, (s) => s.runDetail); + +export const selectTicketAutomationLoadingConfig = createSelector(selectTicketAutomationState, (s) => s.loadingConfig); + +export const selectTicketAutomationLoadingRuns = createSelector(selectTicketAutomationState, (s) => s.loadingRuns); + +export const selectTicketAutomationLoadingRunDetail = createSelector( + selectTicketAutomationState, + (s) => s.loadingRunDetail, +); + +export const selectTicketAutomationSaving = createSelector(selectTicketAutomationState, (s) => s.saving); + +export const selectTicketAutomationError = createSelector(selectTicketAutomationState, (s) => s.error); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts new file mode 100644 index 00000000..ce214f4e --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts @@ -0,0 +1,74 @@ +/** Mirrors backend `TicketVerifierProfileJson` (JSON over HTTP). */ +export interface TicketVerifierProfileJson { + commands: Array<{ cmd: string; cwd?: string }>; +} + +export interface UpdateTicketAutomationDto { + eligible?: boolean; + allowedAgentIds?: string[]; + verifierProfile?: TicketVerifierProfileJson; + requiresApproval?: boolean; + defaultBranchOverride?: string | null; +} + +export type TicketAutomationRunStatus = + | 'pending' + | 'running' + | 'succeeded' + | 'failed' + | 'timed_out' + | 'escalated' + | 'cancelled'; + +export type TicketAutomationRunPhase = 'pre_improve' | 'workspace_prep' | 'agent_loop' | 'verify' | 'finalize'; + +export interface TicketAutomationResponseDto { + ticketId: string; + eligible: boolean; + allowedAgentIds: string[]; + verifierProfile: TicketVerifierProfileJson | null; + requiresApproval: boolean; + approvedAt: string | null; + approvedByUserId: string | null; + approvalBaselineTicketUpdatedAt: string | null; + defaultBranchOverride: string | null; + nextRetryAt: string | null; + consecutiveFailureCount: number; + createdAt: string; + updatedAt: string; +} + +export interface TicketAutomationRunStepResponseDto { + id: string; + stepIndex: number; + phase: string; + kind: string; + payload: Record | null; + excerpt: string | null; + createdAt: string; +} + +export interface TicketAutomationRunResponseDto { + id: string; + ticketId: string; + clientId: string; + agentId: string; + status: TicketAutomationRunStatus; + phase: TicketAutomationRunPhase; + ticketStatusBefore: string; + branchName: string | null; + baseBranch: string | null; + baseSha: string | null; + startedAt: string; + finishedAt: string | null; + updatedAt: string; + iterationCount: number; + completionMarkerSeen: boolean; + verificationPassed: boolean | null; + failureCode: string | null; + summary: Record | null; + cancelRequestedAt: string | null; + cancelledByUserId: string | null; + cancellationReason: string | null; + steps?: TicketAutomationRunStepResponseDto[]; +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/ticket-global-search.utils.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/ticket-global-search.utils.spec.ts index 823cadc4..15620c4b 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/ticket-global-search.utils.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/ticket-global-search.utils.spec.ts @@ -13,6 +13,7 @@ describe('ticket-global-search.utils', () => { content: null, priority: 'medium', status: 'draft', + automationEligible: false, createdAt: '', updatedAt: '', ...overrides, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts index 0d9c0b15..b921819d 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.actions.ts @@ -66,3 +66,9 @@ export const prependTicketDetailActivity = createAction( '[Tickets] Prepend Detail Activity', props<{ activity: TicketActivityResponseDto }>(), ); + +/** Replaces activity list for an open ticket (e.g. after automation patch without reloading the whole bundle). */ +export const replaceTicketDetailActivity = createAction( + '[Tickets] Replace Detail Activity', + props<{ ticketId: string; activity: TicketActivityResponseDto[] }>(), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts index 3b139a8f..e9f88aea 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.effects.spec.ts @@ -41,6 +41,7 @@ describe('TicketsEffects', () => { title: 'T', priority: 'low', status: 'todo', + automationEligible: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts index c7645c27..eb3e5bf2 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.facade.spec.ts @@ -24,6 +24,7 @@ describe('TicketsFacade', () => { title: 'Example', priority: 'medium', status: 'draft', + automationEligible: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts index fbabca50..8779f70d 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts @@ -13,10 +13,12 @@ import { loadTicketsSuccess, openTicketDetail, prependTicketDetailActivity, + replaceTicketDetailActivity, updateTicket, updateTicketFailure, updateTicketSuccess, } from './tickets.actions'; +import { patchTicketAutomationSuccess } from '../ticket-automation/ticket-automation.actions'; import { initialTicketsState, ticketsReducer, type TicketsState } from './tickets.reducer'; import type { TicketActivityResponseDto, TicketCommentResponseDto, TicketResponseDto } from './tickets.types'; @@ -28,6 +30,8 @@ describe('ticketsReducer', () => { content: null, priority: 'medium', status: 'draft', + preferredChatAgentId: null, + automationEligible: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -318,6 +322,38 @@ describe('ticketsReducer', () => { }); }); + describe('replaceTicketDetailActivity', () => { + it('should replace activity when detail is open for that ticket', () => { + const fresh: TicketActivityResponseDto[] = [ + { + id: 'act-new', + ticketId: mockTicket.id, + occurredAt: '2024-01-04T12:00:00Z', + actorType: 'human', + actionType: 'AUTOMATION_APPROVED', + payload: {}, + }, + ]; + const prev: TicketsState = { + ...initialTicketsState, + selectedTicketId: mockTicket.id, + activity: [mockActivity], + }; + const next = ticketsReducer(prev, replaceTicketDetailActivity({ ticketId: mockTicket.id, activity: fresh })); + expect(next.activity).toEqual(fresh); + }); + + it('should not change activity when selected ticket differs', () => { + const prev: TicketsState = { + ...initialTicketsState, + selectedTicketId: 'other-ticket', + activity: [mockActivity], + }; + const next = ticketsReducer(prev, replaceTicketDetailActivity({ ticketId: mockTicket.id, activity: [] })); + expect(next.activity).toEqual([mockActivity]); + }); + }); + describe('closeTicketDetail', () => { it('should clear detail state', () => { const prev: TicketsState = { @@ -356,4 +392,34 @@ describe('ticketsReducer', () => { expect(next.saving).toBe(true); }); }); + + describe('patchTicketAutomationSuccess → tickets slice', () => { + const automationConfig = { + ticketId: 'ticket-1', + eligible: true, + allowedAgentIds: [] as string[], + verifierProfile: null, + requiresApproval: false, + approvedAt: null, + approvedByUserId: null, + approvalBaselineTicketUpdatedAt: null, + defaultBranchOverride: null, + nextRetryAt: null, + consecutiveFailureCount: 0, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }; + + it('updates automationEligible on list and open detail', () => { + const prev: TicketsState = { + ...initialTicketsState, + list: [mockTicket], + detail: mockTicket, + selectedTicketId: mockTicket.id, + }; + const next = ticketsReducer(prev, patchTicketAutomationSuccess({ config: automationConfig })); + expect(next.list[0].automationEligible).toBe(true); + expect(next.detail?.automationEligible).toBe(true); + }); + }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts index da6d6dfe..2f3e7bc2 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.ts @@ -1,4 +1,9 @@ import { createReducer, on } from '@ngrx/store'; +import { + approveTicketAutomationSuccess, + loadTicketAutomationSuccess, + patchTicketAutomationSuccess, +} from '../ticket-automation/ticket-automation.actions'; import type { TicketActivityResponseDto, TicketCommentResponseDto, TicketResponseDto } from './tickets.types'; import { addTicketComment, @@ -19,6 +24,7 @@ import { loadTicketsSuccess, openTicketDetail, prependTicketDetailActivity, + replaceTicketDetailActivity, updateTicket, updateTicketFailure, updateTicketSuccess, @@ -59,6 +65,28 @@ function mergeTicketInList(list: TicketResponseDto[], ticket: TicketResponseDto) } /** When a subtask is created while its parent is open in the detail panel, merge it into `detail.children`. */ +/** Keep `TicketResponseDto.automationEligible` in sync when automation config is loaded or patched (activity is refreshed separately). */ +function syncTicketAutomationEligible(state: TicketsState, ticketId: string, eligible: boolean): TicketsState { + const list = state.list.map((t) => (t.id === ticketId ? { ...t, automationEligible: eligible } : t)); + const detail = state.detail; + if (!detail) { + return { ...state, list }; + } + if (detail.id === ticketId) { + return { ...state, list, detail: { ...detail, automationEligible: eligible } }; + } + const children = detail.children; + if (children?.length) { + const idx = children.findIndex((c) => c.id === ticketId); + if (idx >= 0) { + const nextChildren = [...children]; + nextChildren[idx] = { ...nextChildren[idx], automationEligible: eligible }; + return { ...state, list, detail: { ...detail, children: nextChildren } }; + } + } + return { ...state, list, detail }; +} + function mergeCreatedChildIntoDetail( detail: TicketResponseDto | null, created: TicketResponseDto, @@ -167,4 +195,10 @@ export const ticketsReducer = createReducer( } return { ...state, activity: [activity, ...state.activity] }; }), + on(replaceTicketDetailActivity, (state, { ticketId, activity }) => + state.selectedTicketId === ticketId ? { ...state, activity } : state, + ), + on(patchTicketAutomationSuccess, approveTicketAutomationSuccess, loadTicketAutomationSuccess, (state, { config }) => + syncTicketAutomationEligible(state, config.ticketId, config.eligible), + ), ); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.selectors.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.selectors.spec.ts index fce577a1..be5205ea 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.selectors.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.selectors.spec.ts @@ -25,6 +25,7 @@ describe('tickets selectors', () => { content: null, priority: 'medium', status: 'draft', + automationEligible: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ...overrides, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts index b24ec5ea..a4aecc11 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts @@ -12,6 +12,10 @@ export interface TicketResponseDto { status: TicketStatus; createdByUserId?: string | null; createdByEmail?: string | null; + /** Preferred workspace agent for chat/AI when viewing this ticket. */ + preferredChatAgentId?: string | null; + /** True when autonomous prototyping is enabled for this ticket. */ + automationEligible: boolean; createdAt: string; updatedAt: string; children?: TicketResponseDto[]; @@ -33,6 +37,7 @@ export interface UpdateTicketDto { content?: string; priority?: TicketPriority; status?: TicketStatus; + preferredChatAgentId?: string | null; } export interface TicketCommentResponseDto { diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts index 1b273532..572b906f 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts @@ -9,8 +9,12 @@ import { clearChatHistoryOnUpdateSuccess$, ClientsFacade, clientsReducer, + ClientAgentAutonomyFacade, + clientAgentAutonomyReducer, commit$, connectSocket$, + approveTicketAutomation$, + cancelTicketAutomationRun$, createBranch$, addClientUser$, createClient$, @@ -37,6 +41,7 @@ import { listDirectory$, loadBranches$, loadClient$, + loadClientAgentAutonomy$, loadClientUsers$, loadClientAgent$, loadClientAgentCommandsFromFiles$, @@ -98,16 +103,24 @@ import { statisticsReducer, switchBranch$, addTicketComment$, + patchTicketAutomation$, + refreshTicketDetailActivityAfterAutomation$, createTicket$, deleteTicket$, + loadTicketAutomation$, + loadTicketAutomationRunDetail$, + loadTicketAutomationRuns$, loadTickets$, openTicketDetail$, + TicketAutomationFacade, + ticketAutomationReducer, TicketsFacade, ticketsReducer, triggerWorkflow$, unstageFiles$, updateClient$, updateTicket$, + upsertClientAgentAutonomy$, updateClientAgent$, updateDeploymentConfiguration$, updateEnvironmentVariable$, @@ -206,6 +219,8 @@ export const agentConsoleRoutes: Route[] = [ StatisticsFacade, DeploymentsFacade, TicketsFacade, + TicketAutomationFacade, + ClientAgentAutonomyFacade, // Feature states - registered at feature level for lazy loading provideState('clients', clientsReducer), provideState('agents', agentsReducer), @@ -217,11 +232,14 @@ export const agentConsoleRoutes: Route[] = [ provideState('statistics', statisticsReducer), provideState('deployments', deploymentsReducer), provideState('tickets', ticketsReducer), + provideState('ticketAutomation', ticketAutomationReducer), + provideState('clientAgentAutonomy', clientAgentAutonomyReducer), // Effects - only active when this feature route is loaded provideEffects({ loadClients$, loadClientsBatch$, loadClient$, + loadClientAgentAutonomy$, loadClientUsers$, addClientUser$, removeClientUser$, @@ -313,6 +331,14 @@ export const agentConsoleRoutes: Route[] = [ updateTicket$, deleteTicket$, addTicketComment$, + loadTicketAutomation$, + patchTicketAutomation$, + refreshTicketDetailActivityAfterAutomation$, + approveTicketAutomation$, + loadTicketAutomationRuns$, + loadTicketAutomationRunDetail$, + cancelTicketAutomationRun$, + upsertClientAgentAutonomy$, }), provideMonacoEditor(), ], diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.html index d0381eb2..b4a304fc 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.html @@ -257,7 +257,7 @@
Audit Tables
class="badge" [class.bg-primary]="row.direction === 'input'" [class.bg-secondary]="row.direction === 'output'" - >{{ row.direction }}{{ formatChatIoDirection(row.direction) }} {{ row.wordCount }} @@ -364,7 +364,7 @@
Audit Tables
class="badge" [class.bg-primary]="row.direction === 'incoming'" [class.bg-secondary]="row.direction === 'outgoing'" - >{{ row.direction }}{{ formatFilterDirection(row.direction) }} {{ row.wordCount }} @@ -471,7 +471,7 @@
Audit Tables
class="badge" [class.bg-primary]="row.direction === 'incoming'" [class.bg-secondary]="row.direction === 'outgoing'" - >{{ row.direction }}{{ formatFilterDirection(row.direction) }} {{ row.wordCount }} @@ -559,7 +559,7 @@
Audit Tables
{{ formatDateTime(row.occurredAt) }} {{ resolveUserDisplay(row.originalUserId) }} - {{ row.entityType }} + {{ formatEntityType(row.entityType) }} @if (buildEntityEventRoute(row); as route) { Audit Tables [class.bg-warning]="row.eventType === 'updated'" [class.bg-danger]="row.eventType === 'deleted'" [class.text-dark]="row.eventType === 'updated'" - >{{ row.eventType }}{{ formatEntityEventType(row.eventType) }} diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.ts index aedf1cf2..61c90cec 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/audit/audit.component.ts @@ -151,16 +151,21 @@ export class AuditComponent implements OnInit { } | null>(() => { const s = this.summary(); if (!s) return null; + const dropPrefix = $localize`:@@featureAudit-chartDropPrefix:Drop`; + const flagPrefix = $localize`:@@featureAudit-chartFlagPrefix:Flag`; const drops = (s.filterTypesBreakdown ?? []).map((b) => ({ - label: `Drop: ${b.filterType} (${b.direction})`, + label: `${dropPrefix}: ${b.filterType} (${this.formatFilterDirection(b.direction)})`, count: b.count, })); const flags = (s.filterFlagsBreakdown ?? []).map((b) => ({ - label: `Flag: ${b.filterType} (${b.direction})`, + label: `${flagPrefix}: ${b.filterType} (${this.formatFilterDirection(b.direction)})`, count: b.count, })); const items = [...drops, ...flags]; - const labels = items.length > 0 ? items.map((i) => i.label) : ['No filter drops or flags']; + const labels = + items.length > 0 + ? items.map((i) => i.label) + : [$localize`:@@featureAudit-chartNoFilterBreakdown:No filter drops or flags`]; const series = items.length > 0 ? items.map((i) => i.count) : [1]; const colors = items.length > 0 ? items.map((_, i) => BS_CHART_COLORS[i % BS_CHART_COLORS.length]) : ['var(--bs-secondary)']; @@ -172,7 +177,10 @@ export class AuditComponent implements OnInit { legend: { labels: { colors: 'var(--bs-body-color)' }, }, - title: { text: 'Filter drops and flags by type', style: { color: 'var(--bs-body-color)' } }, + title: { + text: $localize`:@@featureAudit-chartFilterBreakdownTitle:Filter drops and flags by type`, + style: { color: 'var(--bs-body-color)' }, + }, }; }); @@ -484,6 +492,62 @@ export class AuditComponent implements OnInit { } } + /** Human-readable label for Chat I/O direction (API: input | output). */ + formatChatIoDirection(direction: string): string { + switch (direction) { + case 'input': + return $localize`:@@featureAudit-chatIoDirectionInput:User message`; + case 'output': + return $localize`:@@featureAudit-chatIoDirectionOutput:Assistant reply`; + default: + return direction; + } + } + + /** Human-readable label for filter audit direction (API: incoming | outgoing). */ + formatFilterDirection(direction: string): string { + switch (direction) { + case 'incoming': + return $localize`:@@featureAudit-filterDirectionIncoming:Incoming`; + case 'outgoing': + return $localize`:@@featureAudit-filterDirectionOutgoing:Outgoing`; + default: + return direction; + } + } + + /** Human-readable label for entity audit row type. */ + formatEntityType(entityType: string): string { + switch (entityType) { + case 'user': + return $localize`:@@featureAudit-entityTypeUser:User`; + case 'client': + return $localize`:@@featureAudit-entityTypeClient:Client`; + case 'agent': + return $localize`:@@featureAudit-entityTypeAgent:Agent`; + case 'client_user': + return $localize`:@@featureAudit-entityTypeClientUser:Client user`; + case 'provisioning_reference': + return $localize`:@@featureAudit-entityTypeProvisioningReference:Provisioning reference`; + default: + return entityType; + } + } + + /** Human-readable label for entity audit event type. */ + formatEntityEventType(eventType: string): string { + switch (eventType) { + case 'created': + return $localize`:@@featureAudit-eventTypeCreated:Created`; + case 'updated': + return $localize`:@@featureAudit-eventTypeUpdated:Updated`; + case 'deleted': + return $localize`:@@featureAudit-eventTypeDeleted:Deleted`; + default: + return eventType; + } + } + totalPages(total: number): number { return Math.max(0, Math.ceil(total / PAGE_SIZE)); } diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html index 0da20165..67d38219 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -207,26 +207,28 @@
Workspaces
> - - + @if (client.canManageWorkspaceConfiguration) { + + + } } @@ -260,17 +262,19 @@
Workspaces
Environments
- + @if ((activeClient$ | async)?.canManageWorkspaceConfiguration) { + + }
@@ -381,37 +385,50 @@
Environments
} -
- - - -
+ @if ((activeClient$ | async)?.canManageWorkspaceConfiguration) { +
+ + + + +
+ } } @@ -2659,7 +2676,7 @@
Environment Variables
} -
+
@if (environmentVariablesLoading$ | async) {
@@ -2677,7 +2694,7 @@
Environment Variables
@for (envVar of envVars; track envVar.id) {
-
+
{{ envVar.variable }}
@if (editingEnvVarId() === envVar.id) { @@ -2768,6 +2785,166 @@
Environment Variables
+ + + }
-
+
@if (clientUsersLoading$ | async) {
diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts index 4fb8c19a..6cfdbd32 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectorRef, Component, DestroyRef, + effect, ElementRef, inject, OnDestroy, @@ -11,13 +12,14 @@ import { signal, ViewChild, } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AgentsFacade, AuthenticationFacade, + ClientAgentAutonomyFacade, ClientsFacade, ContainerType, DeploymentsService, @@ -30,6 +32,7 @@ import { type AgentResponseDto, type AgentResponseObject, type ChatMessageData, + type ClientAgentAutonomyResponseDto, type ClientAuthenticationType, type ClientResponseDto, type ClientUserResponseDto, @@ -45,6 +48,7 @@ import { type UpdateAgentDto, type UpdateClientDto, type UpdateEnvironmentVariableDto, + type UpsertClientAgentAutonomyDto, type WriteFileDto, } from '@forepath/framework/frontend/data-access-agent-console'; import { ENVIRONMENT, type Environment } from '@forepath/framework/frontend/util-configuration'; @@ -59,6 +63,7 @@ import { map, Observable, of, + pairwise, shareReplay, skip, startWith, @@ -120,6 +125,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe private readonly statsFacade = inject(StatsFacade); private readonly filesFacade = inject(FilesFacade); private readonly envFacade = inject(EnvFacade); + private readonly autonomyFacade = inject(ClientAgentAutonomyFacade); private readonly deploymentsService = inject(DeploymentsService); private readonly cdr = inject(ChangeDetectorRef); private readonly sanitizer = inject(DomSanitizer); @@ -152,6 +158,9 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe @ViewChild('environmentVariablesModal', { static: false }) private environmentVariablesModal!: ElementRef; + @ViewChild('ticketAutonomyModal', { static: false }) + private ticketAutonomyModal!: ElementRef; + @ViewChild('clientUsersModal', { static: false }) private clientUsersModal!: ElementRef; @@ -463,8 +472,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe if (!selectedAgent || selectedAgent.agentType === 'openclaw') { return false; } - // Show chat if editor and deployment manager are not open, or if one is open and chat is visible - return (!editorOpen && !deploymentManagerOpen) || chatVisible; + const sidePanelOpen = deploymentManagerOpen; + return (!editorOpen && !sidePanelOpen) || chatVisible; }), ); @@ -479,8 +488,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe if (!selectedAgent) { return false; } - // Show gateway if editor and deployment manager are not open, or if one is open and chat is visible - return ((!editorOpen && !deploymentManagerOpen) || gatewayVisible) && selectedAgent.agentType === 'openclaw'; + const sidePanelOpen = deploymentManagerOpen; + return ((!editorOpen && !sidePanelOpen) || gatewayVisible) && selectedAgent.agentType === 'openclaw'; }), ); @@ -686,6 +695,19 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe content: '', }); + /** Agent whose prototype autonomy modal is open (null when closed). */ + readonly managingTicketAutonomyAgentId = signal(null); + readonly ticketAutonomyDraftEnabled = signal(false); + readonly ticketAutonomyDraftPreImprove = signal(false); + readonly ticketAutonomyDraftMaxRuntimeMs = signal(3_600_000); + readonly ticketAutonomyDraftMaxIterations = signal(25); + /** Empty string means null token budget. */ + readonly ticketAutonomyDraftTokenBudgetText = signal(''); + readonly autonomyRow = toSignal(this.autonomyFacade.autonomy$, { initialValue: null }); + readonly autonomyLoading = toSignal(this.autonomyFacade.loading$, { initialValue: false }); + readonly autonomySaving = toSignal(this.autonomyFacade.saving$, { initialValue: false }); + readonly autonomyError = toSignal(this.autonomyFacade.error$, { initialValue: null }); + // Environment variables observables (computed based on active client and managing agent) readonly managingEnvVarsAgentId$ = toObservable(this.managingEnvVarsAgentId); readonly environmentVariables$: Observable = combineLatest([ @@ -827,6 +849,25 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return typeof window !== 'undefined' && window.innerWidth <= 767.98; } + constructor() { + effect(() => { + const agentId = this.managingTicketAutonomyAgentId(); + const clientId = this.activeClientId; + if (clientId && agentId) { + this.autonomyFacade.load(clientId, agentId); + } + }); + effect(() => { + if (!this.managingTicketAutonomyAgentId()) { + return; + } + const row = this.autonomyRow(); + if (row) { + this.applyTicketAutonomyDraftFromRow(row); + } + }); + } + ngOnInit(): void { // Default chat model to auto mode on load this.socketsFacade.setChatModel(null); @@ -1995,6 +2036,72 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe this.deploymentManagerOpen.set(false); } + onManageTicketAutonomyClick(agent: AgentResponseDto): void { + const clientId = this.activeClientId; + if (!clientId) { + return; + } + this.managingTicketAutonomyAgentId.set(agent.id); + this.showModal(this.ticketAutonomyModal); + } + + onCloseTicketAutonomyModal(): void { + this.managingTicketAutonomyAgentId.set(null); + this.autonomyFacade.clear(); + } + + private applyTicketAutonomyDraftFromRow(row: ClientAgentAutonomyResponseDto): void { + this.ticketAutonomyDraftEnabled.set(row.enabled); + this.ticketAutonomyDraftPreImprove.set(row.preImproveTicket); + this.ticketAutonomyDraftMaxRuntimeMs.set(row.maxRuntimeMs); + this.ticketAutonomyDraftMaxIterations.set(row.maxIterations); + this.ticketAutonomyDraftTokenBudgetText.set(row.tokenBudgetLimit !== null ? String(row.tokenBudgetLimit) : ''); + } + + onResetTicketAutonomyDraft(): void { + const row = this.autonomyRow(); + if (row) { + this.applyTicketAutonomyDraftFromRow(row); + return; + } + this.ticketAutonomyDraftEnabled.set(false); + this.ticketAutonomyDraftPreImprove.set(false); + this.ticketAutonomyDraftMaxRuntimeMs.set(3_600_000); + this.ticketAutonomyDraftMaxIterations.set(25); + this.ticketAutonomyDraftTokenBudgetText.set(''); + } + + onSaveTicketAutonomy(): void { + const clientId = this.activeClientId; + const agentId = this.managingTicketAutonomyAgentId(); + if (!clientId || !agentId) { + return; + } + const tokenRaw = this.ticketAutonomyDraftTokenBudgetText().trim(); + const tokenBudgetLimit = tokenRaw === '' ? null : Number(tokenRaw); + const dto: UpsertClientAgentAutonomyDto = { + enabled: this.ticketAutonomyDraftEnabled(), + preImproveTicket: this.ticketAutonomyDraftPreImprove(), + maxRuntimeMs: Number(this.ticketAutonomyDraftMaxRuntimeMs()) || 1, + maxIterations: Number(this.ticketAutonomyDraftMaxIterations()) || 1, + tokenBudgetLimit: tokenBudgetLimit !== null && !Number.isNaN(tokenBudgetLimit) ? tokenBudgetLimit : null, + }; + this.autonomyFacade.clearError(); + this.autonomyFacade.upsert(clientId, agentId, dto); + + // Close modal when save completes (same pattern as onSubmitUpdateAgent) + this.autonomyFacade.saving$ + .pipe( + pairwise(), + filter(([wasSaving, isSaving]) => wasSaving && !isSaving), + take(1), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.hideModal(this.ticketAutonomyModal); + }); + } + /** * Open deployment manager in a new standalone window */ diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts index 6f1bd8f1..96bab8f7 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts @@ -1,12 +1,11 @@ import { CommonModule } from '@angular/common'; import { Component, DestroyRef, inject, OnInit } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router'; import { AuthenticationFacade, ClientsFacade } from '@forepath/framework/frontend/data-access-agent-console'; import { LocaleService } from '@forepath/framework/frontend/util-configuration'; -import { combineLatest, filter, map, startWith } from 'rxjs'; import { StandaloneLoadingService } from '@forepath/shared/frontend'; +import { combineLatest, filter, map, startWith } from 'rxjs'; import { ThemeService } from '../theme.service'; @Component({ diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.spec.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.spec.ts new file mode 100644 index 00000000..0915c5a4 --- /dev/null +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.spec.ts @@ -0,0 +1,34 @@ +import { + ticketAutomationCancellationReasonLabel, + ticketAutomationFailureCodeLabel, + ticketAutomationRunPhaseLabel, + ticketAutomationRunStatusLabel, + ticketAutomationRunStepKindLabel, +} from './ticket-automation-run-labels'; + +describe('ticket-automation-run-labels', () => { + it('maps run status away from raw snake_case where applicable', () => { + expect(ticketAutomationRunStatusLabel('timed_out')).not.toBe('timed_out'); + }); + + it('maps phase away from raw API token', () => { + expect(ticketAutomationRunPhaseLabel('agent_loop')).not.toBe('agent_loop'); + }); + + it('maps step kind away from raw API token', () => { + expect(ticketAutomationRunStepKindLabel('vcs_prepare')).not.toBe('vcs_prepare'); + }); + + it('maps failure code away from raw API token', () => { + expect(ticketAutomationFailureCodeLabel('approval_missing')).not.toBe('approval_missing'); + }); + + it('maps cancellation reason away from raw API token', () => { + expect(ticketAutomationCancellationReasonLabel('lease_expired')).not.toBe('lease_expired'); + }); + + it('passes through unknown codes unchanged', () => { + expect(ticketAutomationRunStatusLabel('future_status')).toBe('future_status'); + expect(ticketAutomationRunStepKindLabel('future_kind')).toBe('future_kind'); + }); +}); diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts new file mode 100644 index 00000000..2ef9bc1e --- /dev/null +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts @@ -0,0 +1,105 @@ +/** + * Human-readable labels for ticket automation run API values (status, phase, step kinds, failure codes). + * Mirrors backend enums in `ticket-automation.enums.ts` and OpenAPI; unknown values pass through. + */ + +export function ticketAutomationRunStatusLabel(status: string): string { + switch (status) { + case 'pending': + return $localize`:@@featureTicketsBoard-runStatusPending:Pending`; + case 'running': + return $localize`:@@featureTicketsBoard-runStatusRunning:Running`; + case 'succeeded': + return $localize`:@@featureTicketsBoard-runStatusSucceeded:Succeeded`; + case 'failed': + return $localize`:@@featureTicketsBoard-runStatusFailed:Failed`; + case 'timed_out': + return $localize`:@@featureTicketsBoard-runStatusTimedOut:Timed out`; + case 'escalated': + return $localize`:@@featureTicketsBoard-runStatusEscalated:Escalated`; + case 'cancelled': + return $localize`:@@featureTicketsBoard-runStatusCancelled:Cancelled`; + default: + return status; + } +} + +export function ticketAutomationRunPhaseLabel(phase: string): string { + switch (phase) { + case 'pre_improve': + return $localize`:@@featureTicketsBoard-runPhasePreImprove:Pre-improve`; + case 'workspace_prep': + return $localize`:@@featureTicketsBoard-runPhaseWorkspacePrep:Workspace prep`; + case 'agent_loop': + return $localize`:@@featureTicketsBoard-runPhaseAgentLoop:Agent loop`; + case 'verify': + return $localize`:@@featureTicketsBoard-runPhaseVerify:Verify`; + case 'finalize': + return $localize`:@@featureTicketsBoard-runPhaseFinalize:Finalize`; + default: + return phase; + } +} + +export function ticketAutomationRunStepKindLabel(kind: string): string { + switch (kind) { + case 'vcs_prepare': + return $localize`:@@featureTicketsBoard-runStepKindVcsPrepare:Repository setup`; + case 'agent_turn': + return $localize`:@@featureTicketsBoard-runStepKindAgentTurn:Agent turn`; + case 'git_commit': + return $localize`:@@featureTicketsBoard-runStepKindGitCommit:Git commit`; + case 'git_push': + return $localize`:@@featureTicketsBoard-runStepKindGitPush:Git push`; + default: + return kind; + } +} + +export function ticketAutomationFailureCodeLabel(code: string): string { + switch (code) { + case 'approval_missing': + return $localize`:@@featureTicketsBoard-runFailureApprovalMissing:Approval missing`; + case 'lease_contention': + return $localize`:@@featureTicketsBoard-runFailureLeaseContention:Lease contention`; + case 'vcs_dirty_workspace': + return $localize`:@@featureTicketsBoard-runFailureVcsDirtyWorkspace:Dirty workspace`; + case 'vcs_branch_exists': + return $localize`:@@featureTicketsBoard-runFailureVcsBranchExists:Branch already exists`; + case 'agent_provider_error': + return $localize`:@@featureTicketsBoard-runFailureAgentProviderError:Agent provider error`; + case 'agent_no_completion_marker': + return $localize`:@@featureTicketsBoard-runFailureAgentNoCompletionMarker:No completion marker`; + case 'marker_without_verify': + return $localize`:@@featureTicketsBoard-runFailureMarkerWithoutVerify:Completion marker without verify profile`; + case 'verify_command_failed': + return $localize`:@@featureTicketsBoard-runFailureVerifyCommandFailed:Verify command failed`; + case 'commit_failed': + return $localize`:@@featureTicketsBoard-runFailureCommitFailed:Git commit failed`; + case 'push_failed': + return $localize`:@@featureTicketsBoard-runFailurePushFailed:Git push failed`; + case 'budget_exceeded': + return $localize`:@@featureTicketsBoard-runFailureBudgetExceeded:Budget exceeded`; + case 'human_escalation': + return $localize`:@@featureTicketsBoard-runFailureHumanEscalation:Human escalation`; + case 'orchestrator_stale': + return $localize`:@@featureTicketsBoard-runFailureOrchestratorStale:Orchestrator stale`; + default: + return code; + } +} + +export function ticketAutomationCancellationReasonLabel(reason: string): string { + switch (reason) { + case 'user_request': + return $localize`:@@featureTicketsBoard-runCancelUserRequest:User request`; + case 'approval_invalidated': + return $localize`:@@featureTicketsBoard-runCancelApprovalInvalidated:Approval invalidated`; + case 'lease_expired': + return $localize`:@@featureTicketsBoard-runCancelLeaseExpired:Lease expired`; + case 'system_shutdown': + return $localize`:@@featureTicketsBoard-runCancelSystemShutdown:System shutdown`; + default: + return reason; + } +} diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-body-hierarchy-context.spec.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-body-hierarchy-context.spec.ts index 7885635d..8a29406c 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-body-hierarchy-context.spec.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/ticket-body-hierarchy-context.spec.ts @@ -8,6 +8,7 @@ describe('buildTicketBodyHierarchyContext', () => { title: 'T', priority: 'medium', status: 'draft', + automationEligible: false, createdAt: '', updatedAt: '', ...overrides, diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html index 70be7a9d..e788077d 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html @@ -148,7 +148,20 @@
Tickets
role="button" >
-
{{ row.ticket.title }}
+
+
+ {{ row.ticket.title }} +
+ @if (row.ticket.automationEligible) { + + + + } +
Tickets