From e19e7b46078b97bef6691f2ce548668a3544ba2b Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 20 Sep 2026 21:55:33 -0400 Subject: [PATCH 1/8] Align agent execution with living graph and allocation contracts --- ...emove_retired_workday_demand_authority.sql | 6 + ...emove_retired_decision_execution_input.sql | 2 + ...37_remove_retired_project_agent_policy.sql | 6 + .../0038_remove_retired_mode_runs.sql | 42 ++ ...0039_remove_workday_capacity_envelopes.sql | 12 + ...40_remove_retired_allocation_authority.sql | 23 + src/api/capacity/artifact-manifest.ts | 18 - src/api/capacity/control-plane.ts | 131 +---- src/api/capacity/domain/allocation-path.ts | 50 -- .../policy/artifact-mutation-receipt.ts | 3 +- .../authority/agent-authority-presets.ts | 13 +- src/api/capacity/policy/decision-work.ts | 395 --------------- .../execution/execution-graph-projector.ts | 28 ++ .../execution/workday-execution-projector.ts | 9 +- .../policy/execution/workday-participants.ts | 3 +- src/api/capacity/policy/planning.ts | 204 -------- src/api/capacity/policy/revision-cycle.ts | 32 -- .../policy/runtime/runtime-diagnostics.ts | 9 - .../policy/workdays/chat-activity-profile.ts | 7 +- .../policy/workdays/cooperative-planning.ts | 72 --- .../policy/workdays/planning-graph.ts | 209 -------- src/api/capacity/provider-control-plane.ts | 25 +- .../capacity/accounting/ledger.ts | 2 +- .../capacity/accounting/reservation.ts | 10 +- .../capacity/accounting/task-usage.ts | 1 - .../capacity/assignments/assignment.ts | 4 +- .../capacity/planning/agent-capacity-plan.ts | 166 ------- .../workdays/workday-demand-supply.ts | 84 ---- .../capacity/workdays/workday-demand.ts | 237 --------- .../capacity/workdays/workday-envelope.ts | 222 --------- .../capacity/workdays/workday-event.ts | 10 +- .../workdays/workday-participation.ts | 155 ------ .../capacity/workdays/workday-recovery.ts | 97 ++-- .../projects/agents/project-agent-class.ts | 14 +- .../repositories/support/execution-run.ts | 451 +++--------------- .../capacity/repositories/support/mode-run.ts | 270 ----------- .../repositories/support/planning-state.ts | 254 ---------- .../support/structured-estimate.ts | 97 ---- .../treedx/graph/decision-work-graph.ts | 156 ------ .../accounts/engineering-source-authority.ts | 151 ------ .../services/build/ready-execution-node.ts | 52 +- .../capacity/accounting/settlement-service.ts | 6 +- .../accounting/usage-report-service.ts | 7 +- .../team-clone/agent-team-clone-service.ts | 21 +- .../assignment-capability-service.ts | 159 ------ .../admission/living-allocation-inputs.ts | 4 +- .../admission/living-execution-admission.ts | 44 +- .../context/assignment-deliverable-service.ts | 138 ------ ...assignment-completion-preflight-service.ts | 56 --- ...ssignment-discussion-suspension-service.ts | 9 +- .../lifecycle/assignment-lease-service.ts | 10 +- .../lifecycle/assignment-lifecycle-service.ts | 36 +- .../lifecycle/assignment-recovery-service.ts | 66 ++- .../assignment-cleanup-observation-service.ts | 9 +- .../operator-assignment-service.ts | 37 +- .../planning/assignment-function.ts | 366 -------------- .../planning/assignment-operational-paths.ts | 105 ---- .../assignment-planning-output-service.ts | 291 ----------- .../planning/estimates/integration.ts | 30 +- .../execution/assignment-attempt-builder.ts | 78 +-- .../assignment-workspace-provisioning.ts | 21 - .../execution/living-execution-assignment.ts | 27 +- .../assignments/planning/feedback/subject.ts | 41 -- .../planning/support/assignment-denial.ts | 26 - .../support/assignment-function-support.ts | 23 - ...ion-store.ts => living-execution-store.ts} | 4 +- .../execution/exact-dependency-links.ts | 88 ++++ .../execution/executable-proposal-source.ts | 6 +- .../discussion-invocation-service.ts | 8 +- .../planning/agent-capacity-plan-service.ts | 210 -------- .../workday-assignment-context-service.ts | 3 +- .../workday-summary-query-service.ts | 335 ------------- .../content/mode-run-activity-event.ts | 98 ---- .../content/workday-content-demand-source.ts | 100 ---- .../workdays/content/workday-event-service.ts | 6 +- .../lifecycle/living-workday-lifecycle.ts | 50 +- .../workday-admission-fence-service.ts | 58 +-- ...kday-assignment-terminalization-service.ts | 66 +-- .../lifecycle/workday-continuation-service.ts | 44 -- ...orkday-envelope-terminalization-service.ts | 81 ---- .../lifecycle/workday-recovery-service.ts | 20 +- .../scheduling/workday-run-service.ts | 6 +- .../scheduling/workday-scheduling-service.ts | 8 +- .../agents/project-agent-class-service.ts | 12 +- .../agents/project-agent-summary-service.ts | 54 +-- .../project-agent-artifact-service.ts | 95 ++-- .../project-deletion-blocker-service.ts | 4 +- .../research-workflow-projection-service.ts | 61 --- .../runtime-diagnostics-query-service.ts | 11 +- .../services/support/acting-demand-source.ts | 174 ------- .../support/planning-demand-source.ts | 256 ---------- .../support/planning-state-service.ts | 250 ---------- .../support/structured-estimate-service.ts | 80 ---- .../graph/decision-work-graph-service.ts | 203 -------- .../repositories/treedx-proxy-effects.ts | 12 +- .../catalog/providers/assignments.ts | 2 - .../control-plane/mcp/completion-source.ts | 3 +- .../capacity/agent-governance-service.ts | 82 ---- .../capacity/agent-query-service.ts | 5 + .../capacity/capacity-plan-service.ts | 32 -- .../capacity/communication-service.ts | 4 +- .../communication/scheduling-diagnostics.ts | 27 +- .../execution/execution-graph-service.ts | 26 +- .../capacity/planning-and-estimate-service.ts | 112 ----- .../providers/provider-assignment-service.ts | 25 +- .../providers/provider-assignment-support.ts | 19 +- .../providers/provider-workflow-service.ts | 2 +- .../source/source-workspace-service.ts | 33 +- src/api/discussions/content.ts | 1 - src/api/knowledge/editorial-review.ts | 10 - src/api/support/app.ts | 4 - src/api/support/control-plane-postgres.ts | 4 - .../project-knowledge-binding.ts | 24 +- .../projects-core/project-proposal-types.ts | 30 -- .../capacity/agent-operations.test.ts | 6 +- .../project-agent-artifact-service.test.ts | 34 ++ .../project-agent-summary-service.test.ts | 27 ++ .../agents/team-clone-source-snapshot.test.ts | 49 ++ .../discussion-admission-capacity.test.ts | 2 +- .../scheduling-diagnostics.test.ts | 12 +- ...terminal-invocation-reconciliation.test.ts | 6 +- .../context/workday-identity-context.test.ts | 44 -- ...e-diagnostics-assignment-authority.test.ts | 20 + .../admission/allocation-inputs.test.ts | 13 + .../living-execution-assignment-paths.test.ts | 9 +- .../living-reservation-record.test.ts | 20 + .../execution/admission/postgres.test.ts | 22 +- .../admission/timeout-postgres.test.ts | 4 +- .../assignment-attempt-builder.test.ts | 67 +++ .../assignment-recovery-service.test.ts | 14 +- .../capacity/execution/fixtures/assignment.ts | 2 +- .../decision-work-estimate-graph.test.ts | 123 ----- .../graph/executable-proposal-source.test.ts | 6 +- ...xecution-run-assignment-projection.test.ts | 29 ++ ...living-execution-graph-persistence.test.ts | 17 + .../graph/living-execution-graph.test.ts | 26 + .../retired-allocation-removal.test.ts | 51 ++ .../migrations/retired-demand-removal.test.ts | 126 +++++ .../relations/exact-dependency-links.test.ts | 68 +++ .../graph/workday-execution-projector.test.ts | 19 +- .../living-execution-admission.test.ts | 32 +- .../living-workday-lifecycle.test.ts | 40 ++ .../execution/ready-execution-node.test.ts | 63 ++- .../assignment-estimate-integration.test.ts | 34 +- .../proposal-feedback-subject.test.ts | 81 +--- .../planning/workday-content-source.test.ts | 88 ---- .../chat-activity-profile-policy.test.ts | 13 + .../workdays/acting-content-base-ref.test.ts | 25 - ...y-assignment-context-graph-custody.test.ts | 19 + .../governed-proposal-selection.test.ts | 2 +- .../capacity/workdays/lane-supply.test.ts | 17 - .../lifecycle/workday-admission-fence.test.ts | 48 ++ .../workday-assignment-evidence.test.ts | 33 ++ .../workday-recovery-graph-custody.test.ts | 21 + ...kday-terminalization-graph-custody.test.ts | 23 + .../provisioning-demand-selection.test.ts | 23 - .../source-workspace-service.test.ts | 24 +- .../provider-assignment-settlement.test.ts | 8 + .../provider-discussion-suspension.test.ts | 14 +- .../provider-lease-candidates.test.ts | 3 +- ...provider-workflow-living-authority.test.ts | 26 + ...ovider-assignment-activity-custody.test.ts | 14 + .../provider-assignment-operations.test.ts | 6 +- .../provider-assignment-timestamp.test.ts | 4 +- .../agents/project-proposal-types.test.ts | 41 -- .../seeds/project-knowledge-binding.test.ts | 24 +- 166 files changed, 1859 insertions(+), 7873 deletions(-) create mode 100644 drizzle/control-plane/0035_remove_retired_workday_demand_authority.sql create mode 100644 drizzle/control-plane/0036_remove_retired_decision_execution_input.sql create mode 100644 drizzle/control-plane/0037_remove_retired_project_agent_policy.sql create mode 100644 drizzle/control-plane/0038_remove_retired_mode_runs.sql create mode 100644 drizzle/control-plane/0039_remove_workday_capacity_envelopes.sql create mode 100644 drizzle/control-plane/0040_remove_retired_allocation_authority.sql delete mode 100644 src/api/capacity/artifact-manifest.ts delete mode 100644 src/api/capacity/domain/allocation-path.ts delete mode 100644 src/api/capacity/policy/decision-work.ts delete mode 100644 src/api/capacity/policy/planning.ts delete mode 100644 src/api/capacity/policy/revision-cycle.ts delete mode 100644 src/api/capacity/policy/workdays/cooperative-planning.ts delete mode 100644 src/api/capacity/policy/workdays/planning-graph.ts delete mode 100644 src/api/capacity/repositories/capacity/planning/agent-capacity-plan.ts delete mode 100644 src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts delete mode 100644 src/api/capacity/repositories/capacity/workdays/workday-demand.ts delete mode 100644 src/api/capacity/repositories/capacity/workdays/workday-envelope.ts delete mode 100644 src/api/capacity/repositories/capacity/workdays/workday-participation.ts delete mode 100644 src/api/capacity/repositories/support/mode-run.ts delete mode 100644 src/api/capacity/repositories/support/planning-state.ts delete mode 100644 src/api/capacity/repositories/support/structured-estimate.ts delete mode 100644 src/api/capacity/repositories/treedx/graph/decision-work-graph.ts delete mode 100644 src/api/capacity/services/accounts/engineering-source-authority.ts delete mode 100644 src/api/capacity/services/capacity/assignments/admission/assignment-capability-service.ts delete mode 100644 src/api/capacity/services/capacity/assignments/context/assignment-deliverable-service.ts delete mode 100644 src/api/capacity/services/capacity/assignments/lifecycle/assignment-completion-preflight-service.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/assignment-function.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/assignment-planning-output-service.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/execution/assignment-workspace-provisioning.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/feedback/subject.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/support/assignment-denial.ts delete mode 100644 src/api/capacity/services/capacity/assignments/planning/support/assignment-function-support.ts rename src/api/capacity/services/capacity/assignments/planning/support/{assignment-function-store.ts => living-execution-store.ts} (87%) create mode 100644 src/api/capacity/services/capacity/execution/exact-dependency-links.ts delete mode 100644 src/api/capacity/services/capacity/planning/agent-capacity-plan-service.ts delete mode 100644 src/api/capacity/services/capacity/workdays/assignments/workday-summary-query-service.ts delete mode 100644 src/api/capacity/services/capacity/workdays/content/mode-run-activity-event.ts delete mode 100644 src/api/capacity/services/capacity/workdays/content/workday-content-demand-source.ts delete mode 100644 src/api/capacity/services/capacity/workdays/lifecycle/workday-continuation-service.ts delete mode 100644 src/api/capacity/services/capacity/workdays/lifecycle/workday-envelope-terminalization-service.ts delete mode 100644 src/api/capacity/services/projects/projects-core/research-workflow-projection-service.ts delete mode 100644 src/api/capacity/services/support/acting-demand-source.ts delete mode 100644 src/api/capacity/services/support/planning-demand-source.ts delete mode 100644 src/api/capacity/services/support/planning-state-service.ts delete mode 100644 src/api/capacity/services/support/structured-estimate-service.ts delete mode 100644 src/api/capacity/services/treedx/graph/decision-work-graph-service.ts delete mode 100644 src/api/control-plane/repositories/capacity/capacity-plan-service.ts delete mode 100644 src/api/control-plane/repositories/capacity/planning-and-estimate-service.ts delete mode 100644 src/control-plane/seeds/apply-support/projects/projects-core/project-proposal-types.ts create mode 100644 tests/unit/control-plane/capacity/agents/project-agent-artifact-service.test.ts create mode 100644 tests/unit/control-plane/capacity/agents/project-agent-summary-service.test.ts create mode 100644 tests/unit/control-plane/capacity/agents/team-clone-source-snapshot.test.ts delete mode 100644 tests/unit/control-plane/capacity/context/workday-identity-context.test.ts create mode 100644 tests/unit/control-plane/capacity/diagnostics/runtime-diagnostics-assignment-authority.test.ts create mode 100644 tests/unit/control-plane/capacity/execution/admission/living-reservation-record.test.ts delete mode 100644 tests/unit/control-plane/capacity/execution/graph/decision-work-estimate-graph.test.ts create mode 100644 tests/unit/control-plane/capacity/execution/graph/execution-run-assignment-projection.test.ts create mode 100644 tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts create mode 100644 tests/unit/control-plane/capacity/execution/graph/migrations/retired-demand-removal.test.ts create mode 100644 tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts delete mode 100644 tests/unit/control-plane/capacity/planning/workday-content-source.test.ts delete mode 100644 tests/unit/control-plane/capacity/workdays/acting-content-base-ref.test.ts create mode 100644 tests/unit/control-plane/capacity/workdays/assignments/workday-assignment-context-graph-custody.test.ts delete mode 100644 tests/unit/control-plane/capacity/workdays/lane-supply.test.ts create mode 100644 tests/unit/control-plane/capacity/workdays/lifecycle/workday-admission-fence.test.ts create mode 100644 tests/unit/control-plane/capacity/workdays/lifecycle/workday-assignment-evidence.test.ts create mode 100644 tests/unit/control-plane/capacity/workdays/lifecycle/workday-recovery-graph-custody.test.ts create mode 100644 tests/unit/control-plane/capacity/workdays/lifecycle/workday-terminalization-graph-custody.test.ts delete mode 100644 tests/unit/control-plane/capacity/workdays/provisioning-demand-selection.test.ts create mode 100644 tests/unit/control-plane/providers/assignments/provider-workflow-living-authority.test.ts create mode 100644 tests/unit/control-plane/providers/provider-assignment-activity-custody.test.ts delete mode 100644 tests/unit/control-plane/seeds/agents/project-proposal-types.test.ts diff --git a/drizzle/control-plane/0035_remove_retired_workday_demand_authority.sql b/drizzle/control-plane/0035_remove_retired_workday_demand_authority.sql new file mode 100644 index 00000000..79fa9627 --- /dev/null +++ b/drizzle/control-plane/0035_remove_retired_workday_demand_authority.sql @@ -0,0 +1,6 @@ +-- Graph nodes and immutable assignments are the only scheduling authority. +-- Retired demand, participation, and capacity-plan rows cannot be replayed. +DROP TABLE IF EXISTS capacity_workday_participation_entries; +DROP TABLE IF EXISTS capacity_workday_participation_cycles; +DROP TABLE IF EXISTS capacity_workday_demands; +DROP TABLE IF EXISTS agent_capacity_plans; diff --git a/drizzle/control-plane/0036_remove_retired_decision_execution_input.sql b/drizzle/control-plane/0036_remove_retired_decision_execution_input.sql new file mode 100644 index 00000000..c65f3d6f --- /dev/null +++ b/drizzle/control-plane/0036_remove_retired_decision_execution_input.sql @@ -0,0 +1,2 @@ +-- The immutable assignment attempt is the sole execution input authority. +ALTER TABLE capacity_provider_assignments DROP COLUMN IF EXISTS decision_input_json; diff --git a/drizzle/control-plane/0037_remove_retired_project_agent_policy.sql b/drizzle/control-plane/0037_remove_retired_project_agent_policy.sql new file mode 100644 index 00000000..e6420e62 --- /dev/null +++ b/drizzle/control-plane/0037_remove_retired_project_agent_policy.sql @@ -0,0 +1,6 @@ +-- Activity profiles in exact project-library content own handler and mode policy. +ALTER TABLE project_agent_classes DROP COLUMN IF EXISTS allowed_modes_json; +ALTER TABLE project_agent_classes DROP COLUMN IF EXISTS required_capabilities_json; +ALTER TABLE project_agent_classes DROP COLUMN IF EXISTS kernel_profile_json; +ALTER TABLE project_agent_classes DROP COLUMN IF EXISTS kernel_policy_json; +ALTER TABLE project_agent_classes DROP COLUMN IF EXISTS output_contracts_json; diff --git a/drizzle/control-plane/0038_remove_retired_mode_runs.sql b/drizzle/control-plane/0038_remove_retired_mode_runs.sql new file mode 100644 index 00000000..7dff3ae1 --- /dev/null +++ b/drizzle/control-plane/0038_remove_retired_mode_runs.sql @@ -0,0 +1,42 @@ +-- Keep assignment provenance before removing the duplicate execution authority. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM capacity_ledger_entries ledger + JOIN agent_mode_runs run ON run.id = ledger.mode_run_id + WHERE ledger.assignment_id IS NOT NULL AND ledger.assignment_id <> run.provider_assignment_id + ) OR EXISTS ( + SELECT 1 FROM capacity_usage_actuals usage + JOIN agent_mode_runs run ON run.id = usage.mode_run_id + WHERE usage.assignment_id IS NOT NULL AND usage.assignment_id <> run.provider_assignment_id + ) OR EXISTS ( + SELECT 1 FROM capacity_workday_events event + LEFT JOIN agent_mode_runs run ON run.id = event.mode_run_id + WHERE event.mode_run_id IS NOT NULL + AND (run.id IS NULL OR event.assignment_id IS NOT NULL AND event.assignment_id <> run.provider_assignment_id) + ) THEN + RAISE EXCEPTION 'retired mode-run provenance conflicts with assignment custody'; + END IF; +END $$; + +UPDATE capacity_ledger_entries ledger +SET assignment_id = run.provider_assignment_id +FROM agent_mode_runs run +WHERE ledger.mode_run_id = run.id AND ledger.assignment_id IS NULL; + +UPDATE capacity_usage_actuals usage +SET assignment_id = run.provider_assignment_id +FROM agent_mode_runs run +WHERE usage.mode_run_id = run.id AND usage.assignment_id IS NULL; + +UPDATE capacity_workday_events event +SET assignment_id = run.provider_assignment_id +FROM agent_mode_runs run +WHERE event.mode_run_id = run.id AND event.assignment_id IS NULL; + +ALTER TABLE capacity_ledger_entries DROP CONSTRAINT IF EXISTS fk_capacity_ledger_mode_run; +ALTER TABLE capacity_usage_actuals DROP CONSTRAINT IF EXISTS fk_capacity_usage_actuals_mode_run; +ALTER TABLE capacity_ledger_entries DROP COLUMN IF EXISTS mode_run_id; +ALTER TABLE capacity_usage_actuals DROP COLUMN IF EXISTS mode_run_id; +ALTER TABLE capacity_workday_events DROP COLUMN IF EXISTS mode_run_id; +DROP TABLE agent_mode_runs; diff --git a/drizzle/control-plane/0039_remove_workday_capacity_envelopes.sql b/drizzle/control-plane/0039_remove_workday_capacity_envelopes.sql new file mode 100644 index 00000000..c4df27a6 --- /dev/null +++ b/drizzle/control-plane/0039_remove_workday_capacity_envelopes.sql @@ -0,0 +1,12 @@ +-- A live envelope cannot be discarded while it might still admit work. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM workday_capacity_envelopes + WHERE status IN ('draft', 'queued', 'active', 'paused') + ) THEN + RAISE EXCEPTION 'retired workday envelopes must be drained before migration'; + END IF; +END $$; + +DROP TABLE workday_capacity_envelopes; diff --git a/drizzle/control-plane/0040_remove_retired_allocation_authority.sql b/drizzle/control-plane/0040_remove_retired_allocation_authority.sql new file mode 100644 index 00000000..39405fc7 --- /dev/null +++ b/drizzle/control-plane/0040_remove_retired_allocation_authority.sql @@ -0,0 +1,23 @@ +-- Allocation is derived from the living graph and workday policy. Retired +-- allocation sets and decision graphs must not remain writable authorities. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM capacity_workday_runs WHERE status IN ('queued', 'running', 'degraded')) THEN + RAISE EXCEPTION 'drain active workdays before removing retired allocation authority'; + END IF; + IF EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE status IN ('pending', 'leased', 'running')) THEN + RAISE EXCEPTION 'drain active assignments before removing retired allocation authority'; + END IF; +END $$; + +ALTER TABLE capacity_provider_assignments DROP CONSTRAINT IF EXISTS fk_capacity_provider_assignments_allocation; +ALTER TABLE capacity_reservations DROP CONSTRAINT IF EXISTS fk_capacity_reservations_allocation; +ALTER TABLE capacity_reservations DROP CONSTRAINT IF EXISTS chk_capacity_reservations_allocation_version; + +ALTER TABLE capacity_provider_assignments DROP COLUMN allocation_set_id; +ALTER TABLE capacity_reservations DROP COLUMN allocation_set_id; +ALTER TABLE capacity_reservations DROP COLUMN allocation_version; +ALTER TABLE capacity_reservations DROP COLUMN allocation_slice_ids_json; + +DROP TABLE decision_assignment_graphs; +DROP TABLE capacity_allocation_sets; diff --git a/src/api/capacity/artifact-manifest.ts b/src/api/capacity/artifact-manifest.ts deleted file mode 100644 index 34dd65fc..00000000 --- a/src/api/capacity/artifact-manifest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { AgentArtifactManifest } from '@treeseed/sdk/agent-capacity'; - -function ids(value: unknown) { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && Boolean(entry.trim())).map((entry) => entry.replace(/_/gu, '-')) : []; } - -export function validateAgentArtifactManifest(manifest: AgentArtifactManifest, expected: { publishedSignals?: unknown; artifactContracts?: unknown; signalContracts?: unknown } = {}) { - if (!manifest || manifest.schemaVersion !== 1 || !manifest.assignmentId || !manifest.modeRunId || !manifest.teamId || !manifest.projectId) return { ok: false as const, reason: 'Artifact manifest identity and scope are required.' }; - if ((manifest.artifactReferences ?? []).some((reference) => reference.contract !== 'treeseed.artifact-ref/v1' || (typeof reference.sha256 === 'string' && !/^[0-9a-f]{64}$/u.test(reference.sha256)) || 'signedUrl' in reference || 'credentials' in reference)) return { ok: false as const, reason: 'Artifact references must be stable, digest-addressed, and secret-free.' }; - if ((manifest.mutationReceipts ?? []).some((receipt) => receipt.assignmentId !== manifest.assignmentId || receipt.modeRunId !== manifest.modeRunId || receipt.teamId !== manifest.teamId || receipt.projectId !== manifest.projectId)) return { ok: false as const, reason: 'Artifact mutation receipt is outside the manifest authority scope.' }; - if (!Array.isArray(manifest.citations) || manifest.citations.some((citation) => !citation || typeof citation !== 'object')) return { ok: false as const, reason: 'Artifact manifest citations are invalid.' }; - if (manifest.status !== 'completed') return { ok: true as const }; - const producedSignals = new Set(manifest.signals.map((signal) => signal.code.replace(/_/gu, '-'))); const missingSignals = [...ids(expected.publishedSignals), ...ids(expected.signalContracts)].filter((id) => !producedSignals.has(id)); - if (missingSignals.length) return { ok: false as const, reason: `Completed agent execution omitted validated publications: ${missingSignals.map((id) => `signal:${id}`).join(', ')}.` }; - const producedArtifacts = new Set(manifest.contentReferences.map((reference) => reference.artifactKind?.replace(/_/gu, '-')).filter(Boolean)); const missingArtifacts = ids(expected.artifactContracts).filter((id) => !producedArtifacts.has(id)); - if (missingArtifacts.length) return { ok: false as const, reason: `Completed agent execution omitted validated publications: ${missingArtifacts.map((id) => `artifact:${id}`).join(', ')}.` }; - if (manifest.contentReferences.some((reference) => reference.model === 'note' && (!reference.subjectId || !reference.subjectField))) return { ok: false as const, reason: 'Completed note receipt is missing its validated subject link.' }; - if (manifest.contentReferences.length || manifest.sourceWorktree || manifest.verification.length || manifest.controlPlaneReferences?.length) return { ok: true as const }; - return { ok: false as const, reason: 'Completed agent execution did not produce a TreeDX content receipt, durable control-plane output, source worktree change, or verification result.' }; -} diff --git a/src/api/capacity/control-plane.ts b/src/api/capacity/control-plane.ts index 66f2c763..57707201 100644 --- a/src/api/capacity/control-plane.ts +++ b/src/api/capacity/control-plane.ts @@ -4,7 +4,6 @@ import { ProviderControlPlane } from './provider-control-plane.ts'; import { CapacityLedgerRepository } from './repositories/capacity/accounting/ledger.ts'; import { CapacityReservationRepository } from './repositories/capacity/accounting/reservation.ts'; import { listRecentTaskUsageActuals,listTaskUsageActualsPage as readTaskUsageActualsPage } from './repositories/capacity/accounting/task-usage.ts'; -import { WorkdayCapacityEnvelopeRepository } from './repositories/capacity/workdays/workday-envelope.ts'; import type { DurableCapacityWorkdayRun } from './repositories/capacity/workdays/workday-run.ts'; import { CapacityWorkdayRunRepository } from './repositories/capacity/workdays/workday-run.ts'; import { CapacityRuntimeEvidenceRepository } from './repositories/runtime/runtime-evidence.ts'; @@ -20,12 +19,8 @@ import { CapacityOperationsQueryService } from './services/capacity/capacity-cor import { NativeCapacityService } from './services/capacity/capacity-core/native-capacity-service.ts'; import { CapacitySummaryService } from './services/capacity/observability/capacity-summary-service.ts'; import { buildProjectCapacityDiagnostics } from './services/capacity/observability/project-capacity-diagnostics-service.ts'; -import { AgentCapacityPlanService } from './services/capacity/planning/agent-capacity-plan-service.ts'; -import type { WorkdaySummaryOptions } from './services/capacity/workdays/assignments/workday-summary-query-service.ts'; -import { buildWorkdayCapacitySummary } from './services/capacity/workdays/assignments/workday-summary-query-service.ts'; import { CapacityWorkdayEventService } from './services/capacity/workdays/content/workday-event-service.ts'; import { terminalizeCapacityWorkdayAssignments as terminalizeWorkdayAssignments } from './services/capacity/workdays/lifecycle/workday-assignment-terminalization-service.ts'; -import { closeCapacityWorkdayAdmission as closeWorkdayAdmission,terminalizeCapacityWorkdayEnvelopes as terminalizeWorkdayEnvelopes } from './services/capacity/workdays/lifecycle/workday-envelope-terminalization-service.ts'; import { fenceCapacityWorkdayAdmission as fenceWorkdayAdmission } from './services/capacity/workdays/lifecycle/workday-admission-fence-service.ts'; import { maintainCapacityWorkdayRuns as maintainWorkdayRuns } from './services/capacity/workdays/lifecycle/workday-recovery-service.ts'; import type { WorkdayProject } from './services/capacity/workdays/policy/workday-project-policy.ts'; @@ -40,9 +35,6 @@ import { collectProjectAgentArtifacts } from './services/projects/projects-core/ import { listProjectDeletionBlockers } from './services/projects/projects-core/project-deletion-blocker-service.ts'; import { buildProjectCapacityRuntimeDiagnostics } from './services/runtime/runtime-diagnostics-query-service.ts'; import { maintainCapacityRuntimeRetention as maintainRuntimeRetention } from './services/runtime/runtime-retention-service.ts'; -import { PlanningStateService } from './services/support/planning-state-service.ts'; -import { StructuredAgentEstimateService } from './services/support/structured-estimate-service.ts'; -import { DecisionWorkGraphService } from './services/treedx/graph/decision-work-graph-service.ts'; export interface CapacityControlPlaneHost extends CapacityGovernanceDatabase { config: Record; createTeam(input: Record): Promise | null>; @@ -61,13 +53,8 @@ export type CapacityControlPlaneStore = PublicSurface & PublicSurface & CapacityControlPlaneHost; type CapacityServiceStore = CapacityControlPlaneStore - & ConstructorParameters[0] - & ConstructorParameters[0] - & ConstructorParameters[0] - & ConstructorParameters[0] & ConstructorParameters[0] & ConstructorParameters[0] - & Parameters[0] & Parameters[0] & Parameters[0] & Parameters[0] @@ -96,22 +83,12 @@ function isoNow() { class CapacityControlPlane { private capacityContext!: CapacityServiceStore; private capacityRuntimeEvidenceRepository!: CapacityRuntimeEvidenceRepository; - private workdayCapacityEnvelopeRepository!: WorkdayCapacityEnvelopeRepository; - private agentCapacityPlanService!: AgentCapacityPlanService; - private planningStateService!: PlanningStateService; - private structuredAgentEstimateService!: StructuredAgentEstimateService; - private decisionWorkGraphService!: DecisionWorkGraphService; private researchWorkflowService!: ResearchWorkflowService; constructor(private readonly host: CapacityControlPlaneHost) {} attach(context: CapacityControlPlaneStore) { const serviceContext = context as unknown as CapacityServiceStore; this.capacityContext = serviceContext; this.capacityRuntimeEvidenceRepository = new CapacityRuntimeEvidenceRepository(serviceContext); - this.workdayCapacityEnvelopeRepository = new WorkdayCapacityEnvelopeRepository(serviceContext); - this.agentCapacityPlanService = new AgentCapacityPlanService(serviceContext); - this.planningStateService = new PlanningStateService(serviceContext); - this.structuredAgentEstimateService = new StructuredAgentEstimateService(serviceContext); - this.decisionWorkGraphService = new DecisionWorkGraphService(serviceContext); this.researchWorkflowService = new ResearchWorkflowService(serviceContext); } ensureInitialized() { return this.host.ensureInitialized(); } @@ -122,108 +99,10 @@ class CapacityControlPlane { scopeHash(value: unknown = {}) { return `scope_${createHash('sha256').update(JSON.stringify(value, Object.keys(objectValue(value)).sort())).digest('hex').slice(0, 16)}`; } - async upsertDecisionPlanningStatus(input: Parameters[0]) { - return this.planningStateService.upsertPlanningStatus(input); - } - async getDecisionPlanningStatus(decisionId: string) { - return this.planningStateService.getPlanningStatus(decisionId); - } - async createPlanningInputRequest(decisionId: string, input: Parameters[1]) { - return this.planningStateService.createPlanningRequest(decisionId, input); - } - async listPlanningInputRequests(decisionId: string) { - return this.planningStateService.listPlanningRequests(decisionId); - } - async createDecisionExecutionInput(decisionId: string, input: Parameters[1]) { - return this.planningStateService.createExecutionInput(decisionId, input); - } - async listDecisionExecutionInputs(decisionId: string, filters: Parameters[1] = {}) { - return this.planningStateService.listExecutionInputs(decisionId, filters); - } - async getDecisionExecutionInput(inputId: string) { - return this.planningStateService.getExecutionInput(inputId); - } - async updateDecisionExecutionInputStatus(inputId: string, status: Parameters[1], input: Parameters[2] = {}) { - return this.planningStateService.transitionExecutionInput(inputId, status, input); - } - async createStructuredAgentEstimate(decisionId: string, input: Parameters[1]) { - return this.structuredAgentEstimateService.create(decisionId, input); - } - async listStructuredAgentEstimatesForDecision(decisionId: string, filters: { status?: Parameters[1] } = {}) { - return this.structuredAgentEstimateService.listDecision(decisionId, filters.status ?? null); - } - async getStructuredAgentEstimate(estimateId: string) { - return this.structuredAgentEstimateService.get(estimateId); - } - async updateStructuredAgentEstimateStatus(estimateId: string, status: Parameters[1], input: Parameters[2] = {}) { - if (status !== 'accepted' && status !== 'rejected') throw new Error(`Unsupported structured estimate transition ${status}.`); - return this.structuredAgentEstimateService.transition(estimateId, status, input); - } - async acceptStructuredAgentEstimate(estimateId: string, input: Parameters[2] = {}) { - return this.updateStructuredAgentEstimateStatus(estimateId, 'accepted', input); - } - async rejectStructuredAgentEstimate(estimateId: string, input: Parameters[2] = {}) { - return this.updateStructuredAgentEstimateStatus(estimateId, 'rejected', input); - } - async createDecisionAssignmentGraph(decisionId: string, input: JsonRecord = {}) { - return this.decisionWorkGraphService.compile(decisionId, input); - } - async getDecisionAssignmentGraph(graphId: string) { - return this.decisionWorkGraphService.getGraph(graphId); - } - async listDecisionAssignmentGraphsForDecision(decisionId: string, filters: PageFilters = {}) { - return this.decisionWorkGraphService.listGraphs(decisionId, filters.active); - } - async activateDecisionAssignmentGraphVersion(graphId: string) { - return this.decisionWorkGraphService.activate(graphId); - } - async getDeliverableContract(contractId: string) { - return this.decisionWorkGraphService.getContract(contractId); - } - async getDeliverableManifest(manifestId: string) { - return this.decisionWorkGraphService.getManifest(manifestId); - } - async submitDeliverableManifest(contractId: string, input: JsonRecord = {}) { - return this.decisionWorkGraphService.submitManifest(contractId, input); - } - async markDeliverableContractApproved(contractId: string, input: JsonRecord = {}) { - return this.decisionWorkGraphService.transitionContract(contractId, 'approved', input); - } - async markDeliverableContractRejected(contractId: string, input: JsonRecord = {}) { - return this.decisionWorkGraphService.transitionContract(contractId, 'rejected', input); - } async createResearchWorkflow(projectId: string, input: JsonRecord = {}) { return this.researchWorkflowService.create(projectId, input); } async getResearchWorkflow(id: string) { return this.researchWorkflowService.get(id); } async listResearchWorkflows(projectId: string, filters: PageFilters = {}) { return this.researchWorkflowService.list(projectId, filters.status ?? undefined); } async completeResearchWorkflowStage(id: string, stage: string, input: JsonRecord = {}) { return this.researchWorkflowService.completeStage(id, stage, input); } - async createAgentCapacityPlan(decisionId: string, input: Parameters[1]) { - return this.agentCapacityPlanService.create(decisionId, input); - } - async listAgentCapacityPlans(decisionId: string, filters: Parameters[1] = {}) { - return this.agentCapacityPlanService.list(decisionId, filters); - } - async getAgentCapacityPlan(planId: string) { - return this.agentCapacityPlanService.get(planId); - } - async updateAgentCapacityPlanStatus(planId: string, status: Parameters[1], input: Parameters[2] = {}) { - return this.agentCapacityPlanService.transition(planId, status, input); - } - async createWorkdayCapacityEnvelope(input: Parameters[0], idempotencyKey?: string | null) { - return this.workdayCapacityEnvelopeRepository.create(input, idempotencyKey); - } - async getWorkdayCapacityEnvelope(workdayId: string) { - return this.workdayCapacityEnvelopeRepository.get(workdayId); - } - async listWorkdayCapacityEnvelopes(projectId: string, filters: Parameters[1] = {}) { - return this.workdayCapacityEnvelopeRepository.list(projectId, filters); - } - async updateWorkdayCapacityEnvelopeState(workdayId: string, status: string, idempotencyKey?: string | null) { - return this.workdayCapacityEnvelopeRepository.transition(workdayId, status as Parameters[1], idempotencyKey); - } - async getWorkdayCapacitySummary(workdayId: string, options: WorkdaySummaryOptions = {}) { - await this.ensureInitialized(); - return buildWorkdayCapacitySummary(this.capacityContext, workdayId, options); - } async recordProviderAssignmentExplanation(teamId: string, assignmentId: string, input: Parameters[3]) { return persistProviderAssignmentExplanation(this.capacityContext, teamId, assignmentId, input); } @@ -261,12 +140,6 @@ class CapacityControlPlane { async preflightCapacityWorkdayRun(run: DurableCapacityWorkdayRun) { return preflightWorkdayRun(this.capacityContext, run); } - async terminalizeCapacityWorkdayEnvelopes(teamId: string, runId: string, status: string) { - return terminalizeWorkdayEnvelopes(this.capacityContext, teamId, runId, status); - } - async closeCapacityWorkdayAdmission(teamId: string, runId: string) { - return closeWorkdayAdmission(this.capacityContext, teamId, runId); - } async fenceCapacityWorkdayAdmission(teamId: string, runId: string) { return fenceWorkdayAdmission(this.capacityContext, teamId, runId); } @@ -365,8 +238,8 @@ class CapacityControlPlane { async updateCapacityWorkdaySchedule(teamId: string, scheduleId: string, input: JsonRecord = {}) { return new CapacityWorkdayScheduleService(this.capacityContext).update(teamId, scheduleId, input); } async tickCapacityWorkdaySchedule(teamId: string, scheduleId: string, now?: string) { return new CapacityWorkdayScheduleService(this.capacityContext).tick(teamId, scheduleId, now); } async tickDueCapacityWorkdaySchedules(now?: string) { return new CapacityWorkdayScheduleService(this.capacityContext).tickDue(now); } - async collectControlPlaneGeneratedArtifacts(projectId: string, modeRuns: JsonRecord[] = []) { - return collectProjectAgentArtifacts(this.capacityContext, projectId, modeRuns.length ? modeRuns : undefined); + async collectControlPlaneGeneratedArtifacts(projectId: string) { + return collectProjectAgentArtifacts(this.capacityContext, projectId); } async getProjectAgentsSummary(projectId: string, principal: unknown = null) { return buildProjectAgentSummary(this.capacityContext, projectId, principal); diff --git a/src/api/capacity/domain/allocation-path.ts b/src/api/capacity/domain/allocation-path.ts deleted file mode 100644 index e22f61e6..00000000 --- a/src/api/capacity/domain/allocation-path.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { CapacityAllocationSetV2 } from '@treeseed/sdk/agent-capacity'; -import { CapacityGovernanceError } from '../database.ts'; - -function exactlyOne( - allocation: CapacityAllocationSetV2, - scope: CapacityAllocationSetV2['slices'][number]['scope'], - targetId: string, - parentSliceId: string | null, -) { - const matches = allocation.slices.filter((slice) => slice.scope === scope - && slice.targetId === targetId - && (slice.parentSliceId ?? null) === parentSliceId); - if (matches.length > 1) { - throw new CapacityGovernanceError('capacity_allocation_path_ambiguous', `Allocation ${allocation.id} contains duplicate ${scope} slices for ${targetId}.`, 409, { - allocationSetId: allocation.id, scope, targetId, parentSliceId, - }); - } - return matches[0] ?? null; -} - -export function resolveCapacityAllocationPath( - allocation: CapacityAllocationSetV2, - request: { projectId: string; projectAgentClassId: string; mode: 'planning' | 'acting' }, -): string[] { - const project = exactlyOne(allocation, 'project', request.projectId, null); - if (!project) { - throw new CapacityGovernanceError('capacity_allocation_project_missing', 'Active allocation does not contain the requested project.', 409, { - allocationSetId: allocation.id, projectId: request.projectId, - }); - } - const path = [project.id]; - const agentClass = exactlyOne(allocation, 'agent-class', request.projectAgentClassId, project.id); - if (!agentClass) { - if (allocation.slices.some((slice) => slice.scope === 'agent-class' && slice.parentSliceId === project.id)) { - throw new CapacityGovernanceError('capacity_allocation_agent_class_missing', 'Active allocation does not contain the requested agent class under its project.', 409, { - allocationSetId: allocation.id, projectId: request.projectId, projectAgentClassId: request.projectAgentClassId, - }); - } - return path; - } - path.push(agentClass.id); - const mode = exactlyOne(allocation, 'mode', request.mode, agentClass.id); - if (!mode && allocation.slices.some((slice) => slice.scope === 'mode' && slice.parentSliceId === agentClass.id)) { - throw new CapacityGovernanceError('capacity_allocation_mode_missing', 'Active allocation does not contain the requested mode under its agent class.', 409, { - allocationSetId: allocation.id, projectAgentClassId: request.projectAgentClassId, mode: request.mode, - }); - } - if (mode) path.push(mode.id); - return path; -} diff --git a/src/api/capacity/policy/artifact-mutation-receipt.ts b/src/api/capacity/policy/artifact-mutation-receipt.ts index 04702f1d..3c84564f 100644 --- a/src/api/capacity/policy/artifact-mutation-receipt.ts +++ b/src/api/capacity/policy/artifact-mutation-receipt.ts @@ -19,7 +19,6 @@ export interface ArtifactMutationReceipt { executionMode: AgentWorkExecutionMode; upstreamMutationPolicy: UpstreamMutationPolicy; assignmentId: string; - modeRunId: string; teamId: string; projectId: string; baseRef: string; @@ -54,7 +53,7 @@ export function validateArtifactMutationReceipt(value: unknown) { const receipt = value && typeof value === 'object' && !Array.isArray(value) ? value as Partial : {}; const problems: string[] = []; if (receipt.schemaVersion !== ARTIFACT_MUTATION_RECEIPT_SCHEMA) problems.push('schemaVersion'); - for (const key of ['id', 'assignmentId', 'modeRunId', 'teamId', 'projectId'] as const) if (!exactRef(receipt[key])) problems.push(key); + for (const key of ['id', 'assignmentId', 'teamId', 'projectId'] as const) if (!exactRef(receipt[key])) problems.push(key); if (!['treedx-content', 'source-checkpoint'].includes(String(receipt.kind))) problems.push('kind'); if (!['provisional', 'integrated'].includes(String(receipt.phase))) problems.push('phase'); if (!['simulation', 'production'].includes(String(receipt.executionMode))) problems.push('executionMode'); diff --git a/src/api/capacity/policy/authority/agent-authority-presets.ts b/src/api/capacity/policy/authority/agent-authority-presets.ts index 949a72d2..98391368 100644 --- a/src/api/capacity/policy/authority/agent-authority-presets.ts +++ b/src/api/capacity/policy/authority/agent-authority-presets.ts @@ -7,7 +7,7 @@ export const AGENT_AUTHORITY_PRESET_IDS = [ export type AgentAuthorityPresetId = (typeof AGENT_AUTHORITY_PRESET_IDS)[number]; -const OPERATIONAL_TOOLS = ['treeseed.status','treeseed.assignment_activity','treeseed.assignment_plan','treeseed.assignment_status_update','treeseed.assignment_summary']; +const OPERATIONAL_TOOLS = ['treeseed.status','treeseed.assignment_activity']; const MESSAGING_TOOLS = ['treeseed.discussion.read','treeseed.discussion.follow','treeseed.discussion.respond','treeseed.discussion.request_handoff']; const CONTENT_TOOLS = ['treeseed.content.describe','treeseed.content.query','treeseed.content.read','treeseed.content.create','treeseed.content.update','treeseed.content.link','treeseed.content.validate','treeseed.content.commit']; const SOURCE_READ_TOOLS = ['treeseed.repository.read_file','treeseed.repository.search','treeseed.changed_paths','treeseed.verify']; @@ -48,14 +48,13 @@ export interface CompiledAgentAuthoritySnapshot { diagnostics: string[]; } -const OPERATIONAL_MODELS = new Set(['assignment_plan','assignment_status','assignment_summary']); const MUTABLE_MODELS:Record>={ - planning:new Set(['proposal','question','note','discussion_message',...OPERATIONAL_MODELS]), - estimating:new Set(['note','question','discussion_message',...OPERATIONAL_MODELS]), + planning:new Set(['proposal','question','note','discussion_message']), + estimating:new Set(['note','question','discussion_message']), acting:new Set(), - reviewing:new Set(['note','question','discussion_message',...OPERATIONAL_MODELS]), - reporting:new Set(['note','discussion_message',...OPERATIONAL_MODELS]), - chat:new Set(['proposal','question','note','discussion_message',...OPERATIONAL_MODELS]), + reviewing:new Set(['note','question','discussion_message']), + reporting:new Set(['note','discussion_message']), + chat:new Set(['proposal','question','note','discussion_message']), }; function boundedPermissions(activityType:AgentActivityType,permissions:AgentActivityPermissions|undefined) { diff --git a/src/api/capacity/policy/decision-work.ts b/src/api/capacity/policy/decision-work.ts deleted file mode 100644 index f31e667d..00000000 --- a/src/api/capacity/policy/decision-work.ts +++ /dev/null @@ -1,395 +0,0 @@ -import type { -AgentCapacityContractDiagnostic, -AgentCapacityContractValidationResult, -ContentRef, -DecisionAssignmentGraph, -DecisionAssignmentGraphCompileResult, -DecisionAssignmentGraphEdge, -DecisionAssignmentGraphNode, -DecisionDependencySpec, -DeliverableContract, -DeliverableManifest, -StructuredAgentEstimate, -} from '@treeseed/sdk/agent-capacity'; - -function uniqueStrings(values: string[]): string[] { - return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); -} - -function diagnostic( - diagnostics: AgentCapacityContractDiagnostic[], - code: string, - message: string, - path?: string, - severity: AgentCapacityContractDiagnostic['severity'] = 'error', -) { - diagnostics.push({ severity, code, message, path }); -} - -function validateNonEmptyString(diagnostics: AgentCapacityContractDiagnostic[], value: unknown, field: string, path = field) { - if (typeof value !== 'string' || !value.trim()) diagnostic(diagnostics, 'required_string_missing', `${field} is required.`, path); -} - -function validateNonNegativeNumber(diagnostics: AgentCapacityContractDiagnostic[], value: unknown, field: string, path = field) { - if (!Number.isFinite(Number(value)) || Number(value) < 0) { - diagnostic(diagnostics, 'non_negative_number_required', `${field} must be a non-negative number.`, path); - } -} - -function validationResult(diagnostics: AgentCapacityContractDiagnostic[]): AgentCapacityContractValidationResult { - return { ok: diagnostics.every((entry) => entry.severity !== 'error'), diagnostics }; -} - -function edgeTypeForDependency(dependency: DecisionDependencySpec): DecisionAssignmentGraphEdge['edgeType'] { - if (dependency.requiredBefore === 'complete') return 'blocks-completion'; - if (dependency.requiredBefore === 'release') return 'blocks-release'; - return 'blocks-start'; -} - -export function validateDecisionDependencySpec(dependency: DecisionDependencySpec, path = 'dependency'): AgentCapacityContractValidationResult { - const diagnostics: AgentCapacityContractDiagnostic[] = []; - validateNonEmptyString(diagnostics, dependency.id, 'id', `${path}.id`); - if (!['artifact', 'capability', 'decision', 'external-resource', 'human-input'].includes(dependency.type)) { - diagnostic(diagnostics, 'invalid_dependency_type', `Dependency ${dependency.id || ''} has an invalid type.`, `${path}.type`); - } - if (!['start', 'complete', 'review', 'release'].includes(dependency.requiredBefore)) { - diagnostic(diagnostics, 'invalid_dependency_required_before', `Dependency ${dependency.id || ''} has an invalid requiredBefore value.`, `${path}.requiredBefore`); - } - if (dependency.type === 'artifact' && !dependency.deliverableType) diagnostic(diagnostics, 'artifact_dependency_missing_deliverable_type', 'Artifact dependencies must declare deliverableType.', `${path}.deliverableType`); - if (dependency.type === 'capability' && !dependency.capability) diagnostic(diagnostics, 'capability_dependency_missing_capability', 'Capability dependencies must declare an exact standardized capability.', `${path}.capability`); - if (dependency.capability && !/^(?:treeseed|provider\.[a-z0-9-]+)\.[a-z0-9.-]+$/u.test(dependency.capability)) diagnostic(diagnostics, 'capability_dependency_invalid', 'Capabilities must use a TreeSeed or provider namespace.', `${path}.capability`); - if (dependency.type === 'human-input') { - const policy = dependency.humanInputPolicy; - if (!policy || !['team-human', 'any-human', 'any-human-or-agent'].includes(policy.requiredFrom)) { - diagnostic(diagnostics, 'human_input_policy_missing', 'Human-input dependencies must declare a valid humanInputPolicy.requiredFrom.', `${path}.humanInputPolicy.requiredFrom`); - } - } - return validationResult(diagnostics); -} - -export function validateStructuredAgentEstimate(estimate: StructuredAgentEstimate): AgentCapacityContractValidationResult { - const diagnostics: AgentCapacityContractDiagnostic[] = []; - if (estimate.schemaVersion !== undefined && ![1,2,3].includes(estimate.schemaVersion)) diagnostic(diagnostics, 'estimate_schema_version_invalid', 'schemaVersion must be 1, 2, or 3.', 'schemaVersion'); - validateNonEmptyString(diagnostics, estimate.id, 'id'); - validateNonEmptyString(diagnostics, estimate.teamId, 'teamId'); - validateNonEmptyString(diagnostics, estimate.projectId, 'projectId'); - validateNonEmptyString(diagnostics, estimate.agentClass, 'agentClass'); - if (!estimate.decisionId && !estimate.proposalId) diagnostic(diagnostics, 'estimate_missing_subject', 'Structured estimates must reference a decisionId or proposalId.', 'decisionId'); - validateNonNegativeNumber(diagnostics, estimate.minSeconds, 'minSeconds'); - validateNonNegativeNumber(diagnostics, estimate.expectedSeconds, 'expectedSeconds'); - validateNonNegativeNumber(diagnostics, estimate.maxSeconds, 'maxSeconds'); - if (Number(estimate.minSeconds) > Number(estimate.expectedSeconds) || Number(estimate.expectedSeconds) > Number(estimate.maxSeconds)) { - diagnostic(diagnostics, 'estimate_time_bounds_invalid', 'Estimate time bounds must satisfy min <= expected <= max.', 'expectedSeconds'); - } - if (!['low', 'medium', 'high'].includes(estimate.confidence)) diagnostic(diagnostics, 'estimate_confidence_invalid', 'Estimate confidence must be low, medium, or high.', 'confidence'); - if (!['low', 'medium', 'high'].includes(estimate.riskLevel)) diagnostic(diagnostics, 'estimate_risk_level_invalid', 'Estimate riskLevel must be low, medium, or high.', 'riskLevel'); - if (estimate.schemaVersion === 2 || estimate.schemaVersion === 3) { - const breakdown=estimate.workBreakdown; - if(!breakdown) diagnostic(diagnostics,'estimate_work_breakdown_required','Version 2 estimates require a complete work breakdown.','workBreakdown'); - else { - for(const field of ['preparationSeconds','implementationSeconds','verificationSeconds','independentReviewSeconds','revisionSeconds','revisionVerificationSeconds','finalReviewSeconds','reportingSeconds','reserveSeconds'] as const) validateNonNegativeNumber(diagnostics,breakdown[field],`workBreakdown.${field}`); - if(!Number.isInteger(breakdown.expectedRevisionCycles)||breakdown.expectedRevisionCycles<0) diagnostic(diagnostics,'estimate_revision_cycles_invalid','expectedRevisionCycles must be a non-negative integer.','workBreakdown.expectedRevisionCycles'); - if(breakdown.independentReviewSeconds<=0||breakdown.finalReviewSeconds<=0||breakdown.reportingSeconds<=0) diagnostic(diagnostics,'estimate_governed_phases_required','Independent review, final review, and reporting must receive positive capacity.','workBreakdown'); - const allocated=breakdown.preparationSeconds+breakdown.implementationSeconds+breakdown.verificationSeconds+breakdown.independentReviewSeconds+breakdown.revisionSeconds+breakdown.revisionVerificationSeconds+breakdown.finalReviewSeconds+breakdown.reportingSeconds+breakdown.reserveSeconds; - if(allocated!==estimate.expectedSeconds) diagnostic(diagnostics,'estimate_work_breakdown_total_invalid','Work breakdown seconds must sum to expectedSeconds.','workBreakdown'); - } - } - if (estimate.schemaVersion === 3) { - for (const [field, revision] of [['proposalRevision', estimate.proposalRevision], ['decisionRevision', estimate.decisionRevision]] as const) { - if (!revision || !revision.id || !Number.isInteger(revision.version) || revision.version < 1 || !revision.digest) diagnostic(diagnostics, 'estimate_exact_governance_revision_required', `Version 3 estimates require exact ${field}.`, field); - } - if (!estimate.groupSnapshot) diagnostic(diagnostics, 'estimate_group_snapshot_required', 'Version 3 estimates require the frozen group membership snapshot.', 'groupSnapshot'); - if (!estimate.agentDefinitionRevision?.id || !Number.isInteger(estimate.agentDefinitionRevision.revision) || estimate.agentDefinitionRevision.revision < 1 || !estimate.agentDefinitionRevision.digest) diagnostic(diagnostics, 'estimate_agent_definition_revision_required', 'Version 3 estimates require exact agent-definition provenance.', 'agentDefinitionRevision'); - if (!estimate.requiredProviderCapabilities?.length) diagnostic(diagnostics, 'estimate_provider_capabilities_required', 'Version 3 estimates require provider capabilities.', 'requiredProviderCapabilities'); - if (!estimate.acceptableProviderClasses?.length) diagnostic(diagnostics, 'estimate_provider_classes_required', 'Version 3 estimates require acceptable provider classes.', 'acceptableProviderClasses'); - for (const [index, range] of (estimate.providerNativeRanges ?? []).entries()) { - if (!range.unit || !Number.isFinite(range.minimum) || !Number.isFinite(range.expected) || !Number.isFinite(range.maximum) || range.minimum < 0 || range.minimum > range.expected || range.expected > range.maximum) diagnostic(diagnostics, 'estimate_provider_native_range_invalid', 'Provider-native ranges require unit and minimum <= expected <= maximum.', `providerNativeRanges.${index}`); - } - } - for (const [index, dependency] of (estimate.dependencies ?? []).entries()) { - diagnostics.push(...validateDecisionDependencySpec(dependency, `dependencies.${index}`).diagnostics); - } - return validationResult(diagnostics); -} - -export function validateDeliverableContract(contract: DeliverableContract): AgentCapacityContractValidationResult { - const diagnostics: AgentCapacityContractDiagnostic[] = []; - validateNonEmptyString(diagnostics, contract.id, 'id'); - validateNonEmptyString(diagnostics, contract.teamId, 'teamId'); - validateNonEmptyString(diagnostics, contract.projectId, 'projectId'); - validateNonEmptyString(diagnostics, contract.decisionId, 'decisionId'); - validateNonEmptyString(diagnostics, contract.deliverableType, 'deliverableType'); - if (!Array.isArray(contract.producerAgentClasses) || contract.producerAgentClasses.length === 0) diagnostic(diagnostics, 'deliverable_contract_missing_producer', 'Deliverable contracts must declare at least one producerAgentClass.', 'producerAgentClasses'); - if (!['required', 'draft', 'submitted', 'approved', 'rejected', 'stale'].includes(contract.status)) diagnostic(diagnostics, 'deliverable_contract_status_invalid', 'Deliverable contract has an invalid status.', 'status'); - return validationResult(diagnostics); -} - -export function validateDeliverableManifest(manifest: DeliverableManifest): AgentCapacityContractValidationResult { - const diagnostics: AgentCapacityContractDiagnostic[] = []; - validateNonEmptyString(diagnostics, manifest.id, 'id'); - validateNonEmptyString(diagnostics, manifest.deliverableContractId, 'deliverableContractId'); - validateNonEmptyString(diagnostics, manifest.projectId, 'projectId'); - validateNonEmptyString(diagnostics, manifest.decisionId, 'decisionId'); - if (!Array.isArray(manifest.producedRefs) || manifest.producedRefs.length === 0) diagnostic(diagnostics, 'deliverable_manifest_missing_refs', 'Deliverable manifests must map the contract to at least one produced content ref.', 'producedRefs'); - validateNonEmptyString(diagnostics, manifest.summary, 'summary'); - if (manifest.sourceAuthority) { - validateNonEmptyString(diagnostics, manifest.sourceAuthority.assignmentId, 'sourceAuthority.assignmentId'); - validateNonEmptyString(diagnostics, manifest.sourceAuthority.modeRunId, 'sourceAuthority.modeRunId'); - for (const field of ['baseRef', 'effectiveRef'] as const) { - const value = manifest.sourceAuthority[field]; - if (!/^[0-9a-f]{7,64}$/iu.test(value)) diagnostic(diagnostics, 'deliverable_source_ref_invalid', `${field} must be an immutable hexadecimal commit id.`, `sourceAuthority.${field}`); - } - const checkpoint = manifest.sourceAuthority.checkpointCommit; - if (checkpoint != null && (!/^[0-9a-f]{7,64}$/iu.test(checkpoint) || checkpoint !== manifest.sourceAuthority.effectiveRef)) { - diagnostic(diagnostics, 'deliverable_checkpoint_ref_invalid', 'checkpointCommit must be an immutable commit id equal to effectiveRef.', 'sourceAuthority.checkpointCommit'); - } - } - return validationResult(diagnostics); -} - -export function validateDecisionAssignmentGraph(graph: DecisionAssignmentGraph): AgentCapacityContractValidationResult { - const diagnostics: AgentCapacityContractDiagnostic[] = []; - validateNonEmptyString(diagnostics, graph.id, 'id'); - validateNonEmptyString(diagnostics, graph.teamId, 'teamId'); - validateNonEmptyString(diagnostics, graph.projectId, 'projectId'); - validateNonEmptyString(diagnostics, graph.decisionId, 'decisionId'); - if (!Number.isInteger(graph.version) || graph.version < 1) diagnostic(diagnostics, 'graph_version_invalid', 'Decision assignment graph version must be a positive integer.', 'version'); - if (graph.compiledBy !== 'api-control-plane') diagnostic(diagnostics, 'graph_compiler_invalid', 'Decision assignment graphs must be compiled by api-control-plane.', 'compiledBy'); - const estimateIds = new Set(graph.estimateIds); - if (graph.estimateIds.length === 0) diagnostic(diagnostics, 'graph_estimate_provenance_required', 'Decision assignment graphs require accepted estimate provenance.', 'estimateIds'); - if (estimateIds.size !== graph.estimateIds.length) diagnostic(diagnostics, 'graph_estimate_provenance_duplicate', 'Decision assignment graph estimate provenance must be unique.', 'estimateIds'); - const nodeIds = new Set(graph.nodes.map((node) => node.id)); - for (const [index, node] of graph.nodes.entries()) { - validateNonEmptyString(diagnostics, node.id, 'node.id', `nodes.${index}.id`); - validateNonEmptyString(diagnostics, node.targetAgentClass, 'node.targetAgentClass', `nodes.${index}.targetAgentClass`); - validateNonNegativeNumber(diagnostics, node.capacity.expectedSeconds, 'node.capacity.expectedSeconds', `nodes.${index}.capacity.expectedSeconds`); - validateNonNegativeNumber(diagnostics, node.capacity.maxSeconds, 'node.capacity.maxSeconds', `nodes.${index}.capacity.maxSeconds`); - const contributingEstimateIds = Array.isArray(node.metadata?.contributingEstimateIds) - ? node.metadata.contributingEstimateIds.map(String) - : []; - if (contributingEstimateIds.length === 0) diagnostic(diagnostics, 'graph_node_estimate_provenance_required', 'Every graph node must identify its contributing accepted estimates.', `nodes.${index}.metadata.contributingEstimateIds`); - for (const estimateId of contributingEstimateIds) { - if (!estimateIds.has(estimateId)) diagnostic(diagnostics, 'graph_node_estimate_provenance_unknown', `Node references unknown estimate ${estimateId}.`, `nodes.${index}.metadata.contributingEstimateIds`); - } - if (node.estimateId && !contributingEstimateIds.includes(node.estimateId)) diagnostic(diagnostics, 'graph_node_primary_estimate_missing', 'A node primary estimate must be included in its contributing estimate provenance.', `nodes.${index}.estimateId`); - } - for (const [index, edge] of graph.edges.entries()) { - if (!nodeIds.has(edge.fromNodeId)) diagnostic(diagnostics, 'graph_edge_from_missing', `Edge ${index} references missing fromNodeId.`, `edges.${index}.fromNodeId`); - if (!nodeIds.has(edge.toNodeId)) diagnostic(diagnostics, 'graph_edge_to_missing', `Edge ${index} references missing toNodeId.`, `edges.${index}.toNodeId`); - } - for (const [index, contract] of graph.deliverableContracts.entries()) { - diagnostics.push(...validateDeliverableContract(contract).diagnostics.map((entry) => ({ ...entry, path: `deliverableContracts.${index}${entry.path ? `.${entry.path}` : ''}` }))); - } - return validationResult(diagnostics); -} - -export function compileDecisionAssignmentGraphFromEstimates(input: { - id?: string; - teamId: string; - projectId: string; - decisionId: string; - version?: number; - estimates: StructuredAgentEstimate[]; - executionMode?: 'simulation' | 'production'; - reviewersByAgentClass?: Record; - maximumRevisionCycles?: number; - exactBaseRef?: string; - compiledAt?: string | null; -}): DecisionAssignmentGraphCompileResult { - const diagnostics: AgentCapacityContractDiagnostic[] = []; - const estimates = [...(input.estimates ?? [])].sort((left, right) => ( - left.agentClass.localeCompare(right.agentClass) - || String(left.agentId ?? '').localeCompare(String(right.agentId ?? '')) - || left.id.localeCompare(right.id) - )); - if (estimates.length === 0) diagnostic(diagnostics, 'graph_estimates_required', 'At least one accepted structured estimate is required to compile a decision assignment graph.', 'estimates'); - for (const [index, estimate] of estimates.entries()) { - diagnostics.push(...validateStructuredAgentEstimate(estimate).diagnostics.map((entry) => ({ ...entry, path: `estimates.${index}${entry.path ? `.${entry.path}` : ''}` }))); - } - const contractMap = new Map(); - const nodes: DecisionAssignmentGraphNode[] = []; - const edges: DecisionAssignmentGraphEdge[] = []; - const estimateNodeIds = new Map(); - const outputProducerNodeIds = new Map(); - const outputContractIds = new Map(); - const releaseDependencyProducers: Array<{ producerNodeId: string; reason: string }> = []; - for (const estimate of estimates) { - const nodeId = estimate.workUnitId || `estimate:${estimate.id}:work`; - for (const reference of [estimate.id, estimate.workUnitId].filter((value): value is string => Boolean(value))) estimateNodeIds.set(reference, nodeId); - for (const output of estimate.expectedOutputs) { - for (const reference of [output.id, output.outputType].filter((value): value is string => Boolean(value))) { - outputProducerNodeIds.set(reference, uniqueStrings([...(outputProducerNodeIds.get(reference) ?? []), nodeId])); - } - } - outputContractIds.set(nodeId, `${input.projectId}:${input.decisionId}:estimate:${estimate.id}:deliverable`); - } - for (const estimate of estimates) { - const nodeId = estimate.workUnitId || `estimate:${estimate.id}:work`; - const outputContractId = `${input.projectId}:${input.decisionId}:estimate:${estimate.id}:deliverable`; - const primaryOutput = estimate.expectedOutputs.find((output) => output.required !== false) ?? estimate.expectedOutputs[0]; - contractMap.set(outputContractId, { - id: outputContractId, - teamId: input.teamId, - projectId: input.projectId, - decisionId: input.decisionId, - deliverableType: primaryOutput?.outputType ?? 'assignment_deliverable', - producerAgentClasses: [estimate.agentClass], - acceptanceCriteria: estimate.acceptanceCriteria, - status: 'required', - metadata: { sourceEstimateId: estimate.id, sourceNodeId: nodeId }, - }); - const dependencyProducers = estimate.dependencies.flatMap((dependency) => { - const candidates = uniqueStrings([ - ...(estimateNodeIds.has(dependency.id) ? [estimateNodeIds.get(dependency.id)!] : []), - ...(outputProducerNodeIds.get(dependency.id) ?? []), - ...(dependency.deliverableType ? outputProducerNodeIds.get(dependency.deliverableType) ?? [] : []), - ].filter((candidate) => candidate !== nodeId)); - if (candidates.length > 1) diagnostic(diagnostics, 'graph_dependency_ambiguous', `Dependency ${dependency.id} resolves to multiple accepted estimate work units.`, `estimates.${estimate.id}.dependencies.${dependency.id}`); - if (candidates.length === 0 && dependency.type === 'artifact' && dependency.optional !== true && !(dependency.contentRefs?.length)) { - diagnostic(diagnostics, 'graph_artifact_dependency_unresolved', `Required artifact dependency ${dependency.id} is not produced by an accepted estimate.`, `estimates.${estimate.id}.dependencies.${dependency.id}`); - } - return candidates.length === 1 ? [{ dependency, producerNodeId: candidates[0]! }] : []; - }); - const requiredDeliverableContractIds = uniqueStrings(dependencyProducers - .filter(({ dependency }) => dependency.requiredBefore === 'start') - .map(({ producerNodeId }) => outputContractIds.get(producerNodeId)!).filter(Boolean)); - const inputRefs = estimate.dependencies.flatMap((dependency) => (dependency.contentRefs ?? []).map((ref): ContentRef => ({ model: 'note', collection: 'notes', slug: ref, id: ref }))); - nodes.push({ - id: nodeId, - decisionId: input.decisionId, - projectId: input.projectId, - targetAgentClass: estimate.agentClass, - activityType: 'acting', - estimateId: estimate.id, - groupSnapshot: estimate.groupSnapshot, - handler: null, - requiredCapabilities: uniqueStrings([ - ...(estimate.requiredProviderCapabilities ?? []), - ...estimate.dependencies.map((dependency) => dependency.capability ?? '').filter(Boolean), - ]), - requiredDeliverableContractIds, - inputRefs, - outputRequirements: estimate.expectedOutputs, - capacity: { expectedSeconds: estimate.expectedSeconds, maxSeconds: estimate.maxSeconds }, - status: 'pending', - metadata: { - stage: 'implementation', - estimateId: estimate.id, - contributingEstimateIds: [estimate.id], - producesDeliverableContractId: outputContractId, - confidence: estimate.confidence, - riskLevel: estimate.riskLevel, - humanInputDependencies: estimate.dependencies.filter((dependency) => dependency.type === 'human-input'), - }, - }); - const reviewerClasses = uniqueStrings(input.reviewersByAgentClass?.[estimate.agentClass] - ?? (Array.isArray(estimate.metadata?.reviewerAgentClasses) ? estimate.metadata.reviewerAgentClasses.map(String) : ['review'])); - const reviewNodeIds: string[] = []; - for (const reviewerClass of reviewerClasses) { - if (reviewerClass === estimate.agentClass) diagnostic(diagnostics, 'graph_reviewer_not_independent', `Reviewer ${reviewerClass} cannot review its own acting assignment.`, `estimates.${estimate.id}.reviewerAgentClasses`); - const reviewContractId = `${outputContractId}:review:${reviewerClass}`; - const reviewNodeId = `${nodeId}:review:${reviewerClass}`; - contractMap.set(reviewContractId, { - id: reviewContractId, teamId: input.teamId, projectId: input.projectId, decisionId: input.decisionId, - deliverableType: 'review_disposition', producerAgentClasses: [reviewerClass], reviewerAgentClasses: [reviewerClass], - acceptanceCriteria: ['Review must bind the exact actor checkpoint, diff, verification, receipts, and accepted plan revision.'], status: 'required', - metadata: { sourceEstimateId: estimate.id, reviewedContractId: outputContractId }, - }); - nodes.push({ - id: reviewNodeId, decisionId: input.decisionId, projectId: input.projectId, targetAgentClass: reviewerClass, - activityType: 'reviewing', estimateId: estimate.id, groupSnapshot: estimate.groupSnapshot, handler: null, - requiredCapabilities: ['treeseed.engineering.review'], requiredDeliverableContractIds: [outputContractId], inputRefs, - outputRequirements: [{ id: reviewContractId, outputType: 'review_disposition', required: true }], - capacity: { expectedSeconds: Math.max(1, estimate.workBreakdown?.independentReviewSeconds ?? 300), maxSeconds: Math.max(1, estimate.workBreakdown?.finalReviewSeconds ?? estimate.workBreakdown?.independentReviewSeconds ?? 300) }, - status: 'pending', metadata: { stage: 'review', reviewedNodeId: nodeId, reviewedContractId: outputContractId, contributingEstimateIds: [estimate.id], - producesDeliverableContractId: reviewContractId, exactCheckpointRequired: true, rejectionCreatesRevision: true, - maximumRevisionCycles: input.maximumRevisionCycles ?? estimate.workBreakdown?.expectedRevisionCycles ?? 1 }, - }); - reviewNodeIds.push(reviewNodeId); - edges.push({ fromNodeId: nodeId, toNodeId: reviewNodeId, edgeType: 'blocks-start', reason: 'Independent review requires the exact acting checkpoint.' }); - } - for (const { dependency, producerNodeId } of dependencyProducers) { - const reason = dependency.summary ?? dependency.deliverableType ?? dependency.id; - if (dependency.requiredBefore === 'release') releaseDependencyProducers.push({ producerNodeId, reason }); - else for (const targetNodeId of dependency.requiredBefore === 'review' ? reviewNodeIds : [nodeId]) { - edges.push({ fromNodeId: producerNodeId, toNodeId: targetNodeId, edgeType: edgeTypeForDependency(dependency), reason }); - } - } - } - const reviewNodes = nodes.filter((node) => node.activityType === 'reviewing'); - const integrationContractId = `${input.projectId}:${input.decisionId}:platform-integration`; - const integrationNodeId = `${input.projectId}:${input.decisionId}:platform-integration`; - contractMap.set(integrationContractId, { - id: integrationContractId, teamId: input.teamId, projectId: input.projectId, decisionId: input.decisionId, - deliverableType: 'governed_integration_receipt', producerAgentClasses: ['platform-integration'], - acceptanceCriteria: ['The platform must bind the exact approved checkpoint to a valid execution-authority receipt before reporting.'], - status: 'required', metadata: { platformControlled: true }, - }); - nodes.push({ - id: integrationNodeId, decisionId: input.decisionId, projectId: input.projectId, targetAgentClass: 'platform-integration', - activityType: 'acting', handler: 'platform-integration', requiredCapabilities: ['treeseed.engineering.code-change'], - requiredDeliverableContractIds: reviewNodes.map((node) => String(node.metadata?.producesDeliverableContractId)), inputRefs: [], - outputRequirements: [{ id: integrationContractId, outputType: 'governed_integration_receipt', required: true }], - capacity: { expectedSeconds: 1, maxSeconds: 1 }, status: 'pending', - metadata: { stage: 'integration', platformControlled: true, producesDeliverableContractId: integrationContractId, contributingEstimateIds: estimates.map((estimate) => estimate.id).sort() }, - }); - for (const reviewNode of reviewNodes) edges.push({ fromNodeId: reviewNode.id, toNodeId: integrationNodeId, edgeType: 'blocks-start', reason: 'Platform integration waits for every exact review approval.' }); - for (const dependency of releaseDependencyProducers) edges.push({ fromNodeId: dependency.producerNodeId, toNodeId: integrationNodeId, edgeType: 'blocks-release', reason: dependency.reason }); - const graph: DecisionAssignmentGraph = { - id: input.id ?? `${input.projectId}:${input.decisionId}:graph:v${input.version ?? 1}`, - teamId: input.teamId, - projectId: input.projectId, - decisionId: input.decisionId, - version: input.version ?? 1, - status: diagnostics.some((entry) => entry.severity === 'error') ? 'blocked' : 'compiled', - estimateIds: estimates.map((estimate) => estimate.id).sort(), - deliverableContracts: [...contractMap.values()].sort((left, right) => left.id.localeCompare(right.id)), - nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)), - edges: edges.sort((left, right) => left.fromNodeId.localeCompare(right.fromNodeId) || left.toNodeId.localeCompare(right.toNodeId) || left.edgeType.localeCompare(right.edgeType)), - compiledAt: input.compiledAt ?? null, - compiledBy: 'api-control-plane', - executionMode: input.executionMode ?? 'simulation', - metadata: { compiler: 'compileDecisionAssignmentGraphFromEstimates', compilerVersion: 3, workflowKind: 'estimate-derived', maximumRevisionCycles: input.maximumRevisionCycles ?? 1, ...(input.exactBaseRef ? { exactBaseRef: input.exactBaseRef } : {}) }, - }; - diagnostics.push(...validateDecisionAssignmentGraph(graph).diagnostics); - return { graph, diagnostics }; -} - -export function advanceDecisionAssignmentGraph( - graph: DecisionAssignmentGraph, - completedContractId: string, - approvedContractIds: ReadonlySet, -): DecisionAssignmentGraph { - const producingNode = graph.nodes.find((node) => node.metadata?.producesDeliverableContractId === completedContractId); - if (!producingNode) return graph; - const completedNodes = new Set(graph.nodes.filter((node) => node.status === 'completed').map((node) => node.id)); - completedNodes.add(producingNode.id); - const nodes = graph.nodes.map((node): DecisionAssignmentGraphNode => { - if (node.id === producingNode.id) return { ...node, status: 'completed' }; - if (node.status !== 'pending') return node; - const predecessors = graph.edges.filter((edge) => edge.toNodeId === node.id && edge.edgeType === 'blocks-start').map((edge) => edge.fromNodeId); - const dependenciesComplete = predecessors.every((id) => completedNodes.has(id)); - const contractsApproved = node.requiredDeliverableContractIds.every((id) => approvedContractIds.has(id)); - return dependenciesComplete && contractsApproved ? { ...node, status: 'ready' } : node; - }); - const complete = nodes.length > 0 && nodes.every((node) => node.status === 'completed'); - return { - ...graph, - status: complete ? 'completed' : graph.status, - nodes, - deliverableContracts: graph.deliverableContracts.map((contract) => contract.id === completedContractId ? { ...contract, status: 'approved' } : contract), - }; -} - -export function activateDecisionAssignmentGraph(graph: DecisionAssignmentGraph): DecisionAssignmentGraph { - const incoming = new Set(graph.edges.filter((edge) => edge.edgeType === 'blocks-start').map((edge) => edge.toNodeId)); - return { - ...graph, - status: 'ready', - nodes: graph.nodes.map((node) => ( - node.status === 'pending' && !incoming.has(node.id) && node.requiredDeliverableContractIds.length === 0 - ? { ...node, status: 'ready' } - : node - )), - }; -} diff --git a/src/api/capacity/policy/execution/execution-graph-projector.ts b/src/api/capacity/policy/execution/execution-graph-projector.ts index 2765651e..c34d3227 100644 --- a/src/api/capacity/policy/execution/execution-graph-projector.ts +++ b/src/api/capacity/policy/execution/execution-graph-projector.ts @@ -31,6 +31,12 @@ export interface ExecutionGraphProjection { revision: GraphRevision; } +export interface VerifiedDependencyLink { + from: ExactEntityReference; + to: ExactEntityReference; + sourceRef: ExactEntityReference; +} + const RULE_REVISION = 2; const record = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; const text = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; @@ -179,6 +185,7 @@ export function projectTeamExecutionGraph(input: { revision: number; sources: ExecutableProposalSource[]; profiles: Record; + dependencyLinks?: VerifiedDependencyLink[]; createdAt?: string; }): ExecutionGraphProjection { const nodes: ExecutionNode[] = []; @@ -258,6 +265,27 @@ export function projectTeamExecutionGraph(input: { } } + for (const link of input.dependencyLinks ?? []) { + const endpoint = (ref: ExactEntityReference, role: 'predecessor' | 'dependent') => { + const item = ref.anchor?.match(/^work-item\/([a-z0-9]+(?:-[a-z0-9]+)*)$/u)?.[1]; + const candidates = nodes.filter((node) => node.sourceRef.store === 'treedx' + && node.sourceRef.model === 'proposal' && node.sourceRef.id === ref.id + && node.sourceRef.repository === ref.repository && node.sourceRef.commit === ref.commit + && node.sourceRef.path === ref.path && node.sourceRef.digest === ref.digest + && node.sourceRef.revision === ref.revision && node.workItemId === item); + return role === 'predecessor' + ? candidates.find((node) => node.pairRole === 'reviewer') ?? candidates.find((node) => node.pairRole === 'actor') + : candidates.find((node) => node.pairRole === 'actor'); + }; + const predecessor = endpoint(link.from, 'predecessor'); + const dependent = endpoint(link.to, 'dependent'); + if (!predecessor || !dependent) throw Object.assign(new Error('An exact TreeDX dependency endpoint is absent from the selected graph.'), { + code: 'execution_dependency_endpoint_missing', sourceRef: link.sourceRef, + }); + edges.push(edge({ teamId: input.teamId, fromNodeId: predecessor.id, toNodeId: dependent.id, + provenance: 'treedx-link', sourceRef: link.sourceRef }, input.revision)); + } + const uniqueEdges = [...new Map(edges.map((candidate) => [candidate.id, candidate])).values()]; const incoming = new Set(uniqueEdges.map((candidate) => candidate.toNodeId)); for (const node of nodes) { diff --git a/src/api/capacity/policy/execution/workday-execution-projector.ts b/src/api/capacity/policy/execution/workday-execution-projector.ts index ef016693..7bc789f9 100644 --- a/src/api/capacity/policy/execution/workday-execution-projector.ts +++ b/src/api/capacity/policy/execution/workday-execution-projector.ts @@ -72,17 +72,16 @@ export function projectActiveWorkdays(input: { teamId: string; revision: number; const workItem = activity === 'estimating' && definition.agentClass !== 'reviewer' ? workItems.filter((item) => text(item.agentClass) === definition.agentClass) : []; if (activity === 'estimating' && (!workItems.length - || (definition.agentClass !== 'reviewer' && workItem.length !== 1))) throw new Error( - `Estimating ${definition.agentClass} requires its exact proposal work item.`); + || (definition.agentClass !== 'reviewer' && !workItem.length))) throw new Error( + `Estimating ${definition.agentClass} requires proposal work items owned by that class.`); const estimatingCriteria = definition.agentClass === 'reviewer' ? workItems.filter((item) => item.review === 'required').map((item) => `Estimate the generated review of work item ${text(item.id)} independently: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.`) - : [`Estimate work item ${text(workItem[0]?.id)}: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.`, - ...array(workItem[0]?.acceptanceCriteria).map(text)]; + : workItem.flatMap((item) => [`Estimate work item ${text(item.id)}: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.`, + ...array(item.acceptanceCriteria).map(text)]); const proposalStatus = source.proposalStatusesByProjectId?.[projectId]; const node = executionNodeSchema.parse({ schemaVersion: 'treeseed.execution-node/v1', id: plannedId, teamId: input.teamId, projectId, workdayId: workday.id, kind: activity, pairRole: null, - ...(activity === 'estimating' && workItem[0] ? { workItemId: text(workItem[0].id) } : {}), sourceRef: nodeSource, authorityRefs: [reference], ruleRevision: 1, nodeRevision: 1, agentClass: definition.agentClass, status: activity === 'estimating' && proposalStatus diff --git a/src/api/capacity/policy/execution/workday-participants.ts b/src/api/capacity/policy/execution/workday-participants.ts index 3ff1ac90..1b002ebb 100644 --- a/src/api/capacity/policy/execution/workday-participants.ts +++ b/src/api/capacity/policy/execution/workday-participants.ts @@ -37,7 +37,8 @@ export function workdayParticipants(parameters: Row): WorkdayParticipant[] { const canEstimate = workItems.some(item => text(item.agentClass) === validation.data!.agentClass) || (validation.data.agentClass === 'reviewer' && workItems.some(item => item.review === 'required')); const activities = (explicitActivitySelection ? frozenActivities : frozenActivities.filter((activity) => - activity === 'planning' || (activity === 'estimating' && canEstimate))) + activity === 'planning' || activity === 'estimating')) + .filter((activity) => activity !== 'estimating' || canEstimate) .filter((activity): activity is 'planning' | 'estimating' => activity === 'planning' || activity === 'estimating'); for (const activity of activities) { if (!validation.data.activityProfiles[activity]) continue; diff --git a/src/api/capacity/policy/planning.ts b/src/api/capacity/policy/planning.ts deleted file mode 100644 index 5e9655ac..00000000 --- a/src/api/capacity/policy/planning.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { type AgentCapacityEnvelope,type DecisionExecutionInput,type ProviderAssignment } from '@treeseed/sdk/agent-capacity'; -import type { AgentCapacityPlanRecord,AgentCapacityPlanWorkUnit,DecisionExecutionInputRecord,DecisionPlanningStatus,PlanningInputRequest } from '@treeseed/sdk/agent-capacity'; -import { normalizeAgentExecutionMode } from './support-primitives.ts'; -import { numberOrNull,record,stableStringify } from './support-primitives.ts'; - -export function computeDecisionScopeHash(scope: unknown): string { - const text = stableStringify(scope ?? {}); - let hash = 5381; - for (let index = 0; index < text.length; index += 1) { - hash = ((hash << 5) + hash) ^ text.charCodeAt(index); - } - return `scope_${(hash >>> 0).toString(16).padStart(8, '0')}`; -} - -export function isDecisionReadyForActing(status?: Pick | null): boolean { - if (!status) return false; - const readiness = status.executionReadiness; - const planning = status.planningInputsStatus; - return (readiness === 'ready' || readiness === 'waived') && (planning === 'complete' || planning === 'waived'); -} - -export function isPlanningInputOpen(request: Pick): boolean { - return request.status === 'requested' || request.status === 'stale'; -} - -export function isDecisionExecutionInputAccepted(input: Pick): boolean { - return input.status === 'accepted'; -} - -export function isAgentCapacityPlanAccepted(plan?: Pick | null): boolean { - return Boolean(plan && ['accepted', 'scheduled', 'active'].includes(String(plan.status))); -} - -export function isAgentCapacityPlanStaleOrSuperseded(plan: Pick & { scopeHash?: string | null }, currentScopeHash?: string | null): boolean { - if (plan.status === 'superseded') return true; - return Boolean(currentScopeHash && plan.scopeHash && currentScopeHash !== plan.scopeHash); -} - -export function hasAcceptedCapacityPlanProvenance(input: { - assignment?: Pick | null; - decisionInput?: DecisionExecutionInput | null; - capacityEnvelope?: AgentCapacityEnvelope | null; -}): boolean { - const assignmentMetadata = record(input.assignment?.metadata); - const decisionMetadata = record(input.decisionInput?.metadata ?? record(input.assignment?.decisionInput).metadata); - const capacityMetadata = record(input.capacityEnvelope?.metadata ?? record(input.assignment?.capacityEnvelope).metadata); - const status = String(assignmentMetadata.capacityPlanStatus ?? decisionMetadata.capacityPlanStatus ?? capacityMetadata.capacityPlanStatus ?? ''); - const capacityPlanId = assignmentMetadata.capacityPlanId ?? decisionMetadata.capacityPlanId ?? capacityMetadata.capacityPlanId; - const synthesizedFrom = input.assignment?.synthesizedFrom ?? assignmentMetadata.synthesizedFrom ?? capacityMetadata.synthesizedFrom; - return Boolean(capacityPlanId && (['accepted', 'scheduled', 'active'].includes(status) || synthesizedFrom === 'capacity_plan')); -} - -export function normalizeDecisionExecutionEstimate(input: DecisionExecutionInputRecord | DecisionExecutionInput): { - expectedSeconds: number; - highSeconds: number; - requiredCapabilities: string[]; - dependencies: string[]; - blockers: string[]; - assumptions: string[]; - confidence: number | null; - environmentNeeds: string[]; - risk: Record; -} { - const source = 'input' in input && input.input && 'input' in input.input - ? input.input.input - : record((input as DecisionExecutionInput).input); - const metadata = record((input as DecisionExecutionInputRecord).metadata ?? (input as DecisionExecutionInput).metadata); - const estimate = record(source.estimate ?? metadata.estimate); - const capabilities = Array.isArray(source.requiredCapabilities) - ? source.requiredCapabilities - : Array.isArray(metadata.requiredCapabilities) - ? metadata.requiredCapabilities - : []; - const dependencies = Array.isArray(source.dependencies) ? source.dependencies : Array.isArray(metadata.dependencies) ? metadata.dependencies : []; - const blockers = Array.isArray(source.blockers) ? source.blockers : Array.isArray(metadata.blockers) ? metadata.blockers : []; - const assumptions = Array.isArray(source.assumptions) ? source.assumptions : Array.isArray(metadata.assumptions) ? metadata.assumptions : []; - const environmentNeeds = Array.isArray(source.environmentNeeds) ? source.environmentNeeds : Array.isArray(metadata.environmentNeeds) ? metadata.environmentNeeds : []; - const expectedSeconds = numberOrNull(estimate.expectedSeconds ?? source.expectedSeconds ?? metadata.expectedSeconds) ?? 900; - const highSeconds = Math.max(expectedSeconds, numberOrNull(estimate.highSeconds ?? source.highSeconds ?? metadata.highSeconds) ?? expectedSeconds); - const confidence = numberOrNull(estimate.confidence ?? source.confidence ?? metadata.confidence); - return { - expectedSeconds, - highSeconds, - requiredCapabilities: capabilities.map(String).filter(Boolean), - dependencies: dependencies.map(String).filter(Boolean), - blockers: blockers.map(String).filter(Boolean), - assumptions: assumptions.map(String).filter(Boolean), - confidence, - environmentNeeds: environmentNeeds.map(String).filter(Boolean), - risk: record(source.risk ?? metadata.risk), - }; -} - -export function buildAgentCapacityPlanDraft(input: { - id: string; - teamId: string; - projectId: string; - decisionId: string; - scopeHash: string; - executionInputs: DecisionExecutionInputRecord[]; - allocationSetId?: string | null; - workDayId?: string | null; - status?: DurableAgentCapacityPlanStatus; - metadata?: Record; - now?: string; -}): AgentCapacityPlanRecord { - const timestamp = input.now ?? new Date().toISOString(); - const accepted = input.executionInputs.filter(isDecisionExecutionInputAccepted); - const workUnits = accepted.map((entry, index): AgentCapacityPlanWorkUnit => { - const estimate = normalizeDecisionExecutionEstimate(entry); - const decisionInput = entry.input; - const mode = normalizeAgentExecutionMode(entry.mode, 'acting'); - const estimateId = typeof entry.estimateId === 'string' && entry.estimateId.trim() - ? entry.estimateId.trim() - : typeof decisionInput.estimateId === 'string' && decisionInput.estimateId.trim() - ? decisionInput.estimateId.trim() - : typeof entry.metadata?.estimateId === 'string' && entry.metadata.estimateId.trim() - ? entry.metadata.estimateId.trim() : null; - const contributingEstimateIds = Array.isArray(entry.metadata?.contributingEstimateIds) - ? [...new Set(entry.metadata.contributingEstimateIds.map(String).map((id) => id.trim()).filter(Boolean))].sort() - : estimateId ? [estimateId] : []; - const workGraphNodeId = typeof decisionInput.workGraphNodeId === 'string' && decisionInput.workGraphNodeId.trim() - ? decisionInput.workGraphNodeId.trim() - : null; - if (mode === 'acting' && !workGraphNodeId) { - throw new Error(`Acting decision execution input ${entry.id} requires workGraphNodeId provenance.`); - } - if (mode === 'acting' && contributingEstimateIds.length === 0) throw new Error(`Acting decision execution input ${entry.id} requires accepted estimate provenance.`); - const capacityEnvelope = { - ...decisionInput.capacity, - teamId: input.teamId, - projectId: input.projectId, - workDayId: decisionInput.capacity?.workDayId ?? input.workDayId ?? null, - allocationSetId: decisionInput.capacity?.allocationSetId ?? input.allocationSetId ?? null, - mode: normalizeAgentExecutionMode(decisionInput.mode, 'acting'), - projectAgentClassId: entry.projectAgentClassId, - metadata: { - ...(decisionInput.capacity?.metadata ?? {}), - capacityPlanId: input.id, - decisionExecutionInputId: entry.id, - }, - }; - return { - id: `${input.id}:wu:${index + 1}`, - decisionExecutionInputId: entry.id, - decisionId: input.decisionId, - workGraphNodeId, - estimateId, - projectAgentClassId: entry.projectAgentClassId, - mode, - ...(decisionInput.activityType ? { activityType: decisionInput.activityType } : {}), - taskId: decisionInput.taskId ?? null, - agentId: decisionInput.agentId ?? null, - handlerId: decisionInput.handlerId ?? null, - workDayId: capacityEnvelope.workDayId ?? null, - expectedSeconds: estimate.expectedSeconds, - highSeconds: estimate.highSeconds, - requiredCapabilities: estimate.requiredCapabilities, - dependencies: estimate.dependencies, - blockers: estimate.blockers, - risk: estimate.risk, - assumptions: estimate.assumptions, - confidence: estimate.confidence, - capacityEnvelope, - decisionInput: { - ...decisionInput, - estimateId, - capacity: capacityEnvelope, - metadata: { - ...(decisionInput.metadata ?? {}), - contributingEstimateIds, - capacityPlanId: input.id, - decisionExecutionInputId: entry.id, - }, - }, - metadata: entry.metadata ?? {}, - }; - }); - const unique = (values: string[]) => [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); - return { - id: input.id, - teamId: input.teamId, - projectId: input.projectId, - decisionId: input.decisionId, - status: input.status ?? 'draft', - scopeHash: input.scopeHash, - allocationSetId: input.allocationSetId ?? null, - workDayId: input.workDayId ?? null, - expectedSeconds: workUnits.reduce((sum, unit) => sum + unit.expectedSeconds, 0), - highSeconds: workUnits.reduce((sum, unit) => sum + unit.highSeconds, 0), - workUnits, - capabilityNeeds: unique(workUnits.flatMap((unit) => unit.requiredCapabilities)), - environmentNeeds: unique(accepted.flatMap((entry) => normalizeDecisionExecutionEstimate(entry).environmentNeeds)), - reserves: { highSeconds: workUnits.reduce((sum, unit) => sum + unit.highSeconds, 0) }, - blockers: unique(workUnits.flatMap((unit) => unit.blockers)), - priorityRationale: typeof input.metadata?.priorityRationale === 'string' ? input.metadata.priorityRationale : null, - review: {}, - metadata: input.metadata ?? {}, - acceptedAt: null, - scheduledAt: null, - createdAt: timestamp, - updatedAt: timestamp, - }; -} diff --git a/src/api/capacity/policy/revision-cycle.ts b/src/api/capacity/policy/revision-cycle.ts deleted file mode 100644 index a73143cd..00000000 --- a/src/api/capacity/policy/revision-cycle.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - DecisionAssignmentGraph,DecisionAssignmentGraphEdge,DecisionAssignmentGraphNode,DeliverableContract, - GovernedRevisionCycleResult,GovernedRevisionCycleInput, -} from '@treeseed/sdk/agent-capacity'; - -function nextCycle(graph: DecisionAssignmentGraph) { - return Math.max(0,...graph.nodes.map((node)=>Number(node.metadata?.revisionCycle??0)).filter(Number.isFinite))+1; -} - -/** Adds a new acting/review pair without reopening or mutating prior assignments. */ -export function compileGovernedRevisionCycle(graph:DecisionAssignmentGraph,input:GovernedRevisionCycleInput):GovernedRevisionCycleResult|null { - const rejected=graph.nodes.find((node)=>node.id===input.rejectedReviewNodeId&&node.activityType==='reviewing'); if(!rejected)return null; - const maximum=Math.max(0,Number(rejected.metadata?.maximumRevisionCycles??graph.metadata?.maximumRevisionCycles??1)); const revisionCycle=nextCycle(graph); if(revisionCycle>maximum)return null; - const prefix=`${graph.id}:revision:${revisionCycle}`; - const revisionContract:DeliverableContract={id:`${prefix}:deliverable:checkpoint`,teamId:graph.teamId,projectId:graph.projectId,decisionId:graph.decisionId, - deliverableType:'implementation_revision',producerAgentClasses:[input.actorAgentClass],acceptanceCriteria:[`Resolve immutable findings ${input.findingsRef} against checkpoint ${input.rejectedCheckpointRef}.`],status:'required', - metadata:{revisionCycle,rejectedReviewNodeId:rejected.id,rejectedCheckpointRef:input.rejectedCheckpointRef,findingsRef:input.findingsRef}}; - const reviewContract:DeliverableContract={id:`${prefix}:deliverable:review`,teamId:graph.teamId,projectId:graph.projectId,decisionId:graph.decisionId, - deliverableType:'review_disposition',producerAgentClasses:[input.reviewerAgentClass],reviewerAgentClasses:[input.reviewerAgentClass],acceptanceCriteria:['Review the exact revised checkpoint; any prior approval is stale.'],status:'required',metadata:{revisionCycle,reviewedContractId:revisionContract.id}}; - const revisionNode:DecisionAssignmentGraphNode={id:`${prefix}:node:acting`,decisionId:graph.decisionId,projectId:graph.projectId,targetAgentClass:input.actorAgentClass,activityType:'acting',requiredCapabilities:['treeseed.engineering.code-change'],requiredDeliverableContractIds:[], - inputRefs:[{model:'note',collection:'notes',slug:input.findingsRef,id:input.findingsRef}],outputRequirements:[{id:revisionContract.id,outputType:'implementation_revision',required:true}],capacity:{expectedSeconds:input.availableSeconds,maxSeconds:input.availableSeconds},status:'ready', - metadata:{stage:'revision',revisionCycle,priorCheckpointRef:input.rejectedCheckpointRef,findingsRef:input.findingsRef,producesDeliverableContractId:revisionContract.id,contributingEstimateIds:rejected.metadata?.contributingEstimateIds??[]}}; - const reviewNode:DecisionAssignmentGraphNode={id:`${prefix}:node:review`,decisionId:graph.decisionId,projectId:graph.projectId,targetAgentClass:input.reviewerAgentClass,activityType:'reviewing',requiredCapabilities:['treeseed.engineering.review'],requiredDeliverableContractIds:[revisionContract.id],inputRefs:[], - outputRequirements:[{id:reviewContract.id,outputType:'review_disposition',required:true}],capacity:{expectedSeconds:Math.max(1,Math.floor(input.availableSeconds/3)),maxSeconds:Math.max(1,Math.floor(input.availableSeconds/3))},status:'pending', - metadata:{stage:'review',revisionCycle,reviewedNodeId:revisionNode.id,reviewedContractId:revisionContract.id,exactCheckpointRequired:true,maximumRevisionCycles:maximum,rejectionCreatesRevision:true,producesDeliverableContractId:reviewContract.id,contributingEstimateIds:rejected.metadata?.contributingEstimateIds??[]}}; - const integration=graph.nodes.find((node)=>node.metadata?.platformControlled===true&&node.metadata?.stage==='integration'); - const edges:DecisionAssignmentGraphEdge[]=graph.edges.filter((edge)=>!integration||edge.fromNodeId!==rejected.id||edge.toNodeId!==integration.id); - edges.push({fromNodeId:rejected.id,toNodeId:revisionNode.id,edgeType:'blocks-start',reason:input.reason},{fromNodeId:revisionNode.id,toNodeId:reviewNode.id,edgeType:'blocks-start',reason:'The revised checkpoint requires a new independent review.'}); - if(integration)edges.push({fromNodeId:reviewNode.id,toNodeId:integration.id,edgeType:'blocks-start',reason:'Platform integration waits for the revised checkpoint review.'}); - return {revisionCycle,newContracts:[revisionContract,reviewContract],graph:{...graph,status:'executing',deliverableContracts:[...graph.deliverableContracts.map((contract)=>contract.id===rejected.metadata?.producesDeliverableContractId?{...contract,status:'rejected' as const}:contract.status==='approved'&&contract.metadata?.reviewedContractId===rejected.metadata?.reviewedContractId?{...contract,status:'stale' as const}:contract),revisionContract,reviewContract], - nodes:[...graph.nodes.map((node)=>node.id===rejected.id?{...node,status:'completed' as const}:node.id===integration?.id?{...node,requiredDeliverableContractIds:[...node.requiredDeliverableContractIds,reviewContract.id],status:'pending' as const}:node),revisionNode,reviewNode],edges,metadata:{...(graph.metadata??{}),revisionCycles:revisionCycle,latestRevisionReason:input.reason}}}; -} diff --git a/src/api/capacity/policy/runtime/runtime-diagnostics.ts b/src/api/capacity/policy/runtime/runtime-diagnostics.ts index bc1f3705..8a130f62 100644 --- a/src/api/capacity/policy/runtime/runtime-diagnostics.ts +++ b/src/api/capacity/policy/runtime/runtime-diagnostics.ts @@ -79,13 +79,6 @@ const CAPACITY_RUNTIME_REASON_DETAILS: Record>; ledgerEntries?: CapacityLedgerEntry[]; fallbackOutputs?: Array>; @@ -282,7 +274,6 @@ export function summarizeCapacityRuntimeDiagnostics(input: { generatedAt: input.generatedAt ?? new Date().toISOString(), assignments, explanations, - modeRuns: input.modeRuns ?? [], treeDxProxyAudit: input.treeDxProxyAudit ?? [], ledgerEntries: input.ledgerEntries ?? [], fallbackOutputs: input.fallbackOutputs ?? [], diff --git a/src/api/capacity/policy/workdays/chat-activity-profile.ts b/src/api/capacity/policy/workdays/chat-activity-profile.ts index c08f8312..5071d685 100644 --- a/src/api/capacity/policy/workdays/chat-activity-profile.ts +++ b/src/api/capacity/policy/workdays/chat-activity-profile.ts @@ -7,10 +7,10 @@ export function compileDefaultChatActivityProfile( // Discussion responses are authored only by treeseed.discussion.respond. // Generic content tools retain custody only for explicitly linked artifacts // and the assignment's own operational records. - const writableModels = new Set(['note', 'question', 'proposal', 'assignment_plan', 'assignment_status', 'assignment_summary']); + const writableModels = new Set(['note', 'question', 'proposal']); const contextModels = [...new Set([ 'discussion', 'discussion_message', 'discussion_event', 'discussion_topic', 'agent', 'note', 'question', - 'proposal', 'decision', 'objective', 'knowledge', 'assignment_plan', 'assignment_status', 'assignment_summary', + 'proposal', 'decision', 'objective', 'knowledge', ...(specialization.contextModels ?? []), ])]; const tools = [...new Set([ @@ -18,8 +18,7 @@ export function compileDefaultChatActivityProfile( 'treedx.read_repository_files', 'treedx.search_workspace', 'treedx.read_workspace_file', 'treeseed.repository.read_file', 'treeseed.repository.search', 'treeseed.content.create', 'treeseed.content.update', 'treeseed.content.link', 'treeseed.content.validate', - 'treeseed.content.commit', 'treeseed.status', 'treeseed.assignment_activity', 'treeseed.assignment_plan', - 'treeseed.assignment_status_update', 'treeseed.assignment_summary', 'treeseed.discussion.read', + 'treeseed.content.commit', 'treeseed.status', 'treeseed.assignment_activity', 'treeseed.discussion.read', 'treeseed.discussion.follow', 'treeseed.discussion.respond', 'treeseed.discussion.request_handoff', 'treeseed.discussion.create_artifact', 'treeseed.operation.prepare_handoff', 'treeseed.client_session.request_action', ...(specialization.toolAdditions ?? []), diff --git a/src/api/capacity/policy/workdays/cooperative-planning.ts b/src/api/capacity/policy/workdays/cooperative-planning.ts deleted file mode 100644 index a7976ac1..00000000 --- a/src/api/capacity/policy/workdays/cooperative-planning.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { AgentPlanningGraph } from './planning-graph.ts'; - -export const COOPERATIVE_PLANNING_STAGES = ['discovery', 'synthesis', 'deliberation', 'evaluation', 'revision', 'closeout'] as const; -export type CooperativePlanningStage = typeof COOPERATIVE_PLANNING_STAGES[number]; - -export interface PlanningParticipant { - agentId: string; - nodeId: string; - projectAgentClassId: string; - timeboxSeconds: number; -} - -export interface PlanningWave { - round: number; - wave: number; - stage: CooperativePlanningStage; - participantNodeIds: string[]; - requestedSeconds: number; -} - -export function expandSignalDependencyClosure(graph: AgentPlanningGraph, targetNodeIds: string[]): string[] { - const selected = new Set(targetNodeIds); - const queue = [...targetNodeIds]; - while (queue.length) { - const target = queue.shift() as string; - for (const edge of graph.edges.filter((entry) => entry.toNodeId === target)) { - if (selected.has(edge.fromNodeId)) continue; - selected.add(edge.fromNodeId); - queue.push(edge.fromNodeId); - } - } - return [...selected].sort(); -} - -function stageFor(nodeStage: string | null): CooperativePlanningStage { - if (COOPERATIVE_PLANNING_STAGES.includes(nodeStage as CooperativePlanningStage)) return nodeStage as CooperativePlanningStage; - return 'deliberation'; -} - -export function compileCooperativePlanningWaves(input: { - graph: AgentPlanningGraph; - participants: PlanningParticipant[]; - rounds: number; - maxConcurrentAssignments: number; - allocatedSeconds: number; -}): { waves: PlanningWave[]; requiredSeconds: number; fits: boolean } { - const concurrency = Math.max(1, Math.floor(input.maxConcurrentAssignments)); - const rounds = Math.max(1, Math.floor(input.rounds)); - const byNode = new Map(input.participants.map((entry) => [entry.nodeId, entry])); - const waves: PlanningWave[] = []; - let requiredSeconds = 0; - const roundFor = (stage: CooperativePlanningStage) => stage === 'discovery' || stage === 'synthesis' ? 1 - : stage === 'deliberation' || stage === 'evaluation' ? Math.min(2, rounds) - : stage === 'revision' ? rounds : rounds + 1; - for (let round = 1; round <= rounds + 1; round += 1) { - const ordered = input.graph.nodes.filter((entry) => byNode.has(entry.id) && roundFor(stageFor(entry.stage)) === round).sort((left, right) => { - const stage = COOPERATIVE_PLANNING_STAGES.indexOf(stageFor(left.stage)) - COOPERATIVE_PLANNING_STAGES.indexOf(stageFor(right.stage)); - return stage || left.id.localeCompare(right.id); - }); - let waveIndex = 0; - for (const stage of COOPERATIVE_PLANNING_STAGES) { - const stageNodes = ordered.filter((entry) => stageFor(entry.stage) === stage); - for (let cursor = 0; cursor < stageNodes.length; cursor += concurrency) { - const cohort = stageNodes.slice(cursor, cursor + concurrency); - const requestedSeconds = cohort.reduce((sum, entry) => sum + (byNode.get(entry.id)?.timeboxSeconds ?? 0), 0); - requiredSeconds += requestedSeconds; - waves.push({ round, wave: ++waveIndex, stage, participantNodeIds: cohort.map((entry) => entry.id), requestedSeconds }); - } - } - } - return { waves, requiredSeconds, fits: requiredSeconds <= input.allocatedSeconds }; -} diff --git a/src/api/capacity/policy/workdays/planning-graph.ts b/src/api/capacity/policy/workdays/planning-graph.ts deleted file mode 100644 index 010c3605..00000000 --- a/src/api/capacity/policy/workdays/planning-graph.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { EffectiveGroupMembership, SignalGroupScope } from '@treeseed/sdk/agent-capacity'; -import { matchSignalGroupScope } from './signal-scope.ts'; -import type { AgentSignalContract } from '@treeseed/sdk/agent-capacity'; - -export type SignalProducerPolicy = 'any' | 'all' | 'quorum'; - -export interface SignalSubscription { - contract: string; - groupScope?: SignalGroupScope; - filters?: Record; - cardinality?: 'single' | 'each'; - producerPolicy?: SignalProducerPolicy; - quorum?: number; -} - -export interface PlanningGraphProfile { - id?: string; - agentId: string; - activityType: string; - stage?: string | null; - signals?: { subscribesTo?: SignalSubscription[]; publishes?: string[] }; -} - -export interface PlanningGraphNode { - id: string; - agentId: string; - activityType: string; - stage: string | null; - requires: SignalSubscription[]; - produces: string[]; -} - -export interface PlanningGraphEdge { - fromNodeId: string; - toNodeId: string; - contracts: string[]; -} - -export interface PlanningGraphDiagnostic { - code: 'duplicate_node' | 'missing_producer' | 'self_dependency' | 'cycle' | 'invalid_quorum' | 'missing_contract' | 'publisher_not_allowed' | 'subscriber_not_allowed' | 'filter_not_allowed'; - nodeId: string; - contractId?: string; - message: string; -} - -export interface AgentPlanningGraph { - nodes: PlanningGraphNode[]; - edges: PlanningGraphEdge[]; - externalRoots: string[]; - diagnostics: PlanningGraphDiagnostic[]; - ok: boolean; -} - -export interface PlanningGraphEvidenceReference { - contractId: string; - recordId: string; - subjectId?: string | null; - payload?: Record; - metadata?: Record; - groupMembership?: EffectiveGroupMembership; -} - -export interface PlanningGraphGroupContext { - projectId: string; - agentMembershipByNodeId: Record; - depthByGroupId?: Record; -} - -export interface PlanningGraphNodeEvidence { - nodeId: string; - references: PlanningGraphEvidenceReference[]; -} - -function normalize(value: string): string { return value.trim().replace(/_/gu, '-'); } -function unique(values: string[] = []): string[] { return [...new Set(values.map(normalize).filter(Boolean))].sort(); } - -function node(profile: PlanningGraphProfile): PlanningGraphNode { - return { - id: profile.id?.trim() || `${profile.agentId}:${profile.activityType}`, - agentId: profile.agentId, - activityType: profile.activityType, - stage: profile.stage?.trim() || null, - requires: (profile.signals?.subscribesTo ?? []).map((entry) => ({ - ...entry, - contract: normalize(entry.contract), - cardinality: entry.cardinality ?? 'single', - producerPolicy: entry.producerPolicy ?? 'any', - })).sort((left, right) => left.contract.localeCompare(right.contract)), - produces: unique(profile.signals?.publishes), - }; -} - -function cycles(nodes: PlanningGraphNode[], edges: PlanningGraphEdge[]): PlanningGraphDiagnostic[] { - const outgoing = new Map(nodes.map((entry) => [entry.id, [] as string[]])); - const incoming = new Map(nodes.map((entry) => [entry.id, 0])); - for (const edge of edges) { outgoing.get(edge.fromNodeId)?.push(edge.toNodeId); incoming.set(edge.toNodeId, (incoming.get(edge.toNodeId) ?? 0) + 1); } - const ready = nodes.filter((entry) => incoming.get(entry.id) === 0).map((entry) => entry.id).sort(); - let visited = 0; - while (ready.length) { - const id = ready.shift() as string; visited += 1; - for (const target of outgoing.get(id) ?? []) { const count = (incoming.get(target) ?? 0) - 1; incoming.set(target, count); if (!count) ready.push(target); } - ready.sort(); - } - return visited === nodes.length ? [] : nodes.filter((entry) => (incoming.get(entry.id) ?? 0) > 0).map((entry) => ({ - code: 'cycle', nodeId: entry.id, message: `Signal graph node ${entry.id} participates in a cycle.`, - })); -} - -export function compileAgentPlanningGraph( - profiles: PlanningGraphProfile[], - options: { externalRoots?: string[]; contracts?: Record } = {}, -): AgentPlanningGraph { - const nodes = profiles.map(node).sort((left, right) => left.id.localeCompare(right.id)); - const externalRoots = unique(options.externalRoots); - const diagnostics: PlanningGraphDiagnostic[] = []; - const byId = new Map(); - for (const entry of nodes) { - if (byId.has(entry.id)) diagnostics.push({ code: 'duplicate_node', nodeId: entry.id, message: `Signal graph node ${entry.id} is declared more than once.` }); - else byId.set(entry.id, entry); - } - const producers = new Map(); - for (const entry of byId.values()) for (const contract of entry.produces) { - const definition = options.contracts?.[contract]; - if (options.contracts && !definition) diagnostics.push({ code: 'missing_contract', nodeId: entry.id, contractId: contract, message: `${entry.id} publishes unknown signal contract ${contract}.` }); - else if (definition?.allowedProducerProfiles?.length && !definition.allowedProducerProfiles.includes(entry.activityType)) diagnostics.push({ code: 'publisher_not_allowed', nodeId: entry.id, contractId: contract, message: `${entry.activityType} may not publish ${contract}.` }); - producers.set(contract, [...(producers.get(contract) ?? []), entry]); - } - const grouped = new Map(); - for (const entry of byId.values()) for (const subscription of entry.requires) { - const definition = options.contracts?.[subscription.contract]; - if (options.contracts && !definition) diagnostics.push({ code: 'missing_contract', nodeId: entry.id, contractId: subscription.contract, message: `${entry.id} subscribes to unknown signal contract ${subscription.contract}.` }); - if (definition?.subscriberActivityProfiles?.length && !definition.subscriberActivityProfiles.includes(entry.activityType)) diagnostics.push({ code: 'subscriber_not_allowed', nodeId: entry.id, contractId: subscription.contract, message: `${entry.activityType} may not subscribe to ${subscription.contract}.` }); - const invalidFilters = Object.keys(subscription.filters ?? {}).filter((key) => !definition?.filterableFields?.includes(key)); - if (definition && invalidFilters.length) diagnostics.push({ code: 'filter_not_allowed', nodeId: entry.id, contractId: subscription.contract, message: `${subscription.contract} does not expose filter fields: ${invalidFilters.join(', ')}.` }); - const candidates = (producers.get(subscription.contract) ?? []).filter((producer) => producer.id !== entry.id); - const self = (producers.get(subscription.contract) ?? []).some((producer) => producer.id === entry.id); - if (!candidates.length && !externalRoots.includes(subscription.contract)) diagnostics.push({ - code: self ? 'self_dependency' : 'missing_producer', nodeId: entry.id, contractId: subscription.contract, - message: `${entry.id} subscribes to ${subscription.contract}, but ${self ? 'only publishes it itself' : 'no selected profile or external root publishes it'}.`, - }); - if (subscription.producerPolicy === 'quorum' && (subscription.quorum ?? 0) > candidates.length) diagnostics.push({ - code: 'invalid_quorum', nodeId: entry.id, contractId: subscription.contract, - message: `${entry.id} requires quorum ${subscription.quorum}, but only ${candidates.length} producers exist.`, - }); - for (const producer of candidates) { const key = `${producer.id}\u0000${entry.id}`; grouped.set(key, [...(grouped.get(key) ?? []), subscription.contract]); } - } - const edges = [...grouped].map(([key, contracts]) => { const [fromNodeId, toNodeId] = key.split('\u0000'); return { fromNodeId, toNodeId, contracts: unique(contracts) }; }) - .sort((left, right) => `${left.fromNodeId}:${left.toNodeId}`.localeCompare(`${right.fromNodeId}:${right.toNodeId}`)); - diagnostics.push(...cycles([...byId.values()], edges)); - return { nodes: [...byId.values()], edges, externalRoots, diagnostics, ok: diagnostics.length === 0 }; -} - -function matchesFilters(reference: PlanningGraphEvidenceReference, filters: Record = {}): boolean { - const values = { ...(reference.payload ?? {}), ...(reference.metadata ?? {}) }; - return Object.entries(filters).every(([key, expected]) => { - const actual = values[key]; - return Array.isArray(expected) ? expected.includes(actual) || (Array.isArray(actual) && actual.some((item) => expected.includes(item))) : actual === expected; - }); -} - -function satisfying(graph: AgentPlanningGraph, node: PlanningGraphNode, subscription: SignalSubscription, evidence: PlanningGraphNodeEvidence[], groups?: PlanningGraphGroupContext) { - const predecessorIds = new Set(graph.edges.filter((edge) => edge.toNodeId === node.id && edge.contracts.includes(subscription.contract)).map((edge) => edge.fromNodeId)); - return evidence.flatMap((entry) => entry.references.filter((reference) => - (entry.nodeId === '$external' || predecessorIds.has(entry.nodeId)) && normalize(reference.contractId) === subscription.contract - && matchesFilters(reference, subscription.filters) && matchesGroups(node, subscription, reference, groups), - ).map((reference) => ({ nodeId: entry.nodeId, reference }))); -} - -function matchesGroups(node: PlanningGraphNode, subscription: SignalSubscription, reference: PlanningGraphEvidenceReference, groups?: PlanningGraphGroupContext) { - if (!subscription.groupScope) return true; - if (!groups) return false; - const agentMembership = groups.agentMembershipByNodeId[node.id]; - const subjectMembership = reference.groupMembership; - if (!agentMembership || !subjectMembership) return false; - return matchSignalGroupScope({ scope: subscription.groupScope, agentMembership, subjectMembership, depthByGroupId: groups.depthByGroupId }).matched; -} - -export function evaluatePlanningGraphNode(graph: AgentPlanningGraph, nodeId: string, evidence: PlanningGraphNodeEvidence[], groups?: PlanningGraphGroupContext) { - const selected = graph.nodes.find((entry) => entry.id === nodeId); - if (!selected || !graph.ok) return { ready: false, missing: selected?.requires ?? [], matched: [] as PlanningGraphNodeEvidence[] }; - const matched = new Map(); - const missing = selected.requires.filter((subscription) => { - const found = satisfying(graph, selected, subscription, evidence, groups); - for (const item of found) matched.set(item.nodeId, [...(matched.get(item.nodeId) ?? []), item.reference]); - const producerCount = new Set(found.map((item) => item.nodeId)).size; - if (subscription.producerPolicy === 'all') { - const expected = new Set(graph.edges.filter((edge) => edge.toNodeId === nodeId && edge.contracts.includes(subscription.contract)).map((edge) => edge.fromNodeId)).size; - return producerCount < expected; - } - if (subscription.producerPolicy === 'quorum') return producerCount < (subscription.quorum ?? 1); - return found.length === 0; - }); - return { ready: missing.length === 0, missing, matched: [...matched].map(([entryNodeId, references]) => ({ nodeId: entryNodeId, references })) }; -} - -export function evaluatePlanningGraphNodeInstances(graph: AgentPlanningGraph, nodeId: string, evidence: PlanningGraphNodeEvidence[], groups?: PlanningGraphGroupContext) { - const selected = graph.nodes.find((entry) => entry.id === nodeId); - if (!selected) return []; - const each = selected.requires.find((entry) => entry.cardinality === 'each'); - if (!each) { const result = evaluatePlanningGraphNode(graph, nodeId, evidence, groups); return result.ready ? [{ instanceKey: 'single', matched: result.matched }] : []; } - const anchors = satisfying(graph, selected, each, evidence, groups); - return anchors.flatMap(({ reference }) => { - const subject = reference.subjectId ?? reference.recordId; - const scoped = evidence.map((entry) => ({ ...entry, references: entry.references.filter((candidate) => (candidate.subjectId ?? candidate.recordId) === subject) })); - const result = evaluatePlanningGraphNode(graph, nodeId, scoped, groups); - return result.ready ? [{ instanceKey: subject, matched: result.matched }] : []; - }).filter((entry, index, all) => all.findIndex((candidate) => candidate.instanceKey === entry.instanceKey) === index) - .sort((left, right) => left.instanceKey.localeCompare(right.instanceKey)); -} diff --git a/src/api/capacity/provider-control-plane.ts b/src/api/capacity/provider-control-plane.ts index 8bb52a39..33a0d33b 100644 --- a/src/api/capacity/provider-control-plane.ts +++ b/src/api/capacity/provider-control-plane.ts @@ -5,11 +5,6 @@ import { listCapacityExecutionProviders } from './repositories/capacity/provider import { CapacityProviderIdentityRepository } from './repositories/capacity/providers/provider-identity.ts'; import { listExecutionRunsForTeamPage as readExecutionRunsForTeamPage } from './repositories/support/execution-run.ts'; import { -persistAgentModeRun, -readAgentModeRun, -listAgentModeRunsPage as readAgentModeRunsPage, -} from './repositories/support/mode-run.ts'; -import { AvailabilitySessionService, type ProviderAvailabilityPrincipal, } from './services/accounts/availability-session-service.ts'; @@ -18,7 +13,6 @@ import { CapacityGrantService } from './services/capacity/allocations/grant-serv import type { ProviderSynthesisRequest } from './services/capacity/assignments/context/assignment-synthesis-service.ts'; import { leaseNextProviderAssignment as leaseProviderAssignment } from './services/capacity/assignments/lifecycle/assignment-lease-service.ts'; import { ProviderAssignmentLifecycleService } from './services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts'; -import { preflightProviderAssignmentCompletion } from './services/capacity/assignments/lifecycle/assignment-completion-preflight-service.ts'; import { resolveProviderSynthesisContext } from './services/capacity/providers/provider-synthesis-context-service.ts'; import { ProjectAgentClassService } from './services/projects/agents/project-agent-class-service.ts'; @@ -37,9 +31,7 @@ export interface ProviderControlPlaneContext extends CapacityGovernanceDatabase type ProviderServiceContext = ProviderControlPlaneContext & ConstructorParameters[0] & Parameters[0] - & ConstructorParameters[0] - & Parameters[0] - & Parameters[0]; + & ConstructorParameters[0]; export class ProviderControlPlane { private readonly providerContext: ProviderServiceContext; @@ -144,27 +136,12 @@ export class ProviderControlPlane { return new ProviderAssignmentLifecycleService(this.providerContext).complete(principal, assignmentId, input); } - preflightProviderAssignmentCompletion(principal: ProviderLeasePrincipal, assignmentId: string, input: JsonRecord) { - return preflightProviderAssignmentCompletion(this.providerContext,principal,assignmentId,input); - } - failProviderAssignment(principal: ProviderLeasePrincipal, assignmentId: string, input: Parameters[2]) { return new ProviderAssignmentLifecycleService(this.providerContext).fail(principal, assignmentId, input); } - createAgentModeRun(input: Parameters[1]) { - return persistAgentModeRun(this.providerContext, input); - } - - listAgentModeRunsPage(projectId: string, filters: Parameters[2] = {}) { - return readAgentModeRunsPage(this.providerContext, projectId, filters); - } - listExecutionRunsForTeamPage(teamId: string, filters: Parameters[2] = {}) { return readExecutionRunsForTeamPage(this.providerContext, teamId, filters); } - getAgentModeRun(teamId: string, modeRunId: string) { - return readAgentModeRun(this.providerContext, teamId, modeRunId); - } } diff --git a/src/api/capacity/repositories/capacity/accounting/ledger.ts b/src/api/capacity/repositories/capacity/accounting/ledger.ts index 74b2a2c2..523e9ec7 100644 --- a/src/api/capacity/repositories/capacity/accounting/ledger.ts +++ b/src/api/capacity/repositories/capacity/accounting/ledger.ts @@ -42,7 +42,7 @@ export function serializeCapacityLedgerEntryRow(row: Row | null): CapacityLedger return { id: requiredText(row, 'id'), settlementKey: requiredText(row, 'settlement_key'), membershipId: requiredText(row, 'membership_id'), capacityProviderId: requiredText(row, 'capacity_provider_id'), reservationId: nullableText(row, 'reservation_id'), assignmentId: nullableText(row, 'assignment_id'), - modeRunId: nullableText(row, 'mode_run_id'), mode: mode as 'planning' | 'acting' | null, teamId: requiredText(row, 'team_id'), projectId: nullableText(row, 'project_id'), + mode: mode as 'planning' | 'acting' | null, teamId: requiredText(row, 'team_id'), projectId: nullableText(row, 'project_id'), workDayId: nullableText(row, 'work_day_id'), taskId: nullableText(row, 'task_id'), phase, activeSeconds: number(row, 'active_seconds')!, elapsedSeconds: number(row, 'elapsed_seconds')!, providerUnits: number(row, 'provider_units', true), usd: number(row, 'usd', true), source: requiredText(row, 'source'), diff --git a/src/api/capacity/repositories/capacity/accounting/reservation.ts b/src/api/capacity/repositories/capacity/accounting/reservation.ts index 30c30937..a8737ed6 100644 --- a/src/api/capacity/repositories/capacity/accounting/reservation.ts +++ b/src/api/capacity/repositories/capacity/accounting/reservation.ts @@ -50,11 +50,6 @@ function object(row: Row, column: string): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : corrupt(row, column); } -function strings(row: Row, column: string): string[] { - const value = json(row, column); - return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry) ? value : corrupt(row, column); -} - function json(row: Row, column: string): unknown { const encoded = row[column]; if (typeof encoded !== 'string') return encoded; @@ -70,13 +65,10 @@ export function serializeCapacityReservationRow(row: Row | null): CapacityReserv if (!row) return null; const mode = requiredText(row, 'mode'); if (mode !== 'planning' && mode !== 'acting') corrupt(row, 'mode'); - const allocationVersion = number(row, 'allocation_version'); - if (!Number.isInteger(allocationVersion) || allocationVersion! < 1) corrupt(row, 'allocation_version'); return { id: requiredText(row, 'id'), idempotencyKey: requiredText(row, 'idempotency_key'), membershipId: requiredText(row, 'membership_id'), - grantId: requiredText(row, 'grant_id'), capacityProviderId: requiredText(row, 'capacity_provider_id'), + grantId: nullableText(row, 'grant_id'), capacityProviderId: requiredText(row, 'capacity_provider_id'), executionProviderId: nullableText(row, 'execution_provider_id'), laneId: nullableText(row, 'lane_id'), - allocationSetId: requiredText(row, 'allocation_set_id'), allocationVersion: allocationVersion!, allocationSliceIds: strings(row, 'allocation_slice_ids_json'), policySnapshot: object(row, 'policy_snapshot_json'), projectAgentClassId: requiredText(row, 'project_agent_class_id'), assignmentId: nullableText(row, 'assignment_id'), mode, teamId: requiredText(row, 'team_id'), projectId: requiredText(row, 'project_id'), workDayId: nullableText(row, 'work_day_id'), taskId: nullableText(row, 'task_id'), state: state(row), diff --git a/src/api/capacity/repositories/capacity/accounting/task-usage.ts b/src/api/capacity/repositories/capacity/accounting/task-usage.ts index 2c0bac19..e8ba3201 100644 --- a/src/api/capacity/repositories/capacity/accounting/task-usage.ts +++ b/src/api/capacity/repositories/capacity/accounting/task-usage.ts @@ -99,7 +99,6 @@ export function serializeTaskUsageActualRow(row: Row | null): CapacityUsageActua assignmentAttempt: nonnegativeNumber(row, 'assignment_attempt'), usageDimension: requiredText(row, 'usage_dimension'), accountingMode: accountingMode(row.accounting_mode), - modeRunId: nullableText(row.mode_run_id), mode: capacityMode(row.mode), capacityProviderId: nullableText(row.capacity_provider_id), executionProviderId: nullableText(row.execution_provider_id), diff --git a/src/api/capacity/repositories/capacity/assignments/assignment.ts b/src/api/capacity/repositories/capacity/assignments/assignment.ts index fed4b578..6fe48084 100644 --- a/src/api/capacity/repositories/capacity/assignments/assignment.ts +++ b/src/api/capacity/repositories/capacity/assignments/assignment.ts @@ -18,6 +18,7 @@ export interface ProviderAssignmentFilters { projectId?: string | null; providerId?: string | null; status?: string | null; + mode?: string | null; assignmentId?: string | null; workdayId?: string | null; executionProviderId?: string | null; @@ -103,7 +104,6 @@ function serializeExecutableAssignmentRow(row: Row | null): DurableProviderAssig handoffDepth: Number(row.handoff_depth ?? 0), sourceMessageRefs: (() => { try { return JSON.parse(text(row.source_message_refs_json) || '[]') as string[]; } catch { return []; } })(), operationHandoffId: row.operation_handoff_id == null ? null : text(row.operation_handoff_id), - allocationSetId: row.allocation_set_id == null ? null : text(row.allocation_set_id), projectAgentClassId: text(row.project_agent_class_id), reservationId: row.reservation_id == null ? null : text(row.reservation_id), workDayId: row.work_day_id == null ? null : text(row.work_day_id), @@ -119,7 +119,6 @@ function serializeExecutableAssignmentRow(row: Row | null): DurableProviderAssig agentId: row.agent_id == null ? null : text(row.agent_id), handlerId: row.handler_id == null ? null : text(row.handler_id), capacityEnvelope: json(row.capacity_envelope_json, {}, 'capacity_envelope_json', id) as unknown as DurableProviderAssignment['capacityEnvelope'], - decisionInput: json(row.decision_input_json, {}, 'decision_input_json', id), workspaceContext, allowedOutputs: json(row.allowed_outputs_json, {}, 'allowed_outputs_json', id), explanation: json(row.explanation_json, {}, 'explanation_json', id), @@ -192,6 +191,7 @@ export class ProviderAssignmentRepository { [filters.projectId, 'assignment.project_id'], [filters.providerId, 'assignment.capacity_provider_id'], [filters.status, 'assignment.status'], + [filters.mode, 'assignment.mode'], [filters.assignmentId, 'assignment.id'], [filters.workdayId, 'assignment.work_day_id'], [filters.executionProviderId, 'assignment.execution_provider_id'], diff --git a/src/api/capacity/repositories/capacity/planning/agent-capacity-plan.ts b/src/api/capacity/repositories/capacity/planning/agent-capacity-plan.ts deleted file mode 100644 index b469b429..00000000 --- a/src/api/capacity/repositories/capacity/planning/agent-capacity-plan.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { -AgentCapacityPlanRecord, -AgentCapacityPlanWorkUnit, -DurableAgentCapacityPlanStatus, -} from '@treeseed/sdk/agent-capacity'; -import type { CapacityGovernanceDatabase } from '../../../database.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { decodeDurableJsonArray,decodeDurableJsonObject } from '../../../durable-json.ts'; - -type Row = Record; -type JsonRecord = Record; - -const STATUSES = new Set([ - 'draft', 'accepted', 'revision_requested', 'deferred', 'scheduled', 'active', 'completed', 'superseded', -]); - -export interface AgentCapacityPlanTransitionInput { - workDayId?: string | null; - allocationSetId?: string | null; - reason?: string | null; - review?: JsonRecord; - metadata?: JsonRecord; - idempotencyKey?: string; -} - -function text(value: unknown): string { - return value == null ? '' : String(value); -} - -function nullableText(value: unknown): string | null { - return value == null ? null : String(value); -} - -function status(value: unknown): DurableAgentCapacityPlanStatus { - const candidate = text(value) as DurableAgentCapacityPlanStatus; - if (!STATUSES.has(candidate)) { - throw new CapacityGovernanceError('agent_capacity_plan_status_invalid', `Unknown agent capacity plan status ${candidate || '(empty)'}.`, 500); - } - return candidate; -} - -function finiteAmount(value: unknown, field: string, planId: string): number { - const amount = Number(value); - if (!Number.isFinite(amount) || amount < 0) { - throw new CapacityGovernanceError('agent_capacity_plan_amount_invalid', `Agent capacity plan ${planId} has invalid ${field}.`, 500, { planId, field }); - } - return amount; -} - -function context(id: string, column: string) { - return { owner: 'agent capacity plan', ownerId: id, column }; -} - -export function serializeAgentCapacityPlanRow(row: Row | null): AgentCapacityPlanRecord | null { - if (!row) return null; - const id = text(row.id); - const expectedSeconds = finiteAmount(row.expected_seconds, 'expected_seconds', id); - const highSeconds = finiteAmount(row.high_seconds, 'high_seconds', id); - if (highSeconds < expectedSeconds) { - throw new CapacityGovernanceError('agent_capacity_plan_amount_invalid', `Agent capacity plan ${id} high time is below expected time.`, 500, { planId: id }); - } - for (const [field, value] of [['teamId', row.team_id], ['projectId', row.project_id], ['decisionId', row.decision_id], ['scopeHash', row.scope_hash]] as const) { - if (!text(value)) throw new CapacityGovernanceError('agent_capacity_plan_field_invalid', `Agent capacity plan ${id || '(unknown)'} requires ${field}.`, 500, { planId: id || null, field }); - } - return { - id, - teamId: text(row.team_id), - projectId: text(row.project_id), - decisionId: text(row.decision_id), - status: status(row.status), - scopeHash: text(row.scope_hash), - allocationSetId: nullableText(row.allocation_set_id), - workDayId: nullableText(row.work_day_id), - expectedSeconds, - highSeconds, - workUnits: decodeDurableJsonArray(row.work_units_json, context(id, 'work_units_json')), - capabilityNeeds: decodeDurableJsonArray(row.capability_needs_json, context(id, 'capability_needs_json')), - environmentNeeds: decodeDurableJsonArray(row.environment_needs_json, context(id, 'environment_needs_json')), - reserves: decodeDurableJsonObject(row.reserves_json, context(id, 'reserves_json')), - blockers: decodeDurableJsonArray(row.blockers_json, context(id, 'blockers_json')), - priorityRationale: nullableText(row.priority_rationale), - review: decodeDurableJsonObject(row.review_json, context(id, 'review_json')), - metadata: decodeDurableJsonObject(row.metadata_json, context(id, 'metadata_json')), - acceptedAt: nullableText(row.accepted_at), - scheduledAt: nullableText(row.scheduled_at), - supersededAt: nullableText(row.superseded_at), - createdAt: text(row.created_at), - updatedAt: text(row.updated_at), - }; -} - -export class AgentCapacityPlanRepository { - constructor(private readonly database: CapacityGovernanceDatabase) {} - - async upsert(plan: AgentCapacityPlanRecord, review: JsonRecord = {}) { - await this.database.ensureInitialized(); - const now = plan.updatedAt ?? new Date().toISOString(); - await this.database.run( - `INSERT INTO agent_capacity_plans ( - id, team_id, project_id, decision_id, status, scope_hash, allocation_set_id, work_day_id, - expected_seconds, high_seconds, work_units_json, capability_needs_json, environment_needs_json, - reserves_json, blockers_json, priority_rationale, review_json, metadata_json, - accepted_at, scheduled_at, superseded_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - team_id = EXCLUDED.team_id, project_id = EXCLUDED.project_id, decision_id = EXCLUDED.decision_id, - status = EXCLUDED.status, scope_hash = EXCLUDED.scope_hash, - allocation_set_id = EXCLUDED.allocation_set_id, work_day_id = EXCLUDED.work_day_id, - expected_seconds = EXCLUDED.expected_seconds, high_seconds = EXCLUDED.high_seconds, - work_units_json = EXCLUDED.work_units_json, capability_needs_json = EXCLUDED.capability_needs_json, - environment_needs_json = EXCLUDED.environment_needs_json, reserves_json = EXCLUDED.reserves_json, - blockers_json = EXCLUDED.blockers_json, priority_rationale = EXCLUDED.priority_rationale, - review_json = EXCLUDED.review_json, metadata_json = EXCLUDED.metadata_json, - accepted_at = EXCLUDED.accepted_at, scheduled_at = EXCLUDED.scheduled_at, - superseded_at = EXCLUDED.superseded_at, updated_at = EXCLUDED.updated_at`, - [ - plan.id, plan.teamId, plan.projectId, plan.decisionId, status(plan.status), plan.scopeHash, - plan.allocationSetId ?? null, plan.workDayId ?? null, plan.expectedSeconds, plan.highSeconds, - JSON.stringify(plan.workUnits), JSON.stringify(plan.capabilityNeeds), JSON.stringify(plan.environmentNeeds), - JSON.stringify(plan.reserves), JSON.stringify(plan.blockers), plan.priorityRationale ?? null, - JSON.stringify(review), JSON.stringify(plan.metadata ?? {}), plan.acceptedAt ?? null, - plan.scheduledAt ?? null, plan.supersededAt ?? null, plan.createdAt ?? now, now, - ], - ); - return this.get(plan.id); - } - - async get(planId: string) { - await this.database.ensureInitialized(); - return serializeAgentCapacityPlanRow(await this.database.first(`SELECT * FROM agent_capacity_plans WHERE id = ? LIMIT 1`, [planId])); - } - - async list(decisionId: string, filters: { status?: DurableAgentCapacityPlanStatus | null } = {}) { - await this.database.ensureInitialized(); - const values: unknown[] = [decisionId]; - const statusClause = filters.status ? ' AND status = ?' : ''; - if (filters.status) values.push(status(filters.status)); - const rows = await this.database.all( - `SELECT * FROM agent_capacity_plans WHERE decision_id = ?${statusClause} ORDER BY created_at DESC, id DESC LIMIT 200`, values, - ); - return rows.map((row) => serializeAgentCapacityPlanRow(row) as AgentCapacityPlanRecord); - } - - async transition(planId: string, nextStatus: DurableAgentCapacityPlanStatus, input: AgentCapacityPlanTransitionInput = {}) { - const existing = await this.get(planId); - if (!existing) return null; - const next = status(nextStatus); - const now = new Date().toISOString(); - await this.database.run( - `UPDATE agent_capacity_plans SET - status = ?, work_day_id = COALESCE(?, work_day_id), allocation_set_id = COALESCE(?, allocation_set_id), - accepted_at = CASE WHEN ? = 'accepted' THEN COALESCE(accepted_at, ?) ELSE accepted_at END, - scheduled_at = CASE WHEN ? = 'scheduled' THEN COALESCE(scheduled_at, ?) ELSE scheduled_at END, - superseded_at = CASE WHEN ? = 'superseded' THEN COALESCE(superseded_at, ?) ELSE superseded_at END, - review_json = ?, metadata_json = ?, updated_at = ? WHERE id = ?`, - [ - next, input.workDayId ?? null, input.allocationSetId ?? null, - next, now, next, now, next, now, - JSON.stringify({ ...(existing.review ?? {}), ...(input.review ?? {}) }), - JSON.stringify({ ...(existing.metadata ?? {}), ...(input.metadata ?? {}), statusReason: input.reason ?? null }), - now, planId, - ], - ); - return this.get(planId); - } -} diff --git a/src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts b/src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts deleted file mode 100644 index 4581a0d5..00000000 --- a/src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { type CapacitySupplyCandidate } from '@treeseed/sdk/agent-capacity'; -import { capacitySupplyCandidateStatus,selectCapacitySupply } from '../../../policy/supply-selection.ts'; -import type { CapacityGovernanceDatabase } from '../../../database.ts'; -import { decodeDurableJsonArray,decodeDurableJsonObject } from '../../../durable-json.ts'; -import { teamSupplyPolicy } from '../../../domain/supply-policy.ts'; - -type Row = Record; - -function strings(value: unknown, context: { owner: string; ownerId: string; column: string }): string[] { - return decodeDurableJsonArray(value, context).map(String).filter(Boolean); -} - -function providers(row: Row, grants: Row[], mode: string, lanePurpose: string, requiredCapabilities: string[]): CapacitySupplyCandidate[] { - return decodeDurableJsonArray(row.execution_providers_json, { - owner: 'provider availability session', ownerId: String(row.id), column: 'execution_providers_json', - }).flatMap((provider) => grants.filter((grant) => { - const ownerId = String(grant.id); - const executionProviderIds = strings(grant.execution_provider_ids_json, { owner: 'capacity grant', ownerId, column: 'execution_provider_ids_json' }); - const allowedModes = strings(grant.allowed_modes_json, { owner: 'capacity grant', ownerId, column: 'allowed_modes_json' }); - return String(grant.membership_id) === String(row.membership_id) - && (!executionProviderIds.length || executionProviderIds.includes(String(provider.id))) - && allowedModes.includes(mode); - }).map((grant) => { - const granted = new Set(strings(grant.capabilities_json, { owner: 'capacity grant', ownerId: String(grant.id), column: 'capabilities_json' })); - const advertised = Array.isArray(provider.capabilities) ? provider.capabilities.map(String).filter(Boolean) : []; - const matchingLane = Array.isArray(provider.lanes) && provider.lanes.some((value: unknown) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const lane = value as Row; - const capabilities = Array.isArray(lane.capabilities) ? lane.capabilities.map(String) : []; - return typeof lane.id === 'string' && Boolean(lane.id.trim()) && lane.purpose === lanePurpose && Math.max(1, Number(lane.maxConcurrentRunners ?? 1)) > 0 - && requiredCapabilities.every(capability => !capabilities.length || capabilities.includes(capability)); - }); - return ({ - capacityProviderId: String(row.capacity_provider_id), membershipId: String(row.membership_id), - providerSessionId: String(row.id), grantId: String(grant.id), executionProviderId: String(provider.id ?? ''), - status: matchingLane ? capacitySupplyCandidateStatus(provider.status) : 'unavailable', - capabilities: advertised.filter((capability) => granted.has(capability)), - reliability: Number.isFinite(Number(provider.reliability)) ? Math.max(0, Math.min(1, Number(provider.reliability))) : 1, - pressure: ['idle','normal','busy','throttled','exhausted'].includes(String(provider.pressure)) ? provider.pressure as CapacitySupplyCandidate['pressure'] : 'normal', - availableConcurrency: Number.isInteger(Number(provider.availableConcurrency)) ? Math.max(0, Number(provider.availableConcurrency)) : 1, - preferred: provider.preferred === true, - estimatedCost: Number.isFinite(Number(provider.estimatedCost)) ? Number(provider.estimatedCost) : null, - }); - })).filter((provider) => provider.executionProviderId); -} - -export async function selectWorkdayDemandSupply(database: CapacityGovernanceDatabase, demand: Row, now: string) { - const metadata = decodeDurableJsonObject(demand.metadata_json, { owner: 'capacity workday demand', ownerId: String(demand.id), column: 'metadata_json' }); - const environment = String(metadata.environment ?? 'local'); - const [team, sessions, grants] = await Promise.all([ - database.first('SELECT metadata_json FROM teams WHERE id = ? LIMIT 1', [demand.team_id]), - database.all(`SELECT session.* FROM capacity_provider_availability_sessions session - JOIN capacity_provider_team_memberships membership ON membership.id = session.membership_id - JOIN capacity_providers provider ON provider.id = session.capacity_provider_id - WHERE session.team_id = ? AND session.status = 'open' AND session.expires_at > ? - AND (session.available_from IS NULL OR session.available_from <= ?) - AND (session.available_until IS NULL OR session.available_until > ?) - AND membership.status = 'approved' AND provider.status = 'active'`, - [demand.team_id, now, now, now]), - database.all(`SELECT * FROM capacity_grants WHERE team_id = ? AND project_id = ? AND environment = ? - AND status = 'active' AND (expires_at IS NULL OR expires_at > ?)`, [demand.team_id, demand.project_id, environment, now]), - ]); - const policy = teamSupplyPolicy(team); - const capabilityDemand = metadata.capabilityDemand && typeof metadata.capabilityDemand === 'object' && !Array.isArray(metadata.capabilityDemand) - ? metadata.capabilityDemand as Row : {}; - const resolvedCapabilities = Array.isArray(capabilityDemand.resolved) - ? capabilityDemand.resolved.flatMap((entry) => entry && typeof entry === 'object' && !Array.isArray(entry) && typeof (entry as Row).id === 'string' ? [String((entry as Row).id)] : []) - : []; - if (resolvedCapabilities.length === 0) throw new Error(`Capacity demand ${String(demand.id)} has no resolved capability snapshot.`); - const requiredCapabilities = [...new Set(resolvedCapabilities)]; - const primaryProviderId = String(demand.primary_provider_id ?? ''); - const failoverCount = Math.max(0, Number(metadata.failoverCount ?? 0)); - const failoverAllowed = failoverCount < policy.maxFailovers && (demand.mode === 'acting' ? policy.allowActingFailover : policy.allowPlanningFailover); - const attemptedSupply = new Set((Array.isArray(metadata.failoverHistory) ? metadata.failoverHistory : []).map((entry) => { - const value = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry as Row : {}; - return `${String(value.capacityProviderId ?? '')}:${String(value.executionProviderId ?? '')}`; - })); - const lanePurpose = metadata.lanePurpose === 'platform' ? 'platform' : metadata.executionKind === 'conversation' ? 'communication' : 'workday'; - const portfolio = sessions.flatMap((session) => providers(session, grants, String(demand.mode), lanePurpose, requiredCapabilities)) - .filter((candidate) => !failoverCount || !attemptedSupply.has(`${candidate.capacityProviderId}:${candidate.executionProviderId}`)); - const candidates = failoverAllowed ? portfolio : portfolio.filter((candidate) => candidate.capacityProviderId === primaryProviderId); - const selection = selectCapacitySupply({ candidates, requiredCapabilities, policy }); - return { ...selection, policy }; -} diff --git a/src/api/capacity/repositories/capacity/workdays/workday-demand.ts b/src/api/capacity/repositories/capacity/workdays/workday-demand.ts deleted file mode 100644 index bf3564e8..00000000 --- a/src/api/capacity/repositories/capacity/workdays/workday-demand.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { -CapacityWorkdayDemandRecord, -CapacityWorkdayDemandSource, -CapacityWorkdayDemandStatus, -} from '@treeseed/sdk/agent-capacity'; -import { randomUUID } from 'node:crypto'; -import type { CapacityGovernanceDatabase } from '../../../database.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { decodeDurableJsonObject } from '../../../durable-json.ts'; -import { selectWorkdayDemandSupply } from './workday-demand-supply.ts'; -import { CapacityAuditRepository } from '../../support/audit.ts'; - -type Row = Record; -type JsonRecord = Record; - -const SOURCES = new Set([ - 'objective', 'question', 'proposal', 'decision-review', 'knowledge-gap', 'release-readiness', - 'idle-intent', 'planning-input', 'capacity-plan', 'assignment-completion', 'assignment-blockage', - 'workday-summary', 'handoff', - 'research-workflow', -]); -const STATUSES = new Set([ - 'pending', 'claimed', 'admitted', 'completed', 'blocked', 'cancelled', 'superseded', -]); - -function required(row: Row, column: string): string { - const value = row[column]; - if (typeof value !== 'string' || !value) throw new CapacityGovernanceError( - 'capacity_workday_demand_corrupt', `Capacity workday demand has invalid ${column}.`, 500, - { demandId: typeof row.id === 'string' ? row.id : null, column }, - ); - return value; -} -function nullable(value: unknown): string | null { return typeof value === 'string' && value ? value : null; } -function finite(row: Row, column: string, integer = false): number { - const value = Number(row[column]); - if (!Number.isFinite(value) || (integer && !Number.isInteger(value))) throw new CapacityGovernanceError( - 'capacity_workday_demand_corrupt', `Capacity workday demand has invalid ${column}.`, 500, - { demandId: String(row.id ?? ''), column, value: row[column] ?? null }, - ); - return value; -} - -export function capacityDemandRunDeadlineOpen(parameters: unknown, now: string): boolean { - const value = typeof parameters === 'string' ? JSON.parse(parameters) : parameters; - const record = value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; - const deadline = record.deadlineAt; - if (deadline === null || deadline === undefined || deadline === '') return true; - const parsed = Date.parse(String(deadline)); - return Number.isFinite(parsed) && parsed > Date.parse(now); -} - -export function serializeCapacityWorkdayDemandRow(row: Row | null): CapacityWorkdayDemandRecord | null { - if (!row) return null; - const sourceType = required(row, 'source_type') as CapacityWorkdayDemandSource; - const status = required(row, 'status') as CapacityWorkdayDemandStatus; - const mode = required(row, 'mode'); - if (!SOURCES.has(sourceType) || !STATUSES.has(status) || !['planning', 'acting'].includes(mode)) throw new CapacityGovernanceError( - 'capacity_workday_demand_corrupt', 'Capacity workday demand has an unknown source, status, or mode.', 500, - { demandId: String(row.id ?? ''), sourceType, status, mode }, - ); - const requestedSeconds = finite(row, 'requested_seconds', true); - if (requestedSeconds <= 0) throw new CapacityGovernanceError('capacity_workday_demand_corrupt', 'Demand requested time must be positive.', 500, { demandId: String(row.id ?? '') }); - return { - id: required(row, 'id'), teamId: required(row, 'team_id'), projectId: required(row, 'project_id'), - workdayRunId: required(row, 'workday_run_id'), workdayId: required(row, 'workday_id'), sourceType, - sourceId: required(row, 'source_id'), mode: mode as 'planning' | 'acting', - projectAgentClassId: required(row, 'project_agent_class_id'), agentId: nullable(row.agent_id), - handlerId: required(row, 'handler_id'), activityType: required(row, 'activity_type'), - decisionId: nullable(row.decision_id), capacityPlanId: nullable(row.capacity_plan_id), status, - priority: finite(row, 'priority', true), requestedSeconds, idempotencyKey: required(row, 'idempotency_key'), - claimToken: nullable(row.claim_token), assignmentId: nullable(row.assignment_id), - payload: decodeDurableJsonObject(row.payload_json, { owner: 'capacity workday demand', ownerId: String(row.id ?? ''), column: 'payload_json' }), - metadata: decodeDurableJsonObject(row.metadata_json, { owner: 'capacity workday demand', ownerId: String(row.id ?? ''), column: 'metadata_json' }), - availableAt: required(row, 'available_at'), claimedAt: nullable(row.claimed_at), admittedAt: nullable(row.admitted_at), - completedAt: nullable(row.completed_at), createdAt: required(row, 'created_at'), updatedAt: required(row, 'updated_at'), - }; -} - -export interface CapacityWorkdayDemandWrite { - id: string; teamId: string; projectId: string; workdayRunId: string; workdayId: string; - sourceType: CapacityWorkdayDemandSource; sourceId: string; mode: 'planning' | 'acting'; - projectAgentClassId: string; agentId: string | null; handlerId: string; activityType: string; - decisionId?: string | null; capacityPlanId?: string | null; priority: number; requestedSeconds: number; - idempotencyKey: string; payload: JsonRecord; metadata?: JsonRecord; availableAt: string; now: string; -} - -export class CapacityWorkdayDemandRepository { - constructor(private readonly database: CapacityGovernanceDatabase) {} - - async create(value: CapacityWorkdayDemandWrite): Promise { - await this.database.ensureInitialized(); - if (!Number.isInteger(value.requestedSeconds) || value.requestedSeconds <= 0) { - throw new CapacityGovernanceError('capacity_workday_demand_time_invalid', 'Demand requested time must be positive.', 400, { requestedSeconds: value.requestedSeconds }); - } - await this.database.run( - `INSERT INTO capacity_workday_demands (id, team_id, project_id, workday_run_id, workday_id, source_type, source_id, - mode, project_agent_class_id, agent_id, handler_id, activity_type, decision_id, capacity_plan_id, status, priority, - requested_seconds, idempotency_key, payload_json, metadata_json, available_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (team_id, idempotency_key) DO NOTHING`, - [value.id, value.teamId, value.projectId, value.workdayRunId, value.workdayId, value.sourceType, value.sourceId, - value.mode, value.projectAgentClassId, value.agentId, value.handlerId, value.activityType, value.decisionId ?? null, - value.capacityPlanId ?? null, value.priority, value.requestedSeconds, value.idempotencyKey, - JSON.stringify(value.payload), JSON.stringify(value.metadata ?? {}), value.availableAt, value.now, value.now], - ); - const result = serializeCapacityWorkdayDemandRow(await this.database.first( - `SELECT * FROM capacity_workday_demands WHERE team_id = ? AND idempotency_key = ? LIMIT 1`, - [value.teamId, value.idempotencyKey], - )); - if (!result) throw new CapacityGovernanceError('capacity_workday_demand_persistence_failed', 'Demand was not durably persisted.', 500, { idempotencyKey: value.idempotencyKey }); - return result; - } - - async claimNext(teamId: string, providerId: string, now: string, membershipId?: string, providerSessionId?: string): Promise { - await this.database.ensureInitialized(); - const candidates = await this.database.all( - `SELECT demand.*, run.capacity_provider_id AS primary_provider_id, run.parameters_json AS run_parameters_json FROM capacity_workday_demands demand - JOIN capacity_workday_runs run ON run.id = demand.workday_run_id - JOIN workday_capacity_envelopes workday ON workday.id = demand.workday_id - WHERE demand.team_id = ? AND run.status = 'running' - AND workday.status = 'active' AND demand.status = 'pending' AND demand.available_at <= ? - ORDER BY demand.priority DESC, demand.available_at DESC, demand.created_at DESC, demand.id ASC LIMIT 100`, - [teamId, now], - ); - let candidate: Row | null = null; - let supplySelection: Record | null = null; - const selectionDiagnostics: Record[] = []; - for (const pending of candidates) { - if (!capacityDemandRunDeadlineOpen(pending.run_parameters_json, now)) continue; - const selection = await selectWorkdayDemandSupply(this.database, pending, now); - if (selection.selected?.capacityProviderId === providerId - && (!membershipId || selection.selected.membershipId === membershipId) - && (!providerSessionId || selection.selected.providerSessionId === providerSessionId)) { - candidate = pending; - supplySelection = { - policyGeneration: selection.policy.generation, - capacityProviderId: selection.selected.capacityProviderId, - membershipId: selection.selected.membershipId, - providerSessionId: selection.selected.providerSessionId, - grantId: selection.selected.grantId, - executionProviderId: selection.selected.executionProviderId, - }; - break; - } - selectionDiagnostics.push({ demandId: pending.id, selectedProviderId: selection.selected?.capacityProviderId ?? null, - selectedMembershipId: selection.selected?.membershipId ?? null, selectedSessionId: selection.selected?.providerSessionId ?? null }); - } - if (!candidate?.id) { - if (selectionDiagnostics.length > 0 && membershipId && providerSessionId) { - const diagnosticDemandId = String(selectionDiagnostics[0]?.demandId ?? providerSessionId); - await new CapacityAuditRepository(this.database).record({ - id: `audit:claim:${providerSessionId}:${diagnosticDemandId}`, teamId, providerId, membershipId, - actorType: 'provider-membership', actorId: membershipId, - action: 'assignment-function.no-matching-demand', resourceType: 'capacity-workday-demand', resourceId: diagnosticDemandId, - idempotencyKey: `claim:${providerSessionId}:${diagnosticDemandId}`, - metadata: { providerId, membershipId, providerSessionId, candidates: selectionDiagnostics.slice(0, 25) }, now, - }); - } else if (candidates.length === 0 && membershipId && providerSessionId) { - const pending = await this.database.first( - `SELECT demand.id AS demand_id, demand.available_at, demand.status AS demand_status, - run.status AS run_status, run.parameters_json AS run_parameters_json, - workday.status AS workday_status - FROM capacity_workday_demands demand - JOIN capacity_workday_runs run ON run.id = demand.workday_run_id - JOIN workday_capacity_envelopes workday ON workday.id = demand.workday_id - WHERE demand.team_id = ? AND demand.status = 'pending' - ORDER BY demand.created_at DESC LIMIT 1`, - [teamId], - ); - if (pending?.demand_id) { - const diagnosticDemandId = String(pending.demand_id); - const parameters = decodeDurableJsonObject(String(pending.run_parameters_json ?? '{}'), { - owner: 'capacity workday run', ownerId: diagnosticDemandId, column: 'parameters_json', - }); - await new CapacityAuditRepository(this.database).record({ - id: `audit:claim-unavailable:${providerSessionId}:${diagnosticDemandId}`, teamId, providerId, membershipId, - actorType: 'provider-membership', actorId: membershipId, - action: 'assignment-function.no-claimable-demand', resourceType: 'capacity-workday-demand', resourceId: diagnosticDemandId, - idempotencyKey: `claim-unavailable:${providerSessionId}:${diagnosticDemandId}`, - metadata: { providerId, membershipId, providerSessionId, now, - demandStatus: pending.demand_status, availableAt: pending.available_at, - runStatus: pending.run_status, workdayStatus: pending.workday_status, - deadlineAt: parameters.deadlineAt ?? null }, now, - }); - } - } - return null; - } - const claimToken = randomUUID(); - const metadata = decodeDurableJsonObject(candidate.metadata_json, { owner: 'capacity workday demand', ownerId: String(candidate.id), column: 'metadata_json' }); - await this.database.run( - `UPDATE capacity_workday_demands SET status = 'claimed', claim_token = ?, metadata_json = ?, claimed_at = ?, updated_at = ? - WHERE id = ? AND status = 'pending'`, - [claimToken, JSON.stringify({ ...metadata, supplySelection }), now, now, candidate.id], - ); - return serializeCapacityWorkdayDemandRow(await this.database.first( - `SELECT * FROM capacity_workday_demands WHERE claim_token = ? LIMIT 1`, [claimToken], - )); - } - - async markAdmitted(demandId: string, claimToken: string, assignmentId: string, now: string): Promise { - await this.database.run( - `UPDATE capacity_workday_demands SET status = 'admitted', assignment_id = ?, admitted_at = ?, updated_at = ? - WHERE id = ? AND status = 'claimed' AND claim_token = ?`, [assignmentId, now, now, demandId, claimToken], - ); - return serializeCapacityWorkdayDemandRow(await this.database.first(`SELECT * FROM capacity_workday_demands WHERE id = ? LIMIT 1`, [demandId])); - } - - async releaseClaim(demandId: string, claimToken: string, now: string): Promise { - await this.database.run( - `UPDATE capacity_workday_demands SET status = 'pending', claim_token = NULL, claimed_at = NULL, updated_at = ? - WHERE id = ? AND status = 'claimed' AND claim_token = ?`, [now, demandId, claimToken], - ); - } - - async cancelClaim(demandId: string, claimToken: string, now: string): Promise { - await this.database.run( - `UPDATE capacity_workday_demands SET status = 'cancelled', claim_token = NULL, completed_at = ?, updated_at = ? - WHERE id = ? AND status = 'claimed' AND claim_token = ?`, [now, now, demandId, claimToken], - ); - } - - async listProvisioning(teamId: string, providerId: string, limit = 25): Promise { - const rows = await this.database.all( - `SELECT demand.* FROM capacity_workday_demands demand - JOIN capacity_workday_runs run ON run.id = demand.workday_run_id - JOIN treedx_proxy_handles handle ON handle.assignment_id = demand.assignment_id - JOIN capacity_provider_assignments assignment ON assignment.id = demand.assignment_id - WHERE demand.team_id = ? AND run.capacity_provider_id = ? AND run.status = 'running' - AND demand.status = 'admitted' AND handle.status = 'provisioning' - AND assignment.status = 'pending' AND assignment.lease_state = 'unleased' - ORDER BY demand.admitted_at ASC, demand.id ASC LIMIT ?`, [teamId, providerId, limit], - ); - return rows.map((row) => serializeCapacityWorkdayDemandRow(row)!); - } -} diff --git a/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts b/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts deleted file mode 100644 index c287a47f..00000000 --- a/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { WorkdayCapacityEnvelopeRecord } from '@treeseed/sdk/agent-capacity'; -import { randomUUID } from 'node:crypto'; -import { CapacityGovernanceError,type CapacityGovernanceDatabase } from '../../../database.ts'; -import { decodeDurableJsonObject } from '../../../durable-json.ts'; -import { -assertCapacityWorkdayParametersSafe, -assertCapacityWorkdayTransition, -capacityWorkdayStatus, -capacityWorkdayTimestampField, -type CapacityWorkdayStatus, -} from '../../../services/capacity/workdays/lifecycle/workday-lifecycle-service.ts'; -import { CapacityOperationReceiptRepository } from '../../operations/operation-receipt.ts'; - -type JsonRecord = Record; -type Row = Record; - -export interface DurableWorkdayCapacityEnvelope extends WorkdayCapacityEnvelopeRecord { - workdayRunId: string | null; - metadata: JsonRecord; - createdAt: string; - updatedAt: string; -} - -export interface CreateWorkdayCapacityEnvelopeInput { - id?: string; - workDayId?: string; - projectId: string; - workdayRunId?: string | null; - allocationSetId?: string | null; - status?: CapacityWorkdayStatus; - startedAt?: string | null; - pausedAt?: string | null; - completedAt?: string | null; - environment?: string | null; - availableSeconds?: number | null; - envelope?: JsonRecord; - metadata?: JsonRecord; -} - -function text(value: unknown): string { - return value == null ? '' : String(value); -} - -function nullableText(value: unknown): string | null { - return value == null ? null : String(value); -} - -function amount(value: unknown, fallback: number | null): number | null { - if (value == null) return fallback; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function object(value: unknown): JsonRecord { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; -} - -function json(row: Row, id: string, column: string) { - return decodeDurableJsonObject(row[column], { owner: 'workday capacity envelope', ownerId: id, column }); -} - -export function serializeWorkdayCapacityEnvelopeRow(row: Row | null): DurableWorkdayCapacityEnvelope | null { - if (!row) return null; - const id = text(row.id); - return { - id, - teamId: text(row.team_id), - projectId: text(row.project_id), - workdayRunId: nullableText(row.workday_run_id), - allocationSetId: nullableText(row.allocation_set_id), - status: capacityWorkdayStatus(row.status, 'draft'), - startedAt: nullableText(row.started_at), - pausedAt: nullableText(row.paused_at), - completedAt: nullableText(row.completed_at), - envelope: json(row, id, 'envelope_json') as unknown as DurableWorkdayCapacityEnvelope['envelope'], - metadata: json(row, id, 'metadata_json'), - createdAt: text(row.created_at), - updatedAt: text(row.updated_at), - }; -} - -export class WorkdayCapacityEnvelopeRepository { - private readonly operationReceipts: CapacityOperationReceiptRepository; - constructor(private readonly database: CapacityGovernanceDatabase) { - this.operationReceipts = new CapacityOperationReceiptRepository(database); - } - - async create(input: CreateWorkdayCapacityEnvelopeInput, idempotencyKey?: string | null): Promise { - await this.database.ensureInitialized(); - const project = await this.database.first(`SELECT id, team_id FROM projects WHERE id = ? LIMIT 1`, [input.projectId]); - if (!project) return null; - const operation = idempotencyKey - ? { teamId: text(project.team_id), operation: 'capacity-workday.create', idempotencyKey, request: input } - : null; - if (operation) { - const replay = await this.operationReceipts.replay(operation); - if (replay.found) return replay.response; - } - const now = new Date().toISOString(); - const id = input.id ?? input.workDayId ?? randomUUID(); - const suppliedEnvelope = object(input.envelope); - const metadata = object(input.metadata); - const envelope = { - teamId: text(project.team_id), projectId: text(project.id), workDayId: id, - environment: input.environment ?? null, - allocationSetId: input.allocationSetId ?? nullableText(suppliedEnvelope.allocationSetId), - availableSeconds: amount(input.availableSeconds, null), - metadata: object(suppliedEnvelope.metadata ?? metadata), - ...suppliedEnvelope, - }; - assertCapacityWorkdayParametersSafe(envelope.metadata); - const workdayStatus = capacityWorkdayStatus(input.status, 'draft'); - const statement = `INSERT INTO workday_capacity_envelopes ( - id, team_id, project_id, workday_run_id, allocation_set_id, status, started_at, paused_at, - completed_at, envelope_json, metadata_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ${operation ? '' : `ON CONFLICT (id) DO UPDATE SET - team_id = EXCLUDED.team_id, project_id = EXCLUDED.project_id, - workday_run_id = EXCLUDED.workday_run_id, allocation_set_id = EXCLUDED.allocation_set_id, - status = EXCLUDED.status, started_at = EXCLUDED.started_at, paused_at = EXCLUDED.paused_at, - completed_at = EXCLUDED.completed_at, envelope_json = EXCLUDED.envelope_json, - metadata_json = EXCLUDED.metadata_json, updated_at = EXCLUDED.updated_at`}`; - const params = [ - id, project.team_id, project.id, input.workdayRunId ?? null, input.allocationSetId ?? envelope.allocationSetId ?? null, - workdayStatus, input.startedAt ?? null, input.pausedAt ?? null, input.completedAt ?? null, - JSON.stringify(envelope), JSON.stringify(metadata), now, now, - ]; - if (!operation) { - await this.database.run(statement, params); - return this.get(id); - } - const response: DurableWorkdayCapacityEnvelope = { - id, teamId: text(project.team_id), projectId: text(project.id), workdayRunId: input.workdayRunId ?? null, - allocationSetId: input.allocationSetId ?? nullableText(envelope.allocationSetId), status: workdayStatus, - startedAt: input.startedAt ?? null, pausedAt: input.pausedAt ?? null, completedAt: input.completedAt ?? null, - envelope: envelope as DurableWorkdayCapacityEnvelope['envelope'], metadata, createdAt: now, updatedAt: now, - }; - try { - await this.database.batch([ - { query: statement, params }, - this.operationReceipts.insertOperation(operation, 'capacity-workday', id, response, now), - ]); - return response; - } catch (error) { - const raced = await this.operationReceipts.replay(operation); - if (raced.found) return raced.response; - throw error; - } - } - - async get(workdayId: string): Promise { - await this.database.ensureInitialized(); - return serializeWorkdayCapacityEnvelopeRow(await this.database.first( - `SELECT * FROM workday_capacity_envelopes WHERE id = ? LIMIT 1`, [workdayId], - )); - } - - async list(projectId: string, filters: { status?: CapacityWorkdayStatus | null } = {}) { - await this.database.ensureInitialized(); - const values: unknown[] = [projectId]; - const statusClause = filters.status ? ' AND status = ?' : ''; - if (filters.status) values.push(capacityWorkdayStatus(filters.status, 'draft')); - const rows = await this.database.all( - `SELECT * FROM workday_capacity_envelopes WHERE project_id = ?${statusClause} ORDER BY created_at DESC, id DESC LIMIT 200`, - values, - ); - return rows.map((row) => serializeWorkdayCapacityEnvelopeRow(row) as DurableWorkdayCapacityEnvelope); - } - - async transition(workdayId: string, status: CapacityWorkdayStatus, idempotencyKey?: string | null) { - const existing = await this.get(workdayId); - if (!existing) return null; - const next = capacityWorkdayStatus(status, existing.status as CapacityWorkdayStatus); - const operation = idempotencyKey - ? { teamId: existing.teamId, operation: `capacity-workday.transition.${next}`, idempotencyKey, request: { workdayId, status: next } } - : null; - if (operation) { - const replay = await this.operationReceipts.replay(operation); - if (replay.found) return replay.response; - } - assertCapacityWorkdayTransition(capacityWorkdayStatus(existing.status, 'draft'), next); - const now = new Date().toISOString(); - const timestampColumn = capacityWorkdayTimestampField(next); - const statement = `UPDATE workday_capacity_envelopes - SET status = ?, ${timestampColumn ? `${timestampColumn} = COALESCE(${timestampColumn}, ?),` : ''} updated_at = ? - WHERE id = ?${operation ? ' AND status = ?' : ''}`; - const params = timestampColumn - ? [next, now, now, workdayId, ...(operation ? [existing.status] : [])] - : [next, now, workdayId, ...(operation ? [existing.status] : [])]; - if (!operation) { - await this.database.run(statement, params); - return this.get(workdayId); - } - const timestampField = next === 'active' - ? 'startedAt' - : next === 'paused' - ? 'pausedAt' - : next === 'completed' || next === 'cancelled' - ? 'completedAt' - : null; - const response: DurableWorkdayCapacityEnvelope = { - ...existing, status: next, - ...(timestampField ? { [timestampField]: existing[timestampField] ?? now } : {}), - updatedAt: now, - }; - const receipt = this.operationReceipts.insertOperationWhen( - operation, 'capacity-workday', workdayId, response, now, - 'SELECT 1 FROM workday_capacity_envelopes WHERE id = ? AND team_id = ? AND status = ? AND updated_at = ?', - [workdayId, existing.teamId, next, now], - ); - try { - await this.database.batch([{ query: statement, params }, receipt]); - } catch (error) { - const raced = await this.operationReceipts.replay(operation); - if (raced.found) return raced.response; - throw error; - } - const committed = await this.operationReceipts.replay(operation); - if (committed.found) return committed.response; - throw new CapacityGovernanceError('capacity_workday_transition_conflict', 'Capacity workday transition lost a concurrent state change.', 409, { workdayId, expectedStatus: existing.status }); - } -} diff --git a/src/api/capacity/repositories/capacity/workdays/workday-event.ts b/src/api/capacity/repositories/capacity/workdays/workday-event.ts index d984e7dd..c7a28525 100644 --- a/src/api/capacity/repositories/capacity/workdays/workday-event.ts +++ b/src/api/capacity/repositories/capacity/workdays/workday-event.ts @@ -39,7 +39,7 @@ export function serializeCapacityWorkdayEventRow(row: Row | null): CapacityWorkd } return { id: required(row, 'id'), runId: required(row, 'run_id'), teamId: required(row, 'team_id'), - projectId: nullable(row.project_id), workdayId: nullable(row.workday_id), assignmentId: nullable(row.assignment_id), modeRunId: nullable(row.mode_run_id), + projectId: nullable(row.project_id), workdayId: nullable(row.workday_id), assignmentId: nullable(row.assignment_id), eventIndex, eventType: required(row, 'event_type'), status, title: nullable(row.title), message: nullable(row.message), parameters: json(row, 'parameters_json'), context: json(row, 'context_json'), refs: json(row, 'refs_json'), metadata: json(row, 'metadata_json'), createdAt: required(row, 'created_at'), @@ -47,7 +47,7 @@ export function serializeCapacityWorkdayEventRow(row: Row | null): CapacityWorkd } export interface CapacityWorkdayEventWrite { - id: string; projectId: string | null; workdayId: string | null; assignmentId: string | null; modeRunId: string | null; + id: string; projectId: string | null; workdayId: string | null; assignmentId: string | null; eventType: string; status: CapacityWorkdayEventStatus; title: string | null; message: string | null; parameters: JsonRecord; context: JsonRecord; refs: JsonRecord; metadata: JsonRecord; createdAt: string; } @@ -59,10 +59,10 @@ export class CapacityWorkdayEventRepository { await this.database.ensureInitialized(); await this.database.batch([ { query: `UPDATE capacity_workday_runs SET next_event_index = next_event_index + 1 WHERE id = ? AND team_id = ? AND NOT EXISTS (SELECT 1 FROM capacity_workday_events WHERE id = ?)`, params: [runId, teamId, value.id] }, - { query: `INSERT INTO capacity_workday_events (id, run_id, team_id, project_id, workday_id, assignment_id, mode_run_id, event_index, event_type, status, title, message, parameters_json, context_json, refs_json, metadata_json, created_at) - SELECT ?, id, team_id, ?, ?, ?, ?, next_event_index - 1, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM capacity_workday_runs + { query: `INSERT INTO capacity_workday_events (id, run_id, team_id, project_id, workday_id, assignment_id, event_index, event_type, status, title, message, parameters_json, context_json, refs_json, metadata_json, created_at) + SELECT ?, id, team_id, ?, ?, ?, next_event_index - 1, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM capacity_workday_runs WHERE id = ? AND team_id = ? AND NOT EXISTS (SELECT 1 FROM capacity_workday_events WHERE id = ?)`, - params: [value.id, value.projectId, value.workdayId, value.assignmentId, value.modeRunId, value.eventType, value.status, value.title, value.message, JSON.stringify(value.parameters), JSON.stringify(value.context), JSON.stringify(value.refs), JSON.stringify(value.metadata), value.createdAt, runId, teamId, value.id] }, + params: [value.id, value.projectId, value.workdayId, value.assignmentId, value.eventType, value.status, value.title, value.message, JSON.stringify(value.parameters), JSON.stringify(value.context), JSON.stringify(value.refs), JSON.stringify(value.metadata), value.createdAt, runId, teamId, value.id] }, ]); return this.get(teamId, runId, value.id); } diff --git a/src/api/capacity/repositories/capacity/workdays/workday-participation.ts b/src/api/capacity/repositories/capacity/workdays/workday-participation.ts deleted file mode 100644 index e9f0ee92..00000000 --- a/src/api/capacity/repositories/capacity/workdays/workday-participation.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { -CapacityWorkdayParticipationCycleRecord, -CapacityWorkdayParticipationCycleStatus, -CapacityWorkdayParticipationEntryRecord, -CapacityWorkdayParticipationEntryStatus, -} from '@treeseed/sdk/agent-capacity'; -import type { CapacityDatabaseOperation,CapacityGovernanceDatabase } from '../../../database.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { decodeDurableJsonObject } from '../../../durable-json.ts'; - -type Row = Record; -const CYCLE_STATUSES = new Set(['open', 'covered', 'closed']); -const ENTRY_STATUSES = new Set(['pending', 'assigned', 'completed', 'excluded', 'blocked']); - -function required(row: Row, column: string, owner: string): string { - const value = row[column]; - if (typeof value !== 'string' || !value) throw new CapacityGovernanceError( - 'capacity_workday_participation_corrupt', `${owner} has invalid ${column}.`, 500, - { id: typeof row.id === 'string' ? row.id : null, column }, - ); - return value; -} -function nullable(value: unknown): string | null { return typeof value === 'string' && value ? value : null; } - -export function serializeParticipationCycleRow(row: Row | null): CapacityWorkdayParticipationCycleRecord | null { - if (!row) return null; - const status = required(row, 'status', 'Participation cycle') as CapacityWorkdayParticipationCycleStatus; - const cycleNumber = Number(row.cycle_number); - if (!CYCLE_STATUSES.has(status) || !Number.isInteger(cycleNumber) || cycleNumber < 1) throw new CapacityGovernanceError( - 'capacity_workday_participation_corrupt', 'Participation cycle has invalid status or number.', 500, - { cycleId: String(row.id ?? ''), status, cycleNumber: row.cycle_number ?? null }, - ); - return { - id: required(row, 'id', 'Participation cycle'), teamId: required(row, 'team_id', 'Participation cycle'), - projectId: required(row, 'project_id', 'Participation cycle'), workdayRunId: required(row, 'workday_run_id', 'Participation cycle'), - cycleNumber, status, openedAt: required(row, 'opened_at', 'Participation cycle'), coveredAt: nullable(row.covered_at), - closedAt: nullable(row.closed_at), createdAt: required(row, 'created_at', 'Participation cycle'), updatedAt: required(row, 'updated_at', 'Participation cycle'), - }; -} - -export function serializeParticipationEntryRow(row: Row | null): CapacityWorkdayParticipationEntryRecord | null { - if (!row) return null; - const status = required(row, 'status', 'Participation entry') as CapacityWorkdayParticipationEntryStatus; - if (!ENTRY_STATUSES.has(status)) throw new CapacityGovernanceError( - 'capacity_workday_participation_corrupt', 'Participation entry has unknown status.', 500, - { entryId: String(row.id ?? ''), status }, - ); - return { - id: required(row, 'id', 'Participation entry'), cycleId: required(row, 'cycle_id', 'Participation entry'), - teamId: required(row, 'team_id', 'Participation entry'), projectId: required(row, 'project_id', 'Participation entry'), - workdayRunId: required(row, 'workday_run_id', 'Participation entry'), agentId: required(row, 'agent_id', 'Participation entry'), - projectAgentClassId: required(row, 'project_agent_class_id', 'Participation entry'), status, - reasonCode: nullable(row.reason_code), demandId: nullable(row.demand_id), assignmentId: nullable(row.assignment_id), - coveredAt: nullable(row.covered_at), - metadata: decodeDurableJsonObject(row.metadata_json, { owner: 'capacity workday participation entry', ownerId: String(row.id ?? ''), column: 'metadata_json' }), - createdAt: required(row, 'created_at', 'Participation entry'), updatedAt: required(row, 'updated_at', 'Participation entry'), - }; -} - -export interface ParticipationAgentInput { - agentId: string; - projectAgentClassId: string; - eligible: boolean; - reasonCode?: string | null; - metadata?: Record; -} - -export class CapacityWorkdayParticipationRepository { - constructor(private readonly database: CapacityGovernanceDatabase) {} - - async ensureOpenCycle(input: { - teamId: string; projectId: string; workdayRunId: string; agents: ParticipationAgentInput[]; now: string; - }): Promise<{ cycle: CapacityWorkdayParticipationCycleRecord; entries: CapacityWorkdayParticipationEntryRecord[] }> { - await this.database.ensureInitialized(); - const latest = serializeParticipationCycleRow(await this.database.first( - `SELECT * FROM capacity_workday_participation_cycles WHERE workday_run_id = ? AND project_id = ? ORDER BY cycle_number DESC LIMIT 1`, - [input.workdayRunId, input.projectId], - )); - let cycleNumber = latest?.cycleNumber ?? 1; - if (latest) { - const missingAgentOperations = input.agents.map((agent): CapacityDatabaseOperation => ({ - query: `INSERT INTO capacity_workday_participation_entries (id, cycle_id, team_id, project_id, workday_run_id, agent_id, - project_agent_class_id, status, reason_code, covered_at, metadata_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (cycle_id, agent_id) DO NOTHING`, - params: [ - `entry:${latest.id}:${agent.agentId}`, latest.id, input.teamId, input.projectId, input.workdayRunId, agent.agentId, - agent.projectAgentClassId, agent.eligible ? 'pending' : 'excluded', agent.eligible ? null : agent.reasonCode ?? 'agent_ineligible', - agent.eligible ? null : input.now, JSON.stringify(agent.metadata ?? {}), input.now, input.now, - ], - })); - if (missingAgentOperations.length) await this.database.batch(missingAgentOperations); - const unresolved = await this.database.first<{ total?: unknown }>( - `SELECT COUNT(*) AS total FROM capacity_workday_participation_entries WHERE cycle_id = ? AND status IN ('pending','assigned','blocked')`, - [latest.id], - ); - if (Number(unresolved?.total ?? 0) === 0) { - await this.database.run( - `UPDATE capacity_workday_participation_cycles SET status = 'closed', covered_at = COALESCE(covered_at, ?), closed_at = COALESCE(closed_at, ?), updated_at = ? WHERE id = ? AND status != 'closed'`, - [input.now, input.now, input.now, latest.id], - ); - cycleNumber = latest.cycleNumber + 1; - } else { - return { cycle: latest, entries: await this.listEntries(latest.id) }; - } - } - const cycleId = `cycle:${input.workdayRunId}:${input.projectId}:${cycleNumber}`; - const operations: CapacityDatabaseOperation[] = [{ - query: `INSERT INTO capacity_workday_participation_cycles (id, team_id, project_id, workday_run_id, cycle_number, status, opened_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?) ON CONFLICT (workday_run_id, project_id, cycle_number) DO NOTHING`, - params: [cycleId, input.teamId, input.projectId, input.workdayRunId, cycleNumber, input.now, input.now, input.now], - }]; - for (const agent of input.agents) operations.push({ - query: `INSERT INTO capacity_workday_participation_entries (id, cycle_id, team_id, project_id, workday_run_id, agent_id, - project_agent_class_id, status, reason_code, covered_at, metadata_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (cycle_id, agent_id) DO NOTHING`, - params: [ - `entry:${cycleId}:${agent.agentId}`, cycleId, input.teamId, input.projectId, input.workdayRunId, agent.agentId, - agent.projectAgentClassId, agent.eligible ? 'pending' : 'excluded', agent.eligible ? null : agent.reasonCode ?? 'agent_ineligible', - agent.eligible ? null : input.now, JSON.stringify(agent.metadata ?? {}), input.now, input.now, - ], - }); - await this.database.batch(operations); - const cycle = serializeParticipationCycleRow(await this.database.first(`SELECT * FROM capacity_workday_participation_cycles WHERE id = ? LIMIT 1`, [cycleId])); - if (!cycle) throw new CapacityGovernanceError('capacity_workday_participation_persistence_failed', 'Participation cycle was not persisted.', 500, { cycleId }); - return { cycle, entries: await this.listEntries(cycle.id) }; - } - - async listEntries(cycleId: string): Promise { - return (await this.database.all( - `SELECT * FROM capacity_workday_participation_entries WHERE cycle_id = ? ORDER BY agent_id ASC`, [cycleId], - )).map((row) => serializeParticipationEntryRow(row)!); - } - - async bindDemand(entryId: string, demandId: string, now: string): Promise { - await this.database.run( - `UPDATE capacity_workday_participation_entries SET status = 'assigned', demand_id = ?, updated_at = ? - WHERE id = ? AND status = 'pending' AND demand_id IS NULL`, [demandId, now, entryId], - ); - return serializeParticipationEntryRow(await this.database.first(`SELECT * FROM capacity_workday_participation_entries WHERE id = ? LIMIT 1`, [entryId])); - } - - async bindAssignment(demandId: string, assignmentId: string, now: string): Promise { - await this.database.run( - `UPDATE capacity_workday_participation_entries SET assignment_id = ?, updated_at = ? WHERE demand_id = ? AND status = 'assigned'`, - [assignmentId, now, demandId], - ); - } - - async markCovered(assignmentId: string, status: 'completed' | 'blocked', reasonCode: string | null, now: string): Promise { - await this.database.run( - `UPDATE capacity_workday_participation_entries SET status = ?, reason_code = ?, covered_at = ?, updated_at = ? - WHERE assignment_id = ? AND status = 'assigned'`, [status, reasonCode, now, now, assignmentId], - ); - } -} diff --git a/src/api/capacity/repositories/capacity/workdays/workday-recovery.ts b/src/api/capacity/repositories/capacity/workdays/workday-recovery.ts index 9f3499c6..9115e813 100644 --- a/src/api/capacity/repositories/capacity/workdays/workday-recovery.ts +++ b/src/api/capacity/repositories/capacity/workdays/workday-recovery.ts @@ -1,19 +1,12 @@ -import type { -CapacityWorkdayRunRecord -} from "@treeseed/sdk/agent-capacity"; +import { assignmentResultSchema, type CapacityWorkdayRunRecord } from '@treeseed/sdk/agent-capacity'; import { MAX_CAPACITY_PAGE_LIMIT } from "@treeseed/sdk/capacity-pagination"; import type { CapacityGovernanceDatabase } from "../../../database.ts"; import { CapacityGovernanceError } from "../../../database.ts"; -import { canonicalArtifactManifestReferences,canonicalArtifactMutationReceipts } from "../../../domain/artifact-manifest-evidence.ts"; import { decodeDurableJsonObject } from "../../../durable-json.ts"; import { serializeCapacityWorkdayRunRow } from "./workday-run.ts"; -import { logicalModeRunSql } from "../../support/mode-run.ts"; type Row = Record; -export interface WorkdayModeRunEvidence { - modeRunCount: number; - succeededModeRuns: number; - failedModeRuns: number; +export interface WorkdayAssignmentEvidence { contentArtifactCount: number; requiredContentOutcomeAssignments: number; integratedContentOutcomeAssignments: number; @@ -23,29 +16,17 @@ export interface WorkdayModeRunEvidence { export interface RecoverableTerminalWorkday { run: CapacityWorkdayRunRecord; hasUnfinishedAssignments: boolean; - hasActiveDemands: boolean; - hasOpenEnvelopes: boolean; + hasReadyNodes: boolean; missingDeadlineEvent: boolean; } -function count(value: unknown, field: string): number { - const parsed = Number(value ?? 0); - if (!Number.isInteger(parsed) || parsed < 0) - throw new CapacityGovernanceError( - "capacity_workday_evidence_corrupt", - `Workday evidence has invalid ${field}.`, - 500, - { field, value }, - ); - return parsed; -} function referenceKey(value: unknown): string { if (typeof value === "string" && value) return `string:${value}`; if (value && typeof value === "object" && !Array.isArray(value)) return `object:${JSON.stringify(value)}`; throw new CapacityGovernanceError( "capacity_workday_evidence_corrupt", - "Mode-run artifact reference must be a nonempty string or object.", + "Assignment artifact reference must be a nonempty string or object.", 500, ); } @@ -67,53 +48,42 @@ export class CapacityWorkdayRecoveryRepository { return rows.map((row) => serializeCapacityWorkdayRunRow(row)!); } - async modeRunEvidence( + async assignmentEvidence( teamId: string, runId: string, - ): Promise { + ): Promise { await this.database.ensureInitialized(); - const totals = await this.database.first( - `SELECT COUNT(*) AS mode_run_count, - COALESCE(SUM(CASE WHEN amr.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS succeeded_mode_runs, - COALESCE(SUM(CASE WHEN amr.status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_mode_runs - FROM agent_mode_runs amr JOIN capacity_provider_assignments pa ON pa.id = amr.provider_assignment_id AND pa.team_id = amr.team_id - JOIN capacity_workday_demands demand ON demand.assignment_id = pa.id - WHERE pa.team_id = ? AND demand.workday_run_id = ? AND ${logicalModeRunSql('amr')}`, - [teamId, runId], - ); const references = new Set(); const contentOutcomes = new Set(); let cursor = ""; while (true) { const rows = await this.database.all( - `SELECT amr.id, pa.id AS assignment_id, amr.outputs_json FROM agent_mode_runs amr - JOIN capacity_provider_assignments pa ON pa.id = amr.provider_assignment_id AND pa.team_id = amr.team_id - JOIN capacity_workday_demands demand ON demand.assignment_id = pa.id - WHERE pa.team_id = ? AND demand.workday_run_id = ? AND amr.status = 'succeeded' AND ${logicalModeRunSql('amr')} AND amr.id > ? - ORDER BY amr.id ASC LIMIT ?`, + `SELECT id, assignment_result_json FROM capacity_provider_assignments + WHERE team_id = ? AND work_day_id = ? AND status = 'completed' AND id > ? + ORDER BY id ASC LIMIT ?`, [teamId, runId, cursor, MAX_CAPACITY_PAGE_LIMIT], ); for (const row of rows) { - const modeRunId = String(row.id ?? ""); - const outputs = decodeDurableJsonObject(row.outputs_json, { - owner: "agent mode run", - ownerId: modeRunId, - column: "outputs_json", + const assignmentId = String(row.id ?? ""); + const value = decodeDurableJsonObject(row.assignment_result_json, { + owner: 'capacity provider assignment', + ownerId: assignmentId, + column: 'assignment_result_json', }); - for (const ref of canonicalArtifactManifestReferences( - outputs, - `mode run ${modeRunId}`, - )) + const result = assignmentResultSchema.safeParse(value); + if (!result.success || result.data.assignmentId !== assignmentId) throw new CapacityGovernanceError( + 'capacity_workday_evidence_corrupt', `Assignment ${assignmentId} has no valid immutable result.`, 500); + for (const ref of result.data.references) if (ref.kind === 'treedx') { references.add(referenceKey(ref)); - for (const receipt of canonicalArtifactMutationReceipts(outputs, `mode run ${modeRunId}`)) - if (String(receipt.kind ?? '') === 'treedx-content') contentOutcomes.add(String(row.assignment_id ?? '')); + contentOutcomes.add(assignmentId); + } } if (rows.length < MAX_CAPACITY_PAGE_LIMIT) break; cursor = String(rows.at(-1)?.id ?? ""); if (!cursor) throw new CapacityGovernanceError( "capacity_workday_evidence_cursor_invalid", - "Mode-run evidence cursor is missing.", + "Assignment evidence cursor is missing.", 500, { runId }, ); @@ -122,20 +92,14 @@ export class CapacityWorkdayRecoveryRepository { while (contentOutcomes.size) { const rows = await this.database.all(`SELECT audit.id,audit.target_id,audit.event_type FROM audit_events audit JOIN capacity_provider_assignments pa ON pa.id = audit.target_id AND pa.team_id = ? - JOIN capacity_workday_demands demand ON demand.assignment_id = pa.id AND demand.workday_run_id = ? - WHERE audit.target_type = 'capacity_provider_assignment' AND audit.event_type IN ('assignment.content.integrated','assignment.content.abandoned') + WHERE pa.work_day_id = ? + AND audit.target_type = 'capacity_provider_assignment' AND audit.event_type IN ('assignment.content.integrated','assignment.content.abandoned') AND audit.id > ? ORDER BY audit.id ASC LIMIT ?`,[teamId,runId,auditCursor,MAX_CAPACITY_PAGE_LIMIT]); for(const row of rows){const assignmentId=String(row.target_id??'');if(!contentOutcomes.has(assignmentId))continue;if(row.event_type==='assignment.content.integrated')integrated.add(assignmentId);else abandoned.add(assignmentId);} if(rows.length { await this.database.ensureInitialized(); - const [assignment, demand, envelope, event] = await Promise.all([ + const [assignment, readyNode, event] = await Promise.all([ this.database.first( - `SELECT assignment.id FROM capacity_provider_assignments assignment JOIN capacity_workday_demands demand ON demand.assignment_id = assignment.id + `SELECT assignment.id FROM capacity_provider_assignments assignment LEFT JOIN agent_invocation_requests suspended_invocation ON suspended_invocation.assignment_id = assignment.id AND suspended_invocation.status = 'suspended' AND suspended_invocation.final_message_ref IS NOT NULL - WHERE assignment.team_id = ? AND demand.workday_run_id = ? AND assignment.status IN ('pending','leased','running','returned') + WHERE assignment.team_id = ? AND assignment.work_day_id = ? AND assignment.status IN ('pending','leased','running','returned') AND NOT (assignment.status = 'returned' AND assignment.lease_state = 'released' AND assignment.execution_kind = 'conversation' AND assignment.lifecycle_code = 'discussion_response_required' AND suspended_invocation.id IS NOT NULL) LIMIT 1`, [run.teamId, run.id], ), this.database.first( - `SELECT id FROM capacity_workday_demands WHERE team_id = ? AND workday_run_id = ? AND status IN ('pending','claimed','admitted','blocked') LIMIT 1`, - [run.teamId, run.id], - ), - this.database.first( - `SELECT id FROM workday_capacity_envelopes WHERE team_id = ? AND workday_run_id = ? AND status NOT IN ('completed','cancelled','failed','degraded') LIMIT 1`, + `SELECT id FROM execution_nodes WHERE team_id = ? AND workday_id = ? AND status = 'ready' LIMIT 1`, [run.teamId, run.id], ), this.database.first( @@ -219,8 +179,7 @@ export class CapacityWorkdayRecoveryRepository { return { run, hasUnfinishedAssignments: Boolean(assignment), - hasActiveDemands: Boolean(demand), - hasOpenEnvelopes: Boolean(envelope), + hasReadyNodes: Boolean(readyNode), missingDeadlineEvent: Boolean(run.actual.deadlineTerminalizedAt) && !event, }; diff --git a/src/api/capacity/repositories/projects/agents/project-agent-class.ts b/src/api/capacity/repositories/projects/agents/project-agent-class.ts index 5a5549f7..f96f2d12 100644 --- a/src/api/capacity/repositories/projects/agents/project-agent-class.ts +++ b/src/api/capacity/repositories/projects/agents/project-agent-class.ts @@ -2,7 +2,7 @@ import type { ProjectAgentClass,ProjectAgentClassStatus } from '@treeseed/sdk/ag import { encodeCapacityPageCursor,type CapacityPage,type CapacityPageCursor } from '@treeseed/sdk/capacity-pagination'; import type { CapacityDatabaseOperation,CapacityGovernanceDatabase } from '../../../database.ts'; import { CapacityGovernanceError } from '../../../database.ts'; -import { decodeDurableJsonArray,decodeDurableJsonObject } from '../../../durable-json.ts'; +import { decodeDurableJsonObject } from '../../../durable-json.ts'; type Row = Record; const STATUSES = new Set(['active', 'paused', 'archived']); @@ -22,17 +22,9 @@ export function serializeProjectAgentClassRow(row: Row | null): ProjectAgentClas for (const [field, value] of [['id', id], ['teamId', row.team_id], ['projectId', row.project_id], ['slug', row.slug], ['name', row.name]] as const) { if (!text(value)) throw new CapacityGovernanceError('project_agent_class_record_invalid', `Project agent class ${id || '(unknown)'} requires ${field}.`, 500, { projectAgentClassId: id || null, field }); } - const allowedModes = decodeDurableJsonArray(row.allowed_modes_json, context(id, 'allowed_modes_json')); - if (allowedModes.length === 0 || allowedModes.some((mode) => mode !== 'planning' && mode !== 'acting') || new Set(allowedModes).size !== allowedModes.length) { - throw new CapacityGovernanceError('project_agent_class_modes_invalid', `Project agent class ${id} has invalid allowed modes.`, 500, { projectAgentClassId: id }); - } return { id, teamId: text(row.team_id), projectId: text(row.project_id), slug: text(row.slug), name: text(row.name), status: status(row.status, id), - allowedModes: allowedModes as ProjectAgentClass['allowedModes'], - kernelProfile: decodeDurableJsonObject(row.kernel_profile_json, context(id, 'kernel_profile_json')), - kernelPolicy: decodeDurableJsonObject(row.kernel_policy_json, context(id, 'kernel_policy_json')), handlerRefs: decodeDurableJsonObject(row.handler_refs_json, context(id, 'handler_refs_json')), - outputContracts: decodeDurableJsonObject(row.output_contracts_json, context(id, 'output_contracts_json')), metadata: decodeDurableJsonObject(row.metadata_json, context(id, 'metadata_json')), createdAt: text(row.created_at), updatedAt: text(row.updated_at), }; @@ -72,7 +64,7 @@ export class ProjectAgentClassRepository { async create(value: ProjectAgentClass, operations: CapacityDatabaseOperation[] = [], now = new Date().toISOString()): Promise { await this.database.ensureInitialized(); await this.database.batch([ - { query: `INSERT INTO project_agent_classes (id, team_id, project_id, slug, name, status, allowed_modes_json, required_capabilities_json, kernel_profile_json, kernel_policy_json, handler_refs_json, output_contracts_json, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, ?, ?, ?, ?, ?)`, params: [value.id, value.teamId, value.projectId, value.slug, value.name, value.status, JSON.stringify(value.allowedModes), JSON.stringify(value.kernelProfile), JSON.stringify(value.kernelPolicy), JSON.stringify(value.handlerRefs), JSON.stringify(value.outputContracts), JSON.stringify(value.metadata ?? {}), now, now] }, + { query: `INSERT INTO project_agent_classes (id, team_id, project_id, slug, name, status, handler_refs_json, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params: [value.id, value.teamId, value.projectId, value.slug, value.name, value.status, JSON.stringify(value.handlerRefs), JSON.stringify(value.metadata ?? {}), now, now] }, ...operations, ]); return this.required(value.projectId, value.id); @@ -81,7 +73,7 @@ export class ProjectAgentClassRepository { async update(value: ProjectAgentClass, operations: CapacityDatabaseOperation[] = [], now = new Date().toISOString()): Promise { await this.database.ensureInitialized(); await this.database.batch([ - { query: `UPDATE project_agent_classes SET slug = ?, name = ?, status = ?, allowed_modes_json = ?, required_capabilities_json = '[]', kernel_profile_json = ?, kernel_policy_json = ?, handler_refs_json = ?, output_contracts_json = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND project_id = ?`, params: [value.slug, value.name, value.status, JSON.stringify(value.allowedModes), JSON.stringify(value.kernelProfile), JSON.stringify(value.kernelPolicy), JSON.stringify(value.handlerRefs), JSON.stringify(value.outputContracts), JSON.stringify(value.metadata ?? {}), now, value.id, value.projectId] }, + { query: `UPDATE project_agent_classes SET slug = ?, name = ?, status = ?, handler_refs_json = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND project_id = ?`, params: [value.slug, value.name, value.status, JSON.stringify(value.handlerRefs), JSON.stringify(value.metadata ?? {}), now, value.id, value.projectId] }, ...operations, ]); return this.required(value.projectId, value.id); diff --git a/src/api/capacity/repositories/support/execution-run.ts b/src/api/capacity/repositories/support/execution-run.ts index 97935919..02f6b952 100644 --- a/src/api/capacity/repositories/support/execution-run.ts +++ b/src/api/capacity/repositories/support/execution-run.ts @@ -1,412 +1,81 @@ -import { -encodeCapacityPageCursor, -normalizeCapacityPageLimit, -type CapacityPage, -type CapacityPageCursor, -} from "@treeseed/sdk/capacity-pagination"; -import type { CapacityGovernanceDatabase } from "../../database.ts"; -import { CapacityGovernanceError } from "../../database.ts"; -import { canonicalArtifactManifestReferences } from "../../domain/artifact-manifest-evidence.ts"; -import { logicalModeRunSql,serializeAgentModeRunRow } from "./mode-run.ts"; +import type { CapacityPage, CapacityPageCursor } from '@treeseed/sdk/capacity-pagination'; +import type { CapacityGovernanceDatabase } from '../../database.ts'; +import { ProviderAssignmentRepository, type DurableProviderAssignment } from '../capacity/assignments/assignment.ts'; type Row = Record; export interface ExecutionRunListFilters { - projectId?: string | null; - providerId?: string | null; - status?: string | null; - mode?: string | null; - assignmentId?: string | null; - workdayId?: string | null; - executionProviderId?: string | null; - limit?: unknown; - cursor?: CapacityPageCursor | null; - projection?: 'activity' | null; + projectId?: string | null; + providerId?: string | null; + status?: string | null; + mode?: string | null; + assignmentId?: string | null; + workdayId?: string | null; + executionProviderId?: string | null; + limit?: unknown; + cursor?: CapacityPageCursor | null; + projection?: 'activity' | null; } function record(value: unknown): Row { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Row) - : {}; + return value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; } -function jsonRecord(value: unknown): Row { - if (typeof value !== "string") return record(value); - try { - return record(JSON.parse(value)); - } catch { - return {}; - } +function durationMs(start: string | null, end: string | null): number | null { + if (!start || !end) return null; + const duration = Date.parse(end) - Date.parse(start); + return Number.isFinite(duration) ? Math.max(0, duration) : null; } -function stringValue(value: unknown): string | null { - return typeof value === "string" && value ? value : null; -} - -function durationMs(start: unknown, end: unknown): number | null { - if (typeof start !== "string" || typeof end !== "string") return null; - const started = Date.parse(start); - const finished = Date.parse(end); - return Number.isFinite(started) && Number.isFinite(finished) - ? Math.max(0, finished - started) - : null; -} - -function contentArtifactRefs(row: Row): Row[] { - const refs: Row[] = []; - const modeOutputs = jsonRecord(row.outputs_json); - const assignmentOutput = jsonRecord( - row.assignment_lifecycle_output_json ?? row.lifecycle_output_json, - ); - refs.push( - ...canonicalArtifactManifestReferences( - modeOutputs, - `mode run ${String(row.id ?? "")}`, - ), - ); - refs.push( - ...canonicalArtifactManifestReferences( - assignmentOutput, - `assignment ${String(row.assignment_id ?? "")}`, - ), - ); - const seen = new Set(); - return refs.filter((reference) => { - const key = JSON.stringify(reference); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function tokenDiagnostics(row: Row) { - const usage = jsonRecord(row.usage_actual_json); - const outputs = jsonRecord(row.outputs_json); - const outputMetadata = record(outputs.metadata); - const artifact = record(outputMetadata.artifact); - const snapshot = record( - outputMetadata.executionSnapshot ?? artifact.executionSnapshot, - ); - const snapshotMetadata = record(snapshot.metadata); - const codex = record(snapshotMetadata.codex ?? outputMetadata.codex); - const nativeUsage = record(usage.nativeUsage); - const rawUsage = record( - codex.usage ?? snapshot.usage ?? nativeUsage.executionUsage, - ); - const numberOrNull = (...values: unknown[]) => { - for (const value of values) { - const parsed = Number(value); - if (Number.isFinite(parsed) && parsed !== 0) return parsed; - } - return null; - }; - return { - inputTokens: numberOrNull( - rawUsage.input_tokens, - rawUsage.inputTokens, - rawUsage.prompt_tokens, - rawUsage.promptTokens, - ), - outputTokens: numberOrNull( - rawUsage.output_tokens, - rawUsage.outputTokens, - rawUsage.completion_tokens, - rawUsage.completionTokens, - ), - cachedInputTokens: numberOrNull( - rawUsage.cached_input_tokens, - rawUsage.cachedInputTokens, - ), - rawUsage: Object.keys(rawUsage).length > 0 ? rawUsage : null, - }; -} - -function activityTokenDiagnostics(row: Row) { - const usage = jsonRecord(row.usage_actual_json); - const nativeUsage = record(usage.nativeUsage); - const entries = Array.isArray(nativeUsage.executionUsage) ? nativeUsage.executionUsage : []; - const metadata = record(record(entries.at(-1)).metadata); - const numberOrNull = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : null; +function tokenCounts(assignment: DurableProviderAssignment) { + const usage = record(assignment.assignmentResult?.usage); + const native = record(usage.native); + const inputTokens = Number(usage.modelInputTokens ?? native.inputTokens ?? 0); + const outputTokens = Number(usage.modelOutputTokens ?? native.outputTokens ?? 0); + const cachedInputTokens = Number(native.cachedInputTokens ?? 0); return { - inputTokens: numberOrNull(metadata.inputTokens ?? metadata.input_tokens), - outputTokens: numberOrNull(metadata.outputTokens ?? metadata.output_tokens), - cachedInputTokens: numberOrNull(metadata.cachedInputTokens ?? metadata.cached_input_tokens), - rawUsage: Object.keys(metadata).length > 0 ? metadata : null, + inputTokens: Number.isFinite(inputTokens) ? inputTokens : 0, + outputTokens: Number.isFinite(outputTokens) ? outputTokens : 0, + cachedInputTokens: Number.isFinite(cachedInputTokens) ? cachedInputTokens : 0, + rawUsage: usage, }; } -function activityContentArtifactRefs(row: Row) { - return canonicalArtifactManifestReferences( - jsonRecord(row.assignment_lifecycle_output_json ?? row.lifecycle_output_json), - `assignment ${String(row.assignment_id ?? row.provider_assignment_id ?? '')}`, - ); -} - -function activityExecutionRun(row: Row) { - const tokens = activityTokenDiagnostics(row); - const selectedInput = jsonRecord(row.selected_input_json); - const finishedAt = row.completed_at ?? row.failed_at ?? row.assignment_completed_at ?? row.assignment_failed_at ?? row.assignment_returned_at ?? null; - return { - id: row.id, - status: row.status, - mode: row.mode, - timing: { createdAt: row.created_at, startedAt: row.started_at, completedAt: row.completed_at, failedAt: row.failed_at, - finishedAt, durationMs: durationMs(row.started_at ?? row.created_at, finishedAt) }, - agent: { projectId: row.project_id, projectSlug: row.project_slug ?? null, projectName: row.project_name ?? null, - projectAgentClassId: row.project_agent_class_id, classSlug: row.agent_class_slug ?? null, className: row.agent_class_name ?? null, - agentId: row.agent_id, handlerId: row.handler_id }, - assignment: { id: row.provider_assignment_id, status: row.assignment_status ?? null, leaseState: row.assignment_lease_state ?? null, - workdayId: row.assignment_work_day_id ?? null, taskId: row.assignment_task_id ?? null, decisionId: row.assignment_decision_id ?? null, - proposalId: row.assignment_proposal_id ?? null, runnerId: row.assignment_runner_id ?? null, - lifecycleCode: row.assignment_lifecycle_code ?? null, lifecycleReason: row.assignment_lifecycle_reason ?? null }, - executionProvider: { id: row.execution_provider_id, capacityProviderId: row.capacity_provider_id, tokenCounts: tokens, - hasTokenCounts: Boolean(tokens.inputTokens || tokens.outputTokens || tokens.cachedInputTokens) }, - input: { selectedInput: { cycle: selectedInput.cycle ?? null } }, - contentArtifactRefs: activityContentArtifactRefs(row), +function project(assignment: DurableProviderAssignment, activity: boolean): Row { + const result = assignment.assignmentResult; + const finishedAt = assignment.completedAt ?? assignment.failedAt ?? assignment.returnedAt; + const tokens = tokenCounts(assignment); + const base = { + id: assignment.id, status: assignment.status, mode: assignment.mode, + timing: { createdAt: assignment.createdAt, startedAt: assignment.claimedAt, + completedAt: assignment.completedAt, failedAt: assignment.failedAt, finishedAt, + durationMs: durationMs(assignment.claimedAt ?? assignment.assignedAt, finishedAt) }, + agent: { projectId: assignment.projectId, projectAgentClassId: assignment.projectAgentClassId, + agentId: assignment.agentId, handlerId: assignment.handlerId }, + assignment: { id: assignment.id, status: assignment.status, leaseState: assignment.leaseState, + workdayId: assignment.workDayId, taskId: assignment.taskId, decisionId: assignment.decisionId, + proposalId: assignment.proposalId, runnerId: assignment.runnerId, + lifecycleCode: assignment.lifecycleCode, lifecycleReason: assignment.lifecycleReason }, + executionProvider: { id: assignment.executionProviderId, capacityProviderId: assignment.capacityProviderId, + tokenCounts: tokens, hasTokenCounts: tokens.inputTokens + tokens.outputTokens + tokens.cachedInputTokens > 0 }, + contentArtifactRefs: result?.references ?? [], + }; + if (activity) return { ...base, input: { selectedInput: { cycle: assignment.attemptCount } } }; + return { ...base, + input: { assignmentAttempt: assignment.assignmentAttempt, workspaceContext: assignment.workspaceContext, + allowedOutputs: assignment.allowedOutputs, capacityEnvelope: assignment.capacityEnvelope }, + output: { assignmentResult: result, lifecycleOutput: assignment.lifecycleOutput, + usageActual: result?.usage ?? null }, + context: { assignmentExplanation: assignment.explanation }, }; } -function telemetryEntry(row: Row) { - const run = serializeAgentModeRunRow(row); - if (!run) return null; - const outputs = record(run.outputs); - const metadata = record(run.metadata); - const outputMetadata = record(outputs.metadata); - return { - id: run.id, - status: run.status, - mode: run.mode, - agentId: run.agentId, - handlerId: run.handlerId, - assignmentId: run.providerAssignmentId, - createdAt: run.createdAt, - startedAt: run.startedAt, - completedAt: run.completedAt, - failedAt: run.failedAt, - source: stringValue(metadata.source ?? outputMetadata.source), - outputStatus: outputs.status ?? null, - summary: outputs.summary ?? null, - outputs, - traceRefs: run.traceRefs, - usageActual: run.usageActual, - validation: run.validation, - fallbackReason: run.fallbackReason, - metadata, - }; -} - +/** Assignment/result custody is the sole execution-run source; no mode-run projection. */ export async function listExecutionRunsForTeamPage( - database: CapacityGovernanceDatabase, - teamId: string, - filters: ExecutionRunListFilters = {}, + database: CapacityGovernanceDatabase, + teamId: string, + filters: ExecutionRunListFilters = {}, ): Promise> { - await database.ensureInitialized(); - const clauses = ["a.team_id = ?", logicalModeRunSql('m')]; - const values: unknown[] = [teamId]; - if (filters.projectId) { - clauses.push("a.project_id = ?"); - values.push(filters.projectId); - } - if (filters.providerId) { - clauses.push("a.capacity_provider_id = ?"); - values.push(filters.providerId); - } - if (filters.status) { - clauses.push("(m.status = ? OR a.status = ?)"); - values.push(filters.status, filters.status); - } - if (filters.mode) { - clauses.push("m.mode = ?"); - values.push(filters.mode); - } - if (filters.assignmentId) { - clauses.push("a.id = ?"); - values.push(filters.assignmentId); - } - if (filters.workdayId) { - clauses.push("a.work_day_id = ?"); - values.push(filters.workdayId); - } - if (filters.executionProviderId) { - clauses.push( - "COALESCE(m.execution_provider_id, a.execution_provider_id) = ?", - ); - values.push(filters.executionProviderId); - } - if (filters.cursor) { - clauses.push("(m.created_at < ? OR (m.created_at = ? AND m.id < ?))"); - values.push( - filters.cursor.createdAt, - filters.cursor.createdAt, - filters.cursor.id, - ); - } - const limit = normalizeCapacityPageLimit(filters.limit); - const rows = await database.all( - `SELECT - m.*, - a.status AS assignment_status, - a.lease_state AS assignment_lease_state, - a.work_day_id AS assignment_work_day_id, - a.task_id AS assignment_task_id, - a.decision_id AS assignment_decision_id, - a.proposal_id AS assignment_proposal_id, - a.runner_id AS assignment_runner_id, - a.lifecycle_reason AS assignment_lifecycle_reason, - a.lifecycle_code AS assignment_lifecycle_code, - a.lifecycle_output_json AS assignment_lifecycle_output_json, - a.decision_input_json AS assignment_decision_input_json, - a.workspace_context_json AS assignment_workspace_context_json, - a.allowed_outputs_json AS assignment_allowed_outputs_json, - a.explanation_json AS assignment_explanation_json, - a.created_at AS assignment_created_at, - a.assigned_at AS assignment_assigned_at, - a.claimed_at AS assignment_claimed_at, - a.completed_at AS assignment_completed_at, - a.failed_at AS assignment_failed_at, - a.returned_at AS assignment_returned_at, - p.slug AS project_slug, - p.name AS project_name, - c.slug AS agent_class_slug, - c.name AS agent_class_name - FROM agent_mode_runs m - JOIN capacity_provider_assignments a ON a.id = m.provider_assignment_id - LEFT JOIN projects p ON p.id = m.project_id - LEFT JOIN project_agent_classes c ON c.project_id = m.project_id AND c.id = m.project_agent_class_id - WHERE ${clauses.join(" AND ")} - ORDER BY ${filters.projection === 'activity' ? "CASE WHEN m.status IN ('succeeded', 'completed', 'failed', 'cancelled') THEN 0 ELSE 1 END," : ''} m.created_at DESC, m.id DESC - LIMIT ?`, - [...values, limit + 1], - ); - const selected = rows.slice(0, limit); - if (filters.projection === 'activity') { - const last = selected.at(-1); - return { - items: selected.map(activityExecutionRun), - page: { limit, hasMore: rows.length > limit, nextCursor: rows.length > limit && last - ? encodeCapacityPageCursor({ createdAt: String(last.created_at), id: String(last.id) }) : null }, - }; - } - const assignmentIds = [ - ...new Set( - selected - .map((row) => stringValue(row.provider_assignment_id)) - .filter((id): id is string => Boolean(id)), - ), - ]; - const telemetryRows = assignmentIds.length - ? await database.all( - `SELECT * FROM agent_mode_runs - WHERE team_id = ? AND provider_assignment_id IN (${assignmentIds.map(() => "?").join(", ")}) - ORDER BY COALESCE(started_at, created_at) ASC, created_at ASC`, - [teamId, ...assignmentIds], - ) - : []; - const telemetryByAssignment = new Map(); - for (const telemetryRow of telemetryRows) { - const entry = telemetryEntry(telemetryRow); - if (!entry || !entry.assignmentId) continue; - telemetryByAssignment.set(entry.assignmentId, [ - ...(telemetryByAssignment.get(entry.assignmentId) ?? []), - entry, - ]); - } - const items = selected.map((row) => { - const run = serializeAgentModeRunRow(row); - if (!run) { - throw new CapacityGovernanceError( - "execution_run_projection_invalid", - "Mode-run row could not be serialized.", - 500, - ); - } - const finishedAt = - row.completed_at ?? - row.failed_at ?? - row.assignment_completed_at ?? - row.assignment_failed_at ?? - row.assignment_returned_at ?? - null; - const tokens = tokenDiagnostics(row); - return { - id: run.id, - status: run.status, - mode: run.mode, - timing: { - createdAt: run.createdAt, - startedAt: run.startedAt, - completedAt: run.completedAt, - failedAt: run.failedAt, - finishedAt, - durationMs: durationMs(run.startedAt ?? run.createdAt, finishedAt), - }, - agent: { - projectId: run.projectId, - projectSlug: row.project_slug ?? null, - projectName: row.project_name ?? null, - projectAgentClassId: run.projectAgentClassId, - classSlug: row.agent_class_slug ?? null, - className: row.agent_class_name ?? null, - agentId: run.agentId, - handlerId: run.handlerId, - }, - assignment: { - id: run.providerAssignmentId, - status: row.assignment_status ?? null, - leaseState: row.assignment_lease_state ?? null, - workdayId: row.assignment_work_day_id ?? null, - taskId: row.assignment_task_id ?? null, - decisionId: row.assignment_decision_id ?? null, - proposalId: row.assignment_proposal_id ?? null, - runnerId: row.assignment_runner_id ?? null, - lifecycleCode: row.assignment_lifecycle_code ?? null, - lifecycleReason: row.assignment_lifecycle_reason ?? null, - }, - executionProvider: { - id: run.executionProviderId, - capacityProviderId: run.capacityProviderId, - tokenCounts: tokens, - hasTokenCounts: Boolean( - tokens.inputTokens || tokens.outputTokens || tokens.cachedInputTokens, - ), - }, - input: { - selectedInput: run.selectedInput, - decisionInput: jsonRecord(row.assignment_decision_input_json), - capacityEnvelope: run.capacityEnvelope, - workspaceContext: jsonRecord(row.assignment_workspace_context_json), - allowedOutputs: jsonRecord(row.assignment_allowed_outputs_json), - }, - output: { - outputs: run.outputs, - lifecycleOutput: jsonRecord(row.assignment_lifecycle_output_json), - usageActual: run.usageActual, - validation: run.validation, - fallbackReason: run.fallbackReason, - }, - contentArtifactRefs: contentArtifactRefs(row), - context: { - traceRefs: run.traceRefs, - assignmentExplanation: jsonRecord(row.assignment_explanation_json), - }, - metadata: { modeRun: run.metadata }, - modeRuns: telemetryByAssignment.get(run.providerAssignmentId) ?? [], - }; - }); - const hasMore = rows.length > limit; - const last = selected.at(-1); - return { - items, - page: { - limit, - hasMore, - nextCursor: - hasMore && last - ? encodeCapacityPageCursor({ - createdAt: String(last.created_at), - id: String(last.id), - }) - : null, - }, - }; + const page = await new ProviderAssignmentRepository(database).list(teamId, filters); + return { ...page, items: page.items.map((assignment) => project(assignment, filters.projection === 'activity')) }; } diff --git a/src/api/capacity/repositories/support/mode-run.ts b/src/api/capacity/repositories/support/mode-run.ts deleted file mode 100644 index c98a514e..00000000 --- a/src/api/capacity/repositories/support/mode-run.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type { AgentModeRun,ProviderAssignment } from '@treeseed/sdk/agent-capacity'; -import { validateAgentModeRun } from '@treeseed/sdk/agent-capacity'; -import { -encodeCapacityPageCursor, -normalizeCapacityPageLimit, -type CapacityPage, -type CapacityPageCursor, -} from '@treeseed/sdk/capacity-pagination'; -import { randomUUID } from 'node:crypto'; -import type { CapacityDatabaseOperation,CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; - -type JsonRecord = Record; - -interface ModeRunRepositoryDatabase extends CapacityGovernanceDatabase { - getProviderAssignment(teamId: string, assignmentId: string): Promise; -} - -interface AgentModeRunRow extends Record { - id: string; - team_id: string; - project_id: string; - provider_assignment_id: string; - capacity_provider_id: string; - project_agent_class_id: string; - mode: string; - status: string; - created_at: string; -} - -export interface AgentModeRunListFilters { - mode?: string | null; - assignmentId?: string | null; - workDayId?: string | null; - limit?: unknown; - cursor?: CapacityPageCursor | null; -} - -export interface AgentModeRunWrite { - id?: string; - teamId: string; - providerAssignmentId: string; - executionProviderId?: string | null; - agentId?: string | null; - handlerId?: string | null; - mode?: AgentModeRun['mode']; - status?: AgentModeRun['status']; - selectedInput?: JsonRecord; - capacityEnvelope?: JsonRecord; - outputs?: JsonRecord; - traceRefs?: JsonRecord; - usageActual?: JsonRecord; - validation?: JsonRecord; - fallbackReason?: string | null; - startedAt?: string | null; - completedAt?: string | null; - failedAt?: string | null; - metadata?: JsonRecord; - capacityUsageActualId?: string | null; -} - -export function logicalModeRunSql(alias = 'agent_mode_runs'): string { - const metadata = `${alias}.metadata_json::jsonb`; - const source = `${metadata} ->> 'source'`; - return `COALESCE(${metadata} ->> 'recordKind', CASE WHEN COALESCE(${source}, '') LIKE 'provider_runner_%' OR COALESCE(${source}, '') LIKE 'execution_provider_%' THEN 'telemetry' ELSE 'mode-run' END) = 'mode-run'`; -} - -function record(value: unknown, field: string, modeRunId: string): JsonRecord { - if (value && typeof value === 'object' && !Array.isArray(value)) return value as JsonRecord; - throw new CapacityGovernanceError('agent_mode_run_json_invalid', `Mode run ${modeRunId} has invalid ${field}.`, 500, { modeRunId, field }); -} - -function jsonRecord(value: unknown, field: string, modeRunId: string): JsonRecord { - if (typeof value !== 'string') return record(value, field, modeRunId); - try { - return record(JSON.parse(value), field, modeRunId); - } catch { - throw new CapacityGovernanceError('agent_mode_run_json_invalid', `Mode run ${modeRunId} has invalid ${field}.`, 500, { modeRunId, field }); - } -} - -function text(value: unknown): string { - return value == null ? '' : String(value); -} - -function nullableText(value: unknown): string | null { - return typeof value === 'string' && value ? value : null; -} - -export function serializeAgentModeRunRow(row: Record | null): AgentModeRun | null { - if (!row) return null; - const id = text(row.id); - const modeRun = { - id, - teamId: text(row.team_id), - projectId: text(row.project_id), - providerAssignmentId: text(row.provider_assignment_id), - capacityProviderId: text(row.capacity_provider_id), - executionProviderId: nullableText(row.execution_provider_id), - projectAgentClassId: text(row.project_agent_class_id), - agentId: nullableText(row.agent_id), - handlerId: nullableText(row.handler_id), - mode: text(row.mode), - status: text(row.status), - selectedInput: jsonRecord(row.selected_input_json, 'selected_input_json', id), - capacityEnvelope: jsonRecord(row.capacity_envelope_json, 'capacity_envelope_json', id) as unknown as AgentModeRun['capacityEnvelope'], - outputs: jsonRecord(row.outputs_json, 'outputs_json', id), - traceRefs: jsonRecord(row.trace_refs_json, 'trace_refs_json', id), - usageActual: jsonRecord(row.usage_actual_json, 'usage_actual_json', id), - validation: jsonRecord(row.validation_json, 'validation_json', id), - fallbackReason: nullableText(row.fallback_reason), - startedAt: nullableText(row.started_at), - completedAt: nullableText(row.completed_at), - failedAt: nullableText(row.failed_at), - metadata: jsonRecord(row.metadata_json, 'metadata_json', id), - createdAt: text(row.created_at), - updatedAt: text(row.updated_at), - }; - const validation = validateAgentModeRun(modeRun); - if (!validation.ok) { - const first = validation.diagnostics[0]!; - throw new CapacityGovernanceError(first.code, `Mode run ${id || 'unknown'} is corrupt: ${first.message}`, 500, { modeRunId: id || null, path: first.path }); - } - return modeRun as AgentModeRun; -} - -export async function readAgentModeRun( - database: CapacityGovernanceDatabase, - teamId: string, - modeRunId: string, -): Promise { - await database.ensureInitialized(); - return serializeAgentModeRunRow(await database.first( - `SELECT * FROM agent_mode_runs WHERE id = ? AND team_id = ? LIMIT 1`, - [modeRunId, teamId], - )); -} - -export async function listAgentModeRunsPage( - database: CapacityGovernanceDatabase, - projectId: string, - filters: AgentModeRunListFilters = {}, -): Promise> { - await database.ensureInitialized(); - const clauses = ['project_id = ?', logicalModeRunSql()]; - const values: unknown[] = [projectId]; - if (filters.mode) { clauses.push('mode = ?'); values.push(filters.mode); } - if (filters.assignmentId) { clauses.push('provider_assignment_id = ?'); values.push(filters.assignmentId); } - if (filters.workDayId) { - clauses.push('provider_assignment_id IN (SELECT id FROM capacity_provider_assignments WHERE work_day_id = ?)'); - values.push(filters.workDayId); - } - if (filters.cursor) { - clauses.push('(created_at < ? OR (created_at = ? AND id < ?))'); - values.push(filters.cursor.createdAt, filters.cursor.createdAt, filters.cursor.id); - } - const limit = normalizeCapacityPageLimit(filters.limit); - const rows = await database.all( - `SELECT * FROM agent_mode_runs - WHERE ${clauses.join(' AND ')} - ORDER BY created_at DESC, id DESC LIMIT ?`, - [...values, limit + 1], - ); - const hasMore = rows.length > limit; - const selected = rows.slice(0, limit); - const last = selected.at(-1); - return { - items: selected.map((row) => serializeAgentModeRunRow(row)).filter((run): run is AgentModeRun => run !== null), - page: { - limit, - hasMore, - nextCursor: hasMore && last - ? encodeCapacityPageCursor({ createdAt: String(last.created_at), id: String(last.id) }) - : null, - }, - }; -} - -export async function persistAgentModeRun( - database: ModeRunRepositoryDatabase, - input: AgentModeRunWrite, -): Promise { - await database.ensureInitialized(); - const assignment = await database.getProviderAssignment(input.teamId, input.providerAssignmentId); - if (!assignment) return null; - const now = new Date().toISOString(); - const id = input.id ?? randomUUID(); - const operations: CapacityDatabaseOperation[] = [{ - query: `INSERT INTO agent_mode_runs ( - id, team_id, project_id, provider_assignment_id, capacity_provider_id, execution_provider_id, - project_agent_class_id, agent_id, handler_id, mode, status, selected_input_json, capacity_envelope_json, - outputs_json, trace_refs_json, usage_actual_json, validation_json, fallback_reason, - started_at, completed_at, failed_at, metadata_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - execution_provider_id = EXCLUDED.execution_provider_id, - project_agent_class_id = EXCLUDED.project_agent_class_id, - agent_id = EXCLUDED.agent_id, - handler_id = EXCLUDED.handler_id, - mode = EXCLUDED.mode, - status = EXCLUDED.status, - selected_input_json = EXCLUDED.selected_input_json, - capacity_envelope_json = EXCLUDED.capacity_envelope_json, - outputs_json = EXCLUDED.outputs_json, - trace_refs_json = CASE - WHEN EXCLUDED.trace_refs_json = '{}' THEN agent_mode_runs.trace_refs_json - ELSE (agent_mode_runs.trace_refs_json::jsonb || EXCLUDED.trace_refs_json::jsonb)::text - END, - usage_actual_json = EXCLUDED.usage_actual_json, - validation_json = EXCLUDED.validation_json, - fallback_reason = EXCLUDED.fallback_reason, - started_at = COALESCE(EXCLUDED.started_at, agent_mode_runs.started_at), - completed_at = COALESCE(EXCLUDED.completed_at, agent_mode_runs.completed_at), - failed_at = COALESCE(EXCLUDED.failed_at, agent_mode_runs.failed_at), - metadata_json = EXCLUDED.metadata_json, - updated_at = EXCLUDED.updated_at - WHERE agent_mode_runs.team_id = EXCLUDED.team_id - AND agent_mode_runs.provider_assignment_id = EXCLUDED.provider_assignment_id`, - params: [ - id, - assignment.teamId, - assignment.projectId, - assignment.id, - assignment.capacityProviderId, - input.executionProviderId ?? assignment.executionProviderId ?? null, - assignment.projectAgentClassId, - input.agentId ?? assignment.agentId ?? null, - input.handlerId ?? assignment.handlerId ?? null, - input.mode ?? assignment.mode, - input.status ?? 'queued', - JSON.stringify(input.selectedInput ?? {}), - JSON.stringify(input.capacityEnvelope ?? assignment.capacityEnvelope), - JSON.stringify(input.outputs ?? {}), - JSON.stringify(input.traceRefs ?? {}), - JSON.stringify(input.usageActual ?? {}), - JSON.stringify(input.validation ?? {}), - input.fallbackReason ?? null, - input.startedAt ?? null, - input.completedAt ?? null, - input.failedAt ?? null, - JSON.stringify(input.metadata ?? {}), - now, - now, - ], - }]; - const usageActualId = input.capacityUsageActualId ?? null; - if (usageActualId) { - operations.push({ - query: `UPDATE capacity_usage_actuals - SET assignment_id = ?, mode_run_id = ?, mode = ? - WHERE id = ? - AND EXISTS ( - SELECT 1 FROM agent_mode_runs - WHERE id = ? AND team_id = ? AND provider_assignment_id = ? - )`, - params: [assignment.id, id, input.mode ?? assignment.mode, usageActualId, id, assignment.teamId, assignment.id], - }); - } - await database.batch(operations); - const stored = await readAgentModeRun(database, assignment.teamId, id); - if (!stored || stored.providerAssignmentId !== assignment.id) { - throw new CapacityGovernanceError( - 'agent_mode_run_idempotency_conflict', - 'Mode-run id is already owned by a different provider assignment.', - 409, - { modeRunId: id, assignmentId: assignment.id }, - ); - } - return stored; -} diff --git a/src/api/capacity/repositories/support/planning-state.ts b/src/api/capacity/repositories/support/planning-state.ts deleted file mode 100644 index 3c157184..00000000 --- a/src/api/capacity/repositories/support/planning-state.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { -DecisionExecutionInputRecord, -DecisionExecutionInputStatus, -DecisionExecutionReadinessStatus, -DecisionPlanningStatus, -PlanningInputRequest, -PlanningInputRequestStatus, -} from '@treeseed/sdk/agent-capacity'; -import type { CapacityDatabaseOperation,CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; -import { decodeDurableJsonObject } from '../../durable-json.ts'; - -type Row = Record; -type JsonRecord = Record; - -const READINESS = new Set(['draft', 'blocked', 'ready', 'stale', 'waived']); -const PLANNING_STATUSES = new Set(['requested', 'complete', 'waived', 'rejected', 'stale']); -const EXECUTION_STATUSES = new Set(['proposed', 'accepted', 'revision_requested', 'rejected', 'stale']); - -function text(value: unknown): string { return value == null ? '' : String(value); } -function nullableText(value: unknown): string | null { return value == null ? null : String(value); } -function required(row: Row, id: string, field: string, column: string) { - const value = text(row[column]); - if (!value) throw new CapacityGovernanceError('planning_state_field_invalid', `Planning state ${id || '(unknown)'} requires ${field}.`, 500, { id: id || null, field }); - return value; -} -function enumValue(value: unknown, allowed: Set, code: string): T { - const candidate = text(value) as T; - if (!allowed.has(candidate)) throw new CapacityGovernanceError(code, `Unknown planning state value ${candidate || '(empty)'}.`, 500); - return candidate; -} -function object(row: Row, id: string, column: string) { - return decodeDurableJsonObject(row[column], { owner: 'planning state', ownerId: id, column }); -} - -export function serializeDecisionPlanningStatusRow(row: Row | null): DecisionPlanningStatus | null { - if (!row) return null; - const id = text(row.id); - return { - id, - teamId: required(row, id, 'teamId', 'team_id'), - projectId: required(row, id, 'projectId', 'project_id'), - decisionId: required(row, id, 'decisionId', 'decision_id'), - humanApprovalState: nullableText(row.human_approval_state), - executionReadiness: enumValue(row.execution_readiness, READINESS, 'decision_execution_readiness_invalid'), - planningInputsStatus: enumValue(row.planning_inputs_status, PLANNING_STATUSES, 'planning_input_status_invalid'), - scopeHash: required(row, id, 'scopeHash', 'scope_hash'), - staleReason: nullableText(row.stale_reason), - readyAt: nullableText(row.ready_at), - staleAt: nullableText(row.stale_at), - metadata: object(row, id, 'metadata_json'), - createdAt: required(row, id, 'createdAt', 'created_at'), - updatedAt: required(row, id, 'updatedAt', 'updated_at'), - }; -} - -export function serializePlanningInputRequestRow(row: Row | null): PlanningInputRequest | null { - if (!row) return null; - const id = text(row.id); - const mode = text(row.mode); - if (mode !== 'planning' && mode !== 'acting') throw new CapacityGovernanceError('planning_input_mode_invalid', `Planning input request ${id} has invalid mode.`, 500, { id }); - const invocationStatus = text(row.status); - const status = invocationStatus === 'queued' ? 'requested' - : invocationStatus === 'completed' ? 'complete' - : invocationStatus === 'cancelled' ? 'waived' - : invocationStatus === 'failed' ? 'rejected' - : invocationStatus; - return { - id, - teamId: required(row, id, 'teamId', 'team_id'), - projectId: required(row, id, 'projectId', 'project_id'), - decisionId: required(row, id, 'decisionId', 'decision_id'), - projectAgentClassId: nullableText(row.project_agent_class_id), - mode, - status: enumValue(status, PLANNING_STATUSES, 'planning_input_status_invalid'), - scopeHash: required(row, id, 'scopeHash', 'scope_hash'), - prompt: nullableText(row.prompt), - response: object(row, id, 'response_json'), - metadata: object(row, id, 'metadata_json'), - requestedAt: required(row, id, 'requestedAt', 'requested_at'), - completedAt: nullableText(row.completed_at), - staleAt: nullableText(row.stale_at), - }; -} - -export function serializeDecisionExecutionInputRow(row: Row | null): DecisionExecutionInputRecord | null { - if (!row) return null; - const id = text(row.id); - const mode = text(row.mode); - if (mode !== 'planning' && mode !== 'acting') throw new CapacityGovernanceError('decision_execution_input_mode_invalid', `Decision execution input ${id} has invalid mode.`, 500, { id }); - return { - id, - teamId: required(row, id, 'teamId', 'team_id'), - projectId: required(row, id, 'projectId', 'project_id'), - decisionId: required(row, id, 'decisionId', 'decision_id'), - workGraphNodeId: nullableText(row.work_graph_node_id), - projectAgentClassId: required(row, id, 'projectAgentClassId', 'project_agent_class_id'), - mode, - status: enumValue(row.status, EXECUTION_STATUSES, 'decision_execution_input_status_invalid'), - scopeHash: required(row, id, 'scopeHash', 'scope_hash'), - input: object(row, id, 'input_json') as unknown as DecisionExecutionInputRecord['input'], - metadata: object(row, id, 'metadata_json'), - acceptedAt: nullableText(row.accepted_at), - revisionRequestedAt: nullableText(row.revision_requested_at), - staleAt: nullableText(row.stale_at), - createdAt: required(row, id, 'createdAt', 'created_at'), - updatedAt: required(row, id, 'updatedAt', 'updated_at'), - }; -} - -export function decisionPlanningStatusOperation(value: DecisionPlanningStatus): CapacityDatabaseOperation { - const now = value.updatedAt ?? new Date().toISOString(); - return { - query: `INSERT INTO decision_planning_statuses ( - id, team_id, project_id, decision_id, human_approval_state, execution_readiness, planning_inputs_status, - scope_hash, stale_reason, ready_at, stale_at, metadata_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (decision_id) DO UPDATE SET - team_id = EXCLUDED.team_id, project_id = EXCLUDED.project_id, - human_approval_state = EXCLUDED.human_approval_state, execution_readiness = EXCLUDED.execution_readiness, - planning_inputs_status = EXCLUDED.planning_inputs_status, scope_hash = EXCLUDED.scope_hash, - stale_reason = EXCLUDED.stale_reason, ready_at = EXCLUDED.ready_at, stale_at = EXCLUDED.stale_at, - metadata_json = EXCLUDED.metadata_json, updated_at = EXCLUDED.updated_at`, - params: [ - value.id, value.teamId, value.projectId, value.decisionId, value.humanApprovalState ?? null, - value.executionReadiness, value.planningInputsStatus, value.scopeHash, value.staleReason ?? null, - value.readyAt ?? null, value.staleAt ?? null, JSON.stringify(value.metadata ?? {}), value.createdAt ?? now, now, - ], - }; -} - -export class PlanningStateRepository { - constructor(private readonly database: CapacityGovernanceDatabase) {} - - async upsertPlanningStatus(value: DecisionPlanningStatus) { - await this.database.ensureInitialized(); - await this.database.batch([decisionPlanningStatusOperation(value)]); - return this.getPlanningStatus(value.decisionId); - } - - async getPlanningStatus(decisionId: string) { - await this.database.ensureInitialized(); - return serializeDecisionPlanningStatusRow(await this.database.first( - `SELECT * FROM decision_planning_statuses WHERE decision_id = ? LIMIT 1`, [decisionId], - )); - } - - async createPlanningRequest(request: PlanningInputRequest, planning: DecisionPlanningStatus) { - await this.database.ensureInitialized(); - const status = request.status === 'requested' ? 'queued' - : request.status === 'complete' ? 'completed' - : request.status === 'waived' ? 'cancelled' - : request.status === 'rejected' ? 'failed' - : request.status; - await this.database.batch([ - decisionPlanningStatusOperation(planning), - { - query: `INSERT INTO agent_invocation_requests ( - id, team_id, project_id, decision_id, project_agent_class_id, mode, execution_kind, - trigger_kind, status, scope_hash, prompt, content_refs_json, priority_class, available_at, - idempotency_key, request_digest, response_json, metadata_json, requested_at, completed_at, stale_at - ) VALUES (?, ?, ?, ?, ?, ?, 'workday', 'manual', ?, ?, ?, '[]', 'operational', ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - team_id = EXCLUDED.team_id, project_id = EXCLUDED.project_id, decision_id = EXCLUDED.decision_id, - project_agent_class_id = EXCLUDED.project_agent_class_id, mode = EXCLUDED.mode, status = EXCLUDED.status, - scope_hash = EXCLUDED.scope_hash, prompt = EXCLUDED.prompt, response_json = EXCLUDED.response_json, - metadata_json = EXCLUDED.metadata_json, completed_at = EXCLUDED.completed_at, stale_at = EXCLUDED.stale_at`, - params: [ - request.id, request.teamId, request.projectId, request.decisionId, request.projectAgentClassId ?? null, - request.mode, status, request.scopeHash, request.prompt ?? null, request.requestedAt, request.id, - request.scopeHash, JSON.stringify(request.response ?? {}), - JSON.stringify(request.metadata ?? {}), request.requestedAt, request.completedAt ?? null, request.staleAt ?? null, - ], - }, - ]); - return this.getPlanningRequest(request.id); - } - - async getPlanningRequest(id: string) { - return serializePlanningInputRequestRow(await this.database.first(`SELECT * FROM agent_invocation_requests WHERE id = ? AND execution_kind = 'workday' LIMIT 1`, [id])); - } - - async listPlanningRequests(decisionId: string) { - await this.database.ensureInitialized(); - const rows = await this.database.all(`SELECT * FROM agent_invocation_requests WHERE decision_id = ? AND execution_kind = 'workday' ORDER BY requested_at DESC, id DESC LIMIT 200`, [decisionId]); - return rows.map((row) => serializePlanningInputRequestRow(row) as PlanningInputRequest); - } - - async createExecutionInput(value: DecisionExecutionInputRecord, planning: DecisionPlanningStatus) { - await this.database.ensureInitialized(); - const now = value.updatedAt ?? new Date().toISOString(); - const workGraphNodeId = value.workGraphNodeId ?? value.input.workGraphNodeId ?? null; - const sameSlot = workGraphNodeId - ? 'work_graph_node_id = ?' - : 'work_graph_node_id IS NULL'; - const slotParams = workGraphNodeId ? [workGraphNodeId] : []; - await this.database.batch([ - decisionPlanningStatusOperation(planning), - { - query: `UPDATE agent_capacity_plans SET status = 'superseded', superseded_at = COALESCE(superseded_at, ?), updated_at = ? - WHERE decision_id = ? AND project_id = ? AND status IN ('draft','accepted','scheduled','active') - AND EXISTS (SELECT 1 FROM decision_execution_inputs prior WHERE prior.decision_id = ? AND prior.project_id = ? - AND prior.${sameSlot} AND prior.scope_hash != ? AND prior.status IN ('proposed','accepted'))`, - params: [now, now, value.decisionId, value.projectId, value.decisionId, value.projectId, ...slotParams, value.scopeHash], - }, - { query: `UPDATE decision_execution_inputs SET status = 'stale', stale_at = COALESCE(stale_at, ?), updated_at = ? WHERE decision_id = ? AND project_id = ? AND ${sameSlot} AND scope_hash != ? AND status IN ('proposed','accepted')`, params: [now, now, value.decisionId, value.projectId, ...slotParams, value.scopeHash] }, - { - query: `INSERT INTO decision_execution_inputs ( - id, team_id, project_id, decision_id, work_graph_node_id, project_agent_class_id, mode, status, scope_hash, - input_json, metadata_json, accepted_at, revision_requested_at, stale_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - team_id = EXCLUDED.team_id, project_id = EXCLUDED.project_id, decision_id = EXCLUDED.decision_id, - work_graph_node_id = EXCLUDED.work_graph_node_id, - project_agent_class_id = EXCLUDED.project_agent_class_id, mode = EXCLUDED.mode, status = EXCLUDED.status, - scope_hash = EXCLUDED.scope_hash, input_json = EXCLUDED.input_json, metadata_json = EXCLUDED.metadata_json, - accepted_at = EXCLUDED.accepted_at, revision_requested_at = EXCLUDED.revision_requested_at, - stale_at = EXCLUDED.stale_at, updated_at = EXCLUDED.updated_at`, - params: [ - value.id, value.teamId, value.projectId, value.decisionId, workGraphNodeId, value.projectAgentClassId, value.mode, - value.status, value.scopeHash, JSON.stringify(value.input), JSON.stringify(value.metadata ?? {}), - value.acceptedAt ?? null, value.revisionRequestedAt ?? null, value.staleAt ?? null, value.createdAt ?? now, now, - ], - }, - ]); - return this.getExecutionInput(value.id); - } - - async listExecutionInputs(decisionId: string, filters: { status?: DecisionExecutionInputStatus | null } = {}) { - await this.database.ensureInitialized(); - const values: unknown[] = [decisionId]; - const statusClause = filters.status ? ' AND status = ?' : ''; - if (filters.status) values.push(enumValue(filters.status, EXECUTION_STATUSES, 'decision_execution_input_status_invalid')); - const rows = await this.database.all(`SELECT * FROM decision_execution_inputs WHERE decision_id = ?${statusClause} ORDER BY created_at DESC, id DESC LIMIT 200`, values); - return rows.map((row) => serializeDecisionExecutionInputRow(row) as DecisionExecutionInputRecord); - } - - async getExecutionInput(id: string) { - await this.database.ensureInitialized(); - return serializeDecisionExecutionInputRow(await this.database.first(`SELECT * FROM decision_execution_inputs WHERE id = ? LIMIT 1`, [id])); - } - - async transitionExecutionInput(value: DecisionExecutionInputRecord, planning: DecisionPlanningStatus) { - await this.database.ensureInitialized(); - await this.database.batch([ - decisionPlanningStatusOperation(planning), - { - query: `UPDATE decision_execution_inputs SET status = ?, accepted_at = ?, revision_requested_at = ?, metadata_json = ?, updated_at = ? WHERE id = ?`, - params: [value.status, value.acceptedAt ?? null, value.revisionRequestedAt ?? null, JSON.stringify(value.metadata ?? {}), value.updatedAt, value.id], - }, - ]); - return this.getExecutionInput(value.id); - } -} diff --git a/src/api/capacity/repositories/support/structured-estimate.ts b/src/api/capacity/repositories/support/structured-estimate.ts deleted file mode 100644 index fa42fb2f..00000000 --- a/src/api/capacity/repositories/support/structured-estimate.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { -DecisionPlanningStatus, -StructuredAgentEstimate, -StructuredAgentEstimateRecord, -StructuredAgentEstimateStatus, -} from '@treeseed/sdk/agent-capacity'; -import { validateStructuredAgentEstimate } from '../../policy/decision-work.ts'; -import type { CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; -import { decodeDurableJsonObject } from '../../durable-json.ts'; -import { decisionPlanningStatusOperation } from './planning-state.ts'; - -type Row = Record; -const STATUSES = new Set(['submitted', 'accepted', 'rejected', 'superseded']); - -function required(row: Row, column: string): string { - const value = row[column]; - if (typeof value !== 'string' || !value) throw new CapacityGovernanceError('structured_agent_estimate_corrupt', `Structured estimate has invalid ${column}.`, 500, { estimateId: typeof row.id === 'string' ? row.id : null, column }); - return value; -} -function nullable(row: Row, column: string): string | null { const value = row[column]; return value == null ? null : required(row, column); } -function status(value: unknown, errorStatus = 500): StructuredAgentEstimateStatus { - const candidate = String(value ?? '') as StructuredAgentEstimateStatus; - if (!STATUSES.has(candidate)) throw new CapacityGovernanceError('structured_agent_estimate_status_invalid', `Unknown structured estimate status ${candidate || '(empty)'}.`, errorStatus); - return candidate; -} - -export function serializeStructuredAgentEstimateRow(row: Row | null): StructuredAgentEstimateRecord | null { - if (!row) return null; - const id = required(row, 'id'); - const stored = decodeDurableJsonObject(row.estimate_json, { owner: 'structured agent estimate', ownerId: id, column: 'estimate_json' }) as unknown as StructuredAgentEstimate; - const metadata = decodeDurableJsonObject(row.metadata_json, { owner: 'structured agent estimate', ownerId: id, column: 'metadata_json' }); - const estimate: StructuredAgentEstimateRecord = { - ...stored, - id, - teamId: required(row, 'team_id'), - projectId: required(row, 'project_id'), - decisionId: nullable(row, 'decision_id'), - proposalId: nullable(row, 'proposal_id'), - workUnitId: nullable(row, 'work_unit_id'), - agentClass: required(row, 'agent_class'), - agentId: nullable(row, 'agent_id'), - status: status(row.status), - metadata: { ...(stored.metadata ?? {}), ...metadata }, - createdAt: required(row, 'created_at'), - acceptedAt: nullable(row, 'accepted_at'), - rejectedAt: nullable(row, 'rejected_at'), - }; - const validation = validateStructuredAgentEstimate(estimate); - if (!validation.ok) throw new CapacityGovernanceError('structured_agent_estimate_corrupt', 'Stored structured estimate violates the SDK contract.', 500, { estimateId: id, diagnostics: validation.diagnostics }); - return estimate; -} - -export class StructuredAgentEstimateRepository { - constructor(private readonly database: CapacityGovernanceDatabase) {} - - async get(id: string) { - await this.database.ensureInitialized(); - return serializeStructuredAgentEstimateRow(await this.database.first(`SELECT * FROM structured_agent_estimates WHERE id = ? LIMIT 1`, [id])); - } - - async listDecision(decisionId: string, filterStatus?: StructuredAgentEstimateStatus | null) { - await this.database.ensureInitialized(); - const selectedStatus = filterStatus ? status(filterStatus, 400) : null; - const rows = await this.database.all(`SELECT * FROM structured_agent_estimates WHERE decision_id = ?${selectedStatus ? ' AND status = ?' : ''} ORDER BY created_at ASC, agent_class ASC, id ASC LIMIT 500`, selectedStatus ? [decisionId, selectedStatus] : [decisionId]); - return rows.map((row) => serializeStructuredAgentEstimateRow(row)!); - } - - async create(value: StructuredAgentEstimateRecord, recordMetadata: Record, planning: DecisionPlanningStatus) { - await this.database.ensureInitialized(); - await this.database.batch([ - decisionPlanningStatusOperation(planning), - { - query: `INSERT INTO structured_agent_estimates ( - id, team_id, project_id, decision_id, proposal_id, work_unit_id, agent_class, agent_id, status, - estimate_json, metadata_json, created_at, accepted_at, rejected_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - params: [value.id, value.teamId, value.projectId, value.decisionId ?? null, value.proposalId ?? null, value.workUnitId ?? null, - value.agentClass, value.agentId ?? null, value.status, JSON.stringify(value), JSON.stringify(recordMetadata), value.createdAt, - value.acceptedAt, value.rejectedAt], - }, - ]); - return this.get(value.id); - } - - async transition(id: string, from: StructuredAgentEstimateStatus[], to: StructuredAgentEstimateStatus, metadata: Record, now: string) { - const next = status(to, 400); - const existing = await this.get(id); - if (!existing) return null; - if (!from.includes(existing.status)) throw new CapacityGovernanceError('structured_agent_estimate_transition_conflict', `Cannot transition structured estimate from ${existing.status} to ${next}.`, 409, { estimateId: id, from: existing.status, to: next }); - await this.database.run(`UPDATE structured_agent_estimates SET status = ?, metadata_json = ?, accepted_at = CASE WHEN ? = 'accepted' THEN COALESCE(accepted_at, ?) ELSE accepted_at END, rejected_at = CASE WHEN ? = 'rejected' THEN COALESCE(rejected_at, ?) ELSE rejected_at END WHERE id = ? AND status = ?`, - [next, JSON.stringify(metadata), next, now, next, now, id, existing.status]); - const updated = await this.get(id); - if (updated?.status !== next) throw new CapacityGovernanceError('structured_agent_estimate_transition_conflict', 'Structured estimate changed concurrently.', 409, { estimateId: id }); - return updated; - } -} diff --git a/src/api/capacity/repositories/treedx/graph/decision-work-graph.ts b/src/api/capacity/repositories/treedx/graph/decision-work-graph.ts deleted file mode 100644 index 24535db8..00000000 --- a/src/api/capacity/repositories/treedx/graph/decision-work-graph.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { -DecisionAssignmentGraphRecord, -DecisionPlanningStatus, -DeliverableContractRecord, -DeliverableManifestRecord, -} from '@treeseed/sdk/agent-capacity'; -import { validateDecisionAssignmentGraph,validateDeliverableContract,validateDeliverableManifest } from '../../../policy/decision-work.ts'; -import type { CapacityDatabaseOperation,CapacityGovernanceDatabase } from '../../../database.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { decodeDurableJsonObject } from '../../../durable-json.ts'; -import { decisionPlanningStatusOperation } from '../../support/planning-state.ts'; - -type Row = Record; -type JsonRecord = Record; -const GRAPH_STATUSES = new Set(['draft', 'compiled', 'ready', 'executing', 'completed', 'blocked']); -const CONTRACT_STATUSES = new Set(['required', 'draft', 'submitted', 'approved', 'rejected']); - -function corrupt(owner: string, row: Row, column: string): never { - throw new CapacityGovernanceError(`${owner}_corrupt`, `${owner.replaceAll('_', ' ')} has invalid ${column}.`, 500, { id: typeof row.id === 'string' ? row.id : null, column }); -} -function text(owner: string, row: Row, column: string): string { const value = row[column]; return typeof value === 'string' && value ? value : corrupt(owner, row, column); } -function nullable(owner: string, row: Row, column: string): string | null { return row[column] == null ? null : text(owner, row, column); } -function metadata(owner: string, row: Row): JsonRecord { return decodeDurableJsonObject(row.metadata_json, { owner: owner.replaceAll('_', ' '), ownerId: text(owner, row, 'id'), column: 'metadata_json' }); } - -export function serializeDecisionAssignmentGraphRow(row: Row | null): DecisionAssignmentGraphRecord | null { - if (!row) return null; - const owner = 'decision_assignment_graph'; - const id = text(owner, row, 'id'); - const stored = decodeDurableJsonObject(row.graph_json, { owner: 'decision assignment graph', ownerId: id, column: 'graph_json' }) as unknown as DecisionAssignmentGraphRecord; - const status = text(owner, row, 'status') as DecisionAssignmentGraphRecord['status']; - if (!GRAPH_STATUSES.has(status)) corrupt(owner, row, 'status'); - const version = Number(row.version); - if (!Number.isInteger(version) || version < 1) corrupt(owner, row, 'version'); - if (row.active !== 0 && row.active !== 1 && row.active !== false && row.active !== true) corrupt(owner, row, 'active'); - const graph: DecisionAssignmentGraphRecord = { - ...stored, id, teamId: text(owner, row, 'team_id'), projectId: text(owner, row, 'project_id'), decisionId: text(owner, row, 'decision_id'), - version, status, active: row.active === 1 || row.active === true, metadata: { ...(stored.metadata ?? {}), ...metadata(owner, row) }, - compiledAt: nullable(owner, row, 'compiled_at'), createdAt: text(owner, row, 'created_at'), updatedAt: text(owner, row, 'updated_at'), - }; - const validation = validateDecisionAssignmentGraph(graph); - if (!validation.ok) throw new CapacityGovernanceError('decision_assignment_graph_corrupt', 'Stored decision assignment graph violates the SDK contract.', 500, { graphId: id, diagnostics: validation.diagnostics }); - return graph; -} - -export function serializeDeliverableContractRow(row: Row | null): DeliverableContractRecord | null { - if (!row) return null; - const owner = 'deliverable_contract'; - const id = text(owner, row, 'id'); - const stored = decodeDurableJsonObject(row.contract_json, { owner: 'deliverable contract', ownerId: id, column: 'contract_json' }) as unknown as DeliverableContractRecord; - const status = text(owner, row, 'status') as DeliverableContractRecord['status']; - if (!CONTRACT_STATUSES.has(status)) corrupt(owner, row, 'status'); - const contract: DeliverableContractRecord = { - ...stored, id, teamId: text(owner, row, 'team_id'), projectId: text(owner, row, 'project_id'), decisionId: text(owner, row, 'decision_id'), - deliverableType: text(owner, row, 'deliverable_type'), status, metadata: { ...(stored.metadata ?? {}), ...metadata(owner, row) }, - createdAt: text(owner, row, 'created_at'), updatedAt: text(owner, row, 'updated_at'), - }; - const validation = validateDeliverableContract(contract); - if (!validation.ok) throw new CapacityGovernanceError('deliverable_contract_corrupt', 'Stored deliverable contract violates the SDK contract.', 500, { contractId: id, diagnostics: validation.diagnostics }); - return contract; -} - -export function serializeDeliverableManifestRow(row: Row | null): DeliverableManifestRecord | null { - if (!row) return null; - const owner = 'deliverable_manifest'; - const id = text(owner, row, 'id'); - const stored = decodeDurableJsonObject(row.manifest_json, { owner: 'deliverable manifest', ownerId: id, column: 'manifest_json' }) as unknown as DeliverableManifestRecord; - if (row.ready_for_review !== 0 && row.ready_for_review !== 1 && row.ready_for_review !== false && row.ready_for_review !== true) corrupt(owner, row, 'ready_for_review'); - const manifest: DeliverableManifestRecord = { - ...stored, id, deliverableContractId: text(owner, row, 'deliverable_contract_id'), projectId: text(owner, row, 'project_id'), - decisionId: text(owner, row, 'decision_id'), readyForReview: row.ready_for_review === 1 || row.ready_for_review === true, - metadata: { ...(stored.metadata ?? {}), ...metadata(owner, row) }, submittedAt: nullable(owner, row, 'submitted_at'), createdAt: text(owner, row, 'created_at'), - }; - const validation = validateDeliverableManifest(manifest); - if (!validation.ok) throw new CapacityGovernanceError('deliverable_manifest_corrupt', 'Stored deliverable manifest violates the SDK contract.', 500, { manifestId: id, diagnostics: validation.diagnostics }); - return manifest; -} - -function contractInsert(contract: DeliverableContractRecord, idempotent = false): CapacityDatabaseOperation { - return { - query: `INSERT INTO deliverable_contracts (id, team_id, project_id, decision_id, deliverable_type, status, contract_json, metadata_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${idempotent ? ' ON CONFLICT (id) DO NOTHING' : ''}`, - params: [contract.id, contract.teamId, contract.projectId, contract.decisionId, contract.deliverableType, contract.status, JSON.stringify(contract), JSON.stringify(contract.metadata ?? {}), contract.createdAt, contract.updatedAt], - }; -} - -function graphBusinessFingerprint(graph: DecisionAssignmentGraphRecord) { - const { active: _active, compiledAt: _compiledAt, createdAt: _createdAt, updatedAt: _updatedAt, status: _status, ...business } = graph; - return JSON.stringify(business); -} - -export class DecisionWorkGraphRepository { - constructor(private readonly database: CapacityGovernanceDatabase) {} - async getGraph(id: string) { await this.database.ensureInitialized(); return serializeDecisionAssignmentGraphRow(await this.database.first(`SELECT * FROM decision_assignment_graphs WHERE id = ? LIMIT 1`, [id])); } - async listGraphs(decisionId: string, active?: boolean) { - await this.database.ensureInitialized(); - const rows = await this.database.all(`SELECT * FROM decision_assignment_graphs WHERE decision_id = ?${active === undefined ? '' : ' AND active = ?'} ORDER BY version DESC, created_at DESC LIMIT 100`, active === undefined ? [decisionId] : [decisionId, active ? 1 : 0]); - return rows.map((row) => serializeDecisionAssignmentGraphRow(row)!); - } - async nextVersion(decisionId: string) { await this.database.ensureInitialized(); const row = await this.database.first(`SELECT COALESCE(MAX(version), 0) AS version FROM decision_assignment_graphs WHERE decision_id = ?`, [decisionId]); return Number(row?.version ?? 0) + 1; } - async createGraph(graph: DecisionAssignmentGraphRecord, contracts: DeliverableContractRecord[]) { - await this.database.ensureInitialized(); - await this.database.batch([ - { query: `INSERT INTO decision_assignment_graphs (id, team_id, project_id, decision_id, version, status, active, graph_json, metadata_json, compiled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING`, params: [graph.id, graph.teamId, graph.projectId, graph.decisionId, graph.version, graph.status, JSON.stringify(graph), JSON.stringify(graph.metadata ?? {}), graph.compiledAt ?? null, graph.createdAt, graph.updatedAt] }, - ...contracts.map((contract) => contractInsert(contract, true)), - ]); - const persisted = await this.getGraph(graph.id); - if (!persisted || graphBusinessFingerprint(persisted) !== graphBusinessFingerprint(graph)) { - throw new CapacityGovernanceError('decision_assignment_graph_idempotency_conflict', 'Decision assignment graph id is already owned by different graph content.', 409, { graphId: graph.id }); - } - return persisted; - } - async activate(graph: DecisionAssignmentGraphRecord, planning: DecisionPlanningStatus, now: string, expectedStatus: DecisionAssignmentGraphRecord['status']) { - await this.database.batch([ - decisionPlanningStatusOperation(planning), - { query: `UPDATE decision_assignment_graphs SET active = 0, updated_at = ? WHERE decision_id = ? AND active = 1`, params: [now, graph.decisionId] }, - { query: `UPDATE decision_assignment_graphs SET active = 1, status = 'ready', graph_json = ?, updated_at = ? WHERE id = ? AND status = ? RETURNING id, active, status`, params: [JSON.stringify(graph), now, graph.id, expectedStatus] }, - { query: `SELECT 1 / CASE WHEN EXISTS (SELECT 1 FROM decision_assignment_graphs WHERE id = ? AND decision_id = ? AND active = 1 AND status = 'ready') THEN 1 ELSE 0 END AS activation_guard`, params: [graph.id, graph.decisionId] }, - ]); - const updated = await this.getGraph(graph.id); - if (!updated?.active || updated.status !== 'ready') throw new CapacityGovernanceError('decision_assignment_graph_activation_conflict', 'Decision assignment graph changed concurrently or is not activatable.', 409, { graphId: graph.id }); - return updated; - } - async getContract(id: string) { await this.database.ensureInitialized(); return serializeDeliverableContractRow(await this.database.first(`SELECT * FROM deliverable_contracts WHERE id = ? LIMIT 1`, [id])); } - async getManifest(id: string) { await this.database.ensureInitialized(); return serializeDeliverableManifestRow(await this.database.first(`SELECT * FROM deliverable_manifests WHERE id = ? LIMIT 1`, [id])); } - async submitManifest(contract: DeliverableContractRecord, manifest: DeliverableManifestRecord) { - await this.database.batch([ - { query: `UPDATE deliverable_contracts SET status = CASE WHEN ? = 1 THEN 'submitted' ELSE status END, updated_at = ? WHERE id = ? AND status IN ('required','draft','rejected')`, params: [manifest.readyForReview ? 1 : 0, manifest.createdAt, contract.id] }, - { query: `INSERT INTO deliverable_manifests (id, deliverable_contract_id, project_id, decision_id, ready_for_review, manifest_json, metadata_json, submitted_at, created_at) - SELECT ?, ?, ?, ?, CAST(? AS integer), ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM deliverable_contracts WHERE id = ? AND status IN ('required','draft','submitted','rejected')) ON CONFLICT (id) DO NOTHING`, - params: [manifest.id, manifest.deliverableContractId, manifest.projectId, manifest.decisionId, manifest.readyForReview ? 1 : 0, JSON.stringify(manifest), JSON.stringify(manifest.metadata ?? {}), manifest.submittedAt ?? null, manifest.createdAt, contract.id] }, - { query: `SELECT 1 / CASE WHEN EXISTS (SELECT 1 FROM deliverable_manifests WHERE id = ? AND deliverable_contract_id = ?) THEN 1 ELSE 0 END AS manifest_guard`, params: [manifest.id, contract.id] }, - ]); - return this.getManifest(manifest.id); - } - async transitionContract( - contract: DeliverableContractRecord, - to: 'approved' | 'rejected', - metadataValue: JsonRecord, - graph: DecisionAssignmentGraphRecord, - expectedGraphUpdatedAt: string, - newContracts: DeliverableContractRecord[], - now: string, - ) { - if (contract.status !== 'submitted') throw new CapacityGovernanceError('deliverable_contract_transition_conflict', `Cannot transition deliverable contract from ${contract.status} to ${to}.`, 409, { contractId: contract.id }); - await this.database.batch([ - { query: `UPDATE deliverable_contracts SET status = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND status = 'submitted'`, params: [to, JSON.stringify(metadataValue), now, contract.id] }, - { query: `UPDATE decision_assignment_graphs SET status = ?, graph_json = ?, metadata_json = ?, updated_at = ? WHERE id = ? AND version = ? AND updated_at = ?`, params: [graph.status, JSON.stringify(graph), JSON.stringify(graph.metadata ?? {}), now, graph.id, graph.version, expectedGraphUpdatedAt] }, - ...newContracts.map((candidate) => contractInsert(candidate)), - { query: `SELECT 1 / CASE WHEN EXISTS (SELECT 1 FROM deliverable_contracts WHERE id = ? AND status = ?) THEN 1 ELSE 0 END AS transition_guard`, params: [contract.id, to] }, - { query: `SELECT 1 / CASE WHEN EXISTS (SELECT 1 FROM decision_assignment_graphs WHERE id = ? AND updated_at = ?) THEN 1 ELSE 0 END AS graph_transition_guard`, params: [graph.id, now] }, - ]); - const updated = await this.getContract(contract.id); - if (updated?.status !== to) throw new CapacityGovernanceError('deliverable_contract_transition_conflict', 'Deliverable contract changed concurrently.', 409, { contractId: contract.id }); - return updated; - } -} diff --git a/src/api/capacity/services/accounts/engineering-source-authority.ts b/src/api/capacity/services/accounts/engineering-source-authority.ts deleted file mode 100644 index 544db8f7..00000000 --- a/src/api/capacity/services/accounts/engineering-source-authority.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { -type AgentArtifactManifest, -type DeliverableManifestRecord, -type GovernedPredecessorEvidence, -type GovernedReviewPolicy, -} from '@treeseed/sdk/agent-capacity'; -import { validateAgentArtifactManifest } from '../../artifact-manifest.ts'; -import type { CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; -import { decodeDurableJsonObject } from '../../durable-json.ts'; - -type JsonRecord = Record; - -function record(value: unknown): JsonRecord { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; -} - -function text(value: unknown): string { - return typeof value === 'string' && value.trim() ? value.trim() : ''; -} - -function immutableRef(value: unknown, context: JsonRecord): string { - const ref = text(value); - if (!/^[0-9a-f]{7,64}$/iu.test(ref)) { - throw new CapacityGovernanceError('engineering_source_ref_invalid', 'Engineering source authority must be an immutable hexadecimal commit id.', 409, context); - } - return ref; -} - -function artifactManifestFromOutputs(outputs: JsonRecord): AgentArtifactManifest | null { - const candidate = record(outputs.artifactManifest ?? record(outputs.metadata).artifactManifest); - return candidate.schemaVersion === 1 ? candidate as unknown as AgentArtifactManifest : null; -} - -async function selectedPredecessorEvidence(input: { - database: CapacityGovernanceDatabase; - graphId: string; - graph: JsonRecord; - node: JsonRecord; -}): Promise<{ exactBaseRef: string; predecessorEvidence: GovernedPredecessorEvidence[]; reviewPolicy: GovernedReviewPolicy | null }> { - const graphProjectId = text(input.graph.projectId); - const graphDecisionId = text(input.graph.decisionId); - const graphBaseRef = immutableRef(record(input.graph.metadata).exactBaseRef, { graphId: input.graphId, source: 'graph' }); - const nodes = Array.isArray(input.graph.nodes) ? input.graph.nodes.map(record) : []; - const edges = Array.isArray(input.graph.edges) ? input.graph.edges.map(record) : []; - const nodeId = text(input.node.id); - const projectPredecessorEvidence = text(record(input.node.metadata).stage) === 'review'; - const immediatePredecessorIds = edges.filter((edge) => text(edge.toNodeId) === nodeId).map((edge) => text(edge.fromNodeId)); - const ancestorIds = new Set(); - const pending = [...immediatePredecessorIds]; - while (pending.length) { - const ancestorId = pending.pop()!; - if (!ancestorId || ancestorIds.has(ancestorId)) continue; - ancestorIds.add(ancestorId); - for (const edge of edges.filter((candidate) => text(candidate.toNodeId) === ancestorId)) { - const predecessorId = text(edge.fromNodeId); - if (predecessorId) pending.push(predecessorId); - } - } - const ancestors = nodes.filter((candidate) => ancestorIds.has(text(candidate.id))); - const completedRevisionCycles = Math.max(0, ...nodes - .map((candidate) => Number(record(candidate.metadata).revisionCycle ?? 0)) - .filter(Number.isFinite)); - const requireRevisionCycle = record(input.graph.metadata).requireRevisionCycle === true; - const reviewPolicy = projectPredecessorEvidence ? { - requireRevisionCycle, - completedRevisionCycles, - requiredDisposition: requireRevisionCycle && completedRevisionCycles === 0 ? 'rejected' as const : null, - } : null; - if (!ancestors.length) return { exactBaseRef: graphBaseRef, predecessorEvidence: [], reviewPolicy }; - - const evidence: GovernedPredecessorEvidence[] = []; - const effectiveRefs = new Map(); - for (const predecessor of ancestors) { - const predecessorNodeId = text(predecessor.id); - if (predecessor.status !== 'completed') { - throw new CapacityGovernanceError('engineering_predecessor_incomplete', 'Engineering evidence projection requires completed graph ancestors.', 409, { graphId: input.graphId, nodeId, predecessorNodeId }); - } - const contractId = text(record(predecessor.metadata).producesDeliverableContractId); - if (!contractId) throw new CapacityGovernanceError('engineering_source_contract_missing', 'Completed predecessor has no source-owning deliverable contract.', 409, { graphId: input.graphId, nodeId, predecessorNodeId }); - const contract = await input.database.first(`SELECT status, deliverable_type, metadata_json FROM deliverable_contracts WHERE id = ? LIMIT 1`, [contractId]); - const contractStatus = text(contract?.status); - if (!contract || !['approved', 'rejected'].includes(contractStatus)) { - throw new CapacityGovernanceError('engineering_source_contract_unresolved', 'Predecessor source contract is not terminal.', 409, { graphId: input.graphId, nodeId, contractId }); - } - const contractMetadata = decodeDurableJsonObject(contract.metadata_json, { owner: 'deliverable contract', ownerId: contractId, column: 'metadata_json' }); - const manifestId = text(contractMetadata.deliverableManifestId); - if (!manifestId) throw new CapacityGovernanceError('engineering_source_manifest_missing', 'Terminal predecessor contract has no selected deliverable manifest.', 409, { graphId: input.graphId, nodeId, contractId }); - const manifestRow = await input.database.first(`SELECT manifest_json FROM deliverable_manifests WHERE id = ? AND deliverable_contract_id = ? LIMIT 1`, [manifestId, contractId]); - if (!manifestRow) throw new CapacityGovernanceError('engineering_source_manifest_missing', 'Selected predecessor deliverable manifest does not exist.', 409, { graphId: input.graphId, nodeId, contractId, manifestId }); - const manifest = decodeDurableJsonObject(manifestRow.manifest_json, { owner: 'deliverable manifest', ownerId: manifestId, column: 'manifest_json' }) as unknown as DeliverableManifestRecord; - const authority = record(manifest.sourceAuthority); - if (text(manifest.projectId) !== graphProjectId || text(manifest.decisionId) !== graphDecisionId) { - throw new CapacityGovernanceError('engineering_source_scope_mismatch', 'Predecessor source authority belongs to another project or decision.', 409, { graphId: input.graphId, nodeId, contractId, manifestId }); - } - const effectiveRef = immutableRef(authority.effectiveRef, { graphId: input.graphId, nodeId, contractId, manifestId }); - if (immediatePredecessorIds.includes(predecessorNodeId)) effectiveRefs.set(predecessorNodeId, effectiveRef); - if (!projectPredecessorEvidence) continue; - const assignmentId = text(authority.assignmentId); - const modeRunId = text(authority.modeRunId); - if (!assignmentId || !modeRunId) { - throw new CapacityGovernanceError('engineering_artifact_manifest_missing', 'Selected predecessor deliverable has no mode-run artifact provenance.', 409, { graphId: input.graphId, nodeId, contractId, manifestId }); - } - const modeRun = await input.database.first( - `SELECT outputs_json FROM agent_mode_runs WHERE id = ? AND provider_assignment_id = ? AND project_id = ? AND status = 'succeeded' LIMIT 1`, - [modeRunId, assignmentId, graphProjectId], - ); - if (!modeRun) throw new CapacityGovernanceError('engineering_artifact_manifest_missing', 'Selected predecessor mode run is missing or not successful.', 409, { graphId: input.graphId, nodeId, contractId, manifestId, assignmentId, modeRunId }); - const outputs = decodeDurableJsonObject(modeRun.outputs_json, { owner: 'agent mode run', ownerId: modeRunId, column: 'outputs_json' }); - const artifactManifest = artifactManifestFromOutputs(outputs); - const validation = artifactManifest ? validateAgentArtifactManifest(artifactManifest) : null; - if (!artifactManifest || !validation?.ok - || artifactManifest.assignmentId !== assignmentId - || artifactManifest.modeRunId !== modeRunId - || artifactManifest.projectId !== graphProjectId - || artifactManifest.status !== 'completed') { - throw new CapacityGovernanceError('engineering_artifact_manifest_invalid', validation?.reason ?? 'Selected predecessor artifact manifest is missing, incomplete, or outside graph scope.', 409, { graphId: input.graphId, nodeId, contractId, manifestId, assignmentId, modeRunId }); - } - evidence.push({ - graphNodeId: predecessorNodeId, - stage: text(record(predecessor.metadata).stage) || null, - deliverableContractId: contractId, - deliverableType: text(contract.deliverable_type), - contractStatus: contractStatus as 'approved' | 'rejected', - deliverableManifest: manifest, - artifactManifest, - }); - } - const uniqueRefs = [...new Set(effectiveRefs.values())]; - if (uniqueRefs.length !== 1) throw new CapacityGovernanceError('engineering_source_ref_ambiguous', 'Engineering node predecessors do not converge on one exact source ref.', 409, { graphId: input.graphId, nodeId, effectiveRefs: uniqueRefs }); - return { exactBaseRef: uniqueRefs[0]!, predecessorEvidence: evidence, reviewPolicy }; -} - -export async function resolveEngineeringNodeAuthority(input: { - database: CapacityGovernanceDatabase; - graphId: string; - graph: JsonRecord; - node: JsonRecord; -}): Promise<{ exactBaseRef: string | null; predecessorEvidence: GovernedPredecessorEvidence[]; reviewPolicy: GovernedReviewPolicy | null }> { - if (record(input.graph.metadata).workflowKind !== 'estimate-derived') return { exactBaseRef: null, predecessorEvidence: [], reviewPolicy: null }; - return selectedPredecessorEvidence(input); -} - -export async function resolveEngineeringNodeSourceRef(input: { - database: CapacityGovernanceDatabase; - graphId: string; - graph: JsonRecord; - node: JsonRecord; -}): Promise { - return (await resolveEngineeringNodeAuthority(input)).exactBaseRef; -} diff --git a/src/api/capacity/services/build/ready-execution-node.ts b/src/api/capacity/services/build/ready-execution-node.ts index ce43ba83..79f14050 100644 --- a/src/api/capacity/services/build/ready-execution-node.ts +++ b/src/api/capacity/services/build/ready-execution-node.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { assignmentResultSchema, effectiveActivityProfileSchema, @@ -36,7 +35,6 @@ const stable = (value: unknown): string => { .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`).join(',')}}`; return JSON.stringify(value); }; -const sha256 = (value: unknown) => `sha256:${createHash('sha256').update(stable(value)).digest('hex')}`; function repositorySlug(value: unknown): string { const candidate = text(value).replace(/\.git$/u, ''); @@ -179,14 +177,37 @@ export async function workItemContext(store: any, node: ExecutionNode): Promise< } async function effectiveProfile(store: any, node: ExecutionNode): Promise<{ projectAgentClassId: string; profile: EffectiveActivityProfile }> { - const rows = await store.all(`SELECT id,handler_refs_json FROM project_agent_classes + const rows = await store.all(`SELECT id,handler_refs_json,metadata_json FROM project_agent_classes WHERE project_id=? AND status='active' ORDER BY id`, [node.projectId]); - for (const row of rows) for (const candidate of array(record(row.handler_refs_json).agents)) { + for (const row of rows) for (const [index, candidate] of array(record(row.handler_refs_json).agents).entries()) { const validation = validateAgentDefinitionModel(candidate); if (!validation.ok || !validation.data || validation.data.agentClass !== node.agentClass) continue; const activity = node.kind === 'communication' ? 'chat' : node.kind; if (!['planning','estimating','acting','reviewing','reporting','chat'].includes(activity)) continue; - const selected = validation.data.activityProfiles[activity as keyof typeof validation.data.activityProfiles]; + const metadata = record(row.metadata_json); + const commit = text(metadata.immutableRef); + const path = text(array(metadata.definitionPaths)[index]); + if (metadata.source !== 'project-library' || !/^[a-f0-9]{40}$/u.test(commit) || !path) { + throw new CapacityGovernanceError('execution_node_agent_profile_unpinned', + `Agent class ${text(row.id)} lacks an exact project-library definition.`, 409); + } + const connection = await resolveKnowledgeGatewayConnection(store, { + projectId: node.projectId, write: false, readRefs: [commit], + }); + if (!connection) throw new CapacityGovernanceError('execution_node_agent_profile_unavailable', + `Agent class ${text(row.id)} project library is unavailable.`, 409); + const response = record(await connection.client.readRepositoryFile({ + repoId: connection.repositoryId, ref: commit, path, + encoding: 'utf8', parseFrontmatter: true, allowProtected: true, + })); + const file = record(response.file ?? (Array.isArray(response.files) ? response.files[0] : null)); + const exact = validateAgentDefinitionModel(record(file.frontmatter)); + if (text(response.resolvedRef) !== commit || text(file.path) !== path + || !exact.ok || !exact.data || stable(exact.data) !== stable(validation.data)) { + throw new CapacityGovernanceError('execution_node_agent_profile_moved', + `Agent class ${text(row.id)} differs from its exact project-library definition.`, 409); + } + const selected = exact.data.activityProfiles[activity as keyof typeof exact.data.activityProfiles]; if (!selected) continue; return { projectAgentClassId: text(row.id), @@ -195,10 +216,8 @@ async function effectiveProfile(store: any, node: ExecutionNode): Promise<{ proj prompt: selected.prompt, ...(selected.additionalContext ? { additionalContext: selected.additionalContext } : {}), ...(selected.parameters ? { parameters: selected.parameters } : {}), - profileRef: { - store: 'treedx', model: 'agent', id: validation.data.id, - revision: 1, digest: sha256(validation.data), - }, + profileRef: { store: 'treedx', model: 'agent', id: exact.data.id, + repository: connection.repositoryId, commit, path }, activity, handlerOrigin: selected.handler.includes('/') ? 'project-runtime' : 'agent-package', permissionCeiling: selected.permissions, @@ -218,7 +237,20 @@ async function predecessorContext(store: any, node: ExecutionNode): Promise<{ re AND result.execution_node_revision=predecessor.node_revision AND result.status='completed' WHERE edge.team_id=? AND edge.to_node_id=? AND edge.graph_revision_removed IS NULL - ORDER BY edge.id,result.completed_at DESC`, [node.teamId,node.id]); + UNION ALL + SELECT actor_result.assignment_result_json,actor_result.assignment_attempt_json + FROM execution_edges downstream + JOIN execution_nodes reviewer ON reviewer.team_id=downstream.team_id AND reviewer.id=downstream.from_node_id + AND reviewer.kind='reviewing' AND reviewer.pair_role='reviewer' AND reviewer.status='completed' + JOIN execution_edges pair ON pair.team_id=reviewer.team_id AND pair.to_node_id=reviewer.id + AND pair.provenance='review-pair' AND pair.graph_revision_removed IS NULL + JOIN execution_nodes actor ON actor.team_id=pair.team_id AND actor.id=pair.from_node_id + AND actor.pair_role='actor' AND actor.work_item_id=reviewer.work_item_id + JOIN capacity_provider_assignments actor_result ON actor_result.team_id=actor.team_id + AND actor_result.execution_node_id=actor.id + AND actor_result.execution_node_revision=actor.node_revision AND actor_result.status='completed' + WHERE downstream.team_id=? AND downstream.to_node_id=? AND downstream.graph_revision_removed IS NULL`, + [node.teamId,node.id,node.teamId,node.id]); if (node.pairRole === 'actor' && node.nodeRevision > 1 && node.workItemId) rows.push(...await store.all( `SELECT result.assignment_result_json,result.assignment_attempt_json FROM capacity_provider_assignments result WHERE result.team_id=? AND result.execution_node_id=? diff --git a/src/api/capacity/services/capacity/accounting/settlement-service.ts b/src/api/capacity/services/capacity/accounting/settlement-service.ts index 5f5ecb01..6c0a22d5 100644 --- a/src/api/capacity/services/capacity/accounting/settlement-service.ts +++ b/src/api/capacity/services/capacity/accounting/settlement-service.ts @@ -24,7 +24,6 @@ export interface CapacitySettlementRequest { elapsedSeconds: number; providerUnits?: number | null; usd?: number | null; - modeRunId?: string | null; source: string; existingSettlementPolicy?: 'require-match' | 'replay'; metadata?: Record; @@ -52,7 +51,6 @@ function usageReportInput(input: CapacitySettlementRequest): CapacityUsageReport elapsedSeconds: input.elapsedSeconds, providerUnits: input.providerUnits, usd: input.usd, - modeRunId: input.modeRunId, source: input.source, metadata: { ...(input.metadata ?? {}), settlementKey: input.settlementKey }, usageActual: input.usageActual, @@ -191,8 +189,8 @@ function prepareCapacitySettlement( }); operations.push(...counterSettlementOperations(input, settlementToken, now, counterSettlements)); operations.push({ - query: `INSERT INTO capacity_ledger_entries (id, settlement_key, membership_id, capacity_provider_id, execution_provider_id, lane_id, lane_purpose, communication_overflow, execution_kind, trigger_kind, invocation_id, operation_handoff_id, reservation_id, assignment_id, mode_run_id, mode, team_id, project_id, work_day_id, task_id, phase, active_seconds, elapsed_seconds, provider_units, usd, source, metadata_json, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'task_completed_actual_settlement', CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS REAL), CAST(? AS REAL), ?, ?, ? WHERE EXISTS (SELECT 1 FROM capacity_reservations WHERE id = ? AND team_id = ? AND settlement_token = ?) ON CONFLICT (reservation_id, phase) DO NOTHING`, - params: [entryId, input.settlementKey, input.membershipId, reservation.capacity_provider_id, reservation.execution_provider_id ?? null, reservation.lane_id ?? null, reservation.lane_purpose ?? null, reservation.communication_overflow ?? 0, reservation.execution_kind ?? 'workday', reservation.trigger_kind ?? 'scheduled', reservation.invocation_id ?? null, reservation.operation_handoff_id ?? null, input.reservationId, input.assignmentId, input.modeRunId ?? null, reservation.mode ?? null, input.teamId, reservation.project_id ?? null, reservation.work_day_id ?? null, reservation.task_id ?? null, activeSeconds, elapsedSeconds, input.providerUnits ?? null, input.usd ?? null, input.source, JSON.stringify({ ...(input.metadata ?? {}), reservedSeconds, activeSeconds, elapsedSeconds, releasedSeconds: Math.max(0, reservedSeconds - activeSeconds), overrunSeconds: Math.max(0, activeSeconds - reservedSeconds) }), now, input.reservationId, input.teamId, settlementToken], + query: `INSERT INTO capacity_ledger_entries (id, settlement_key, membership_id, capacity_provider_id, execution_provider_id, lane_id, lane_purpose, communication_overflow, execution_kind, trigger_kind, invocation_id, operation_handoff_id, reservation_id, assignment_id, mode, team_id, project_id, work_day_id, task_id, phase, active_seconds, elapsed_seconds, provider_units, usd, source, metadata_json, created_at) SELECT ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'task_completed_actual_settlement', CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS REAL), CAST(? AS REAL), ?, ?, ? WHERE EXISTS (SELECT 1 FROM capacity_reservations WHERE id = ? AND team_id = ? AND settlement_token = ?) ON CONFLICT (reservation_id, phase) DO NOTHING`, + params: [entryId, input.settlementKey, input.membershipId, reservation.capacity_provider_id, reservation.execution_provider_id ?? null, reservation.lane_id ?? null, reservation.lane_purpose ?? null, reservation.communication_overflow ?? 0, reservation.execution_kind ?? 'workday', reservation.trigger_kind ?? 'scheduled', reservation.invocation_id ?? null, reservation.operation_handoff_id ?? null, input.reservationId, input.assignmentId, reservation.mode ?? null, input.teamId, reservation.project_id ?? null, reservation.work_day_id ?? null, reservation.task_id ?? null, activeSeconds, elapsedSeconds, input.providerUnits ?? null, input.usd ?? null, input.source, JSON.stringify({ ...(input.metadata ?? {}), reservedSeconds, activeSeconds, elapsedSeconds, releasedSeconds: Math.max(0, reservedSeconds - activeSeconds), overrunSeconds: Math.max(0, activeSeconds - reservedSeconds) }), now, input.reservationId, input.teamId, settlementToken], }); operations.push({ query: `UPDATE capacity_reservations SET active_seconds = ?, elapsed_seconds = ?, released_seconds = ?, overrun_seconds = ?, consumed_provider_units = ?, consumed_usd = ?, state = 'consumed', updated_at = ? WHERE id = ? AND team_id = ? AND settlement_token = ? AND EXISTS (SELECT 1 FROM capacity_ledger_entries WHERE reservation_id = ? AND phase = ? AND id = ?)`, diff --git a/src/api/capacity/services/capacity/accounting/usage-report-service.ts b/src/api/capacity/services/capacity/accounting/usage-report-service.ts index 76fec73c..774badb0 100644 --- a/src/api/capacity/services/capacity/accounting/usage-report-service.ts +++ b/src/api/capacity/services/capacity/accounting/usage-report-service.ts @@ -18,7 +18,6 @@ export interface CapacityUsageReportRequest { elapsedSeconds: number; providerUnits?: number | null; usd?: number | null; - modeRunId?: string | null; source: string; metadata?: Record; usageActual?: CapacityUsageActualInput; @@ -72,14 +71,14 @@ export function capacityUsageInsertOperation(input: CapacityUsageReportRequest, const usage = input.usageActual ?? {}; return { query: `INSERT INTO capacity_usage_actuals ( - id, idempotency_key, task_id, work_day_id, project_id, task_signature, execution_profile_id, assignment_id, assignment_attempt, usage_dimension, accounting_mode, mode_run_id, mode, + id, idempotency_key, task_id, work_day_id, project_id, task_signature, execution_profile_id, assignment_id, assignment_attempt, usage_dimension, accounting_mode, mode, capacity_provider_id, execution_provider_id, lane_id, lane_purpose, communication_overflow, execution_kind, trigger_kind, invocation_id, parent_workday_id, parent_assignment_id, handoff_root_id, handoff_parent_id, handoff_depth, source_message_refs_json, operation_handoff_id, business_model, model_name, input_tokens, output_tokens, cached_input_tokens, reasoning_tokens, quota_minutes, wall_minutes, files_opened, files_changed, diff_lines_added, diff_lines_removed, test_runs, retry_count, active_seconds, elapsed_seconds, actual_usd, native_usage_json, metadata_json, created_at - ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, ?, + ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, ?, ?, CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS REAL), CAST(? AS REAL), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS INTEGER), CAST(? AS REAL), ?, ?, ? @@ -88,7 +87,7 @@ export function capacityUsageInsertOperation(input: CapacityUsageReportRequest, params: [identity.id, identity.idempotencyKey, reservation.task_id ?? null, reservation.work_day_id ?? null, reservation.project_id, usage.taskSignature ?? `${reservation.project_agent_class_id ?? 'assignment'}:${reservation.mode ?? 'unknown'}`, usage.executionProfileId ?? 'standard-code-model', input.assignmentId, identity.assignmentAttempt, identity.usageDimension, accountingMode, - input.modeRunId ?? null, reservation.mode ?? null, reservation.capacity_provider_id, + reservation.mode ?? null, reservation.capacity_provider_id, usage.executionProviderId ?? reservation.execution_provider_id ?? null, reservation.lane_id ?? null, reservation.lane_purpose ?? null, reservation.communication_overflow ?? 0, reservation.execution_kind ?? 'workday', reservation.trigger_kind ?? 'scheduled', reservation.invocation_id ?? null, reservation.parent_workday_id ?? null, diff --git a/src/api/capacity/services/capacity/agents/team-clone/agent-team-clone-service.ts b/src/api/capacity/services/capacity/agents/team-clone/agent-team-clone-service.ts index 4c98fc43..d4ea729f 100644 --- a/src/api/capacity/services/capacity/agents/team-clone/agent-team-clone-service.ts +++ b/src/api/capacity/services/capacity/agents/team-clone/agent-team-clone-service.ts @@ -80,15 +80,30 @@ async function resolveTargetSnapshot(store:any,projectId:string){ const exact=await connection(store,projectId,false,[commit]); return {connection:exact,snapshot:await snapshotAgents(exact,commit).catch((error)=>failed('target_read',projectId,error))}; } -async function snapshotAgents(value:KnowledgeGatewayConnection,ref=value.baseRef){ +export async function snapshotAgents(value:KnowledgeGatewayConnection,ref=value.baseRef){ const listed=await value.client.listRepositoryPaths({repoId:value.repositoryId,ref,paths:[projectLibraryPath(value.contentPath,'agents/**')],kinds:['blob'],extensions:['.md','.mdx'],limit:200,allowProtected:true}); const commit=text(listed.resolvedRef); if(!/^[0-9a-f]{40}$/u.test(commit))throw new CapacityOperationError(409,'agent_team_snapshot_invalid','TreeDX did not resolve the agent collection to an exact commit.'); + if(/^[0-9a-f]{40}$/u.test(ref)&&commit!==ref)throw new CapacityOperationError(409, + 'agent_team_snapshot_moved','Agent collection moved from the requested exact commit.'); const paths=(Array.isArray(listed.entries)?listed.entries:[]).map((item:unknown)=>text(record(item).path)).filter(Boolean).sort(); if(!paths.length)return {commit,files:[] as SourceFile[]}; const read=await value.client.readRepositoryFiles({repoId:value.repositoryId,ref:commit,paths,encoding:'utf8',parseFrontmatter:false,allowProtected:true}); if(text(read.resolvedRef)!==commit)throw new CapacityOperationError(409,'agent_team_snapshot_moved','Agent definition bytes did not match the listed commit.'); - const files=fileRows(read).map((file)=>{const content=sourceBytes(file);const parsed=parseFrontmatterDocument(content);const validation=validateAgentDefinitionModel(parsed.frontmatter);return validation.ok&&validation.data?{path:text(file.path),content,definition:validation.data,sourceDigest:hash(content)}:null;}).filter((file):file is SourceFile=>Boolean(file)); + const returned=fileRows(read); + if(returned.length!==paths.length||new Set(returned.map((file)=>text(file.path))).size!==paths.length + ||returned.some((file)=>!paths.includes(text(file.path))))throw new CapacityOperationError(409, + 'agent_team_snapshot_incomplete','TreeDX did not return every exact agent definition in the source snapshot.'); + const files=returned.map((file)=>{ + const content=sourceBytes(file),path=text(file.path),parsed=parseFrontmatterDocument(content); + const validation=validateAgentDefinitionModel(parsed.frontmatter); + if(!validation.ok||!validation.data)throw new CapacityOperationError(409,'agent_team_definition_invalid', + `Agent definition ${path||'(unknown)'} is invalid; the exact source snapshot cannot be cloned.`, + {path,diagnostics:validation.diagnostics}); + return {path,content,definition:validation.data,sourceDigest:hash(content)}; + }); + if(new Set(files.map((file)=>file.definition.agentClass)).size!==files.length)throw new CapacityOperationError(409, + 'agent_team_class_ambiguous','Multiple source agent definitions select the same class and would overwrite one target file.'); return {commit,files}; } async function readExisting(value:KnowledgeGatewayConnection,commit:string,paths:string[]){ @@ -107,7 +122,7 @@ async function activateDefinitions(store:any,target:Project,definitions:ReturnTy const validation=validateAgentDefinitionModel(parseFrontmatterDocument(item.content).frontmatter); if(!validation.ok||!validation.data)throw new CapacityOperationError(422,'agent_team_definition_invalid','The cloned agent definition cannot be activated.'); const existing=entries.find((entry)=>text(entry.slug)===item.agentClass&&entry.status==='active')??entries.find((entry)=>text(entry.slug)===item.agentClass); - const input={slug:item.agentClass,name:validation.data.name,status:'active',allowedModes:['planning','acting'],kernelProfile:record(existing?.kernelProfile),kernelPolicy:record(existing?.kernelPolicy),handlerRefs:{agents:[validation.data]},outputContracts:record(existing?.outputContracts),metadata:{...record(existing?.metadata),source:'project-library',immutableRef:commit,libraryRef,definitionPaths:[item.path],definitionDigest:item.digest.replace(/^sha256:/u,'')}}; + const input={slug:item.agentClass,name:validation.data.name,status:'active',handlerRefs:{agents:[validation.data]},metadata:{...record(existing?.metadata),source:'project-library',immutableRef:commit,libraryRef,definitionPaths:[item.path],definitionDigest:item.digest.replace(/^sha256:/u,'')}}; if(existing)await store.updateProjectAgentClass(target.id,text(existing.id),input,`${idempotencyKey}:${target.id}:${item.agentClass}:update`); else await store.createProjectAgentClass(target.id,{id:`${target.id}:${item.agentClass}`,...input},`${idempotencyKey}:${target.id}:${item.agentClass}:create`); } diff --git a/src/api/capacity/services/capacity/assignments/admission/assignment-capability-service.ts b/src/api/capacity/services/capacity/assignments/admission/assignment-capability-service.ts deleted file mode 100644 index 7853cd9b..00000000 --- a/src/api/capacity/services/capacity/assignments/admission/assignment-capability-service.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { ProviderAssignmentSynthesisSource } from '@treeseed/sdk/agent-capacity'; -import { CapacityGovernanceError } from '../../../../database.ts'; - -type RecordValue = Record; - -function record(value: unknown): RecordValue { - return value && typeof value === 'object' && !Array.isArray(value) ? value as RecordValue : {}; -} - -function array(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function text(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function secretLikeKey(key: string) { return /(^|[_-])(plaintext|token|passphrase|password|private[_-]?key|deploy[_-]?key|raw[_-]?secret|unencrypted|credential)([_-]|$)/iu.test(key) || ['secretValue', 'rawSecret', 'githubInstallationToken', 'deployKey', 'privateKey'].includes(key); } -function secretLikePath(value: unknown, path = '$'): string | null { - if (Array.isArray(value)) { for (let index = 0; index < value.length; index += 1) { const found = secretLikePath(value[index], `${path}[${index}]`); if (found) return found; } return null; } - if (!value || typeof value !== 'object') return null; - for (const [key, entry] of Object.entries(value as RecordValue)) { if (secretLikeKey(key)) return `${path}.${key}`; const found = secretLikePath(entry, `${path}.${key}`); if (found) return found; } - return null; -} -function providerAssignmentCapabilityHandlesContainSecretMaterial(value: unknown) { return Boolean(secretLikePath(value)); } -function redactedProviderAssignmentCapabilityHandles(value: unknown) { - const source = record(value); const redact = (entry: unknown) => Object.fromEntries(Object.entries(record(entry)).filter(([key]) => !secretLikeKey(key))); - return { workspaceAccessMode: workspaceAccessMode(source), repository: array(source.repository).map(redact), treeDx: array(source.treeDx).map(redact), workflowOperations: array(source.workflowOperations).map(redact), secrets: array(source.secrets).map(redact), metadata: record(source.metadata) }; -} -function validateProviderAssignmentCapabilityHandles(input: { assignment: AssignmentCapabilityInput & { capabilityHandles?: RecordValue }; capabilityHandles?: RecordValue; now?: Date }) { - const handles = input.capabilityHandles ?? input.assignment.capabilityHandles; const leakedPath = secretLikePath(handles); if (leakedPath) return { code: 'assignment_capability_handle_secret_material', reason: `Assignment capability handles include secret-like material at ${leakedPath}.`, metadata: { path: leakedPath } }; - for (const handle of [...array(handles?.repository), ...array(handles?.treeDx), ...array(handles?.workflowOperations), ...array(handles?.secrets)].map(record)) { - if (!text(handle.id) || !text(handle.kind)) return { code: 'assignment_capability_handle_invalid', reason: 'An assignment capability handle is missing its identity or kind.', metadata: {} }; - if ((handle.teamId && handle.teamId !== input.assignment.teamId) || (handle.projectId && handle.projectId !== input.assignment.projectId) || (handle.assignmentId && handle.assignmentId !== input.assignment.id)) return { code: 'assignment_capability_handle_invalid', reason: `Capability handle ${text(handle.id)} is scoped to a different assignment.`, metadata: { handleId: handle.id } }; - if (handle.expiresAt && Date.parse(String(handle.expiresAt)) <= (input.now ?? new Date()).getTime()) return { code: 'assignment_capability_handle_invalid', reason: `Capability handle ${text(handle.id)} has expired.`, metadata: { handleId: handle.id } }; - const operations = array(handle.operations).map(String); const writeCapable = operations.some((operation) => ['write', 'commit', 'push', 'release', 'dispatch_workflow', 'files:write', 'git:commit'].includes(operation)); - if (writeCapable && input.assignment.mode !== 'acting' && record(input.assignment.metadata).allowPlanningContentArtifacts !== true) return { code: 'assignment_capability_handle_write_not_ready', reason: 'Write-capable handles require acting mode or explicit planning-artifact authority.', metadata: { handleId: handle.id } }; - } - return null; -} - -function exactBaseRef(input: AssignmentCapabilityInput) { - return text(record(record(input.decisionInput).input).exactBaseRef); -} - -function workspaceAccessMode(input: RecordValue) { - const explicit = text(input.workspaceAccessMode ?? input.workspace_access_mode); - if (['context_only', 'workspace_write', 'brokered_workspace', 'full_workspace_no_credentials', 'trusted_direct'].includes(explicit)) return explicit; - return input.mode === 'acting' ? 'brokered_workspace' : 'context_only'; -} - -export interface AssignmentCapabilityInput extends RecordValue { - id: string; - teamId: string; - projectId: string; - mode: 'planning' | 'acting'; - workspaceContext?: RecordValue; - capabilityHandles?: RecordValue; - treedxProxyHandle?: RecordValue; - decisionInput?: RecordValue; - capacityEnvelope?: RecordValue; - metadata?: RecordValue; - synthesizedFrom?: string | null; -} - -export function compileAssignmentCapabilityContext(input: AssignmentCapabilityInput) { - const context = record(input.workspaceContext); - const supplied = record(input.capabilityHandles ?? context.capabilityHandles); - if (providerAssignmentCapabilityHandlesContainSecretMaterial(supplied)) { - throw new CapacityGovernanceError( - 'assignment_capability_handle_secret_material', - 'Provider assignment capability handles must not contain secret material.', - 400, - ); - } - const accessMode = workspaceAccessMode({ ...input, workspaceAccessMode: supplied.workspaceAccessMode ?? context.workspaceAccessMode }); - const handles = redactedProviderAssignmentCapabilityHandles({ - workspaceAccessMode: accessMode, - repository: array(supplied.repository), - treeDx: array(supplied.treeDx), - workflowOperations: array(supplied.workflowOperations ?? input.workflowOperationHandles), - secrets: array(supplied.secrets), - metadata: record(supplied.metadata), - }); - const treeDx = record(input.treedxProxyHandle ?? context.treedxProxyHandle); - const governedBaseRef = exactBaseRef(input); - const synthesizedFrom = ['approved_decision', 'planning_input_request', 'capacity_plan', 'living_execution_graph', 'workday_demand', 'verification_failure', 'fallback_queue'] - .includes(String(input.synthesizedFrom ?? '')) - ? input.synthesizedFrom as ProviderAssignmentSynthesisSource - : null; - if (treeDx.id) { - const proxyHandleId = text(treeDx.id); - const repositoryId = text(treeDx.repositoryId); - const workspaceId = text(treeDx.workspaceId); - if (!handles.treeDx.some((handle) => handle.proxyHandleId === proxyHandleId || handle.id === `tdx-workspace-${proxyHandleId}`)) { - handles.treeDx.push({ - id: `tdx-workspace-${proxyHandleId}`, - kind: 'treedx_workspace', - teamId: input.teamId, - projectId: input.projectId, - assignmentId: input.id, - status: 'active', - workspaceAccessMode: accessMode, - proxyHandleId, - repositoryId: repositoryId || null, - workspaceId: workspaceId || null, - operations: array(treeDx.allowedOperations).map(String), - allowedOperations: array(treeDx.allowedOperations).map(String), - allowedPaths: array(treeDx.allowedPaths).map(String), - expiresAt: text(treeDx.expiresAt) || null, - metadata: { source: 'treedx_proxy_handle' }, - }); - } - if (repositoryId && !handles.repository.some((handle) => handle.repositoryId === repositoryId)) { - handles.repository.push({ - id: `repo-access-${proxyHandleId}`, - kind: 'repository_access', - teamId: input.teamId, - projectId: input.projectId, - assignmentId: input.id, - status: 'active', - workspaceAccessMode: accessMode, - provider: 'treedx_proxy', - repositoryId, - operations: accessMode === 'context_only' ? ['read'] : ['read', 'write', 'commit', 'test'], - allowedRefs: governedBaseRef ? [governedBaseRef] : [], - allowedPaths: array(treeDx.allowedPaths).map(String), - credentialMode: 'brokered', - expiresAt: text(treeDx.expiresAt) || null, - metadata: { source: 'treedx_proxy_handle' }, - }); - } - } - const fallback = validateProviderAssignmentCapabilityHandles({ - assignment: { - ...input, - metadata: input.metadata ?? {}, - decisionInput: input.decisionInput ?? {}, - capacityEnvelope: { - ...(input.capacityEnvelope ?? {}), - mode: input.mode, - teamId: input.teamId, - projectId: input.projectId, - }, - synthesizedFrom, - capabilityHandles: handles, - }, - capabilityHandles: handles, - }); - if (fallback) { - throw new CapacityGovernanceError( - fallback.code ?? 'assignment_capability_handle_invalid', - fallback.reason ?? 'Invalid provider assignment capability handles.', - 400, - fallback.metadata ?? {}, - ); - } - return { ...context, workspaceAccessMode: accessMode, capabilityHandles: handles }; -} diff --git a/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts b/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts index 7fa1fac3..adc94cde 100644 --- a/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts +++ b/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts @@ -47,6 +47,7 @@ export async function livingAllocationInputs(store: CapacityGovernanceDatabase, const readiness = await store.first(`SELECT COUNT(*) AS ready_count, COALESCE(MAX((node.estimate_json::jsonb->>'maximumSeconds')::numeric),0) AS maximum_seconds FROM execution_nodes node WHERE node.team_id=? AND ${scope.sql} AND node.status='ready' + AND ${input.activity === 'chat' ? "node.kind='communication'" : "node.kind<>'communication'"} AND ${projectIds.length ? `node.project_id IN (${projectIds.map(() => '?').join(',')})` : 'false'} AND node.required_capabilities_json::jsonb @> ?::jsonb AND ${plan.state === 'closing' ? "node.kind='reporting'" : phase === 'planning' @@ -59,7 +60,8 @@ export async function livingAllocationInputs(store: CapacityGovernanceDatabase, planningCommittedSeconds: usage.filter(row => row.planning).reduce((sum, row) => sum + row.seconds, 0), maximumAdditionalSeconds: Number(readiness?.ready_count) > 0 ? plan.state === 'closing' ? Number(readiness?.maximum_seconds ?? 0) - : Math.max(0, (Date.parse(plan.endsAt) - Date.parse(input.now)) / 1000) * plan.policySnapshot.maximumConcurrency : 0 }); + : Math.max(0, (Date.parse(plan.endsAt) - Date.parse(input.now)) / 1000) + * (input.activity === 'chat' ? plan.policySnapshot.communicationConcurrency : plan.policySnapshot.maximumConcurrency) : 0 }); } const shares = allocateWorkdayCapacity({ remainingSeconds: supply, now: input.now, workdays }); const rows = await store.all(`SELECT usage.id,usage.created_at,usage.active_seconds,assignment.status,assignment.lifecycle_code, diff --git a/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts b/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts index 9a41e1f0..ce40a5d0 100644 --- a/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts +++ b/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts @@ -32,6 +32,7 @@ export async function admitLivingExecutionAssignment(store: Store, input: { laneId: string; lanePurpose: 'workday' | 'communication'; executionKind: 'workday' | 'conversation'; + workdayConcurrencyLimit: number; invocationId?: string | null; predecessorResults: unknown[]; treedxProxyHandle: JsonRecord; @@ -49,6 +50,9 @@ export async function admitLivingExecutionAssignment(store: Store, input: { if (!input.allocation.admitted || input.allocation.allocatedSeconds !== assignment.limits.maximumSeconds) { throw new CapacityGovernanceError('assignment_allocation_mismatch', 'Assignment limits must match the allocator-issued duration.', 409); } + if (!Number.isInteger(input.workdayConcurrencyLimit) || input.workdayConcurrencyLimit < 1) { + throw new CapacityGovernanceError('workday_concurrency_limit_invalid', 'A positive workday concurrency limit is required.', 409); + } const mode = assignmentAccountingMode(assignment); const decisionId = assignment.authorityRefs.find((reference) => reference.model === 'decision')?.id ?? null; const proposalId = assignment.sourceRef.model === 'proposal' ? assignment.sourceRef.id : null; @@ -60,16 +64,14 @@ export async function admitLivingExecutionAssignment(store: Store, input: { requestedSeconds: assignment.limits.maximumSeconds, reservedSeconds: assignment.limits.maximumSeconds, limits: assignment.limits, budget: timing.capacityBudget, }; - const decisionInput = { - teamId: assignment.teamId, projectId: assignment.projectId, projectAgentClassId: input.projectAgentClassId, - mode, activityType: assignment.effectiveProfile.activity, workDayId: assignment.workdayId, - agentId: assignment.effectiveProfile.profileRef.id, handlerId: assignment.effectiveProfile.handler, - capacity: capacityEnvelope, input: {}, metadata: { source: 'living_execution_graph', nodeId: assignment.nodeId }, - }; const common = [assignment.teamId,assignment.nodeId,assignment.nodeRevision]; const claims = capabilityCounterClaims(assignment, input.accountingLimits, input.now); const admissionToken = randomUUID(); await store.batch([ + // Serializes admissions for this workday before checking its per-kind + // concurrency. The count in the reservation INSERT is therefore atomic. + { query: `SELECT id FROM capacity_workday_runs WHERE team_id=? AND id=? AND status='running' FOR UPDATE`, + params: [assignment.teamId, assignment.workdayId] }, ...initializeCapabilityCounters(assignment, claims, input.now), { query: `SELECT node.id FROM execution_nodes node WHERE node.team_id=? AND node.id=? AND node.node_revision=? AND node.status='ready' @@ -104,19 +106,26 @@ export async function admitLivingExecutionAssignment(store: Store, input: { ) ) AND ${claims.map(() => `EXISTS (SELECT 1 FROM capacity_admission_counters WHERE id=? AND committed_amount+?<=LEAST(hard_limit,?))`).join(' AND ')} + AND EXISTS (SELECT 1 FROM capacity_workday_runs run + WHERE run.team_id=? AND run.id=? AND run.status='running') + AND (SELECT COUNT(*) FROM capacity_provider_assignments active + WHERE active.team_id=? AND active.work_day_id=? AND active.execution_kind=? + AND active.status IN ('pending','leased','running')) < ? ON CONFLICT (id) DO NOTHING`, params: [assignment.reservationId,assignment.idempotencyKey,principal.membershipId, principal.capacityProviderId,input.executionProviderId,input.laneId,input.lanePurpose,input.projectAgentClassId,assignment.id,mode, assignment.teamId,assignment.projectId,assignment.workdayId,assignment.limits.maximumSeconds, assignment.limits.maximumSeconds,assignment.deadline,JSON.stringify({ nodeId: assignment.nodeId, nodeRevision: assignment.nodeRevision, graphRevision: assignment.graphRevision }),input.now,input.now,admissionToken,...common, - ...claims.flatMap(claim => [claim.id, assignment.limits.maximumSeconds, claim.hardLimit])] }, + ...claims.flatMap(claim => [claim.id, assignment.limits.maximumSeconds, claim.hardLimit]), + assignment.teamId, assignment.workdayId, + assignment.teamId, assignment.workdayId, input.executionKind, input.workdayConcurrencyLimit] }, ...commitCapabilityCounters(assignment, claims, admissionToken, input.now), { query: `INSERT INTO capacity_provider_assignments (id,membership_id,team_id,project_id,capacity_provider_id,provider_session_id,execution_provider_id,lane_id,lane_purpose, project_agent_class_id,reservation_id,work_day_id,mode,execution_kind,invocation_id,status,lease_state,state_version,agent_id,handler_id, - capacity_envelope_json,decision_input_json,workspace_context_json,allowed_outputs_json,explanation_json, + capacity_envelope_json,workspace_context_json,allowed_outputs_json,explanation_json, attempt_count,assigned_at,lifecycle_output_json,synthesized_from,synthesis_key,decision_id,proposal_id,metadata_json,created_at,updated_at) - SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending','unleased',1,?,?,?::jsonb,?::jsonb,?::jsonb,'{}','{}',0,?,'{}', + SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending','unleased',1,?,?,?::jsonb,?::jsonb,'{}','{}',0,?,'{}', 'living_execution_graph',?,?,?,?::jsonb,?,? WHERE EXISTS (SELECT 1 FROM capacity_reservations WHERE id=? AND team_id=? AND assignment_id=?) AND NOT EXISTS ( @@ -131,7 +140,7 @@ export async function admitLivingExecutionAssignment(store: Store, input: { principal.capacityProviderId,input.providerSessionId,input.executionProviderId,input.laneId,input.lanePurpose,input.projectAgentClassId, assignment.reservationId,assignment.workdayId,mode,input.executionKind,input.invocationId ?? null, assignment.effectiveProfile.profileRef.id,assignment.effectiveProfile.handler, - JSON.stringify(capacityEnvelope),JSON.stringify(decisionInput), + JSON.stringify(capacityEnvelope), JSON.stringify({ assignmentAttempt: assignment, predecessorResults: input.predecessorResults }),input.now, assignment.idempotencyKey,decisionId,proposalId,JSON.stringify({ requiredCapabilities: assignment.requiredCapabilities }),input.now,input.now, assignment.reservationId,assignment.teamId,assignment.id, @@ -167,7 +176,18 @@ export async function admitLivingExecutionAssignment(store: Store, input: { assignment.nodeId,assignment.nodeRevision] }, ]); const committed = await store.getProviderAssignment(assignment.teamId, assignment.id); - if (!committed) throw new CapacityGovernanceError('execution_node_claim_lost', - 'The execution node changed before assignment admission.', 409, { nodeId: assignment.nodeId, nodeRevision: assignment.nodeRevision }); + if (!committed) { + const active = await store.first(`SELECT COUNT(*) AS active_count FROM capacity_provider_assignments + WHERE team_id=? AND work_day_id=? AND execution_kind=? AND status IN ('pending','leased','running')`, + [assignment.teamId, assignment.workdayId, input.executionKind]); + if (Number(active?.active_count ?? 0) >= input.workdayConcurrencyLimit) { + throw new CapacityGovernanceError('capacity_assignment_allocation_deferred', + 'The workday execution lane has reached its independent concurrency limit.', 409, + { reason: 'workday_concurrency_exhausted', executionKind: input.executionKind, + limit: input.workdayConcurrencyLimit }); + } + throw new CapacityGovernanceError('execution_node_claim_lost', + 'The execution node changed before assignment admission.', 409, { nodeId: assignment.nodeId, nodeRevision: assignment.nodeRevision }); + } return committed; } diff --git a/src/api/capacity/services/capacity/assignments/context/assignment-deliverable-service.ts b/src/api/capacity/services/capacity/assignments/context/assignment-deliverable-service.ts deleted file mode 100644 index 0eff2472..00000000 --- a/src/api/capacity/services/capacity/assignments/context/assignment-deliverable-service.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { AgentArtifactManifest,DecisionAssignmentGraphRecord,DeliverableContractRecord,DeliverableManifestRecord } from '@treeseed/sdk/agent-capacity'; -import { validateAgentArtifactManifest } from '../../../../artifact-manifest.ts'; -import { CapacityGovernanceError } from '../../../../database.ts'; -import type { DurableProviderAssignment } from '../../../../repositories/capacity/assignments/assignment.ts'; - -type JsonRecord = Record; - -export interface AssignmentDeliverableStore { - getDecisionAssignmentGraph(graphId: string): Promise; - getDeliverableContract(contractId: string): Promise; - submitDeliverableManifest(contractId: string, input: JsonRecord): Promise; - markDeliverableContractApproved(contractId: string, input: JsonRecord): Promise; - markDeliverableContractRejected(contractId: string, input: JsonRecord): Promise; -} - -function record(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; } -function records(value: unknown): JsonRecord[] { return Array.isArray(value) ? value.map(record).filter((entry) => Object.keys(entry).length > 0) : []; } -function text(...values: unknown[]): string { for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim(); return ''; } - -function executionProvenance(assignment: DurableProviderAssignment) { - const decisionInput = record(assignment.decisionInput); - const payload = record(decisionInput.input); - return { - graphId: text(payload.workGraphId, record(decisionInput.metadata).graphId), - nodeId: text(decisionInput.workGraphNodeId, payload.workGraphNodeId), - }; -} - -function assignmentBaseRef(assignment: DurableProviderAssignment) { - const input = record(record(assignment.decisionInput).input); - return text(input.exactBaseRef, input.immutableContentRef, input.subjectRef); -} - -function verificationOutputRef(assignment: DurableProviderAssignment, manifest: AgentArtifactManifest, requiredTypes: string[]) { - if (!requiredTypes.includes('release_readiness') || !manifest.verification.some((entry) => entry.status === 'passed')) return null; - const input = record(record(assignment.decisionInput).input); - const path = text(input.subjectPath, input.targetPath, record(input.approvedChange).targetPath); - if (!path) return null; - return { - model: text(input.subjectModel, record(input.approvedChange).model, 'knowledge') as never, - collection: path.split('/').at(-2) ?? 'knowledge', - slug: path.split('/').at(-1)?.replace(/\.(?:md|mdx)$/iu, '') ?? path, - path, - id: text(input.subjectId) || undefined, - }; -} - -export function assignmentArtifactManifest(input: JsonRecord): AgentArtifactManifest | null { - const output = record(input.output); - const candidate = record(output.artifactManifest ?? record(output.metadata).artifactManifest ?? input.artifactManifest); - return candidate.schemaVersion === 1 ? candidate as unknown as AgentArtifactManifest : null; -} - -function reviewDisposition(manifest: AgentArtifactManifest) { - const signals = records(manifest.signals); - if (signals.some((signal) => ['review_rejected', 'revision_required'].includes(text(signal.type, signal.code)))) return 'rejected' as const; - if (signals.some((signal) => ['review_approved', 'approved'].includes(text(signal.type, signal.code)))) return 'approved' as const; - return null; -} - -export async function projectCompletedAssignmentDeliverable( - store: AssignmentDeliverableStore, - assignment: DurableProviderAssignment, - input: JsonRecord, -) { - if (assignment.mode !== 'acting') return null; - // Living execution-graph assignments settle their typed outputs through the - // execution node referenced by the durable assignment. The decision-assignment - // graph below is the retired promotion path and must not become a second source - // of topology or provenance for the same assignment. - if (assignment.executionNodeId) return null; - const { graphId, nodeId } = executionProvenance(assignment); - if (!graphId || !nodeId) throw new CapacityGovernanceError('assignment_deliverable_provenance_missing', 'Acting assignment completion requires graph and node provenance.', 409, { assignmentId: assignment.id }); - const graph = await store.getDecisionAssignmentGraph(graphId); - const node = graph?.nodes.find((candidate) => candidate.id === nodeId); - const contractId = text(node?.metadata?.producesDeliverableContractId); - if (!graph?.active || !node || !contractId) throw new CapacityGovernanceError('assignment_deliverable_graph_not_ready', 'Acting assignment completion does not reference an active deliverable-producing graph node.', 409, { assignmentId: assignment.id, graphId, nodeId }); - let contract = await store.getDeliverableContract(contractId); - if (!contract) throw new CapacityGovernanceError('assignment_deliverable_contract_missing', 'Acting assignment deliverable contract is missing.', 500, { assignmentId: assignment.id, contractId }); - if (contract.status === 'approved') return contract; - const manifest = assignmentArtifactManifest(input); - if (!manifest) throw new CapacityGovernanceError('assignment_artifact_manifest_required', 'Acting assignment completion requires the canonical artifact manifest.', 409, { assignmentId: assignment.id, contractId }); - const validation = validateAgentArtifactManifest(manifest); - if (!validation.ok) throw new CapacityGovernanceError('assignment_artifact_manifest_invalid', validation.reason ?? 'Acting assignment artifact manifest is invalid.', 409, { assignmentId: assignment.id, contractId }); - if (manifest.assignmentId !== assignment.id || manifest.projectId !== assignment.projectId || manifest.teamId !== assignment.teamId - || manifest.mode !== 'acting' || manifest.agentClassId !== assignment.projectAgentClassId - || (assignment.agentId && manifest.agentId !== assignment.agentId)) { - throw new CapacityGovernanceError('assignment_artifact_manifest_scope_invalid', 'Artifact manifest scope does not match the completing assignment.', 409, { assignmentId: assignment.id, contractId }); - } - const requiredTypes = node.outputRequirements.filter((requirement) => requirement.required !== false).map((requirement) => requirement.outputType); - const producedRefs = manifest.contentReferences.filter((reference) => requiredTypes.includes(reference.artifactKind ?? '')) - .map((reference) => ({ - model: reference.model as never, - collection: reference.contentPath.split('/')[0] ?? reference.model, - slug: reference.contentPath.split('/').at(-1)?.replace(/\.(?:md|mdx)$/iu, '') ?? reference.contentPath, - path: reference.contentPath, - subjectId: reference.subjectId ?? undefined, - subjectField: reference.subjectField ?? undefined, - })); - const verificationRef = verificationOutputRef(assignment, manifest, requiredTypes); - if (verificationRef) producedRefs.push(verificationRef); - if (!producedRefs.length) { - const producedTypes = [...new Set(manifest.contentReferences.map((reference) => reference.artifactKind).filter((kind): kind is string => Boolean(kind)))]; - throw new CapacityGovernanceError( - 'assignment_deliverable_output_missing', - `Artifact manifest produced ${producedTypes.join(', ') || 'no typed content'} but the graph node requires ${requiredTypes.join(', ') || 'a typed output'}.`, - 409, - { assignmentId: assignment.id, contractId, requiredTypes, producedTypes }, - ); - } - const stage = text(node.metadata?.stage); - if (['test', 'implementation'].includes(stage) && !manifest.commit?.sha) throw new CapacityGovernanceError('assignment_source_commit_required', `${stage} completion requires an assignment checkpoint commit.`, 409, { assignmentId: assignment.id, contractId }); - if (stage === 'verification' && !manifest.verification.some((entry) => entry.status === 'passed')) throw new CapacityGovernanceError('assignment_verification_evidence_required', 'Verification completion requires passing verification evidence.', 409, { assignmentId: assignment.id, contractId }); - const disposition = stage === 'review' ? reviewDisposition(manifest) : 'approved'; - if (stage === 'review' && !disposition) throw new CapacityGovernanceError('assignment_review_disposition_required', 'Review completion requires an explicit approved or rejected signal.', 409, { assignmentId: assignment.id, contractId }); - if (contract.status !== 'submitted') { - const baseRef = assignmentBaseRef(assignment); - if (!baseRef) throw new CapacityGovernanceError('assignment_source_base_ref_required', 'Acting assignment completion requires immutable source authority.', 409, { assignmentId: assignment.id, contractId }); - const checkpointCommit = text(manifest.commit?.sha) || null; - const deliverableManifestId = `deliverable:${assignment.id}`; - await store.submitDeliverableManifest(contractId, { - id: deliverableManifestId, producedRefs, summary: manifest.summary, readyForReview: true, - submittedByAgentId: assignment.agentId, metadata: { assignmentId: assignment.id, modeRunId: manifest.modeRunId, graphId, nodeId, artifactManifestId: `${manifest.assignmentId}:${manifest.modeRunId}` }, - sourceAuthority: { assignmentId: assignment.id, modeRunId: manifest.modeRunId, baseRef, effectiveRef: checkpointCommit ?? baseRef, checkpointCommit }, - }); - contract = await store.getDeliverableContract(contractId); - } - if (contract?.status === 'approved') return contract; - try { - return disposition === 'rejected' - ? await store.markDeliverableContractRejected(contractId, { reason: 'Reviewer requested revision.', metadata: { assignmentId: assignment.id, modeRunId: manifest.modeRunId, deliverableManifestId: `deliverable:${assignment.id}` } }) - : await store.markDeliverableContractApproved(contractId, { approvedBy: assignment.agentId, metadata: { assignmentId: assignment.id, modeRunId: manifest.modeRunId, deliverableManifestId: `deliverable:${assignment.id}`, source: 'validated_assignment_completion' } }); - } catch (error) { - const observed = await store.getDeliverableContract(contractId); - if (observed?.status === disposition) return observed; - throw error; - } -} diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-completion-preflight-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-completion-preflight-service.ts deleted file mode 100644 index 6458c31a..00000000 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-completion-preflight-service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { validateAgentArtifactManifest } from '../../../../artifact-manifest.ts'; -import { createHash } from 'node:crypto'; -import { CapacityGovernanceError,type CapacityGovernanceDatabase } from '../../../../database.ts'; -import { resolveWorkdayTreeDxConnection,type WorkdayTreeDxConnectionStore } from '../../workdays/treedx/workday-treedx-connection.ts'; -import type { ProviderLeasePrincipal } from '../../../accounts/lease-authority-service.ts'; -import { semanticArtifactChecks,type SemanticArtifactExpectation } from './semantic/artifact-checks.ts'; - -type Row=Record; -type Store=CapacityGovernanceDatabase&WorkdayTreeDxConnectionStore&{ - getProviderAssignment(teamId:string,assignmentId:string):Promise; - createCapacityWorkdayEvent(teamId:string,runId:string,input:Row):Promise; -}; -const record=(value:unknown):Row=>value&&typeof value==='object'&&!Array.isArray(value)?value as Row:{}; -const text=(...values:unknown[])=>{for(const value of values)if(typeof value==='string'&&value.trim())return value.trim();return '';}; -const records=(value:unknown)=>Array.isArray(value)?value.map(record):[]; -const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex'); - -export function semanticCompletionPreflightRequired(assignment:Row,input:Row){const output=record(input.output);const manifest=record(output.artifactManifest??record(output.metadata).artifactManifest);const expectations=record(record(assignment.decisionInput).input).semanticArtifactExpectations;return manifest.schemaVersion===1||(Array.isArray(expectations)&&expectations.length>0);} - -function assertLease(assignment:Row|null,principal:ProviderLeasePrincipal,leaseToken:unknown) { - if(!assignment||assignment.capacityProviderId!==principal.capacityProviderId||assignment.membershipId!==principal.membershipId - ||assignment.status!=='leased'||assignment.leaseState!=='leased'||text(assignment.leaseToken)!==text(leaseToken)) { - throw new CapacityGovernanceError('assignment_completion_preflight_lease_invalid','Semantic completion preflight requires the exact active assignment lease.',409); - } - return assignment; -} - -export async function preflightProviderAssignmentCompletion(store:Store,principal:ProviderLeasePrincipal,assignmentId:string,input:Row) { - await store.ensureInitialized();const assignment=assertLease(await store.getProviderAssignment(principal.teamId,assignmentId),principal,input.leaseToken); - if(text(input.idempotencyKey)!==`assignment:${assignmentId}:semantic-completion-preflight`)throw new CapacityGovernanceError('assignment_completion_preflight_idempotency_invalid','Semantic completion preflight requires the canonical assignment idempotency key.',400,{assignmentId}); - const manifest=record(input.artifactManifest);const validation=validateAgentArtifactManifest(manifest as never); - if(!validation.ok)throw new CapacityGovernanceError('assignment_completion_manifest_invalid',validation.reason??'Completion artifact manifest is invalid.',409,{assignmentId}); - if(text(manifest.assignmentId)!==assignmentId||text(manifest.projectId)!==text(assignment.projectId))throw new CapacityGovernanceError('assignment_completion_manifest_scope_invalid','Completion manifest does not belong to the exact assignment and project.',409,{assignmentId}); - const payload=record(record(assignment.decisionInput).input);const expectations=records(payload.semanticArtifactExpectations) as SemanticArtifactExpectation[]; - const references=records(manifest.contentReferences);const runId=text(record(assignment.metadata).workdayRunId,assignment.workDayId); - const results:Row[]=[]; - if(expectations.length){ - const repositoryId=text(payload.repositoryId);const connection=await resolveWorkdayTreeDxConnection(store,{projectId:text(assignment.projectId),repositoryId,runId,capabilities:['repos:read','files:read']}); - if(!connection)throw new CapacityGovernanceError('assignment_completion_treedx_unavailable','Semantic preflight cannot read the exact project repository.',503,{assignmentId}); - const client=connection.client; - for(const expectation of expectations){ - const candidates=references.filter((reference)=>text(reference.model)===expectation.model||text(reference.contentPath).startsWith(expectation.pathPrefix)); - const attempts=[]; - for(const reference of candidates){ - const ref=text(reference.ref,reference.commitSha,record(manifest.commit).sha);const path=text(reference.contentPath); - const response=await client.readRepositoryFiles({ref,paths:[path],encoding:'utf8',parseFrontmatter:true});const file=record(response.files?.[0]??response.results?.[0]??response.file); - const artifact={...reference,...file,content:text(file.content,file.body)};attempts.push({path,ref,checks:semanticArtifactChecks(artifact,expectation)}); - } - const passed=attempts.some((attempt)=>Object.values(attempt.checks).every(Boolean));results.push({id:expectation.id,passed,attempts}); - if(!passed)throw new CapacityGovernanceError('assignment_semantic_artifact_preflight_failed',`Artifact ${expectation.id} failed semantic preflight before settlement.`,409,{assignmentId,expectationId:expectation.id,attempts}); - } - } - const receiptDigest=digest({assignmentId,manifestDigest:digest(manifest),expectations,results}); - await store.createCapacityWorkdayEvent(principal.teamId,runId,{id:`semantic-preflight:${assignmentId}:${receiptDigest.slice(0,20)}`,eventType:'assignment.semantic_completion_preflight_passed',status:'completed',title:'Semantic artifact preflight passed',message:'Exact repository artifacts passed completion preflight before settlement.',assignmentId,projectId:assignment.projectId,context:{receiptDigest,expectations:expectations.map((entry)=>entry.id),results},metadata:{source:'api-control-plane'}}); - return {assignmentId,receiptDigest,required:expectations.length>0,assertions:results}; -} diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-discussion-suspension-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-discussion-suspension-service.ts index ada7af08..06683e56 100644 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-discussion-suspension-service.ts +++ b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-discussion-suspension-service.ts @@ -31,8 +31,6 @@ export async function suspendAssignmentForDiscussionResponse(store:Store,input:{ await store.batch([ ...(!alreadySuspended?[{query:`UPDATE capacity_provider_assignments SET status='returned',lease_state='released',lease_token=NULL,lease_expires_at=NULL,lease_renewed_at=NULL,returned_at=COALESCE(returned_at,?),lifecycle_code='discussion_response_required',lifecycle_reason=?,metadata_json=?,treedx_proxy_handle_json=?,workspace_context_json=?,state_version=state_version+1,updated_at=? WHERE id=? AND team_id=? AND status='leased' AND lease_state='leased' AND lease_token=?`,params:[now,input.message,suspendedMetadata,JSON.stringify(authority.proxyHandle),JSON.stringify(authority.workspaceContext),now,input.assignmentId,input.teamId,input.leaseToken]}]:[]), {query:`UPDATE treedx_proxy_handles SET status='revoked',revoked_at=COALESCE(revoked_at,?),updated_at=? WHERE assignment_id=? AND team_id=? AND ${suspendedAssignmentExists}`,params:[now,now,input.assignmentId,input.teamId,input.assignmentId,input.teamId,input.messageId]}, - {query:`UPDATE capacity_workday_demands SET status='completed',completed_at=COALESCE(completed_at,?),metadata_json=metadata_json,updated_at=? WHERE assignment_id=? AND status IN ('admitted','blocked') AND ${suspendedAssignmentExists}`,params:[now,now,input.assignmentId,input.assignmentId,input.teamId,input.messageId]}, - {query:`UPDATE capacity_workday_participation_entries SET status='completed',covered_at=COALESCE(covered_at,?),updated_at=? WHERE assignment_id=? AND status='assigned' AND ${suspendedAssignmentExists}`,params:[now,now,input.assignmentId,input.assignmentId,input.teamId,input.messageId]}, ...(current.invocationId?[{query:`UPDATE agent_invocation_requests SET status='suspended',assignment_id=?,final_message_ref=?,completed_at=COALESCE(completed_at,?),updated_at=? WHERE id=? AND team_id=? AND status IN ('admitted','running','suspended') AND ${suspendedAssignmentExists}`,params:[input.assignmentId,input.messagePath,now,now,current.invocationId,input.teamId,input.assignmentId,input.teamId,input.messageId]}]:[]), ]); const suspended=await store.getProviderAssignment(input.teamId,input.assignmentId); @@ -44,6 +42,7 @@ export async function suspendAssignmentForDiscussionResponse(store:Store,input:{ export async function closeSuspendedConversationExecution(store:Store,assignment:DurableProviderAssignment,now=new Date().toISOString()) { if(!assignment.invocationId) throw new CapacityGovernanceError('communication_invocation_provenance_missing','Suspended conversation assignment lacks its exact invocation.',409,{assignmentId:assignment.id}); + if(!assignment.workDayId) throw new CapacityGovernanceError('communication_workday_provenance_missing','Suspended conversation assignment lacks its exact workday.',409,{assignmentId:assignment.id}); const ready=await store.first(`SELECT invocation.final_message_ref FROM agent_invocation_requests invocation JOIN capacity_reservations reservation ON reservation.assignment_id=? AND reservation.team_id=? JOIN capacity_ledger_entries settlement ON settlement.reservation_id=reservation.id AND settlement.phase='task_completed_actual_settlement' @@ -53,10 +52,8 @@ export async function closeSuspendedConversationExecution(store:Store,assignment const summary=JSON.stringify({invocationId:assignment.invocationId,assignmentId:assignment.id,outcome:'required_response_suspended',finalMessageRef:ready.final_message_ref}); await store.batch([ {query:`UPDATE capacity_workday_runs SET status='degraded',summary_json=?,completed_at=COALESCE(completed_at,?),updated_at=? - WHERE team_id=? AND execution_kind='conversation' AND status='running' AND id=(SELECT workday_run_id FROM capacity_workday_demands WHERE team_id=? AND assignment_id=? ORDER BY updated_at DESC LIMIT 1)`,params:[summary,now,now,assignment.teamId,assignment.teamId,assignment.id]}, - {query:`UPDATE workday_capacity_envelopes SET status='degraded',completed_at=COALESCE(completed_at,?),updated_at=? - WHERE team_id=? AND status IN ('queued','active','paused') AND workday_run_id=(SELECT workday_run_id FROM capacity_workday_demands WHERE team_id=? AND assignment_id=? ORDER BY updated_at DESC LIMIT 1)`,params:[now,now,assignment.teamId,assignment.teamId,assignment.id]}, + WHERE team_id=? AND execution_kind='conversation' AND status='running' AND id=?`,params:[summary,now,now,assignment.teamId,assignment.workDayId]}, ]); return store.first(`SELECT status,completed_at FROM capacity_workday_runs WHERE team_id=? AND execution_kind='conversation' - AND id=(SELECT workday_run_id FROM capacity_workday_demands WHERE team_id=? AND assignment_id=? ORDER BY updated_at DESC LIMIT 1)`,[assignment.teamId,assignment.teamId,assignment.id]); + AND id=?`,[assignment.teamId,assignment.workDayId]); } diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lease-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lease-service.ts index 1b46482f..c548b43c 100644 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lease-service.ts +++ b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lease-service.ts @@ -194,8 +194,8 @@ async function eligibleLeaseCandidates(input:{store:ProviderAssignmentLeaseStore const {store,principal,request,assignments,workdayStatuses,now}=input; const workdayWeight = (assignment: DurableProviderAssignment): number => { const workdayId = assignmentWorkdayId(assignment); if (!workdayId) return 1; - const status = workdayStatuses.get(workdayId); if (status === 'active') return 0; - if (status === 'draft' || status === 'paused') return 2; return status ? 3 : 1; + const status = workdayStatuses.get(workdayId); if (status === 'running') return 0; + if (status === 'queued') return 2; return status ? 3 : 1; }; const retryWeight = (assignment: DurableProviderAssignment): number => { if (assignment.status === 'pending' && assignment.leaseState === 'unleased') return 0; @@ -272,9 +272,9 @@ export async function leaseNextProviderAssignment( WHERE team_id = ? AND capacity_provider_id = ? AND status IN ('pending', 'returned') AND execution_provider_id IN (${executionProviderIds.map(() => '?').join(', ') || 'NULL'}) - AND NOT EXISTS (SELECT 1 FROM workday_capacity_envelopes workday + AND NOT EXISTS (SELECT 1 FROM capacity_workday_runs workday WHERE workday.id=capacity_provider_assignments.work_day_id - AND workday.status IN ('completed','cancelled')) + AND workday.status IN ('completed','cancelled','failed','degraded')) AND NOT (status='returned' AND execution_kind='conversation' AND lifecycle_code='discussion_response_required') ORDER BY CASE WHEN status = 'pending' THEN 0 ELSE 1 END, CASE WHEN status = 'pending' THEN created_at END ASC, @@ -288,7 +288,7 @@ export async function leaseNextProviderAssignment( const workdayStatuses = new Map(); if (workdayIds.length) { const workdayRows = await store.all( - `SELECT id, status FROM workday_capacity_envelopes WHERE id IN (${workdayIds.map(() => '?').join(', ')})`, + `SELECT id, status FROM capacity_workday_runs WHERE id IN (${workdayIds.map(() => '?').join(', ')})`, workdayIds, ); for (const row of workdayRows) { diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts index 36407770..2102334e 100644 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts +++ b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts @@ -8,14 +8,11 @@ import { CapacityRuntimeEvidenceRepository } from '../../../../repositories/runt import { capacityTransaction } from '../../../../transaction.ts'; import type { AgentFallbackOutputWrite } from '../../../../repositories/runtime/runtime-evidence.ts'; import { evaluateProviderAssignmentLeaseAuthority,type ProviderLeasePrincipal } from '../../../accounts/lease-authority-service.ts'; -import { projectCompletedResearchWorkflow,type ResearchWorkflowProjectionStore } from '../../../projects/projects-core/research-workflow-projection-service.ts'; import { settleCapacityReservationExactlyOnce } from '../../accounting/settlement-service.ts'; -import { projectCompletedAssignmentDeliverable,type AssignmentDeliverableStore } from '../context/assignment-deliverable-service.ts'; import { validateAssignmentResultCompletion } from '../context/assignment-result-completion.ts'; import { resolveProposalReviewDisposition, resolveReviewDisposition } from '../context/review-result.ts'; import { livingExecutionLifecycleOperations } from './execution/living-execution-lifecycle.ts'; import type { ProviderAssignmentExplanationWrite } from '../observability/assignment-explanation-service.ts'; -import { projectCompletedPlanningOutputs,type AssignmentPlanningOutputStore } from '../planning/assignment-planning-output-service.ts'; import { integrateAssignmentEstimate } from '../planning/estimates/integration.ts'; import { normalizeProviderAssignmentLeaseSeconds } from './assignment-lease-service.ts'; import { terminalAssignmentAuthority } from './assignment-terminal-authority.ts'; @@ -23,13 +20,11 @@ import { composeAssignmentLifecycleOutput } from './assignment-lifecycle-output. import { closeSuspendedConversationExecution } from './assignment-discussion-suspension-service.ts'; import { terminalizeOperationHandoff } from '../handoffs/operation-handoff-lifecycle-service.ts'; import { archivedConversationCancellation } from './assignment-failure-policy.ts'; -import { contentIntegrationRequirementOperation } from './assignment-content-integration-requirement.ts'; -import { semanticCompletionPreflightRequired } from './assignment-completion-preflight-service.ts'; import { assertAssignmentCompletionEvidence } from './completion/assignment-completion-evidence.ts'; import { quarantineContextOverflowOffer } from './context-capacity/overflow.ts'; import { optionalFiniteNumber,record,terminalPerformance,type ExtendedProviderAssignmentLifecycleRequest,type JsonRecord } from './completion/assignment-terminal-performance.ts'; export type { ExtendedProviderAssignmentLifecycleRequest } from './completion/assignment-terminal-performance.ts'; -interface ProviderAssignmentLifecycleStore extends CapacityGovernanceDatabase, AssignmentDeliverableStore, AssignmentPlanningOutputStore, ResearchWorkflowProjectionStore { +interface ProviderAssignmentLifecycleStore extends CapacityGovernanceDatabase { getProviderAssignment(teamId: string, assignmentId: string): Promise; recordAgentFallbackOutput(input: AgentFallbackOutputWrite): Promise; recordProviderAssignmentExplanation(teamId:string,assignmentId:string,input:ProviderAssignmentExplanationWrite):Promise; @@ -275,10 +270,9 @@ export class ProviderAssignmentLifecycleService { const now = new Date().toISOString(); const assignment = await this.store.getProviderAssignment(principal.teamId, assignmentId); if (!activeLeaseOwnedBy(assignment, principal, input.leaseToken, now)) return null; + if (!assignment.assignmentAttempt) throw new CapacityGovernanceError('assignment_graph_authority_required', + 'Only a living-graph assignment with an immutable attempt can complete.', 409, { assignmentId }); const terminalInput = Object.keys(record(input.completion)).length ? input : { ...input, completion: { disposition: 'completed' } }; - const semanticReceipt=String(record(terminalInput.metadata).semanticCompletionPreflightReceiptDigest??''); - const semanticPreflight=assignment.assignmentAttempt ? true : semanticCompletionPreflightRequired(assignment,terminalInput)&&/^[a-f0-9]{64}$/u.test(semanticReceipt)?await this.store.first(`SELECT id FROM capacity_workday_events WHERE id = ? AND assignment_id = ? AND team_id = ? AND event_type = 'assignment.semantic_completion_preflight_passed' LIMIT 1`,[`semantic-preflight:${assignment.id}:${semanticReceipt.slice(0,20)}`,assignment.id,assignment.teamId]):!semanticCompletionPreflightRequired(assignment,terminalInput); - if(!semanticPreflight)throw new CapacityGovernanceError('assignment_semantic_completion_preflight_required','Artifact-producing assignment completion requires a durable semantic preflight before settlement.',409,{assignmentId:assignment.id}); if (assignment.reservationId) { const reservation = await this.store.first( `SELECT state FROM capacity_reservations WHERE id = ? AND team_id = ? AND membership_id = ? AND assignment_id = ? LIMIT 1`, @@ -288,9 +282,7 @@ export class ProviderAssignmentLifecycleService { } await assertRequiredSignals(this.store, assignment); await assertCommunicationOutcome(this.store, assignment); - const assignmentResult = assignment.assignmentAttempt - ? validateAssignmentResultCompletion(assignment, terminalInput as JsonRecord) - : null; + const assignmentResult = validateAssignmentResultCompletion(assignment, terminalInput as JsonRecord); if (assignmentResult) await integrateAssignmentEstimate(this.store, assignment, assignmentResult); const reviewDisposition = assignmentResult ? await resolveReviewDisposition(this.store, assignment, assignmentResult) @@ -298,11 +290,6 @@ export class ProviderAssignmentLifecycleService { const proposalReview = assignmentResult ? await resolveProposalReviewDisposition(this.store, assignment, assignmentResult) : null; - if (!assignment.assignmentAttempt) { - await projectCompletedPlanningOutputs(this.store, assignment, input as JsonRecord); - await projectCompletedResearchWorkflow(this.store, assignment, input as JsonRecord); - await projectCompletedAssignmentDeliverable(this.store, assignment, input as JsonRecord); - } const completed = await this.transition(principal, assignment, terminalInput, now, { status: 'completed', timestampColumn: 'completed_at', @@ -384,7 +371,6 @@ export class ProviderAssignmentLifecycleService { elapsedSeconds: Math.max(0, Number(input.elapsedSeconds ?? usage.elapsedSeconds ?? 0)), providerUnits: optionalFiniteNumber(input.providerUnits ?? usage.providerUnits, 'providerUnits'), usd: optionalFiniteNumber(input.actualUsd ?? usage.actualUsd, 'actualUsd'), - modeRunId: input.modeRunId ?? null, usageActual: usage, source: 'provider_assignment_fail', existingSettlementPolicy: 'replay', @@ -459,8 +445,6 @@ export class ProviderAssignmentLifecycleService { status: options.status, now, result: options.assignmentResult, reviewDisposition: options.reviewDisposition ?? null })); if (['completed','failed','cancelled'].includes(options.status)) { - const integrationRequirement=!assignment.assignmentAttempt && options.status==='completed'?contentIntegrationRequirementOperation({ assignmentId:assignment.id,capacityProviderId:principal.capacityProviderId,stateVersion:assignment.stateVersion+1,lifecycleOutput,now }):null; - if(integrationRequirement)operations.push(integrationRequirement); const terminalWorkspace = terminalAssignmentAuthority(assignment, now); operations.push({ query: `UPDATE treedx_proxy_handles SET status = 'revoked', revoked_at = COALESCE(revoked_at, ?), updated_at = ? @@ -473,18 +457,6 @@ export class ProviderAssignmentLifecycleService { WHERE id = ? AND team_id = ? AND status = ? AND state_version = ?`, params: [JSON.stringify(terminalWorkspace.proxyHandle), JSON.stringify(terminalWorkspace.workspaceContext), assignment.id, principal.teamId, options.status, assignment.stateVersion + 1], }); - if (!assignment.assignmentAttempt) { - const demandStatus = options.status === 'completed' ? 'completed' : options.status==='cancelled'?'cancelled':'blocked'; - const participationStatus=options.status==='completed'?'completed':'blocked'; - operations.push({ - query: `UPDATE capacity_workday_demands SET status = ?, completed_at = ?, updated_at = ? WHERE assignment_id = ? AND status = 'admitted'`, - params: [demandStatus, now, now, assignment.id], - }); - operations.push({ - query: `UPDATE capacity_workday_participation_entries SET status = ?, reason_code = ?, covered_at = ?, updated_at = ? WHERE assignment_id = ? AND status = 'assigned'`, - params: [participationStatus, options.status === 'completed' ? null : input.code ?? options.defaultCode, now, now, assignment.id], - }); - } if (options.status !== 'completed' && assignment.invocationId) operations.push({ query: `UPDATE agent_invocation_requests SET status=?, assignment_id=?, completed_at=COALESCE(completed_at,?), blocking_state_json=?, updated_at=? WHERE id=? AND team_id=? AND status IN ('admitted','running')`, params: [options.status==='cancelled'?'cancelled':'failed',assignment.id, now, JSON.stringify({ code: input.code ?? options.defaultCode, reason: input.reason ?? input.message ?? options.defaultReason }), now, assignment.invocationId, assignment.teamId], diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-recovery-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-recovery-service.ts index 4f19ac2a..50812d3a 100644 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-recovery-service.ts +++ b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-recovery-service.ts @@ -2,8 +2,6 @@ import { createHash } from 'node:crypto'; import type { CapacityDatabaseOperation,CapacityGovernanceDatabase } from '../../../../database.ts'; import { ProviderAssignmentRepository,serializeProviderAssignmentRow,type DurableProviderAssignment } from '../../../../repositories/capacity/assignments/assignment.ts'; import { settleCapacityReservationExactlyOnce } from '../../accounting/settlement-service.ts'; -import { logicalModeRunSql } from '../../../../repositories/support/mode-run.ts'; -import { decodeDurableJsonObject } from '../../../../durable-json.ts'; import { teamSupplyPolicy } from '../../../../domain/supply-policy.ts'; import { terminalAssignmentAuthority } from './assignment-terminal-authority.ts'; import { recoverOperationHandoff } from '../handoffs/operation-handoff-lifecycle-service.ts'; @@ -44,11 +42,10 @@ export interface RecoveryEvidence { reservation: Record | null; settlement: Record | null; usageCount: number; - succeededModeRuns: number; - activeModeRuns: number; + hasAssignmentResult: boolean; proxyEvents: number; fallbackOutputs: number; - demand: Record | null; + node: Record | null; failoverAllowed: boolean; failoverCount: number; invocationFinalMessageRef: string | null; @@ -66,40 +63,31 @@ function retryLimit(assignment: DurableProviderAssignment) { } async function evidence(database: CapacityGovernanceDatabase, assignment: DurableProviderAssignment): Promise { - const [reservation, settlement, usage, modeRuns, proxy, fallback, demand, team, invocation] = await Promise.all([ + const [reservation, settlement, usage, proxy, fallback, node, attempts, run, team, invocation] = await Promise.all([ assignment.reservationId ? database.first(`SELECT * FROM capacity_reservations WHERE id = ? AND team_id = ? LIMIT 1`, [assignment.reservationId, assignment.teamId]) : Promise.resolve(null), assignment.reservationId ? database.first(`SELECT * FROM capacity_ledger_entries WHERE reservation_id = ? AND phase = 'task_completed_actual_settlement' LIMIT 1`, [assignment.reservationId]) : Promise.resolve(null), database.first(`SELECT COUNT(*) AS total FROM capacity_usage_actuals WHERE assignment_id = ?`, [assignment.id]), - database.first(`SELECT COALESCE(SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END), 0) AS succeeded, COALESCE(SUM(CASE WHEN status IN ('queued','running') THEN 1 ELSE 0 END), 0) AS active FROM agent_mode_runs WHERE provider_assignment_id = ? AND ${logicalModeRunSql()}`, [assignment.id]), database.first(`SELECT COUNT(*) AS total FROM treedx_project_proxy_audit WHERE assignment_id = ?`, [assignment.id]), database.first(`SELECT COUNT(*) AS total FROM agent_fallback_outputs WHERE assignment_id = ?`, [assignment.id]), - database.first(`SELECT * FROM capacity_workday_demands WHERE assignment_id = ? AND team_id = ? LIMIT 1`, [assignment.id, assignment.teamId]), + assignment.executionNodeId ? database.first(`SELECT id,status,node_revision FROM execution_nodes WHERE id=? AND team_id=? LIMIT 1`, [assignment.executionNodeId, assignment.teamId]) : Promise.resolve(null), + assignment.executionNodeId ? database.first(`SELECT COUNT(*) AS total FROM capacity_provider_assignments WHERE team_id=? AND execution_node_id=?`, [assignment.teamId, assignment.executionNodeId]) : Promise.resolve(null), + assignment.workDayId ? database.first(`SELECT status FROM capacity_workday_runs WHERE id=? AND team_id=? LIMIT 1`, [assignment.workDayId, assignment.teamId]) : Promise.resolve(null), database.first(`SELECT metadata_json FROM teams WHERE id = ? LIMIT 1`, [assignment.teamId]), assignment.invocationId ? database.first(`SELECT final_message_ref FROM agent_invocation_requests WHERE id = ? AND team_id = ? LIMIT 1`, [assignment.invocationId,assignment.teamId]) : Promise.resolve(null), ]); - const demandMetadata = demand ? decodeDurableJsonObject(demand.metadata_json, { owner: 'capacity workday demand', ownerId: String(demand.id), column: 'metadata_json' }) : {}; - const failoverCount = Math.max(0, Number(demandMetadata.failoverCount ?? 0)); + const failoverCount = Math.max(0, Number(attempts?.total ?? 0)); const policy = teamSupplyPolicy(team); - let failoverAllowed = Boolean(demand) && failoverCount < policy.maxFailovers + const failoverAllowed = Boolean(run?.status === 'running' && node && ['assigned','running'].includes(String(node.status)) + && Number(node.node_revision) === assignment.executionNodeRevision && failoverCount <= policy.maxFailovers) && (assignment.mode === 'acting' ? policy.allowActingFailover : policy.allowPlanningFailover); - if (failoverAllowed && assignment.mode === 'acting') { - const payload = decodeDurableJsonObject(demand!.payload_json, { owner: 'capacity workday demand', ownerId: String(demand!.id), column: 'payload_json' }); - const estimateId = String(payload.estimateId ?? record(payload.decisionInput).estimateId ?? record(record(payload.decisionInput).metadata).estimateId ?? ''); - const [readiness, estimate] = await Promise.all([ - database.first(`SELECT decision_id FROM decision_planning_statuses WHERE decision_id = ? AND project_id = ? AND human_approval_state = 'approved' AND execution_readiness IN ('ready','waived') AND planning_inputs_status IN ('complete','waived') LIMIT 1`, [assignment.decisionId, assignment.projectId]), - estimateId ? database.first(`SELECT id FROM structured_agent_estimates WHERE id = ? AND team_id = ? AND project_id = ? AND decision_id = ? AND status = 'accepted' LIMIT 1`, [estimateId, assignment.teamId, assignment.projectId, assignment.decisionId]) : Promise.resolve(null), - ]); - failoverAllowed = Boolean(readiness && estimate); - } return { reservation, settlement, usageCount: Number(usage?.total ?? 0), - succeededModeRuns: Number(modeRuns?.succeeded ?? 0), - activeModeRuns: Number(modeRuns?.active ?? 0), + hasAssignmentResult: Boolean(assignment.assignmentResult), proxyEvents: Number(proxy?.total ?? 0), fallbackOutputs: Number(fallback?.total ?? 0), - demand, + node, failoverAllowed, failoverCount, invocationFinalMessageRef: typeof invocation?.final_message_ref === 'string' && invocation.final_message_ref.trim() ? invocation.final_message_ref.trim() : null, @@ -111,12 +99,14 @@ export function decideAssignmentRecovery(assignment: DurableProviderAssignment, if (observed.settlement) { const settlementSource = String(observed.settlement.source ?? ''); if (settlementSource === 'expired_lease_recovery' || settlementSource === 'provider_assignment_fail') return { assignmentId: assignment.id, disposition: 'terminal-failure', status: 'failed', reasonCode: settlementSource === 'provider_assignment_fail' ? 'provider_failure_settlement_recovered' : 'expired_lease_terminal_settlement_recovered' }; - if (observed.succeededModeRuns > 0) return { assignmentId: assignment.id, disposition: 'completed', status: 'completed', reasonCode: 'expired_lease_completion_recovered' }; + if (observed.hasAssignmentResult) return { assignmentId: assignment.id, disposition: 'operator-action', status: 'expired', reasonCode: 'expired_lease_completion_requires_graph_reconciliation' }; return { assignmentId: assignment.id, disposition: 'operator-action', status: 'expired', reasonCode: 'expired_lease_settlement_without_success_evidence' }; } if (observed.reservation && (observed.reservation.settlement_token || observed.reservation.usage_report_token)) return { assignmentId: assignment.id, disposition: 'operator-action', status: 'expired', reasonCode: 'expired_lease_financial_transition_uncertain' }; - if (observed.usageCount > 0 || observed.succeededModeRuns > 0 || observed.proxyEvents > 0 || observed.fallbackOutputs > 0) return { assignmentId: assignment.id, disposition: 'operator-action', status: 'expired', reasonCode: 'expired_lease_side_effect_evidence_present' }; - if (Number(assignment.attemptCount ?? 0) + 1 >= retryLimit(assignment)) return { assignmentId: assignment.id, disposition: 'terminal-failure', status: 'failed', reasonCode: 'expired_lease_retry_exhausted' }; + if (observed.usageCount > 0 || observed.hasAssignmentResult || observed.proxyEvents > 0 || observed.fallbackOutputs > 0) return { assignmentId: assignment.id, disposition: 'operator-action', status: 'expired', reasonCode: 'expired_lease_side_effect_evidence_present' }; + // A retry admits a new immutable assignment for the same graph node. The + // current assignment's attempt_count therefore cannot bound that sequence. + if (observed.failoverCount >= retryLimit(assignment)) return { assignmentId: assignment.id, disposition: 'terminal-failure', status: 'failed', reasonCode: 'expired_lease_retry_exhausted' }; if (!observed.failoverAllowed) return { assignmentId: assignment.id, disposition: 'terminal-failure', status: 'failed', reasonCode: 'expired_lease_failover_not_allowed' }; return { assignmentId: assignment.id, disposition: 'safe-retry', status: 'failed', reasonCode: 'expired_lease_requeued' }; } @@ -128,28 +118,30 @@ function transitionOperations(assignment: DurableProviderAssignment, result: Ass const idempotencyKey = `lease-recovery:${assignment.id}:${assignment.stateVersion}`; const terminalAuthority = terminalAssignmentAuthority(assignment, now); const operations: CapacityDatabaseOperation[] = [ - { query: `UPDATE agent_mode_runs SET status = 'failed', failed_at = COALESCE(failed_at, ?), fallback_reason = COALESCE(fallback_reason, ?), updated_at = ? WHERE provider_assignment_id = ? AND status IN ('queued','running') AND ${logicalModeRunSql()} AND EXISTS (SELECT 1 FROM capacity_provider_assignments recovery_assignment WHERE recovery_assignment.id = ? AND recovery_assignment.state_version = ? AND recovery_assignment.status = 'leased' AND recovery_assignment.lease_state = 'leased' AND ${recoverableLeaseSql('recovery_assignment')})`, params: [now, result.reasonCode, now, assignment.id, assignment.id, assignment.stateVersion, now] }, { query: `UPDATE capacity_provider_assignments SET status = ?, lease_state = ?, lease_token = NULL, lease_expires_at = NULL, lease_renewed_at = NULL, runner_id = NULL, attempt_count = attempt_count + 1, state_version = state_version + 1, returned_at = CASE WHEN ? = 'returned' THEN ? ELSE returned_at END, failed_at = CASE WHEN ? IN ('failed','expired') THEN COALESCE(failed_at, ?) ELSE failed_at END, completed_at = CASE WHEN ? = 'completed' THEN COALESCE(completed_at, ?) ELSE completed_at END, lifecycle_code = ?, lifecycle_reason = ?, metadata_json = ?, treedx_proxy_handle_json = ?, workspace_context_json = ?, updated_at = ? WHERE id = ? AND team_id = ? AND state_version = ? AND status = 'leased' AND lease_state = 'leased' AND ${recoverableLeaseSql()}`, params: [result.status, leaseState, result.status, now, result.status, now, result.status, now, result.reasonCode, `Lease recovery classified the assignment as ${result.disposition} after ${recoveryTrigger}.`, JSON.stringify(metadata), JSON.stringify(terminalAuthority.proxyHandle), JSON.stringify(terminalAuthority.workspaceContext), now, assignment.id, assignment.teamId, assignment.stateVersion, now] }, { query: `UPDATE treedx_proxy_handles SET status = 'revoked', revoked_at = COALESCE(revoked_at, ?), updated_at = ? WHERE assignment_id = ? AND team_id = ? AND EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id = ? AND team_id = ? AND state_version = ? AND status = ?)`, params: [now, now, assignment.id, assignment.teamId, assignment.id, assignment.teamId, assignment.stateVersion + 1, result.status] }, { query: `INSERT INTO capacity_audit_events (id, team_id, capacity_provider_id, membership_id, actor_type, actor_id, action, resource_type, resource_id, request_id, idempotency_key, metadata_json, created_at) SELECT ?, ?, ?, ?, 'service', 'capacity-assignment-recovery', ?, 'capacity-provider-assignment', ?, NULL, ?, ?, ? WHERE EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id = ? AND team_id = ? AND state_version = ? AND status = ? AND lifecycle_code = ?) ON CONFLICT DO NOTHING`, params: [auditId, assignment.teamId, assignment.capacityProviderId, assignment.membershipId, `capacity-assignment.recovery.${result.disposition}`, assignment.id, idempotencyKey, JSON.stringify({ reasonCode: result.reasonCode, priorLeaseExpiresAt: assignment.leaseExpiresAt, recoveredStateVersion: assignment.stateVersion + 1 }), now, assignment.id, assignment.teamId, assignment.stateVersion + 1, result.status, result.reasonCode] }, ]; - if (result.disposition === 'safe-retry' && observed.demand) { - const demandMetadata = decodeDurableJsonObject(observed.demand.metadata_json, { owner: 'capacity workday demand', ownerId: String(observed.demand.id), column: 'metadata_json' }); - const failoverHistory = Array.isArray(demandMetadata.failoverHistory) ? demandMetadata.failoverHistory : []; + if (result.disposition === 'safe-retry' && observed.node) { operations.push( - { query: `UPDATE capacity_workday_demands SET status = 'pending', claim_token = NULL, claimed_at = NULL, assignment_id = NULL, admitted_at = NULL, metadata_json = ?, available_at = ?, updated_at = ? WHERE id = ? AND assignment_id = ? AND status = 'admitted'`, params: [JSON.stringify({ ...demandMetadata, failoverCount: observed.failoverCount + 1, failoverHistory: [...failoverHistory, { assignmentId: assignment.id, capacityProviderId: assignment.capacityProviderId, executionProviderId: assignment.executionProviderId, reasonCode: result.reasonCode, at: now }] }), now, now, observed.demand.id, assignment.id] }, - { query: `UPDATE capacity_workday_participation_entries SET assignment_id = NULL, reason_code = ?, updated_at = ? WHERE assignment_id = ? AND status = 'assigned'`, params: [result.reasonCode, now, assignment.id] }, + { query: `UPDATE execution_nodes SET status='ready',node_revision=node_revision+1,updated_at=? + WHERE team_id=? AND id=? AND node_revision=? AND status IN ('assigned','running') + AND EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id=? AND team_id=? AND state_version=? AND status='failed')`, + params: [now, assignment.teamId, assignment.executionNodeId, assignment.executionNodeRevision, + assignment.id, assignment.teamId, assignment.stateVersion + 1] }, ); } else if (result.status !== 'returned') { - const demandStatus = result.status === 'completed' + const nodeStatus = result.status === 'completed' ? 'completed' : assignment.executionKind === 'conversation' && result.disposition === 'terminal-failure' ? 'cancelled' - : 'blocked'; - const participationStatus = demandStatus === 'completed' ? 'completed' : 'blocked'; + : 'failed'; operations.push( - { query: `UPDATE capacity_workday_demands SET status = ?, completed_at = ?, updated_at = ? WHERE assignment_id = ? AND status = 'admitted' AND EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id = ? AND state_version = ?)`, params: [demandStatus, now, now, assignment.id, assignment.id, assignment.stateVersion + 1] }, - { query: `UPDATE capacity_workday_participation_entries SET status = ?, reason_code = ?, covered_at = ?, updated_at = ? WHERE assignment_id = ? AND status = 'assigned' AND EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id = ? AND state_version = ?)`, params: [participationStatus, result.status === 'completed' ? null : result.reasonCode, now, now, assignment.id, assignment.id, assignment.stateVersion + 1] }, + ...(assignment.executionNodeId ? [{ query: `UPDATE execution_nodes SET status=?,updated_at=? + WHERE team_id=? AND id=? AND node_revision=? AND status IN ('assigned','running') + AND EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id=? AND team_id=? AND state_version=? AND status=?)`, + params: [nodeStatus, now, assignment.teamId, assignment.executionNodeId, assignment.executionNodeRevision, + assignment.id, assignment.teamId, assignment.stateVersion + 1, result.status] }] : []), ); } if (assignment.invocationId && result.disposition !== 'safe-retry') operations.push({ diff --git a/src/api/capacity/services/capacity/assignments/observability/assignment-cleanup-observation-service.ts b/src/api/capacity/services/capacity/assignments/observability/assignment-cleanup-observation-service.ts index a828252b..4a4e66cc 100644 --- a/src/api/capacity/services/capacity/assignments/observability/assignment-cleanup-observation-service.ts +++ b/src/api/capacity/services/capacity/assignments/observability/assignment-cleanup-observation-service.ts @@ -1,5 +1,4 @@ import { listUnpublishedTreeDxAuthoringState } from '../../../treedx/repositories/treedx-authoring-journal.ts'; -import { logicalModeRunSql } from '../../../../repositories/support/mode-run.ts'; type Row=Record; type Store={ first(sql:string,params?:unknown[]):Promise; all(sql:string,params?:unknown[]):Promise }; @@ -33,18 +32,16 @@ export async function observeAssignmentCleanup(store:Store,assignment:Row) { const projectId=String(assignment.projectId??assignment.project_id??''); const reservationId=String(assignment.reservationId??assignment.reservation_id??''); const status=String(assignment.status??''); const leaseState=String(assignment.leaseState??assignment.lease_state??''); const terminal=['completed','failed','cancelled','expired','returned'].includes(status); - const [reservation,demand,workspace,modeRuns,unpublished]=await Promise.all([ + const [reservation,workspace,unpublished]=await Promise.all([ reservationId?store.first('SELECT COUNT(*) AS count FROM capacity_reservations WHERE id = ? AND team_id = ? AND state IN (\'reserved\',\'consuming\')',[reservationId,teamId]):null, - store.first('SELECT COUNT(*) AS count FROM capacity_workday_demands WHERE assignment_id = ? AND team_id = ? AND status IN (\'pending\',\'claimed\',\'admitted\',\'blocked\')',[assignmentId,teamId]), store.first('SELECT COUNT(*) AS count FROM treedx_proxy_handles WHERE assignment_id = ? AND team_id = ? AND status = \'issued\'',[assignmentId,teamId]), - store.first(`SELECT COUNT(*) AS count FROM agent_mode_runs WHERE provider_assignment_id = ? AND team_id = ? AND status IN ('queued','running') AND ${logicalModeRunSql()}`,[assignmentId,teamId]), listUnpublishedTreeDxAuthoringState(store,projectId,assignmentId), ]); const cleanup={ verified:false,activeAssignments:terminal?0:1,activeLeases:leaseState==='leased'?1:0, - activeReservations:count(reservation),activeDemands:count(demand),activeWorkspaces:count(workspace), + activeReservations:count(reservation),activeWorkspaces:count(workspace), activeWorktrees:sourceWorktreeCount(assignment),unpublishedBranches:unpublished.length, - staleAuthorities:count(modeRuns)+activeCapabilityCount(assignment), + staleAuthorities:activeCapabilityCount(assignment), }; cleanup.verified=Object.entries(cleanup).every(([key,value])=>key==='verified'||value===0); return { ...cleanup,assignmentId,projectId,observedAt:new Date().toISOString(),unpublished }; diff --git a/src/api/capacity/services/capacity/assignments/observability/operator-assignment-service.ts b/src/api/capacity/services/capacity/assignments/observability/operator-assignment-service.ts index 23112b5a..c2955395 100644 --- a/src/api/capacity/services/capacity/assignments/observability/operator-assignment-service.ts +++ b/src/api/capacity/services/capacity/assignments/observability/operator-assignment-service.ts @@ -1,13 +1,10 @@ -import { createHash } from 'node:crypto'; import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import { CapacityGovernanceError } from '../../../../database.ts'; import { ProviderAssignmentRepository } from '../../../../repositories/capacity/assignments/assignment.ts'; import type { DurableProviderAssignment } from '../../../../repositories/capacity/assignments/assignment.ts'; -import { CapacityWorkdayDemandRepository,serializeCapacityWorkdayDemandRow } from '../../../../repositories/capacity/workdays/workday-demand.ts'; import { releaseCapacityReservationsExactlyOnce } from '../../accounting/settlement-service.ts'; import { terminalAssignmentAuthority } from '../lifecycle/assignment-terminal-authority.ts'; -function id(value: string) { return `demand_${createHash('sha256').update(value).digest('base64url').slice(0, 32)}`; } function idempotencyKey(value: string) { if (!value.trim()) throw new CapacityGovernanceError('capacity_idempotency_key_required', 'An idempotency key is required.', 400); return value.trim(); @@ -15,13 +12,11 @@ function idempotencyKey(value: string) { export class OperatorAssignmentService { private readonly assignments: ProviderAssignmentRepository; - private readonly demands: CapacityWorkdayDemandRepository; constructor( private readonly database: CapacityGovernanceDatabase, private readonly closeWorkspace?: (assignment: DurableProviderAssignment) => Promise, ) { this.assignments = new ProviderAssignmentRepository(database); - this.demands = new CapacityWorkdayDemandRepository(database); } async cancel(teamId: string, assignmentId: string, input: { idempotencyKey: string; actorId?: string | null; reason?: string | null }) { @@ -69,8 +64,6 @@ export class OperatorAssignmentService { existingSettlementPolicy: 'replay', metadata: { actorId: input.actorId ?? null, reason: input.reason ?? null }, }]); await this.database.batch([ - { query: `UPDATE capacity_workday_demands SET status = 'cancelled', completed_at = ?, updated_at = ? WHERE assignment_id = ? AND status = 'admitted'`, params: [now, now, assignmentId] }, - { query: `UPDATE capacity_workday_participation_entries SET status = 'blocked', reason_code = 'operator_cancelled', covered_at = ?, updated_at = ? WHERE assignment_id = ? AND status = 'assigned'`, params: [now, now, assignmentId] }, ...(assignment.executionNodeId ? [{ query: `UPDATE execution_nodes SET status='cancelled',updated_at=? WHERE team_id=? AND id=? AND node_revision=? AND EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE id=? AND team_id=?)`, @@ -84,7 +77,7 @@ export class OperatorAssignmentService { async requeue(teamId: string, assignmentId: string, input: { idempotencyKey: string; actorId?: string | null; reason?: string | null }) { await this.database.ensureInitialized(); - const operationKey = idempotencyKey(input.idempotencyKey); + idempotencyKey(input.idempotencyKey); const assignment = await this.assignments.get(teamId, assignmentId); if (!assignment) throw new CapacityGovernanceError('capacity_assignment_not_found', 'Assignment does not exist.', 404, { assignmentId }); if (!['returned', 'failed', 'expired', 'cancelled'].includes(assignment.status) || assignment.leaseState === 'leased') throw new CapacityGovernanceError( @@ -113,31 +106,7 @@ export class OperatorAssignmentService { } return { assignment, demand: null, alreadyLeasable: false }; } - if (assignment.reservationId) { - const reservation = await this.database.first(`SELECT state FROM capacity_reservations WHERE id = ? AND team_id = ? LIMIT 1`, [assignment.reservationId, teamId]); - if (!reservation || !['consumed', 'released'].includes(String(reservation.state ?? ''))) throw new CapacityGovernanceError( - 'capacity_assignment_recovery_decision_required', - 'Assignment recovery must resolve its original reservation before creating replacement demand.', - 409, - { assignmentId, reservationId: assignment.reservationId, reservationState: reservation?.state ?? null }, - ); - } - const original = serializeCapacityWorkdayDemandRow(await this.database.first( - `SELECT * FROM capacity_workday_demands WHERE team_id = ? AND assignment_id = ? LIMIT 1`, [teamId, assignmentId], - )); - if (!original) throw new CapacityGovernanceError('capacity_assignment_demand_missing', 'Assignment has no canonical demand provenance.', 409, { assignmentId }); - const now = new Date().toISOString(); - const retryKey = `requeue:${original.id}:${operationKey}`; - const demand = await this.demands.create({ - id: id(retryKey), teamId, projectId: original.projectId, workdayRunId: original.workdayRunId, - workdayId: original.workdayId, sourceType: original.sourceType, sourceId: original.sourceId, - mode: original.mode, projectAgentClassId: original.projectAgentClassId, agentId: original.agentId, - handlerId: original.handlerId, activityType: original.activityType, decisionId: original.decisionId, - capacityPlanId: original.capacityPlanId, priority: original.priority, requestedSeconds: original.requestedSeconds, - idempotencyKey: retryKey, payload: original.payload, - metadata: { ...original.metadata, requeuedFromAssignmentId: assignmentId, actorId: input.actorId ?? null, reason: input.reason ?? null }, - availableAt: now, now, - }); - return { assignment, demand, alreadyLeasable: false }; + throw new CapacityGovernanceError('capacity_assignment_graph_provenance_required', + 'Only a living-graph assignment can be requeued; this assignment has no execution node.', 409, { assignmentId }); } } diff --git a/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts b/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts deleted file mode 100644 index 3bb7dfb8..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/assignment-function.ts +++ /dev/null @@ -1,366 +0,0 @@ -import type { CapacitySupplyPolicy } from "@treeseed/sdk/agent-capacity"; -import { capacitySupplyCandidateStatus, selectCapacitySupply } from '../../../../policy/supply-selection.ts'; -import { evaluateMinimumAssignmentDuration } from '../../../../policy/timing/assignment-duration.ts'; -import { CapacityGovernanceError } from "../../../../database.ts"; -import type { ProviderLeasePrincipal } from "../../../accounts/lease-authority-service.ts"; -import type { ProviderSynthesisExecutionProvider } from "../../providers/provider-synthesis-context-service.ts"; -import { workdayTreeDxWorkspaceId } from "../../workdays/treedx/workday-treedx-workspace-service.ts"; -import { compileAssignmentTimeBudget } from './assignment-time-budget.ts'; -import { selectAssignmentLane } from './assignment-lane-selection.ts'; -import { assertBatteryAdmission } from './admission/battery-admission.ts'; -import { compilePlanningAllowedOutputs,compilePlanningAssignmentInput } from './planning-assignment-contract.ts'; -import { assignmentBootstrapReadPaths,assignmentContextQueryReadPaths,assignmentDiscussionMessageReadPaths,assignmentDiscussionWritePaths,assignmentInstructionTemplateReadPaths,assignmentOperationalContentPaths,assignmentTreeDxProxyHandle,mergeAssignmentPathScopes } from './assignment-operational-paths.ts'; -export { compilePlanningAllowedOutputs,compilePlanningAssignmentInput } from './planning-assignment-contract.ts'; -export { resolveAssignmentContentBaseRef } from './content-base-ref.ts'; -import { resolveAssignmentContentBaseRef } from './content-base-ref.ts'; -import { assignmentConfigurationAttribution } from './assignment-configuration-attribution.ts'; -import { negotiateAssignmentCapabilityOffers } from './support/capability-offer-negotiation.ts'; -import { constrainAssignmentContentPathScope, resolveAssignmentContentPathScope } from './assignment-content-path-scope.ts'; -import { resolveCrossProjectReadRepositories } from './context/cross-project-read-repositories.ts'; -import { assignmentRecord as record, assignmentText as text, - deterministicAssignmentId as assignmentId, type AssignmentJsonRecord as JsonRecord } from './support/assignment-function-support.ts'; -import type { AssignmentFunctionStore } from './support/assignment-function-store.ts'; -import type { ExecutionNodeAssignmentInput } from '../../../build/execution-node-demand-compiler.ts'; -export { assignmentConfigurationAttribution } from './assignment-configuration-attribution.ts'; -export { resolveAssignmentContentPathScope } from './assignment-content-path-scope.ts'; - -export async function assignmentInput( - store: AssignmentFunctionStore, - demand: ExecutionNodeAssignmentInput, - principal: ProviderLeasePrincipal, - sessionId: string, - executionProviders: ProviderSynthesisExecutionProvider[], - policy: CapacitySupplyPolicy, - now: string, -) { - if (!demand?.claimToken) - throw new CapacityGovernanceError( - "capacity_workday_demand_claim_missing", - "Claimed demand has no claim token.", - 500, - ); - const id = demand.assignmentId ?? assignmentId(demand.id, Math.max(0, Number(demand.metadata.failoverCount ?? 0))); - const payload = record(demand.payload); - const nestedDecisionInput = record(record(payload.decisionInput).input); - const intent = record(payload.intent ?? nestedDecisionInput.intent); - const repositoryId = text(payload.repositoryId); - if (!repositoryId) - throw new CapacityGovernanceError( - "capacity_workday_demand_repository_missing", - "Demand omitted its TreeDX repository.", - 500, - { demandId: demand.id }, - ); - const contentRoot = text(payload.contentRoot); - if (!contentRoot) - throw new CapacityGovernanceError( - "capacity_workday_demand_content_path_missing", - "Demand omitted its configured project content path.", - 500, - { demandId: demand.id }, - ); - const contentBaseRef = resolveAssignmentContentBaseRef(payload); - const project = await store.getProject(demand.projectId); - if (!project) throw new CapacityGovernanceError( - "capacity_workday_demand_project_missing", - "Demand project is unavailable while composing assignment identity.", - 409, - { demandId: demand.id, projectId: demand.projectId }, - ); - const teamLibraryProject=await store.getProjectByTeamAndSlug(demand.teamId,'team'); - if(!teamLibraryProject)throw new CapacityGovernanceError('capacity_team_library_missing','The managed Team Library project is unavailable.',409,{teamId:demand.teamId}); - const teamLibraryProvisioning=record(record(teamLibraryProject.metadata).provisioning); - if(text(teamLibraryProvisioning.state)!=='known-good')throw new CapacityGovernanceError('capacity_team_library_not_ready','The managed Team Library has not reached a verified known-good state.',409,{teamId:demand.teamId,projectId:teamLibraryProject.id,state:text(teamLibraryProvisioning.state)||'unknown'}); - const teamLibraryBinding=await store.getProjectTreeDxLibrary(text(teamLibraryProject.id)); - const teamLibraryRepositoryId=text(teamLibraryBinding?.repositoryId,record(record(record(teamLibraryBinding?.topology).contentRepository).treeDx).repositoryId); - const teamLibraryRef=text(record(teamLibraryBinding?.metadata).resolvedRef,teamLibraryBinding?.contentRepositoryRef); - if(!teamLibraryRepositoryId||!teamLibraryRef)throw new CapacityGovernanceError('capacity_team_library_not_ready','The managed Team Library has no verified current TreeDX view.',409,{teamId:demand.teamId,projectId:teamLibraryProject.id}); - const sameTeamReadRepositories=await resolveCrossProjectReadRepositories({store,teamId:demand.teamId,projectId:demand.projectId,teamLibraryProject,payload,now:new Date(now)}); - const planning = demand.mode === "planning"; - const contentPrefix = contentRoot === '.' ? '' : `${contentRoot.replace(/\/+$/u, '')}/`; - const projectSlug = text(project.slug) || demand.projectId; - const coreObjectivePath = `${contentPrefix}objectives/core`; - const coreObjectiveCandidates = [`${coreObjectivePath}.mdx`, `${coreObjectivePath}.md`]; - const projectReadmePath = `${contentPrefix}README.md`; - const identityAnchorPaths = [...coreObjectiveCandidates, projectReadmePath]; - const activityType=demand.activityType; - const executionMode = demand.metadata.executionMode === 'production' ? 'production' as const : 'simulation' as const; - const requiredCapabilities = Array.isArray(demand.metadata.requiredCapabilities) - ? demand.metadata.requiredCapabilities.map(String).filter(Boolean) - : []; - const { capabilityDemand, offerNegotiations } = negotiateAssignmentCapabilityOffers(demand.metadata.capabilityDemand, executionProviders); - const selectedSupply = record(demand.metadata.supplySelection); - const selectedExecutionProviderId = text(selectedSupply.executionProviderId); - const selection = selectCapacitySupply({ - policy, - requiredCapabilities, - assignmentWindow: { startedAt: now, durationSeconds: demand.requestedSeconds }, - candidates: executionProviders.filter((provider) => (!selectedExecutionProviderId || provider.id === selectedExecutionProviderId) - && (!capabilityDemand || provider.offers.some((offer) => offerNegotiations.has(`${provider.id}:${offer.offerId}`)))).map((provider) => ({ - capacityProviderId: principal.capacityProviderId, - membershipId: principal.membershipId, - providerSessionId: sessionId, - grantId: text(selectedSupply.grantId), - executionProviderId: provider.id, - status: capacitySupplyCandidateStatus(provider.status), - capabilities: provider.capabilities, - reliability: provider.reliability ?? 1, - pressure: provider.pressure ?? 'normal', - availableConcurrency: provider.availableConcurrency ?? 1, - estimatedCost: provider.estimatedCost ?? null, - minimumAssignmentDuration: provider.minimumAssignmentDuration, - })), - }); - const executionProvider = executionProviders.find((provider) => provider.id === selection.selected?.executionProviderId); - if (!executionProvider) { - throw new CapacityGovernanceError( - "capacity_execution_provider_unavailable", - "No advertised execution provider satisfies this demand.", - 409, - { demandId: demand.id, requiredCapabilities, rejected: selection.rejected.map((entry) => ({ executionProviderId: entry.candidate.executionProviderId, reasons: entry.reasons })) }, - ); - } - const executionKind = demand.metadata.executionKind === 'conversation' ? 'conversation' : demand.metadata.executionKind === 'recovery' ? 'recovery' : demand.metadata.executionKind === 'simulation' ? 'simulation' : 'workday'; - const triggerKind = demand.metadata.triggerKind === 'discussion' ? 'discussion' : demand.metadata.triggerKind === 'agent-handoff' ? 'agent-handoff' : demand.metadata.triggerKind === 'manual' ? 'manual' : 'scheduled'; - const requestedLane = demand.metadata.lanePurpose === 'platform' ? 'platform' as const - : executionKind === 'conversation' ? 'communication' as const : 'workday' as const; - const compatibleLanes = executionProvider.lanes.filter((candidate) => requiredCapabilities.every((capability) => candidate.capabilities.length === 0 || candidate.capabilities.includes(capability))); - const laneLoads = new Map(); - for (const candidate of compatibleLanes) { - const count = await store.first(`SELECT COUNT(*) AS count FROM capacity_provider_assignments WHERE capacity_provider_id = ? AND lane_id = ? AND status IN ('pending','leased','running')`, [principal.capacityProviderId, candidate.id]); - laneLoads.set(candidate.id, Number(count?.count ?? 0)); - } - const selectedOffer = capabilityDemand ? executionProvider.offers.find((offer) => offerNegotiations.has(`${executionProvider.id}:${offer.offerId}`)) : null; - const negotiationReceipt = selectedOffer ? offerNegotiations.get(`${executionProvider.id}:${selectedOffer.offerId}`) : null; - const { lane, communicationOverflow } = selectAssignmentLane(executionKind, compatibleLanes, laneLoads, requestedLane); - if (!lane) throw new CapacityGovernanceError('capacity_provider_lane_unavailable', `No ${requestedLane} lane satisfies this demand.`, 409, { executionProviderId: executionProvider.id, lanePurpose: requestedLane }); - await assertBatteryAdmission({ store, teamId: demand.teamId, providerId: principal.capacityProviderId, - executionProvider, lanes: compatibleLanes, laneLoads, requestedLane }); - const effectiveLanePurpose = lane.purpose; - const evaluatedMinimumDuration = executionProvider.minimumAssignmentDuration - ? evaluateMinimumAssignmentDuration(executionProvider.minimumAssignmentDuration, now) - : null; - const minimumAssignmentDuration = evaluatedMinimumDuration ? { - requirement: evaluatedMinimumDuration.requirement, - minimumWindowSeconds: evaluatedMinimumDuration.minimumWindowSeconds, - startedAt: null, - minimumDeadlineAt: null, - } : null; - const taskReadPaths = resolveAssignmentContentPathScope(payload, 'read', contentRoot, executionKind === 'conversation' ? [] : ["**"]); - const sourceMessageRefs = [...new Set([ - text(payload.discussionMessageId), text(payload.subjectPath), - ...(Array.isArray(payload.operationHandoffSourceMessageRefs) ? payload.operationHandoffSourceMessageRefs.map(String) : []), - ].filter(Boolean))]; - const discussionMessageReadPaths = executionKind === 'conversation' ? assignmentDiscussionMessageReadPaths(sourceMessageRefs) : []; - const chatWritePaths=['discussion-messages','discussion-events','notes','questions','proposals'].flatMap((collection)=>[`${contentRoot}/${collection}`,`${contentRoot}/${collection}/**`]); - const declaredOutputPaths = Array.isArray(record(payload.allowedOutputs).paths) - ? record(payload.allowedOutputs).paths as unknown[] : []; - const planningWriteDefaults = declaredOutputPaths.map(String).filter(Boolean); - const resolvedPlanningWritePaths = resolveAssignmentContentPathScope(payload, 'write', contentRoot, - planningWriteDefaults.length ? planningWriteDefaults : [contentRoot, `${contentRoot}/**`]); - const taskWritePaths = activityType==='chat' - ? mergeAssignmentPathScopes(resolveAssignmentContentPathScope(payload,'write',contentRoot,chatWritePaths), assignmentDiscussionWritePaths(contentRoot)) - : planning - ? planningWriteDefaults.length - ? constrainAssignmentContentPathScope(resolvedPlanningWritePaths, planningWriteDefaults) - : resolvedPlanningWritePaths - : ["**"]; - const operationalPaths = assignmentOperationalContentPaths(contentRoot, id); - const bootstrapReadPaths = assignmentBootstrapReadPaths(contentRoot, payload.agentContentPath, intent.subjectPath); - const contextQueryReadPaths = assignmentContextQueryReadPaths(contentRoot, payload.contextQueryRefs, payload.contextQueryChecks); - const instructionTemplateReadPaths = assignmentInstructionTemplateReadPaths(contentRoot, payload.instructionTemplateRefs); - const allowedReadPaths = mergeAssignmentPathScopes(taskReadPaths, discussionMessageReadPaths, bootstrapReadPaths, identityAnchorPaths, - [`${contentPrefix}agents/**`,`${contentPrefix}objectives/**`],contextQueryReadPaths, instructionTemplateReadPaths, operationalPaths); - const allowedWritePaths = mergeAssignmentPathScopes(taskWritePaths, operationalPaths); - const workspaceAllowedPaths = mergeAssignmentPathScopes(allowedReadPaths, allowedWritePaths); - const workspaceId = workdayTreeDxWorkspaceId(id); - const configuredBudget = record(record(payload.capacityEnvelope).budget); - const timing = compileAssignmentTimeBudget({ now, requestedSeconds: demand.requestedSeconds, configuredBudget }); - const { closeoutSeconds, preparationSeconds, authorityExpiresAt } = timing; - const treedxProxyHandle = assignmentTreeDxProxyHandle({ assignmentId: id, teamId: demand.teamId, projectId: demand.projectId, - executionMode, repositoryId, workspaceId, allowedPaths: workspaceAllowedPaths, allowedReadPaths, allowedWritePaths, - baseRef: contentBaseRef, expiresAt: authorityExpiresAt, demandId: demand.id, workdayRunId: demand.workdayRunId }); - treedxProxyHandle.readRepositories=sameTeamReadRepositories; - treedxProxyHandle.metadata={...record(treedxProxyHandle.metadata),readRepositories:treedxProxyHandle.readRepositories}; - const capacityBudget = timing.capacityBudget; - const capacityEnvelope = { - ...record(payload.capacityEnvelope), - workDayId: demand.workdayId, - projectId: demand.projectId, - capacityProviderId: principal.capacityProviderId, - executionProviderId: executionProvider.id, - laneId: lane.id, - lanePurpose: effectiveLanePurpose, - communicationOverflow, - executionKind, - mode: demand.mode, - executionMode, - requestedSeconds: demand.requestedSeconds, - reservedSeconds: demand.requestedSeconds, - activeSeconds: 0, - releasedSeconds: 0, - overrunSeconds: 0, - budget: capacityBudget, - metadata: { - ...record(record(payload.capacityEnvelope).metadata), - source: "workday-demand", - demandId: demand.id, - }, - }; - const planningSubjectModel = text(intent.subjectModel); - const planningSubjectId = text(intent.subjectId); - const decisionInput = - demand.mode === "planning" - ? { - kind: "workday-demand", - projectId: demand.projectId, - agentId: demand.agentId, - handlerId: demand.handlerId, - mode: demand.mode, - executionMode, - input: compilePlanningAssignmentInput(payload, intent, demand.activityType), - metadata: { - source: "workday-demand", - demandId: demand.id, - sourceType: demand.sourceType, - activityType: demand.activityType, - planningGraph: record(payload.planningGraph), - }, - } - : { - ...record(payload.decisionInput), - teamId: demand.teamId, - projectId: demand.projectId, - projectAgentClassId: demand.projectAgentClassId, - mode: "acting", - executionMode, - metadata: { - ...record(record(payload.decisionInput).metadata), - source: "workday-demand", - demandId: demand.id, - capacityPlanId: demand.capacityPlanId, - }, - }; - const attribution=assignmentConfigurationAttribution({payload,projectAgentClassId:demand.projectAgentClassId, - activityType:demand.activityType,handlerId:demand.handlerId,contentBaseRef,executionProvider:executionProvider as unknown as JsonRecord}); - return { - assignmentId: id, - reservationId: `reservation_${id}`, - synthesisKey: demand.idempotencyKey, - synthesizedFrom: "workday_demand", - projectId: demand.projectId, - environment: text(demand.metadata.environment, "local"), - grantId: text(selectedSupply.grantId) || null, - providerSessionId: sessionId, - executionProviderId: executionProvider.id, - offerId: selectedOffer?.offerId ?? null, - contextCapacity: selectedOffer?.contextCapacity ?? null, - capabilityDemand, - capabilityNegotiation: negotiationReceipt, - laneId: lane.id, - lanePurpose: effectiveLanePurpose, - communicationOverflow, - executionKind, - triggerKind, - invocationId: text(demand.metadata.invocationId) || null, - parentWorkdayId: text(demand.metadata.parentWorkdayId) || null, - parentAssignmentId: text(demand.metadata.parentAssignmentId) || null, - handoffRootId: text(demand.metadata.handoffRootId) || null, - handoffParentId: text(demand.metadata.handoffParentId) || null, - handoffDepth: Math.max(0,Number(demand.metadata.handoffDepth??0)), - sourceMessageRefs, - operationHandoffId: text(demand.metadata.operationHandoffId) || text(payload.operationHandoffId) || null, - projectAgentClassId: demand.projectAgentClassId, - mode: demand.mode, - executionMode, - workDayId: demand.workdayId, - requestedSeconds: demand.requestedSeconds, - budget: capacityBudget, - decisionId: demand.decisionId, - proposalId: - planningSubjectModel === "proposal" ? planningSubjectId || null : null, - requiredCapabilities, - agentId: demand.agentId, - handlerId: demand.handlerId, - capacityEnvelope, - decisionInput, - allowedOutputs: planning - ? compilePlanningAllowedOutputs( - payload, - intent, - demand.activityType, - taskWritePaths, - ) - : record(payload.allowedOutputs), - workspaceContext: { - workspaceAccessMode: "workspace_write", - treedxProxyHandle, - }, - treedxProxyHandle, - explanation: { - source: "workday-demand", - demandId: demand.id, - sourceType: demand.sourceType, - sourceId: demand.sourceId, - planningGraph: planning ? record(payload.planningGraph) : undefined, - minimumAssignmentDuration, - lanePurpose: effectiveLanePurpose, - communicationOverflow, - executionKind, - executionMode, - upstreamMutationPolicy: executionMode === 'production' ? 'checkpoint-only' : 'denied', - }, - metadata: { - demandId: demand.id, - offerId: selectedOffer?.offerId ?? null, - contextCapacity: selectedOffer?.contextCapacity ?? null, - capabilityDemand, - capabilityNegotiation: negotiationReceipt, - executionMode, - upstreamMutationPolicy: executionMode === 'production' ? 'checkpoint-only' : 'denied', - activityType: demand.activityType, - executionPolicy: record(payload.executionPolicy), - chatProfile: record(payload.chatProfile), - permissions: record(payload.permissions), - toolPolicy: record(payload.toolPolicy), - contextQueryRefs: Array.isArray(payload.contextQueryRefs) ? payload.contextQueryRefs : [], - contextQueryChecks: Array.isArray(payload.contextQueryChecks) ? payload.contextQueryChecks : [], - contextQueryLayers: record(payload.contextQueryLayers), - communication: record(demand.metadata.communication), - // Every real guest verifies the same immutable identity, not only chat guests. - identityManifest: { - schemaVersion: 'treeseed.agent-identity-manifest/v1', - agentHandle: `@${projectSlug}/${text(demand.agentId)}`, - teamId: demand.teamId, projectId: demand.projectId, projectSlug, - agentSlug: text(demand.agentId), repositoryId, immutableRef: contentBaseRef, - agentProfile: { path: text(payload.agentContentPath), expectedRevision: contentBaseRef }, - coreObjective: { path: coreObjectivePath, expectedRevision: contentBaseRef }, - projectReadme: { path: projectReadmePath, expectedRevision: contentBaseRef }, - teamLibrary: { projectId:text(teamLibraryProject.id),projectSlug:'team',repositoryId:teamLibraryRepositoryId,immutableRef:teamLibraryRef, - readme:{path:'README.md',expectedRevision:teamLibraryRef},coreObjective:{path:'objectives/core',expectedRevision:teamLibraryRef} }, - instructionTemplates: instructionTemplateReadPaths.map((path) => ({ path, expectedRevision: contentBaseRef })), - }, - contextManifest: allowedReadPaths.map((path) => ({ path, immutableRef: contentBaseRef, access: 'read' })), - agentClassSlug: text(demand.metadata.agentClassSlug), - contentRoot, - agentContentPath: text(payload.agentContentPath), - workdayRunId: demand.workdayRunId, - workspaceProvisioning: true, - allowPlanningContentArtifacts: planning, - groupIds:attribution.groupIds, - configurationRevisions:attribution.configurationRevisions, - minimumAssignmentDuration, - lanePurpose: effectiveLanePurpose, - communicationOverflow, - executionKind, - }, - workspace: { - repositoryId, - workspaceId, - // Repository reads have their own handle scope. A writable workspace must - // not include read-only anchors: TreeDX intersects its paths on every use. - allowedPaths: allowedWritePaths, - baseRef: contentBaseRef, - }, - }; -} diff --git a/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts b/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts deleted file mode 100644 index f534937f..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/assignment-operational-paths.ts +++ /dev/null @@ -1,105 +0,0 @@ -function normalizedRoot(contentRoot: string) { - const root = contentRoot.replace(/\\/gu, '/').replace(/\/+$/u, '').replace(/^\.\//u, ''); - return root === '.' ? '' : root; -} - -export function assignmentOperationalContentPaths(contentRoot: string, assignmentId: string) { - const root = normalizedRoot(contentRoot); - const prefix = root ? `${root}/` : ''; - return [ - `${prefix}assignment-plans/${assignmentId}.mdx`, - `${prefix}assignment-statuses/${assignmentId}-status-*`, - `${prefix}assignment-summaries/${assignmentId}.mdx`, - ]; -} - -export function assignmentBootstrapReadPaths(contentRoot: string, agentContentPath: unknown, subjectPath: unknown) { - const root = normalizedRoot(contentRoot); - const prefix = root ? `${root}/` : ''; - const identityAnchors = [`${prefix}README.md`]; - const editorialAnchors = typeof agentContentPath === 'string' && agentContentPath.includes('/agents/editorial/') - ? [ - `${prefix}README.md`, - `${prefix}notes/editorial/core.mdx`, - `${prefix}notes/editorial/core.md`, - `${prefix}notes/editorial/books/treeseed-guide/core.mdx`, - `${prefix}notes/editorial/books/treeseed-guide/core.md`, - ] - : []; - return [agentContentPath, subjectPath, ...identityAnchors, ...editorialAnchors] - .filter((path): path is string => typeof path === 'string' && path.trim().length > 0) - .map((path) => path.trim()); -} - -export function assignmentContextQueryReadPaths(contentRoot: string, contextQueryRefs: unknown, contextQueryChecks: unknown) { - const root = normalizedRoot(contentRoot); - const prefix = root ? `${root}/` : ''; - const references = Array.isArray(contextQueryRefs) ? contextQueryRefs : []; - const checks = Array.isArray(contextQueryChecks) ? contextQueryChecks : []; - const definitionPaths = references.flatMap((value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - const reference = value as Record; - if (typeof reference.id !== 'string' || !reference.id.trim()) return []; - if (reference.kind === 'query') return [`${prefix}agent-context-queries/${reference.id.trim()}.mdx`]; - if (reference.kind === 'query-set') return [ - `${prefix}agent-context-query-sets/${reference.id.trim()}.mdx`, - `${prefix}agent-context-queries/**`, - ]; - return []; - }); - const resultPaths = checks.flatMap((value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - const stats = (value as Record).stats; - if (!stats || typeof stats !== 'object' || Array.isArray(stats)) return []; - return Array.isArray((stats as Record).paths) - ? ((stats as Record).paths as unknown[]).filter((path): path is string => typeof path === 'string') - : []; - }); - return mergeAssignmentPathScopes(definitionPaths, resultPaths); -} - -export function assignmentInstructionTemplateReadPaths(contentRoot: string, instructionTemplateRefs: unknown) { - const root = normalizedRoot(contentRoot); - const prefix = root ? `${root}/` : ''; - return mergeAssignmentPathScopes((Array.isArray(instructionTemplateRefs) ? instructionTemplateRefs : []).flatMap((value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return []; - const id = typeof (value as Record).id === 'string' ? String((value as Record).id).trim() : ''; - return id ? [`${prefix}agent-instruction-templates/${id}.mdx`,`${prefix}agent-instruction-templates/${id}.md`] : []; - })); -} - -export function mergeAssignmentPathScopes(...scopes: string[][]) { - return [...new Set(scopes.flat().map((path) => path.trim()).filter(Boolean))]; -} - -export function assignmentDiscussionMessageReadPaths(refs: unknown[]) { - return mergeAssignmentPathScopes(refs.map(String).map((path) => path.replace(/\\/gu, '/').replace(/^\.\//u, '')) - .filter((path) => path.startsWith('discussion-messages/') || path.includes('/discussion-messages/'))); -} - -export function assignmentDiscussionWritePaths(contentRoot: string) { - const root = normalizedRoot(contentRoot); - const prefix = root ? `${root}/` : ''; - return ['discussion-messages', 'discussion-events'].flatMap((collection) => [ - `${prefix}${collection}`, - `${prefix}${collection}/**`, - ]); -} - -export function assignmentTreeDxProxyHandle(input: { - assignmentId: string; teamId: string; projectId: string; executionMode: 'simulation' | 'production'; - repositoryId: string; workspaceId: string; allowedPaths: string[]; allowedReadPaths: string[]; - allowedWritePaths: string[]; baseRef: string; expiresAt: string; demandId: string; workdayRunId: string; -}) { - return { - id: `tdx_${input.assignmentId}`, teamId: input.teamId, projectId: input.projectId, assignmentId: input.assignmentId, - executionMode: input.executionMode, repositoryId: input.repositoryId, repositoryProjectId: input.projectId, - workspaceId: input.workspaceId, baseRef: input.baseRef, status: 'provisioning', - scopes: ['project:read', 'project:write', 'workspace:read', 'workspace:write', 'files:read', 'files:search', 'graph:query', 'files:write', 'git:commit'], - allowedOperations: ['files:read', 'files:search', 'graph:query', 'files:write', 'git:commit', 'workspace:write'], - allowedPaths: input.allowedPaths, allowedReadPaths: input.allowedReadPaths, allowedWritePaths: input.allowedWritePaths, - expiresAt: input.expiresAt, - metadata: { source: 'workday-demand', demandId: input.demandId, workdayRunId: input.workdayRunId, repositoryProjectId: input.projectId, - executionMode: input.executionMode, upstreamMutationPolicy: input.executionMode === 'production' ? 'checkpoint-only' : 'denied' }, - }; -} diff --git a/src/api/capacity/services/capacity/assignments/planning/assignment-planning-output-service.ts b/src/api/capacity/services/capacity/assignments/planning/assignment-planning-output-service.ts deleted file mode 100644 index 1bc05d36..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/assignment-planning-output-service.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { evaluateGovernanceProposalReadiness } from '../../../../../governance/proposal-readiness.ts'; -import type { AgentArtifactManifest,CapacityWorkdayRunRecord } from '@treeseed/sdk/agent-capacity'; -import { validateAgentArtifactManifest } from '../../../../artifact-manifest.ts'; -import { validatePortableContentData } from '@treeseed/sdk/content-validation'; -import { createHash } from 'node:crypto'; -import { CapacityGovernanceError,type CapacityGovernanceDatabase } from '../../../../database.ts'; -import type { DurableProviderAssignment } from '../../../../repositories/capacity/assignments/assignment.ts'; -import { assignmentArtifactManifest } from '../context/assignment-deliverable-service.ts'; -import { resolveWorkdayTreeDxConnection,type WorkdayTreeDxConnectionStore } from '../../workdays/treedx/workday-treedx-connection.ts'; -import { resolveProposalFeedbackSubject, reviewedProposalVersion } from './feedback/subject.ts'; -import { decodeWorkdayAgentProfileSnapshot } from '../../workdays/policy/workday-agent-profile-policy.ts'; - -type JsonRecord = Record; - -export interface AssignmentPlanningOutputStore extends CapacityGovernanceDatabase,WorkdayTreeDxConnectionStore { - getGovernanceProposal(id: string): Promise; - createGovernanceProposal(principal: unknown, input: JsonRecord): Promise; - updateGovernanceProposalDraft(principal: unknown,proposalId: string,input: JsonRecord): Promise; - getCapacityWorkdayRun(teamId: string, runId: string): Promise; - recordGovernanceEvent(input: JsonRecord): Promise; -} - -function record(value: unknown): JsonRecord { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; -} - -function text(...values: unknown[]): string { - for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim(); - return ''; -} - -function strings(value: unknown): string[] { - return Array.isArray(value) ? [...new Set(value.map(text).filter(Boolean))] : []; -} - -function slug(value: string): string { - return value.replace(/^proposal:/u, '').replace(/^.*\//u, '').replace(/\.(?:md|mdx)$/iu, ''); -} - -/** Resolve the activity payload from the durable assignment envelope. - * - * Living execution-node assignments deliberately retain the original - * decision input inside the admission envelope. Planning-output validation - * must bind artifacts against that inner immutable input, not against the - * admission wrapper. - */ -export function assignmentActivityInput(assignment: DurableProviderAssignment): JsonRecord { - const envelopeInput = record(record(assignment.decisionInput).input); - const activityInput = record(record(envelopeInput.decisionInput).input); - return Object.keys(activityInput).length ? activityInput : envelopeInput; -} - -function proposalRevisionBases(assignment: DurableProviderAssignment) { - const input = assignmentActivityInput(assignment); - const intent = record(input.intent); - const candidates = [intent.relatedArtifact,...(Array.isArray(intent.relatedArtifacts) ? intent.relatedArtifacts : [])].map(record); - return new Set(candidates.flatMap((candidate) => [ - text(candidate.commitSha),text(candidate.version),text(candidate.digest), - ]).filter(Boolean)); -} - -export async function persistAssignmentProposalRevision(input: { - store: AssignmentPlanningOutputStore; - assignment: DurableProviderAssignment; - existing: JsonRecord; - proposalId: string; - digest: string; - contentProvenance: JsonRecord; - proposal: JsonRecord; -}) { - const existingMetadata = record(input.existing.metadata); - const existingProvenance = record(existingMetadata.contentProvenance); - if (text(existingProvenance.digest) === input.digest) return input.existing; - const bases = proposalRevisionBases(input.assignment); - const currentBase = [text(existingProvenance.commitSha),text(existingProvenance.digest)].filter(Boolean); - if (!currentBase.some((value) => bases.has(value))) throw new CapacityGovernanceError( - 'assignment_proposal_revision_stale', - 'Proposal revision was not produced from the current immutable proposal version.', - 409, - { assignmentId: input.assignment.id,proposalId: input.proposalId,currentVersion: input.existing.activeVersion ?? null }, - ); - try { - return await input.store.updateGovernanceProposalDraft({ id: input.assignment.agentId,type: 'agent' },input.proposalId,{ - ...input.proposal, - expectedProposalVersion: Number(input.existing.activeVersion), - changeReason: text(input.proposal.changeReason,input.proposal.revisionSummary,`Agent ${input.assignment.agentId} published a provenance-linked revision.`), - contentProvenance: input.contentProvenance, - createdByType: 'agent',createdById: input.assignment.agentId, - metadata: { ...record(input.proposal.metadata),authorAgentId: input.assignment.agentId,assignmentId: input.assignment.id,workdayId: input.assignment.workDayId }, - }); - } catch (error) { - const current = await input.store.getGovernanceProposal(input.proposalId); - if (current && text(record(record(current.metadata).contentProvenance).digest) === input.digest) return current; - throw error; - } -} - -export function assignmentWorkdayRunId(assignment: DurableProviderAssignment): string { - return text(record(assignment.metadata).workdayRunId, record(assignment.metadata).workday_run_id, assignment.workDayId); -} - -function proposalParticipation(workday: unknown, projectId: string, proposalTypes: string[]) { - const snapshot = decodeWorkdayAgentProfileSnapshot(record(record(record(workday).parameters).agentProfilesByProjectId)[projectId], projectId); - const agents = snapshot.agents as unknown[]; - const contracts = record(snapshot.proposalTypeContracts); - const missingTypes = proposalTypes.filter((id) => !contracts[id]); - if (missingTypes.length) throw new CapacityGovernanceError('assignment_proposal_type_not_frozen', 'Agent proposal output contains a type outside the immutable workday contracts.', 409, { projectId, proposalTypes: missingTypes }); - const requiredClasses = new Set(proposalTypes.flatMap((id) => strings(record(contracts[id]).requiredReviewerClasses))); - const reviewerParticipants = agents.map(record).filter((agent) => requiredClasses.has(text(agent.projectAgentClassSlug)) && ['estimating','reviewing'].includes(text(agent.activityType))).map((agent) => text(agent.slug)).filter(Boolean); - const represented = new Set(agents.map(record).filter((agent) => reviewerParticipants.includes(text(agent.slug))).map((agent) => text(agent.projectAgentClassSlug))); - const missingReviewerClasses = [...requiredClasses].filter((id) => !represented.has(id)); - if (missingReviewerClasses.length) throw new CapacityGovernanceError('assignment_proposal_reviewer_unavailable', 'The workday graph cannot satisfy the proposal type reviewer contract.', 409, { projectId, proposalTypes, missingReviewerClasses }); - return { participantIds: reviewerParticipants.sort(), requiredReviewerClasses: [...requiredClasses].sort(), participationSnapshot: null }; -} - -function repositoryFile(response: { files?: unknown[]; results?: unknown[]; file?: unknown }): JsonRecord { - return record(response.files?.[0] ?? response.results?.[0] ?? response.file); -} - -export function assertPlanningArtifactContent(model: string,path: string,frontmatter: JsonRecord,assignmentId: string) { - const validation = validatePortableContentData(model,frontmatter); - if (!validation.portable || !validation.ok) throw new CapacityGovernanceError('assignment_content_model_invalid', 'Planning artifact content failed model validation.', 409, { - assignmentId,contentPath:path,model,diagnostics:validation.diagnostics, - }); -} - -function planningManifest(assignment: DurableProviderAssignment,input: JsonRecord): AgentArtifactManifest | null { - const manifest = assignmentArtifactManifest(input); - if (!manifest) return null; - const validation = validateAgentArtifactManifest(manifest); - if (!validation.ok) throw new CapacityGovernanceError('assignment_artifact_manifest_invalid', validation.reason ?? 'Planning artifact manifest is invalid.', 409, { assignmentId: assignment.id }); - const expected = { assignmentId: assignment.id, projectId: assignment.projectId, teamId: assignment.teamId, mode: 'planning', - agentClassId: assignment.projectAgentClassId, agentId: assignment.agentId ?? null }; - const actual = { assignmentId: manifest.assignmentId, projectId: manifest.projectId, teamId: manifest.teamId, mode: manifest.mode, - agentClassId: manifest.agentClassId, agentId: manifest.agentId }; - const mismatches = Object.keys(expected).filter((key) => expected[key as keyof typeof expected] !== actual[key as keyof typeof actual] - && !(key === 'agentId' && expected.agentId === null)); - if (mismatches.length) { - const differences = mismatches.map((key) => `${key} (expected=${JSON.stringify(expected[key as keyof typeof expected])}, actual=${JSON.stringify(actual[key as keyof typeof actual])})`); - throw new CapacityGovernanceError('assignment_artifact_manifest_scope_invalid', `Planning artifact manifest scope does not match the completing assignment: ${differences.join(', ')}.`, 409, - { assignmentId: assignment.id, mismatches, expected, actual }); - } - return manifest; -} - -async function registerProposalArtifacts( - store: AssignmentPlanningOutputStore, - assignment: DurableProviderAssignment, - manifest: AgentArtifactManifest | null, -) { - if (!manifest) return []; - const proposalReferences = manifest.contentReferences.filter((reference) => reference.model === 'proposal' || reference.artifactKind === 'planning_proposal'); - const feedbackReferences = manifest.contentReferences.filter((reference) => reference.artifactKind === 'proposal_feedback_note' - || (reference.model === 'question' && reference.subjectField === 'relatedProposals' && Boolean(reference.subjectId))); - const executionPlanReferences = manifest.contentReferences.filter((reference) => reference.model === 'execution_plan' || reference.artifactKind === 'execution_plan'); - if (!proposalReferences.length && !feedbackReferences.length && !executionPlanReferences.length) return []; - if (!assignment.workDayId) throw new CapacityGovernanceError('assignment_proposal_workday_missing', 'Proposal output requires durable workday provenance.', 409, { assignmentId: assignment.id }); - const connection = await resolveWorkdayTreeDxConnection(store, { - projectId: assignment.projectId, runId: assignment.workDayId, capabilities: ['repos:read','files:read'], - }); - if (!connection) throw new CapacityGovernanceError('assignment_proposal_treedx_unavailable', 'Proposal output could not be read through the project TreeDX binding.', 503, { assignmentId: assignment.id }); - // Completion projects immutable agent output into governance. Under concurrent - // report collection, repository reads can exceed the interactive proxy timeout. - const client = connection.client; - const workday = await store.getCapacityWorkdayRun(assignment.teamId, assignmentWorkdayRunId(assignment)); - const registered: unknown[] = []; - for (const reference of executionPlanReferences) { - const commitSha = text(reference.commitSha,manifest.commit?.sha); - if (!commitSha) throw new CapacityGovernanceError('assignment_execution_plan_commit_missing', 'Execution-plan output requires an immutable TreeDX commit.', 409, { assignmentId: assignment.id, contentPath: reference.contentPath }); - const response = await client.readRepositoryFiles({ ref: commitSha, paths: [reference.contentPath], encoding: 'utf8', parseFrontmatter: true }); - const file = repositoryFile(response), frontmatter = record(file.frontmatter); - assertPlanningArtifactContent('execution_plan',reference.contentPath,frontmatter,assignment.id); - const assignedInput = assignmentActivityInput(assignment), proposalRef = record(assignedInput.proposalRef); - if (text(frontmatter.proposalId) !== text(proposalRef.id) || Number(frontmatter.proposalRevision) !== Number(proposalRef.revision) - || text(frontmatter.proposalDigest) !== text(proposalRef.digest) || text(reference.subjectId) !== text(proposalRef.id)) { - throw new CapacityGovernanceError('assignment_execution_plan_scope_invalid', 'Execution plan does not bind the exact assigned proposal revision.', 409, { assignmentId: assignment.id, contentPath: reference.contentPath }); - } - registered.push({ model: 'execution_plan', id: text(frontmatter.id), revision: Number(frontmatter.revision), contentPath: reference.contentPath, commitSha }); - } - for (const reference of proposalReferences) { - const commitSha = text(reference.commitSha,manifest.commit?.sha); - if (!commitSha) throw new CapacityGovernanceError('assignment_proposal_commit_missing', 'Proposal output requires an immutable TreeDX commit.', 409, { assignmentId: assignment.id, contentPath: reference.contentPath }); - const response = await client.readRepositoryFiles({ ref: commitSha, paths: [reference.contentPath], encoding: 'utf8', parseFrontmatter: true }); - const file = repositoryFile(response); - const frontmatter = record(file.frontmatter); - assertPlanningArtifactContent('proposal',reference.contentPath,frontmatter,assignment.id); - const body = text(file.body) || text(file.content); - const proposalSlug = slug(text(frontmatter.slug) || reference.contentPath); - const assignedProposalId = text(record(assignmentActivityInput(assignment).proposalRef).id); - const proposalId = text(reference.subjectId, assignedProposalId) || `proposal:${assignment.projectId}:${proposalSlug}`; - if (assignedProposalId && proposalId !== assignedProposalId) throw new CapacityGovernanceError( - 'assignment_proposal_scope_invalid', 'Proposal output does not identify the exact assigned proposal.', 409, - { assignmentId: assignment.id, expectedProposalId: assignedProposalId, proposalId }, - ); - const digest = createHash('sha256').update(text(file.content) || JSON.stringify({ frontmatter,body })).digest('hex'); - const objectives = strings(frontmatter.relatedObjectives ?? frontmatter.related_objectives); - const proposalTypes = strings(frontmatter.proposalTypes ?? frontmatter.proposal_types ?? [frontmatter.proposalType ?? frontmatter.proposal_type]); - const evidenceRefs = strings(frontmatter.evidenceRefs ?? frontmatter.evidence_refs); - const decisionDependencies = Array.isArray(frontmatter.decisionDependencies ?? frontmatter.decision_dependencies) - ? frontmatter.decisionDependencies ?? frontmatter.decision_dependencies : []; - const plan = record(frontmatter.plan); - const contentProvenance = { repositoryId: connection.repositoryId, contentPath: reference.contentPath, commitSha, digest }; - const current = await store.getGovernanceProposal(proposalId); - const proposalVersion = current ? Number(current.activeVersion ?? 0) + 1 : 1; - const participation = proposalParticipation(workday, assignment.projectId, proposalTypes); - const requiredParticipantIds = participation.participantIds; - const readiness = evaluateGovernanceProposalReadiness({ title: text(frontmatter.title), summary: text(frontmatter.summary,frontmatter.description), body, relatedObjectives: objectives, proposalTypes, evidenceRefs, plan, contentProvenance }); - if (!readiness.contentReady) throw new CapacityGovernanceError('assignment_proposal_plan_incomplete', 'Agent proposal output does not satisfy the governance planning contract.', 409, { assignmentId: assignment.id, contentPath: reference.contentPath, missingRequirements: readiness.missingContent }); - const signature = JSON.stringify({ objectives: [...objectives].sort(), scope: strings(plan.scope).sort() }); - const related = await store.all(`SELECT * FROM governance_proposals WHERE project_id = ? AND status NOT IN ('withdrawn','superseded','rejected','accepted') ORDER BY created_at ASC LIMIT 500`, [assignment.projectId]); - const duplicate = related.find((row) => { - const metadata = record(row.metadata_json); - return text(record(metadata.contentProvenance).digest) === digest - && JSON.stringify({ objectives: strings(metadata.relatedObjectives).sort(), scope: strings(record(metadata.plan).scope).sort() }) === signature; - }); - if (duplicate) { registered.push(duplicate); continue; } - const existing = current; - if (existing) { - if (record(existing.metadata).contentProvenance && text(record(record(existing.metadata).contentProvenance).digest) === digest) { registered.push(existing); continue; } - registered.push(await persistAssignmentProposalRevision({ - store,assignment,existing,proposalId,digest,contentProvenance, - proposal: { - title: text(frontmatter.title),summary: text(frontmatter.summary,frontmatter.description),body, - proposalType: proposalTypes[0],proposalTypes,relatedObjectives: objectives,evidenceRefs, - decisionDependencies,plan,changeReason: text(frontmatter.changeReason,frontmatter.revisionSummary), - metadata: { requiredParticipantIds,requiredReviewerClasses: participation.requiredReviewerClasses,participationSnapshot: participation.participationSnapshot, - modeRunId: manifest.modeRunId,scene: record(workday?.metadata).scene ?? null,run: record(workday?.metadata).run ?? null }, - }, - })); - continue; - } - registered.push(await store.createGovernanceProposal(null, { - id: proposalId, teamId: assignment.teamId, projectId: assignment.projectId, scope: 'project', status: 'submitted', - title: text(frontmatter.title), summary: text(frontmatter.summary,frontmatter.description), body, - proposalType: proposalTypes[0], proposalTypes, contentProposalSlug: proposalSlug, - relatedObjectives: objectives, evidenceRefs, decisionDependencies, plan, contentProvenance, createdByType: 'agent', createdById: assignment.agentId, - metadata: { authorAgentId: assignment.agentId, assignmentId: assignment.id, workdayId: assignment.workDayId, - modeRunId: manifest.modeRunId, requiredParticipantIds, requiredReviewerClasses: participation.requiredReviewerClasses, participationSnapshot: participation.participationSnapshot, - scene: record(workday?.metadata).scene ?? null, run: record(workday?.metadata).run ?? null }, - })); - } - for (const reference of feedbackReferences) { - const commitSha = text(reference.commitSha,manifest.commit?.sha); - if (!commitSha) throw new CapacityGovernanceError('assignment_proposal_feedback_scope_invalid', 'Proposal feedback requires immutable TreeDX content.', 409, { assignmentId: assignment.id }); - const proposal = await resolveProposalFeedbackSubject(store, assignment, text(reference.subjectId)); - const proposalId = String(proposal.id); - const proposalVersion = reviewedProposalVersion(assignment, proposal); - const response = await client.readRepositoryFiles({ ref: commitSha, paths: [reference.contentPath], encoding: 'utf8', parseFrontmatter: true }); - const file = repositoryFile(response); - const frontmatter = record(file.frontmatter); - assertPlanningArtifactContent(reference.model,reference.contentPath,frontmatter,assignment.id); - const body = text(file.body,file.content); - const kind = reference.model === 'question' ? 'question' : text(frontmatter.feedbackKind,frontmatter.feedback_kind).toLowerCase(); - if (!['support','concern','question','response'].includes(kind)) throw new CapacityGovernanceError('assignment_proposal_feedback_kind_invalid', 'Proposal feedback must declare support, concern, question, or response.', 409, { assignmentId: assignment.id, contentPath: reference.contentPath }); - const feedbackSeverity = text(frontmatter.feedbackSeverity,frontmatter.feedback_severity) || (['concern','question'].includes(kind) ? 'blocking' : 'advisory'); - const feedbackStatus = text(frontmatter.feedbackStatus,frontmatter.feedback_status) || (kind === 'response' ? 'resolved' : 'open'); - const resolves = strings(frontmatter.resolves); - if (kind === 'response' && !resolves.length) throw new CapacityGovernanceError('assignment_proposal_feedback_resolution_missing', 'Proposal feedback responses must identify the exact feedback event they resolve.', 409, { assignmentId: assignment.id, contentPath: reference.contentPath }); - const eventId = `proposal-feedback:${assignment.id}:${reference.receiptId}`; - const existingEvent = await store.first(`SELECT id FROM governance_events WHERE id = ? LIMIT 1`, [eventId]); - if (!existingEvent) await store.recordGovernanceEvent({ - id: eventId, eventType: 'proposal.discussion', actorType: 'agent', actorId: assignment.agentId, - teamId: assignment.teamId, projectId: assignment.projectId, proposalId, - message: text(frontmatter.summary,frontmatter.description,body.slice(0,500)), - evidence: { kind, feedbackSeverity, feedbackStatus, producerClass: assignment.projectAgentClassId, - ...(resolves[0] ? { resolvesEventId: resolves[0] } : {}), proposalVersion, contentPath: reference.contentPath, commitSha, - digest: createHash('sha256').update(text(file.content) || JSON.stringify({ frontmatter,body })).digest('hex'), - assignmentId: assignment.id, workdayId: assignment.workDayId, modeRunId: manifest.modeRunId }, - }); - } - return registered; -} - -export async function projectCompletedPlanningOutputs( - store: AssignmentPlanningOutputStore, - assignment: DurableProviderAssignment, - input: JsonRecord, -) { - if (assignment.mode !== 'planning') return null; - const output = record(input.output); - const manifest = planningManifest(assignment,input); - const registered = await registerProposalArtifacts(store,assignment,manifest); - const envelopeInput = record(record(assignment.decisionInput).input); - const planningInputRequestId = text(envelopeInput.planningInputRequestId, assignmentActivityInput(assignment).planningInputRequestId); - if (planningInputRequestId) await store.run( - `UPDATE agent_invocation_requests SET status = 'completed', response_json = ?, completed_at = COALESCE(completed_at, ?) - WHERE id = ? AND team_id = ? AND project_id = ? AND execution_kind = 'workday' AND status IN ('queued','completed')`, - [JSON.stringify({ assignmentId: assignment.id, summary: output.summary ?? null }), new Date().toISOString(), planningInputRequestId, assignment.teamId, assignment.projectId], - ); - return registered.length ? registered : null; -} diff --git a/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts b/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts index a1c316b5..349b1744 100644 --- a/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts +++ b/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts @@ -5,7 +5,12 @@ import type { DurableProviderAssignment } from '../../../../../repositories/capa import { readExactProposal } from '../../../../../../governance/executable-proposal.ts'; import { commitProposalVersionContent } from '../../../../../../control-plane/governance/proposal-version-content.ts'; import { resolveKnowledgeGatewayConnection } from '../../../../../../knowledge/gateway-treedx-connection.ts'; -import type { AssignmentPlanningOutputStore } from '../assignment-planning-output-service.ts'; +import type { CapacityGovernanceDatabase } from '../../../../../database.ts'; +import type { WorkdayTreeDxConnectionStore } from '../../../workdays/treedx/workday-treedx-connection.ts'; + +type EstimateIntegrationStore = CapacityGovernanceDatabase & WorkdayTreeDxConnectionStore & { + getGovernanceProposal(id: string): Promise | null>; +}; type Row = Record; const record = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; @@ -18,18 +23,19 @@ function workItems(value: Row): Row[] { return Array.isArray(record(value.executionPlan).workItems) ? record(value.executionPlan).workItems as Row[] : []; } -/** One estimate belongs to the exact work item selected by the graph, not to a new plan authority. */ +/** An estimator owns all proposal work items of its class; Reviewer owns required reviews. */ export function mergeAssignmentEstimate(input: { - frozen: Row; candidate: Row; current: Row; workItemId: string | null; + frozen: Row; candidate: Row; current: Row; agentClass: string; }): Row { const frozenItems = workItems(input.frozen); const candidateItems = workItems(input.candidate); const currentItems = workItems(input.current); - const reviewer = input.workItemId === null; - const index = reviewer ? -1 : frozenItems.findIndex((item) => item.id === input.workItemId); - if ((!reviewer && index < 0) || !frozenItems.length || frozenItems.length !== candidateItems.length || frozenItems.length !== currentItems.length + const reviewer = input.agentClass === 'reviewer'; + const targets = frozenItems.flatMap((item, index) => (reviewer ? item.review === 'required' + : item.agentClass === input.agentClass) ? [index] : []); + if (!targets.length || frozenItems.length !== candidateItems.length || frozenItems.length !== currentItems.length || new Set(frozenItems.map((item) => item.id)).size !== frozenItems.length) { - throw new CapacityGovernanceError('assignment_estimate_work_item_invalid', 'Estimator result does not target one frozen proposal work item.', 409); + throw new CapacityGovernanceError('assignment_estimate_work_item_invalid', 'Estimator result has no class-owned frozen proposal work items.', 409); } const withoutEstimate = (item: Row): Row => { const { estimate: _estimate, reviewEstimate: _reviewEstimate, ...rest } = item; return rest; }; if (stable({ ...input.candidate, executionPlan: { ...record(input.candidate.executionPlan), workItems: candidateItems.map(withoutEstimate) } }) @@ -38,7 +44,8 @@ export function mergeAssignmentEstimate(input: { } if (candidateItems.some((item, itemIndex) => stable(reviewer ? item.estimate : item.reviewEstimate) !== stable(reviewer ? frozenItems[itemIndex].estimate : frozenItems[itemIndex].reviewEstimate) - || (!reviewer && itemIndex !== index && stable(item.estimate) !== stable(frozenItems[itemIndex].estimate)))) { + || (!targets.includes(itemIndex) && stable(item[reviewer ? 'reviewEstimate' : 'estimate']) + !== stable(frozenItems[itemIndex][reviewer ? 'reviewEstimate' : 'estimate'])))) { throw new CapacityGovernanceError('assignment_estimate_unrelated_change', 'Estimator result changed another work item estimate.', 409); } if (currentItems.some((item, itemIndex) => item.id !== frozenItems[itemIndex].id @@ -46,7 +53,6 @@ export function mergeAssignmentEstimate(input: { throw new CapacityGovernanceError('assignment_estimate_source_changed', 'Current proposal work items no longer match the frozen assignment source.', 409); } const field = reviewer ? 'reviewEstimate' : 'estimate'; - const targets = reviewer ? frozenItems.map((_, itemIndex) => itemIndex) : [index]; for (const target of targets) { const estimate = record(candidateItems[target][field]); if (!Object.keys(estimate).length || !text(estimate.rationale)) throw new CapacityGovernanceError( @@ -61,7 +67,7 @@ export function mergeAssignmentEstimate(input: { } export async function integrateAssignmentEstimate( - store: AssignmentPlanningOutputStore, assignment: DurableProviderAssignment, result: AssignmentResult, + store: EstimateIntegrationStore, assignment: DurableProviderAssignment, result: AssignmentResult, ): Promise { if (assignment.assignmentAttempt?.effectiveProfile.activity !== 'estimating') return; const attempt = assignment.assignmentAttempt; @@ -99,7 +105,7 @@ export async function integrateAssignmentEstimate( 'Estimator proposal content is invalid.', 409, { diagnostics: parsed.diagnostics }); const candidate = parsed.data as Row; const merged = mergeAssignmentEstimate({ frozen: frozen.definition, candidate, current: current.definition, - workItemId: attempt.workItemId ?? null }); + agentClass: attempt.agentClass }); if (stable(merged) === stable(current.definition)) return; const allEstimated = workItems(merged).every((item) => Object.keys(record(item.estimate)).length > 0 && (item.review !== 'required' || Object.keys(record(item.reviewEstimate)).length > 0)); @@ -110,7 +116,7 @@ export async function integrateAssignmentEstimate( objectiveRefs: merged.objectiveRefs, evidenceRefs: merged.evidenceRefs, discussionRef: merged.discussionRef, executionPlan: merged.executionPlan, workdayId: assignment.workDayId, expectedProposalVersion: proposal.activeVersion, - changeReason: `Integrate ${attempt.workItemId ?? 'review'} estimate from assignment ${assignment.id}.` } }); + changeReason: `Integrate ${attempt.agentClass} estimates from assignment ${assignment.id}.` } }); await store.updateGovernanceProposalDraft({ id: assignment.agentId, type: 'agent' }, text(proposal.id), { ...authored.update, createdByType: 'agent', createdById: assignment.agentId }); } diff --git a/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts b/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts index a81be063..710deaf1 100644 --- a/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts +++ b/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts @@ -28,11 +28,13 @@ const stable = (value: unknown): string => { }; const id = (prefix: string, values: unknown[]) => `${prefix}_${createHash('sha256').update(stable(values)).digest('base64url').slice(0, 32)}`; -function selectProvider(requiredCapabilities: string[], providers: ProviderSynthesisExecutionProvider[], lanePurpose: 'workday' | 'communication') { +function eligibleProviders(requiredCapabilities: string[], providers: ProviderSynthesisExecutionProvider[], lanePurpose: 'workday' | 'communication') { if (!requiredCapabilities.length) throw new CapacityGovernanceError( 'capacity_execution_capabilities_required', 'An executable node must declare its provider capability demand before admission.', 409, ); + const eligible: Array<{ provider: ProviderSynthesisExecutionProvider; lane: ProviderSynthesisExecutionProvider['lanes'][number]; + offer: ProviderSynthesisExecutionProvider['offers'][number] }> = []; for (const provider of [...providers].sort((left, right) => left.id.localeCompare(right.id))) { if (!['available', 'idle', 'normal'].includes(provider.status) || (provider.availableConcurrency ?? 1) < 1) continue; if (!requiredCapabilities.every((capability) => provider.capabilities.includes(capability))) continue; @@ -45,10 +47,11 @@ function selectProvider(requiredCapabilities: string[], providers: ProviderSynth const capabilities = candidate.capabilities.map((capability) => capability.id); return requiredCapabilities.every((capability) => capabilities.includes(capability)); }).sort((left, right) => left.capabilities.length - right.capabilities.length || left.offerId.localeCompare(right.offerId))[0]; - if (lane && offer) return { provider, lane, offer }; + if (lane && offer) eligible.push({ provider, lane, offer }); } - throw new CapacityGovernanceError('capacity_execution_provider_unavailable', + if (!eligible.length) throw new CapacityGovernanceError('capacity_execution_provider_unavailable', 'No advertised provider runtime satisfies the ready execution node.', 409, { requiredCapabilities }); + return eligible; } function uniqueReferences(values: ExactEntityReference[]): ExactEntityReference[] { @@ -117,12 +120,20 @@ function workspace(candidate: ReadyExecutionNode, assignmentId: string, exactGra : [reference.path ?? '**']; if (!writablePaths.length) throw new CapacityGovernanceError('assignment_workspace_write_scope_missing', `Node ${candidate.node.id} has no exact writable paths for its ${candidate.node.workspace} workspace.`, 409); - const priorCandidate = candidate.node.pairRole === 'actor' && candidate.node.nodeRevision > 1 - ? candidate.predecessorResults.flatMap((result) => result.references) - .find((item) => item.kind === 'git' && item.repository === reference.repository) - : undefined; + const predecessorCommits = candidate.node.workspace === 'git' + ? [...new Set(candidate.predecessorResults.flatMap((result) => result.references) + .filter((item) => item.kind === 'git' && item.repository === reference.repository) + .map((item) => item.commit))] + : []; + const explicitIntegration = predecessorCommits.length > 1 + && candidate.node.kind === 'acting' && candidate.node.pairRole === 'actor' + && candidate.node.agentClass === 'releaser' && exactGrant.tools.includes('release'); + if (predecessorCommits.length > 1 && !explicitIntegration) throw new CapacityGovernanceError( + 'assignment_git_integration_required', + `Node ${candidate.node.id} has multiple Git predecessor commits; an explicit integration assignment by a Releaser must establish one base.`, 409); return candidate.node.workspace === 'git' - ? { mode: 'git' as const, repository: reference.repository, baseCommit: priorCandidate?.commit ?? reference.commit, + ? { mode: 'git' as const, repository: reference.repository, + baseCommit: explicitIntegration ? reference.commit : predecessorCommits[0] ?? reference.commit, branch: assignmentSourceBranch(assignmentId), writablePaths } : { mode: 'treedx' as const, workspaceId: workdayTreeDxWorkspaceId(assignmentId), repository: reference.repository, baseCommit: reference.commit, writablePaths }; @@ -137,12 +148,13 @@ export function buildAssignmentAttempt(input: { allocationInputs: LivingAllocationInputs; attempt: number; now: string; -}): { assignment: AssignmentAttempt; allocation: ReturnType; accountingLimits: CapabilityAccountingLimits; +}): { assignment: AssignmentAttempt; allocation: ReturnType & { + opportunity: LivingAllocationInputs[string]['opportunity'] }; accountingLimits: CapabilityAccountingLimits; executionProviderId: string; laneId: string; lanePurpose: 'workday' | 'communication' } { const { candidate } = input; if (!candidate.node.estimate) throw new CapacityGovernanceError('execution_node_estimate_missing', 'Ready execution nodes require an estimate.', 409); const communication = candidate.node.kind === 'communication'; - const selected = selectProvider(candidate.node.requiredCapabilities ?? [], input.providers, communication ? 'communication' : 'workday'); + const eligible = eligibleProviders(candidate.node.requiredCapabilities ?? [], input.providers, communication ? 'communication' : 'workday'); const assignmentId = id('assignment', [candidate.node.teamId,candidate.node.id,candidate.node.nodeRevision,input.attempt]); const appliedPlan = appliedWorkdaySchema.parse(input.run.parameters.appliedPlan); if (appliedPlan.executionMode !== input.run.executionMode) throw new CapacityGovernanceError( @@ -157,25 +169,32 @@ export function buildAssignmentAttempt(input: { const utcDayEnd = Date.parse(`${input.now.slice(0, 10)}T00:00:00.000Z`) + 86_400_000; const availableSeconds = candidate.node.kind === 'reporting' && appliedPlan.state === 'closing' ? candidate.node.estimate.maximumSeconds : Math.max(0, (Date.parse(windowEnd) - Date.parse(input.now)) / 1000 - preparationSeconds); - const limits = selected.provider.accountingLimits!; - const observation = selected.provider.accountingObservation!; const capability = candidate.node.requiredCapabilities![0]!; - const capabilityLimits = limits.capabilityLimits[capability]!; - const remaining = (dailyLimitSeconds: number, value: typeof observation.modelUsage | undefined) => value - ? remainingCapabilitySeconds({ now: input.now, maximumObservationAgeSeconds: 90, dailyLimitSeconds, - observation: value, ledgerActiveSeconds: 0, ledgerReservedSeconds: 0 }).availableSeconds : 0; - const allocationInputs = input.allocationInputs[selected.provider.id]; - if (!allocationInputs) throw new CapacityGovernanceError('capacity_assignment_allocation_deferred', 'No current allocation inputs exist for the selected provider.', 409); - const allocation = calculateAssignmentAllocation({ estimate: candidate.node.estimate, measurements: allocationInputs.measurements, - constraints: [{ id: 'execution-window', remainingSeconds: availableSeconds }, - { id: 'utc-day-window', remainingSeconds: Math.max(0, (utcDayEnd - Date.parse(input.now)) / 1000 - preparationSeconds) }, - { id: 'model-day', remainingSeconds: remaining(limits.dailyActiveSecondsLimit, observation.modelUsage) }, - { id: 'capability-day', remainingSeconds: remaining(capabilityLimits.dailyActiveSecondsLimit, observation.capabilityUsage[capability]) }, ...allocationInputs.constraints], - providerMinimumSeconds: capabilityLimits.minimumAssignmentSeconds, - providerMaximumSeconds: capabilityLimits.maximumAssignmentSeconds, - ...(planningTurn ? { planningTurnMaximumSeconds: appliedPlan.policySnapshot.planningTurnMaximumSeconds } : {}) }); - if (!allocation.admitted) throw new CapacityGovernanceError('capacity_assignment_allocation_deferred', - 'The remaining execution window cannot fit the viable task minimum.', 409, { nodeId: candidate.node.id, allocation }); + const considered = eligible.flatMap((selected) => { + const allocationInputs = input.allocationInputs[selected.provider.id]; + if (!allocationInputs) return []; + const limits = selected.provider.accountingLimits!; + const observation = selected.provider.accountingObservation!; + const capabilityLimits = limits.capabilityLimits[capability]!; + const remaining = (dailyLimitSeconds: number, value: typeof observation.modelUsage | undefined) => value + ? remainingCapabilitySeconds({ now: input.now, maximumObservationAgeSeconds: 90, dailyLimitSeconds, + observation: value, ledgerActiveSeconds: 0, ledgerReservedSeconds: 0 }).availableSeconds : 0; + const allocation = calculateAssignmentAllocation({ estimate: candidate.node.estimate, measurements: allocationInputs.measurements, + constraints: [{ id: 'execution-window', remainingSeconds: availableSeconds }, + { id: 'utc-day-window', remainingSeconds: Math.max(0, (utcDayEnd - Date.parse(input.now)) / 1000 - preparationSeconds) }, + { id: 'model-day', remainingSeconds: remaining(limits.dailyActiveSecondsLimit, observation.modelUsage) }, + { id: 'capability-day', remainingSeconds: remaining(capabilityLimits.dailyActiveSecondsLimit, observation.capabilityUsage[capability]) }, ...allocationInputs.constraints], + providerMinimumSeconds: capabilityLimits.minimumAssignmentSeconds, + providerMaximumSeconds: capabilityLimits.maximumAssignmentSeconds, + ...(planningTurn ? { planningTurnMaximumSeconds: appliedPlan.policySnapshot.planningTurnMaximumSeconds } : {}) }); + return [{ selected, allocation, allocationInputs }]; + }); + const admitted = considered.find(({ allocation }) => allocation.admitted); + if (!admitted) throw new CapacityGovernanceError('capacity_assignment_allocation_deferred', + 'The remaining execution window cannot fit the viable task minimum.', 409, + { nodeId: candidate.node.id, providers: considered.map(({ selected, allocation }) => ({ providerId: selected.provider.id, allocation })) }); + const { selected, allocation, allocationInputs } = admitted; + const limits = selected.provider.accountingLimits!; const deadline = compileAssignmentTimeBudget({ now: input.now, requestedSeconds: allocation.allocatedSeconds, configuredBudget: candidate.node.kind === 'reporting' && appliedPlan.state === 'closing' ? {} : { deadline: windowEnd } }).authorityExpiresAt; @@ -186,7 +205,8 @@ export function buildAssignmentAttempt(input: { const assignment = assignmentAttemptSchema.parse({ schemaVersion: 'treeseed.assignment-attempt/v1', id: assignmentId, idempotencyKey: assignmentId, teamId: candidate.node.teamId, projectId: candidate.node.projectId, workdayId: input.run.id, - nodeId: candidate.node.id, ...(candidate.node.workItemId ? { workItemId: candidate.node.workItemId } : {}), + nodeId: candidate.node.id, agentClass: candidate.node.agentClass, + ...(candidate.node.workItemId ? { workItemId: candidate.node.workItemId } : {}), nodeRevision: candidate.node.nodeRevision, graphRevision: candidate.graphRevision, sourceRef: candidate.node.sourceRef, authorityRefs: candidate.node.authorityRefs, effectiveProfile: candidate.effectiveProfile, diff --git a/src/api/capacity/services/capacity/assignments/planning/execution/assignment-workspace-provisioning.ts b/src/api/capacity/services/capacity/assignments/planning/execution/assignment-workspace-provisioning.ts deleted file mode 100644 index 65138e31..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/execution/assignment-workspace-provisioning.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { CapacityGovernanceError } from '../../../../../database.ts'; -import { CapacityWorkdayRunRepository } from '../../../../../repositories/capacity/workdays/workday-run.ts'; -import type { CapacityWorkdayDemandRepository } from '../../../../../repositories/capacity/workdays/workday-demand.ts'; -import type { ExecutionNodeAssignmentInput } from '../../../../build/execution-node-demand-compiler.ts'; -import type { assignmentInput } from '../assignment-function.ts'; -import type { AssignmentFunctionStore } from '../support/assignment-function-store.ts'; -import { persistIssuedWorkspaceAuthority } from '../workspace-authority-persistence.ts'; - -export async function provisionAssignmentWorkspace(store: AssignmentFunctionStore, - demand: NonNullable>> | ExecutionNodeAssignmentInput, - input: ReturnType, now: string) { - const run = await new CapacityWorkdayRunRepository(store).get(demand.teamId, demand.workdayRunId); - if (!run) throw new CapacityGovernanceError('capacity_workday_run_missing', 'Demand-owned workday run no longer exists.', 500, { demandId: demand.id }); - const workspace = await store.createCapacityWorkdayTreeDxWorkspace({ id: demand.projectId }, run, { - repositoryId: input.workspace.repositoryId, assignmentId: input.assignmentId, baseRef: input.workspace.baseRef, - branchName: `refs/heads/${input.assignmentId}`, mode: 'writable', allowedPaths: input.workspace.allowedPaths, - ttlSeconds: Math.max(1800, Number(run.parameters.durationSeconds ?? 600) + 1800), - }); - await persistIssuedWorkspaceAuthority({ store, assignmentId: input.assignmentId, proxyHandle: input.treedxProxyHandle, - workspaceContext: input.workspaceContext, workspace, now }); -} diff --git a/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts b/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts index 84e538ea..545c19cc 100644 --- a/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts +++ b/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts @@ -9,7 +9,7 @@ import { isProposalGovernanceReview, listReadyExecutionNodes } from '../../../.. import { capacityWorkdayRequestedProjectReferences, resolveCapacityWorkdayProjects } from '../../../workdays/policy/workday-project-policy.ts'; import { admitLivingExecutionAssignment } from '../../admission/living-execution-admission.ts'; import { buildAssignmentAttempt } from './assignment-attempt-builder.ts'; -import type { AssignmentFunctionStore } from '../support/assignment-function-store.ts'; +import type { LivingExecutionStore } from '../support/living-execution-store.ts'; import { livingAllocationInputs } from '../../admission/living-allocation-inputs.ts'; const record = (value: unknown): Record => { @@ -20,6 +20,14 @@ const record = (value: unknown): Record => { const unique = (values: Array) => [...new Set(values.filter((value): value is string => Boolean(value)))]; +export function workdayConcurrencyAvailable(kind: string, active: Record, policy: { + maximumConcurrency: number; communicationConcurrency: number; +}): boolean { + return kind === 'communication' + ? (active.conversation ?? 0) < policy.communicationConcurrency + : (active.workday ?? 0) < policy.maximumConcurrency; +} + export function isNodeEligibleInWorkdayPhase( node: Parameters[0], phase: 'planning' | 'acting', closing: boolean, ): boolean { @@ -42,7 +50,7 @@ export const treeDxAuthorizedPaths = (values: Array) => uniq : [path, `${path}.md`, `${path}.mdx`, `${path}.yaml`, `${path}.yml`, `${path}.json`]; }); -async function issueLivingTreeDxAuthority(store: AssignmentFunctionStore, run: Parameters[0]['run'], +async function issueLivingTreeDxAuthority(store: LivingExecutionStore, run: Parameters[0]['run'], assignment: ReturnType['assignment'], now: string) { const currentRepositoryId = assignment.workspace.mode === 'treedx' ? assignment.workspace.repository @@ -121,7 +129,7 @@ async function issueLivingTreeDxAuthority(store: AssignmentFunctionStore, run: P } export async function executionNodeAssignmentGeneration( - store: Pick, + store: Pick, teamId: string, nodeId: string, nodeRevision: number, @@ -133,7 +141,7 @@ export async function executionNodeAssignmentGeneration( /** Claim one ready graph node without creating a demand or capacity-plan record. */ export async function assignNextReadyExecutionNode( - store: AssignmentFunctionStore, + store: LivingExecutionStore, principal: ProviderLeasePrincipal, providerSessionId: string, executionProviders: ProviderSynthesisExecutionProvider[], @@ -145,9 +153,9 @@ export async function assignNextReadyExecutionNode( if (!parsedPlan.success) continue; const appliedPlan = parsedPlan.data; if (appliedPlan.state === 'planned' || appliedPlan.state === 'ended') continue; - const activeRow = await store.first(`SELECT COUNT(*) AS active_count FROM capacity_provider_assignments - WHERE team_id=? AND work_day_id=? AND status IN ('pending','leased','running')`, [run.teamId,run.id]); - if (Number(activeRow?.active_count ?? 0) >= appliedPlan.policySnapshot.maximumConcurrency) continue; + const activeRows = await store.all(`SELECT execution_kind,COUNT(*) AS active_count FROM capacity_provider_assignments + WHERE team_id=? AND work_day_id=? AND status IN ('pending','leased','running') GROUP BY execution_kind`, [run.teamId,run.id]); + const activeByKind = Object.fromEntries(activeRows.map((row) => [String(row.execution_kind), Number(row.active_count)])); const projects = resolveCapacityWorkdayProjects( capacityWorkdayRequestedProjectReferences(run.parameters), await store.listTeamProjects(run.teamId), @@ -155,6 +163,7 @@ export async function assignNextReadyExecutionNode( const phase = workdayPhase(appliedPlan, now); const candidates = (await Promise.all(projects.map((project) => listReadyExecutionNodes(store, run, project)))).flat() .filter((candidate) => isNodeEligibleInWorkdayPhase(candidate.node, phase, appliedPlan.state === 'closing')) + .filter((candidate) => workdayConcurrencyAvailable(candidate.node.kind, activeByKind, appliedPlan.policySnapshot)) .filter((candidate) => appliedPlan.state === 'closing' || Date.parse(appliedPlan.endsAt) - Date.parse(now) >= (candidate.node.estimate?.minimumSeconds ?? 1) * 1_000); const prior = await store.all(`SELECT node.project_id,node.agent_class,reservation.active_seconds, @@ -167,7 +176,7 @@ export async function assignNextReadyExecutionNode( const remaining = [...candidates]; while (remaining.length) { const selectedNode = selectFairReadyNode(remaining.map((candidate) => ({ id: candidate.node.id, - projectId: candidate.node.projectId, agentClass: candidate.node.agentClass!, graphPriority: 0, + projectId: candidate.node.projectId, agentClass: candidate.node.agentClass!, readyAt: candidate.readyAt || now })), usage, policy); const candidate = remaining.find((item) => item.node.id === selectedNode?.id); if (!candidate) break; @@ -186,6 +195,8 @@ export async function assignNextReadyExecutionNode( const attempt = selected.assignment; const treedxProxyHandle = await issueLivingTreeDxAuthority(store, run, attempt, now); return await admitLivingExecutionAssignment(store, { principal, assignment: attempt, allocation: { ...selected.allocation, selection: selectedNode }, + workdayConcurrencyLimit: candidate.node.kind === 'communication' + ? appliedPlan.policySnapshot.communicationConcurrency : appliedPlan.policySnapshot.maximumConcurrency, accountingLimits: selected.accountingLimits, projectAgentClassId: candidate.projectAgentClassId, providerSessionId, executionProviderId: selected.executionProviderId, laneId: selected.laneId, diff --git a/src/api/capacity/services/capacity/assignments/planning/feedback/subject.ts b/src/api/capacity/services/capacity/assignments/planning/feedback/subject.ts deleted file mode 100644 index 9b0c00df..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/feedback/subject.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { CapacityGovernanceError } from '../../../../../database.ts'; -import type { AssignmentPlanningOutputStore } from '../assignment-planning-output-service.ts'; - -type Scope = { id: string; teamId: string; projectId: string }; -type Store = Pick; - -function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } - -/** Feedback must refer to the proposal revision actually placed in the assignment context. */ -export function reviewedProposalVersion(assignment: Scope & { decisionInput?: unknown }, proposal: Record) { - const intent = record(record(record(assignment.decisionInput).input).intent); - const artifacts = [intent.relatedArtifact, ...(Array.isArray(intent.relatedArtifacts) ? intent.relatedArtifacts : [])].map(record); - const provenance = record(record(proposal.metadata).contentProvenance); - const version = Number(proposal.activeVersion); - if (!Number.isSafeInteger(version) || version < 1 || typeof provenance.commitSha !== 'string' - || !/^[a-f0-9]{40}$/u.test(provenance.commitSha) || !artifacts.some(artifact => artifact.model === 'proposal' - && artifact.contentPath === provenance.contentPath && typeof artifact.commitSha === 'string' && /^[a-f0-9]{40}$/u.test(artifact.commitSha) - && (artifact.commitSha === provenance.commitSha || (typeof provenance.digest === 'string' && /^[a-f0-9]{64}$/u.test(provenance.digest) && artifact.digest === provenance.digest)))) { - throw new CapacityGovernanceError('assignment_proposal_feedback_revision_stale', - 'Proposal feedback was not produced from the current immutable proposal revision.', 409, { assignmentId: assignment.id }); - } - return version; -} - -/** Resolve custody of an existing proposal; human and agent IDs are equally authoritative. */ -export async function resolveProposalFeedbackSubject(store: Store, assignment: Scope, subject: string) { - const invalid = () => new CapacityGovernanceError('assignment_proposal_feedback_scope_invalid', - 'Proposal feedback requires one existing proposal in the assignment team and project.', 409, - { assignmentId: assignment.id }); - if (!subject.trim()) throw invalid(); - let proposal = await store.getGovernanceProposal(subject.trim()); - if (!proposal) { - const contentSlug = subject.trim().replace(/^proposal:/u, '').replace(/^.*\//u, '').replace(/\.(?:md|mdx)$/iu, ''); - const matches = await store.all(`SELECT id FROM governance_proposals WHERE team_id = ? AND project_id = ? AND content_proposal_slug = ? LIMIT 2`, - [assignment.teamId, assignment.projectId, contentSlug]); - if (matches.length !== 1) throw invalid(); - proposal = await store.getGovernanceProposal(String(matches[0].id)); - } - if (!proposal || proposal.teamId !== assignment.teamId || proposal.projectId !== assignment.projectId) throw invalid(); - return proposal; -} diff --git a/src/api/capacity/services/capacity/assignments/planning/support/assignment-denial.ts b/src/api/capacity/services/capacity/assignments/planning/support/assignment-denial.ts deleted file mode 100644 index 1286ca77..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/support/assignment-denial.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { CapacityGovernanceDatabase } from '../../../../../database.ts'; -import { CapacityAuditRepository } from '../../../../../repositories/support/audit.ts'; -import type { ProviderLeasePrincipal } from '../../../../accounts/lease-authority-service.ts'; -import { assignmentErrorCode, type AssignmentJsonRecord } from './assignment-function-support.ts'; - -export async function recordAssignmentDenial( - store: CapacityGovernanceDatabase, - principal: ProviderLeasePrincipal, - demandId: string, - error: unknown, - now: string, -) { - const code = assignmentErrorCode(error); - const details = error && typeof error === 'object' && 'details' in error - && error.details && typeof error.details === 'object' && !Array.isArray(error.details) - ? error.details as AssignmentJsonRecord - : {}; - await new CapacityAuditRepository(store).record({ - id: `audit:${demandId}:${code}`, teamId: principal.teamId, providerId: principal.capacityProviderId, - membershipId: principal.membershipId, actorType: 'provider-membership', actorId: principal.membershipId, - action: 'assignment-function.denied', resourceType: 'capacity-workday-demand', resourceId: demandId, - idempotencyKey: `${demandId}:${code}`, - metadata: { reasons: [code], message: error instanceof Error ? error.message : String(error), details }, - now, - }); -} diff --git a/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-support.ts b/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-support.ts deleted file mode 100644 index b07dd0e7..00000000 --- a/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-support.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createHash } from 'node:crypto'; - -export type AssignmentJsonRecord = Record; - -export function assignmentRecord(value: unknown): AssignmentJsonRecord { - if (typeof value === 'string') { - try { return assignmentRecord(JSON.parse(value)); } catch { return {}; } - } - return value && typeof value === 'object' && !Array.isArray(value) ? value as AssignmentJsonRecord : {}; -} - -export function assignmentText(value: unknown, fallback = ''): string { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -export function deterministicAssignmentId(demandId: string, generation: number): string { - return `assignment_${createHash('sha256').update(`${demandId}:${generation}`).digest('base64url').slice(0, 32)}`; -} - -export function assignmentErrorCode(error: unknown): string { - return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' - ? error.code : 'capacity_admission_denied'; -} diff --git a/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts b/src/api/capacity/services/capacity/assignments/planning/support/living-execution-store.ts similarity index 87% rename from src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts rename to src/api/capacity/services/capacity/assignments/planning/support/living-execution-store.ts index e79d45b2..90eaf947 100644 --- a/src/api/capacity/services/capacity/assignments/planning/support/assignment-function-store.ts +++ b/src/api/capacity/services/capacity/assignments/planning/support/living-execution-store.ts @@ -2,9 +2,9 @@ import type { CapacityGovernanceDatabase } from '../../../../../database.ts'; import type { DurableProviderAssignment } from '../../../../../repositories/capacity/assignments/assignment.ts'; import type { DurableCapacityWorkdayRun } from '../../../../../repositories/capacity/workdays/workday-run.ts'; import type { ConfiguredWorkspaceInput } from '../../../workdays/treedx/workday-treedx-workspace-service.ts'; -import type { AssignmentJsonRecord as JsonRecord } from './assignment-function-support.ts'; +type JsonRecord = Record; -export interface AssignmentFunctionStore extends CapacityGovernanceDatabase { +export interface LivingExecutionStore extends CapacityGovernanceDatabase { getProject(projectId: string): Promise; getProjectByTeamAndSlug(teamId: string, slug: string): Promise; getProjectTreeDxLibrary(projectId: string): Promise; diff --git a/src/api/capacity/services/capacity/execution/exact-dependency-links.ts b/src/api/capacity/services/capacity/execution/exact-dependency-links.ts new file mode 100644 index 00000000..0aaf326d --- /dev/null +++ b/src/api/capacity/services/capacity/execution/exact-dependency-links.ts @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto'; +import { exactDependencyLinkSchema, validatePortableContentData } from '@treeseed/sdk/content-validation'; +import { resolveKnowledgeGatewayConnection } from '../../../../knowledge/gateway-treedx-connection.ts'; +import type { ExecutableProposalSource, VerifiedDependencyLink } from '../../../policy/execution/execution-graph-projector.ts'; + +type Row = Record; +const record = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; +const text = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; +const exactCommit = /^[a-f0-9]{40}$/u; + +/** Read relation notes from each granted project library at a pinned TreeDX commit. */ +export async function loadTeamExactDependencyLinks(store: any, sources: ExecutableProposalSource[]): Promise { + const projects = [...new Map(sources.map((source) => [source.projectId, source])).values()]; + const selected = new Set(sources.map((source) => `${source.repository}:${source.commit}:${source.path}:${source.frontmatter.id}`)); + const sourceByRef = new Map(sources.map((source) => [`${source.repository}:${source.commit}:${source.path}:${source.frontmatter.id}`, source])); + const grantedEndpoints = new Set(); + const requireEndpointGrant = async (key: string) => { + if (grantedEndpoints.has(key)) return; + const source = sourceByRef.get(key); + if (!source) throw new Error('TreeDX dependency endpoint is absent from the selected team graph.'); + const grant = await resolveKnowledgeGatewayConnection(store, { + projectId: source.projectId, write: false, relationPaths: true, readRefs: [source.commit], + }); + const allowed = grant?.allowedPaths.some((pattern) => pattern === '**' || pattern === source.path + || (pattern.endsWith('/**') && source.path.startsWith(pattern.slice(0, -3) + '/'))); + if (!grant || grant.repositoryId !== source.repository || !allowed) { + throw new Error(`TreeDX exact dependency endpoint read grant is missing for project ${source.projectId}.`); + } + grantedEndpoints.add(key); + }; + const links: VerifiedDependencyLink[] = []; + for (const project of projects) { + const connection = await resolveKnowledgeGatewayConnection(store, { + projectId: project.projectId, write: false, relationPaths: true, authoringPaths: true, + }); + if (!connection || connection.repositoryId !== project.repository) throw new Error(`TreeDX read grant is missing for project ${project.projectId}.`); + const response = record(await connection.client.queryGraph({ repoId: connection.repositoryId, + ref: connection.publicationRef, seeds: [{ kind: 'type', value: 'ExactEntityReference' }], + relations: ['depends_on'], options: { direction: 'both', depth: 1, maxNodes: 50 } })); + const commit = text(response.resolvedRef); + if (!exactCommit.test(commit)) throw new Error('TreeDX did not pin the dependency graph to an exact commit.'); + const pinned = await resolveKnowledgeGatewayConnection(store, { + projectId: project.projectId, write: false, relationPaths: true, readRefs: [commit], + }); + if (!pinned || pinned.repositoryId !== project.repository) throw new Error(`TreeDX exact dependency read grant is missing for project ${project.projectId}.`); + const nodes = response.nodes; + if (!Array.isArray(nodes) || nodes.length >= 50 || !Array.isArray(response.edges)) { + throw new Error('TreeDX dependency graph was invalid or truncated.'); + } + const files = new Map(); + for (const rawEdge of response.edges) { + const edge = record(rawEdge); + if (edge.type !== 'DEPENDS_ON') continue; + const data = record(edge.data); + const parsed = exactDependencyLinkSchema.safeParse(data.link); + const path = text(data.ownerPath); + if (!parsed.success || !path) throw new Error('TreeDX returned an invalid exact dependency edge.'); + const link = parsed.data; + const from = `${link.from.repository}:${link.from.commit}:${link.from.path}:${link.from.id}`; + const to = `${link.to.repository}:${link.to.commit}:${link.to.path}:${link.to.id}`; + if (!selected.has(from) || !selected.has(to)) continue; + await requireEndpointGrant(from); + await requireEndpointGrant(to); + let file = files.get(path); + if (!file) { + const reply = record(await pinned.client.readRepositoryFile({ repoId: pinned.repositoryId, + ref: commit, path, encoding: 'utf8', parseFrontmatter: true, allowProtected: true })); + if (text(reply.resolvedRef) !== commit) throw new Error('TreeDX dependency note moved during read.'); + const value = record(reply.file ?? (Array.isArray(reply.files) ? reply.files[0] : null)); + const content = text(value.content); + const validated = validatePortableContentData('note', record(value.frontmatter)); + if (!validated.ok || !validated.data || validated.data.projectId !== project.projectId) { + throw new Error('TreeDX dependency note failed content and project validation.'); + } + file = { sourceRef: { store: 'treedx', model: 'note', id: validated.data.id, + repository: project.repository, commit, path, + digest: `sha256:${createHash('sha256').update(content).digest('hex')}` }, + links: validated.data.links ?? [] }; + files.set(path, file); + } + if (!file.links.some((candidate) => JSON.stringify(candidate) === JSON.stringify(link))) { + throw new Error('TreeDX dependency edge is not present in its exact note.'); + } + links.push({ from: link.from, to: link.to, sourceRef: file.sourceRef }); + } + } + return [...new Map(links.map((link) => [`${link.from.repository}:${link.from.id}:${link.from.anchor}:${link.to.repository}:${link.to.id}:${link.to.anchor}`, link])).values()]; +} diff --git a/src/api/capacity/services/capacity/execution/executable-proposal-source.ts b/src/api/capacity/services/capacity/execution/executable-proposal-source.ts index 3f1c43d9..3347c2c8 100644 --- a/src/api/capacity/services/capacity/execution/executable-proposal-source.ts +++ b/src/api/capacity/services/capacity/execution/executable-proposal-source.ts @@ -39,12 +39,10 @@ export async function loadTeamExecutableProposalSources(store: any, teamId: stri try { exact = await readExactProposal(store, { ...row, id: row.proposal_id }); } catch (error) { // An unaccepted, non-executable draft is governed history, not graph - // demand. An accepted proposal whose executable contract is invalid - // is likewise quarantined: it contributes no demand and its prior - // nodes become stale, without blocking unrelated team work. + // demand. An accepted source must never disappear silently: doing so + // would stale its existing graph nodes without a new decision. if (!accepted) continue; const value = error as { status?: number; code?: string }; - if (value.code === 'proposal_execution_plan_invalid') continue; throw Object.assign(new CapacityOperationError(Number(value.status ?? 409), value.code ?? 'proposal_execution_plan_invalid', error instanceof Error ? error.message : 'The accepted proposal could not be read.'), { diagnostics: (error as { diagnostics?: unknown }).diagnostics }); } diff --git a/src/api/capacity/services/capacity/invocations/discussion-invocation-service.ts b/src/api/capacity/services/capacity/invocations/discussion-invocation-service.ts index db153393..cc641ddb 100644 --- a/src/api/capacity/services/capacity/invocations/discussion-invocation-service.ts +++ b/src/api/capacity/services/capacity/invocations/discussion-invocation-service.ts @@ -215,7 +215,7 @@ async function communicationSupply(store: DiscussionInvocationStore, teamId: str const minimumSeconds = Math.max(0, ...durations.map((duration) => evaluateMinimumAssignmentDuration(duration, now).minimumWindowSeconds)); const maxConcurrentWorkers = Math.max(1, Number(communicationLane.maxConcurrentWorkers ?? provider.maxConcurrentWorkers ?? 1) || 1); return { ...candidate, minimumSeconds, maxConcurrentWorkers, - providerSourceClosureDigest: text(record(session?.metadata_json).sourceClosureDigest) || null }; + providerRuntimeBuild: text(record(session?.metadata_json).runtimeBuild) || null }; } return null; } @@ -255,8 +255,8 @@ async function persistInvocation(store: DiscussionInvocationStore, input: Discus let status = text(existing.status); if (executionId && ['admitted', 'running'].includes(status)) { const execution = await store.first(`SELECT status FROM capacity_workday_runs WHERE id = ? AND team_id = ? LIMIT 1`, [executionId, input.teamId]); - const useful = await store.first(`SELECT id FROM capacity_provider_assignments WHERE invocation_id = ? AND team_id = ? AND status IN ('pending','leased') LIMIT 1`, [existing.id, input.teamId]) - ?? await store.first(`SELECT id FROM capacity_workday_demands WHERE workday_run_id = ? AND status IN ('queued','claimed','admitted') LIMIT 1`, [executionId]); + const useful = await store.first(`SELECT id FROM capacity_provider_assignments WHERE invocation_id = ? AND team_id = ? AND status IN ('pending','leased','running') LIMIT 1`, [existing.id, input.teamId]) + ?? await store.first(`SELECT id FROM execution_nodes WHERE team_id = ? AND workday_id = ? AND kind = 'communication' AND status = 'ready' LIMIT 1`, [input.teamId, executionId]); const updatedAt = Date.parse(text(existing.updated_at)); const admissionIsFresh = Number.isFinite(updatedAt) && Date.now() - updatedAt < 60_000; if ((!execution && !admissionIsFresh) || ['failed', 'cancelled'].includes(text(execution?.status)) || (!useful && !admissionIsFresh)) { @@ -363,7 +363,7 @@ export async function admitDiscussionInvocations(store: DiscussionInvocationStor capacityProviderId: text(supply.capacity_provider_id), scenarioId: `conversation:${input.discussionId}:${agentSlug}`, environment: 'local', executionKind: 'conversation', triggerKind: input.triggerKind ?? 'discussion', hidden: true, status: 'running', startedAt: new Date().toISOString(), parameters: { durationSeconds: effectiveSeconds, maxActiveAssignments: 1, planningPercent: 0, projectSlugs: [input.projectSlug], scheduledProjectIds: [input.projectId], - providerSourceClosureDigest: supply.providerSourceClosureDigest, + providerRuntimeBuild: supply.providerRuntimeBuild, agentSelection: { agentSlugs: [agentSlug], activityTypes: ['chat'], classIds: [], classSlugs: [], mode: 'intersection' }, discussion: { discussionId: input.discussionId, messageId: input.messageId, messagePath: input.messagePath, commitSha: input.messageCommit, contextRefs: input.contextRefs, invocationId: invocation.id, parentAssignmentId: invocation.parentAssignmentId ?? null, handoffRootId: invocation.handoffRootId ?? null, handoffParentId: invocation.handoffParentId ?? null, handoffDepth: invocation.handoffDepth ?? 0 }, }, requestedById: input.requestedById ?? null, diff --git a/src/api/capacity/services/capacity/planning/agent-capacity-plan-service.ts b/src/api/capacity/services/capacity/planning/agent-capacity-plan-service.ts deleted file mode 100644 index cc430cb2..00000000 --- a/src/api/capacity/services/capacity/planning/agent-capacity-plan-service.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { -type AgentCapacityPlanRecord, -type DecisionExecutionInputRecord, -type DurableAgentCapacityPlanStatus, -} from '@treeseed/sdk/agent-capacity'; -import { buildAgentCapacityPlanDraft } from '../../../policy/planning.ts'; -import type { CapacityGovernanceDatabase } from '../../../database.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { -AgentCapacityPlanRepository, -type AgentCapacityPlanTransitionInput, -} from '../../../repositories/capacity/planning/agent-capacity-plan.ts'; - -type JsonRecord = Record; - -interface AgentCapacityPlanStore extends CapacityGovernanceDatabase { - getProject(projectId: string): Promise<{ id: string; teamId: string } | null>; - listDecisionExecutionInputs(decisionId: string, filters: { status: 'accepted' }): Promise; - scopeHash(value: unknown): string; - upsertDecisionPlanningStatus(input: { - projectId: string; - decisionId: string; - humanApprovalState: string; - executionReadiness: 'ready' | 'blocked'; - planningInputsStatus: 'complete'; - scopeHash: string; - metadata: JsonRecord; - }): Promise; -} - -export interface CreateAgentCapacityPlanInput { - id?: string; - projectId: string; - scopeHash?: string; - allocationSetId?: string | null; - workDayId?: string | null; - status?: DurableAgentCapacityPlanStatus; - humanApprovalState?: string; - review?: JsonRecord; - metadata?: JsonRecord; - decisionExecutionInputIds?: string[]; - idempotencyKey?: string; -} - -type MutationReceipt = { digest: string; status?: DurableAgentCapacityPlanStatus }; - -function records(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function idPart(value: string, fallback = 'item') { - return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').replace(/^-+|-+$/gu, '') || fallback; -} - -function readiness(status: DurableAgentCapacityPlanStatus) { - return ['accepted', 'scheduled', 'active'].includes(status) ? 'ready' as const : 'blocked' as const; -} - -function isActivePlan(plan: AgentCapacityPlanRecord) { - return ['accepted', 'scheduled', 'active'].includes(plan.status); -} - -export class AgentCapacityPlanService { - private readonly repository: AgentCapacityPlanRepository; - - constructor(private readonly store: AgentCapacityPlanStore) { - this.repository = new AgentCapacityPlanRepository(store); - } - - get(planId: string) { - return this.repository.get(planId); - } - - list(decisionId: string, filters: { status?: DurableAgentCapacityPlanStatus | null } = {}) { - return this.repository.list(decisionId, filters); - } - - async create(decisionId: string, input: CreateAgentCapacityPlanInput): Promise { - const project = await this.store.getProject(input.projectId); - if (!project) return null; - const acceptedInputs = await this.store.listDecisionExecutionInputs(decisionId, { status: 'accepted' }); - const requestedIds = Array.isArray(input.decisionExecutionInputIds) - ? [...new Set(input.decisionExecutionInputIds.map(String).map((id) => id.trim()).filter(Boolean))] - : []; - const executionInputs = requestedIds.length - ? acceptedInputs.filter((entry) => requestedIds.includes(entry.id)) - : acceptedInputs; - const missingInputIds = requestedIds.filter((id) => !executionInputs.some((entry) => entry.id === id)); - if (missingInputIds.length) { - throw new CapacityGovernanceError('agent_capacity_plan_execution_input_missing', 'Requested accepted decision execution inputs are unavailable.', 409, { decisionId, projectId: project.id, missingInputIds }); - } - if (executionInputs.length === 0) { - throw new CapacityGovernanceError('agent_capacity_plan_execution_input_required', 'At least one accepted decision execution input is required to build a capacity plan.', 409, { decisionId, projectId: project.id }); - } - const actingWithoutGraphNode = executionInputs.find((entry) => entry.mode === 'acting' && !entry.input.workGraphNodeId?.trim()); - if (actingWithoutGraphNode) { - throw new CapacityGovernanceError( - 'agent_capacity_plan_work_graph_node_required', - 'Accepted acting execution input requires workGraphNodeId provenance before capacity planning.', - 409, - { decisionId, projectId: project.id, decisionExecutionInputId: actingWithoutGraphNode.id }, - ); - } - for (const entry of executionInputs.filter((candidate) => candidate.mode === 'acting')) { - const estimateId = typeof entry.input.estimateId === 'string' && entry.input.estimateId.trim() - ? entry.input.estimateId.trim() - : typeof entry.metadata?.estimateId === 'string' ? entry.metadata.estimateId.trim() : ''; - const contributingEstimateIds = Array.isArray(entry.metadata?.contributingEstimateIds) - ? [...new Set(entry.metadata.contributingEstimateIds.map(String).map((id) => id.trim()).filter(Boolean))].sort() - : estimateId ? [estimateId] : []; - if (contributingEstimateIds.length === 0) throw new CapacityGovernanceError('agent_capacity_plan_accepted_estimate_required', 'Every acting execution input requires accepted estimate provenance for the same decision and project.', 409, { decisionId, projectId: project.id, decisionExecutionInputId: entry.id }); - for (const contributingEstimateId of contributingEstimateIds) { - const estimate = await this.store.first(`SELECT id FROM structured_agent_estimates - WHERE id = ? AND team_id = ? AND project_id = ? AND decision_id = ? AND status = 'accepted' LIMIT 1`, - [contributingEstimateId, project.teamId, project.id, decisionId]); - if (!estimate) throw new CapacityGovernanceError('agent_capacity_plan_accepted_estimate_required', 'Every contributing estimate must remain accepted for the same decision and project.', 409, { decisionId, projectId: project.id, decisionExecutionInputId: entry.id, estimateId: contributingEstimateId }); - } - } - const now = new Date().toISOString(); - const scopeHash = input.scopeHash ?? this.store.scopeHash({ - decisionId, - projectId: project.id, - executionInputIds: executionInputs.map((entry) => entry.id).sort(), - allocationSetId: input.allocationSetId ?? null, - workDayId: input.workDayId ?? null, - }); - const idempotencyKey = input.idempotencyKey?.trim() || null; - const requestDigest = this.store.scopeHash({ - decisionId, projectId: project.id, scopeHash, allocationSetId: input.allocationSetId ?? null, - workDayId: input.workDayId ?? null, status: input.status ?? 'draft', humanApprovalState: input.humanApprovalState ?? 'approved', - review: input.review ?? {}, metadata: input.metadata ?? {}, executionInputIds: executionInputs.map((entry) => entry.id).sort(), - }); - const plan = buildAgentCapacityPlanDraft({ - id: input.id ?? `acp_${idPart(decisionId)}_${idPart(scopeHash)}`, - teamId: project.teamId, - projectId: project.id, - decisionId, - scopeHash, - executionInputs, - allocationSetId: input.allocationSetId ?? null, - workDayId: input.workDayId ?? null, - status: input.status ?? 'draft', - metadata: { - ...(input.metadata ?? {}), - source: 'accepted_decision_execution_inputs', - executionInputIds: executionInputs.map((entry) => entry.id), - ...(idempotencyKey ? { creationIdempotencyKey: idempotencyKey, creationRequestDigest: requestDigest } : {}), - }, - now, - }); - const existing = await this.repository.get(plan.id); - if (existing) { - const priorKey = typeof existing.metadata.creationIdempotencyKey === 'string' ? existing.metadata.creationIdempotencyKey : null; - const priorDigest = typeof existing.metadata.creationRequestDigest === 'string' ? existing.metadata.creationRequestDigest : null; - if (priorDigest === requestDigest) return existing; - if (idempotencyKey && priorKey === idempotencyKey) { - throw new CapacityGovernanceError('agent_capacity_plan_idempotency_conflict', 'The capacity-plan idempotency key was already used with different input.', 409, { planId: plan.id, idempotencyKey }); - } - throw new CapacityGovernanceError('agent_capacity_plan_identity_conflict', 'The derived capacity-plan identity already exists with different content.', 409, { planId: plan.id }); - } - const persisted = await this.repository.upsert(plan, input.review ?? {}); - if (!persisted) throw new CapacityGovernanceError('agent_capacity_plan_persistence_failed', 'Agent capacity plan was not persisted.', 500, { planId: plan.id }); - await this.recordPlanningStatus(persisted, input.humanApprovalState ?? 'approved'); - return persisted; - } - - async transition(planId: string, status: DurableAgentCapacityPlanStatus, input: AgentCapacityPlanTransitionInput = {}) { - const existing = await this.repository.get(planId); - if (!existing) return null; - const idempotencyKey = input.idempotencyKey?.trim() || null; - const requestDigest = this.store.scopeHash({ - planId, status, workDayId: input.workDayId ?? null, allocationSetId: input.allocationSetId ?? null, - reason: input.reason ?? null, review: input.review ?? {}, metadata: input.metadata ?? {}, - }); - const receipts = records(existing.metadata.transitionReceipts); - if (idempotencyKey && receipts[idempotencyKey]) { - if (receipts[idempotencyKey]!.digest !== requestDigest) throw new CapacityGovernanceError( - 'agent_capacity_plan_idempotency_conflict', 'The capacity-plan idempotency key was already used with different input.', 409, - { planId, idempotencyKey }, - ); - return existing; - } - const updated = await this.repository.transition(planId, status, { - ...input, - metadata: { - ...(input.metadata ?? {}), - ...(idempotencyKey ? { transitionReceipts: { ...receipts, [idempotencyKey]: { digest: requestDigest, status } } } : {}), - }, - }); - if (!updated) return null; - const authoritative = isActivePlan(updated) - ? updated - : (await this.repository.list(updated.decisionId)).find(isActivePlan) ?? updated; - await this.recordPlanningStatus(authoritative, 'approved'); - return updated; - } - - private async recordPlanningStatus(plan: AgentCapacityPlanRecord, humanApprovalState: string) { - await this.store.upsertDecisionPlanningStatus({ - projectId: plan.projectId, - decisionId: plan.decisionId, - humanApprovalState, - executionReadiness: readiness(plan.status), - planningInputsStatus: 'complete', - scopeHash: plan.scopeHash, - metadata: { source: 'agent_capacity_plan', latestCapacityPlanId: plan.id, latestCapacityPlanStatus: plan.status }, - }); - } -} diff --git a/src/api/capacity/services/capacity/workdays/assignments/workday-assignment-context-service.ts b/src/api/capacity/services/capacity/workdays/assignments/workday-assignment-context-service.ts index 5b40dc1c..cf80cdd8 100644 --- a/src/api/capacity/services/capacity/workdays/assignments/workday-assignment-context-service.ts +++ b/src/api/capacity/services/capacity/workdays/assignments/workday-assignment-context-service.ts @@ -107,10 +107,9 @@ export async function listCapacityWorkdayContentArtifactRefs( const rows = await store.all( `SELECT assignment.id, assignment.lifecycle_output_json AS outputs_json FROM capacity_provider_assignments assignment - JOIN capacity_workday_demands demand ON demand.assignment_id = assignment.id WHERE assignment.team_id = ? AND assignment.project_id = ? - AND demand.workday_run_id = ? + AND assignment.work_day_id = ? AND assignment.status = 'completed' ORDER BY assignment.completed_at DESC, assignment.id ASC LIMIT ?`, diff --git a/src/api/capacity/services/capacity/workdays/assignments/workday-summary-query-service.ts b/src/api/capacity/services/capacity/workdays/assignments/workday-summary-query-service.ts deleted file mode 100644 index 9112f128..00000000 --- a/src/api/capacity/services/capacity/workdays/assignments/workday-summary-query-service.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { -normalizeCapacityPageLimit, -type CapacityPageCursor, -} from '@treeseed/sdk/capacity-pagination'; -import { logicalModeRunSql } from '../../../../repositories/support/mode-run.ts'; - -interface WorkdaySummaryDatabase { - first(query: string, values?: unknown[]): Promise | null>; - all(query: string, values?: unknown[]): Promise>>; -} - -export interface EvidencePage { - items: Array>; - page: { limit: number; hasMore: boolean; nextCursor: string | null }; -} - -export interface WorkdaySummaryEnvelope extends Record { - id: string; - teamId: string; - projectId: string; - allocationSetId?: string | null; -} - -interface WorkdaySummaryRepository extends WorkdaySummaryDatabase { - getWorkdayCapacityEnvelope(workdayId: string): Promise; - listProviderAssignmentsPage(teamId: string, filters: Record): Promise; - listAgentModeRunsPage(projectId: string, filters: Record): Promise; - listCapacityReservationsForProjectPage(projectId: string, filters: Record): Promise; - listTaskUsageActualsPage(projectId: string, filters: Record): Promise; - listCapacityLedgerEntriesPage(projectId: string, filters: Record): Promise; -} - -export interface WorkdaySummaryOptions { - evidence?: 'assignments' | 'mode-runs' | 'reservations' | 'usage-actuals' | 'ledger-entries' | null; - limit?: number | null; - cursor?: CapacityPageCursor | null; -} - -export interface WorkdaySummaryAggregate { - assignment: { - total: number; - pending: number; - leased: number; - completed: number; - failed: number; - returned: number; - cancelled: number; - missingSettlementCount: number; - missingUsageCount: number; - }; - modeRun: { - total: number; - queued: number; - running: number; - succeeded: number; - failed: number; - usageReported: number; - }; - reservation: { total: number; requestedSeconds: number; reservedSeconds: number; activeSeconds: number; elapsedSeconds: number; releasedSeconds: number; overrunSeconds: number }; - usage: { - total: number; - assignmentTotal: number; - inputTokens: number; - outputTokens: number; - cachedInputTokens: number; - reasoningTokens: number; - quotaMinutes: number; - wallMinutes: number; - activeSeconds: number; - elapsedSeconds: number; - actualUsd: number; - }; - ledger: { total: number; activeSeconds: number; elapsedSeconds: number }; - warningSamples: { - missingSettlementAssignmentIds: string[]; - missingUsageAssignmentIds: string[]; - }; -} - -function count(row: Record | null, key: string): number { - return Number(row?.[key] ?? 0); -} - -function rowIds(rows: Array>): string[] { - return rows.map((row) => String(row.id ?? '')).filter(Boolean); -} - -export async function aggregateWorkdaySummary( - database: WorkdaySummaryDatabase, - input: { workDayId: string; warningSampleLimit?: number }, -): Promise { - const assignmentScope = `FROM capacity_provider_assignments assignment WHERE assignment.work_day_id = ?`; - const [assignment, missingSettlement, missingUsage, modeRun, reservation, usage, ledger] = await Promise.all([ - database.first( - `SELECT COUNT(*) AS total, - COALESCE(SUM(CASE WHEN assignment.status = 'pending' THEN 1 ELSE 0 END), 0) AS pending, - COALESCE(SUM(CASE WHEN assignment.status = 'leased' THEN 1 ELSE 0 END), 0) AS leased, - COALESCE(SUM(CASE WHEN assignment.status = 'completed' THEN 1 ELSE 0 END), 0) AS completed, - COALESCE(SUM(CASE WHEN assignment.status IN ('failed', 'expired') THEN 1 ELSE 0 END), 0) AS failed, - COALESCE(SUM(CASE WHEN assignment.status = 'returned' THEN 1 ELSE 0 END), 0) AS returned, - COALESCE(SUM(CASE WHEN assignment.status = 'cancelled' THEN 1 ELSE 0 END), 0) AS cancelled - ${assignmentScope}`, - [input.workDayId], - ), - database.first( - `SELECT COUNT(*) AS total FROM capacity_provider_assignments assignment - LEFT JOIN ( - SELECT DISTINCT assignment_id FROM capacity_ledger_entries - WHERE phase = 'task_completed_actual_settlement' - ) settled ON settled.assignment_id = assignment.id - WHERE assignment.work_day_id = ? AND assignment.status = 'completed' - AND assignment.reservation_id IS NOT NULL AND settled.assignment_id IS NULL`, - [input.workDayId], - ), - database.first( - `SELECT COUNT(*) AS total FROM capacity_provider_assignments assignment - LEFT JOIN (SELECT DISTINCT assignment_id FROM capacity_usage_actuals) task_usage - ON task_usage.assignment_id = assignment.id - LEFT JOIN ( - SELECT DISTINCT provider_assignment_id FROM agent_mode_runs - WHERE usage_actual_json IS NOT NULL AND usage_actual_json <> '{}' AND ${logicalModeRunSql()} - ) mode_usage ON mode_usage.provider_assignment_id = assignment.id - WHERE assignment.work_day_id = ? AND assignment.status = 'completed' - AND task_usage.assignment_id IS NULL AND mode_usage.provider_assignment_id IS NULL`, - [input.workDayId], - ), - database.first( - `SELECT COUNT(*) AS total, - COALESCE(SUM(CASE WHEN mode_run.status = 'queued' THEN 1 ELSE 0 END), 0) AS queued, - COALESCE(SUM(CASE WHEN mode_run.status = 'running' THEN 1 ELSE 0 END), 0) AS running, - COALESCE(SUM(CASE WHEN mode_run.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS succeeded, - COALESCE(SUM(CASE WHEN mode_run.status = 'failed' THEN 1 ELSE 0 END), 0) AS failed, - COALESCE(SUM(CASE WHEN mode_run.usage_actual_json IS NOT NULL AND mode_run.usage_actual_json <> '{}' THEN 1 ELSE 0 END), 0) AS usage_reported - FROM agent_mode_runs mode_run - JOIN capacity_provider_assignments assignment ON assignment.id = mode_run.provider_assignment_id - WHERE assignment.work_day_id = ? AND ${logicalModeRunSql('mode_run')}`, - [input.workDayId], - ), - database.first( - `SELECT COUNT(*) AS total, - COALESCE(SUM(reservation.requested_seconds), 0) AS requested_seconds, - COALESCE(SUM(reservation.reserved_seconds), 0) AS reserved_seconds, - COALESCE(SUM(reservation.active_seconds), 0) AS active_seconds, - COALESCE(SUM(reservation.elapsed_seconds), 0) AS elapsed_seconds, - COALESCE(SUM(reservation.released_seconds), 0) AS released_seconds, - COALESCE(SUM(reservation.overrun_seconds), 0) AS overrun_seconds - FROM capacity_reservations reservation - WHERE reservation.work_day_id = ?`, - [input.workDayId], - ), - database.first( - `SELECT COUNT(*) AS total, - COUNT(DISTINCT usage.assignment_id) AS assignment_total, - COALESCE(SUM(CASE WHEN usage.usage_dimension = 'aggregate' OR aggregate_usage.assignment_id IS NULL THEN usage.input_tokens ELSE 0 END), 0) AS input_tokens, - COALESCE(SUM(CASE WHEN usage.usage_dimension = 'aggregate' OR aggregate_usage.assignment_id IS NULL THEN usage.output_tokens ELSE 0 END), 0) AS output_tokens, - COALESCE(SUM(CASE WHEN usage.usage_dimension = 'aggregate' OR aggregate_usage.assignment_id IS NULL THEN usage.cached_input_tokens ELSE 0 END), 0) AS cached_input_tokens, - COALESCE(SUM(CASE WHEN usage.usage_dimension = 'aggregate' OR aggregate_usage.assignment_id IS NULL THEN usage.reasoning_tokens ELSE 0 END), 0) AS reasoning_tokens, - COALESCE(SUM(CASE WHEN usage.usage_dimension = 'aggregate' OR aggregate_usage.assignment_id IS NULL THEN usage.quota_minutes ELSE 0 END), 0) AS quota_minutes, - COALESCE(SUM(CASE WHEN usage.usage_dimension = 'aggregate' OR aggregate_usage.assignment_id IS NULL THEN usage.wall_minutes ELSE 0 END), 0) AS wall_minutes, - COALESCE(SUM(CASE WHEN usage.accounting_mode = 'aggregate' OR aggregate_usage.assignment_id IS NULL AND usage.accounting_mode = 'incremental' THEN usage.active_seconds ELSE 0 END), 0) AS active_seconds, - COALESCE(SUM(CASE WHEN usage.accounting_mode = 'aggregate' OR aggregate_usage.assignment_id IS NULL AND usage.accounting_mode = 'incremental' THEN usage.elapsed_seconds ELSE 0 END), 0) AS elapsed_seconds, - COALESCE(SUM(CASE WHEN usage.accounting_mode = 'aggregate' OR aggregate_usage.assignment_id IS NULL AND usage.accounting_mode = 'incremental' THEN usage.actual_usd ELSE 0 END), 0) AS actual_usd - FROM capacity_usage_actuals usage - LEFT JOIN (SELECT assignment_id, assignment_attempt FROM capacity_usage_actuals WHERE usage_dimension = 'aggregate' GROUP BY assignment_id, assignment_attempt) aggregate_usage - ON aggregate_usage.assignment_id = usage.assignment_id AND aggregate_usage.assignment_attempt = usage.assignment_attempt - WHERE usage.work_day_id = ?`, - [input.workDayId], - ), - database.first( - `SELECT COUNT(*) AS total, - COALESCE(SUM(CASE WHEN phase = 'task_completed_actual_settlement' THEN active_seconds ELSE 0 END), 0) AS active_seconds, - COALESCE(SUM(CASE WHEN phase = 'task_completed_actual_settlement' THEN elapsed_seconds ELSE 0 END), 0) AS elapsed_seconds - FROM capacity_ledger_entries - WHERE work_day_id = ?`, - [input.workDayId], - ), - ]); - const sampleLimit = Math.max(1, Math.min(Number(input.warningSampleLimit ?? 20), 100)); - const [missingSettlementRows, missingUsageRows] = await Promise.all([ - database.all( - `SELECT assignment.id FROM capacity_provider_assignments assignment - LEFT JOIN ( - SELECT DISTINCT assignment_id FROM capacity_ledger_entries - WHERE phase = 'task_completed_actual_settlement' - ) settled ON settled.assignment_id = assignment.id - WHERE assignment.work_day_id = ? AND assignment.status = 'completed' - AND assignment.reservation_id IS NOT NULL AND settled.assignment_id IS NULL - ORDER BY assignment.created_at ASC, assignment.id ASC LIMIT ?`, - [input.workDayId, sampleLimit], - ), - database.all( - `SELECT assignment.id FROM capacity_provider_assignments assignment - LEFT JOIN (SELECT DISTINCT assignment_id FROM capacity_usage_actuals) task_usage - ON task_usage.assignment_id = assignment.id - LEFT JOIN ( - SELECT DISTINCT provider_assignment_id FROM agent_mode_runs - WHERE usage_actual_json IS NOT NULL AND usage_actual_json <> '{}' AND ${logicalModeRunSql()} - ) mode_usage ON mode_usage.provider_assignment_id = assignment.id - WHERE assignment.work_day_id = ? AND assignment.status = 'completed' - AND task_usage.assignment_id IS NULL AND mode_usage.provider_assignment_id IS NULL - ORDER BY assignment.created_at ASC, assignment.id ASC LIMIT ?`, - [input.workDayId, sampleLimit], - ), - ]); - return { - assignment: { - total: count(assignment, 'total'), pending: count(assignment, 'pending'), leased: count(assignment, 'leased'), - completed: count(assignment, 'completed'), failed: count(assignment, 'failed'), returned: count(assignment, 'returned'), - cancelled: count(assignment, 'cancelled'), missingSettlementCount: count(missingSettlement, 'total'), - missingUsageCount: count(missingUsage, 'total'), - }, - modeRun: { - total: count(modeRun, 'total'), queued: count(modeRun, 'queued'), running: count(modeRun, 'running'), - succeeded: count(modeRun, 'succeeded'), failed: count(modeRun, 'failed'), usageReported: count(modeRun, 'usage_reported'), - }, - reservation: { total: count(reservation, 'total'), requestedSeconds: count(reservation, 'requested_seconds'), reservedSeconds: count(reservation, 'reserved_seconds'), activeSeconds: count(reservation, 'active_seconds'), elapsedSeconds: count(reservation, 'elapsed_seconds'), releasedSeconds: count(reservation, 'released_seconds'), overrunSeconds: count(reservation, 'overrun_seconds') }, - usage: { - total: count(usage, 'total'), assignmentTotal: count(usage, 'assignment_total'), inputTokens: count(usage, 'input_tokens'), - outputTokens: count(usage, 'output_tokens'), cachedInputTokens: count(usage, 'cached_input_tokens'), - reasoningTokens: count(usage, 'reasoning_tokens'), - quotaMinutes: count(usage, 'quota_minutes'), wallMinutes: count(usage, 'wall_minutes'), - activeSeconds: count(usage, 'active_seconds'), elapsedSeconds: count(usage, 'elapsed_seconds'), actualUsd: count(usage, 'actual_usd'), - }, - ledger: { total: count(ledger, 'total'), activeSeconds: count(ledger, 'active_seconds'), elapsedSeconds: count(ledger, 'elapsed_seconds') }, - warningSamples: { - missingSettlementAssignmentIds: rowIds(missingSettlementRows), - missingUsageAssignmentIds: rowIds(missingUsageRows), - }, - }; -} - -export async function buildWorkdayCapacitySummary( - repository: WorkdaySummaryRepository, - workdayId: string, - options: WorkdaySummaryOptions = {}, -) { - const envelope = await repository.getWorkdayCapacityEnvelope(workdayId); - if (!envelope) return null; - const selectedEvidence = options.evidence ?? null; - const aggregate = await aggregateWorkdaySummary(repository, { workDayId: workdayId }); - const evidenceLimit = normalizeCapacityPageLimit(options.limit); - let evidence: Record | null = null; - if (selectedEvidence === 'assignments') evidence = { assignments: { ...await repository.listProviderAssignmentsPage(envelope.teamId, { - projectId: envelope.projectId, - workdayId, - limit: evidenceLimit, - cursor: options.cursor ?? null, - }),total: aggregate.assignment.total } }; - if (selectedEvidence === 'mode-runs') evidence = { modeRuns: { ...await repository.listAgentModeRunsPage(envelope.projectId, { - workDayId: workdayId, - limit: evidenceLimit, - cursor: options.cursor ?? null, - }),total: aggregate.modeRun.total } }; - if (selectedEvidence === 'reservations') evidence = { reservations: { ...await repository.listCapacityReservationsForProjectPage(envelope.projectId, { - workDayId: workdayId, - limit: evidenceLimit, - cursor: options.cursor ?? null, - }),total: aggregate.reservation.total } }; - if (selectedEvidence === 'usage-actuals') evidence = { usageActuals: { ...await repository.listTaskUsageActualsPage(envelope.projectId, { - workDayId: workdayId, - limit: evidenceLimit, - cursor: options.cursor ?? null, - }),total: aggregate.usage.total } }; - if (selectedEvidence === 'ledger-entries') evidence = { ledgerEntries: { ...await repository.listCapacityLedgerEntriesPage(envelope.projectId, { - workDayId: workdayId, - limit: evidenceLimit, - cursor: options.cursor ?? null, - }),total: aggregate.ledger.total } }; - const warnings: string[] = []; - if (aggregate.assignment.missingSettlementCount > 0) { - warnings.push(`${aggregate.assignment.missingSettlementCount} completed assignments have no actual-settlement ledger entry; sample: ${aggregate.warningSamples.missingSettlementAssignmentIds.join(', ') || 'unavailable'}`); - } - if (aggregate.assignment.missingUsageCount > 0) { - warnings.push(`${aggregate.assignment.missingUsageCount} completed assignments have no durable native usage; sample: ${aggregate.warningSamples.missingUsageAssignmentIds.join(', ') || 'unavailable'}`); - } - if (aggregate.reservation.activeSeconds !== aggregate.ledger.activeSeconds) { - warnings.push(`reservation active time ${aggregate.reservation.activeSeconds}s does not match settlement ledger active time ${aggregate.ledger.activeSeconds}s`); - } - return { - ok: true as const, - payload: { - workday: envelope, - totals: { - assignments: { - total: aggregate.assignment.total, - pending: aggregate.assignment.pending, - leased: aggregate.assignment.leased, - completed: aggregate.assignment.completed, - failed: aggregate.assignment.failed, - returned: aggregate.assignment.returned, - cancelled: aggregate.assignment.cancelled, - }, - modeRuns: { - total: aggregate.modeRun.total, - queued: aggregate.modeRun.queued, - running: aggregate.modeRun.running, - succeeded: aggregate.modeRun.succeeded, - failed: aggregate.modeRun.failed, - usageReported: aggregate.modeRun.usageReported, - }, - reservations: aggregate.reservation.total, - usageActuals: aggregate.usage.total, - ledgerEntries: aggregate.ledger.total, - }, - settlement: { - teamId: envelope.teamId, - projectId: envelope.projectId, - workDayId: envelope.id, - allocationSetId: envelope.allocationSetId ?? null, - requestedSeconds: aggregate.reservation.requestedSeconds, - reservedSeconds: aggregate.reservation.reservedSeconds, - activeSeconds: aggregate.ledger.activeSeconds, - elapsedSeconds: aggregate.ledger.elapsedSeconds, - releasedSeconds: aggregate.reservation.releasedSeconds, - overrunSeconds: aggregate.reservation.overrunSeconds, - nativeUsage: { - taskActualCount: aggregate.usage.assignmentTotal, - modeRunUsageCount: aggregate.modeRun.usageReported, - inputTokens: aggregate.usage.inputTokens, - outputTokens: aggregate.usage.outputTokens, - cachedInputTokens: aggregate.usage.cachedInputTokens, - reasoningTokens: aggregate.usage.reasoningTokens, - quotaMinutes: aggregate.usage.quotaMinutes, - wallMinutes: aggregate.usage.wallMinutes, - activeSeconds: aggregate.usage.activeSeconds, - elapsedSeconds: aggregate.usage.elapsedSeconds, - actualUsd: aggregate.usage.actualUsd, - }, - providerConfidence: warnings.length ? 'medium' : 'high', - warnings, - }, - evidence, - }, - }; -} diff --git a/src/api/capacity/services/capacity/workdays/content/mode-run-activity-event.ts b/src/api/capacity/services/capacity/workdays/content/mode-run-activity-event.ts deleted file mode 100644 index c1a66e2f..00000000 --- a/src/api/capacity/services/capacity/workdays/content/mode-run-activity-event.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createHash } from 'node:crypto'; - -type Row = Record; - -function record(value: unknown): Row { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; -} - -function text(...values: unknown[]): string | null { - for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim(); - return null; -} - -function assignmentActivityType(assignment: Row): string | null { - const decision = record(assignment.decisionInput); - return text( - decision.activityType, - record(decision.metadata).activityType, - record(decision.input).activityType, - record(assignment.metadata).activityType, - ); -} - -function severity(status: unknown, payload: Row) { - if (status === 'failed' || payload.error) return 'error'; - if (status === 'warning') return 'warning'; - return 'info'; -} - -export function modeRunActivityEvent(input: { assignment: Row; modeRun: Row }) { - const { assignment, modeRun } = input; - const outputs = record(modeRun.outputs); - const outputMetadata = record(outputs.metadata); - const message = record(outputMetadata.message); - const payload = record(message.payload); - const item = record(payload.item); - const metadata = record(modeRun.metadata); - const eventType = text(payload.eventType, message.type, metadata.source, outputs.status, 'agent.mode-run')!; - const summary = text( - item.text, - item.command, - record(payload.error).message, - outputs.summary, - `${eventType} recorded.`, - )!; - const payloadDigest = text(payload.payloadDigest) - ?? createHash('sha256').update(JSON.stringify({ eventType, item, outputs: { status: outputs.status, summary: outputs.summary } })).digest('hex'); - const status = modeRun.status === 'failed' ? 'failed' : modeRun.status === 'running' ? 'active' : 'recorded'; - const title = text(item.type, outputs.status, eventType); - const messageText = summary.slice(0, 1_000); - const context = { - agentId: text(modeRun.agentId, assignment.agentId), - agentClassId: text(modeRun.projectAgentClassId, assignment.projectAgentClassId), - activityType: assignmentActivityType(assignment), - handlerId: text(modeRun.handlerId, assignment.handlerId), - capacityProviderId: text(modeRun.capacityProviderId, assignment.capacityProviderId), - providerManagerId: text(metadata.providerManagerId), - runnerId: text(metadata.runnerId, assignment.runnerId), - executionProviderId: text(modeRun.executionProviderId, assignment.executionProviderId), - executionRunId: text(record(modeRun.traceRefs).executionRunId), - contextPackDigest: text(record(record(assignment.decisionInput).contextPack).digest, record(assignment.metadata).contextPackDigest), - }; - const refs = { - transcriptRef: `mode-run://${String(modeRun.id)}`, - artifacts: Array.isArray(outputs.artifacts) ? outputs.artifacts : [], - }; - const eventMetadata = { - sourceEventId: text(metadata.telemetryEventId, modeRun.id), - severity: severity(modeRun.status, payload), - usageDelta: record(payload.usage), - durationMs: null, - errorCategory: text(record(payload.error).code, record(payload.error).message), - recoveryState: text(metadata.recoveryState), - redactionStatus: text(payload.redactionStatus, 'sanitized'), - payloadDigest, - }; - const createdAt = text(modeRun.createdAt, new Date().toISOString()); - const identityDigest = createHash('sha256').update(JSON.stringify({ eventType, status, title, messageText, context, refs, eventMetadata, createdAt })).digest('hex'); - return { - // A mode run is a mutable aggregate while its telemetry records are - // append-only. Bind the event identity to the exact payload so an exact - // retry replays and a later phase/status becomes a distinct event. - id: `activity:${String(modeRun.id)}:${identityDigest.slice(0, 24)}`, - projectId: text(modeRun.projectId, assignment.projectId), - workdayId: text(assignment.workDayId), - assignmentId: text(modeRun.providerAssignmentId, assignment.id), - modeRunId: text(modeRun.id), - eventType, - status, - title, - message: messageText, - parameters: {}, - context, - refs, - metadata: eventMetadata, - createdAt, - }; -} diff --git a/src/api/capacity/services/capacity/workdays/content/workday-content-demand-source.ts b/src/api/capacity/services/capacity/workdays/content/workday-content-demand-source.ts deleted file mode 100644 index 2a45cf87..00000000 --- a/src/api/capacity/services/capacity/workdays/content/workday-content-demand-source.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { validatePortableContentData } from '@treeseed/sdk/content-validation'; -import { createHash } from 'node:crypto'; -import { CapacityGovernanceError } from '../../../../database.ts'; -import type { DurableCapacityWorkdayRun } from '../../../../repositories/capacity/workdays/workday-run.ts'; -import type { WorkdayProject } from '../policy/workday-project-policy.ts'; -import { canonicalTreeDxBranchRef, projectLibraryPath } from '../../../../../knowledge/gateway-treedx-connection.ts'; -import { resolveWorkdayTreeDxConnection,type WorkdayTreeDxConnectionStore } from '../treedx/workday-treedx-connection.ts'; - -export type TreeDxPlanningSourceType = 'objective' | 'question' | 'proposal' | 'decision-review' | 'knowledge-gap'; -export interface TreeDxPlanningDemandSource { - sourceType: TreeDxPlanningSourceType; - sourceId: string; - priority: number; - payload: Record; -} - -const MODELS = [ - { model: 'objective', directory: 'objectives', type: 'objective', priority: 80 }, - { model: 'question', directory: 'questions', type: 'question', priority: 75 }, - { model: 'proposal', directory: 'proposals', type: 'proposal', priority: 70 }, - { model: 'decision', directory: 'decisions', type: 'decision-review', priority: 65 }, -] as const; -const CLOSED = new Set(['closed', 'complete', 'completed', 'decided', 'rejected', 'cancelled', 'archived', 'superseded']); - -function record(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; -} -function text(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function id(path: string): string { return path.replace(/^.*\//u, '').replace(/\.(md|mdx)$/u, ''); } - -export async function listTreeDxPlanningDemandSources( - store: WorkdayTreeDxConnectionStore, - run: DurableCapacityWorkdayRun, - project: WorkdayProject, -): Promise { - const library = await store.getProjectTreeDxLibrary(project.id); - const topology = record(library?.topology); - const contentRepository = record(topology.contentRepository); - const treeDx = record(contentRepository.treeDx); - if (!text(library?.repositoryId ?? treeDx.repositoryId)) return []; - const connection = await resolveWorkdayTreeDxConnection(store, { - projectId: project.id, runId: run.id, - capabilities: ['repos:read', 'files:read', 'files:search'], - }); - if (!connection) return []; - const client = connection.client; - const root = text(library?.contentPath); - const selectedRef = text(library?.contentRepositoryRef) || text(library?.contentRepositoryDefaultBranch); - if (!selectedRef) throw new CapacityGovernanceError('capacity_workday_content_ref_missing', 'The project library has no selected content ref.', 409, { projectId: project.id }); - let ref = /^[a-f0-9]{40}$/u.test(selectedRef) ? selectedRef : canonicalTreeDxBranchRef(selectedRef); - let snapshot = /^[a-f0-9]{40}$/u.test(ref) ? ref : ''; - const selectedObjectives = new Set((Array.isArray(run.parameters.objectiveRefs) ? run.parameters.objectiveRefs : []) - .map((value) => text(value).replace(/^objective:/u, '')).filter(Boolean)); - const sources: TreeDxPlanningDemandSource[] = []; - try { - for (const model of MODELS) { - const response = await client.searchRepositoryFiles({ - ref, paths: [projectLibraryPath(root, model.directory, '**')], query: '', limit: 50, - includeBody: true, includeFrontmatter: true, - }); - const resolved = text(response.resolvedRef); - if (!/^[a-f0-9]{40}$/u.test(resolved) || (snapshot && resolved !== snapshot)) throw new CapacityGovernanceError( - 'capacity_workday_content_snapshot_invalid', 'TreeDX planning collections must resolve to one immutable content revision.', 409, - { projectId: project.id, repositoryId: connection.repositoryId }); - ref = snapshot = resolved; - const candidates = response.results ?? response.files ?? []; - const digests = new Map(); - if (model.model === 'proposal' && candidates.length) { - const raw = await client.readRepositoryFiles({ ref: snapshot, paths: candidates.map((file: unknown) => text(record(file).path)), encoding: 'utf8', parseFrontmatter: false, allowProtected: true }); - if (raw.resolvedRef !== snapshot) throw new CapacityGovernanceError('capacity_workday_content_snapshot_invalid', 'Proposal bytes must match the planning snapshot.', 409, { projectId: project.id }); - for (const file of raw.files ?? []) if (typeof file.content === 'string') digests.set(text(file.path), createHash('sha256').update(file.content).digest('hex')); - } - for (const candidate of candidates) { - const file = record(candidate); const frontmatter = record(file.frontmatter); const path = text(file.path); - if (!path) continue; - if (model.model === 'proposal' && !digests.has(path)) throw new CapacityGovernanceError('capacity_workday_content_snapshot_invalid', 'Proposal source bytes are missing from the planning snapshot.', 409, { projectId: project.id }); - const validation = validatePortableContentData(model.model, frontmatter); - if (!validation.ok) throw new CapacityGovernanceError('capacity_workday_content_model_invalid', 'TreeDX planning content failed model validation.', 409, { - projectId: project.id, repositoryId: connection.repositoryId, path, model: model.model, diagnostics: validation.diagnostics, - }); - const status = text(frontmatter.status).toLowerCase(); - if (CLOSED.has(status)) continue; - if (model.model === 'objective' && selectedObjectives.size && !selectedObjectives.has(id(path))) continue; - const sourceType = model.model === 'question' && text(frontmatter.question_type ?? frontmatter.questionType).toLowerCase() === 'knowledge-gap' - ? 'knowledge-gap' : model.type; - sources.push({ - sourceType, sourceId: `${model.model}:${id(path)}`, priority: model.priority, - payload: { model: model.model, contentPath: path, contentBaseRef: snapshot, commitSha: snapshot, digest: digests.get(path), title: text(frontmatter.title), status: status || null, - body: text(file.body), frontmatter, planningSource: 'treedx-content' }, - }); - } - } - } catch (error) { - if (error instanceof CapacityGovernanceError) throw error; - throw new CapacityGovernanceError('capacity_workday_treedx_demand_query_failed', 'TreeDX could not compile project planning demand.', 503, { - projectId: project.id, repositoryId: connection.repositoryId, details: error instanceof Error ? error.message : String(error), - }); - } - return sources.sort((left, right) => right.priority - left.priority || left.sourceId.localeCompare(right.sourceId)); -} diff --git a/src/api/capacity/services/capacity/workdays/content/workday-event-service.ts b/src/api/capacity/services/capacity/workdays/content/workday-event-service.ts index 11e3e041..b3f8583e 100644 --- a/src/api/capacity/services/capacity/workdays/content/workday-event-service.ts +++ b/src/api/capacity/services/capacity/workdays/content/workday-event-service.ts @@ -31,6 +31,8 @@ export class CapacityWorkdayEventService { } async create(teamId: string, runId: string, input: JsonRecord): Promise { + if (Object.hasOwn(input, 'modeRunId')) throw new CapacityGovernanceError('mode_run_contract_retired', + 'Mode-run identity is retired; use assignmentId.', 400); const run = await this.runs.get(teamId, runId); if (!run) return null; const eventType = nullable(input.eventType ?? input.type); @@ -40,7 +42,7 @@ export class CapacityWorkdayEventService { const write = { id, projectId: nullable(input.projectId), workdayId: nullable(input.workdayId ?? input.workDayId), - assignmentId: nullable(input.assignmentId), modeRunId: nullable(input.modeRunId), eventType, + assignmentId: nullable(input.assignmentId), eventType, status: parseCapacityWorkdayEventStatus(input.status ?? 'recorded'), title: nullable(input.title), message: nullable(input.message), parameters: object(input.parameters), context: object(input.context), refs: object(input.refs), metadata: object(input.metadata), createdAt: nullable(input.createdAt) ?? new Date().toISOString(), @@ -48,7 +50,7 @@ export class CapacityWorkdayEventService { const existing = await this.events.get(teamId, runId, id); if (existing) { const comparable = (value: CapacityWorkdayEventRecord | typeof write) => ({ - projectId: value.projectId, workdayId: value.workdayId, assignmentId: value.assignmentId, modeRunId: value.modeRunId, + projectId: value.projectId, workdayId: value.workdayId, assignmentId: value.assignmentId, eventType: value.eventType, status: value.status, title: value.title, message: value.message, parameters: value.parameters, context: value.context, refs: value.refs, metadata: value.metadata, createdAt: value.createdAt, }); diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts b/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts index 85edd628..057ec999 100644 --- a/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts +++ b/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts @@ -1,11 +1,54 @@ -import { appliedWorkdaySchema, compilePlanningRounds, workdayPhase, type AppliedWorkday } from '@treeseed/sdk/agent-capacity'; +import { appliedWorkdaySchema, compilePlanningRounds, exactEntityReferenceSchema, workdayPhase, type AppliedWorkday } from '@treeseed/sdk/agent-capacity'; import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import type { DurableCapacityWorkdayRun } from '../../../../repositories/capacity/workdays/workday-run.ts'; +import { workdayParticipants } from '../../../../policy/execution/workday-participants.ts'; +import { readExactProposal } from '../../../../../governance/executable-proposal.ts'; type Row = Record; const terminalNodeStates = new Set(['completed', 'failed', 'cancelled', 'stale']); const terminalReservationStates = new Set(['consumed', 'released', 'expired', 'failed']); const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); +const record = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; + +async function nextPlanningParticipants(store: CapacityGovernanceDatabase, run: DurableCapacityWorkdayRun, plan: AppliedWorkday) { + const sources = { ...record(run.parameters.planningSourceByProjectId) }; + const proposalsByProjectId: Record = {}; + const first = plan.planningRounds[0]!; + const prefix = `planning:${plan.id}:${first.round}:`; + const initialAgentIds = first.assignmentIds.map((id) => id.slice(prefix.length)); + const projectIds = Array.isArray(run.parameters.scheduledProjectIds) ? run.parameters.scheduledProjectIds : []; + for (const projectId of projectIds) { + if (typeof projectId !== 'string') continue; + const existing = record(sources[projectId]); + if (typeof existing.id === 'string') { + const proposal = await store.getGovernanceProposal(existing.id); + if (!proposal) throw new Error(`planning_proposal_missing:${projectId}`); + proposalsByProjectId[projectId] = (await readExactProposal(store, proposal, + exactEntityReferenceSchema.parse(existing))).definition; + continue; + } + const candidates = await store.all(`SELECT * FROM governance_proposals WHERE team_id=? AND project_id=? + AND status IN ('draft','submitted','open','voting') ORDER BY id LIMIT 101`, [run.teamId, projectId]); + if (candidates.length > 100) throw new Error(`planning_proposal_inventory_too_large:${projectId}`); + const executable: Array>> = []; + for (const proposal of candidates) { + try { executable.push(await readExactProposal(store, proposal)); } + catch (error) { + if ((error as { code?: unknown })?.code !== 'proposal_execution_plan_invalid') throw error; + } + } + if (executable.length > 1) throw new Error(`planning_proposal_ambiguous:${projectId}`); + const exact = executable[0]; + if (!exact) continue; + sources[projectId] = exact.ref; + proposalsByProjectId[projectId] = exact.definition; + } + const estimators = Object.keys(proposalsByProjectId).length + ? workdayParticipants({ ...run.parameters, proposalsByProjectId }) + .filter((participant) => participant.activity === 'estimating' && projectIds.includes(participant.projectId)) + .map((participant) => participant.id) : []; + return { sources, agentIds: [...new Set([...initialAgentIds, ...estimators])].sort() }; +} function advanceRounds(plan: AppliedWorkday, states: Map, now: string): AppliedWorkday { const rounds = plan.planningRounds.map((round, index) => { @@ -32,13 +75,12 @@ export async function advanceLivingWorkday(store: CapacityGovernanceDatabase & { if (next.state === 'planned') next = { ...next, state: 'active', activatedAt: next.activatedAt ?? now }; if (!requestClose && next.state === 'active' && workdayPhase(next, now) === 'planning' && next.planningRounds.length && next.planningRounds.every((round) => round.state === 'complete')) { - const first = next.planningRounds[0]!; - const prefix = `planning:${next.id}:${first.round}:`; - const agentIds = first.assignmentIds.map((id) => id.slice(prefix.length)); + const { sources, agentIds } = await nextPlanningParticipants(store, run, next); const round = next.planningRounds.at(-1)!.round + 1; const turns = compilePlanningRounds(next.id, agentIds, next.policySnapshot.planningTurnMaximumSeconds, round); next = { ...next, planningRounds: [...next.planningRounds, { round, state: 'active', assignmentIds: turns.map((turn) => turn.id), startedAt: now }] }; + run = { ...run, parameters: { ...run.parameters, planningSourceByProjectId: sources } }; } if (next.state === 'active' && (requestClose || Date.parse(now) >= Date.parse(next.endsAt))) { next = { ...next, state: 'closing', closingAt: next.closingAt ?? now }; diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/workday-admission-fence-service.ts b/src/api/capacity/services/capacity/workdays/lifecycle/workday-admission-fence-service.ts index 65e03e1d..b6cc492b 100644 --- a/src/api/capacity/services/capacity/workdays/lifecycle/workday-admission-fence-service.ts +++ b/src/api/capacity/services/capacity/workdays/lifecycle/workday-admission-fence-service.ts @@ -1,12 +1,11 @@ import { MAX_CAPACITY_PAGE_LIMIT } from '@treeseed/sdk/capacity-pagination'; -import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import { CapacityGovernanceError } from '../../../../database.ts'; -import { logicalModeRunSql } from '../../../../repositories/support/mode-run.ts'; import { assignmentContentIntegrationReadySql,CONTENT_INTEGRATED_EVENT,CONTENT_INTEGRATION_REQUIRED_EVENT } from '../../assignments/lifecycle/assignment-content-integration-requirement.ts'; +import { CapacityWorkdayRunRepository } from '../../../../repositories/capacity/workdays/workday-run.ts'; +import { advanceLivingWorkday } from './living-workday-lifecycle.ts'; +import { reconcileExecutionGraph } from '../../../../../control-plane/repositories/capacity/execution/execution-graph-service.ts'; -interface WorkdayAdmissionFenceStore extends CapacityGovernanceDatabase { - closeCapacityWorkdayAdmission(teamId: string, runId: string): Promise<{ closed: number }>; -} +type WorkdayAdmissionFenceStore = Parameters[0]; function count(row: Record | null, key: string) { return Number(row?.[key] ?? 0); @@ -21,18 +20,14 @@ export async function fenceCapacityWorkdayAdmission( teamId: string, runId: string, ) { - const run = await store.first( - `SELECT id,status FROM capacity_workday_runs WHERE id = ? AND team_id = ? LIMIT 1`, - [runId,teamId], - ); + const run = await new CapacityWorkdayRunRepository(store).get(teamId, runId); if (!run) throw new CapacityGovernanceError('capacity_workday_run_not_found','Capacity workday run does not exist.',404,{ runId }); - const closure = await store.closeCapacityWorkdayAdmission(teamId,runId); - const envelope = await store.first( - `SELECT COUNT(*) AS total, - COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END),0) AS active - FROM workday_capacity_envelopes WHERE team_id = ? AND workday_run_id = ?`, - [teamId,runId], - ); + if (run.status === 'running' && !run.parameters.appliedPlan) throw new CapacityGovernanceError( + 'capacity_workday_plan_missing', 'Running workday has no authoritative applied plan to close.', 409, { runId }); + if (run.status === 'running' && run.parameters.appliedPlan) { + await advanceLivingWorkday(store, run, new Date().toISOString(), true); + await reconcileExecutionGraph(store, teamId); + } const assignment = await store.first( `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN assignment.status = 'completed' @@ -53,22 +48,7 @@ export async function fenceCapacityWorkdayAdmission( LEFT JOIN (SELECT DISTINCT target_id AS id FROM audit_events WHERE target_type = 'capacity_provider_assignment' AND event_type = '${CONTENT_INTEGRATED_EVENT}') integrated_assignment ON integrated_assignment.id = assignment.id - WHERE assignment.team_id = ? AND EXISTS ( - SELECT 1 FROM capacity_workday_demands demand - WHERE demand.assignment_id = assignment.id AND demand.workday_run_id = ? - )`, - [teamId,runId], - ); - const modeRun = await store.first( - `SELECT COUNT(*) AS total, - COALESCE(SUM(CASE WHEN mode_run.status = 'failed' THEN 1 ELSE 0 END),0) AS failed, - COALESCE(SUM(CASE WHEN mode_run.status NOT IN ('succeeded','failed','cancelled') THEN 1 ELSE 0 END),0) AS non_terminal - FROM agent_mode_runs mode_run - JOIN capacity_provider_assignments assignment ON assignment.id = mode_run.provider_assignment_id - WHERE assignment.team_id = ? AND EXISTS ( - SELECT 1 FROM capacity_workday_demands demand - WHERE demand.assignment_id = assignment.id AND demand.workday_run_id = ? - ) AND ${logicalModeRunSql('mode_run')}`, + WHERE assignment.team_id = ? AND assignment.work_day_id = ?`, [teamId,runId], ); const samples = await store.all( @@ -82,32 +62,24 @@ export async function fenceCapacityWorkdayAdmission( LEFT JOIN (SELECT DISTINCT target_id AS id FROM audit_events WHERE target_type = 'capacity_provider_assignment' AND event_type = '${CONTENT_INTEGRATED_EVENT}') integrated_assignment ON integrated_assignment.id = assignment.id - WHERE assignment.team_id = ? AND EXISTS ( - SELECT 1 FROM capacity_workday_demands demand - WHERE demand.assignment_id = assignment.id AND demand.workday_run_id = ? - ) + WHERE assignment.team_id = ? AND assignment.work_day_id = ? AND (assignment.status <> 'completed' OR NOT (${assignmentContentIntegrationReadySql()}) OR (assignment.reservation_id IS NOT NULL AND settlement.assignment_id IS NULL)) ORDER BY assignment.created_at ASC,assignment.id ASC LIMIT ?`, [teamId,runId,Math.min(20,MAX_CAPACITY_PAGE_LIMIT)], ); - const activeEnvelopes = count(envelope,'active'); const nonTerminalAssignments = count(assignment,'non_terminal'); const unsettledAssignments = count(assignment,'unsettled'); - const nonTerminalModeRuns = count(modeRun,'non_terminal'); const failedAssignments = count(assignment,'failed'); - const failedModeRuns = count(modeRun,'failed'); return { schemaVersion: 'treeseed.capacity-workday-admission-fence/v1' as const, - teamId,runId,closedEnvelopes: closure.closed, - envelopes: { total: count(envelope,'total'), active: activeEnvelopes }, + teamId,runId,admissionClosed: true, assignments: { total: count(assignment,'total'), completed: count(assignment,'completed'), failed: failedAssignments, nonTerminal: nonTerminalAssignments, unsettled: unsettledAssignments, }, - modeRuns: { total: count(modeRun,'total'), failed: failedModeRuns, nonTerminal: nonTerminalModeRuns }, - ready: activeEnvelopes === 0 && nonTerminalAssignments === 0 && unsettledAssignments === 0 && nonTerminalModeRuns === 0, + ready: nonTerminalAssignments === 0 && unsettledAssignments === 0, successful: failedAssignments === 0, problemAssignmentIds: ids(samples), }; diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/workday-assignment-terminalization-service.ts b/src/api/capacity/services/capacity/workdays/lifecycle/workday-assignment-terminalization-service.ts index 5f55a69f..38368997 100644 --- a/src/api/capacity/services/capacity/workdays/lifecycle/workday-assignment-terminalization-service.ts +++ b/src/api/capacity/services/capacity/workdays/lifecycle/workday-assignment-terminalization-service.ts @@ -2,7 +2,6 @@ import { MAX_CAPACITY_PAGE_LIMIT } from '@treeseed/sdk/capacity-pagination'; import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import { CapacityGovernanceError } from '../../../../database.ts'; import { releaseCapacityReservationsExactlyOnce } from '../../accounting/settlement-service.ts'; -import { logicalModeRunSql } from '../../../../repositories/support/mode-run.ts'; import { ProviderAssignmentRepository } from '../../../../repositories/capacity/assignments/assignment.ts'; import { closeTerminalAssignmentWorkspace } from '../../assignments/observability/assignment-terminal-workspace.ts'; import { terminalAssignmentAuthority } from '../../assignments/lifecycle/assignment-terminal-authority.ts'; @@ -30,7 +29,6 @@ export interface WorkdayAssignmentTerminalizationInput { source?: string; code?: string; reason?: string; - demandStatus?: 'blocked' | 'cancelled' | 'superseded'; metadata?: Record; } @@ -108,11 +106,11 @@ async function releaseUnsettledTerminalAssignments( `SELECT assignment.id, assignment.membership_id, assignment.reservation_id, assignment.state_version, assignment.lifecycle_output_json FROM capacity_provider_assignments assignment - JOIN workday_capacity_envelopes envelope ON envelope.id = assignment.work_day_id + JOIN capacity_workday_runs run ON run.id = assignment.work_day_id AND run.team_id = assignment.team_id JOIN capacity_reservations reservation ON reservation.id = assignment.reservation_id LEFT JOIN capacity_ledger_entries settlement ON settlement.reservation_id = assignment.reservation_id AND settlement.phase = 'task_completed_actual_settlement' - WHERE assignment.team_id = ? AND envelope.workday_run_id = ? + WHERE assignment.team_id = ? AND run.id = ? AND assignment.status IN ('failed', 'expired', 'cancelled') AND reservation.state = 'reserved' AND settlement.id IS NULL @@ -137,9 +135,9 @@ async function cleanupTerminalAssignmentWorkspaces( const rows = await database.all<{ id: string }>( `SELECT DISTINCT assignment.id FROM capacity_provider_assignments assignment - JOIN workday_capacity_envelopes envelope ON envelope.id = assignment.work_day_id + JOIN capacity_workday_runs run ON run.id = assignment.work_day_id AND run.team_id = assignment.team_id JOIN treedx_proxy_handles handle ON handle.assignment_id = assignment.id AND handle.team_id = assignment.team_id - WHERE assignment.team_id = ? AND envelope.workday_run_id = ? + WHERE assignment.team_id = ? AND run.id = ? AND assignment.status IN ('completed', 'failed', 'expired', 'cancelled') AND handle.status != 'revoked' ORDER BY assignment.id ASC LIMIT ?`, @@ -179,7 +177,7 @@ export async function terminalizeCapacityWorkdayAssignments( AND assignment.execution_kind = 'conversation' AND assignment.lifecycle_code = 'discussion_response_required' AND suspended_invocation.id IS NOT NULL) THEN 1 ELSE 0 END), 0) AS unfinished_assignments FROM capacity_provider_assignments assignment - JOIN workday_capacity_envelopes envelope ON envelope.id = assignment.work_day_id + JOIN capacity_workday_runs run ON run.id = assignment.work_day_id AND run.team_id = assignment.team_id LEFT JOIN agent_invocation_requests suspended_invocation ON suspended_invocation.assignment_id = assignment.id AND suspended_invocation.status = 'suspended' AND suspended_invocation.final_message_ref IS NOT NULL LEFT JOIN (SELECT DISTINCT target_id AS id FROM audit_events @@ -188,7 +186,7 @@ export async function terminalizeCapacityWorkdayAssignments( LEFT JOIN (SELECT DISTINCT target_id AS id FROM audit_events WHERE target_type = 'capacity_provider_assignment' AND event_type = '${CONTENT_INTEGRATED_EVENT}') integrated_assignment ON integrated_assignment.id = assignment.id - WHERE assignment.team_id = ? AND envelope.workday_run_id = ?`, + WHERE assignment.team_id = ? AND run.id = ?`, [teamId, runId], ); const preserveUntil = input.preserveActiveLeasesUntil && Number.isFinite(Date.parse(input.preserveActiveLeasesUntil)) @@ -200,10 +198,10 @@ export async function terminalizeCapacityWorkdayAssignments( `SELECT assignment.id, assignment.membership_id, assignment.reservation_id, assignment.state_version, assignment.lifecycle_output_json FROM capacity_provider_assignments assignment - JOIN workday_capacity_envelopes envelope ON envelope.id = assignment.work_day_id + JOIN capacity_workday_runs run ON run.id = assignment.work_day_id AND run.team_id = assignment.team_id LEFT JOIN agent_invocation_requests suspended_invocation ON suspended_invocation.assignment_id = assignment.id AND suspended_invocation.status = 'suspended' AND suspended_invocation.final_message_ref IS NOT NULL - WHERE assignment.team_id = ? AND envelope.workday_run_id = ? + WHERE assignment.team_id = ? AND run.id = ? AND assignment.status NOT IN ('completed', 'failed', 'expired', 'cancelled') AND NOT (assignment.status = 'returned' AND assignment.lease_state = 'released' AND assignment.execution_kind = 'conversation' AND assignment.lifecycle_code = 'discussion_response_required' @@ -263,33 +261,7 @@ export async function terminalizeCapacityWorkdayAssignments( ], })); return [...terminalAssignmentOperations,{ - query: `UPDATE agent_mode_runs - SET status = 'failed', - fallback_reason = COALESCE(fallback_reason, ?), - failed_at = COALESCE(failed_at, ?), - updated_at = ? - WHERE team_id = ? AND status IN ('queued', 'running') AND ${logicalModeRunSql()} - AND provider_assignment_id IN ( - SELECT id FROM capacity_provider_assignments - WHERE team_id = ? AND state_version = ? AND id IN (${ids}) - AND status = 'failed' AND lifecycle_code = ? - )`, - params: [input.reason ?? 'Workday terminalized before the mode run completed.', now, now, teamId, teamId, stateVersion + 1, ...idValues,input.code ?? 'workday_terminalized'], - }, - { - query: `UPDATE capacity_workday_demands SET status = 'blocked', completed_at = ?, updated_at = ? - WHERE assignment_id IN (${ids}) AND status = 'admitted' - AND assignment_id IN (SELECT id FROM capacity_provider_assignments WHERE status = 'failed' AND id IN (${ids}))`, - params: [now, now, ...idValues, ...idValues], - }, - { - query: `UPDATE capacity_workday_participation_entries SET status = 'blocked', reason_code = ?, covered_at = ?, updated_at = ? - WHERE assignment_id IN (${ids}) AND status = 'assigned' - AND assignment_id IN (SELECT id FROM capacity_provider_assignments WHERE status = 'failed' AND id IN (${ids}))`, - params: [input.code ?? 'workday_terminalized', now, now, ...idValues, ...idValues], - }, - { - query: `UPDATE execution_nodes SET status='ready',node_revision=node_revision+1,updated_at=? + query: `UPDATE execution_nodes SET status='cancelled',updated_at=? WHERE team_id=? AND EXISTS (SELECT 1 FROM capacity_provider_assignments assignment WHERE assignment.team_id=execution_nodes.team_id AND assignment.execution_node_id=execution_nodes.id AND assignment.execution_node_revision=execution_nodes.node_revision @@ -301,13 +273,11 @@ export async function terminalizeCapacityWorkdayAssignments( await database.batch(stateOperations); if (assignments.length < MAX_CAPACITY_PAGE_LIMIT) break; } - if (input.demandStatus) { - await database.run( - `UPDATE capacity_workday_demands SET status = ?, completed_at = COALESCE(completed_at, ?), updated_at = ? - WHERE team_id = ? AND workday_run_id = ? AND status IN ('pending','claimed','admitted','blocked')`, - [input.demandStatus, now, now, teamId, runId], - ); - } + await database.run( + `UPDATE execution_nodes SET status='cancelled',updated_at=? + WHERE team_id=? AND workday_id=? AND status IN ('proposed','blocked','ready')`, + [now, teamId, runId], + ); await releaseUnsettledTerminalAssignments(database, teamId, runId, now, input); await cleanupTerminalAssignmentWorkspaces(database, teamId, runId, now); const finalTotals = await database.first( @@ -321,7 +291,7 @@ export async function terminalizeCapacityWorkdayAssignments( AND assignment.execution_kind = 'conversation' AND assignment.lifecycle_code = 'discussion_response_required' AND suspended_invocation.id IS NOT NULL) THEN 1 ELSE 0 END), 0) AS unfinished_assignments FROM capacity_provider_assignments assignment - JOIN workday_capacity_envelopes envelope ON envelope.id = assignment.work_day_id + JOIN capacity_workday_runs run ON run.id = assignment.work_day_id AND run.team_id = assignment.team_id LEFT JOIN agent_invocation_requests suspended_invocation ON suspended_invocation.assignment_id = assignment.id AND suspended_invocation.status = 'suspended' AND suspended_invocation.final_message_ref IS NOT NULL LEFT JOIN (SELECT DISTINCT target_id AS id FROM audit_events @@ -330,12 +300,12 @@ export async function terminalizeCapacityWorkdayAssignments( LEFT JOIN (SELECT DISTINCT target_id AS id FROM audit_events WHERE target_type = 'capacity_provider_assignment' AND event_type = '${CONTENT_INTEGRATED_EVENT}') integrated_assignment ON integrated_assignment.id = assignment.id - WHERE assignment.team_id = ? AND envelope.workday_run_id = ?`, [teamId, runId], + WHERE assignment.team_id = ? AND run.id = ?`, [teamId, runId], ); const deferred = await database.first<{ total?: unknown }>( `SELECT COUNT(*) AS total FROM capacity_provider_assignments assignment - JOIN workday_capacity_envelopes envelope ON envelope.id = assignment.work_day_id - WHERE assignment.team_id = ? AND envelope.workday_run_id = ? AND assignment.status = 'leased' AND assignment.lease_state = 'leased' + JOIN capacity_workday_runs run ON run.id = assignment.work_day_id AND run.team_id = assignment.team_id + WHERE assignment.team_id = ? AND run.id = ? AND assignment.status = 'leased' AND assignment.lease_state = 'leased' AND assignment.lease_token IS NOT NULL AND assignment.lease_expires_at IS NOT NULL AND assignment.lease_expires_at > ? AND ? > ?`, [teamId, runId, now, preserveUntil, now], ); diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/workday-continuation-service.ts b/src/api/capacity/services/capacity/workdays/lifecycle/workday-continuation-service.ts deleted file mode 100644 index 001d7afd..00000000 --- a/src/api/capacity/services/capacity/workdays/lifecycle/workday-continuation-service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { evaluateWorkdayContinuation } from '../../../../policy/workdays/workday.ts'; -import type { CapacityGovernanceDatabase } from '../../../../database.ts'; -import { CapacityGovernanceError } from '../../../../database.ts'; -import { decodeDurableJsonObject } from '../../../../durable-json.ts'; - -export interface DurableWorkdayContinuationDecision { - continue: boolean; - reason: 'workday_not_active' | 'duration_bound_reached' | 'budget_bound_reached' | 'no_useful_eligible_work' | 'within_duration_and_budget'; - totalSeconds: number; - committedSeconds: number; -} - -export async function evaluateDurableWorkdayContinuation( - database: CapacityGovernanceDatabase, - input: { teamId: string; workdayRunId: string; workdayId: string; usefulEligibleWork: boolean; now: string }, -): Promise { - await database.ensureInitialized(); - const row = await database.first( - `SELECT run.status AS run_status, run.parameters_json, workday.status AS workday_status, workday.envelope_json - FROM capacity_workday_runs run JOIN workday_capacity_envelopes workday ON workday.workday_run_id = run.id AND workday.team_id = run.team_id - WHERE run.id = ? AND run.team_id = ? AND workday.id = ? LIMIT 1`, - [input.workdayRunId, input.teamId, input.workdayId], - ); - if (!row) throw new CapacityGovernanceError('capacity_workday_continuation_state_missing', 'Workday continuation state is missing.', 409, input); - const parameters = decodeDurableJsonObject(row.parameters_json, { owner: 'capacity workday run', ownerId: input.workdayRunId, column: 'parameters_json' }); - const envelope = decodeDurableJsonObject(row.envelope_json, { owner: 'workday capacity envelope', ownerId: input.workdayId, column: 'envelope_json' }); - const totalSeconds = Number(envelope.availableSeconds); - if (!Number.isFinite(totalSeconds) || totalSeconds < 0) throw new CapacityGovernanceError( - 'capacity_workday_time_budget_invalid', 'Workday continuation requires a non-negative finite agent-time budget.', 500, - { workdayId: input.workdayId, value: envelope.availableSeconds ?? null }, - ); - const totals = await database.first<{ committed_seconds?: unknown }>( - `SELECT COALESCE(SUM(CASE WHEN state IN ('reserved','consuming') THEN reserved_seconds ELSE active_seconds END), 0) AS committed_seconds - FROM capacity_reservations WHERE team_id = ? AND work_day_id = ?`, [input.teamId, input.workdayId], - ); - const committedSeconds = Number(totals?.committed_seconds ?? 0); - if (!Number.isFinite(committedSeconds) || committedSeconds < 0) throw new CapacityGovernanceError('capacity_workday_committed_budget_invalid', 'Workday committed agent time is invalid.', 500, { workdayId: input.workdayId }); - const status = row.run_status === 'running' && row.workday_status === 'active' ? 'active' : String(row.workday_status ?? row.run_status ?? ''); - const decision = evaluateWorkdayContinuation({ - status, now: input.now, deadlineAt: typeof parameters.deadlineAt === 'string' ? parameters.deadlineAt : null, - totalSeconds, committedSeconds, usefulEligibleWork: input.usefulEligibleWork, - }); - return { ...decision, totalSeconds, committedSeconds }; -} diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/workday-envelope-terminalization-service.ts b/src/api/capacity/services/capacity/workdays/lifecycle/workday-envelope-terminalization-service.ts deleted file mode 100644 index 6a768053..00000000 --- a/src/api/capacity/services/capacity/workdays/lifecycle/workday-envelope-terminalization-service.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { MAX_CAPACITY_PAGE_LIMIT } from '@treeseed/sdk/capacity-pagination'; -import type { CapacityGovernanceDatabase } from '../../../../database.ts'; -import { CapacityGovernanceError } from '../../../../database.ts'; - -interface WorkdayEnvelopeTerminalizationStore extends CapacityGovernanceDatabase { - updateWorkdayCapacityEnvelopeState(workdayId: string, status: string): Promise<{ status?: unknown } | null>; -} - -const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'failed', 'degraded']); - -export async function terminalizeCapacityWorkdayEnvelopes( - store: WorkdayEnvelopeTerminalizationStore, - teamId: string, - runId: string, - status: string, -): Promise<{ terminalized: number }> { - if (!TERMINAL_STATUSES.has(status)) { - throw new CapacityGovernanceError( - 'capacity_workday_envelope_terminal_status_invalid', - `Unknown workday envelope terminal status ${status}.`, - 400, - { status }, - ); - } - let terminalized = 0; - while (true) { - const rows = await store.all( - `SELECT id FROM workday_capacity_envelopes - WHERE team_id = ? AND workday_run_id = ? - AND status NOT IN ('completed', 'cancelled', 'failed', 'degraded') - ORDER BY id ASC LIMIT ?`, - [teamId, runId, MAX_CAPACITY_PAGE_LIMIT], - ); - if (rows.length === 0) return { terminalized }; - for (const row of rows) { - const workdayId = typeof row.id === 'string' ? row.id : ''; - if (!workdayId) { - throw new CapacityGovernanceError( - 'capacity_workday_envelope_corrupt', - 'Run-owned workday envelope has no valid id.', - 500, - { runId }, - ); - } - const updated = await store.updateWorkdayCapacityEnvelopeState(workdayId, status); - if (!updated || updated.status !== status) { - throw new CapacityGovernanceError( - 'capacity_workday_envelope_terminalization_failed', - `Workday envelope ${workdayId} did not reach ${status}.`, - 500, - { runId, workdayId, status }, - ); - } - terminalized += 1; - } - } -} - -export async function closeCapacityWorkdayAdmission( - store: WorkdayEnvelopeTerminalizationStore, - teamId: string, - runId: string, -): Promise<{ closed: number }> { - let closed = 0; - while (true) { - const rows = await store.all( - `SELECT id FROM workday_capacity_envelopes WHERE team_id = ? AND workday_run_id = ? AND status = 'active' ORDER BY id ASC LIMIT ?`, - [teamId, runId, MAX_CAPACITY_PAGE_LIMIT], - ); - if (!rows.length) return { closed }; - for (const row of rows) { - const workdayId = typeof row.id === 'string' ? row.id : ''; - if (!workdayId) throw new CapacityGovernanceError('capacity_workday_envelope_corrupt', 'Run-owned workday envelope has no valid id.', 500, { runId }); - const updated = await store.updateWorkdayCapacityEnvelopeState(workdayId, 'paused'); - if (!updated || updated.status !== 'paused') throw new CapacityGovernanceError( - 'capacity_workday_admission_close_failed', `Workday envelope ${workdayId} did not stop admission.`, 500, { runId, workdayId }, - ); - closed += 1; - } - } -} diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/workday-recovery-service.ts b/src/api/capacity/services/capacity/workdays/lifecycle/workday-recovery-service.ts index 1c5a2604..6c2d0a13 100644 --- a/src/api/capacity/services/capacity/workdays/lifecycle/workday-recovery-service.ts +++ b/src/api/capacity/services/capacity/workdays/lifecycle/workday-recovery-service.ts @@ -1,4 +1,4 @@ -import type { CapacityWorkdayEventRecord,CapacityWorkdayRunRecord,CapacityWorkdayRunStatus } from '@treeseed/sdk/agent-capacity'; +import type { CapacityWorkdayEventRecord,CapacityWorkdayRunRecord } from '@treeseed/sdk/agent-capacity'; import { MAX_CAPACITY_PAGE_LIMIT } from '@treeseed/sdk/capacity-pagination'; import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import { CapacityWorkdayRecoveryRepository } from '../../../../repositories/capacity/workdays/workday-recovery.ts'; @@ -7,8 +7,6 @@ import type { WorkdayAssignmentTerminalizationResult } from './workday-assignmen type JsonRecord = Record; interface WorkdayRecoveryStore extends CapacityGovernanceDatabase { terminalizeCapacityWorkdayAssignments(teamId: string, runId: string, input: JsonRecord): Promise; - terminalizeCapacityWorkdayEnvelopes(teamId: string, runId: string, status: string): Promise<{ terminalized: number }>; - closeCapacityWorkdayAdmission(teamId: string, runId: string): Promise<{ closed: number }>; createCapacityWorkdayEvent(teamId: string, runId: string, input: JsonRecord): Promise; } @@ -18,7 +16,6 @@ function deadline(run: CapacityWorkdayRunRecord) { const settlementGraceSeconds = Math.max(300, Number(run.parameters.settlementGraceSeconds ?? run.parameters.waitSeconds ?? 0) || 0); return { deadlineAt: configured, deadlineMs, settlementGraceSeconds, staleAtMs: deadlineMs + settlementGraceSeconds * 1000 }; } -function terminalEnvelopeStatus(status: CapacityWorkdayRunStatus) { return status === 'cancelled' ? 'cancelled' : status === 'completed' ? 'completed' : status === 'degraded' ? 'degraded' : 'failed'; } function terminalGraceUntil(run: CapacityWorkdayRunRecord) { const seconds = Math.max(300, Number(run.parameters.settlementGraceSeconds ?? run.parameters.waitSeconds ?? 0) || 0); const start = Date.parse(run.completedAt ?? new Date().toISOString()); @@ -36,7 +33,6 @@ export async function maintainCapacityWorkdayRuns(store: WorkdayRecoveryStore, t const runs = await repository.listRunning(teamId, cursor); for (const run of runs) { const timing = deadline(run); if (!Number.isFinite(timing.deadlineMs) || timing.deadlineMs > nowMs) continue; - await store.closeCapacityWorkdayAdmission(run.teamId, run.id); const admissionEventId = `workday-deadline-admission:${run.id}`; const existingAdmissionEvent = await store.first( `SELECT id FROM capacity_workday_events WHERE id = ? AND run_id = ? AND team_id = ? LIMIT 1`, @@ -50,12 +46,11 @@ export async function maintainCapacityWorkdayRuns(store: WorkdayRecoveryStore, t if (timing.staleAtMs > nowMs) continue; const terminalization = await store.terminalizeCapacityWorkdayAssignments(run.teamId, run.id, { now, settlementKeyPrefix: 'workday-deadline', source: 'capacity_workday_deadline_terminalization', code: 'workday_deadline_elapsed', reason: 'Workday deadline elapsed before this assignment reached a terminal state.', - demandStatus:'cancelled', preserveActiveLeasesUntil: new Date(timing.staleAtMs).toISOString(), metadata: { deadlineAt: timing.deadlineAt, deadlineSource: 'configured', settlementGraceSeconds: timing.settlementGraceSeconds, expiredAt: now } }); - const evidence = await repository.modeRunEvidence(run.teamId, run.id); + const evidence = await repository.assignmentEvidence(run.teamId, run.id); const terminalStatus = terminalization.assignmentCount > 0 && terminalization.unfinishedAssignmentCount === 0 && terminalization.failedAssignments === 0 - && evidence.failedModeRuns === 0 && evidence.succeededModeRuns > 0 && evidence.contentArtifactCount > 0 + && terminalization.completedAssignments > 0 && evidence.unresolvedContentOutcomeAssignments === 0 && evidence.abandonedContentOutcomeAssignments === 0 && terminalization.settlementErrorCount === 0 ? 'completed' : 'degraded'; const actual = { assignmentCount: terminalization.assignmentCount, completedAssignments: terminalization.completedAssignments, @@ -67,11 +62,10 @@ export async function maintainCapacityWorkdayRuns(store: WorkdayRecoveryStore, t const won = await repository.completeDeadline(run, terminalStatus, { status: terminalStatus, message: terminalStatus === 'completed' ? 'Timed workday completed within its governed deadline.' : 'Timed workday closed with incomplete or failed evidence.', ...actual }, { status: terminalStatus, assignmentCompletionPercent: terminalization.assignmentCount ? Math.round((terminalization.completedAssignments / terminalization.assignmentCount) * 100) : 0, - modeRunSuccessPercent: evidence.modeRunCount ? Math.round((evidence.succeededModeRuns / evidence.modeRunCount) * 100) : 0, contentArtifactCount: evidence.contentArtifactCount }, + contentArtifactCount: evidence.contentArtifactCount }, actual, terminalStatus === 'completed' ? {} : { code: 'workday_deadline_degraded', message: 'Timed workday deadline elapsed without complete successful assignment, content, and settlement evidence.', deadlineAt: timing.deadlineAt, deadlineSource: 'configured', settlementGraceSeconds: timing.settlementGraceSeconds, expiredAt: now }, now); if (!won) continue; - await store.terminalizeCapacityWorkdayEnvelopes(run.teamId, run.id, terminalStatus); await store.createCapacityWorkdayEvent(run.teamId, run.id, eventInput({ ...run, status: terminalStatus }, actual)); expired += 1; } if (runs.length === 0 || runs.length < MAX_CAPACITY_PAGE_LIMIT) break; @@ -82,15 +76,13 @@ export async function maintainCapacityWorkdayRuns(store: WorkdayRecoveryStore, t const terminalRuns = await repository.listTerminal(teamId, terminalCursor); if (!terminalRuns.length) break; for (const terminalRun of terminalRuns) { const candidate = await repository.recoveryState(terminalRun); - if (!candidate.hasUnfinishedAssignments && !candidate.hasActiveDemands && !candidate.hasOpenEnvelopes && !candidate.missingDeadlineEvent) continue; + if (!candidate.hasUnfinishedAssignments && !candidate.hasReadyNodes && !candidate.missingDeadlineEvent) continue; const { run } = candidate; const terminalization = await store.terminalizeCapacityWorkdayAssignments(run.teamId, run.id, { now, settlementKeyPrefix: 'workday-terminal-recovery', source: 'capacity_workday_terminal_recovery', code: `workday_${run.status}_recovered`, reason: `Recovered unfinished assignment state from terminal workday ${run.id}.`, - demandStatus:'cancelled', preserveActiveLeasesUntil: terminalGraceUntil(run), metadata: { status: run.status, recovery: true } }); - await store.terminalizeCapacityWorkdayEnvelopes(run.teamId, run.id, terminalEnvelopeStatus(run.status)); if (candidate.missingDeadlineEvent) await store.createCapacityWorkdayEvent(run.teamId, run.id, eventInput(run, run.actual)); - if (candidate.hasUnfinishedAssignments || candidate.hasActiveDemands || candidate.hasOpenEnvelopes || candidate.missingDeadlineEvent || terminalization.unfinishedAssignmentCount > 0) recoveredTerminalRuns += 1; + if (candidate.hasUnfinishedAssignments || candidate.hasReadyNodes || candidate.missingDeadlineEvent || terminalization.unfinishedAssignmentCount > 0) recoveredTerminalRuns += 1; } if (terminalRuns.length < MAX_CAPACITY_PAGE_LIMIT) break; terminalCursor = terminalRuns.at(-1)!.id; diff --git a/src/api/capacity/services/capacity/workdays/scheduling/workday-run-service.ts b/src/api/capacity/services/capacity/workdays/scheduling/workday-run-service.ts index 4f066939..6ecf6bd1 100644 --- a/src/api/capacity/services/capacity/workdays/scheduling/workday-run-service.ts +++ b/src/api/capacity/services/capacity/workdays/scheduling/workday-run-service.ts @@ -13,8 +13,6 @@ interface WorkdayRunServiceStore extends WorkdayScheduleStore { preflightCapacityWorkdayRun(run: CapacityWorkdayRunRecord): Promise; scheduleCapacityWorkdayRun(run: CapacityWorkdayRunRecord): Promise; terminalizeCapacityWorkdayAssignments(teamId: string, runId: string, input: JsonRecord): Promise; - terminalizeCapacityWorkdayEnvelopes(teamId: string, runId: string, status: string): Promise<{ terminalized: number }>; - closeCapacityWorkdayAdmission(teamId: string, runId: string): Promise; createCapacityWorkdayEvent(teamId: string, runId: string, input: JsonRecord): Promise; updateCapacityWorkdayRun(teamId: string, runId: string, input: JsonRecord): Promise; } @@ -115,12 +113,10 @@ export class CapacityWorkdayRunService { reportRefs: object(input.reportRefs ?? input.report_refs ?? existing.reportRefs), error: object(input.error ?? existing.error), startedAt, completedAt: nullable(input.completedAt) ?? existing.completedAt ?? (TERMINAL.has(status) ? now : null), updatedAt: now, }; - if (TERMINAL.has(status) && !TERMINAL.has(existing.status)) await this.store.closeCapacityWorkdayAdmission(teamId, runId); const updated = await this.writes.update(next, existing.status); if (!updated) throw new CapacityGovernanceError('capacity_workday_run_transition_conflict', 'Workday run changed concurrently.', 409, { runId, expectedStatus: existing.status }); if (TERMINAL.has(status) && !TERMINAL.has(existing.status)) { - await this.store.terminalizeCapacityWorkdayAssignments(teamId, runId, { now, preserveActiveLeasesUntil: workdayTerminalizationPreserveUntil(status, parameters, now), settlementKeyPrefix: 'workday-explicit-terminal', source: 'capacity_workday_explicit_terminalization', code: `workday_${status}`, reason: `Workday was explicitly terminalized with status ${status}.`, demandStatus:'cancelled', metadata: { status } }); - await this.store.terminalizeCapacityWorkdayEnvelopes(teamId, runId, status); + await this.store.terminalizeCapacityWorkdayAssignments(teamId, runId, { now, preserveActiveLeasesUntil: workdayTerminalizationPreserveUntil(status, parameters, now), settlementKeyPrefix: 'workday-explicit-terminal', source: 'capacity_workday_explicit_terminalization', code: `workday_${status}`, reason: `Workday was explicitly terminalized with status ${status}.`, metadata: { status } }); } return this.runs.get(teamId, runId); } diff --git a/src/api/capacity/services/capacity/workdays/scheduling/workday-scheduling-service.ts b/src/api/capacity/services/capacity/workdays/scheduling/workday-scheduling-service.ts index 818c94d7..c2676d82 100644 --- a/src/api/capacity/services/capacity/workdays/scheduling/workday-scheduling-service.ts +++ b/src/api/capacity/services/capacity/workdays/scheduling/workday-scheduling-service.ts @@ -24,7 +24,6 @@ export interface WorkdayScheduleStore extends CapacityGovernanceDatabase { getProjectTreeDxLibrary(projectId: string): Promise<{ repositoryId?: unknown; contentPath?: unknown; contentRepositoryRef?: unknown; metadata?: unknown } | null>; createCapacityWorkdayEvent(teamId: string, runId: string, input: JsonRecord): Promise; updateCapacityWorkdayRun(teamId: string, runId: string, input: JsonRecord): Promise; - terminalizeCapacityWorkdayEnvelopes(teamId: string, runId: string, status: string): Promise<{ terminalized: number }>; } function text(value: unknown, fallback = ''): string { @@ -88,7 +87,7 @@ export function acceptedLibraryRevision(library: { metadata?: unknown; contentRe return immutableRef; } -/** Cooperative project planning requires one exact proposal; conversation runs bind exact message context instead. */ +/** Estimating may attach to one exact proposal; autonomous planning starts with none. */ export function requiresGovernedPlanningProposal(run: Pick): boolean { return run.executionKind === 'workday' && Array.isArray(record(run.parameters.agentSelection).activityTypes) && (record(run.parameters.agentSelection).activityTypes as unknown[]).includes('estimating'); @@ -143,8 +142,8 @@ async function resolveCapacityWorkdayPreflight( const profileSnapshot=await resolveWorkdayAgentProfileSnapshot(store, project.id, parameters.agentSelection); agentProfiles.set(project.id,profileSnapshot); const projectProposals = selectedProposals.filter((proposal) => proposal.projectId === project.id); - if (requiresGovernedPlanningProposal(run) && projectProposals.length !== 1) throw new CapacityGovernanceError( - 'capacity_workday_proposal_selection_invalid', 'A planning-only workday requires exactly one governed proposal per selected project.', 409, + if (requiresGovernedPlanningProposal(run) && projectProposals.length > 1) throw new CapacityGovernanceError( + 'capacity_workday_proposal_selection_invalid', 'A planning workday can bind at most one exact proposal per selected project.', 409, { projectId: project.id, proposalIds: projectProposals.map((proposal) => proposal.proposalId) }); if (projectProposals[0]) proposalContexts.set(project.id, projectProposals[0].ref); } @@ -275,7 +274,6 @@ export async function recordCapacityWorkdayScheduleFailure( const evidence = errorEvidence(error); const failures: JsonRecord[] = []; for (const [owner, operation] of [ - ['envelopes', () => store.terminalizeCapacityWorkdayEnvelopes(run.teamId, run.id, 'failed')], ['event', () => recordRequiredEvent(store, run.teamId, run.id, { eventType: 'workday.schedule_failed', status: 'error', title: 'Workday schedule failed', context: { error: evidence.message, code: evidence.code }, })], diff --git a/src/api/capacity/services/projects/agents/project-agent-class-service.ts b/src/api/capacity/services/projects/agents/project-agent-class-service.ts index 1c0897b2..ab45514c 100644 --- a/src/api/capacity/services/projects/agents/project-agent-class-service.ts +++ b/src/api/capacity/services/projects/agents/project-agent-class-service.ts @@ -11,7 +11,6 @@ type JsonRecord = Record; const STATUSES = new Set(['active', 'paused', 'archived']); function object(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; } -function strings(value: unknown): string[] { return Array.isArray(value) ? [...new Set(value.map(String).map((item) => item.trim()).filter(Boolean))] : []; } function slug(value: unknown): string { return String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').replace(/^-+|-+$/gu, '') || 'agent-class'; } interface ProjectAgentClassStore extends CapacityGovernanceDatabase { @@ -82,19 +81,18 @@ export class ProjectAgentClassService { } private value(teamId: string, projectId: string, id: string, classSlug: string, input: JsonRecord): ProjectAgentClass { + for (const retired of ['allowedModes', 'kernelProfile', 'kernelPolicy', 'outputContracts', 'requiredCapabilities']) { + if (retired in input) throw new CapacityGovernanceError('project_agent_class_retired_field', + `Project agent class ${retired} is derived from the project-owned agent definition.`, 400, { field: retired }); + } const candidateStatus = String(input.status ?? 'active') as ProjectAgentClassStatus; if (!STATUSES.has(candidateStatus)) throw new CapacityGovernanceError('project_agent_class_status_invalid', `Unknown project agent class status ${candidateStatus}.`, 400); - const allowedModes = strings(input.allowedModes); - const modes = allowedModes.length ? allowedModes : ['planning', 'acting']; - if (modes.some((mode) => mode !== 'planning' && mode !== 'acting')) throw new CapacityGovernanceError('project_agent_class_modes_invalid', 'allowedModes must contain only planning and/or acting.', 400); const handlerRefs = object(input.handlerRefs); const handlerRefIssues = validateProjectAgentActivityRefs(handlerRefs); if (handlerRefIssues.length) throw new CapacityGovernanceError('project_agent_activity_refs_invalid', 'Project agent activity references are invalid.', 400, { diagnostics: handlerRefIssues }); return { id, teamId, projectId, slug: classSlug, name: String(input.name ?? classSlug).trim() || classSlug, status: candidateStatus, - allowedModes: modes as ProjectAgentClass['allowedModes'], - kernelProfile: object(input.kernelProfile), kernelPolicy: object(input.kernelPolicy), - handlerRefs, outputContracts: object(input.outputContracts), metadata: object(input.metadata), + handlerRefs, metadata: object(input.metadata), }; } } diff --git a/src/api/capacity/services/projects/agents/project-agent-summary-service.ts b/src/api/capacity/services/projects/agents/project-agent-summary-service.ts index 8a894ebf..db1234b6 100644 --- a/src/api/capacity/services/projects/agents/project-agent-summary-service.ts +++ b/src/api/capacity/services/projects/agents/project-agent-summary-service.ts @@ -5,50 +5,48 @@ type Row = Record; interface SummaryStore { getProjectDetails(projectId: string): Promise; listApprovalRequestsForProject(projectId: string, limit: number): Promise; - listWorkdayCapacityEnvelopes(projectId: string): Promise; - listAgentModeRunsPage(projectId: string, filters: { limit: number }): Promise<{ items: Row[]; page: { hasMore: boolean } }>; + all(query: string, values?: unknown[]): Promise; + listProviderAssignmentsPage(teamId: string, filters: { projectId: string; limit: number }): Promise<{ items: Row[]; page: { hasMore: boolean } }>; } function record(value: unknown): Row { return value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; } -function values(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - export async function buildProjectAgentSummary(store: SummaryStore, projectId: string, principal: unknown = null) { const details = await store.getProjectDetails(projectId); if (!details) return null; - const [approvals, workdays, modeRunPage] = await Promise.all([ + const [approvals, workdays, assignmentPage] = await Promise.all([ store.listApprovalRequestsForProject(projectId, 200), - store.listWorkdayCapacityEnvelopes(projectId), - store.listAgentModeRunsPage(projectId, { limit: 200 }), + store.all(`SELECT id,status,summary_json::jsonb AS summary,created_at AS "createdAt",updated_at AS "updatedAt",completed_at AS "completedAt" + FROM capacity_workday_runs WHERE team_id=? AND jsonb_exists(parameters_json::jsonb -> 'scheduledProjectIds', ?) + ORDER BY created_at DESC,id DESC LIMIT 200`, [String(details.teamId), projectId]), + store.listProviderAssignmentsPage(String(details.teamId), { projectId, limit: 200 }), ]); - const modeRuns = modeRunPage.items; - const generatedArtifacts = await collectProjectAgentArtifacts(store, projectId, modeRuns); + const assignments = assignmentPage.items; + const generatedArtifacts = await collectProjectAgentArtifacts(store, projectId, assignments); const byContentPath = (pattern: RegExp) => generatedArtifacts.filter((artifact) => pattern.test(String(artifact.contentPath ?? artifact.outputRef ?? ''))); const researchNotes = byContentPath(/(?:^|\/)notes\/research\//u); const knowledgeDrafts = generatedArtifacts.filter((artifact) => ['book', 'knowledge', 'page'].includes(String(artifact.model ?? ''))); const optimizationReports = byContentPath(/(?:optimization|review|report)/u); const pendingApprovals = approvals.filter((approval) => ['pending', 'waiting_for_approval', 'human_approval_pending'].includes(String(approval?.state ?? 'pending'))); - const activeModeRuns = modeRuns.filter((run) => ['queued', 'running'].includes(String(run.status))); - const failedModeRuns = modeRuns.filter((run) => String(run.status) === 'failed'); - const currentWorkday = workdays.find((workday) => ['active', 'paused', 'queued'].includes(String(workday.status))) ?? null; + const activeAssignments = assignments.filter((assignment) => ['queued', 'leased', 'running'].includes(String(assignment.status))); + const failedAssignments = assignments.filter((assignment) => String(assignment.status) === 'failed'); + const currentWorkday = workdays.find((workday) => ['running', 'queued'].includes(String(workday.status))) ?? null; const runtimeReports = workdays.slice(0, 6).map((workday) => ({ id: workday.id, workDayId: workday.id, - projectId: workday.projectId, + projectId, kind: 'workday_capacity_summary', state: workday.status, - summary: record(workday.metadata).summary ?? {}, + summary: record(workday.summary), createdAt: workday.completedAt ?? workday.updatedAt ?? workday.createdAt, })); const taskHealth = { - activeTasks: activeModeRuns, + activeTasks: activeAssignments, staleTasks: [], - recoveredTaskCount: modeRuns.filter((run) => record(run.metadata).recovered === true).length, - failedStaleTaskCount: failedModeRuns.filter((run) => record(run.metadata).stale === true).length, + recoveredTaskCount: assignments.filter((assignment) => record(assignment.metadata).recovered === true).length, + failedStaleTaskCount: failedAssignments.filter((assignment) => record(assignment.metadata).stale === true).length, retryBackoffPolicy: { source: 'provider_assignment_retry_policy' }, }; const docsAutomation = { @@ -60,18 +58,18 @@ export async function buildProjectAgentSummary(store: SummaryStore, projectId: s optimizationReportCount: optimizationReports.length, pendingApprovalCount: pendingApprovals.length, docsMutationCount: generatedArtifacts.filter((artifact) => artifact.artifactKind === 'docs_mutation_result').length, - verificationFailureCount: failedModeRuns.length, - repairTaskCount: modeRuns.filter((run) => record(run.metadata).repair === true).length, + verificationFailureCount: failedAssignments.length, + repairTaskCount: assignments.filter((assignment) => record(assignment.metadata).repair === true).length, staleTaskCount: 0, recoveredTaskCount: taskHealth.recoveredTaskCount, failedStaleTaskCount: taskHealth.failedStaleTaskCount, latestReport: runtimeReports[0] ?? null, }; - const agentEntries: Array<[string, Row]> = modeRuns - .map((run): [string, Row] => [String(run.agentSlug ?? run.agentId ?? ''), { - agentSlug: run.agentSlug ?? run.agentId, - status: activeModeRuns.some((active) => ( - String(active.agentSlug ?? active.agentId ?? '') === String(run.agentSlug ?? run.agentId ?? '') + const agentEntries: Array<[string, Row]> = assignments + .map((assignment): [string, Row] => [String(assignment.agentId ?? ''), { + agentSlug: assignment.agentId, + status: activeAssignments.some((active) => ( + String(active.agentId ?? '') === String(assignment.agentId ?? '') )) ? 'active' : 'idle', }]) .filter(([key]) => Boolean(key)); @@ -88,8 +86,8 @@ export async function buildProjectAgentSummary(store: SummaryStore, projectId: s docsAutomation, currentWorkday, runtimeReports, - warnings: modeRunPage.page.hasMore - ? ['More than 200 mode runs exist; use the paginated project agent-run API for complete history.'] + warnings: assignmentPage.page.hasMore + ? ['More than 200 assignments exist; use the paginated provider assignment API for complete history.'] : [], workdaySummaries: runtimeReports, }; diff --git a/src/api/capacity/services/projects/projects-core/project-agent-artifact-service.ts b/src/api/capacity/services/projects/projects-core/project-agent-artifact-service.ts index a556a93d..e82ef968 100644 --- a/src/api/capacity/services/projects/projects-core/project-agent-artifact-service.ts +++ b/src/api/capacity/services/projects/projects-core/project-agent-artifact-service.ts @@ -1,70 +1,51 @@ type Row = Record; -interface ModeRunPage { - items: Row[]; - page: { hasMore: boolean; nextCursor?: string | null }; -} - interface ArtifactStore { - listAgentModeRunsPage(projectId: string, filters: { limit: number }): Promise; -} - -function record(value: unknown): Row { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; + getProjectDetails(projectId: string): Promise; + listProviderAssignmentsPage(teamId: string, filters: { projectId: string; limit: number }): Promise<{ + items: Row[]; page: { hasMore: boolean; nextCursor?: string | null }; + }>; } -function records(value: unknown): Row[] { - return Array.isArray(value) ? value.map(record).filter((item) => Object.keys(item).length > 0) : []; -} +const record = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; +const rows = (value: unknown): Row[] => Array.isArray(value) ? value.map(record) : []; +const text = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; -function text(value: unknown, fallback = ''): string { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - -function artifactRefs(run: Row): Row[] { - const outputs = record(run.outputs); - const metadata = record(outputs.metadata); - const manifest = record(outputs.artifactManifest ?? metadata.artifactManifest); - return records(manifest.contentReferences); -} - -export async function collectProjectAgentArtifacts( - store: ArtifactStore, - projectId: string, - modeRuns?: Row[], -): Promise { - let runs = modeRuns; - if (!runs) { - const page = await store.listAgentModeRunsPage(projectId, { limit: 200 }); - if (page.page.hasMore) { - throw Object.assign(new Error('Project artifact evidence exceeds the bounded mode-run projection.'), { - code: 'project_agent_artifact_evidence_bound_exceeded', - status: 409, - details: { projectId, nextCursor: page.page.nextCursor ?? null }, - }); - } - runs = page.items; +/** Project artifact summaries are projections of immutable assignment results, not another execution record. */ +export async function collectProjectAgentArtifacts(store: ArtifactStore, projectId: string, assignments?: Row[]): Promise { + let evidence = assignments; + if (!evidence) { + const project = await store.getProjectDetails(projectId); + if (!project) return []; + const page = await store.listProviderAssignmentsPage(text(project.teamId), { projectId, limit: 200 }); + if (page.page.hasMore) throw Object.assign(new Error('Project artifact evidence exceeds the bounded assignment projection.'), { + code: 'project_agent_artifact_evidence_bound_exceeded', status: 409, + details: { projectId, nextCursor: page.page.nextCursor ?? null }, + }); + evidence = page.items; } const artifacts: Row[] = []; - for (const run of runs) { - for (const ref of artifactRefs(run)) { - const contentPath = text(ref.contentPath ?? ref.path); - if (!contentPath) continue; + for (const assignment of evidence) { + const result = record(assignment.assignmentResult); + const grant = record(record(assignment.assignmentAttempt).grant); + const writable = rows(grant.contentWrite); + for (const reference of rows(result.references)) { + if (reference.kind !== 'treedx') continue; + const repository = text(reference.repository); + const contentPath = text(reference.path); + const authorization = writable.find((entry) => text(entry.repository) === repository && text(entry.path) === contentPath); + if (!authorization || !text(reference.commit)) continue; + const model = text(authorization.model); artifacts.push({ - id: text(ref.id, contentPath), - title: text(ref.title, contentPath.split('/').at(-1)?.replace(/\.(?:md|mdx)$/u, '') ?? contentPath), - artifactKind: text(ref.artifactKind ?? ref.model, 'content_artifact'), - model: text(ref.model), - contentPath, - subjectId: ref.subjectId ?? null, - taskId: run.id, - modeRunId: run.id, - workDayId: record(run.capacityEnvelope).workDayId ?? record(run.metadata).workDayId ?? null, - taskState: run.status, - outputRef: `treedx:${contentPath}`, - createdAt: run.completedAt ?? run.updatedAt ?? run.createdAt ?? null, + id: `${text(assignment.id)}:${contentPath}`, + title: contentPath.split('/').at(-1)?.replace(/\.(?:md|mdx)$/u, '') ?? contentPath, + artifactKind: model || 'content_artifact', model, contentPath, + taskId: text(assignment.taskId) || text(assignment.id), assignmentId: text(assignment.id), + workDayId: assignment.workDayId ?? null, taskState: assignment.status, + outputRef: `treedx:${repository}:${text(reference.commit)}:${contentPath}`, + createdAt: result.completedAt ?? assignment.completedAt ?? assignment.updatedAt ?? null, }); } } - return [...new Map(artifacts.map((artifact) => [String(artifact.id ?? artifact.outputRef), artifact])).values()]; + return artifacts; } diff --git a/src/api/capacity/services/projects/projects-core/project-deletion-blocker-service.ts b/src/api/capacity/services/projects/projects-core/project-deletion-blocker-service.ts index 0d1a7f6c..dd05f83c 100644 --- a/src/api/capacity/services/projects/projects-core/project-deletion-blocker-service.ts +++ b/src/api/capacity/services/projects/projects-core/project-deletion-blocker-service.ts @@ -16,8 +16,8 @@ export async function listProjectDeletionBlockers(store: DeletionBlockerStore, p [projectId], ), store.all( - `SELECT id, status, started_at FROM workday_capacity_envelopes - WHERE project_id = ? AND status IN ('draft', 'queued', 'active', 'paused') + `SELECT id, status, started_at FROM capacity_workday_runs + WHERE status = 'running' AND jsonb_exists(parameters_json::jsonb -> 'scheduledProjectIds', ?) ORDER BY updated_at DESC LIMIT 20`, [projectId], ), diff --git a/src/api/capacity/services/projects/projects-core/research-workflow-projection-service.ts b/src/api/capacity/services/projects/projects-core/research-workflow-projection-service.ts deleted file mode 100644 index 4c18d2ed..00000000 --- a/src/api/capacity/services/projects/projects-core/research-workflow-projection-service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ResearchClaim,ResearchWorkflowRecord } from '@treeseed/sdk/agent-capacity'; -import { validateAgentArtifactManifest } from '../../../artifact-manifest.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { decodeDurableJsonObject } from '../../../durable-json.ts'; -import type { DurableProviderAssignment } from '../../../repositories/capacity/assignments/assignment.ts'; -import { assignmentArtifactManifest } from '../../capacity/assignments/context/assignment-deliverable-service.ts'; - -type JsonRecord = Record; -export interface ResearchWorkflowProjectionStore { - first = Record>(query: string, params?: unknown[]): Promise; - getResearchWorkflow(id: string): Promise; - completeResearchWorkflowStage(id: string, stage: string, input: JsonRecord): Promise; -} -function record(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; } -function text(...values: unknown[]) { for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim(); return ''; } - -function claims(signals: Array<{ code: string; metadata?: JsonRecord }>): ResearchClaim[] | undefined { - const values = signals.filter((signal) => signal.code === 'research_claim').map((signal) => record(signal.metadata)).map((claim) => ({ - id: text(claim.id), text: text(claim.text), material: claim.material !== false, - status: claim.status as ResearchClaim['status'], citationIds: Array.isArray(claim.citationIds) ? claim.citationIds.map(String).filter(Boolean) : [], - })); - return values.length ? values : undefined; -} - -export async function projectCompletedResearchWorkflow( - store: ResearchWorkflowProjectionStore, - assignment: DurableProviderAssignment, - input: JsonRecord, -) { - if (assignment.mode !== 'planning') return null; - const demand = await store.first('SELECT id, payload_json FROM capacity_workday_demands WHERE assignment_id = ? AND source_type = ? LIMIT 1', [assignment.id, 'research-workflow']); - if (!demand) return null; - const payload = decodeDurableJsonObject(demand.payload_json, { owner: 'research workflow demand', ownerId: String(demand.id ?? ''), column: 'payload_json' }); - const workflowId = text(payload.researchWorkflowId); - const stage = text(payload.researchStage); - const expectedStateVersion = Number(payload.researchWorkflowStateVersion); - if (!workflowId || !stage || !Number.isInteger(expectedStateVersion)) throw new CapacityGovernanceError('research_workflow_demand_corrupt', 'Research demand omits workflow stage provenance.', 500, { assignmentId: assignment.id }); - const workflow = await store.getResearchWorkflow(workflowId); - if (!workflow) throw new CapacityGovernanceError('research_workflow_missing', 'Research assignment references a missing workflow.', 500, { assignmentId: assignment.id, workflowId }); - const prior = workflow.nodes.find((node) => node.stage === stage); - if (workflow.stateVersion > expectedStateVersion && prior?.status === 'completed' && prior.assignmentId === assignment.id) return workflow; - if (workflow.stateVersion !== expectedStateVersion) throw new CapacityGovernanceError('research_workflow_state_conflict', 'Research workflow changed before assignment completion.', 409, { assignmentId: assignment.id, workflowId, expectedStateVersion, actualStateVersion: workflow.stateVersion }); - const manifest = assignmentArtifactManifest(input); - if (!manifest) throw new CapacityGovernanceError('research_artifact_manifest_required', 'Research stage completion requires the canonical artifact manifest.', 409, { assignmentId: assignment.id, workflowId, stage }); - const validation = validateAgentArtifactManifest(manifest); - if (!validation.ok || manifest.assignmentId !== assignment.id || manifest.projectId !== assignment.projectId || manifest.teamId !== assignment.teamId || manifest.mode !== 'planning') throw new CapacityGovernanceError('research_artifact_manifest_invalid', validation.reason ?? 'Research artifact manifest scope is invalid.', 409, { assignmentId: assignment.id, workflowId, stage }); - const rejected = manifest.signals.find((signal) => signal.code === 'research_review_rejected'); - const approved = manifest.signals.find((signal) => signal.code === 'research_review_approved'); - const projectedClaims = claims(manifest.signals); - const artifactRefs = manifest.contentReferences.map((reference) => reference.contentPath); - const publication = manifest.contentReferences.find((reference) => reference.model === 'knowledge' || reference.artifactKind === 'knowledge_page'); - const report = manifest.contentReferences.find((reference) => reference.artifactKind === 'workday_summary'); - return store.completeResearchWorkflowStage(workflowId, stage, { - expectedStateVersion, assignmentId: assignment.id, artifactRefs, - ...(manifest.citations.length ? { citations: manifest.citations } : {}), - ...(projectedClaims ? { claims: projectedClaims } : {}), - ...(rejected ? { reviewOutcome: 'rejected', reviewReason: rejected.message ?? text(rejected.metadata?.reason) } : approved ? { reviewOutcome: 'approved' } : {}), - ...(publication ? { publicationRef: publication.contentPath } : {}), ...(report ? { reportRef: report.contentPath } : {}), - metadata: { modeRunId: manifest.modeRunId, artifactManifestId: `${manifest.assignmentId}:${manifest.modeRunId}` }, - }); -} diff --git a/src/api/capacity/services/runtime/runtime-diagnostics-query-service.ts b/src/api/capacity/services/runtime/runtime-diagnostics-query-service.ts index a71f2213..24ba0930 100644 --- a/src/api/capacity/services/runtime/runtime-diagnostics-query-service.ts +++ b/src/api/capacity/services/runtime/runtime-diagnostics-query-service.ts @@ -14,7 +14,6 @@ export interface RuntimeDiagnosticPage { interface RuntimeDiagnosticsRepository extends RuntimeDiagnosticsDatabase { getProject(projectId: string): Promise | null>; listProviderAssignmentsPage(teamId: string, filters: Record): Promise; - listAgentModeRunsPage(projectId: string, filters: Record): Promise; listTreeDxProxyAuditPage(projectId: string, filters: Record): Promise; listAgentFallbackOutputsPage(projectId: string, filters: Record): Promise; listCapacityLedgerEntriesPage(projectId: string, filters: Record): Promise; @@ -23,7 +22,6 @@ interface RuntimeDiagnosticsRepository extends RuntimeDiagnosticsDatabase { export interface RuntimeDiagnosticIndex { totals: { assignments: number; - modeRuns: number; treeDxProxyAudit: number; ledgerEntries: number; fallbackOutputs: number; @@ -43,7 +41,6 @@ export async function loadRuntimeDiagnosticIndex( const totals = await database.first( `SELECT (SELECT COUNT(*) FROM capacity_provider_assignments WHERE team_id = ? AND project_id = ?) AS assignment_count, - (SELECT COUNT(*) FROM agent_mode_runs WHERE team_id = ? AND project_id = ?) AS mode_run_count, (SELECT COUNT(*) FROM treedx_project_proxy_audit WHERE team_id = ? AND project_id = ?) AS treedx_audit_count, (SELECT COUNT(*) FROM capacity_ledger_entries WHERE team_id = ? AND project_id = ? AND phase IN ('task_completed_actual_settlement', 'reservation_released', 'task_failed_refund')) AS ledger_entry_count, @@ -53,14 +50,12 @@ export async function loadRuntimeDiagnosticIndex( input.teamId, input.projectId, input.teamId, input.projectId, input.teamId, input.projectId, - input.teamId, input.projectId, ], ); if (input.assignmentIds.length === 0) { return { totals: { assignments: Number(totals?.assignment_count ?? 0), - modeRuns: Number(totals?.mode_run_count ?? 0), treeDxProxyAudit: Number(totals?.treedx_audit_count ?? 0), ledgerEntries: Number(totals?.ledger_entry_count ?? 0), fallbackOutputs: Number(totals?.fallback_output_count ?? 0), @@ -87,7 +82,6 @@ export async function loadRuntimeDiagnosticIndex( return { totals: { assignments: Number(totals?.assignment_count ?? 0), - modeRuns: Number(totals?.mode_run_count ?? 0), treeDxProxyAudit: Number(totals?.treedx_audit_count ?? 0), ledgerEntries: Number(totals?.ledger_entry_count ?? 0), fallbackOutputs: Number(totals?.fallback_output_count ?? 0), @@ -120,8 +114,7 @@ export async function buildProjectCapacityRuntimeDiagnostics( limit: DEFAULT_CAPACITY_PAGE_LIMIT, }); const assignments = assignmentPage.items; - const [modeRunPage, treeDxProxyAuditPage, fallbackOutputPage] = await Promise.all([ - repository.listAgentModeRunsPage(projectId, { limit: DEFAULT_CAPACITY_PAGE_LIMIT }), + const [treeDxProxyAuditPage, fallbackOutputPage] = await Promise.all([ repository.listTreeDxProxyAuditPage(projectId, { limit: DEFAULT_CAPACITY_PAGE_LIMIT }), repository.listAgentFallbackOutputsPage(projectId, { limit: DEFAULT_CAPACITY_PAGE_LIMIT }), ]); @@ -144,7 +137,6 @@ export async function buildProjectCapacityRuntimeDiagnostics( assignmentId: assignment.id, }] : []; }) as unknown as NonNullable[0]['explanations']>, - modeRuns: modeRunPage.items as unknown as Parameters[0]['modeRuns'], treeDxProxyAudit: treeDxProxyAuditPage.items, ledgerEntries: ledgerPage.items as unknown as Parameters[0]['ledgerEntries'], fallbackOutputs: fallbackOutputPage.items, @@ -152,7 +144,6 @@ export async function buildProjectCapacityRuntimeDiagnostics( auditedAssignmentIds: index.auditedAssignmentIds, windows: { assignments: { ...assignmentPage.page, total: index.totals.assignments }, - modeRuns: { ...modeRunPage.page, total: index.totals.modeRuns }, treeDxProxyAudit: { ...treeDxProxyAuditPage.page, total: index.totals.treeDxProxyAudit }, ledgerEntries: { ...ledgerPage.page, total: index.totals.ledgerEntries }, fallbackOutputs: { ...fallbackOutputPage.page, total: index.totals.fallbackOutputs }, diff --git a/src/api/capacity/services/support/acting-demand-source.ts b/src/api/capacity/services/support/acting-demand-source.ts deleted file mode 100644 index 64923665..00000000 --- a/src/api/capacity/services/support/acting-demand-source.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; -import { decodeDurableJsonArray,decodeDurableJsonObject } from '../../durable-json.ts'; -import type { DurableCapacityWorkdayRun } from '../../repositories/capacity/workdays/workday-run.ts'; -import { resolveEngineeringNodeAuthority } from '../accounts/engineering-source-authority.ts'; -import type { WorkdayProject } from '../capacity/workdays/policy/workday-project-policy.ts'; -import { projectAgentActivityRefs } from '../projects/projects-core/project-agent-activity-refs.ts'; -import { approvedOperationHandoffForWorkUnit } from '../capacity/assignments/handoffs/operation-handoff-lifecycle-service.ts'; - -export interface ActingDemandSource { - sourceType: 'capacity-plan'; sourceId: string; decisionId: string; capacityPlanId: string; - projectAgentClassId: string; agentId: string | null; handlerId: string; activityType: string; - priority: number; requestedSeconds: number; requiredCapabilities: string[]; agentContentPath: string | null; payload: Record; -} - -function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } -function text(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } -export function actingArtifactKinds(requirements: Record[]) { - return ['source-candidate', ...requirements.map((requirement) => text(requirement.outputType)).filter((value): value is string => Boolean(value))]; -} - -async function readyGraphNode(input: { - database: CapacityGovernanceDatabase; - decisionId: string; - projectId: string; - workGraphNodeId: string; - projectAgentClassId: string; - projectAgentClassSlug: string; - handlerId: string; - activityType: 'acting' | 'reviewing'; -}) { - const { database, decisionId, projectId, workGraphNodeId, projectAgentClassId, projectAgentClassSlug, handlerId, activityType } = input; - const row = await database.first( - `SELECT id, graph_json FROM decision_assignment_graphs WHERE decision_id = ? AND project_id = ? AND active = 1 AND status = 'ready' ORDER BY version DESC LIMIT 1`, - [decisionId, projectId], - ); - if (!row) return null; - const graph = decodeDurableJsonObject(row.graph_json, { owner: 'decision assignment graph', ownerId: String(row.id), column: 'graph_json' }); - const nodes = Array.isArray(graph.nodes) ? graph.nodes.map(record) : []; - const edges = Array.isArray(graph.edges) ? graph.edges.map(record) : []; - const node = nodes.find((candidate) => text(candidate.id) === workGraphNodeId); - if (!node || !['pending', 'ready'].includes(String(node.status ?? ''))) return null; - if (node.activityType !== activityType) return null; - if (![projectAgentClassId, projectAgentClassSlug].includes(text(node.targetAgentClass) ?? '')) return null; - const graphHandler = text(node.handler); - if (graphHandler && graphHandler !== handlerId) return null; - const incoming = edges.filter((edge) => text(edge.toNodeId) === workGraphNodeId); - for (const edge of incoming) { - const predecessor = nodes.find((candidate) => text(candidate.id) === text(edge.fromNodeId)); - if (!predecessor || predecessor.status !== 'completed') return null; - } - const contractIds = Array.isArray(node.requiredDeliverableContractIds) ? node.requiredDeliverableContractIds.map(String) : []; - for (const contractId of contractIds) { - if (!await database.first(`SELECT id FROM deliverable_contracts WHERE id = ? AND status = 'approved' LIMIT 1`, [contractId])) return null; - } - const authority = await resolveEngineeringNodeAuthority({ database, graphId: String(row.id), graph, node }); - const outputRequirements = Array.isArray(node.outputRequirements) ? node.outputRequirements.map(record) : []; - return { - graphId: String(row.id), nodeId: workGraphNodeId, - stage: text(record(node.metadata).stage), - outputRequirements, - ...authority, - }; -} - -async function classAuthorizesActing(database: CapacityGovernanceDatabase, projectId: string, classId: string, agentId: string | null, handlerId: string, activityType: 'acting' | 'reviewing') { - const row = await database.first(`SELECT slug, status, allowed_modes_json, handler_refs_json FROM project_agent_classes WHERE id = ? AND project_id = ? LIMIT 1`, [classId, projectId]); - if (!row || row.status !== 'active') return null; - const modes = decodeDurableJsonArray(row.allowed_modes_json, { owner: 'project agent class', ownerId: classId, column: 'allowed_modes_json' }); - if (!modes.includes('acting')) return null; - const refs = decodeDurableJsonObject(row.handler_refs_json, { owner: 'project agent class', ownerId: classId, column: 'handler_refs_json' }); - const activity = projectAgentActivityRefs(refs, activityType).find((agent) => agent.handlerId === handlerId && (!agentId || agent.agentId === agentId)); - return activity ? { slug: text(row.slug) ?? classId, contentPath: activity.contentPath } : null; -} - -export async function listActingDemandSources( - database: CapacityGovernanceDatabase, - run: DurableCapacityWorkdayRun, - project: WorkdayProject, - workdayId: string, -): Promise { - const rows = await database.all( - `SELECT plan.*, readiness.human_approval_state, readiness.execution_readiness, readiness.planning_inputs_status - FROM agent_capacity_plans plan LEFT JOIN decision_planning_statuses readiness ON readiness.decision_id = plan.decision_id - WHERE plan.team_id = ? AND plan.project_id = ? AND plan.status IN ('accepted','scheduled','active') - AND (plan.work_day_id = ? OR plan.work_day_id IS NULL) - ORDER BY plan.created_at DESC, plan.id DESC LIMIT 50`, - [run.teamId, project.id, workdayId], - ); - const sources: ActingDemandSource[] = []; - const seenDecisions = new Set(); - for (const row of rows) { - const decisionId = text(row.decision_id); - if (!decisionId || seenDecisions.has(decisionId)) continue; - seenDecisions.add(decisionId); - if (row.human_approval_state !== 'approved') continue; - if (!['ready', 'waived'].includes(String(row.execution_readiness ?? ''))) continue; - if (!['complete', 'waived'].includes(String(row.planning_inputs_status ?? ''))) continue; - const workUnits = decodeDurableJsonArray(row.work_units_json, { owner: 'capacity plan', ownerId: String(row.id), column: 'work_units_json' }); - for (const [index, value] of workUnits.entries()) { - const unit = record(value); - if (unit.mode && unit.mode !== 'acting') continue; - if (Array.isArray(unit.blockers) && unit.blockers.length > 0) continue; - const projectAgentClassId = text(unit.projectAgentClassId); - const activityType = text(unit.activityType ?? record(unit.decisionInput).activityType) === 'reviewing' ? 'reviewing' : 'acting'; - const handlerId = text(unit.handlerId ?? record(unit.decisionInput).handlerId); - const requestedSeconds = Number(unit.highSeconds ?? unit.expectedSeconds ?? 0); - if (!projectAgentClassId || !handlerId || !Number.isInteger(requestedSeconds) || requestedSeconds <= 0) throw new CapacityGovernanceError( - 'capacity_workday_acting_demand_invalid', `Capacity plan ${String(row.id)} has an invalid acting work unit.`, 500, - { capacityPlanId: String(row.id), workUnitIndex: index }, - ); - const sourceId = text(unit.id) ?? `${String(row.id)}:${index}`; - const estimateId = text(unit.estimateId ?? record(unit.metadata).estimateId ?? record(unit.decisionInput).estimateId ?? record(record(unit.decisionInput).metadata).estimateId); - const acceptedEstimate = estimateId ? await database.first(`SELECT id FROM structured_agent_estimates - WHERE id = ? AND team_id = ? AND project_id = ? AND decision_id = ? AND status = 'accepted' LIMIT 1`, - [estimateId, run.teamId, project.id, decisionId]) : null; - if (!acceptedEstimate) continue; - const workGraphNodeId = text(unit.workGraphNodeId ?? record(unit.decisionInput).workGraphNodeId); - const agentId = text(unit.agentId ?? record(unit.decisionInput).agentId); - if (!workGraphNodeId) continue; - const authorizedClass = await classAuthorizesActing(database, project.id, projectAgentClassId, agentId, handlerId, activityType); - if (!authorizedClass) continue; - const graphNode = await readyGraphNode({ - database, decisionId, projectId: project.id, workGraphNodeId, projectAgentClassId, - projectAgentClassSlug: authorizedClass.slug, handlerId, activityType, - }); - if (!graphNode) continue; - const requiredCapabilities = Array.isArray(unit.requiredCapabilities) ? unit.requiredCapabilities.map(String).filter(Boolean) : []; - const capacityPlanId = String(row.id); - const proposalId = text(row.proposal_id); - const operationHandoff = await approvedOperationHandoffForWorkUnit(database, { - teamId: run.teamId, projectId: project.id, decisionId, proposalId, - workUnitId: sourceId, workGraphNodeId: graphNode.nodeId, - }); - const capacityPlanStatus = String(row.status); - const decisionInput = decodeDurableJsonObject(JSON.stringify(record(unit.decisionInput)), { owner: 'capacity plan work unit', ownerId: sourceId, column: 'decisionInput' }); - const governedInput = { - ...record(decisionInput.input), - workGraphId: graphNode.graphId, - workGraphNodeId: graphNode.nodeId, - ...(graphNode.stage ? { workGraphStage: graphNode.stage } : {}), - ...(text(graphNode.outputRequirements.find((requirement) => requirement.required !== false)?.outputType - ?? graphNode.outputRequirements[0]?.outputType) ? { - artifactKind: text(graphNode.outputRequirements.find((requirement) => requirement.required !== false)?.outputType - ?? graphNode.outputRequirements[0]?.outputType), - } : {}), - ...(graphNode.exactBaseRef ? { - exactBaseRef: graphNode.exactBaseRef, - governedPredecessorEvidence: graphNode.predecessorEvidence, - ...(graphNode.reviewPolicy ? { governedReviewPolicy: graphNode.reviewPolicy } : {}), - } : {}), - }; - sources.push({ - sourceType: 'capacity-plan', sourceId, decisionId, capacityPlanId, projectAgentClassId, - agentId, handlerId, - activityType, priority: Number(record(unit.metadata).priority ?? 100), - requestedSeconds, requiredCapabilities, agentContentPath: authorizedClass.contentPath, - payload: { - agentContentPath: authorizedClass.contentPath, - decisionInput: { ...decisionInput, input: governedInput, metadata: { ...record(decisionInput.metadata), capacityPlanId, capacityPlanStatus, synthesizedFrom: 'capacity_plan', readiness: { executionReadiness: row.execution_readiness, planningInputsStatus: row.planning_inputs_status } } }, - capacityEnvelope: { ...record(unit.capacityEnvelope), metadata: { ...record(record(unit.capacityEnvelope).metadata), capacityPlanId, capacityPlanStatus, synthesizedFrom: 'capacity_plan' } }, requiredCapabilities, - executionReadiness: row.execution_readiness, planningInputsStatus: row.planning_inputs_status, - allowedOutputs: { - types: ['content_artifact_refs'], - artifactKinds: actingArtifactKinds(graphNode.outputRequirements), - }, - decisionExecutionInputId: unit.decisionExecutionInputId ?? null, estimateId, ...graphNode, - ...(operationHandoff ? { operationHandoffId: operationHandoff.id, operationHandoffDiscussionId: operationHandoff.discussionId, operationHandoffSourceMessageRefs: operationHandoff.sourceMessageRefs } : {}), - }, - }); - } - } - return sources; -} diff --git a/src/api/capacity/services/support/planning-demand-source.ts b/src/api/capacity/services/support/planning-demand-source.ts deleted file mode 100644 index 6d71856d..00000000 --- a/src/api/capacity/services/support/planning-demand-source.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { CapacityGovernanceDatabase } from '../../database.ts'; -import { decodeDurableJsonArray, decodeDurableJsonObject } from '../../durable-json.ts'; -import type { DurableCapacityWorkdayRun } from '../../repositories/capacity/workdays/workday-run.ts'; -import { serializeResearchWorkflowRow } from '../../repositories/operations/research-workflow.ts'; -import { listCapacityWorkdayContentArtifactRefs,type CapacityWorkdayResolvedIntent } from '../capacity/workdays/assignments/workday-assignment-context-service.ts'; -import { listTreeDxPlanningDemandSources,type TreeDxPlanningDemandSource } from '../capacity/workdays/content/workday-content-demand-source.ts'; -import type { CapacityWorkdayAgent } from '../capacity/workdays/policy/workday-agent-policy.ts'; -import type { WorkdayProject } from '../capacity/workdays/policy/workday-project-policy.ts'; -import type { WorkdayTreeDxConnectionStore } from '../capacity/workdays/treedx/workday-treedx-connection.ts'; - -export interface PlanningDemandSource { - sourceType: 'objective' | 'question' | 'proposal' | 'decision-review' | 'knowledge-gap' | 'release-readiness' - | 'idle-intent' | 'planning-input' | 'assignment-completion' | 'assignment-blockage' | 'workday-summary' | 'handoff' - | 'research-workflow'; - sourceId: string; - decisionId: string | null; - priority: number; - payload: Record; -} - -function record(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; -} - -function isTreeDxStore(value: CapacityGovernanceDatabase): value is CapacityGovernanceDatabase & WorkdayTreeDxConnectionStore { - const candidate = value as Partial; - return Boolean(candidate.config && typeof candidate.getProjectTreeDxLibrary === 'function'); -} - -export function preferredContentSource(agent: CapacityWorkdayAgent, sources: TreeDxPlanningDemandSource[]) { - const identity = `${agent.slug}:${agent.activityType}:${agent.handler}`.toLowerCase(); - const preference = agent.activityType === 'estimating' ? ['proposal', 'objective', 'question', 'knowledge-gap', 'decision-review'] - : identity.includes('review') ? ['proposal', 'decision-review', 'objective', 'question'] - : identity.includes('research') ? ['knowledge-gap', 'question', 'objective', 'proposal'] - : ['objective', 'question', 'knowledge-gap', 'proposal', 'decision-review']; - for (const type of preference) { - const source = sources.find((candidate) => candidate.sourceType === type); - if (source) return source; - } - return null; -} - -export function bindPlanningContentIntent(intent: CapacityWorkdayResolvedIntent, source: TreeDxPlanningDemandSource): CapacityWorkdayResolvedIntent { - const model = text(source.payload.model); - if (intent.subjectId || intent.subjectModel !== model) return intent; - const contentPath = text(source.payload.contentPath) ?? ''; - const relatedArtifact = { contentPath, model: model ?? '', artifactKind: model === 'proposal' ? 'planning_proposal' : model ?? '', - subjectId: source.sourceId.replace(/^[^:]+:/u, ''), producedByAgent: '', commitSha: text(source.payload.commitSha), digest: text(source.payload.digest) }; - return { ...intent, subjectId: relatedArtifact.subjectId, subjectPath: contentPath, relatedArtifact, - relatedArtifacts: [...(intent.relatedArtifacts ?? []), relatedArtifact] }; -} - -function researchRole(agent: CapacityWorkdayAgent) { - const identity = `${agent.slug}:${agent.activityType}:${agent.handler}`.toLowerCase(); - if (identity.includes('technical-writer') || identity.includes('technical_writer')) return 'technical-writer'; - if (identity.includes('review')) return 'reviewer'; - if (identity.includes('report')) return 'reporter'; - if (identity.includes('research')) return 'researcher'; - return null; -} - -function researchQuestionSlug(questionRef: string) { - return questionRef.replace(/^question:/u, '').trim(); -} - -function researchStageIntent(stage: string, intent: CapacityWorkdayResolvedIntent, questionRef: string): CapacityWorkdayResolvedIntent { - const artifactKind = stage === 'question-decomposition' ? 'planning_question' - : stage === 'cited-knowledge-publication' ? 'knowledge_page' - : stage === 'workday-report' ? 'workday_summary' - : 'planning_note'; - return { - ...intent, - artifactKind, - subjectModel: 'question', - subjectId: researchQuestionSlug(questionRef) || 'what-should-this-research-map-first', - }; -} - -async function researchWorkflowSource( - database: CapacityGovernanceDatabase, - project: WorkdayProject, - agent: CapacityWorkdayAgent, - intent: CapacityWorkdayResolvedIntent, -): Promise { - const role = researchRole(agent); - if (!role) return null; - const rows = await database.all( - `SELECT * FROM research_workflows WHERE project_id = ? AND status IN ('ready','running') ORDER BY created_at ASC, id ASC LIMIT 25`, - [project.id], - ); - for (const row of rows) { - const workflow = serializeResearchWorkflowRow(row); - const node = workflow?.nodes.find((candidate) => candidate.status === 'ready'); - if (!workflow || !node || node.role !== role) continue; - const reviewAttempts = Array.isArray(workflow.metadata?.reviewAttempts) ? workflow.metadata.reviewAttempts : []; - const latestReviewAttempt = reviewAttempts.length ? reviewAttempts.at(-1) : undefined; - return { - sourceType: 'research-workflow', sourceId: `${workflow.id}:${node.stage}`, decisionId: null, priority: 100, - payload: { - intent: researchStageIntent(node.stage, intent, workflow.questionRef), planningSource: 'research-workflow', researchWorkflowId: workflow.id, researchWorkflowStateVersion: workflow.stateVersion, - researchStage: node.stage, objectiveRef: workflow.objectiveRef, questionRef: workflow.questionRef, - minimumIndependentSources: workflow.minimumIndependentSources, maxRevisionCycles: workflow.maxRevisionCycles, - citations: workflow.citations, claims: workflow.claims, - reviewerRejectedUnsupportedClaims: workflow.reviewerRejectedUnsupportedClaims, - reviewerApprovedRevision: workflow.reviewerApprovedRevision, revisionCount: workflow.revisionCount, - ...(latestReviewAttempt ? { latestReviewAttempt } : {}), - }, - }; - } - return null; -} - -async function operationalSource( - database: CapacityGovernanceDatabase, - run: DurableCapacityWorkdayRun, - project: WorkdayProject, - agent: CapacityWorkdayAgent, -): Promise { - const identity = `${agent.slug}:${agent.activityType}:${agent.handler}`.toLowerCase(); - const failed = await database.first( - `SELECT assignment.id, assignment.lifecycle_code, assignment.lifecycle_reason FROM capacity_provider_assignments assignment - JOIN capacity_workday_demands demand ON demand.assignment_id = assignment.id - WHERE demand.workday_run_id = ? AND demand.project_id = ? AND assignment.status = 'failed' - ORDER BY assignment.updated_at ASC, assignment.id ASC LIMIT 1`, [run.id, project.id], - ); - if (failed && (identity.includes('review') || identity.includes('architect') || identity.includes('engineer'))) return { - sourceType: 'assignment-blockage', sourceId: `assignment:${String(failed.id)}`, decisionId: null, priority: 95, - payload: { planningSource: 'assignment-blockage', assignmentId: failed.id, - lifecycleCode: failed.lifecycle_code ?? null, lifecycleReason: failed.lifecycle_reason ?? null }, - }; - const completed = await database.first( - `SELECT assignment.id FROM capacity_provider_assignments assignment JOIN capacity_workday_demands demand ON demand.assignment_id = assignment.id - WHERE demand.workday_run_id = ? AND demand.project_id = ? AND assignment.status = 'completed' - ORDER BY assignment.completed_at ASC, assignment.id ASC LIMIT 1`, [run.id, project.id], - ); - if (completed && identity.includes('review')) return { - sourceType: 'assignment-completion', sourceId: `assignment:${String(completed.id)}`, decisionId: null, priority: 90, - payload: { planningSource: 'assignment-completion', assignmentId: completed.id }, - }; - if (completed && identity.includes('release')) return { - sourceType: 'release-readiness', sourceId: `run:${run.id}:project:${project.id}`, decisionId: null, priority: 85, - payload: { planningSource: 'release-readiness', completedAssignmentId: completed.id }, - }; - if (completed && identity.includes('report')) return { - ...(await listCapacityWorkdayContentArtifactRefs(database, run, project.id)).some((artifact) => - artifact.artifactKind === 'workday_summary' || artifact.artifactKind === 'workday-summary') - ? { sourceType: 'handoff' as const, sourceId: `run:${run.id}:project:${project.id}:handoff`, priority: 35, - payload: { planningSource: 'handoff', completedAssignmentId: completed.id } } - : { sourceType: 'workday-summary' as const, sourceId: `run:${run.id}:project:${project.id}`, priority: 40, - payload: { planningSource: 'workday-summary', completedAssignmentId: completed.id } }, - decisionId: null, - }; - return null; -} - -function text(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } -function positive(value: unknown, fallback: number): number { - const candidate = Number(value ?? fallback); - return Number.isFinite(candidate) && candidate > 0 ? candidate : fallback; -} - -export async function resolvePlanningDemandSource( - database: CapacityGovernanceDatabase, - run: DurableCapacityWorkdayRun, - project: WorkdayProject, - agent: CapacityWorkdayAgent, - intent: CapacityWorkdayResolvedIntent, -): Promise { - if (agent.activityType === 'chat') { - const invocation = await database.first( - `SELECT * FROM agent_invocation_requests - WHERE team_id = ? AND project_id = ? AND execution_id = ? AND agent_id = ? - AND execution_kind = 'conversation' AND status = 'admitted' - ORDER BY priority_class DESC, requested_at ASC, id ASC LIMIT 1`, - [run.teamId, project.id, run.id, agent.slug], - ); - if (invocation) { - const workdayParameters=record(run.parameters); - const metadata = decodeDurableJsonObject(invocation.metadata_json, { - owner: 'agent invocation', ownerId: String(invocation.id), column: 'metadata_json', - }); - const contentRefs = decodeDurableJsonArray(invocation.content_refs_json, { - owner: 'agent invocation', ownerId: String(invocation.id), column: 'content_refs_json', - }); - return { - sourceType: 'planning-input', sourceId: String(invocation.id), decisionId: text(invocation.decision_id), - priority: invocation.priority_class === 'human-interactive' ? 400 - : invocation.priority_class === 'workday-blocking-agent' ? 300 - : invocation.priority_class === 'agent-asynchronous' ? 200 : 100, - payload: { - intent: { ...intent, discussionIntent: 'discuss' }, planningSource: 'discussion-invocation', - agentInvocationId: String(invocation.id), discussionId: text(metadata.discussionId), - discussionMessageId: text(metadata.sourceMessageId), subjectPath: contentRefs[0] ?? null, - contentBaseRef: text(metadata.sourceCommit), contextPack: { digest: invocation.subject_digest ?? null, refs: contentRefs }, - parentWorkdayId: text(invocation.parent_workday_id), parentAssignmentId: text(invocation.parent_assignment_id), - providerSourceClosureDigest: text(workdayParameters.providerSourceClosureDigest), - handoffRootId: text(invocation.handoff_root_id), handoffParentId: text(invocation.handoff_parent_id), - handoffDepth: Number(invocation.handoff_depth ?? 0), triggerKind: text(invocation.trigger_kind), - communication: record(metadata.communication), - }, - }; - } - } - const discussion = record(record(run.parameters).discussion); - if (agent.activityType === 'chat' && text(discussion.discussionId) && text(discussion.messageId)) return { - sourceType: 'planning-input', sourceId: `discussion-message:${text(discussion.messageId)}`, decisionId: text(discussion.decisionId), priority: 110, - payload: { - intent: { ...intent, discussionIntent: text(discussion.intent) ?? 'discuss' }, planningSource: 'discussion-message', - discussionId: text(discussion.discussionId), discussionMessageId: text(discussion.messageId), - subjectPath: text(discussion.messagePath), contentBaseRef: text(discussion.commitSha), - contextPack: { digest: text(discussion.snapshotDigest), refs: [text(discussion.messagePath)].filter(Boolean) }, - }, - }; - const research = await researchWorkflowSource(database, project, agent, intent); - if (research) return research; - const rows = await database.all( - `SELECT * FROM agent_invocation_requests - WHERE team_id = ? AND project_id = ? AND execution_kind = 'workday' AND status = 'queued' - AND (project_agent_class_id = ? OR project_agent_class_id IS NULL) - ORDER BY requested_at ASC, id ASC LIMIT 25`, - [run.teamId, project.id, agent.projectAgentClassId], - ); - for (const row of rows) { - const metadata = decodeDurableJsonObject(row.metadata_json, { - owner: 'planning input request', ownerId: String(row.id ?? ''), column: 'metadata_json', - }); - const requestedAgent = text(metadata.agentId ?? metadata.agentSlug); - if (requestedAgent && requestedAgent !== agent.slug) continue; - const requestedActivity = text(metadata.activityType); - if (requestedActivity && requestedActivity !== agent.activityType) continue; - const assignmentInput = record(metadata.assignmentInput); - return { - sourceType: 'planning-input', sourceId: String(row.id), decisionId: text(row.decision_id), - priority: Number.isFinite(Number(metadata.priority)) ? Number(metadata.priority) : 50, - payload: { - ...assignmentInput, intent: { ...intent, ...assignmentInput }, prompt: text(row.prompt), planningInputRequestId: String(row.id), scopeHash: row.scope_hash ?? null, - planningSource: metadata.planningSource ?? 'planning-input', - ...(text(metadata.objectiveId) ? { objectiveId: text(metadata.objectiveId) } : {}), - }, - }; - } - const operational = await operationalSource(database, run, project, agent); - if (operational) return { ...operational, payload: { intent, ...operational.payload } }; - if (isTreeDxStore(database)) { - const content = preferredContentSource(agent, await listTreeDxPlanningDemandSources(database, run, project)); - if (content) return { - sourceType: content.sourceType, sourceId: content.sourceId, decisionId: null, priority: content.priority, - payload: { ...content.payload, intent: bindPlanningContentIntent(intent, content), subjectPath: content.payload.contentPath }, - }; - } - return { - sourceType: 'idle-intent', sourceId: `${run.id}:${project.id}:${agent.slug}`, - decisionId: null, priority: agent.planningPriority ?? 0, - payload: { intent, planningSource: 'configured-idle-intent' }, - }; -} diff --git a/src/api/capacity/services/support/planning-state-service.ts b/src/api/capacity/services/support/planning-state-service.ts deleted file mode 100644 index b3361468..00000000 --- a/src/api/capacity/services/support/planning-state-service.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { -type DecisionExecutionInput, -type DecisionExecutionInputRecord, -type DecisionExecutionInputStatus, -type DecisionExecutionReadinessStatus, -type DecisionPlanningStatus, -type PlanningInputRequest, -type PlanningInputRequestStatus, -} from '@treeseed/sdk/agent-capacity'; -import { AGENT_ACTIVITY_TYPES,type AgentActivityType } from '@treeseed/sdk/types/agents'; -import { randomUUID } from 'node:crypto'; -import type { CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; -import { PlanningStateRepository } from '../../repositories/support/planning-state.ts'; - -type JsonRecord = Record; - -function activityType(value: unknown): AgentActivityType | undefined { - if (value == null) return undefined; - if (typeof value === 'string' && AGENT_ACTIVITY_TYPES.includes(value as AgentActivityType)) return value as AgentActivityType; - throw new CapacityGovernanceError('agent_activity_type_invalid', 'activityType must identify a canonical agent activity profile.', 400); -} - -interface PlanningStateStore extends CapacityGovernanceDatabase { - getProject(projectId: string): Promise<{ id: string; teamId: string } | null>; - scopeHash(value: unknown): string; -} - -export interface UpsertDecisionPlanningStatusInput { - id?: string; - projectId: string; - decisionId: string; - humanApprovalState?: string | null; - executionReadiness?: DecisionExecutionReadinessStatus; - planningInputsStatus?: PlanningInputRequestStatus; - scopeHash?: string; - scope?: JsonRecord; - staleReason?: string | null; - readyAt?: string | null; - staleAt?: string | null; - metadata?: JsonRecord; -} - -export interface CreatePlanningInputRequestInput { - id?: string; - projectId: string; - projectAgentClassId?: string | null; - mode?: 'planning' | 'acting'; - status?: PlanningInputRequestStatus; - scopeHash?: string; - scope?: JsonRecord; - prompt?: string | null; - response?: JsonRecord; - metadata?: JsonRecord; - statusMetadata?: JsonRecord; - humanApprovalState?: string; - executionReadiness?: DecisionExecutionReadinessStatus; - completedAt?: string | null; - staleAt?: string | null; -} - -export interface CreateDecisionExecutionInputInput { - id?: string; - projectId: string; - projectAgentClassId?: string; - mode?: 'planning' | 'acting'; - status?: DecisionExecutionInputStatus; - scopeHash?: string; - scope?: JsonRecord; - input?: Partial; - capacity?: JsonRecord; - payload?: JsonRecord; - metadata?: JsonRecord; - humanApprovalState?: string; - planningInputsStatus?: PlanningInputRequestStatus; - acceptedAt?: string | null; - revisionRequestedAt?: string | null; - staleAt?: string | null; -} - -function idPart(value: string, fallback: string) { - return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').replace(/^-+|-+$/gu, '') || fallback; -} - -function record(value: unknown): JsonRecord { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; -} - -function optionalText(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -function mode(value: unknown, fallback: 'planning' | 'acting') { - const selected = value ?? fallback; - if (selected !== 'planning' && selected !== 'acting') { - throw new CapacityGovernanceError('decision_execution_input_mode_invalid', 'Mode must be planning or acting.', 400); - } - return selected; -} - -function assertAcceptedActingProvenance(input: Pick, status: DecisionExecutionInputStatus) { - if (status === 'accepted' && input.mode === 'acting' && !optionalText(input.workGraphNodeId)) { - throw new CapacityGovernanceError( - 'decision_execution_input_work_graph_node_required', - 'Accepted acting execution input requires workGraphNodeId provenance.', - 409, - ); - } -} - -export function buildDecisionPlanningStatusRecord(input: { - id?: string; - teamId: string; - projectId: string; - decisionId: string; - humanApprovalState?: string | null; - executionReadiness: DecisionExecutionReadinessStatus; - planningInputsStatus: PlanningInputRequestStatus; - scopeHash: string; - staleReason?: string | null; - readyAt?: string | null; - staleAt?: string | null; - metadata?: JsonRecord; - now: string; -}): DecisionPlanningStatus { - return { - id: input.id ?? `dps_${idPart(input.decisionId, 'decision')}`, - teamId: input.teamId, - projectId: input.projectId, - decisionId: input.decisionId, - humanApprovalState: input.humanApprovalState ?? null, - executionReadiness: input.executionReadiness, - planningInputsStatus: input.planningInputsStatus, - scopeHash: input.scopeHash, - staleReason: input.staleReason ?? null, - readyAt: input.readyAt ?? (input.executionReadiness === 'ready' ? input.now : null), - staleAt: input.staleAt ?? (input.executionReadiness === 'stale' ? input.now : null), - metadata: input.metadata ?? {}, - createdAt: input.now, - updatedAt: input.now, - }; -} - -export class PlanningStateService { - private readonly repository: PlanningStateRepository; - - constructor(private readonly store: PlanningStateStore) { - this.repository = new PlanningStateRepository(store); - } - - async upsertPlanningStatus(input: UpsertDecisionPlanningStatusInput) { - const project = await this.store.getProject(input.projectId); - if (!project) return null; - if (!input.decisionId) throw new CapacityGovernanceError('decision_id_required', 'decisionId is required.', 400); - const now = new Date().toISOString(); - const readiness = input.executionReadiness ?? 'draft'; - const scopeHash = input.scopeHash ?? this.store.scopeHash(input.scope ?? { projectId: project.id, decisionId: input.decisionId, metadata: input.metadata ?? {} }); - return this.repository.upsertPlanningStatus(buildDecisionPlanningStatusRecord({ - ...input, teamId: project.teamId, projectId: project.id, scopeHash, now, - decisionId: input.decisionId, - executionReadiness: readiness, - planningInputsStatus: input.planningInputsStatus ?? (readiness === 'ready' ? 'complete' : 'requested'), - })); - } - - getPlanningStatus(decisionId: string) { return this.repository.getPlanningStatus(decisionId); } - listPlanningRequests(decisionId: string) { return this.repository.listPlanningRequests(decisionId); } - listExecutionInputs(decisionId: string, filters: { status?: DecisionExecutionInputStatus | null } = {}) { return this.repository.listExecutionInputs(decisionId, filters); } - getExecutionInput(id: string) { return this.repository.getExecutionInput(id); } - - async createPlanningRequest(decisionId: string, input: CreatePlanningInputRequestInput) { - const project = await this.store.getProject(input.projectId); - if (!project) return null; - const now = new Date().toISOString(); - const requestMode = mode(input.mode, 'planning'); - const scopeHash = input.scopeHash ?? this.store.scopeHash(input.scope ?? { decisionId, projectId: project.id, projectAgentClassId: input.projectAgentClassId ?? null, mode: requestMode }); - const request: PlanningInputRequest = { - id: input.id ?? randomUUID(), teamId: project.teamId, projectId: project.id, decisionId, - projectAgentClassId: input.projectAgentClassId ?? null, mode: requestMode, status: input.status ?? 'requested', - scopeHash, prompt: input.prompt ?? null, response: input.response ?? {}, metadata: input.metadata ?? {}, - requestedAt: now, completedAt: input.completedAt ?? null, staleAt: input.staleAt ?? null, - }; - const planning = buildDecisionPlanningStatusRecord({ - teamId: project.teamId, projectId: project.id, decisionId, humanApprovalState: input.humanApprovalState ?? 'approved', - executionReadiness: input.executionReadiness ?? 'blocked', planningInputsStatus: request.status, - scopeHash, metadata: { source: 'planning_input_request', ...(input.statusMetadata ?? {}) }, now, - }); - return this.repository.createPlanningRequest(request, planning); - } - - async createExecutionInput(decisionId: string, input: CreateDecisionExecutionInputInput) { - const project = await this.store.getProject(input.projectId); - if (!project) return null; - const selectedMode = mode(input.mode ?? input.input?.mode, 'acting'); - const projectAgentClassId = input.projectAgentClassId ?? input.input?.projectAgentClassId; - if (!projectAgentClassId) throw new CapacityGovernanceError('project_agent_class_required', 'projectAgentClassId is required.', 400); - const now = new Date().toISOString(); - const sourceInput = input.input ?? {}; - const selectedActivityType = activityType(sourceInput.activityType); - const normalizedInput: DecisionExecutionInput = { - teamId: project.teamId, projectId: project.id, projectAgentClassId, mode: selectedMode, - ...(selectedActivityType ? { activityType: selectedActivityType } : {}), - workGraphNodeId: optionalText(sourceInput.workGraphNodeId), - taskId: optionalText(sourceInput.taskId), workDayId: optionalText(sourceInput.workDayId), - agentId: optionalText(sourceInput.agentId), handlerId: optionalText(sourceInput.handlerId), - capacity: { - ...record(sourceInput.capacity ?? input.capacity), - mode: selectedMode, - teamId: project.teamId, - projectId: project.id, - }, input: record(sourceInput.input ?? input.payload), - metadata: record(sourceInput.metadata ?? input.metadata), - }; - const scopeHash = input.scopeHash ?? this.store.scopeHash(input.scope ?? normalizedInput); - const selectedStatus = input.status ?? 'proposed'; - assertAcceptedActingProvenance(normalizedInput, selectedStatus); - const value: DecisionExecutionInputRecord = { - id: input.id ?? randomUUID(), teamId: project.teamId, projectId: project.id, decisionId, - workGraphNodeId: normalizedInput.workGraphNodeId ?? null, - projectAgentClassId, mode: selectedMode, status: selectedStatus, scopeHash, input: normalizedInput, - metadata: input.metadata ?? {}, acceptedAt: input.acceptedAt ?? (selectedStatus === 'accepted' ? now : null), - revisionRequestedAt: input.revisionRequestedAt ?? null, staleAt: input.staleAt ?? null, createdAt: now, updatedAt: now, - }; - const planning = buildDecisionPlanningStatusRecord({ - teamId: project.teamId, projectId: project.id, decisionId, humanApprovalState: input.humanApprovalState ?? 'approved', - executionReadiness: selectedStatus === 'accepted' ? 'ready' : 'blocked', planningInputsStatus: input.planningInputsStatus ?? 'complete', - scopeHash, metadata: { source: 'decision_execution_input' }, now, - }); - return this.repository.createExecutionInput(value, planning); - } - - async transitionExecutionInput(id: string, status: DecisionExecutionInputStatus, input: { reason?: string | null; metadata?: JsonRecord } = {}) { - const existing = await this.repository.getExecutionInput(id); - if (!existing) return null; - assertAcceptedActingProvenance(existing.input, status); - const now = new Date().toISOString(); - const value: DecisionExecutionInputRecord = { - ...existing, status, - acceptedAt: status === 'accepted' ? existing.acceptedAt ?? now : existing.acceptedAt ?? null, - revisionRequestedAt: status === 'revision_requested' ? existing.revisionRequestedAt ?? now : existing.revisionRequestedAt ?? null, - metadata: { ...(existing.metadata ?? {}), ...(input.metadata ?? {}), reason: input.reason ?? null }, updatedAt: now, - }; - const planning = buildDecisionPlanningStatusRecord({ - teamId: existing.teamId, projectId: existing.projectId, decisionId: existing.decisionId, humanApprovalState: 'approved', - executionReadiness: status === 'accepted' ? 'ready' : 'blocked', planningInputsStatus: status === 'accepted' ? 'complete' : 'requested', - scopeHash: existing.scopeHash, metadata: { latestExecutionInputId: existing.id, latestExecutionInputStatus: status }, now, - }); - return this.repository.transitionExecutionInput(value, planning); - } -} diff --git a/src/api/capacity/services/support/structured-estimate-service.ts b/src/api/capacity/services/support/structured-estimate-service.ts deleted file mode 100644 index d1334d7b..00000000 --- a/src/api/capacity/services/support/structured-estimate-service.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { StructuredAgentEstimateRecord,StructuredAgentEstimateStatus } from '@treeseed/sdk/agent-capacity'; -import { validateStructuredAgentEstimate } from '../../policy/decision-work.ts'; -import { randomUUID } from 'node:crypto'; -import type { CapacityGovernanceDatabase } from '../../database.ts'; -import { CapacityGovernanceError } from '../../database.ts'; -import { StructuredAgentEstimateRepository } from '../../repositories/support/structured-estimate.ts'; -import { buildDecisionPlanningStatusRecord } from './planning-state-service.ts'; - -type JsonRecord = Record; -interface StructuredEstimateStore extends CapacityGovernanceDatabase { - getProject(projectId: string): Promise<{ id: string; teamId: string } | null>; - scopeHash(value: unknown): string; -} - -function record(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; } -function strings(value: unknown): string[] { return Array.isArray(value) ? value.map(String).map((entry) => entry.trim()).filter(Boolean) : []; } -function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } -function text(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function createStatus(value: unknown): StructuredAgentEstimateStatus { - const selected = value ?? 'submitted'; - if (selected !== 'submitted' && selected !== 'accepted' && selected !== 'rejected') throw new CapacityGovernanceError('structured_agent_estimate_status_invalid', `Cannot create a structured estimate in ${String(selected)} state.`, 400); - return selected; -} - -export class StructuredAgentEstimateService { - private readonly repository: StructuredAgentEstimateRepository; - constructor(private readonly store: StructuredEstimateStore) { this.repository = new StructuredAgentEstimateRepository(store); } - - get(id: string) { return this.repository.get(id); } - listDecision(decisionId: string, status?: StructuredAgentEstimateStatus | null) { return this.repository.listDecision(decisionId, status); } - - async create(decisionId: string, input: JsonRecord) { - const projectId = text(input.projectId); - if (!projectId) throw new CapacityGovernanceError('project_id_required', 'projectId is required.', 400); - const project = await this.store.getProject(projectId); - if (!project) return null; - if (!decisionId) throw new CapacityGovernanceError('decision_id_required', 'decisionId is required.', 400); - const now = new Date().toISOString(); - const status = createStatus(input.status); - const minimum = Number(input.minSeconds ?? 0); - const expected = Number(input.expectedSeconds ?? input.minSeconds ?? 0); - const maximum = Number(input.maxSeconds ?? input.expectedSeconds ?? input.minSeconds ?? 0); - const estimate: StructuredAgentEstimateRecord = { - schemaVersion: input.schemaVersion === 3 ? 3 : input.schemaVersion === 2 ? 2 : 1, - id: text(input.id) || randomUUID(), teamId: project.teamId, projectId: project.id, decisionId, - proposalId: text(input.proposalId) || null, workUnitId: text(input.workUnitId) || null, - agentClass: text(input.agentClass ?? input.projectAgentClassSlug ?? input.projectAgentClassId), agentId: text(input.agentId) || null, - minSeconds: minimum, expectedSeconds: expected, maxSeconds: maximum, - confidence: (input.confidence ?? 'medium') as StructuredAgentEstimateRecord['confidence'], riskLevel: (input.riskLevel ?? 'medium') as StructuredAgentEstimateRecord['riskLevel'], - assumptions: strings(input.assumptions), blockers: strings(input.blockers), dependencies: array(input.dependencies) as StructuredAgentEstimateRecord['dependencies'], - expectedOutputs: array(input.expectedOutputs) as StructuredAgentEstimateRecord['expectedOutputs'], acceptanceCriteria: strings(input.acceptanceCriteria), completionEvidence: strings(input.completionEvidence), - workBreakdown: input.workBreakdown as StructuredAgentEstimateRecord['workBreakdown'], - groupSnapshot: input.groupSnapshot as StructuredAgentEstimateRecord['groupSnapshot'], - proposalRevision: input.proposalRevision as StructuredAgentEstimateRecord['proposalRevision'], - decisionRevision: input.decisionRevision as StructuredAgentEstimateRecord['decisionRevision'], - providerNativeRanges: array(input.providerNativeRanges) as StructuredAgentEstimateRecord['providerNativeRanges'], - requiredProviderCapabilities: strings(input.requiredProviderCapabilities), acceptableProviderClasses: strings(input.acceptableProviderClasses), - agentDefinitionRevision: input.agentDefinitionRevision as StructuredAgentEstimateRecord['agentDefinitionRevision'], - createdAt: text(input.createdAt) || now, metadata: record(input.metadata), status, - acceptedAt: status === 'accepted' ? now : null, rejectedAt: status === 'rejected' ? now : null, - }; - const validation = validateStructuredAgentEstimate(estimate); - if (!validation.ok) throw new CapacityGovernanceError('invalid_structured_agent_estimate', 'Structured agent estimate is invalid.', 400, { diagnostics: validation.diagnostics }); - const scopeHash = this.store.scopeHash({ projectId: project.id, decisionId, estimateId: estimate.id }); - const planning = buildDecisionPlanningStatusRecord({ - teamId: project.teamId, projectId: project.id, decisionId, humanApprovalState: text(input.humanApprovalState) || 'approved', - executionReadiness: 'blocked', planningInputsStatus: 'complete', scopeHash, now, - metadata: { source: 'structured_agent_estimate', latestEstimateId: estimate.id }, - }); - return this.repository.create(estimate, record(input.recordMetadata), planning); - } - - async transition(id: string, to: 'accepted' | 'rejected', input: JsonRecord) { - const existing = await this.repository.get(id); - if (!existing) return null; - return this.repository.transition(id, ['submitted'], to, { - ...(existing.metadata ?? {}), ...record(input.metadata), reason: text(input.reason) || null, - }, new Date().toISOString()); - } -} diff --git a/src/api/capacity/services/treedx/graph/decision-work-graph-service.ts b/src/api/capacity/services/treedx/graph/decision-work-graph-service.ts deleted file mode 100644 index d1997c7e..00000000 --- a/src/api/capacity/services/treedx/graph/decision-work-graph-service.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { -DecisionAssignmentGraphRecord, -DeliverableContractRecord, -DeliverableManifestRecord, -StructuredAgentEstimate, -StructuredAgentEstimateStatus, -} from '@treeseed/sdk/agent-capacity'; -import { activateDecisionAssignmentGraph,advanceDecisionAssignmentGraph,compileDecisionAssignmentGraphFromEstimates,validateDecisionAssignmentGraph,validateDeliverableManifest } from '../../../policy/decision-work.ts'; -import { compileGovernedRevisionCycle } from '../../../policy/revision-cycle.ts'; -import { randomUUID } from 'node:crypto'; -import type { CapacityGovernanceDatabase } from '../../../database.ts'; -import { CapacityGovernanceError } from '../../../database.ts'; -import { DecisionWorkGraphRepository } from '../../../repositories/treedx/graph/decision-work-graph.ts'; -import { buildDecisionPlanningStatusRecord } from '../../support/planning-state-service.ts'; -import { validateDecisionAuthority,validateExecutionAuthorityReceipt } from '../../../../governance/decision-authority.ts'; - -type JsonRecord = Record; -interface DecisionWorkGraphStore extends CapacityGovernanceDatabase { - getProject(projectId: string): Promise<{ id: string; teamId: string } | null>; - scopeHash(value: unknown): string; - listStructuredAgentEstimatesForDecision(decisionId: string, filters: { status: StructuredAgentEstimateStatus }): Promise; -} - -function record(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; } -function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } -function text(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function idPart(value: string, fallback: string) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/gu, '-').replace(/^-+|-+$/gu, '') || fallback; } -function nextTransitionTimestamp(previous: string) { - const candidate = new Date().toISOString(); - return candidate === previous ? new Date(Date.parse(previous) + 1).toISOString() : candidate; -} - -export class DecisionWorkGraphService { - private readonly repository: DecisionWorkGraphRepository; - constructor(private readonly store: DecisionWorkGraphStore) { this.repository = new DecisionWorkGraphRepository(store); } - getGraph(id: string) { return this.repository.getGraph(id); } - listGraphs(decisionId: string, active?: boolean) { return this.repository.listGraphs(decisionId, active); } - getContract(id: string) { return this.repository.getContract(id); } - getManifest(id: string) { return this.repository.getManifest(id); } - - async compile(decisionId: string, input: JsonRecord) { - const projectId = text(input.projectId); - if (!projectId) throw new CapacityGovernanceError('project_id_required', 'projectId is required.', 400); - const project = await this.store.getProject(projectId); - if (!project) return null; - if (!decisionId) throw new CapacityGovernanceError('decision_id_required', 'decisionId is required.', 400); - const authority = await validateDecisionAuthority(this.store, decisionId, { teamId: project.teamId, projectId: project.id }); - if (!authority.valid || !authority.current) throw new CapacityGovernanceError(authority.code ?? 'governance_decision_invalid', authority.message ?? 'Decision authority is invalid.', 409, { decisionId, projectId }); - const version = input.version == null ? await this.repository.nextVersion(decisionId) : Number(input.version); - if (!Number.isInteger(version) || version < 1) throw new CapacityGovernanceError('decision_assignment_graph_version_invalid', 'Graph version must be a positive integer.', 400); - const now = new Date().toISOString(); - const workflowKind = text(input.workflowKind) || 'estimate-derived'; - if (workflowKind !== 'estimate-derived') { - throw new CapacityGovernanceError('decision_assignment_graph_workflow_invalid', `Unsupported workflowKind ${workflowKind}.`, 400); - } - const estimateStatus = (input.estimateStatus ?? 'accepted') as StructuredAgentEstimateStatus; - const estimates = Array.isArray(input.estimates) ? input.estimates as StructuredAgentEstimate[] : await this.store.listStructuredAgentEstimatesForDecision(decisionId, { status: estimateStatus }); - if (!estimates.length) throw new CapacityGovernanceError('decision_assignment_graph_estimates_required', 'At least one accepted structured estimate is required to compile a decision assignment graph.', 409, { decisionId, estimateStatus }); - const exactBaseRef=text(input.exactBaseRef); - if(estimates.some((estimate)=>estimate.schemaVersion===3)&&!/^[0-9a-f]{7,64}$/iu.test(exactBaseRef)) throw new CapacityGovernanceError('decision_assignment_graph_exact_base_required','Canonical estimate v3 workflows require an immutable exact base ref.',400); - const compiled = compileDecisionAssignmentGraphFromEstimates({ id: text(input.id) || `dag_${idPart(decisionId, 'decision')}_v${version}`, teamId: project.teamId, projectId: project.id, decisionId, version, estimates, exactBaseRef:exactBaseRef||undefined, compiledAt: now, executionMode:text(input.executionMode)==='production'?'production':'simulation', reviewersByAgentClass:record(input.reviewersByAgentClass) as Record, maximumRevisionCycles:Number.isInteger(Number(input.maximumRevisionCycles))?Number(input.maximumRevisionCycles):undefined }); - if (compiled.diagnostics.some((entry) => entry.severity === 'error')) throw new CapacityGovernanceError('decision_assignment_graph_invalid', 'Decision assignment graph compilation failed.', 400, { diagnostics: compiled.diagnostics }); - const requestedStatus = input.graphStatus ?? compiled.graph.status; - if (requestedStatus !== 'draft' && requestedStatus !== 'compiled' && requestedStatus !== 'blocked') throw new CapacityGovernanceError('decision_assignment_graph_status_invalid', `Cannot create a graph in ${String(requestedStatus)} state.`, 400); - const contractIds = new Map(compiled.graph.deliverableContracts.map((contract) => [contract.id, `${contract.id}:v${version}`])); - const graph: DecisionAssignmentGraphRecord = { - ...compiled.graph, - deliverableContracts: compiled.graph.deliverableContracts.map((contract) => ({ ...contract, id: contractIds.get(contract.id)!, metadata:contract.metadata?{ - ...contract.metadata,...(contract.metadata.reviewedContractId?{reviewedContractId:contractIds.get(String(contract.metadata.reviewedContractId))??contract.metadata.reviewedContractId}:{}), - }:contract.metadata })), - nodes: compiled.graph.nodes.map((node) => ({ - ...node, - requiredDeliverableContractIds: node.requiredDeliverableContractIds.map((id) => contractIds.get(id) ?? id), - metadata: node.metadata ? { - ...node.metadata, - ...(node.metadata.producesDeliverableContractId ? { producesDeliverableContractId: contractIds.get(String(node.metadata.producesDeliverableContractId)) ?? node.metadata.producesDeliverableContractId } : {}), - ...(node.metadata.reviewedContractId ? { reviewedContractId: contractIds.get(String(node.metadata.reviewedContractId)) ?? node.metadata.reviewedContractId } : {}), - } : node.metadata, - })), - status: requestedStatus, active: false, createdAt: now, updatedAt: now, - metadata: { - ...(compiled.graph.metadata ?? {}), ...record(input.metadata), diagnostics: compiled.diagnostics, - proposalId: authority.current.proposalId, proposalVersion: authority.current.proposalVersion, - proposalContentHash: authority.current.proposalContentHash, - decisionDependencies: authority.current.decisionDependencies, - }, - }; - const validation = validateDecisionAssignmentGraph(graph); - if (!validation.ok) throw new CapacityGovernanceError('decision_assignment_graph_invalid', 'Decision assignment graph is invalid.', 400, { diagnostics: validation.diagnostics }); - const contracts: DeliverableContractRecord[] = graph.deliverableContracts.map((contract) => ({ - ...contract, metadata: { ...(contract.metadata ?? {}), - ...(contract.metadata?.reviewedContractId?{reviewedContractId:contractIds.get(String(contract.metadata.reviewedContractId))??contract.metadata.reviewedContractId}:{}), - graphId: graph.id, graphVersion: graph.version }, createdAt: now, updatedAt: now, - })); - return this.repository.createGraph(graph, contracts); - } - - async activate(graphId: string) { - const graph = await this.repository.getGraph(graphId); - if (!graph) return null; - if (graph.status !== 'draft' && graph.status !== 'compiled' && graph.status !== 'ready') throw new CapacityGovernanceError('decision_assignment_graph_activation_conflict', `Graph in ${graph.status} state cannot be activated.`, 409, { graphId }); - const now = new Date().toISOString(); - const planning = buildDecisionPlanningStatusRecord({ - teamId: graph.teamId, projectId: graph.projectId, decisionId: graph.decisionId, humanApprovalState: 'approved', executionReadiness: 'ready', planningInputsStatus: 'complete', - scopeHash: this.store.scopeHash({ projectId: graph.projectId, decisionId: graph.decisionId, graphId, graphVersion: graph.version }), now, - metadata: { source: 'decision_assignment_graph', activeGraphId: graphId, activeGraphVersion: graph.version }, - }); - return this.repository.activate({ ...activateDecisionAssignmentGraph(graph), active: graph.active, createdAt: graph.createdAt, updatedAt: now }, planning, now, graph.status); - } - - async submitManifest(contractId: string, input: JsonRecord) { - const contract = await this.repository.getContract(contractId); - if (!contract) return null; - const requestedId = text(input.id); - if (requestedId) { - const existing = await this.repository.getManifest(requestedId); - if (existing) { - if (existing.deliverableContractId !== contractId) throw new CapacityGovernanceError('deliverable_manifest_idempotency_conflict', 'Deliverable manifest id is already bound to another contract.', 409, { manifestId: requestedId, contractId }); - return existing; - } - } - if (contract.status === 'approved') throw new CapacityGovernanceError('deliverable_contract_transition_conflict', 'An approved deliverable contract cannot accept another manifest.', 409, { contractId }); - const graphId = text(contract.metadata?.graphId); - const graph = graphId ? await this.repository.getGraph(graphId) : null; - const node = graph?.nodes.find((entry) => entry.metadata?.producesDeliverableContractId === contract.id); - if (!graph || !graph.active || !node || !['ready', 'leased', 'running'].includes(node.status)) { - throw new CapacityGovernanceError('deliverable_contract_not_ready', 'Deliverable manifests require an active ready graph node.', 409, { contractId, graphId: graph?.id ?? graphId ?? null, nodeStatus: node?.status ?? null }); - } - const now = new Date().toISOString(); - const manifest: DeliverableManifestRecord = { - id: requestedId || randomUUID(), deliverableContractId: contract.id, projectId: contract.projectId, decisionId: contract.decisionId, - producedRefs: array(input.producedRefs) as DeliverableManifestRecord['producedRefs'], - coverage: input.coverage == null ? undefined : record(input.coverage) as unknown as DeliverableManifestRecord['coverage'], - summary: text(input.summary), readyForReview: input.readyForReview === true, submittedByAgentId: text(input.submittedByAgentId) || null, - submittedAt: text(input.submittedAt) || now, - sourceAuthority: input.sourceAuthority == null ? undefined : record(input.sourceAuthority) as unknown as DeliverableManifestRecord['sourceAuthority'], - metadata: record(input.metadata), createdAt: now, - }; - const validation = validateDeliverableManifest(manifest); - if (!validation.ok) throw new CapacityGovernanceError('invalid_deliverable_manifest', 'Deliverable manifest is invalid.', 400, { diagnostics: validation.diagnostics }); - return this.repository.submitManifest(contract, manifest); - } - - async transitionContract(contractId: string, to: 'approved' | 'rejected', input: JsonRecord) { - const contract = await this.repository.getContract(contractId); - if (!contract) return null; - const graphId = text(contract.metadata?.graphId); - const graph = graphId ? await this.repository.getGraph(graphId) : null; - if (!graph) throw new CapacityGovernanceError('deliverable_contract_graph_missing', 'Deliverable contract is not attached to a durable assignment graph.', 500, { contractId }); - const producingNode = graph.nodes.find((node) => node.metadata?.producesDeliverableContractId === contract.id); - if (!producingNode) throw new CapacityGovernanceError('deliverable_contract_node_missing', 'Deliverable contract has no producing graph node.', 500, { contractId, graphId }); - if (to === 'approved') { - const terminalBlockers = graph.edges - .filter((edge) => edge.toNodeId === producingNode.id && (edge.edgeType === 'blocks-completion' || edge.edgeType === 'blocks-release')) - .filter((edge) => graph.nodes.find((node) => node.id === edge.fromNodeId)?.status !== 'completed'); - if (terminalBlockers.length) throw new CapacityGovernanceError('deliverable_dependency_incomplete', 'Deliverable cannot be approved before its typed dependency gates complete.', 409, { contractId, graphId, blockers: terminalBlockers }); - } - if (contract.metadata?.platformControlled === true) { - if (to !== 'approved') throw new CapacityGovernanceError('platform_integration_rejection_invalid', 'Platform integration evidence may only be approved after exact verification.', 409, { contractId }); - const authority=record(input.executionAuthority); - const validation=await validateExecutionAuthorityReceipt(this.store,authority); - const expectedPolicy=graph.executionMode==='production'?'exact-approved-ref':'denied'; - if (!validation.valid || text(authority.graphId)!==graph.id || text(authority.projectId)!==graph.projectId - || text(authority.decisionId)!==graph.decisionId || text(authority.executionMode)!==graph.executionMode - || text(authority.upstreamMutationPolicy)!==expectedPolicy || text(authority.integratedCommit)!==text(authority.checkpointCommit)) { - throw new CapacityGovernanceError('platform_integration_authority_invalid', validation.message ?? 'Platform integration requires the exact valid execution-authority receipt.', 409, { contractId, graphId: graph.id }); - } - } - const now = nextTransitionTimestamp(graph.updatedAt); - const reason = text(input.reason); - const metadata = { ...(contract.metadata ?? {}), ...record(input.metadata), ...(to === 'approved' ? { approvedBy: text(input.approvedBy) || null } : { reason: reason || null }) }; - let updatedGraph: DecisionAssignmentGraphRecord; - let newContracts: DeliverableContractRecord[] = []; - if (to === 'approved') { - const contractStates = await Promise.all(graph.deliverableContracts.map((entry) => this.repository.getContract(entry.id))); - const approvedIds = new Set(contractStates.filter((entry) => entry?.status === 'approved').map((entry) => entry!.id)); - approvedIds.add(contract.id); - updatedGraph = { ...advanceDecisionAssignmentGraph(graph, contract.id, approvedIds), active: graph.active, createdAt: graph.createdAt, updatedAt: now }; - } else { - const reviewNode=graph.nodes.find((node)=>node.metadata?.producesDeliverableContractId===contract.id&&node.activityType==='reviewing'); - const actorNode=graph.nodes.find((node)=>node.id===reviewNode?.metadata?.reviewedNodeId); - const governedRevision=reviewNode&&actorNode&&text(input.rejectedCheckpointRef)&&text(input.findingsRef) - ?compileGovernedRevisionCycle(graph,{rejectedReviewNodeId:reviewNode.id,rejectedCheckpointRef:text(input.rejectedCheckpointRef),findingsRef:text(input.findingsRef),reason:reason||'Reviewer requested revision.',actorAgentClass:actorNode.targetAgentClass,reviewerAgentClass:reviewNode.targetAgentClass,availableSeconds:Math.max(1,Number(input.availableSeconds??actorNode.capacity.maxSeconds))}):null; - const revision=governedRevision; - if (revision) { - updatedGraph = { ...revision.graph, active: graph.active, createdAt: graph.createdAt, updatedAt: now }; - newContracts = revision.newContracts.map((entry) => ({ ...entry, metadata: { ...(entry.metadata ?? {}), graphId: graph.id, graphVersion: graph.version }, createdAt: now, updatedAt: now })); - } else { - updatedGraph = { - ...graph, - status: reviewNode ? 'blocked' : graph.status, - deliverableContracts: graph.deliverableContracts.map((entry) => entry.id === contract.id ? { ...entry, status: 'rejected' } : entry), - nodes: graph.nodes.map((node) => node.metadata?.producesDeliverableContractId === contract.id ? { ...node, status: 'completed' } : node), - metadata:{...(graph.metadata??{}),terminalReason:reviewNode?'revision-exhausted-or-missing-exact-evidence':graph.metadata?.terminalReason}, - updatedAt: now, - }; - } - } - const validation = validateDecisionAssignmentGraph(updatedGraph); - if (!validation.ok) throw new CapacityGovernanceError('decision_assignment_graph_transition_invalid', 'Deliverable transition would invalidate the assignment graph.', 500, { diagnostics: validation.diagnostics }); - return this.repository.transitionContract(contract, to, metadata, updatedGraph, graph.updatedAt, newContracts, now); - } -} diff --git a/src/api/capacity/services/treedx/repositories/treedx-proxy-effects.ts b/src/api/capacity/services/treedx/repositories/treedx-proxy-effects.ts index 8abe2c86..cb8470e4 100644 --- a/src/api/capacity/services/treedx/repositories/treedx-proxy-effects.ts +++ b/src/api/capacity/services/treedx/repositories/treedx-proxy-effects.ts @@ -73,15 +73,9 @@ export async function projectTreeDxProxyCommit(input: { const library = await input.store.getProjectTreeDxLibrary(input.projectId); const repositoryId = String(input.access.handle?.repositoryId ?? record(library).repositoryId ?? ''); if (!repositoryId) throw new CapacityGovernanceError('treedx_authoring_repository_missing','TreeDX commit journaling requires the durable repository identity.',500,{ projectId:input.projectId }); - const decision = record(assignment?.decisionInput); - const assignmentMetadata = record(assignment?.metadata); - const activityType = assignment ? String( - decision.activityType - ?? record(decision.metadata).activityType - ?? record(decision.input).activityType - ?? assignmentMetadata.activityType - ?? '', - ) || null : null; + const activityType = assignment + ? String(record(assignment.assignmentAttempt?.effectiveProfile).activity ?? '') || null + : null; await recordTreeDxAuthoringState(input.store,'unpublished',{ projectId:input.projectId, repositoryId, diff --git a/src/api/control-plane/catalog/providers/assignments.ts b/src/api/control-plane/catalog/providers/assignments.ts index bbbc2750..85706e09 100644 --- a/src/api/control-plane/catalog/providers/assignments.ts +++ b/src/api/control-plane/catalog/providers/assignments.ts @@ -29,7 +29,6 @@ export function createProviderAssignmentOperations(dependencies: ProviderAssignm { binding: operations.renewAssignment, handler: (input, context) => result(() => assignments.renew(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.startExecution, handler: (input, context) => result(() => assignments.startExecution(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.startCloseout, handler: (input, context) => result(() => assignments.startCloseout(context.providerAuth, input.path.assignmentId, input.body as Record)) }, - { binding: operations.completionPreflight, handler: (input, context) => result(() => assignments.preflight(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.discussionResponse, handler: (input, context) => result(() => assignments.respondToDiscussion(context.providerAuth, input.path.assignmentId, input.body as Record, context.idempotencyKey)) }, { binding: operations.notificationAcknowledge, handler: (input, context) => result(() => assignments.acknowledgeCommunication(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.traceEvent, handler: (input, context) => result(() => assignments.traceCommunication(context.providerAuth, input.path.assignmentId, input.body as Record)) }, @@ -38,7 +37,6 @@ export function createProviderAssignmentOperations(dependencies: ProviderAssignm { binding: operations.failAssignment, handler: (input, context) => result(() => assignments.fail(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.reportUsage, handler: (input, context) => result(() => assignments.reportUsage(context.providerAuth, input.path.assignmentId, input.body as Record, context.idempotencyKey)) }, { binding: operations.settleAssignment, handler: (input, context) => result(() => assignments.settle(context.providerAuth, input.path.assignmentId, input.body as Record, context.idempotencyKey)) }, - { binding: operations.createModeRun, handler: (input, context) => result(() => assignments.createModeRun(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.createEvent, handler: (input, context) => result(() => assignments.createEvent(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.publishSignal, handler: (input, context) => result(() => signals(context.providerAuth, input.path.assignmentId, input.body as Record)) }, { binding: operations.dispatchWorkflow, handler: (input, context) => result(() => workflows.dispatch(context.providerAuth, input.path.assignmentId, input.path.operationId, input.body as Record)) }, diff --git a/src/api/control-plane/mcp/completion-source.ts b/src/api/control-plane/mcp/completion-source.ts index b69d79c1..0a4c18c2 100644 --- a/src/api/control-plane/mcp/completion-source.ts +++ b/src/api/control-plane/mcp/completion-source.ts @@ -11,7 +11,6 @@ const sourceOperations: Readonly> = { connectionId: 'services.connections.list', agentSlug: 'agents.list', invocationId: 'communications.invocations.list', - capacityPlanId: 'plans.list', runId: 'workdays.list', assignmentId: 'assignments.list', operationId: 'operations.list', @@ -56,7 +55,7 @@ const promptSuggestions: Readonly> = { operate: ['Inspect current control-plane status', 'Diagnose blockers and suggest the next safe action'], research: ['Research the open question using governed knowledge', 'Identify evidence gaps before proposing work'], 'governance-review': ['Review the proposal against current evidence', 'Explain approval blockers and required revisions'], - 'workday-planning': ['Plan the next time-based workday', 'Explain allocation, borrowing, and starvation protections'], + 'workday-planning': ['Plan the next time-based workday', 'Explain the workday allocation and available capability supply'], 'project-agent-chat': ['Address team agents in a governed cross-project topic', 'Check the status of the current team discussion'], }; diff --git a/src/api/control-plane/repositories/capacity/agent-governance-service.ts b/src/api/control-plane/repositories/capacity/agent-governance-service.ts index 6fe58de3..2b72e37e 100644 --- a/src/api/control-plane/repositories/capacity/agent-governance-service.ts +++ b/src/api/control-plane/repositories/capacity/agent-governance-service.ts @@ -1,5 +1,4 @@ import { CapacityGovernanceError } from '../../../capacity/database.ts'; -import { validateExecutionAuthorityReceipt } from '../../../governance/decision-authority.ts'; import { authorizeCapacityProject, type CapacityPrincipal } from './capacity-authorization.ts'; import { CapacityOperationError } from './capacity-operation-error.ts'; @@ -9,89 +8,8 @@ function translate(error: unknown): never { throw error; } -function projectId(body: Record) { - const value = typeof body.projectId === 'string' ? body.projectId.trim() : ''; - if (!value) throw new CapacityOperationError(400, 'project_id_required', 'projectId is required.'); - return value; -} - -function active(value: unknown): boolean | undefined { - if (value == null || value === '') return undefined; - if (value === 'true') return true; - if (value === 'false') return false; - throw new CapacityOperationError(400, 'decision_assignment_graph_active_filter_invalid', 'active must be true or false.'); -} - export function createAgentGovernanceService(store: any) { return { - async validateAuthorities(principal: CapacityPrincipal, body: Record) { - const authorities = Array.isArray(body.authorities) ? body.authorities : []; - if (!authorities.length || authorities.length > 200) throw new CapacityOperationError(400, - 'governance_execution_authorities_invalid', 'One to 200 execution authorities are required.'); - const results = []; - for (const value of authorities) { - const authority = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; - const authorityProjectId = projectId(authority); - await authorizeCapacityProject(store, principal, authorityProjectId, 'projects:read:team'); - try { - results.push({ authorityId: typeof authority.authorityId === 'string' ? authority.authorityId : null, - ...(await validateExecutionAuthorityReceipt(store, authority)) }); - } catch (error) { translate(error); } - } - return { items: results }; - }, - async compileGraph(principal: CapacityPrincipal, decisionId: string, body: Record) { - await authorizeCapacityProject(store, principal, projectId(body), 'projects:manage:team'); - try { - const graph = await store.createDecisionAssignmentGraph(decisionId, body); - if (!graph) throw new CapacityOperationError(404, 'project_not_found', 'Project not found.'); - return body.activate === false ? graph : await store.activateDecisionAssignmentGraphVersion(graph.id) ?? graph; - } catch (error) { translate(error); } - }, - async graphs(principal: CapacityPrincipal, decisionId: string, query: Record) { - try { - const items = await store.listDecisionAssignmentGraphsForDecision(decisionId, { active: active(query.active) }); - const planning = items.length ? null : await store.getDecisionPlanningStatus(decisionId); - const graphProjectId = items[0]?.projectId ?? planning?.projectId; - if (!graphProjectId) throw new CapacityOperationError(404, 'decision_planning_not_found', 'Decision planning status not found.'); - await authorizeCapacityProject(store, principal, graphProjectId, 'projects:read:team'); - return { items, cursor: null }; - } catch (error) { translate(error); } - }, - async graph(principal: CapacityPrincipal, graphId: string) { - const value = await store.getDecisionAssignmentGraph(graphId); - if (!value) throw new CapacityOperationError(404, 'assignment_graph_not_found', 'Decision assignment graph not found.'); - await authorizeCapacityProject(store, principal, value.projectId, 'projects:read:team'); - return value; - }, - async manifest(principal: CapacityPrincipal, manifestId: string) { - const value = await store.getDeliverableManifest(manifestId); - if (!value) throw new CapacityOperationError(404, 'deliverable_manifest_not_found', 'Deliverable manifest not found.'); - await authorizeCapacityProject(store, principal, value.projectId, 'projects:read:team'); - return value; - }, - async createManifest(principal: CapacityPrincipal, contractId: string, body: Record) { - const contract = await store.getDeliverableContract(contractId); - if (!contract) throw new CapacityOperationError(404, 'deliverable_contract_not_found', 'Deliverable contract not found.'); - await authorizeCapacityProject(store, principal, contract.projectId, 'projects:manage:team'); - try { - const value = await store.submitDeliverableManifest(contractId, body); - if (!value) throw new CapacityOperationError(404, 'deliverable_contract_not_found', 'Deliverable contract not found.'); - return value; - } catch (error) { translate(error); } - }, - async transitionContract(principal: CapacityPrincipal, contractId: string, status: 'approved' | 'rejected', body: Record) { - const contract = await store.getDeliverableContract(contractId); - if (!contract) throw new CapacityOperationError(404, 'deliverable_contract_not_found', 'Deliverable contract not found.'); - await authorizeCapacityProject(store, principal, contract.projectId, 'projects:manage:team'); - try { - const value = status === 'approved' - ? await store.markDeliverableContractApproved(contractId, body) - : await store.markDeliverableContractRejected(contractId, body); - if (!value) throw new CapacityOperationError(404, 'deliverable_contract_not_found', 'Deliverable contract not found.'); - return value; - } catch (error) { translate(error); } - }, async createResearch(principal: CapacityPrincipal, researchProjectId: string, body: Record) { await authorizeCapacityProject(store, principal, researchProjectId, 'projects:manage:team'); try { diff --git a/src/api/control-plane/repositories/capacity/agent-query-service.ts b/src/api/control-plane/repositories/capacity/agent-query-service.ts index 22ee5ab6..a5c64d94 100644 --- a/src/api/control-plane/repositories/capacity/agent-query-service.ts +++ b/src/api/control-plane/repositories/capacity/agent-query-service.ts @@ -22,6 +22,11 @@ async function acceptedAgents(store: any, projectId: string) { agentSlug: agent.id.split('/').at(-1) ?? agent.id, name: agent.name, projectAgentClassId: agentClass.id, allocationClass: agentClass.slug, definitionRevision: String(record(agentClass.metadata).immutableRef ?? agentClass.updatedAt ?? ''), definition: agent, activities: agent.activityProfiles, chatEnabled: Boolean(agent.activityProfiles.chat), + effectiveActivities: Object.fromEntries(Object.entries(agent.activityProfiles).map(([activity, profile]) => [activity, { + handler: profile.handler, origin: profile.handler.includes('/') ? 'project-runtime' : 'agent-package', + prompt: profile.prompt, context: [...agent.context.include, ...(profile.additionalContext ?? [])], + permissions: profile.permissions, dependsOn: profile.dependsOn ?? null, + }])), status: agentClass.status === 'active' ? 'ready' : agentClass.status, })) : []; }).filter((agent: any) => agent.agentSlug); diff --git a/src/api/control-plane/repositories/capacity/capacity-plan-service.ts b/src/api/control-plane/repositories/capacity/capacity-plan-service.ts deleted file mode 100644 index 229aa9d5..00000000 --- a/src/api/control-plane/repositories/capacity/capacity-plan-service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { DurableAgentCapacityPlanStatus } from '@treeseed/sdk/agent-capacity'; -import { authorizeCapacityProject, type CapacityPrincipal } from './capacity-authorization.ts'; -import { CapacityOperationError } from './capacity-operation-error.ts'; - -const STATUSES = new Set([ - 'draft', 'accepted', 'revision_requested', 'deferred', 'scheduled', 'active', 'completed', 'superseded', -]); - -function status(value: unknown): DurableAgentCapacityPlanStatus | null { - if (value == null || value === '') return null; - if (!STATUSES.has(String(value) as DurableAgentCapacityPlanStatus)) { - throw new CapacityOperationError(400, 'capacity_plan_status_invalid', `Unknown capacity plan status ${String(value)}.`); - } - return String(value) as DurableAgentCapacityPlanStatus; -} - -export function createCapacityPlanService(store: any) { - return { - async list(principal: CapacityPrincipal, decisionId: string, query: Record) { - const planning = await store.getDecisionPlanningStatus(decisionId); - if (!planning) throw new CapacityOperationError(404, 'decision_planning_not_found', 'Decision planning status not found.'); - await authorizeCapacityProject(store, principal, planning.projectId, 'projects:read:team'); - return { items: await store.listAgentCapacityPlans(decisionId, { status: status(query.status) }), cursor: null }; - }, - async show(principal: CapacityPrincipal, planId: string) { - const plan = await store.getAgentCapacityPlan(planId); - if (!plan) throw new CapacityOperationError(404, 'capacity_plan_not_found', 'Capacity plan not found.'); - await authorizeCapacityProject(store, principal, plan.projectId, 'projects:read:team'); - return plan; - }, - }; -} diff --git a/src/api/control-plane/repositories/capacity/communication-service.ts b/src/api/control-plane/repositories/capacity/communication-service.ts index fc14eea3..4d49e908 100644 --- a/src/api/control-plane/repositories/capacity/communication-service.ts +++ b/src/api/control-plane/repositories/capacity/communication-service.ts @@ -22,11 +22,11 @@ function readiness(session: Row) { providers = JSON.parse(String(session.execution_providers_json ?? '[]')) as ProviderSnapshot[]; metadata = JSON.parse(String(session.metadata_json ?? '{}')) as Row; } catch { - return { ...session, communicationReady: false, sourceClosureDigest: null, blockers: ['provider_snapshot_invalid'] }; + return { ...session, communicationReady: false, runtimeBuild: null, blockers: ['provider_snapshot_invalid'] }; } const ready = providers.some((provider) => Array.isArray(provider.lanes) && provider.lanes.some((lane) => (lane as Row).purpose === 'communication')); - return { ...session, sourceClosureDigest: typeof metadata.sourceClosureDigest === 'string' ? metadata.sourceClosureDigest : null, + return { ...session, runtimeBuild: typeof metadata.runtimeBuild === 'string' ? metadata.runtimeBuild : null, communicationReady: ready, blockers: ready ? [] : ['provider_communication_lane_unavailable'] }; } diff --git a/src/api/control-plane/repositories/capacity/communication/scheduling-diagnostics.ts b/src/api/control-plane/repositories/capacity/communication/scheduling-diagnostics.ts index 4178afcd..04c00822 100644 --- a/src/api/control-plane/repositories/capacity/communication/scheduling-diagnostics.ts +++ b/src/api/control-plane/repositories/capacity/communication/scheduling-diagnostics.ts @@ -1,6 +1,4 @@ -import { selectWorkdayDemandSupply } from '../../../../capacity/repositories/capacity/workdays/workday-demand-supply.ts'; - -/** Metadata-only admission evidence; never include prompts, credentials or context bodies. */ +/** Metadata-only graph admission evidence; never include prompts, credentials or context bodies. */ export async function communicationSchedulingDiagnostics(store: any, teamId: string, executionId: string) { try { return await schedulingDiagnostics(store, teamId, executionId); } catch { return { executionId, status: 'unavailable', code: 'communication_scheduling_diagnostics_unavailable' }; } @@ -8,23 +6,14 @@ export async function communicationSchedulingDiagnostics(store: any, teamId: str async function schedulingDiagnostics(store: any, teamId: string, executionId: string) { if (!executionId) return null; - const run = await store.first('SELECT id,status,execution_mode,parameters_json FROM capacity_workday_runs WHERE id=? AND team_id=?', [executionId, teamId]); + const run = await store.first('SELECT id,status,execution_mode FROM capacity_workday_runs WHERE id=? AND team_id=?', [executionId, teamId]); if (!run) return null; - const session = await store.first('SELECT id,status,metadata_json FROM workday_planning_sessions WHERE workday_run_id=? AND team_id=?', [executionId, teamId]); + const session = await store.first('SELECT id,status FROM workday_planning_sessions WHERE workday_run_id=? AND team_id=?', [executionId, teamId]); const waves = session ? await store.all('SELECT id,status,round,wave FROM workday_planning_waves WHERE session_id=? ORDER BY round,wave LIMIT 20', [session.id]) : []; - const demands = await store.all('SELECT status,COUNT(*) AS count FROM capacity_workday_demands WHERE workday_run_id=? AND team_id=? GROUP BY status', [executionId, teamId]); - const envelopes = await store.all('SELECT status,COUNT(*) AS count FROM workday_capacity_envelopes WHERE workday_run_id=? AND team_id=? GROUP BY status', [executionId, teamId]); - const pending = await store.all(`SELECT demand.*,run.capacity_provider_id AS primary_provider_id FROM capacity_workday_demands demand - JOIN capacity_workday_runs run ON run.id=demand.workday_run_id WHERE demand.workday_run_id=? AND demand.team_id=? AND demand.status='pending' LIMIT 5`, [executionId, teamId]); - const supply = await Promise.all(pending.map(async (demand: any) => { - const denials = await store.all("SELECT metadata_json,created_at FROM capacity_audit_events WHERE team_id=? AND resource_id=? AND action='assignment-function.denied' ORDER BY created_at DESC LIMIT 5", [teamId, demand.id]); - const reasons = denials.flatMap((row: any) => { try { const metadata = typeof row.metadata_json === 'string' ? JSON.parse(row.metadata_json) : row.metadata_json; return Array.isArray(metadata?.reasons) ? metadata.reasons.filter((code: unknown) => typeof code === 'string' && /^[a-z][a-z0-9_]+$/.test(code)).map((code: string) => ({ code, at: row.created_at })) : []; } catch { return []; } }); - const selection = await selectWorkdayDemandSupply(store, demand, new Date().toISOString()); - return { demandId: demand.id, denials: reasons, selected: selection.selected ? { providerId: selection.selected.capacityProviderId, membershipId: selection.selected.membershipId, providerSessionId: selection.selected.providerSessionId, executionProviderId: selection.selected.executionProviderId } : null, - eligibleCount: selection.eligible.length, rejected: selection.rejected.map((item) => ({ executionProviderId: item.candidate.executionProviderId, reasons: item.reasons })) }; - })); - return { executionId, status: run.status, executionMode: run.execution_mode ?? null, sessionStatus: session?.status ?? null, supply, + const nodes = await store.all('SELECT kind,status,COUNT(*) AS count FROM execution_nodes WHERE workday_id=? AND team_id=? GROUP BY kind,status', [executionId, teamId]); + const assignments = await store.all('SELECT status,COUNT(*) AS count FROM capacity_provider_assignments WHERE work_day_id=? AND team_id=? GROUP BY status', [executionId, teamId]); + return { executionId, status: run.status, executionMode: run.execution_mode ?? null, sessionStatus: session?.status ?? null, waves: waves.map((wave: any) => ({ id: wave.id, status: wave.status, round: Number(wave.round), wave: Number(wave.wave) })), - demands: demands.map((row: any) => ({ status: row.status, count: Number(row.count) })), - envelopes: envelopes.map((row: any) => ({ status: row.status, count: Number(row.count) })) }; + nodes: nodes.map((row: any) => ({ kind: row.kind, status: row.status, count: Number(row.count) })), + assignments: assignments.map((row: any) => ({ status: row.status, count: Number(row.count) })) }; } diff --git a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts index e1f4a29d..46238178 100644 --- a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts +++ b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts @@ -12,6 +12,7 @@ import { projectTeamExecutionGraph } from '../../../../capacity/policy/execution import { projectActiveWorkdays } from '../../../../capacity/policy/execution/workday-execution-projector.ts'; import { projectCommunicationInvocations } from '../../../../capacity/policy/execution/communication-execution-projector.ts'; import { loadTeamExecutableProposalSources } from '../../../../capacity/services/capacity/execution/executable-proposal-source.ts'; +import { loadTeamExactDependencyLinks } from '../../../../capacity/services/capacity/execution/exact-dependency-links.ts'; import { readExactProposal } from '../../../../governance/executable-proposal.ts'; import { authorizeCapacityTeam, type CapacityPrincipal } from '../capacity-authorization.ts'; import { CapacityOperationError } from '../capacity-operation-error.ts'; @@ -252,7 +253,8 @@ export async function persistExecutionGraph(store: any, graph: TeamGraph, curren }]; const revisionGuard = `EXISTS (SELECT 1 FROM execution_graph_revisions WHERE team_id=? AND revision=? AND created_at=?)`; - for (const node of graph.nodes) operations.push({ + const currentNodes = new Map(current.nodes.map((node) => [node.id, node])); + for (const node of graph.nodes.filter((candidate) => stable(currentNodes.get(candidate.id)) !== stable(candidate))) operations.push({ query: `INSERT INTO execution_nodes ( id,team_id,project_id,workday_id,work_item_id,kind,pair_role,source_ref_json,authority_refs_json, rule_revision,node_revision,agent_class,status,estimate_json,required_capabilities_json, @@ -278,6 +280,7 @@ export async function persistExecutionGraph(store: any, graph: TeamGraph, curren node.condition ? JSON.stringify(node.condition) : null,node.graphRevisionCreated,node.graphRevisionUpdated,now,now, revisionRecord.teamId,revisionRecord.revision,revisionRecord.createdAt], }); + const currentEdges = new Map(current.edges.map((edge) => [edge.id, edge])); const desiredEdges = new Set(graph.edges.map((edge) => edge.id)); for (const prior of current.edges) if (!desiredEdges.has(prior.id)) operations.push({ query: `UPDATE execution_edges SET graph_revision_removed = ? @@ -285,7 +288,7 @@ export async function persistExecutionGraph(store: any, graph: TeamGraph, curren params: [graph.revision, graph.teamId, prior.id, revisionRecord.teamId,revisionRecord.revision,revisionRecord.createdAt], }); - for (const edge of graph.edges) operations.push({ + for (const edge of graph.edges.filter((candidate) => stable(currentEdges.get(candidate.id)) !== stable(candidate))) operations.push({ query: `INSERT INTO execution_edges (id,team_id,from_node_id,to_node_id,provenance,source_ref_json,graph_revision_created,graph_revision_removed,created_at) SELECT ?,?,?,?,?,?,?,?,? WHERE ${revisionGuard} ON CONFLICT (id) DO UPDATE SET graph_revision_removed=NULL`, @@ -303,9 +306,8 @@ export async function persistExecutionGraph(store: any, graph: TeamGraph, curren async function reconcileExecutionGraphOnce(store: any, teamId: string, body: Row = {}) { const current = await loadGraphSource('projection', () => readGraph(store, teamId)); - const selectedProjectId = text(body.projectId); const [sources, profiles, workdays, communications] = await Promise.all([ - loadGraphSource('governance', () => loadTeamExecutableProposalSources(store, teamId, selectedProjectId || undefined)), + loadGraphSource('governance', () => loadTeamExecutableProposalSources(store, teamId)), loadGraphSource('agent_profiles', () => loadProfiles(store, teamId)), loadGraphSource('workdays', () => loadActiveWorkdays(store, teamId)), loadGraphSource('communications', () => loadCommunicationInvocations(store, teamId)), @@ -314,19 +316,13 @@ async function reconcileExecutionGraphOnce(store: any, teamId: string, body: Row ? { teamId, baseRevision: current.revision, desiredRevision: current.revision, desiredDigest: current.digest, changes: { added: [], changed: [], completed: [], blocked: [], stale: [], removedEdges: [], addedEdges: [] } } : current; const revision = current.revision + 1; - const proposalProjection = sources.length ? projectTeamExecutionGraph({ teamId, revision, sources, profiles }) : null; + const dependencyLinks = new Set(sources.map((source) => source.projectId)).size > 1 + ? await loadGraphSource('dependencies', () => loadTeamExactDependencyLinks(store, sources)) : []; + const proposalProjection = sources.length ? projectTeamExecutionGraph({ teamId, revision, sources, profiles, dependencyLinks }) : null; const workdayProjection = projectActiveWorkdays({ teamId, revision, sources: workdays, profiles }); const communicationProjection = projectCommunicationInvocations({ teamId, revision, sources: communications, profiles }); - const workdayNodeIds = new Set(workdayProjection.nodes.map((node) => node.id)); - const retainedProposalNodes = selectedProjectId - ? current.nodes.filter((node) => node.sourceRef.model === 'proposal' && node.projectId !== selectedProjectId && !workdayNodeIds.has(node.id)) - : []; - const retainedProposalIds = new Set(retainedProposalNodes.map((node) => node.id)); - const retainedProposalEdges = selectedProjectId - ? current.edges.filter((edge) => retainedProposalIds.has(edge.fromNodeId) && retainedProposalIds.has(edge.toNodeId)) - : []; - const nodes = [...retainedProposalNodes, ...(proposalProjection?.nodes ?? []), ...workdayProjection.nodes, ...communicationProjection.nodes]; - const edges = [...retainedProposalEdges, ...(proposalProjection?.edges ?? []), ...workdayProjection.edges]; + const nodes = [...(proposalProjection?.nodes ?? []), ...workdayProjection.nodes, ...communicationProjection.nodes]; + const edges = [...(proposalProjection?.edges ?? []), ...workdayProjection.edges]; const changedSourceRefs = [...new Map([...(proposalProjection?.revision.changedSourceRefs ?? []), ...workdayProjection.changedSourceRefs, ...communicationProjection.changedSourceRefs, ...(!sources.length && !workdays.length && !communications.length ? current.nodes.map((node) => node.sourceRef) : [])] diff --git a/src/api/control-plane/repositories/capacity/planning-and-estimate-service.ts b/src/api/control-plane/repositories/capacity/planning-and-estimate-service.ts deleted file mode 100644 index d68a46c9..00000000 --- a/src/api/control-plane/repositories/capacity/planning-and-estimate-service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { DecisionExecutionInputStatus, StructuredAgentEstimateStatus } from '@treeseed/sdk/agent-capacity'; -import { CapacityGovernanceError } from '../../../capacity/database.ts'; -import { authorizeCapacityProject, type CapacityPrincipal } from './capacity-authorization.ts'; -import { CapacityOperationError } from './capacity-operation-error.ts'; - -const EXECUTION_STATUSES = new Set(['proposed', 'accepted', 'revision_requested', 'rejected', 'stale']); -const ESTIMATE_STATUSES = new Set(['submitted', 'accepted', 'rejected', 'superseded']); - -function record(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; -} - -function requiredProjectId(body: Record) { - const projectId = typeof body.projectId === 'string' ? body.projectId.trim() : ''; - if (!projectId) throw new CapacityOperationError(400, 'project_id_required', 'projectId is required.'); - return projectId; -} - -function selectedStatus(value: unknown, allowed: Set, code: string): T | null { - if (value == null || value === '') return null; - const status = String(value) as T; - if (!allowed.has(status)) throw new CapacityOperationError(400, code, `Unknown status ${status}.`); - return status; -} - -function translate(error: unknown): never { - if (error instanceof CapacityOperationError) throw error; - if (error instanceof CapacityGovernanceError) throw new CapacityOperationError(error.status, error.code, error.message); - throw error; -} - -export function createPlanningAndEstimateService(store: any) { - return { - async planningStatus(principal: CapacityPrincipal, decisionId: string) { - const value = await store.getDecisionPlanningStatus(decisionId); - if (!value) throw new CapacityOperationError(404, 'decision_planning_not_found', 'Decision planning status not found.'); - await authorizeCapacityProject(store, principal, value.projectId, 'projects:read:team'); - return value; - }, - async requestPlanningInput(principal: CapacityPrincipal, decisionId: string, body: Record) { - const projectId = requiredProjectId(body); - await authorizeCapacityProject(store, principal, projectId, 'projects:manage:team'); - try { - const value = await store.createPlanningInputRequest(decisionId, body); - if (!value) throw new CapacityOperationError(404, 'project_not_found', 'Project not found.'); - return value; - } catch (error) { translate(error); } - }, - async executionInputs(principal: CapacityPrincipal, decisionId: string, query: Record) { - const planning = await store.getDecisionPlanningStatus(decisionId); - if (!planning) throw new CapacityOperationError(404, 'decision_planning_not_found', 'Decision planning status not found.'); - await authorizeCapacityProject(store, principal, planning.projectId, 'projects:read:team'); - try { - return { items: await store.listDecisionExecutionInputs(decisionId, { - status: selectedStatus(query.status, EXECUTION_STATUSES, 'decision_execution_input_status_invalid'), - }), cursor: null }; - } catch (error) { translate(error); } - }, - async createExecutionInput(principal: CapacityPrincipal, decisionId: string, body: Record) { - const projectId = requiredProjectId(body); - await authorizeCapacityProject(store, principal, projectId, 'projects:manage:team'); - try { - const value = await store.createDecisionExecutionInput(decisionId, body); - if (!value) throw new CapacityOperationError(404, 'project_not_found', 'Project not found.'); - return value; - } catch (error) { translate(error); } - }, - async transitionExecutionInput(principal: CapacityPrincipal, inputId: string, - status: 'accepted' | 'revision_requested', body: Record) { - const existing = await store.getDecisionExecutionInput(inputId); - if (!existing) throw new CapacityOperationError(404, 'decision_execution_input_not_found', 'Decision execution input not found.'); - await authorizeCapacityProject(store, principal, existing.projectId, 'projects:manage:team'); - try { - const value = await store.updateDecisionExecutionInputStatus(inputId, status, record(body)); - if (!value) throw new CapacityOperationError(404, 'decision_execution_input_not_found', 'Decision execution input not found.'); - return value; - } catch (error) { translate(error); } - }, - async createEstimate(principal: CapacityPrincipal, decisionId: string, body: Record) { - const projectId = requiredProjectId(body); - await authorizeCapacityProject(store, principal, projectId, 'projects:manage:team'); - try { - const value = await store.createStructuredAgentEstimate(decisionId, body); - if (!value) throw new CapacityOperationError(404, 'project_not_found', 'Project not found.'); - return value; - } catch (error) { translate(error); } - }, - async estimates(principal: CapacityPrincipal, decisionId: string, query: Record) { - try { - const items = await store.listStructuredAgentEstimatesForDecision(decisionId, { - status: selectedStatus(query.status, ESTIMATE_STATUSES, 'structured_agent_estimate_status_invalid'), - }); - const planning = items.length ? null : await store.getDecisionPlanningStatus(decisionId); - const projectId = items[0]?.projectId ?? planning?.projectId; - if (!projectId) throw new CapacityOperationError(404, 'decision_planning_not_found', 'Decision planning status not found.'); - await authorizeCapacityProject(store, principal, projectId, 'projects:read:team'); - return { items, cursor: null }; - } catch (error) { translate(error); } - }, - async transitionEstimate(principal: CapacityPrincipal, estimateId: string, - status: 'accepted' | 'rejected', body: Record) { - const existing = await store.getStructuredAgentEstimate(estimateId); - if (!existing) throw new CapacityOperationError(404, 'structured_agent_estimate_not_found', 'Structured agent estimate not found.'); - await authorizeCapacityProject(store, principal, existing.projectId, 'projects:manage:team'); - try { - const value = await store.updateStructuredAgentEstimateStatus(estimateId, status, record(body)); - if (!value) throw new CapacityOperationError(404, 'structured_agent_estimate_not_found', 'Structured agent estimate not found.'); - return value; - } catch (error) { translate(error); } - }, - }; -} diff --git a/src/api/control-plane/repositories/providers/provider-assignment-service.ts b/src/api/control-plane/repositories/providers/provider-assignment-service.ts index e0e76eed..719289b3 100644 --- a/src/api/control-plane/repositories/providers/provider-assignment-service.ts +++ b/src/api/control-plane/repositories/providers/provider-assignment-service.ts @@ -6,7 +6,6 @@ import { startAssignmentCloseoutWindow, startAssignmentExecutionWindow } from '. import { reconcileBlockedDiscussionInvocations } from '../../../capacity/services/capacity/invocations/discussion-invocation-service.ts'; import { admitDiscussionInvocations } from '../../../capacity/services/capacity/invocations/discussion-invocation-service.ts'; import { parseCommunicationAddresses } from '@treeseed/sdk/operator-contracts'; -import { modeRunActivityEvent } from '../../../capacity/services/capacity/workdays/content/mode-run-activity-event.ts'; import { redactTranscriptValue } from './transcript-redaction.ts'; import { providerPrincipal, type ProviderPrincipal } from './provider-runtime-service.ts'; import { assignmentActivityType, assignmentRecord as record, assignmentWorkdayRunId, assertProviderOwnsAssignment, type ProviderAssignmentStore } from './provider-assignment-support.ts'; @@ -20,6 +19,10 @@ import { createSourceWorkspaceService } from './source/source-workspace-service. type SessionEvents = { subscribe(teamId: string, listener: (event: { eventType: string; payload: Record }) => void): Promise<() => void> }; function objectValue(value: unknown): Record { return record(value); } +function rejectRetiredModeRun(body: Record) { + if (Object.hasOwn(body, 'modeRunId')) throw new CapacityGovernanceError('mode_run_contract_retired', + 'Mode-run identity is retired; submit assignment and attempt identity.', 400); +} function stableId(scope: string, value: string) { return createHash('sha256').update(`${scope}:${value}`).digest('hex').slice(0, 32); } export function normalizeStoredTimestamp(value: unknown) { if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); @@ -89,6 +92,7 @@ export function createProviderAssignmentService(storeValue: ProviderAssignmentSt const principal = (auth: unknown, scopes: string[]) => providerPrincipal(auth, scopes); const lifecycle = async (auth: unknown, assignmentId: string, body: Record, scope: string, method: 'renewProviderAssignmentLease' | 'returnProviderAssignment' | 'completeProviderAssignment') => { + rejectRetiredModeRun(body); const result = await store[method](principal(auth, [scope]), assignmentId, body); if (!result) throw new CapacityGovernanceError('provider_assignment_conflict', 'Assignment lease transition was rejected.', 409); return result; @@ -131,7 +135,6 @@ export function createProviderAssignmentService(storeValue: ProviderAssignmentSt return result; }, startCloseout: (auth: unknown, assignmentId: string, body: Record) => startAssignmentCloseoutWindow(store, principal(auth, ['provider:assignments:write']), assignmentId, body), - preflight: (auth: unknown, assignmentId: string, body: Record) => store.preflightProviderAssignmentCompletion(principal(auth, ['provider:assignments:write']), assignmentId, body), async respondToDiscussion(auth: unknown, assignmentId: string, body: Record, idempotencyKey = '') { const actor = principal(auth, ['provider:assignments:write']); if (!idempotencyKey) throw new CapacityGovernanceError('idempotency_key_required', 'Discussion response requires an idempotency key.', 400); @@ -270,25 +273,28 @@ export function createProviderAssignmentService(storeValue: ProviderAssignmentSt returnAssignment: (auth: unknown, assignmentId: string, body: Record) => lifecycle(auth, assignmentId, body, 'provider:assignments:write', 'returnProviderAssignment'), complete: (auth: unknown, assignmentId: string, body: Record) => lifecycle(auth, assignmentId, body, 'provider:assignments:write', 'completeProviderAssignment'), async fail(auth: unknown, assignmentId: string, body: Record) { - const scopes = ['provider:assignments:write']; if (body.usageActualId || body.modeRunId || body.usageActual || body.usage) scopes.push('provider:usage:write'); + rejectRetiredModeRun(body); + const scopes = ['provider:assignments:write']; if (body.usageActualId || body.usageActual || body.usage) scopes.push('provider:usage:write'); const result = await store.failProviderAssignment(principal(auth, scopes), assignmentId, body); if (!result) throw new CapacityGovernanceError('provider_assignment_conflict', 'Assignment lease transition was rejected.', 409); return result; }, async reportUsage(auth: unknown, assignmentId: string, body: Record, idempotencyKey = '') { + rejectRetiredModeRun(body); const actor = principal(auth, ['provider:usage:write']); const assignment = await ownedAssignment(store, assignmentId, actor); return reportCapacityUsage(store, { teamId: actor.teamId, membershipId: actor.membershipId, reservationId: String(assignment.reservation_id ?? ''), assignmentId: String(assignment.id), idempotencyKey, assignmentAttempt: body.assignmentAttempt == null ? null : Number(body.assignmentAttempt), usageDimension: String(body.usageDimension ?? ''), accountingMode: body.accountingMode === 'incremental' ? 'incremental' : 'informational', activeSeconds: Number(body.activeSeconds ?? 0), elapsedSeconds: Number(body.elapsedSeconds ?? 0), providerUnits: body.providerUnits == null ? null : Number(body.providerUnits), usd: body.usd == null ? null : Number(body.usd), - modeRunId: typeof body.modeRunId === 'string' ? body.modeRunId : null, source: 'provider_usage_report', metadata: objectValue(body.metadata), usageActual: objectValue(body.usageActual) }); + source: 'provider_usage_report', metadata: objectValue(body.metadata), usageActual: objectValue(body.usageActual) }); }, async settle(auth: unknown, assignmentId: string, body: Record, idempotencyKey = '') { + rejectRetiredModeRun(body); const actor = principal(auth, ['provider:usage:write', 'provider:assignments:write']); const assignment = await ownedAssignment(store, assignmentId, actor); const settlement = await settleCapacityReservationExactlyOnce(store, { settlementKey: idempotencyKey, teamId: actor.teamId, membershipId: actor.membershipId, reservationId: String(assignment.reservation_id ?? ''), assignmentId: String(assignment.id), assignmentAttempt: body.assignmentAttempt == null ? null : Number(body.assignmentAttempt), usageDimension: typeof body.usageDimension === 'string' ? body.usageDimension : 'aggregate', usageIdempotencyKey: typeof body.usageIdempotencyKey === 'string' ? body.usageIdempotencyKey : null, activeSeconds: Number(body.activeSeconds), elapsedSeconds: Number(body.elapsedSeconds), providerUnits: body.providerUnits == null ? null : Number(body.providerUnits), usd: body.usd == null ? null : Number(body.usd), - modeRunId: typeof body.modeRunId === 'string' ? body.modeRunId : null, source: 'provider_usage_report', metadata: objectValue(body.metadata), usageActual: objectValue(body.usageActual) as CapacitySettlementRequest['usageActual'] }); + source: 'provider_usage_report', metadata: objectValue(body.metadata), usageActual: objectValue(body.usageActual) as CapacitySettlementRequest['usageActual'] }); const current = await store.getProviderAssignment(actor.teamId, assignmentId); if (current?.executionKind === 'conversation' && current.status === 'returned' && current.leaseState === 'released' && current.lifecycleCode === 'discussion_response_required') { @@ -297,15 +303,6 @@ export function createProviderAssignmentService(storeValue: ProviderAssignmentSt } return settlement; }, - async createModeRun(auth: unknown, assignmentId: string, body: Record) { - const actor = principal(auth, ['provider:assignments:write', 'provider:usage:write']); - const assignment = assertProviderOwnsAssignment(await store.getProviderAssignment(actor.teamId, assignmentId), actor, 'update'); - const modeRun = await store.createAgentModeRun({ ...body, teamId: actor.teamId, providerAssignmentId: assignment.id }); - if (!modeRun) throw new CapacityGovernanceError('provider_assignment_not_found', 'Unknown assignment.', 404); - const runId = assignmentWorkdayRunId(assignment); - if (runId && store.createCapacityWorkdayEvent) await store.createCapacityWorkdayEvent(actor.teamId, runId, modeRunActivityEvent({ assignment, modeRun })); - return modeRun; - }, async createEvent(auth: unknown, assignmentId: string, body: Record) { const actor = principal(auth, ['provider:assignments:write']); const assignment = assertProviderOwnsAssignment(await store.getProviderAssignment(actor.teamId, assignmentId), actor, 'report runtime events for'); diff --git a/src/api/control-plane/repositories/providers/provider-assignment-support.ts b/src/api/control-plane/repositories/providers/provider-assignment-support.ts index 07d1d8ba..509b2826 100644 --- a/src/api/control-plane/repositories/providers/provider-assignment-support.ts +++ b/src/api/control-plane/repositories/providers/provider-assignment-support.ts @@ -8,9 +8,7 @@ export interface ProviderAssignmentStore extends CapacityGovernanceDatabase { renewProviderAssignmentLease(principal: ProviderPrincipal, assignmentId: string, input: Record): Promise | null>; returnProviderAssignment(principal: ProviderPrincipal, assignmentId: string, input: Record): Promise | null>; completeProviderAssignment(principal: ProviderPrincipal, assignmentId: string, input: Record): Promise | null>; - preflightProviderAssignmentCompletion(principal: ProviderPrincipal, assignmentId: string, input: Record): Promise>; failProviderAssignment(principal: ProviderPrincipal, assignmentId: string, input: Record): Promise | null>; - createAgentModeRun(input: Record): Promise | null>; createCapacityWorkdayEvent?(teamId: string, runId: string, input: Record): Promise; } @@ -25,15 +23,16 @@ export function assignmentRecord(value: unknown): Record { } export function assignmentActivityType(assignment: Record): unknown { - const decision = assignmentRecord(assignment.decisionInput); - return decision.activityType ?? assignmentRecord(decision.metadata).activityType - ?? assignmentRecord(decision.input).activityType ?? assignmentRecord(assignment.metadata).activityType; + const raw = assignment.assignmentAttempt ?? assignment.assignment_attempt_json; + let attempt = assignmentRecord(raw); + if (typeof raw === 'string') { + try { attempt = assignmentRecord(JSON.parse(raw)); } + catch { return null; } + } + return assignmentRecord(attempt.effectiveProfile).activity ?? null; } export function assignmentWorkdayRunId(assignment: Record): string | null { - const metadata = assignmentRecord(assignment.metadata); - for (const value of [assignment.workDayId, metadata.workdayRunId]) { - if (typeof value === 'string' && value.trim()) return value.trim(); - } - return null; + const value = assignment.workDayId ?? assignment.work_day_id; + return typeof value === 'string' && value.trim() ? value.trim() : null; } diff --git a/src/api/control-plane/repositories/providers/provider-workflow-service.ts b/src/api/control-plane/repositories/providers/provider-workflow-service.ts index da828317..235872f0 100644 --- a/src/api/control-plane/repositories/providers/provider-workflow-service.ts +++ b/src/api/control-plane/repositories/providers/provider-workflow-service.ts @@ -14,7 +14,7 @@ function authorizedHandle(assignment: Record, principal: Provid || assignment.leaseToken !== body.leaseToken || !assignment.leaseExpiresAt || Date.parse(String(assignment.leaseExpiresAt)) <= now) { throw new CapacityGovernanceError('assignment_workflow_lease_invalid', 'Workflow dispatch requires the active assignment lease.', 409); } - if (assignment.mode !== 'acting' || !assignment.decisionId || !assignment.allocationSetId) throw new CapacityGovernanceError('assignment_workflow_acting_readiness_required', 'Workflow dispatch requires acting mode, an approved decision, and accepted capacity provenance.', 403); + if (assignment.mode !== 'acting' || !assignment.decisionId || !assignment.reservationId || !assignment.executionNodeId) throw new CapacityGovernanceError('assignment_workflow_acting_readiness_required', 'Workflow dispatch requires acting mode, an approved decision, and living-graph capacity provenance.', 403); const handleId = typeof body.handleId === 'string' ? body.handleId : ''; const handle = workflowHandles(assignment).find((entry) => entry.id === handleId && entry.operationId === operationId); if (!handle || handle.kind !== 'workflow_operation' || handle.status !== 'active' || (handle.expiresAt && Date.parse(String(handle.expiresAt)) <= now) diff --git a/src/api/control-plane/repositories/providers/source/source-workspace-service.ts b/src/api/control-plane/repositories/providers/source/source-workspace-service.ts index cd551dd4..388c860d 100644 --- a/src/api/control-plane/repositories/providers/source/source-workspace-service.ts +++ b/src/api/control-plane/repositories/providers/source/source-workspace-service.ts @@ -1,6 +1,6 @@ import { randomUUID, timingSafeEqual } from 'node:crypto'; import { assignmentSourceBranch, simulationSourceBranch, sourceWorkspaceRequestSchema, sourceWorkspaceResponseSchema, type SourceWorkspaceAuthorization } from '@treeseed/sdk/capacity-provider/sandbox'; -import { assignmentAttemptSchema } from '@treeseed/sdk/agent-capacity'; +import { assignmentAttemptSchema, assignmentResultSchema } from '@treeseed/sdk/agent-capacity'; import { sealSourceCredential } from '@treeseed/deployment/security/source'; import { resolveGitHubSourceAuthority } from '../../../../../security/provider-credential-authority.ts'; import { CapacityGovernanceError, type CapacityGovernanceDatabase } from '../../../../capacity/database.ts'; @@ -56,7 +56,7 @@ export function assignmentSourceMode(row: RecordValue) { if (attempt.data.workspace.mode === 'git') { const campaignId = String(record(row.workday_parameters_json).acceptanceCampaignId || 'local'); const frozenUpstreamBase = attempt.data.contextRefs.some(reference => reference.store === 'git' - && reference.commit === attempt.data.workspace.baseCommit); + && reference.commit === attempt.data.workspace.baseCommit) && attempt.data.predecessorResultIds.length === 0; const acquisition = executionMode === 'production' ? 'upstream-authorized' as const : frozenUpstreamBase ? 'upstream-public' as const : 'simulation-local' as const; const publicationRef = executionMode === 'simulation' @@ -71,6 +71,29 @@ export function assignmentSourceMode(row: RecordValue) { publication: 'denied' as const }; } +export function assignmentPredecessorSourceCommits(row: RecordValue, repositories: readonly string[], baseCommit: string): string[] { + const attempt = assignmentAttemptSchema.safeParse(record(row.assignment_attempt_json ?? {})); + if (!attempt.success || attempt.data.workspace.mode !== 'git') return []; + const values = record(row.workspace_context_json).predecessorResults; + const results = Array.isArray(values) ? values : []; + const expected = attempt.data.predecessorResultIds; + if (results.length !== expected.length || new Set(expected).size !== expected.length) throw new CapacityGovernanceError('assignment_source_predecessor_mismatch', + 'Assignment predecessor results do not match its immutable attempt.', 409); + const observed = new Set(); + const commits = new Set(); + for (const value of results) { + const parsed = assignmentResultSchema.safeParse(value); + if (!parsed.success || !expected.includes(parsed.data.id) || observed.has(parsed.data.id)) throw new CapacityGovernanceError( + 'assignment_source_predecessor_mismatch', 'Assignment contains an invalid predecessor result.', 409); + observed.add(parsed.data.id); + for (const reference of parsed.data.references) if (reference.kind === 'git' + && repositories.includes(reference.repository) && reference.commit !== baseCommit) commits.add(reference.commit); + } + if (commits.size > 16) throw new CapacityGovernanceError('assignment_source_predecessor_limit', + 'Assignment requests more than 16 exact predecessor commits.', 409); + return [...commits].sort(); +} + export function createSourceWorkspaceService(database: CapacityGovernanceDatabase, contentStore: SourceStore, options: { controlPlaneId: string; fetchImpl?: typeof fetch; now?: () => Date; }) { @@ -135,14 +158,16 @@ export function createSourceWorkspaceService(database: CapacityGovernanceDatabas await checkAuthority(); const issued = now(); row = assertSourceAssignmentLease(await load(assignmentId, actor), actor, assignmentId, request.runnerId, request.leaseToken, issued); - const accepted = readAssignmentSourcePin(record(row.workspace_context_json)); + const accepted = readAssignmentSourcePin(record(row.workspace_context_json)); if (JSON.stringify(accepted) !== JSON.stringify(pin)) throw new CapacityGovernanceError('assignment_source_pin_changed', 'Assignment source identity changed during authorization.', 409); + const additionalCommits = assignmentPredecessorSourceCommits(row, [configured.id, configuredRepository], pin.exactCommit); const credentialExpiry = credential?.expiresAt ? Date.parse(credential.expiresAt) : issued.getTime() + 300_000; const expiry = Math.min(Date.parse(String(row.lease_expires_at)), credentialExpiry, issued.getTime() + 300_000); if (!Number.isFinite(expiry) || expiry <= issued.getTime()) throw new CapacityGovernanceError('assignment_source_credential_expired', 'Source credential expired during authorization.', 409); const authorization: SourceWorkspaceAuthorization = { schemaVersion: 'treeseed.source-workspace-authorization/v1', id: randomUUID(), providerId: actor.capacityProviderId, assignmentId, attempt: Number(row.attempt_count) + 1, - source: { controlPlaneId: options.controlPlaneId, teamId: actor.teamId, projectId, repositoryId: pin.repository.id, commit: pin.exactCommit, formatVersion: 1, profile: 'source-only' }, + source: { controlPlaneId: options.controlPlaneId, teamId: actor.teamId, projectId, repositoryId: pin.repository.id, + commit: pin.exactCommit, ...(additionalCommits.length ? { additionalCommits } : {}), formatVersion: 1, profile: 'source-only' }, ...sourceMode, ...(pin.credentialBindingId ? { credentialBindingId: pin.credentialBindingId } : {}), issuedAt: issued.toISOString(), expiresAt: new Date(expiry).toISOString() }; const sealed = credential ? sealSourceCredential({ authorization, recipientPublicKey: request.recipientPublicKey, credential }, issued) : null; const { id: _id, ...repository } = pin.repository; diff --git a/src/api/discussions/content.ts b/src/api/discussions/content.ts index 01c3cf18..db567c21 100644 --- a/src/api/discussions/content.ts +++ b/src/api/discussions/content.ts @@ -341,7 +341,6 @@ export async function appendDiscussionEvent(input: { title: text(input.event.title, phase), discussionId: input.discussionId, phase, sequence: Number(input.event.eventIndex ?? Date.now()), ...(input.event.assignmentId ? { assignmentId: String(input.event.assignmentId) } : {}), - ...(input.event.modeRunId ? { modeRunId: String(input.event.modeRunId) } : {}), ...(context.agentId ? { agentId: String(context.agentId) } : {}), ...(context.executionProviderId ? { providerId: String(context.executionProviderId) } : {}), occurredAt, metrics: input.event.metadata ?? {}, refs, diff --git a/src/api/knowledge/editorial-review.ts b/src/api/knowledge/editorial-review.ts index 1da19dd6..bd5fc183 100644 --- a/src/api/knowledge/editorial-review.ts +++ b/src/api/knowledge/editorial-review.ts @@ -15,16 +15,6 @@ export function editorialReviewGate(review: any) { return { ok: true }; } -export async function verifiedEditorialContextTrace(store: any, projectId: string, contextDigest: string) { - if (!/^[a-f0-9]{64}$/u.test(contextDigest)) return null; - return store.first(`SELECT id, trace_refs_json FROM agent_mode_runs - WHERE project_id = ? AND status = 'succeeded' - AND trace_refs_json::jsonb ->> 'editorialContextDigest' = ? - AND trace_refs_json::jsonb ->> 'editorialContextSchemaVersion' = 'treeseed.editorial-context/v1' - AND trace_refs_json::jsonb ->> 'agentSlug' IN ('guide-writer', 'guide-steward', 'knowledge-cartographer') - ORDER BY completed_at DESC NULLS LAST LIMIT 1`, [projectId, contextDigest]); -} - export function requiredRevisionReviewerIds(review: any) { if (review?.status !== 'changes-requested') return {}; return Object.fromEntries(['technical', 'audience'].flatMap((kind) => { diff --git a/src/api/support/app.ts b/src/api/support/app.ts index e4f058b7..34996faa 100644 --- a/src/api/support/app.ts +++ b/src/api/support/app.ts @@ -27,8 +27,6 @@ import { createServiceConnectionService } from '../control-plane/repositories/se import { createServiceCredentials } from '../control-plane/repositories/services/service-credentials.ts'; import { createHostedTopologyService } from '../control-plane/repositories/infrastructure/hosted-topology-service.ts'; import {createAiInstanceService} from '../control-plane/repositories/infrastructure/ai-instance-service.ts'; -import { createCapacityPlanService } from '../control-plane/repositories/capacity/capacity-plan-service.ts'; -import { createPlanningAndEstimateService } from '../control-plane/repositories/capacity/planning-and-estimate-service.ts'; import { createAgentGovernanceService } from '../control-plane/repositories/capacity/agent-governance-service.ts'; import { createCommunicationService } from '../control-plane/repositories/capacity/communication-service.ts'; import { createDiagnosticEnvelopeService } from '../../security/diagnostic-envelope.ts'; @@ -248,8 +246,6 @@ export function createPlatformApiApp(options: any = {}) { aiInstances: createAiInstanceService(store, registeredAiNodes), platformProjectCreation: createPlatformProjectCreationService(store, { env: process.env, fetchImpl: options.fetchImpl ?? fetch }), capabilityOntology, - plans: createCapacityPlanService(capacity), - planningAndEstimates: createPlanningAndEstimateService(capacity), agentGovernance: createAgentGovernanceService(capacity), communications, inbox, diff --git a/src/api/support/control-plane-postgres.ts b/src/api/support/control-plane-postgres.ts index 7c994a74..a19d10c1 100644 --- a/src/api/support/control-plane-postgres.ts +++ b/src/api/support/control-plane-postgres.ts @@ -181,14 +181,10 @@ async function columnExists(pool:PostgresQueryable,tableName:string,columnName:s async function hasAdoptableBaselineSchema(pool: PostgresQueryable): Promise { const baselineTables = [ - 'agent_capacity_plans', 'better_auth_user', 'capacity_ledger_entries', 'capacity_providers', 'capacity_reservations', - 'capacity_workday_demands', - 'capacity_workday_participation_cycles', - 'capacity_workday_participation_entries', 'control_plane_operation_runners', 'platform_operations', 'capacity_provider_assignments', diff --git a/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts b/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts index c679a7e1..cc374922 100644 --- a/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts +++ b/src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts @@ -5,7 +5,6 @@ import { treeDxDelegationAuthority } from '../../../../../api/control-plane/tree import { repositoryDefinitionSource, validateAgentDefinitionSource } from '../../../../../api/control-plane/repositories/agents/agent-definition-source.ts'; import { resolveTreeDxServiceUrl } from '../../../../../api/control-plane/treedx/connection-url.ts'; import { ContextQueryCheckService } from '../../../../../api/capacity/services/capacity/agents/context-query-check-service.ts'; -import { readProjectProposalTypes } from './project-proposal-types.ts'; function text(...values: unknown[]): string { for (const value of values) if (typeof value === 'string' && value.trim()) return value.trim(); @@ -75,7 +74,7 @@ async function waitForGraphRefresh(client: TreeDxClient, repositoryId: string, r throw new Error('TreeDX graph refresh did not complete before the seed reconciliation deadline.'); } -async function reconcileProjectAgentClasses(input: { +export async function reconcileProjectAgentClasses(input: { store: any; client: TreeDxClient; repositoryId: string; projectId: string; teamId: string; projectSlug: string; ref: string; paths?:string[];discoveredRef?:string; }) { @@ -91,6 +90,14 @@ async function reconcileProjectAgentClasses(input: { if (files.length !== paths.length) throw new Error('TreeDX did not read back every discovered agent definition.'); const immutableRef = text(read.resolvedRef,input.discoveredRef); if (!/^[a-f0-9]{40}$/u.test(immutableRef)) throw new Error('TreeDX agent definitions did not resolve to an immutable commit.'); + if (/^[a-f0-9]{40}$/u.test(text(input.discoveredRef)) && immutableRef !== input.discoveredRef) { + throw new Error('TreeDX agent definitions moved after discovery.'); + } + const discovered = new Set(paths); + const returned = files.map((file) => text(object(file).path)); + if (returned.some((path) => !discovered.has(path)) || new Set(returned).size !== discovered.size) { + throw new Error('TreeDX returned agent definitions outside the discovered paths.'); + } const definitions = files.map((file) => { const row = object(file); const path = text(row.path); const source = repositoryDefinitionSource(row); const validation = validateAgentDefinitionSource(source); @@ -104,24 +111,19 @@ async function reconcileProjectAgentClasses(input: { groups.set(key, [...(groups.get(key) ?? []), definition]); } const now = new Date().toISOString(); - const proposalTypeContracts = await readProjectProposalTypes(input.client, input.repositoryId, immutableRef); for (const [classSlug, members] of groups) { const existing = await input.store.first('SELECT id, created_at FROM project_agent_classes WHERE project_id = ? AND slug = ? LIMIT 1', [input.projectId, classSlug]); const classId = text(existing?.id, `${input.projectId}:${classSlug}`); const agents = members.map(({ definition }) => definition); - const profiles = members.flatMap(({ definition }) => Object.entries(object(definition.activityProfiles))); - const allowedModes = [...new Set(profiles.map(([activity]) => activity === 'acting' ? 'acting' : 'planning'))]; const metadata = { source: 'project-library', immutableRef, libraryRef: input.ref, definitionPaths: members.map(({ path }) => path), definitionDigest: createHash('sha256').update(members.map(({ source }) => source).join('\n')).digest('hex') }; await input.store.run(`INSERT INTO project_agent_classes - (id,team_id,project_id,slug,name,status,allowed_modes_json,required_capabilities_json,kernel_profile_json,kernel_policy_json,handler_refs_json,output_contracts_json,metadata_json,created_at,updated_at) - VALUES (?,?,?,?,?,'active',?,?,?,?,?,?,?, ?, ?) - ON CONFLICT(id) DO UPDATE SET slug=excluded.slug,name=excluded.name,status='active',allowed_modes_json=excluded.allowed_modes_json, - required_capabilities_json=excluded.required_capabilities_json,handler_refs_json=excluded.handler_refs_json, + (id,team_id,project_id,slug,name,status,handler_refs_json,metadata_json,created_at,updated_at) + VALUES (?,?,?,?,?,'active',?,?,?,?) + ON CONFLICT(id) DO UPDATE SET slug=excluded.slug,name=excluded.name,status='active',handler_refs_json=excluded.handler_refs_json, metadata_json=excluded.metadata_json,updated_at=excluded.updated_at`, [ classId,input.teamId,input.projectId,classSlug,text(members[0]?.definition.name, classSlug), - JSON.stringify(allowedModes.length ? allowedModes : ['planning']),JSON.stringify([]),JSON.stringify({}),JSON.stringify({}), - JSON.stringify({ agents, proposalTypeContracts }),JSON.stringify({}),JSON.stringify(metadata),text(existing?.created_at, now),now, + JSON.stringify({ agents }),JSON.stringify(metadata),text(existing?.created_at, now),now, ]); } const activeSlugs = [...groups.keys()]; diff --git a/src/control-plane/seeds/apply-support/projects/projects-core/project-proposal-types.ts b/src/control-plane/seeds/apply-support/projects/projects-core/project-proposal-types.ts deleted file mode 100644 index 6ec5b425..00000000 --- a/src/control-plane/seeds/apply-support/projects/projects-core/project-proposal-types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { TreeDxClient } from '@treeseed/treedx/treedx/client'; -import { validateProposalTypeContract, type ProposalTypeContract } from '@treeseed/sdk/agent-capacity'; -import { parse } from 'yaml'; - -type Row = Record; -function row(value: unknown): Row { return value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; } -function result(value: unknown): Row { const outer = row(value); return outer.query ? row(outer.query) : outer; } - -/** Freeze repository-owned proposal contracts at the same commit as the agent definitions. */ -export async function readProjectProposalTypes(client: Pick, repositoryId: string, ref: string): Promise> { - if (!/^[a-f0-9]{40}$/u.test(ref)) throw new Error('Proposal type reconciliation requires an immutable library commit.'); - const listing = result(await client.query.listPaths(repositoryId, { ref, paths: ['.treeseed/governance/proposal-types/**'], kinds: ['blob'], limit: 500, allowProtected: true })); - if (listing.resolvedRef !== ref || !Array.isArray(listing.entries) || row(listing.page).hasMore === true) throw new Error('Proposal type catalog is incomplete or its immutable ref changed.'); - const paths = listing.entries.map((value) => String(row(value).path ?? '')).sort(); - if (paths.some((path) => !/^\.treeseed\/governance\/proposal-types\/[a-z0-9][a-z0-9-]*\.yaml$/u.test(path)) || new Set(paths).size !== paths.length) throw new Error('Proposal type catalog contains a noncanonical or duplicate path.'); - if (!paths.length) return {}; - const read = result(await client.query.readFile(repositoryId, { ref, paths, encoding: 'utf8', parseFrontmatter: false, allowProtected: true })); - if (read.resolvedRef !== ref || !Array.isArray(read.files) || read.files.length !== paths.length) throw new Error('Proposal type source read-back is incomplete or moved.'); - const contracts: Record = {}; - const seen = new Set(); - for (const value of read.files) { - const file = row(value), path = String(file.path ?? ''); - if (!paths.includes(path) || seen.has(path) || typeof file.content !== 'string') throw new Error('Proposal type read-back has duplicate, unexpected, or missing content.'); - seen.add(path); - const validation = validateProposalTypeContract(parse(file.content)); - if (!validation.ok || !validation.value || path !== `.treeseed/governance/proposal-types/${validation.value.id}.yaml`) throw new Error(`Invalid proposal type contract at ${path}.`); - contracts[validation.value.id] = validation.value; - } - return contracts; -} diff --git a/tests/unit/control-plane/capacity/agent-operations.test.ts b/tests/unit/control-plane/capacity/agent-operations.test.ts index 0e6c789c..8001ad8e 100644 --- a/tests/unit/control-plane/capacity/agent-operations.test.ts +++ b/tests/unit/control-plane/capacity/agent-operations.test.ts @@ -27,7 +27,11 @@ describe('agent catalog operations', () => { listProjectAgentClassesPage: vi.fn(async () => ({ items: [{ id: 'class-1', slug: 'engineering', status: 'active', updatedAt: '2026-08-23T00:00:00.000Z', metadata: { immutableRef: 'ref-1' }, handlerRefs: { agents: [{ schemaVersion:'treeseed.agent/v1',id:'project/engineer',name:'Engineer',agentClass:'engineer',purpose:'Implement accepted work.',responsibilities:['Inspect evidence.'],capabilities:['engineering'],activityProfiles:{chat:{handler:'writer',permissions:{content:{read:['knowledge'],write:['discussion']},tools:['source.read']},prompt:{system:'Research before answering.'}}},context:{include:['repository-structure']}}] } }] })), }; await expect(createAgentQueryService(store).show(principal, 'project-1', 'engineer')).resolves.toMatchObject({ - projectId: 'project-1', agent: { agentSlug: 'engineer', allocationClass: 'engineering', chatEnabled: true }, + projectId: 'project-1', agent: { agentSlug: 'engineer', allocationClass: 'engineering', chatEnabled: true, + effectiveActivities: { chat: { handler: 'writer', origin: 'agent-package', + prompt: { system: 'Research before answering.' }, context: ['repository-structure'], + permissions: { content: { read: ['knowledge'], write: ['discussion'] }, tools: ['source.read'] }, + dependsOn: null } } }, }); }); }); diff --git a/tests/unit/control-plane/capacity/agents/project-agent-artifact-service.test.ts b/tests/unit/control-plane/capacity/agents/project-agent-artifact-service.test.ts new file mode 100644 index 00000000..15416603 --- /dev/null +++ b/tests/unit/control-plane/capacity/agents/project-agent-artifact-service.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { collectProjectAgentArtifacts } from '../../../../../src/api/capacity/services/projects/projects-core/project-agent-artifact-service.ts'; + +describe('project agent artifact evidence', () => { + it('projects only committed, authorized TreeDX references from assignment results', async () => { + const store = { + getProjectDetails: vi.fn(async () => ({ id: 'project', teamId: 'team' })), + listProviderAssignmentsPage: vi.fn(async () => ({ items: [{ + id: 'assignment', taskId: 'task', workDayId: 'workday', status: 'completed', + assignmentAttempt: { grant: { contentWrite: [{ store: 'treedx', repository: 'library', + path: 'knowledge/accepted.mdx', model: 'knowledge' }] } }, + assignmentResult: { completedAt: '2026-09-20T10:00:00.000Z', references: [ + { kind: 'treedx', repository: 'library', path: 'knowledge/accepted.mdx', commit: 'a'.repeat(40) }, + { kind: 'treedx', repository: 'library', path: 'knowledge/ungranted.mdx', commit: 'b'.repeat(40) }, + ] }, + }], page: { hasMore: false } })), + }; + const artifacts = await collectProjectAgentArtifacts(store, 'project'); + expect(store.listProviderAssignmentsPage).toHaveBeenCalledWith('team', { projectId: 'project', limit: 200 }); + expect(artifacts).toEqual([expect.objectContaining({ + assignmentId: 'assignment', model: 'knowledge', contentPath: 'knowledge/accepted.mdx', + outputRef: `treedx:library:${'a'.repeat(40)}:knowledge/accepted.mdx`, + })]); + expect(JSON.stringify(artifacts)).not.toContain('modeRunId'); + }); + + it('rejects a truncated assignment projection instead of hiding artifacts', async () => { + const store = { getProjectDetails: vi.fn(async () => ({ teamId: 'team' })), + listProviderAssignmentsPage: vi.fn(async () => ({ items: [], page: { hasMore: true, nextCursor: 'next' } })) }; + await expect(collectProjectAgentArtifacts(store, 'project')).rejects.toMatchObject({ + code: 'project_agent_artifact_evidence_bound_exceeded', details: { nextCursor: 'next' }, + }); + }); +}); diff --git a/tests/unit/control-plane/capacity/agents/project-agent-summary-service.test.ts b/tests/unit/control-plane/capacity/agents/project-agent-summary-service.test.ts new file mode 100644 index 00000000..a1535e89 --- /dev/null +++ b/tests/unit/control-plane/capacity/agents/project-agent-summary-service.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildProjectAgentSummary } from '../../../../../src/api/capacity/services/projects/agents/project-agent-summary-service.ts'; + +describe('project agent health summary', () => { + it('uses assignment state rather than retired mode-run telemetry', async () => { + const store = { + getProjectDetails: vi.fn(async () => ({ id: 'project', teamId: 'team' })), + listApprovalRequestsForProject: vi.fn(async () => []), + all: vi.fn(async () => [{ id: 'run', status: 'running', summary: { completedAssignments: 2 }, createdAt: '2026-09-20T00:00:00Z' }]), + listProviderAssignmentsPage: vi.fn(async () => ({ items: [ + { id: 'active', agentId: 'engineer', status: 'running' }, + { id: 'failed', agentId: 'tester', status: 'failed' }, + ], page: { hasMore: false } })), + }; + const summary = await buildProjectAgentSummary(store, 'project'); + expect(store.listProviderAssignmentsPage).toHaveBeenCalledWith('team', { projectId: 'project', limit: 200 }); + expect(store.all.mock.calls[0]?.[0]).toContain('capacity_workday_runs'); + expect(summary?.currentWorkday).toMatchObject({ id: 'run', status: 'running' }); + expect(summary?.runtimeReports[0]).toMatchObject({ workDayId: 'run', summary: { completedAssignments: 2 } }); + expect(summary?.taskHealth.activeTasks).toEqual([expect.objectContaining({ id: 'active' })]); + expect(summary?.docsAutomation.verificationFailureCount).toBe(1); + expect(summary?.agents).toEqual(expect.arrayContaining([ + expect.objectContaining({ agentSlug: 'engineer', status: 'active' }), + expect.objectContaining({ agentSlug: 'tester', status: 'idle' }), + ])); + }); +}); diff --git a/tests/unit/control-plane/capacity/agents/team-clone-source-snapshot.test.ts b/tests/unit/control-plane/capacity/agents/team-clone-source-snapshot.test.ts new file mode 100644 index 00000000..8734c835 --- /dev/null +++ b/tests/unit/control-plane/capacity/agents/team-clone-source-snapshot.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { serializeFrontmatterDocument } from '../../../../../src/api/content/frontmatter.ts'; +import { snapshotAgents } from '../../../../../src/api/capacity/services/capacity/agents/team-clone/agent-team-clone-service.ts'; + +const commit = 'a'.repeat(40); +const definition = { + schemaVersion: 'treeseed.agent/v1', id: 'sdk/architect', name: 'SDK Architect', agentClass: 'architect', + purpose: 'Guide SDK architecture.', responsibilities: ['Answer bounded questions.'], capabilities: ['reasoning'], + context: { include: ['project-objectives'] }, + activityProfiles: { chat: { handler: 'writer', permissions: { + content: { read: ['discussion'], write: ['discussion'] }, tools: ['discussion'], + }, prompt: { system: 'Research authorized context before answering.' } } }, +}; + +function connection(files: Array<{ path: string; content: string }>, paths = files.map((file) => file.path)) { + return { baseRef: commit, contentPath: '.', repositoryId: 'sdk-library', client: { + listRepositoryPaths: vi.fn(async () => ({ resolvedRef: commit, entries: paths.map((path) => ({ path })) })), + readRepositoryFiles: vi.fn(async () => ({ resolvedRef: commit, files })), + } } as never; +} + +describe('exact TreeDX agent-team source snapshot', () => { + it('accepts a complete valid snapshot', async () => { + const path = 'agents/architect.mdx'; + const result = await snapshotAgents(connection([{ path, content: serializeFrontmatterDocument(definition) }])); + expect(result).toMatchObject({ commit, files: [{ path, definition: { agentClass: 'architect' } }] }); + }); + + it('rejects a collection resolved to a different commit than the requested source', async () => { + const source = connection([]) as { client: { listRepositoryPaths: ReturnType } }; + source.client.listRepositoryPaths.mockResolvedValue({ resolvedRef: 'b'.repeat(40), entries: [] }); + await expect(snapshotAgents(source as never, commit)).rejects.toMatchObject({ code: 'agent_team_snapshot_moved' }); + }); + + it('fails closed when TreeDX omits a listed definition', async () => { + await expect(snapshotAgents(connection([], ['agents/architect.mdx']))).rejects.toThrow(/every exact agent definition/u); + }); + + it('fails closed instead of silently dropping an invalid definition', async () => { + await expect(snapshotAgents(connection([{ path: 'agents/invalid.mdx', content: '---\nid: invalid\n---\n' }]))) + .rejects.toThrow(/invalid; the exact source snapshot cannot be cloned/u); + }); + + it('rejects two definitions that would overwrite the same target agent class', async () => { + const files = ['first', 'second'].map((name) => ({ path: `agents/${name}.mdx`, + content: serializeFrontmatterDocument({ ...definition, id: `sdk/${name}` }) })); + await expect(snapshotAgents(connection(files))).rejects.toThrow(/Multiple source agent definitions select the same class/u); + }); +}); diff --git a/tests/unit/control-plane/capacity/communication/discussion-admission-capacity.test.ts b/tests/unit/control-plane/capacity/communication/discussion-admission-capacity.test.ts index 97295a00..3225fb77 100644 --- a/tests/unit/control-plane/capacity/communication/discussion-admission-capacity.test.ts +++ b/tests/unit/control-plane/capacity/communication/discussion-admission-capacity.test.ts @@ -46,7 +46,7 @@ describe('discussion invocation capacity admission', () => { first: vi.fn(async (query: string, params: unknown[] = []) => { if (query.includes('capacity_provider_availability_sessions')) return { execution_providers_json: [{ id: 'codex', status: 'active', maxConcurrentWorkers: 1, lanes: [{ purpose: 'communication', maxConcurrentWorkers: 1 }] }], - metadata_json: { sourceClosureDigest: `sha256:${'b'.repeat(64)}` }, + metadata_json: { runtimeBuild: `sha256:${'b'.repeat(64)}` }, }; if (query.includes('COUNT(*) AS count FROM capacity_provider_assignments')) return { count: 0 }; if (query.includes('SELECT status,execution_id,blocking_state_json FROM agent_invocation_requests')) return claimed.get(String(params[0])) ?? null; diff --git a/tests/unit/control-plane/capacity/communication/scheduling-diagnostics.test.ts b/tests/unit/control-plane/capacity/communication/scheduling-diagnostics.test.ts index 4f5df556..7d3bae5c 100644 --- a/tests/unit/control-plane/capacity/communication/scheduling-diagnostics.test.ts +++ b/tests/unit/control-plane/capacity/communication/scheduling-diagnostics.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import { communicationSchedulingDiagnostics } from '../../../../../src/api/control-plane/repositories/capacity/communication/scheduling-diagnostics.ts'; -import { selectWorkdayDemandSupply } from '../../../../../src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts'; - -vi.mock('../../../../../src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts', () => ({ selectWorkdayDemandSupply: vi.fn() })); function fixture() { const store = { @@ -10,22 +7,21 @@ function fixture() { ? { status: 'running', execution_mode: 'production', parameters_json: JSON.stringify({ prompt: 'PRIVATE' }) } : { id: 'session', status: 'running' }), all: vi.fn(async (sql: string) => { - if (sql.includes('capacity_audit_events')) return [{ created_at: 'now', metadata_json: JSON.stringify({ reasons: ['capacity_team_library_not_ready'], message: 'PRIVATE', details: { token: 'PRIVATE' } }) }]; - if (sql.includes('SELECT demand.*')) return [{ id: 'demand' }]; + if (sql.includes('execution_nodes')) return [{ kind: 'communication', status: 'ready', count: '1' }]; if (sql.includes('COUNT(*)')) return [{ status: 'pending', count: '1' }]; return [{ id: 'wave', status: 'running', round: '1', wave: '1' }]; }), }; - vi.mocked(selectWorkdayDemandSupply).mockResolvedValue({ selected: null, eligible: [], rejected: [], policy: {} } as any); return store; } describe('unassigned chat scheduling diagnostics', () => { - it('reports pending demand and denial codes without exposing stored content', async () => { + it('reports live graph and assignment counts without exposing stored content or retired demands', async () => { const store = fixture(); const result = await communicationSchedulingDiagnostics(store, 'team', 'run'); - expect(result).toMatchObject({ executionMode: 'production', sessionStatus: 'running', demands: [{ status: 'pending', count: 1 }], supply: [{ demandId: 'demand', denials: [{ code: 'capacity_team_library_not_ready', at: 'now' }] }] }); + expect(result).toMatchObject({ executionMode: 'production', sessionStatus: 'running', nodes: [{ kind: 'communication', status: 'ready', count: 1 }], assignments: [{ status: 'pending', count: 1 }] }); expect(JSON.stringify(result)).not.toContain('PRIVATE'); expect(store.first.mock.calls[0][0]).toContain('team_id=?'); + expect(store.all.mock.calls.every(([sql]) => !sql.includes('capacity_workday_demands'))).toBe(true); expect(store.all.mock.calls.filter(([sql]) => !sql.includes('workday_planning_waves')).every(([sql]) => sql.includes('team_id=?'))).toBe(true); }); it('does not inspect a run outside the authorized team', async () => { diff --git a/tests/unit/control-plane/capacity/communication/terminal-invocation-reconciliation.test.ts b/tests/unit/control-plane/capacity/communication/terminal-invocation-reconciliation.test.ts index e904c4c9..c2e842cf 100644 --- a/tests/unit/control-plane/capacity/communication/terminal-invocation-reconciliation.test.ts +++ b/tests/unit/control-plane/capacity/communication/terminal-invocation-reconciliation.test.ts @@ -36,7 +36,7 @@ describe('terminal conversation invocation reconciliation', () => { all: vi.fn(async () => [invocation]), first: vi.fn(async (query: string) => { if (query.includes('capacity_provider_assignments')) return null; - if (query.includes('capacity_workday_demands')) return null; + if (query.includes('execution_nodes')) return null; if (query.includes('capacity_workday_runs')) return { status: 'degraded' }; if (query.includes('FROM projects')) return { slug: 'sdk' }; return null; @@ -50,11 +50,11 @@ describe('terminal conversation invocation reconciliation', () => { expect(run).toHaveBeenCalledWith(expect.stringContaining("'agent.failed'"), expect.arrayContaining(['@sdk/architect'])); }); - it('keeps an admitted invocation active while its pending demand is useful', async () => { + it('keeps an admitted invocation active while its graph communication node is ready', async () => { const invocation = { id: 'invocation-live', team_id: 'team-1', execution_id: 'conversation-live', execution_kind: 'conversation', status: 'admitted' }; const store = { all: vi.fn(async () => [invocation]), - first: vi.fn(async (query: string) => query.includes('capacity_workday_demands') ? { id: 'demand-live' } : null), + first: vi.fn(async (query: string) => query.includes('execution_nodes') ? { id: 'node-live' } : null), run: vi.fn(), createCapacityWorkdayRun: vi.fn(), tickCapacityWorkdayRun: vi.fn(), updateCapacityWorkdayRun: vi.fn(), }; diff --git a/tests/unit/control-plane/capacity/context/workday-identity-context.test.ts b/tests/unit/control-plane/capacity/context/workday-identity-context.test.ts deleted file mode 100644 index 7117dacc..00000000 --- a/tests/unit/control-plane/capacity/context/workday-identity-context.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { assignmentInput } from '../../../../../src/api/capacity/services/capacity/assignments/planning/assignment-function.ts'; - -vi.mock('../../../../../src/api/capacity/policy/supply-selection.ts', () => ({ - capacitySupplyCandidateStatus: () => 'available', - selectCapacitySupply: () => ({ selected: { executionProviderId: 'provider' }, rejected: [] }), -})); -vi.mock('../../../../../src/api/capacity/services/capacity/assignments/planning/admission/battery-admission.ts', () => ({ assertBatteryAdmission: async () => undefined })); -vi.mock('../../../../../src/api/capacity/services/capacity/assignments/planning/context/cross-project-read-repositories.ts', () => ({ resolveCrossProjectReadRepositories: async () => [] })); - -describe('immutable identity on compiled assignments', () => { - it.each(['conversation', 'workday'] as const)('binds %s identity to the actual project and team library revisions', async executionKind => { - const ref = 'a'.repeat(40), teamRef = 'b'.repeat(40); - const store = { - getProject: async () => ({ id: 'project', slug: 'sdk' }), - getProjectByTeamAndSlug: async () => ({ id: 'team-library', metadata: { provisioning: { state: 'known-good' } } }), - getProjectTreeDxLibrary: async () => ({ repositoryId: 'team-repo', metadata: { resolvedRef: teamRef } }), - first: async () => ({ count: 0 }), - }; - const demand = { - id: 'demand', claimToken: 'claim', teamId: 'team', projectId: 'project', - workdayId: 'day', workdayRunId: 'run', projectAgentClassId: 'review', - agentId: 'reviewer', handlerId: 'writer', activityType: 'reviewing', mode: 'planning', requestedSeconds: 180, - metadata: { executionKind, executionMode: 'production' }, - payload: { repositoryId: 'project-repo', contentRoot: '.', contentBaseRef: ref, agentContentPath: 'agents/reviewer.mdx', - intent: { artifactKind: 'proposal_feedback_note', subjectModel: 'proposal', subjectId: 'proposal' } }, - }; - const providers = [{ id: 'provider', offers: [], capabilities: [], status: 'available', lanes: [ - { id: 'communication', purpose: 'communication', capabilities: [], maxConcurrentRunners: 1 }, - { id: 'workday', purpose: 'workday', capabilities: [], maxConcurrentRunners: 1 }, - ] }]; - const result = await assignmentInput(store as never, demand as never, - { teamId: 'team', capacityProviderId: 'host', membershipId: 'membership' } as never, - 'session', providers as never, {} as never, '2026-09-11T15:00:00Z'); - expect(result.metadata.identityManifest).toMatchObject({ - agentHandle: '@sdk/reviewer', repositoryId: 'project-repo', immutableRef: ref, - agentProfile: { path: 'agents/reviewer.mdx', expectedRevision: ref }, - coreObjective: { path: 'objectives/core', expectedRevision: ref }, - projectReadme: { path: 'README.md', expectedRevision: ref }, - teamLibrary: { projectId: 'team-library', repositoryId: 'team-repo', immutableRef: teamRef }, - }); - expect(result.allowedOutputs.types).toContain('proposal_feedback_note'); - }); -}); diff --git a/tests/unit/control-plane/capacity/diagnostics/runtime-diagnostics-assignment-authority.test.ts b/tests/unit/control-plane/capacity/diagnostics/runtime-diagnostics-assignment-authority.test.ts new file mode 100644 index 00000000..e61fdf83 --- /dev/null +++ b/tests/unit/control-plane/capacity/diagnostics/runtime-diagnostics-assignment-authority.test.ts @@ -0,0 +1,20 @@ +import { expect, it, vi } from 'vitest'; +import { buildProjectCapacityRuntimeDiagnostics } from '../../../../../src/api/capacity/services/runtime/runtime-diagnostics-query-service.ts'; + +it('builds runtime diagnostics without reading retired mode-run records', async () => { + const queries: string[] = []; + const page = { items: [], page: { limit: 25, hasMore: false, nextCursor: null } }; + const repository = { + first: vi.fn(async (sql: string) => { queries.push(sql); return { assignment_count: 0 }; }), + all: vi.fn(async (sql: string) => { queries.push(sql); return []; }), + getProject: vi.fn(async () => ({ id: 'project', teamId: 'team' })), + listProviderAssignmentsPage: vi.fn(async () => page), + listTreeDxProxyAuditPage: vi.fn(async () => page), + listAgentFallbackOutputsPage: vi.fn(async () => page), + listCapacityLedgerEntriesPage: vi.fn(async () => page), + }; + const diagnostics = await buildProjectCapacityRuntimeDiagnostics(repository, 'project', 'team'); + expect(diagnostics?.assignments).toEqual([]); + expect(JSON.stringify(diagnostics)).not.toMatch(/modeRuns/u); + expect(queries.join('\n')).not.toContain('agent_mode_runs'); +}); diff --git a/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts b/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts index 715bb625..4693d7bc 100644 --- a/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts +++ b/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts @@ -16,6 +16,19 @@ const provider = { id: 'codex-implementation', accountingLimits: { modelConfigur accountingObservation: { modelUsage: observation, capabilityUsage: { implementation: observation } } }; describe('live allocation ledger inputs', () => { + it('sizes chat opportunities against communication concurrency and only chat-ready graph nodes', async () => { + const at = '2026-09-16T13:55:00.000Z'; + const store = { all: vi.fn(async () => []), first: vi.fn(async () => ({ ready_count: 1 })) }; + const chatRun = { ...run, parameters: { ...run.parameters, appliedPlan: { ...plan, + policySnapshot: { ...plan.policySnapshot, communicationConcurrency: 2 } } } }; + const chatProvider = { ...provider, accountingObservation: { modelUsage: { ...observation, observedAt: at }, + capabilityUsage: { implementation: { ...observation, observedAt: at } } } }; + const result = await livingAllocationInputs(store as never, { run: chatRun as never, + runs: [chatRun as never], providers: [chatProvider as never], capacityProviderId: 'provider', + capabilityId: 'implementation', agentClass: 'architect', activity: 'chat', now: at }); + expect(result['codex-implementation']?.opportunity.availableSeconds).toBe(600); + expect(store.first.mock.calls[0]?.[0]).toContain("node.kind='communication'"); + }); it('counts shared proposal work through real PostgreSQL graph custody, not only workday-owned nodes', async () => { const db = new PGlite(); try { diff --git a/tests/unit/control-plane/capacity/execution/admission/living-execution-assignment-paths.test.ts b/tests/unit/control-plane/capacity/execution/admission/living-execution-assignment-paths.test.ts index aef7d3bb..f5af4109 100644 --- a/tests/unit/control-plane/capacity/execution/admission/living-execution-assignment-paths.test.ts +++ b/tests/unit/control-plane/capacity/execution/admission/living-execution-assignment-paths.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { reservationFairUsage, treeDxAuthorizedPaths } from '../../../../../../src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts'; +import { reservationFairUsage, treeDxAuthorizedPaths, workdayConcurrencyAvailable } from '../../../../../../src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts'; describe('living execution TreeDX path authority', () => { + it('keeps communication admission independent of the ordinary workday slot', () => { + const policy = { maximumConcurrency: 1, communicationConcurrency: 2 }; + expect(workdayConcurrencyAvailable('acting', { workday: 1, conversation: 0 }, policy)).toBe(false); + expect(workdayConcurrencyAvailable('communication', { workday: 1, conversation: 1 }, policy)).toBe(true); + expect(workdayConcurrencyAvailable('communication', { workday: 0, conversation: 2 }, policy)).toBe(false); + expect(workdayConcurrencyAvailable('reviewing', { workday: 0, conversation: 2 }, policy)).toBe(true); + }); it('counts active reservations once and releases unused terminal capacity for fairness', () => { const rows = ['reserved', 'consuming', 'consumed', 'released'].map((state) => ({ project_id: 'sdk', agent_class: 'engineer', state, reserved_seconds: 180, active_seconds: 30, elapsed_seconds: 900 })); diff --git a/tests/unit/control-plane/capacity/execution/admission/living-reservation-record.test.ts b/tests/unit/control-plane/capacity/execution/admission/living-reservation-record.test.ts new file mode 100644 index 00000000..48cd9d00 --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/admission/living-reservation-record.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { serializeCapacityReservationRow } from '../../../../../../src/api/capacity/repositories/capacity/accounting/reservation.ts'; + +describe('living-graph reservation record', () => { + it('serializes a graph admission without a retired allocation set', () => { + const row = { + id: 'reservation', idempotency_key: 'attempt', membership_id: 'member', grant_id: null, + capacity_provider_id: 'provider', execution_provider_id: 'execution', lane_id: 'workday', + project_agent_class_id: 'class', assignment_id: 'assignment', mode: 'acting', team_id: 'team', + project_id: 'project', work_day_id: 'workday', task_id: null, state: 'reserved', + requested_seconds: 60, reserved_seconds: 60, active_seconds: 0, elapsed_seconds: 0, + released_seconds: 0, overrun_seconds: 0, native_unit: null, reserved_native_amount: null, + consumed_native_amount: null, reserved_provider_units: null, consumed_provider_units: null, + reserved_usd: null, consumed_usd: null, expires_at: null, metadata_json: '{}', + policy_snapshot_json: '{}', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + allocation_set_id: null, allocation_version: null, + }; + expect(serializeCapacityReservationRow(row)).toMatchObject({ id: 'reservation', grantId: null, assignmentId: 'assignment', reservedSeconds: 60 }); + }); +}); diff --git a/tests/unit/control-plane/capacity/execution/admission/postgres.test.ts b/tests/unit/control-plane/capacity/execution/admission/postgres.test.ts index 29bfcec2..ab6d3049 100644 --- a/tests/unit/control-plane/capacity/execution/admission/postgres.test.ts +++ b/tests/unit/control-plane/capacity/execution/admission/postgres.test.ts @@ -7,6 +7,7 @@ import { admitLivingExecutionAssignment } from '../../../../../../src/api/capaci import { assignment } from '../fixtures/assignment.ts'; import { ProviderAssignmentRepository } from '../../../../../../src/api/capacity/repositories/capacity/assignments/assignment.ts'; import { buildProviderAssignmentExplanation } from '../../../../../../src/api/capacity/services/capacity/assignments/observability/assignment-explanation-service.ts'; +import { settleCapacityReservationExactlyOnce } from '../../../../../../src/api/capacity/services/capacity/accounting/settlement-service.ts'; const url = process.env.TREESEED_TEST_POSTGRES_URL; describe.skipIf(!url)('living admission in disposable PostgreSQL', () => { @@ -22,6 +23,8 @@ describe.skipIf(!url)('living admission in disposable PostgreSQL', () => { await database.migrate(); const now = assignment.createdAt; await database.pool.query(`INSERT INTO teams (id,slug,name,created_at,updated_at) VALUES ('team','team','Team',$1,$1)`, [now]); + await database.pool.query(`INSERT INTO capacity_workday_runs (id,team_id,status,execution_mode,created_at,updated_at) + VALUES ('workday','team','running','simulation',$1,$1)`, [now]); await database.pool.query(`INSERT INTO projects (id,team_id,slug,name,created_at,updated_at) VALUES ('project','team','project','Project',$1,$1)`, [now]); await database.pool.query(`INSERT INTO project_agent_classes (id,team_id,project_id,slug,name,created_at,updated_at) VALUES ('class','team','project','engineer','Engineer',$1,$1)`, [now]); await database.pool.query(`INSERT INTO capacity_providers (id,fingerprint,public_jwk_json,display_name,created_at,updated_at) VALUES ('provider','test','{}','Provider',$1,$1)`, [now]); @@ -53,9 +56,9 @@ describe.skipIf(!url)('living admission in disposable PostgreSQL', () => { principal: { teamId: 'team', capacityProviderId: 'provider', membershipId: 'membership' } as never, assignment: attempt, allocation: calculateAssignmentAllocation({ estimate: attempt.estimate, measurements: [], constraints: [{ id: 'model-day', remainingSeconds: 3 }] }), - accountingLimits: { modelConfigurationId: 'terra-medium', dailyActiveSecondsLimit: 4, capabilityLimits: { 'code-change': { dailyActiveSecondsLimit: 4 } } }, + accountingLimits: { modelConfigurationId: 'terra-medium', dailyActiveSecondsLimit: 10, capabilityLimits: { 'code-change': { dailyActiveSecondsLimit: 10 } } }, projectAgentClassId: 'class', providerSessionId: 'session', executionProviderId: 'codex', laneId: 'workday', lanePurpose: 'workday', - executionKind: 'workday', predecessorResults: [], treedxProxyHandle: { id: `tdx-${attempt.id}` }, now: attempt.createdAt, + executionKind: 'workday', workdayConcurrencyLimit: 1, predecessorResults: [], treedxProxyHandle: { id: `tdx-${attempt.id}` }, now: attempt.createdAt, }); const results = await Promise.allSettled(attempts.map(run)); expect(results.filter(result => result.status === 'fulfilled'), results.map(result => result.status === 'rejected' ? String(result.reason) : 'admitted').join('\n')).toHaveLength(1); @@ -72,11 +75,11 @@ describe.skipIf(!url)('living admission in disposable PostgreSQL', () => { expect((await database.pool.query('SELECT sum(reserved_amount)::int AS total FROM capacity_reservation_counter_claims')).rows[0].total).toBe(6); // Terminal overuse is retained as measured truth, never approved by raising // the cap. The real admission path must deny additional work atomically. - await database.pool.query('UPDATE capacity_admission_counters SET committed_amount=5'); + await database.pool.query('UPDATE capacity_admission_counters SET committed_amount=11'); const loser = attempts[results.findIndex(result => result.status === 'rejected')]!; - await expect(run(loser)).rejects.toMatchObject({ code: 'execution_node_claim_lost' }); + await expect(run(loser)).rejects.toMatchObject({ code: 'capacity_assignment_allocation_deferred' }); expect((await database.pool.query('SELECT hard_limit,committed_amount FROM capacity_admission_counters')).rows) - .toEqual([{ hard_limit: 4, committed_amount: 5 }, { hard_limit: 4, committed_amount: 5 }]); + .toEqual([{ hard_limit: 10, committed_amount: 11 }, { hard_limit: 10, committed_amount: 11 }]); expect((await database.pool.query('SELECT count(*)::int AS count FROM capacity_reservations')).rows[0].count).toBe(1); await database.pool.query(`UPDATE capacity_provider_assignments SET assignment_attempt_json='{}' WHERE id=$1`, [winner.id]); const repository = new ProviderAssignmentRepository(store as never); @@ -91,6 +94,15 @@ describe.skipIf(!url)('living admission in disposable PostgreSQL', () => { id: winner.id, status: 'pending', reservationId: winner.reservationId, assignmentAttempt: null, }); expect(await repository.getForCancellation('other-team', winner.id)).toBeNull(); + const settlement = { settlementKey: `settle:${winner.id}`, teamId: 'team', membershipId: 'membership', + reservationId: winner.reservationId, assignmentId: winner.id, activeSeconds: 2, elapsedSeconds: 4, + source: 'postgres-admission-test' }; + expect((await settleCapacityReservationExactlyOnce(store as never, settlement)).replayed).toBe(false); + expect((await settleCapacityReservationExactlyOnce(store as never, settlement)).replayed).toBe(true); + expect((await database.pool.query(`SELECT count(*)::int AS count FROM capacity_ledger_entries + WHERE reservation_id=$1 AND phase='task_completed_actual_settlement'`, [winner.reservationId])).rows[0].count).toBe(1); + expect((await database.pool.query(`SELECT count(*)::int AS count FROM capacity_usage_actuals + WHERE assignment_id=$1 AND accounting_mode='aggregate'`, [winner.id])).rows[0].count).toBe(1); } finally { await database.close(); await admin.query(`DROP DATABASE "${name}"`); await admin.end(); diff --git a/tests/unit/control-plane/capacity/execution/admission/timeout-postgres.test.ts b/tests/unit/control-plane/capacity/execution/admission/timeout-postgres.test.ts index 8d6155c4..218b44bc 100644 --- a/tests/unit/control-plane/capacity/execution/admission/timeout-postgres.test.ts +++ b/tests/unit/control-plane/capacity/execution/admission/timeout-postgres.test.ts @@ -39,8 +39,8 @@ describe.skipIf(!url)('terminal timeout PostgreSQL custody', () => { await db.pool.query(`INSERT INTO capacity_reservation_counter_claims (reservation_id,counter_id,admission_token,reserved_amount,release_policy,created_at,updated_at) VALUES ('reservation','counter','admission',20,'usage-settlement',$1,$1)`, [now]); - await db.pool.query(`UPDATE capacity_provider_assignments SET capacity_envelope_json=$1,decision_input_json=$2 WHERE id='assignment'`, - [JSON.stringify({ teamId: 'team', projectId: 'project', mode: 'acting' }), JSON.stringify({ teamId: 'team', projectId: 'project', projectAgentClassId: 'engineer', mode: 'acting', input: {} })]); + await db.pool.query(`UPDATE capacity_provider_assignments SET capacity_envelope_json=$1 WHERE id='assignment'`, + [JSON.stringify({ teamId: 'team', projectId: 'project', mode: 'acting' })]); const store: CapacityGovernanceDatabase & { db: typeof db } = { db, ensureInitialized: () => db.migrate(), run: async (sql, params = []) => { await db.prepare(sql).bind(...params).run(); }, first: (sql, params = []) => db.prepare(sql).bind(...params).first(), diff --git a/tests/unit/control-plane/capacity/execution/assignment-attempt-builder.test.ts b/tests/unit/control-plane/capacity/execution/assignment-attempt-builder.test.ts index c1dd0cf0..31eaf086 100644 --- a/tests/unit/control-plane/capacity/execution/assignment-attempt-builder.test.ts +++ b/tests/unit/control-plane/capacity/execution/assignment-attempt-builder.test.ts @@ -85,6 +85,57 @@ describe('immutable assignment-attempt construction', () => { expect(result.assignment.workspace).toMatchObject({ baseCommit: '9'.repeat(40) }); }); + it('uses the sole Git predecessor as the base for an initial acting node', () => { + const following = structuredClone(candidate); + following.predecessorResults = [{ + schemaVersion: 'treeseed.assignment-result/v1', id: 'predecessor', assignmentId: 'previous-assignment', + status: 'completed', summary: 'Reviewed predecessor.', + references: [{ kind: 'git', repository: 'treeseed-ai/sdk', commit: '9'.repeat(40) }], + verification: [], usage: { elapsedSeconds: 1 }, diagnostics: [], completedAt: '2026-09-13T12:00:00.000Z', + }] as never; + const result = buildAssignmentAttempt({ candidate: following as never, run, + principal: { teamId: 'team', capacityProviderId: 'provider' } as never, + allocationInputs: { codex: { measurements: [], constraints: [] } }, providerSessionId: 'session', providers: [provider] as never, attempt: 1, + now: '2026-09-13T12:00:00.000Z' }); + expect(result.assignment.workspace).toMatchObject({ baseCommit: '9'.repeat(40) }); + }); + + it('rejects divergent Git predecessors until an explicit integration assignment combines them', () => { + const divergent = structuredClone(candidate); + divergent.predecessorResults = ['8', '9'].map((digit) => ({ + schemaVersion: 'treeseed.assignment-result/v1', id: `predecessor-${digit}`, assignmentId: `assignment-${digit}`, + status: 'completed', summary: 'Reviewed predecessor.', + references: [{ kind: 'git', repository: 'treeseed-ai/sdk', commit: digit.repeat(40) }], + verification: [], usage: { elapsedSeconds: 1 }, diagnostics: [], completedAt: '2026-09-13T12:00:00.000Z', + })) as never; + expect(() => buildAssignmentAttempt({ candidate: divergent as never, run, + principal: { teamId: 'team', capacityProviderId: 'provider' } as never, + allocationInputs: { codex: { measurements: [], constraints: [] } }, providerSessionId: 'session', providers: [provider] as never, attempt: 1, + now: '2026-09-13T12:00:00.000Z' })).toThrow(/explicit integration assignment/u); + }); + + it('gives an authorized Releaser one exact base and every divergent predecessor for integration', () => { + const integration = structuredClone(candidate); + integration.node.agentClass = 'releaser'; + integration.node.workItemId = 'simulate-release'; + integration.node.requestedPermissions.tools = [...permissions.tools, 'release']; + integration.effectiveProfile.handler = 'releaser'; + integration.effectiveProfile.permissionCeiling.tools = [...permissions.tools, 'release']; + integration.predecessorResults = ['8', '9'].map((digit) => ({ + schemaVersion: 'treeseed.assignment-result/v1', id: `predecessor-${digit}`, assignmentId: `assignment-${digit}`, + status: 'completed', summary: 'Reviewed predecessor.', + references: [{ kind: 'git', repository: 'treeseed-ai/sdk', commit: digit.repeat(40) }], + verification: [], usage: { elapsedSeconds: 1 }, diagnostics: [], completedAt: '2026-09-13T12:00:00.000Z', + })) as never; + const result = buildAssignmentAttempt({ candidate: integration as never, run, + principal: { teamId: 'team', capacityProviderId: 'provider' } as never, + allocationInputs: { codex: { measurements: [], constraints: [] } }, providerSessionId: 'session', providers: [provider] as never, + attempt: 1, now: '2026-09-13T12:00:00.000Z' }); + expect(result.assignment.workspace).toMatchObject({ mode: 'git', repository: 'treeseed-ai/sdk', baseCommit: 'c'.repeat(40) }); + expect(result.assignment.predecessorResultIds).toEqual(['predecessor-8', 'predecessor-9']); + expect(result.assignment.grant.tools).toContain('release'); + }); + it('fails closed when no exact provider runtime satisfies the node', () => { expect(() => buildAssignmentAttempt({ candidate: candidate as never, run, @@ -106,6 +157,22 @@ describe('immutable assignment-attempt construction', () => { expect(result.assignment.provider.offerId).toBe('narrow'); }); + it('selects another eligible provider when the first has no workday allocation left', () => { + const exhausted = { ...provider, id: 'a-exhausted', offers: [{ ...provider.offers[0]!, offerId: 'exhausted-offer' }] }; + const available = { ...provider, id: 'b-available', offers: [{ ...provider.offers[0]!, offerId: 'available-offer' }] }; + const result = buildAssignmentAttempt({ candidate: candidate as never, run, + principal: { teamId: 'team', capacityProviderId: 'provider' } as never, + allocationInputs: { + 'a-exhausted': { measurements: [], constraints: [{ id: 'workday-phase-share', remainingSeconds: 0 }], + opportunity: { availableSeconds: 0 } }, + 'b-available': { measurements: [], constraints: [{ id: 'workday-phase-share', remainingSeconds: 300 }], + opportunity: { availableSeconds: 300 } }, + } as never, + providerSessionId: 'session', providers: [exhausted, available] as never, attempt: 1, + now: '2026-09-13T12:00:00.000Z' }); + expect(result.assignment.provider).toMatchObject({ executionProviderId: 'b-available', offerId: 'available-offer' }); + }); + it('rejects executable nodes whose capability demand was not compiled', () => { const missing = structuredClone(candidate); missing.node.requiredCapabilities = []; diff --git a/tests/unit/control-plane/capacity/execution/assignment-recovery-service.test.ts b/tests/unit/control-plane/capacity/execution/assignment-recovery-service.test.ts index 56dc9bea..15108295 100644 --- a/tests/unit/control-plane/capacity/execution/assignment-recovery-service.test.ts +++ b/tests/unit/control-plane/capacity/execution/assignment-recovery-service.test.ts @@ -1,7 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; -import { recoverableLeaseSql, recoverExpiredProviderAssignments } from '../../../../../src/api/capacity/services/capacity/assignments/lifecycle/assignment-recovery-service.ts'; +import { decideAssignmentRecovery, recoverableLeaseSql, recoverExpiredProviderAssignments } from '../../../../../src/api/capacity/services/capacity/assignments/lifecycle/assignment-recovery-service.ts'; describe('assignment recovery eligibility', () => { + it('retries only a graph-admitted attempt, and never equates an assignment result with graph completion', () => { + const assignment = { id: 'assignment', executionKind: 'work', attemptCount: 0 } as never; + const observed = { reservation: null, settlement: null, usageCount: 0, hasAssignmentResult: false, + proxyEvents: 0, fallbackOutputs: 0, node: { id: 'node', status: 'assigned' }, + failoverAllowed: true, failoverCount: 1, invocationFinalMessageRef: null }; + expect(decideAssignmentRecovery(assignment, observed)).toMatchObject({ disposition: 'safe-retry', reasonCode: 'expired_lease_requeued' }); + expect(decideAssignmentRecovery(assignment, { ...observed, failoverAllowed: false })).toMatchObject({ disposition: 'terminal-failure' }); + expect(decideAssignmentRecovery(assignment, { ...observed, failoverCount: 3 })) + .toMatchObject({ disposition: 'terminal-failure', reasonCode: 'expired_lease_retry_exhausted' }); + expect(decideAssignmentRecovery(assignment, { ...observed, settlement: { source: 'task_completed_actual_settlement' }, hasAssignmentResult: true })) + .toMatchObject({ disposition: 'operator-action', reasonCode: 'expired_lease_completion_requires_graph_reconciliation' }); + }); it('treats an expired lease or a closed provider session as the same recovery frontier', () => { const predicate = recoverableLeaseSql('assignment'); expect(predicate).toContain('assignment.lease_expires_at <= ?'); diff --git a/tests/unit/control-plane/capacity/execution/fixtures/assignment.ts b/tests/unit/control-plane/capacity/execution/fixtures/assignment.ts index 5cb810a3..30362276 100644 --- a/tests/unit/control-plane/capacity/execution/fixtures/assignment.ts +++ b/tests/unit/control-plane/capacity/execution/fixtures/assignment.ts @@ -1,6 +1,6 @@ export const assignment = { schemaVersion: 'treeseed.assignment-attempt/v1', id: 'assignment', idempotencyKey: 'assignment', teamId: 'team', projectId: 'project', - workdayId: 'workday', nodeId: 'node', workItemId: 'work-item', nodeRevision: 1, graphRevision: 2, + workdayId: 'workday', nodeId: 'node', agentClass: 'engineer', workItemId: 'work-item', nodeRevision: 1, graphRevision: 2, sourceRef: { store: 'treedx', model: 'proposal', id: 'proposal', revision: 1, digest: `sha256:${'a'.repeat(64)}` }, authorityRefs: [{ store: 'postgresql', model: 'decision', id: 'decision', revision: 1, digest: `sha256:${'e'.repeat(64)}` }], effectiveProfile: { profileRef: { store: 'treedx', model: 'agent', id: 'sdk/engineer', revision: 1, digest: `sha256:${'b'.repeat(64)}` }, activity: 'acting', handler: 'actor', handlerOrigin: 'agent-package', prompt: { system: 'Implement the exact authorized source change.' }, diff --git a/tests/unit/control-plane/capacity/execution/graph/decision-work-estimate-graph.test.ts b/tests/unit/control-plane/capacity/execution/graph/decision-work-estimate-graph.test.ts deleted file mode 100644 index 136e3dea..00000000 --- a/tests/unit/control-plane/capacity/execution/graph/decision-work-estimate-graph.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { StructuredAgentEstimate } from '@treeseed/sdk/agent-capacity'; -import { compileDecisionAssignmentGraphFromEstimates } from '../../../../../../src/api/capacity/policy/decision-work.ts'; - -function estimate(overrides: Partial & Pick): StructuredAgentEstimate { - const value: StructuredAgentEstimate = { - schemaVersion: 3, - teamId: 'team', - projectId: 'project', - decisionId: 'decision', - proposalId: 'proposal', - minSeconds: 60, - expectedSeconds: 120, - maxSeconds: 180, - confidence: 'high', - riskLevel: 'low', - assumptions: [], - blockers: [], - dependencies: [], - acceptanceCriteria: ['The requested work is verified.'], - completionEvidence: ['Exact assignment artifact manifest.'], - proposalRevision: { id: 'proposal', version: 1, digest: 'sha256:proposal' }, - decisionRevision: { id: 'decision', version: 1, digest: 'sha256:decision' }, - groupSnapshot: { projectId: 'project', directGroupIds: [], effectiveGroupIds: [], provenance: [], graphRevision: 'graph-1', immutableRef: 'a'.repeat(40), digest: 'sha256:groups', capturedAt: '2026-09-12T00:00:00.000Z' }, - agentDefinitionRevision: { id: 'agent-definition', revision: 1, digest: 'sha256:agent' }, - requiredProviderCapabilities: ['treeseed.engineering.code-change'], - acceptableProviderClasses: ['local-kata'], - ...overrides, - }; - value.workBreakdown ??= { - preparationSeconds: 0, implementationSeconds: value.expectedSeconds - 3, verificationSeconds: 0, - independentReviewSeconds: 1, revisionSeconds: 0, revisionVerificationSeconds: 0, - finalReviewSeconds: 1, reportingSeconds: 1, reserveSeconds: 0, expectedRevisionCycles: 1, - }; - return value; -} - -describe('estimate-derived decision assignment graph', () => { - it('preserves accepted work units, durations, dependencies, and provenance', () => { - const testing = estimate({ - id: 'estimate-testing', agentClass: 'testing', workUnitId: 'write-failing-test', expectedSeconds: 150, maxSeconds: 240, - expectedOutputs: [{ id: 'failing-test', outputType: 'failing_test_proof', required: true }], - }); - const implementation = estimate({ - id: 'estimate-implementation', agentClass: 'engineering', workUnitId: 'implement-change', expectedSeconds: 420, maxSeconds: 600, - dependencies: [{ id: 'failing-test', type: 'artifact', requiredBefore: 'start', deliverableType: 'failing_test_proof' }], - expectedOutputs: [{ id: 'implementation', outputType: 'implementation_change', required: true }], - }); - const result = compileDecisionAssignmentGraphFromEstimates({ - teamId: 'team', projectId: 'project', decisionId: 'decision', exactBaseRef: 'a'.repeat(40), - estimates: [implementation, testing], executionMode: 'production', compiledAt: '2026-09-12T00:00:00.000Z', - }); - - expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); - expect(result.graph.estimateIds).toEqual(['estimate-implementation', 'estimate-testing']); - expect(result.graph.metadata).toEqual(expect.objectContaining({ workflowKind: 'estimate-derived', compilerVersion: 3 })); - const testNode = result.graph.nodes.find((node) => node.id === 'write-failing-test')!; - const implementationNode = result.graph.nodes.find((node) => node.id === 'implement-change')!; - expect(testNode.capacity).toEqual({ expectedSeconds: 150, maxSeconds: 240 }); - expect(testNode.requiredCapabilities).toEqual(['treeseed.engineering.code-change']); - expect(implementationNode.capacity).toEqual({ expectedSeconds: 420, maxSeconds: 600 }); - expect(implementationNode.metadata?.contributingEstimateIds).toEqual(['estimate-implementation']); - expect(result.graph.edges).toContainEqual(expect.objectContaining({ fromNodeId: 'write-failing-test', toNodeId: 'implement-change', edgeType: 'blocks-start' })); - expect(result.graph.nodes.some((node) => node.metadata?.stage === 'documentation' || node.metadata?.stage === 'release')).toBe(false); - }); - - it('fails closed when a required artifact is absent from accepted estimates', () => { - const result = compileDecisionAssignmentGraphFromEstimates({ - teamId: 'team', projectId: 'project', decisionId: 'decision', exactBaseRef: 'b'.repeat(40), - estimates: [estimate({ - id: 'estimate-implementation', agentClass: 'engineering', workUnitId: 'implement-change', - dependencies: [{ id: 'missing-test', type: 'artifact', requiredBefore: 'start', deliverableType: 'failing_test_proof' }], - expectedOutputs: [{ outputType: 'implementation_change', required: true }], - })], - }); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'graph_artifact_dependency_unresolved', severity: 'error' })); - expect(result.graph.status).toBe('blocked'); - }); - - it('routes estimate dependencies to their declared lifecycle gates', () => { - const producer = estimate({ - id: 'estimate-producer', agentClass: 'testing', workUnitId: 'produce-evidence', - expectedOutputs: [{ id: 'evidence', outputType: 'test_evidence', required: true }], - }); - const consumer = estimate({ - id: 'estimate-consumer', agentClass: 'engineering', workUnitId: 'consume-evidence', - dependencies: [ - { id: 'evidence', type: 'artifact', requiredBefore: 'complete', deliverableType: 'test_evidence' }, - { id: 'evidence', type: 'artifact', requiredBefore: 'review', deliverableType: 'test_evidence' }, - { id: 'evidence', type: 'artifact', requiredBefore: 'release', deliverableType: 'test_evidence' }, - ], - expectedOutputs: [{ id: 'change', outputType: 'implementation_change', required: true }], - }); - const result = compileDecisionAssignmentGraphFromEstimates({ - teamId: 'team', projectId: 'project', decisionId: 'decision', exactBaseRef: 'c'.repeat(40), - estimates: [consumer, producer], executionMode: 'production', - }); - - expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); - expect(result.graph.edges).toEqual(expect.arrayContaining([ - expect.objectContaining({ fromNodeId: 'produce-evidence', toNodeId: 'consume-evidence', edgeType: 'blocks-completion' }), - expect.objectContaining({ fromNodeId: 'produce-evidence', toNodeId: 'consume-evidence:review:review', edgeType: 'blocks-start' }), - expect.objectContaining({ fromNodeId: 'produce-evidence', toNodeId: 'project:decision:platform-integration', edgeType: 'blocks-release' }), - ])); - }); - - it('uses seeded governance classes and rejects an empty estimate authority set', () => { - const result = compileDecisionAssignmentGraphFromEstimates({ - teamId: 'team', projectId: 'project', decisionId: 'decision', estimates: [], - }); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'graph_estimates_required', severity: 'error' })); - expect(result.graph.status).toBe('blocked'); - - const populated = compileDecisionAssignmentGraphFromEstimates({ - teamId: 'team', projectId: 'project', decisionId: 'decision', - estimates: [estimate({ id: 'estimate-work', agentClass: 'engineering', workUnitId: 'work', expectedOutputs: [{ outputType: 'change', required: true }] })], - }); - expect(populated.graph.nodes).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'work:review:review', targetAgentClass: 'review' }), - ])); - expect(populated.graph.nodes.some((node) => node.activityType === 'reporting')).toBe(false); - }); -}); diff --git a/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts b/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts index b16d9bcb..48b59bdb 100644 --- a/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts @@ -16,7 +16,7 @@ describe('executable proposal source selection', () => { expect(all).toHaveBeenCalledWith(expect.stringContaining('AND p.project_id = ?'), ['team', 'project']); }); - it('quarantines an invalid accepted execution plan without admitting demand', async () => { + it('fails closed on an invalid accepted execution plan without mutating its graph', async () => { const all = vi.fn(async () => [{ proposal_id: 'invalid-proposal', project_id: 'project', active_version: 2, active_content_hash: 'a'.repeat(64), metadata_json: {}, decision_id: 'decision', @@ -26,6 +26,8 @@ describe('executable proposal source selection', () => { exactProposal.mockRejectedValueOnce(Object.assign(new Error('Invalid executable plan.'), { status: 422, code: 'proposal_execution_plan_invalid', diagnostics: [{ path: 'executionPlan' }], })); - await expect(loadTeamExecutableProposalSources({ all }, 'team', 'project')).resolves.toEqual([]); + await expect(loadTeamExecutableProposalSources({ all }, 'team', 'project')).rejects.toMatchObject({ + code: 'proposal_execution_plan_invalid', diagnostics: [{ path: 'executionPlan' }], + }); }); }); diff --git a/tests/unit/control-plane/capacity/execution/graph/execution-run-assignment-projection.test.ts b/tests/unit/control-plane/capacity/execution/graph/execution-run-assignment-projection.test.ts new file mode 100644 index 00000000..ba046815 --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/graph/execution-run-assignment-projection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock('../../../../../../src/api/capacity/repositories/capacity/assignments/assignment.ts', () => ({ + ProviderAssignmentRepository: class { list = mocks.list; }, +})); + +import { listExecutionRunsForTeamPage } from '../../../../../../src/api/capacity/repositories/support/execution-run.ts'; + +describe('execution-run assignment projection', () => { + it('reads one immutable assignment/result without querying mode-run state', async () => { + mocks.list.mockResolvedValueOnce({ page: { limit: 10, hasMore: false, nextCursor: null }, items: [{ + id: 'assignment-1', status: 'completed', mode: 'acting', createdAt: '2026-09-20T10:00:00.000Z', + assignedAt: '2026-09-20T10:00:00.000Z', claimedAt: '2026-09-20T10:00:01.000Z', + completedAt: '2026-09-20T10:00:04.000Z', failedAt: null, returnedAt: null, + teamId: 'team-1', projectId: 'sdk', projectAgentClassId: 'engineer', agentId: 'agent-1', + handlerId: 'actor', executionProviderId: 'codex-implementation', capacityProviderId: 'provider-1', + workDayId: 'workday-1', assignmentResult: { assignmentId: 'assignment-1', references: [{ kind: 'git', + repository: 'treeseed-ai/sdk', commit: 'a'.repeat(40) }], usage: { modelInputTokens: 40, modelOutputTokens: 10 } }, + } ] }); + const database = { all: vi.fn(() => { throw new Error('mode-run query forbidden'); }) }; + const page = await listExecutionRunsForTeamPage(database as never, 'team-1', { projection: 'activity' }); + expect(mocks.list).toHaveBeenCalledWith('team-1', { projection: 'activity' }); + expect(page.items[0]).toMatchObject({ id: 'assignment-1', status: 'completed', + executionProvider: { tokenCounts: { inputTokens: 40, outputTokens: 10 } }, + contentArtifactRefs: [{ kind: 'git', repository: 'treeseed-ai/sdk' }] }); + expect(database.all).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts index a3924b1f..2fa6ef18 100644 --- a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts @@ -154,6 +154,23 @@ describe('normalized living execution graph persistence', () => { expect(operations.some((operation) => /graph_events|reconciliation_receipts/u.test(operation.query))).toBe(false); }); + it('writes only the changed component when an unrelated project remains unchanged', async () => { + const unchanged = { ...node('ready'), id: 'other-node', projectId: 'other-project' }; + const changed = { ...node('blocked'), id: 'changed-node' }; + const current = graph(1, [unchanged, changed]); + const next = graph(2, [unchanged, { ...changed, status: 'ready', graphRevisionUpdated: 2 }]); + const operations: Array<{ query: string; params: unknown[] }> = []; + const store = { + batch: async (input: typeof operations) => { operations.push(...input); }, + first: async () => ({ revision: 2, graph_digest: next.digest }), + }; + await persistExecutionGraph(store, next, current, revision(2, next.digest)); + const nodeWrites = operations.filter((operation) => operation.query.includes('INSERT INTO execution_nodes')); + expect(nodeWrites).toHaveLength(1); + expect(nodeWrites[0]?.params[0]).toBe('changed-node'); + expect(operations[1]?.params[9]).toContain('other-node'); + }); + it('fails closed when another reconciliation wins the graph revision', async () => { const next = graph(2); const store = { diff --git a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts index a9cc1859..375ddcfb 100644 --- a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts @@ -117,6 +117,32 @@ describe('proposal-owned living execution graph projection', () => { expect(actor('tests').status).toBe('blocked'); }); + it('projects an exact cross-project TreeDX link from approved reviewer to dependent actor', () => { + const sdk = source([workItem({ id: 'simulate-release', agentClass: 'architect', minimumSeconds: 30, expectedSeconds: 60, maximumSeconds: 90 })]); + const apiBase = source([workItem({ id: 'tests-first', agentClass: 'tester', minimumSeconds: 30, expectedSeconds: 60, maximumSeconds: 90 })]); + const api = { ...apiBase, + projectId: 'api', repository: 'treeseed-ai/api', path: 'proposals/api.mdx', + frontmatter: { ...apiBase.frontmatter, id: 'api-plan', projectId: 'api' } }; + const endpoint = (candidate: ExecutableProposalSource, anchor: string) => ({ + store: 'treedx' as const, model: 'proposal', id: String(candidate.frontmatter.id), + revision: candidate.proposalRevision, digest: candidate.digest, repository: candidate.repository, + commit: candidate.commit, path: candidate.path, anchor, + }); + const noteRef = { store: 'treedx' as const, model: 'note', id: 'dependency', repository: api.repository, + commit: 'd'.repeat(40), path: 'notes/dependency.md', digest: `sha256:${'e'.repeat(64)}` }; + const graph = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [sdk, api], + profiles: { ...profiles, 'api:tester': agent('tester'), 'api:reviewer': agent('reviewer') }, + dependencyLinks: [{ from: endpoint(sdk, 'work-item/simulate-release'), to: endpoint(api, 'work-item/tests-first'), sourceRef: noteRef }] }); + const precursor = graph.nodes.find((node) => node.projectId === 'project' && node.workItemId === 'simulate-release' && node.pairRole === 'reviewer')!; + const dependent = graph.nodes.find((node) => node.projectId === 'api' && node.workItemId === 'tests-first' && node.pairRole === 'actor')!; + expect(graph.edges).toContainEqual(expect.objectContaining({ fromNodeId: precursor.id, toNodeId: dependent.id, + provenance: 'treedx-link', sourceRef: noteRef })); + expect(() => projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [api], + profiles: { 'api:tester': agent('tester'), 'api:reviewer': agent('reviewer') }, + dependencyLinks: [{ from: endpoint(sdk, 'work-item/simulate-release'), to: endpoint(api, 'work-item/tests-first'), sourceRef: noteRef }] })) + .toThrow('absent from the selected graph'); + }); + it('is replay deterministic and contains no provider selection or output taxonomy', () => { const first = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [source()], profiles, createdAt: '2026-09-13T12:00:00.000Z' }); const second = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [source()], profiles, createdAt: '2026-09-13T13:00:00.000Z' }); diff --git a/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts b/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts new file mode 100644 index 00000000..47f9740f --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; +import { PGlite } from '@electric-sql/pglite'; +import { expect, it } from 'vitest'; + +const migration = readFileSync('drizzle/control-plane/0040_remove_retired_allocation_authority.sql', 'utf8'); + +async function database() { + const db = new PGlite(); + await db.exec(`CREATE TABLE capacity_workday_runs(id text PRIMARY KEY, status text NOT NULL); + CREATE TABLE capacity_allocation_sets(id text PRIMARY KEY); + CREATE TABLE decision_assignment_graphs(id text PRIMARY KEY); + CREATE TABLE capacity_provider_assignments(id text PRIMARY KEY, status text NOT NULL DEFAULT 'completed', allocation_set_id text REFERENCES capacity_allocation_sets(id)); + CREATE TABLE capacity_reservations(id text PRIMARY KEY, allocation_set_id text REFERENCES capacity_allocation_sets(id), + allocation_version integer, allocation_slice_ids_json text NOT NULL DEFAULT '[]', + CONSTRAINT chk_capacity_reservations_allocation_version CHECK (allocation_version IS NULL OR allocation_version >= 1));`); + return db; +} + +it('removes only retired allocation and decision authorities while retaining reservations', async () => { + const db = await database(); + try { + await db.exec(`INSERT INTO capacity_allocation_sets VALUES ('old'); + INSERT INTO decision_assignment_graphs VALUES ('old'); + INSERT INTO capacity_provider_assignments(id,allocation_set_id) VALUES ('assignment','old'); + INSERT INTO capacity_reservations VALUES ('reservation','old',1,'["slice"]');`); + await db.exec(migration); + expect((await db.query('SELECT id FROM capacity_reservations')).rows).toEqual([{ id: 'reservation' }]); + expect((await db.query('SELECT id FROM capacity_provider_assignments')).rows).toEqual([{ id: 'assignment' }]); + for (const table of ['capacity_allocation_sets', 'decision_assignment_graphs']) { + expect((await db.query('SELECT to_regclass($1) AS name', [table])).rows).toEqual([{ name: null }]); + } + } finally { await db.close(); } +}, 15_000); + +it('refuses to remove an allocation authority while workdays are running', async () => { + const db = await database(); + try { + await db.exec("INSERT INTO capacity_workday_runs VALUES ('workday','running')"); + await expect(db.exec(migration)).rejects.toThrow('drain active workdays'); + expect((await db.query("SELECT to_regclass('capacity_allocation_sets') AS name")).rows) + .toEqual([{ name: 'capacity_allocation_sets' }]); + } finally { await db.close(); } +}, 15_000); + +it('refuses to remove an allocation authority while assignments are active', async () => { + const db = await database(); + try { + await db.exec("INSERT INTO capacity_provider_assignments(id,status) VALUES ('assignment','leased')"); + await expect(db.exec(migration)).rejects.toThrow('drain active assignments'); + } finally { await db.close(); } +}, 15_000); diff --git a/tests/unit/control-plane/capacity/execution/graph/migrations/retired-demand-removal.test.ts b/tests/unit/control-plane/capacity/execution/graph/migrations/retired-demand-removal.test.ts new file mode 100644 index 00000000..e1c56604 --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/graph/migrations/retired-demand-removal.test.ts @@ -0,0 +1,126 @@ +import { PGlite } from '@electric-sql/pglite'; +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; + +it('removes retired scheduling tables while preserving the living graph and assignments', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE agent_capacity_plans(id text PRIMARY KEY); + CREATE TABLE capacity_workday_demands(id text PRIMARY KEY, capacity_plan_id text REFERENCES agent_capacity_plans(id)); + CREATE TABLE capacity_workday_participation_cycles(id text PRIMARY KEY); + CREATE TABLE capacity_workday_participation_entries(id text PRIMARY KEY, + demand_id text REFERENCES capacity_workday_demands(id), + cycle_id text REFERENCES capacity_workday_participation_cycles(id)); + CREATE TABLE capacity_provider_assignments(id text PRIMARY KEY); + CREATE TABLE execution_nodes(id text PRIMARY KEY);`); + await db.exec(readFileSync('drizzle/control-plane/0035_remove_retired_workday_demand_authority.sql', 'utf8')); + const tables = (await db.query("SELECT table_name FROM information_schema.tables WHERE table_schema='public'")).rows; + expect(tables).toEqual(expect.arrayContaining([ + { table_name: 'capacity_provider_assignments' }, { table_name: 'execution_nodes' }, + ])); + for (const name of ['agent_capacity_plans', 'capacity_workday_demands', + 'capacity_workday_participation_cycles', 'capacity_workday_participation_entries']) { + expect(tables).not.toContainEqual({ table_name: name }); + } + } finally { await db.close(); } +}, 15_000); + +it('removes the duplicated decision input without deleting immutable attempts', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE capacity_provider_assignments(id text PRIMARY KEY, + decision_input_json text NOT NULL DEFAULT '{}', assignment_attempt_json jsonb); + INSERT INTO capacity_provider_assignments(id,assignment_attempt_json) + VALUES ('assignment','{"nodeId":"node"}'::jsonb);`); + await db.exec(readFileSync('drizzle/control-plane/0036_remove_retired_decision_execution_input.sql', 'utf8')); + const rows = (await db.query("SELECT assignment_attempt_json FROM capacity_provider_assignments WHERE id='assignment'")).rows; + expect(rows).toEqual([{ assignment_attempt_json: { nodeId: 'node' } }]); + const columns = (await db.query("SELECT column_name FROM information_schema.columns WHERE table_name='capacity_provider_assignments'")).rows; + expect(columns).not.toContainEqual({ column_name: 'decision_input_json' }); + } finally { await db.close(); } +}, 15_000); + +it('drops duplicate project agent policy without discarding exact library custody', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE project_agent_classes(id text PRIMARY KEY, allowed_modes_json jsonb, + required_capabilities_json jsonb, kernel_profile_json jsonb, kernel_policy_json jsonb, + output_contracts_json jsonb, handler_refs_json jsonb, metadata_json jsonb); + INSERT INTO project_agent_classes(id,handler_refs_json,metadata_json) + VALUES ('class','{"agents":[]}'::jsonb,'{"immutableRef":"abc"}'::jsonb);`); + await db.exec(readFileSync('drizzle/control-plane/0037_remove_retired_project_agent_policy.sql', 'utf8')); + const rows = (await db.query("SELECT handler_refs_json,metadata_json FROM project_agent_classes WHERE id='class'")).rows; + expect(rows).toEqual([{ handler_refs_json: { agents: [] }, metadata_json: { immutableRef: 'abc' } }]); + const columns = (await db.query("SELECT column_name FROM information_schema.columns WHERE table_name='project_agent_classes'")).rows; + for (const column of ['allowed_modes_json', 'required_capabilities_json', 'kernel_profile_json', 'kernel_policy_json', 'output_contracts_json']) + expect(columns).not.toContainEqual({ column_name: column }); + } finally { await db.close(); } +}, 15_000); + +it('replaces mode-run links with exact assignment provenance before dropping the duplicate table', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE capacity_provider_assignments(id text PRIMARY KEY); + CREATE TABLE agent_mode_runs(id text PRIMARY KEY, provider_assignment_id text NOT NULL REFERENCES capacity_provider_assignments(id)); + CREATE TABLE capacity_ledger_entries(id text PRIMARY KEY, assignment_id text, mode_run_id text REFERENCES agent_mode_runs(id)); + CREATE TABLE capacity_usage_actuals(id text PRIMARY KEY, assignment_id text, mode_run_id text REFERENCES agent_mode_runs(id)); + CREATE TABLE capacity_workday_events(id text PRIMARY KEY, assignment_id text, mode_run_id text); + INSERT INTO capacity_provider_assignments VALUES ('assignment'); + INSERT INTO agent_mode_runs VALUES ('retired-run','assignment'); + INSERT INTO capacity_ledger_entries VALUES ('ledger',NULL,'retired-run'); + INSERT INTO capacity_usage_actuals VALUES ('usage',NULL,'retired-run'); + INSERT INTO capacity_workday_events VALUES ('event',NULL,'retired-run');`); + await db.exec(readFileSync('drizzle/control-plane/0038_remove_retired_mode_runs.sql', 'utf8')); + for (const [table, id] of [['capacity_ledger_entries', 'ledger'], ['capacity_usage_actuals', 'usage'], ['capacity_workday_events', 'event']]) { + expect((await db.query(`SELECT assignment_id FROM ${table} WHERE id=$1`, [id])).rows) + .toEqual([{ assignment_id: 'assignment' }]); + } + expect((await db.query("SELECT to_regclass('public.agent_mode_runs') AS table_name")).rows) + .toEqual([{ table_name: null }]); + } finally { await db.close(); } +}, 15_000); + +it('refuses to erase conflicting mode-run assignment provenance', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE agent_mode_runs(id text PRIMARY KEY, provider_assignment_id text NOT NULL); + CREATE TABLE capacity_ledger_entries(id text PRIMARY KEY, assignment_id text, mode_run_id text); + CREATE TABLE capacity_usage_actuals(id text PRIMARY KEY, assignment_id text, mode_run_id text); + CREATE TABLE capacity_workday_events(id text PRIMARY KEY, assignment_id text, mode_run_id text); + INSERT INTO agent_mode_runs VALUES ('retired-run','correct-assignment'); + INSERT INTO capacity_workday_events VALUES ('event','different-assignment','retired-run');`); + await expect(db.exec(readFileSync('drizzle/control-plane/0038_remove_retired_mode_runs.sql', 'utf8'))) + .rejects.toThrow(/provenance conflicts/u); + expect((await db.query("SELECT to_regclass('public.agent_mode_runs') AS table_name")).rows[0]?.table_name) + .not.toBeNull(); + } finally { await db.close(); } +}, 15_000); + +it('removes drained workday envelopes without removing workday or assignment custody', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE capacity_workday_runs(id text PRIMARY KEY); + CREATE TABLE capacity_provider_assignments(id text PRIMARY KEY); + CREATE TABLE workday_capacity_envelopes(id text PRIMARY KEY,status text NOT NULL); + INSERT INTO capacity_workday_runs VALUES ('run'); + INSERT INTO capacity_provider_assignments VALUES ('assignment'); + INSERT INTO workday_capacity_envelopes VALUES ('old','completed');`); + await db.exec(readFileSync('drizzle/control-plane/0039_remove_workday_capacity_envelopes.sql', 'utf8')); + expect((await db.query("SELECT to_regclass('public.workday_capacity_envelopes') AS table_name")).rows) + .toEqual([{ table_name: null }]); + expect((await db.query('SELECT id FROM capacity_workday_runs')).rows).toEqual([{ id: 'run' }]); + expect((await db.query('SELECT id FROM capacity_provider_assignments')).rows).toEqual([{ id: 'assignment' }]); + } finally { await db.close(); } +}, 15_000); + +it('refuses to drop a still-admissible workday envelope', async () => { + const db = new PGlite(); + try { + await db.exec(`CREATE TABLE workday_capacity_envelopes(id text PRIMARY KEY,status text NOT NULL); + INSERT INTO workday_capacity_envelopes VALUES ('live','active');`); + await expect(db.exec(readFileSync('drizzle/control-plane/0039_remove_workday_capacity_envelopes.sql', 'utf8'))) + .rejects.toThrow(/must be drained/u); + expect((await db.query("SELECT to_regclass('public.workday_capacity_envelopes') AS table_name")).rows[0]?.table_name) + .not.toBeNull(); + } finally { await db.close(); } +}, 15_000); diff --git a/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts b/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts new file mode 100644 index 00000000..4bf40eb4 --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { resolve } = vi.hoisted(() => ({ resolve: vi.fn() })); +vi.mock('../../../../../../../src/api/knowledge/gateway-treedx-connection.ts', () => ({ + resolveKnowledgeGatewayConnection: resolve, +})); +import { loadTeamExactDependencyLinks } from '../../../../../../../src/api/capacity/services/capacity/execution/exact-dependency-links.ts'; + +const proposal = (id: string) => ({ teamId: 'team', projectId: id, repository: `${id}-library`, + path: `proposals/${id}.md`, commit: 'a'.repeat(40), digest: `sha256:${'b'.repeat(64)}`, + proposalRevision: 1, frontmatter: { id }, decision: null }); +const sdk = proposal('sdk'), api = proposal('api'); +const endpoint = (source: typeof sdk, anchor: string) => ({ store: 'treedx', model: 'proposal', + id: source.frontmatter.id, revision: 1, repository: source.repository, commit: source.commit, + path: source.path, digest: source.digest, anchor }); +const link = { relation: 'depends_on', from: endpoint(sdk, 'work-item/simulate-release'), + to: endpoint(api, 'work-item/tests-first') }; +const graph = { resolvedRef: 'c'.repeat(40), nodes: [ + { node: { id: 'ref-from', nodeType: 'Reference', entityType: 'ExactEntityReference', ownerFileId: 'note-file', data: link.from } }, + { node: { id: 'ref-to', nodeType: 'Reference', entityType: 'ExactEntityReference', ownerFileId: 'note-file', data: link.to } }, +], edges: [{ type: 'DEPENDS_ON', sourceId: 'ref-from', targetId: 'ref-to', ownerFileId: 'note-file', + data: { link, ownerPath: 'notes/dependency.md' } }] }; +const note = { schemaVersion: 'treeseed.note/v1', id: 'dependency', projectId: 'sdk', + classification: 'general', subjectRefs: [link.from, link.to], body: 'API follows the SDK candidate.', + createdAt: '2026-09-20T00:00:00Z', links: [link] }; + +beforeEach(() => { resolve.mockReset(); }); + +describe('exact TreeDX dependency intake', () => { + it('requires a scoped grant, exact graph commit, and matching note bytes', async () => { + const client = { queryGraph: vi.fn(async () => graph), + readRepositoryFile: vi.fn(async () => ({ resolvedRef: 'c'.repeat(40), + file: { content: 'content', frontmatter: note } })) }; + resolve.mockImplementation(async (_store, input) => ({ repositoryId: `${input.projectId}-library`, + publicationRef: 'refs/heads/staging', allowedPaths: ['proposals/**', 'notes/**'], client: input.projectId === 'sdk' ? client : { + queryGraph: async () => ({ resolvedRef: 'd'.repeat(40), nodes: [], edges: [] }) } })); + const result = await loadTeamExactDependencyLinks({}, [sdk, api] as never); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ from: link.from, to: link.to, + sourceRef: { model: 'note', repository: 'sdk-library', commit: 'c'.repeat(40), path: 'notes/dependency.md' } }); + expect(client.readRepositoryFile).toHaveBeenCalledWith(expect.objectContaining({ ref: 'c'.repeat(40) })); + expect(resolve).toHaveBeenCalledWith({}, expect.objectContaining({ projectId: 'sdk', readRefs: ['c'.repeat(40)] })); + expect(resolve).toHaveBeenCalledWith({}, expect.objectContaining({ projectId: 'api', readRefs: ['a'.repeat(40)] })); + expect(client.queryGraph).toHaveBeenCalledWith(expect.objectContaining({ relations: ['depends_on'], + seeds: [{ kind: 'type', value: 'ExactEntityReference' }] })); + }); + + it('rejects a moved note snapshot', async () => { + resolve.mockResolvedValue({ repositoryId: 'sdk-library', publicationRef: 'refs/heads/staging', + client: { queryGraph: async () => graph, + readRepositoryFile: async () => ({ resolvedRef: 'd'.repeat(40), file: { content: 'content', frontmatter: note } }) } }); + await expect(loadTeamExactDependencyLinks({}, [sdk, api] as never)).rejects.toThrow('moved during read'); + }); + + it('fails closed when the pinned commit is not in the read grant', async () => { + resolve.mockResolvedValueOnce({ repositoryId: 'sdk-library', publicationRef: 'refs/heads/staging', + client: { queryGraph: async () => graph } }).mockResolvedValueOnce(null); + await expect(loadTeamExactDependencyLinks({}, [sdk, api] as never)).rejects.toThrow('exact dependency read grant is missing'); + }); + + it('rejects a cross-project relation without a path-scoped endpoint grant', async () => { + resolve.mockImplementation(async (_store, input) => ({ repositoryId: `${input.projectId}-library`, + publicationRef: 'refs/heads/staging', allowedPaths: input.projectId === 'api' ? ['knowledge/**'] : ['proposals/**', 'notes/**'], + client: { queryGraph: async () => graph, + readRepositoryFile: async () => ({ resolvedRef: 'c'.repeat(40), file: { content: 'content', frontmatter: note } }) } })); + await expect(loadTeamExactDependencyLinks({}, [sdk, api] as never)).rejects.toThrow('endpoint read grant is missing for project api'); + }); +}); diff --git a/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts b/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts index 9788c2b5..c4f7f944 100644 --- a/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts @@ -44,12 +44,14 @@ describe('workday living-graph projection', () => { }); const parameters = { agentProfilesByProjectId: { sdk: { agents } } }; expect(workdayParticipants(parameters).map(participant => participant.activity)).toEqual(['planning', 'planning', 'planning']); + expect(workdayParticipants({ ...parameters, agentSelection: { activityTypes: ['planning', 'estimating'] } }) + .map(participant => participant.activity)).toEqual(['planning', 'planning', 'planning']); const participants = workdayParticipants({ ...parameters, proposalsByProjectId: { sdk: { executionPlan: { workItems: [{ id: 'implementation', agentClass: 'engineer', review: 'required' }] } } } }); expect(participants.filter(participant => participant.activity === 'estimating').map(participant => participant.definition.agentClass)) .toEqual(['engineer', 'reviewer']); }); - it('projects six work-owner estimates and one Reviewer covering all six paired reviews', () => { + it('projects six work-owner contributions, including multiple items for Engineer, and one Reviewer', () => { const owners = ['researcher', 'architect', 'tester', 'engineer', 'technical-writer', 'releaser']; const classes = [...owners, 'reviewer']; const profiles = Object.fromEntries(classes.map((agentClass) => { @@ -64,7 +66,8 @@ describe('workday living-graph projection', () => { agentIds: classes.map((agentClass) => `sdk/sdk/${agentClass}:estimating`), startsAt: '2026-09-14T12:00:00.000Z' }); const projectionInput: Parameters[0] = { teamId: 'team', revision: 1, profiles, sources: [{ id: 'seven-estimates', teamId: 'team', proposalsByProjectId: { sdk: { executionPlan: { - workItems: owners.map((agentClass) => ({ id: `${agentClass}-work`, agentClass, review: 'required', acceptanceCriteria: ['Meet the exact work-item boundary.'] })), + workItems: [...owners.map((agentClass) => ({ id: `${agentClass}-work`, agentClass, review: 'required', acceptanceCriteria: ['Meet the exact work-item boundary.'] })), + { id: 'engineer-integration', agentClass: 'engineer', review: 'required', acceptanceCriteria: ['Integrate the implementation.'] }], } } }, parameters: { appliedPlan, scheduledProjectIds: ['sdk'], planningSourceByProjectId: { sdk: { store: 'treedx', model: 'proposal', id: 'proposal', revision: 1, repository: 'sdk-library', commit: 'b'.repeat(40), path: 'proposals/golden.mdx' } }, @@ -73,9 +76,12 @@ describe('workday living-graph projection', () => { const graph = projectActiveWorkdays(projectionInput); expect(graph.nodes).toHaveLength(7); expect(graph.edges).toHaveLength(0); - expect(graph.nodes.filter((node) => node.workItemId).map((node) => node.workItemId).sort()) - .toEqual(owners.map((agentClass) => `${agentClass}-work`).sort()); - expect(graph.nodes.find((node) => node.agentClass === 'reviewer')?.acceptanceCriteria).toHaveLength(6); + expect(graph.nodes.filter((node) => node.kind === 'estimating').every((node) => !node.workItemId)).toBe(true); + for (const agentClass of owners) expect(graph.nodes.find((node) => node.agentClass === agentClass)?.acceptanceCriteria) + .toContain(`Estimate work item ${agentClass}-work: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.`); + expect(graph.nodes.find((node) => node.agentClass === 'engineer')?.acceptanceCriteria) + .toContain('Estimate work item engineer-integration: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.'); + expect(graph.nodes.find((node) => node.agentClass === 'reviewer')?.acceptanceCriteria).toHaveLength(7); expect(validateExecutionGraph(graph.nodes, graph.edges)).toMatchObject({ ok: true }); const accepted = projectActiveWorkdays({ ...projectionInput, sources: projectionInput.sources.map((source) => ({ ...source, @@ -133,7 +139,8 @@ describe('workday living-graph projection', () => { planningSourceByProjectId: { sdk: proposalRef }, agentProfilesByProjectId: { sdk: { revision: 'test', agents: [{ definition: architect, activities: ['estimating'] }] } } } }] }); expect(graph.nodes.filter((node) => node.kind === 'estimating')).toHaveLength(1); - expect(graph.nodes.find((node) => node.kind === 'estimating')?.workItemId).toBe('architecture-contract'); + expect(graph.nodes.find((node) => node.kind === 'estimating')?.acceptanceCriteria) + .toContain('Estimate work item architecture-contract: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.'); expect(graph.edges).toHaveLength(0); expect(graph.nodes.filter((node) => node.kind === 'estimating').every((node) => node.requiredCapabilities?.[0] === 'treeseed.coordination.estimation')).toBe(true); diff --git a/tests/unit/control-plane/capacity/execution/living-execution-admission.test.ts b/tests/unit/control-plane/capacity/execution/living-execution-admission.test.ts index bb26cb48..b71b782c 100644 --- a/tests/unit/control-plane/capacity/execution/living-execution-admission.test.ts +++ b/tests/unit/control-plane/capacity/execution/living-execution-admission.test.ts @@ -14,10 +14,12 @@ describe('living execution admission', () => { const store = { getProviderAssignment: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(committed), batch: vi.fn(async () => []) }; await expect(admitLivingExecutionAssignment(store as never, { principal: { teamId: 'team', capacityProviderId: 'provider', membershipId: 'membership' } as never, accountingLimits, assignment: assignment as never, allocation, projectAgentClassId: 'class', providerSessionId: 'session', executionProviderId: 'runtime', laneId: 'lane', - lanePurpose: 'workday', executionKind: 'workday', predecessorResults: [], treedxProxyHandle: { id: 'tdx_assignment', status: 'issued', + lanePurpose: 'workday', executionKind: 'workday', workdayConcurrencyLimit: 1, predecessorResults: [], treedxProxyHandle: { id: 'tdx_assignment', status: 'issued', allowedPaths: [], allowedReadPaths: [], allowedWritePaths: [], scopes: [], allowedOperations: [] }, now: assignment.createdAt })).resolves.toBe(committed); const sql = store.batch.mock.calls[0]![0].map((operation: { query: string }) => operation.query).join('\n'); expect(sql).toMatch(/FOR UPDATE/u); + expect(sql).toMatch(/capacity_workday_runs WHERE team_id=\? AND id=\? AND status='running' FOR UPDATE/u); + expect(sql).toMatch(/active\.execution_kind=\?[\s\S]*active\.status IN \('pending','leased','running'\)\) < \?/u); expect(sql).toMatch(/prior\.status<>'returned'/u); expect(sql).toMatch(/capacity_reservations/u); expect(sql).toMatch(/committed_amount\+\?<=LEAST\(hard_limit,\?\)/u); @@ -34,9 +36,10 @@ describe('living execution admission', () => { expect(assignmentInsert.params[14]).toBeNull(); expect(JSON.parse(String(assignmentInsert.params[17]))).toMatchObject({ teamId: 'team', projectId: 'project', mode: 'acting', budget: { time: { executionSeconds: 3, requestedSeconds: 3 } } }); - expect(JSON.parse(String(assignmentInsert.params[18]))).toMatchObject({ teamId: 'team', projectId: 'project', mode: 'acting', - metadata: { source: 'living_execution_graph', nodeId: 'node' } }); - expect(() => JSON.parse(String(assignmentInsert.params[24]))).not.toThrow(); + expect(JSON.parse(String(assignmentInsert.params[18]))).toMatchObject({ assignmentAttempt: { + teamId: 'team', projectId: 'project', nodeId: 'node', effectiveProfile: { activity: 'acting' } }, + predecessorResults: [] }); + expect(assignmentInsert.query).not.toContain('decision_input_json'); expect(store.batch.mock.calls[0]![0].some((operation: { params: unknown[] }) => operation.params.includes('workday'))).toBe(true); expect(store.batch.mock.calls[0]![0].some((operation: { params: unknown[] }) => operation.params.includes('operation'))).toBe(false); expect(sql).not.toMatch(/'workday',\?,\?,\?,\?,'pending'/u); @@ -48,20 +51,33 @@ describe('living execution admission', () => { await expect(admitLivingExecutionAssignment(store as never, { principal: {} as never, accountingLimits, assignment: assignment as never, allocation: { ...allocation, allocatedSeconds: 2 }, projectAgentClassId: 'class', providerSessionId: 'session', executionProviderId: 'runtime', laneId: 'lane', - lanePurpose: 'workday', executionKind: 'workday', predecessorResults: [], treedxProxyHandle: {}, now: assignment.createdAt })) + lanePurpose: 'workday', executionKind: 'workday', workdayConcurrencyLimit: 1, predecessorResults: [], treedxProxyHandle: {}, now: assignment.createdAt })) .rejects.toMatchObject({ code: 'assignment_allocation_mismatch' }); expect(store.batch).not.toHaveBeenCalled(); }); + it('explains a workday lane race rather than misreporting a lost graph node', async () => { + const store = { getProviderAssignment: vi.fn().mockResolvedValue(null), batch: vi.fn(async () => []), + first: vi.fn(async () => ({ active_count: 1 })) }; + await expect(admitLivingExecutionAssignment(store as never, { principal: { teamId: 'team', capacityProviderId: 'provider', membershipId: 'membership' } as never, + accountingLimits, assignment: assignment as never, allocation, projectAgentClassId: 'class', providerSessionId: 'session', executionProviderId: 'runtime', laneId: 'lane', + lanePurpose: 'workday', executionKind: 'workday', workdayConcurrencyLimit: 1, predecessorResults: [], + treedxProxyHandle: { id: 'tdx_assignment' }, now: assignment.createdAt })) + .rejects.toMatchObject({ code: 'capacity_assignment_allocation_deferred', details: { + reason: 'workday_concurrency_exhausted', executionKind: 'workday', limit: 1 } }); + expect(store.first.mock.calls[0]?.[1]).toEqual(['team', 'workday', 'workday']); + }); + it('refuses to re-admit a completed execution-node revision while permitting an explicit returned retry', async () => { const committed = { id: assignment.id, executionNodeId: 'node', executionNodeRevision: 1 }; const store = { getProviderAssignment: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(committed), batch: vi.fn(async () => []) }; await admitLivingExecutionAssignment(store as never, { principal: { teamId: 'team', capacityProviderId: 'provider', membershipId: 'membership' } as never, accountingLimits, assignment: assignment as never, allocation, projectAgentClassId: 'class', providerSessionId: 'session', executionProviderId: 'runtime', laneId: 'lane', - lanePurpose: 'workday', executionKind: 'workday', predecessorResults: [], treedxProxyHandle: { id: 'tdx_assignment', status: 'issued', + lanePurpose: 'workday', executionKind: 'workday', workdayConcurrencyLimit: 1, predecessorResults: [], treedxProxyHandle: { id: 'tdx_assignment', status: 'issued', allowedPaths: [], allowedReadPaths: [], allowedWritePaths: [], scopes: [], allowedOperations: [] }, now: assignment.createdAt }); const operations = store.batch.mock.calls[0]![0] as Array<{ query: string; params: unknown[] }>; const reservation = operations.find((operation) => operation.query.includes('INSERT INTO capacity_reservations'))!; + expect(reservation.params.slice(-4)).toEqual(['team', 'workday', 'workday', 1]); const insertedAssignment = operations.find((operation) => operation.query.includes('INSERT INTO capacity_provider_assignments'))!; expect(reservation.query).toMatch(/prior\.execution_node_revision=node\.node_revision[\s\S]*prior\.status<>'returned'/u); expect(insertedAssignment.query).toMatch(/prior\.execution_node_revision=\?[\s\S]*prior\.status<>'returned'/u); @@ -75,9 +91,11 @@ describe('living execution admission', () => { const store = { getProviderAssignment: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(committed), batch: vi.fn(async () => []) }; await admitLivingExecutionAssignment(store as never, { principal: { teamId: 'team', capacityProviderId: 'provider', membershipId: 'membership' } as never, accountingLimits, assignment: assignment as never, allocation, projectAgentClassId: 'class', providerSessionId: 'session', executionProviderId: 'runtime', laneId: 'communication', - lanePurpose: 'communication', executionKind: 'conversation', invocationId: 'invocation-1', predecessorResults: [], treedxProxyHandle: { id: 'tdx_assignment', status: 'issued', + lanePurpose: 'communication', executionKind: 'conversation', workdayConcurrencyLimit: 2, invocationId: 'invocation-1', predecessorResults: [], treedxProxyHandle: { id: 'tdx_assignment', status: 'issued', allowedPaths: [], allowedReadPaths: [], allowedWritePaths: [], scopes: [], allowedOperations: [] }, now: assignment.createdAt }); const binding = store.batch.mock.calls[0]![0].find((operation: { query: string }) => operation.query.includes('UPDATE agent_invocation_requests'))!; expect(binding.params).toEqual(['assignment', assignment.createdAt, 'invocation-1', 'team', 'assignment']); + const reservation = store.batch.mock.calls[0]![0].find((operation: { query: string }) => operation.query.includes('INSERT INTO capacity_reservations'))!; + expect(reservation.params.slice(-4)).toEqual(['team', 'workday', 'conversation', 2]); }); }); diff --git a/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts b/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts index 5d3107dd..7e4ca747 100644 --- a/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts +++ b/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; import { advanceLivingWorkday } from '../../../../../src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts'; +import { validateAgentDefinitionModel } from '@treeseed/sdk/agent-capacity'; + +vi.mock('../../../../../src/api/governance/executable-proposal.ts', () => ({ + readExactProposal: vi.fn(async (_store: unknown, proposal: { id?: string }) => { + if (proposal.id === 'incomplete') throw Object.assign(new Error('Proposal has no executable plan.'), + { code: 'proposal_execution_plan_invalid' }); + return { ref: { store: 'treedx', model: 'proposal', id: 'new-proposal', revision: 1, + digest: `sha256:${'a'.repeat(64)}`, repository: 'project-library', commit: 'b'.repeat(40), path: 'proposals/new.mdx' }, + definition: { executionPlan: { workItems: [{ id: 'research', agentClass: 'researcher', review: 'required' }] } } }; + }), +})); const now = '2026-09-13T16:00:00.000Z'; const policy = { durationSeconds: 60, maximumConcurrency: 2, planningTurnMaximumSeconds: 10, @@ -33,6 +44,35 @@ describe('living workday lifecycle', () => { expect(result.plan.planningRounds.at(-1)).toMatchObject({ round: 3, state: 'active', assignmentIds: ['planning:workday:3:project/architect'] }); }); + it('adds a newly created proposal owner and Reviewer to the next round without replacing the frozen planning agents', async () => { + const currentPlan = { ...plan, endsAt: '2026-09-13T16:00:00Z', + policySnapshot: { ...policy, durationSeconds: 3600, planningPercent: 20 } }; + const permissions = { content: { read: ['proposal'], write: ['proposal'] }, tools: ['discussion'] }; + const agent = (agentClass: string) => ({ schemaVersion: 'treeseed.agent/v1', id: `project/${agentClass}`, + name: agentClass, agentClass, purpose: 'Estimate governed work.', responsibilities: ['Return exact results.'], + capabilities: ['reasoning'], context: { include: ['project-objectives'] }, activityProfiles: { + planning: { handler: 'writer', permissions, prompt: { system: 'Plan useful governed work for this project.' } }, + estimating: { handler: 'estimate', permissions, prompt: { system: 'Estimate exact proposal work for this project.' } }, + } }); + expect(validateAgentDefinitionModel(agent('researcher')).ok).toBe(true); + const currentRun = { ...run, parameters: { appliedPlan: currentPlan, scheduledProjectIds: ['project'], + agentProfilesByProjectId: { project: { agents: ['architect', 'researcher', 'reviewer'].map((agentClass) => + ({ definition: agent(agentClass), activities: ['planning', 'estimating'] })) } } } } as never; + const store = { all: vi.fn(async (sql: string) => sql.includes('execution_nodes') + ? currentPlan.planningRounds.map((round) => ({ id: round.assignmentIds[0], kind: 'planning', status: 'completed' })) + : [{ id: 'incomplete', team_id: 'team', project_id: 'project' }, + { id: 'new-proposal', team_id: 'team', project_id: 'project' }]), + updateCapacityWorkdayRun: vi.fn(async () => currentRun) }; + const result = await advanceLivingWorkday(store as never, currentRun, '2026-09-13T15:02:00Z'); + expect(result.plan.planningRounds.at(-1)?.assignmentIds).toEqual([ + 'planning:workday:3:project/architect', + 'planning:workday:3:project/project/researcher:estimating', + 'planning:workday:3:project/project/reviewer:estimating', + ]); + expect(store.updateCapacityWorkdayRun).toHaveBeenCalledWith('team', 'workday', expect.objectContaining({ + parameters: expect.objectContaining({ planningSourceByProjectId: { project: expect.objectContaining({ id: 'new-proposal' }) } }), + })); + }); it('completes planning rounds and enters closing without consulting demand or envelope storage', async () => { const updateCapacityWorkdayRun = vi.fn(async () => run); const store = { all: vi.fn(async (sql: string) => sql.includes('execution_nodes') ? [ diff --git a/tests/unit/control-plane/capacity/execution/ready-execution-node.test.ts b/tests/unit/control-plane/capacity/execution/ready-execution-node.test.ts index 32bd1357..90ee22ef 100644 --- a/tests/unit/control-plane/capacity/execution/ready-execution-node.test.ts +++ b/tests/unit/control-plane/capacity/execution/ready-execution-node.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const gateway = vi.hoisted(() => ({ readRepositoryFile: vi.fn() })); vi.mock('../../../../../src/api/knowledge/gateway-treedx-connection.ts', () => ({ @@ -44,6 +44,12 @@ const definition = { }, }, }; +const agentCommit = 'a'.repeat(40); +const agentPath = 'agents/engineer.yaml'; +const classRow = (agent: unknown, id = 'class-engineer') => ({ + id, handler_refs_json: { agents: [agent] }, + metadata_json: { source: 'project-library', immutableRef: agentCommit, definitionPaths: [agentPath] }, +}); const result = { schemaVersion: 'treeseed.assignment-result/v1', id: 'result-one', assignmentId: 'assignment-one', @@ -70,6 +76,11 @@ const teamContextStore = { }; describe('direct ready-node admission input', () => { + beforeEach(() => { + gateway.readRepositoryFile.mockResolvedValue({ + resolvedRef: agentCommit, file: { path: agentPath, frontmatter: definition }, + }); + }); it('keeps a suspended conversation node occupied until its response is settled', async () => { const store = { ...teamContextStore, all: vi.fn().mockResolvedValueOnce([]) }; await listReadyExecutionNodes(store, run as never, project as never, async () => []); @@ -141,7 +152,7 @@ describe('direct ready-node admission input', () => { it('loads the exact profile and predecessor results without creating a demand record', async () => { const store = { ...teamContextStore, all: vi.fn() .mockResolvedValueOnce([nodeRow()]) - .mockResolvedValueOnce([{ id: 'class-engineer', handler_refs_json: { agents: [definition] } }]) + .mockResolvedValueOnce([classRow(definition)]) .mockResolvedValueOnce([{ assignment_result_json: result }]) }; const [candidate] = await listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs); expect(candidate).toMatchObject({ @@ -156,9 +167,44 @@ describe('direct ready-node admission input', () => { expect.objectContaining({ id: 'project:project-objective', path: 'objectives/core', commit: '1'.repeat(40) }), ])); expect(candidate.sourceRepositories).toEqual(['repository-sdk']); + expect(candidate.effectiveProfile.profileRef).toMatchObject({ + store: 'treedx', repository: 'repository', commit: agentCommit, path: agentPath, + }); expect(JSON.stringify(candidate)).not.toMatch(/capacityPlan|demand|sourceCandidate|artifactManifest/u); }); + it('carries an approved Reviewer result and its exact Actor Git candidate to dependent work', async () => { + const reviewResult = { ...result, id: 'review-result', assignmentId: 'review-assignment', + references: [{ kind: 'treedx', projectId, repository: 'repository', + commit: 'f'.repeat(40), path: 'decisions/approval.mdx' }] }; + const store = { ...teamContextStore, all: vi.fn() + .mockResolvedValueOnce([nodeRow()]) + .mockResolvedValueOnce([classRow(definition)]) + .mockResolvedValueOnce([{ assignment_result_json: reviewResult }, { assignment_result_json: result }]) }; + const [candidate] = await listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs); + expect(candidate.predecessorResults).toEqual([reviewResult, result]); + const [sql, bindings] = store.all.mock.calls[2]!; + expect(sql).toContain("pair.provenance='review-pair'"); + expect(sql).toContain("reviewer.status='completed'"); + expect(bindings).toEqual(['team', 'node', 'team', 'node']); + }); + + it('rejects a projected agent definition that differs from its exact TreeDX source', async () => { + gateway.readRepositoryFile.mockResolvedValue({ resolvedRef: agentCommit, + file: { path: agentPath, frontmatter: { ...definition, purpose: 'Changed at source.' } } }); + const store = { ...teamContextStore, all: vi.fn().mockResolvedValueOnce([nodeRow()]) + .mockResolvedValueOnce([classRow(definition)]) }; + await expect(listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs)) + .rejects.toMatchObject({ code: 'execution_node_agent_profile_moved' }); + }); + + it('rejects an unpinned agent definition instead of using cached profile data', async () => { + const store = { ...teamContextStore, all: vi.fn().mockResolvedValueOnce([nodeRow()]) + .mockResolvedValueOnce([{ ...classRow(definition), metadata_json: {} }]) }; + await expect(listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs)) + .rejects.toMatchObject({ code: 'execution_node_agent_profile_unpinned' }); + }); + it.each(['book', 'knowledge'])('loads the exact %s candidate using its producing assignment grant', async (model) => { const target = { store: 'treedx', model, id: 'actor-output', repository: 'repository', commit: 'e'.repeat(40), path: `${model}s/actor-output.mdx` }; @@ -168,11 +214,14 @@ describe('direct ready-node admission input', () => { const reviewer = { ...definition, id: 'agent:reviewer', agentClass: 'reviewer', activityProfiles: { reviewing: { handler: 'reviewer', permissions: reviewPermissions, prompt: { system: 'Review the exact immutable actor output.' } } } }; + gateway.readRepositoryFile.mockResolvedValue({ + resolvedRef: agentCommit, file: { path: agentPath, frontmatter: reviewer }, + }); const node = { ...nodeRow(), kind: 'reviewing', pair_role: 'reviewer', agent_class: 'reviewer', workspace: 'treedx', requested_permissions_json: reviewPermissions }; const createStore = (grants: unknown[]) => ({ ...teamContextStore, all: vi.fn() .mockResolvedValueOnce([node]) - .mockResolvedValueOnce([{ id: 'class-reviewer', handler_refs_json: { agents: [reviewer] } }]) + .mockResolvedValueOnce([classRow(reviewer, 'class-reviewer')]) .mockResolvedValueOnce([{ assignment_result_json: actorResult, assignment_attempt_json: { grant: { contentWrite: grants } } }]) }); const [candidate] = await listReadyExecutionNodes(createStore([target]), run as never, project as never, async () => contextRefs); expect(candidate.contextRefs).toContainEqual({ ...target, commit: 'd'.repeat(40) }); @@ -185,7 +234,7 @@ describe('direct ready-node admission input', () => { getProjectTreeDxLibrary: vi.fn(async () => ({ repositoryId: 'team-repository', contentRepositoryRef: 'refs/remotes/origin/staging', metadata: { resolvedRef: 'f'.repeat(40) } })), all: vi.fn().mockResolvedValueOnce([nodeRow()]) - .mockResolvedValueOnce([{ id: 'class-engineer', handler_refs_json: { agents: [definition] } }]) + .mockResolvedValueOnce([classRow(definition)]) .mockResolvedValueOnce([]), }; const [candidate] = await listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs); @@ -197,7 +246,7 @@ describe('direct ready-node admission input', () => { it('limits admission to decision IDs frozen into the workday', async () => { const store = { ...teamContextStore, all: vi.fn().mockResolvedValueOnce([nodeRow('selected'), nodeRow('other', 'other-decision')]) - .mockResolvedValueOnce([{ id: 'class-engineer', handler_refs_json: { agents: [definition] } }]) + .mockResolvedValueOnce([classRow(definition)]) .mockResolvedValueOnce([]) }; const candidates = await listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs); expect(candidates.map((candidate) => candidate.node.id)).toEqual(['selected']); @@ -212,7 +261,7 @@ describe('direct ready-node admission input', () => { agent_class: 'engineer', workspace: 'treedx', }; const store = { ...teamContextStore, all: vi.fn().mockResolvedValueOnce([planning]) - .mockResolvedValueOnce([{ id: 'class-engineer', handler_refs_json: { agents: [definition] } }]) + .mockResolvedValueOnce([classRow(definition)]) .mockResolvedValueOnce([]) }; const candidates = await listReadyExecutionNodes(store, run as never, project as never, async () => contextRefs); expect(candidates.map((candidate) => candidate.node.id)).toEqual([planning.id]); @@ -232,7 +281,7 @@ describe('direct ready-node admission input', () => { repository: 'repository', commit: 'f'.repeat(40), path: 'decisions/review.mdx' }] }; const store = { ...teamContextStore, all: vi.fn() .mockResolvedValueOnce([revised]) - .mockResolvedValueOnce([{ id: 'class-engineer', handler_refs_json: { agents: [definition] } }]) + .mockResolvedValueOnce([classRow(definition)]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ assignment_result_json: result }]) .mockResolvedValueOnce([{ assignment_result_json: reviewResult }]) }; diff --git a/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts b/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts index 92a1298e..095e0852 100644 --- a/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts +++ b/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts @@ -3,8 +3,8 @@ import { integrateAssignmentEstimate, mergeAssignmentEstimate } from '../../../. const estimate = { minimumSeconds: 100, expectedSeconds: 200, maximumSeconds: 300, rationale: 'Exact source inspection.' }; const frozen = { id: 'proposal-1', projectId: 'project-1', status: 'draft', executionPlan: { workItems: [ - { id: 'research', objective: 'Inspect the source.' }, - { id: 'implementation', objective: 'Implement the contract.' }, + { id: 'research', agentClass: 'researcher', review: 'required', objective: 'Inspect the source.' }, + { id: 'implementation', agentClass: 'engineer', review: 'required', objective: 'Implement the contract.' }, ] } }; const candidate = { ...frozen, executionPlan: { workItems: [ frozen.executionPlan.workItems[0], { ...frozen.executionPlan.workItems[1], estimate }, @@ -16,29 +16,29 @@ describe('exact estimator result integration', () => { const current = { ...frozen, executionPlan: { workItems: [ { ...frozen.executionPlan.workItems[0], estimate: prior }, frozen.executionPlan.workItems[1], ] } }; - const merged = mergeAssignmentEstimate({ frozen, candidate, current, workItemId: 'implementation' }); + const merged = mergeAssignmentEstimate({ frozen, candidate, current, agentClass: 'engineer' }); expect(merged.executionPlan).toEqual({ workItems: [ { ...frozen.executionPlan.workItems[0], estimate: prior }, { ...frozen.executionPlan.workItems[1], estimate }, ] }); - expect(mergeAssignmentEstimate({ frozen, candidate, current: merged, workItemId: 'implementation' })).toEqual(merged); + expect(mergeAssignmentEstimate({ frozen, candidate, current: merged, agentClass: 'engineer' })).toEqual(merged); }); it('rejects unrelated content and a competing estimate', () => { expect(() => mergeAssignmentEstimate({ frozen, candidate: { ...candidate, title: 'New authority' }, current: frozen, - workItemId: 'implementation' })).toThrow('outside its assigned estimate'); + agentClass: 'engineer' })).toThrow('outside its assigned estimate'); const current = { ...frozen, executionPlan: { workItems: [frozen.executionPlan.workItems[0], { ...frozen.executionPlan.workItems[1], estimate: { ...estimate, expectedSeconds: 250 } }] } }; - expect(() => mergeAssignmentEstimate({ frozen, candidate, current, workItemId: 'implementation' })).toThrow('different estimate'); + expect(() => mergeAssignmentEstimate({ frozen, candidate, current, agentClass: 'engineer' })).toThrow('different estimate'); }); it('rejects missing, mistargeted, or changed work items', () => { expect(() => mergeAssignmentEstimate({ frozen, candidate: frozen, current: frozen, - workItemId: 'implementation' })).toThrow('structured estimate'); + agentClass: 'engineer' })).toThrow('structured estimate'); expect(() => mergeAssignmentEstimate({ frozen, candidate, current: frozen, - workItemId: 'unknown' })).toThrow('frozen proposal work item'); + agentClass: 'unknown' })).toThrow('class-owned frozen proposal work items'); const changed = { ...frozen, executionPlan: { workItems: [frozen.executionPlan.workItems[0], { ...frozen.executionPlan.workItems[1], objective: 'Changed objective.' }] } }; expect(() => mergeAssignmentEstimate({ frozen, candidate, current: changed, - workItemId: 'implementation' })).toThrow('no longer match'); + agentClass: 'engineer' })).toThrow('no longer match'); }); it('merges Reviewer estimates for every work item without replacing actor estimates', () => { const current = { ...frozen, executionPlan: { workItems: [ @@ -47,14 +47,26 @@ describe('exact estimator result integration', () => { const reviewEstimate = { ...estimate, rationale: 'Independent review of the work.' }; const reviewCandidate = { ...frozen, executionPlan: { workItems: frozen.executionPlan.workItems.map((item) => ({ ...item, reviewEstimate })) } }; - const merged = mergeAssignmentEstimate({ frozen, candidate: reviewCandidate, current, workItemId: null }); + const merged = mergeAssignmentEstimate({ frozen, candidate: reviewCandidate, current, agentClass: 'reviewer' }); expect((merged.executionPlan as { workItems: unknown[] }).workItems).toEqual([ { ...current.executionPlan.workItems[0], reviewEstimate }, { ...current.executionPlan.workItems[1], reviewEstimate }, ]); expect(() => mergeAssignmentEstimate({ frozen, candidate: { ...reviewCandidate, executionPlan: { workItems: [reviewCandidate.executionPlan.workItems[0], frozen.executionPlan.workItems[1]] } }, - current, workItemId: null })).toThrow('each assigned work item'); + current, agentClass: 'reviewer' })).toThrow('each assigned work item'); + }); + it('accepts one class contribution for multiple owned work items and rejects a partial contribution', () => { + const extra = { id: 'integration', agentClass: 'engineer', review: 'required', objective: 'Integrate the contract.' }; + const source = { ...frozen, executionPlan: { workItems: [...frozen.executionPlan.workItems, extra] } }; + const two = { ...source, executionPlan: { workItems: [source.executionPlan.workItems[0], + { ...source.executionPlan.workItems[1], estimate }, { ...extra, estimate }] } }; + expect((mergeAssignmentEstimate({ frozen: source, candidate: two, current: source, + agentClass: 'engineer' }).executionPlan as { workItems: Array<{ estimate?: unknown }> }).workItems.filter((item) => item.estimate)).toHaveLength(2); + const partial = { ...source, executionPlan: { workItems: [source.executionPlan.workItems[0], + { ...source.executionPlan.workItems[1], estimate }, extra] } }; + expect(() => mergeAssignmentEstimate({ frozen: source, candidate: partial, current: source, + agentClass: 'engineer' })).toThrow('each assigned work item'); }); it('rejects a racing estimator result before reading or writing a closed proposal', async () => { let reads = 0; diff --git a/tests/unit/control-plane/capacity/planning/proposal-feedback-subject.test.ts b/tests/unit/control-plane/capacity/planning/proposal-feedback-subject.test.ts index d515dafb..822e4e8d 100644 --- a/tests/unit/control-plane/capacity/planning/proposal-feedback-subject.test.ts +++ b/tests/unit/control-plane/capacity/planning/proposal-feedback-subject.test.ts @@ -1,85 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; -import { resolveProposalFeedbackSubject, reviewedProposalVersion } from '../../../../../src/api/capacity/services/capacity/assignments/planning/feedback/subject.ts'; -import { assignmentActivityInput } from '../../../../../src/api/capacity/services/capacity/assignments/planning/assignment-planning-output-service.ts'; +import { describe, expect, it } from 'vitest'; import { governanceProposalReadinessMethod } from '../../../../../src/api/store/governance/policy/contracts/governance-proposal-readiness.ts'; import type { ControlPlaneStore } from '../../../../../src/api/persistence/store.ts'; -import type { DurableProviderAssignment } from '../../../../../src/api/capacity/repositories/capacity/assignments/assignment.ts'; - -const assignment = { id: 'assignment-1', teamId: 'team-1', projectId: 'project-1' }; -function fixture(id = '48d5429f-4882-4e89-824e-1866e70e7adb') { - const proposal = { id, teamId: assignment.teamId, projectId: assignment.projectId, activeVersion: 4 }; - const store = { getGovernanceProposal: vi.fn(async (key: string) => key === id ? proposal : null), - all: vi.fn(async () => [{ id }]) }; - return { proposal, store }; -} - -describe('planning assignment envelope custody', () => { - it('binds living-graph output to the inner immutable activity input', () => { - const durable = { - decisionInput: { input: { decisionInput: { input: { - executionNodeId: 'node-1', - proposalRef: { id: 'proposal-1', revision: 3, digest: 'd'.repeat(64) }, - } } } }, - } as unknown as DurableProviderAssignment; - expect(assignmentActivityInput(durable)).toMatchObject({ - executionNodeId: 'node-1', - proposalRef: { id: 'proposal-1', revision: 3, digest: 'd'.repeat(64) }, - }); - }); - - it('keeps direct planning inputs intact', () => { - const durable = { decisionInput: { input: { planningInputRequestId: 'request-1' } } } as unknown as DurableProviderAssignment; - expect(assignmentActivityInput(durable)).toEqual({ planningInputRequestId: 'request-1' }); - }); -}); - -describe('planning feedback subject custody', () => { - it.each(['48d5429f-4882-4e89-824e-1866e70e7adb', 'proposal:project-1:document-workspace'])('preserves exact existing ID %s', async id => { - const { proposal, store } = fixture(id); - expect(await resolveProposalFeedbackSubject(store, assignment, id)).toBe(proposal); - expect(store.all).not.toHaveBeenCalled(); - }); - it.each(['document-workspace', 'proposal:document-workspace', 'proposals/governance/document-workspace.mdx'])('resolves scoped content subject %s without a synthetic ID', async subject => { - const { proposal, store } = fixture(); - expect(await resolveProposalFeedbackSubject(store, assignment, subject)).toBe(proposal); - expect(store.all).toHaveBeenCalledWith(expect.stringContaining('team_id = ? AND project_id = ?'), ['team-1', 'project-1', 'document-workspace']); - }); - it.each(['teamId', 'projectId'] as const)('denies exact ID from another %s without falling back', async field => { - const { proposal, store } = fixture(); proposal[field] = 'foreign'; - await expect(resolveProposalFeedbackSubject(store, assignment, proposal.id)).rejects.toMatchObject({ code: 'assignment_proposal_feedback_scope_invalid' }); - expect(store.all).not.toHaveBeenCalled(); - }); - it.each([0, 2])('rejects %i matching slugs', async count => { - const { store } = fixture(); store.all.mockResolvedValue(Array.from({ length: count }, (_, i) => ({ id: `proposal-${i}` }))); - await expect(resolveProposalFeedbackSubject(store, assignment, 'document-workspace')).rejects.toMatchObject({ code: 'assignment_proposal_feedback_scope_invalid' }); - }); - it('rechecks custody after slug resolution', async () => { - const { proposal, store } = fixture(); proposal.teamId = 'foreign'; - await expect(resolveProposalFeedbackSubject(store, assignment, 'document-workspace')).rejects.toMatchObject({ code: 'assignment_proposal_feedback_scope_invalid' }); - }); - it('rejects empty subjects without querying', async () => { - const { store } = fixture(); - await expect(resolveProposalFeedbackSubject(store, assignment, ' ')).rejects.toMatchObject({ code: 'assignment_proposal_feedback_scope_invalid' }); - expect(store.getGovernanceProposal).not.toHaveBeenCalled(); expect(store.all).not.toHaveBeenCalled(); - }); -}); - -describe('feedback revision custody', () => { - const path = 'proposals/governance/task.mdx', commitSha = 'a'.repeat(40); - const proposal = { activeVersion: 4, metadata: { contentProvenance: { contentPath: path, commitSha } } }; - const artifact = { model: 'proposal', contentPath: path, commitSha }; - it('records the reviewed version from exact assigned evidence', () => { - expect(reviewedProposalVersion({ ...assignment, decisionInput: { input: { intent: { relatedArtifact: artifact } } } }, proposal)).toBe(4); - }); - it('accepts unchanged proposal bytes after unrelated library commits', () => { - const digest = 'b'.repeat(64); - expect(reviewedProposalVersion({ ...assignment, decisionInput: { input: { intent: { relatedArtifact: { ...artifact, commitSha: 'c'.repeat(40), digest } } } } }, - { ...proposal, metadata: { contentProvenance: { contentPath: path, commitSha, digest } } })).toBe(4); - }); - it.each([undefined, { ...artifact, commitSha: 'b'.repeat(40) }, { ...artifact, contentPath: 'proposals/other.mdx' }, { ...artifact, model: 'objective' }])('rejects stale, absent or unrelated evidence', relatedArtifact => { - expect(() => reviewedProposalVersion({ ...assignment, decisionInput: { input: { intent: { relatedArtifact } } } }, proposal)).toThrow('current immutable proposal revision'); - }); -}); describe('human proposal review freshness', () => { it.each([undefined, 3, 4])('counts only current review version %s even without an agent participation snapshot', async proposalVersion => { diff --git a/tests/unit/control-plane/capacity/planning/workday-content-source.test.ts b/tests/unit/control-plane/capacity/planning/workday-content-source.test.ts deleted file mode 100644 index bacbdda6..00000000 --- a/tests/unit/control-plane/capacity/planning/workday-content-source.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { listTreeDxPlanningDemandSources } from '../../../../../src/api/capacity/services/capacity/workdays/content/workday-content-demand-source.ts'; -import type { DurableCapacityWorkdayRun } from '../../../../../src/api/capacity/repositories/capacity/workdays/workday-run.ts'; -import { bindPlanningContentIntent, preferredContentSource } from '../../../../../src/api/capacity/services/support/planning-demand-source.ts'; -import { resolveCapacityWorkdayAssignmentIntent } from '../../../../../src/api/capacity/services/capacity/workdays/assignments/workday-assignment-context-service.ts'; - -const mocks = vi.hoisted(() => ({ resolve: vi.fn(), search: vi.fn(), read: vi.fn(), validate: vi.fn() })); -vi.mock('../../../../../src/api/capacity/services/capacity/workdays/treedx/workday-treedx-connection.ts', () => ({ resolveWorkdayTreeDxConnection: mocks.resolve })); -vi.mock('@treeseed/sdk/content-validation', () => ({ validatePortableContentData: mocks.validate })); -const commit = 'a'.repeat(40); -const run = { id: 'run-1', parameters: { repositoryIdsBySlug: { sdk: 'wrong-guessed-repository' } } } as unknown as DurableCapacityWorkdayRun; -function fixture(ref: string | undefined = 'refs/remotes/origin/staging', contentPath = '.') { - return { config: {}, getProjectTreeDxLibrary: vi.fn(async () => ({ repositoryId: 'assigned-library', contentPath, contentRepositoryRef: ref })) }; -} -beforeEach(() => { - vi.clearAllMocks(); - mocks.resolve.mockResolvedValue({ repositoryId: 'assigned-library', client: { searchRepositoryFiles: mocks.search, readRepositoryFiles: mocks.read } }); - mocks.search.mockResolvedValue({ resolvedRef: commit, results: [] }); - mocks.validate.mockReturnValue({ ok: true }); -}); - -describe('selected planning subject', () => { - const intent = { objective: 'Review the proposal', artifactKind: 'proposal_feedback_note', subjectModel: 'proposal', subjectId: null, includeWorkdayArtifacts: true }; - const source = { sourceType: 'proposal' as const, sourceId: 'proposal:task', priority: 70, payload: { model: 'proposal', contentPath: 'proposals/task.md', commitSha: commit, digest: 'b'.repeat(64) } }; - it('keeps proposal review intent through an empty workday history before selecting the existing library proposal', async () => { - const resolved = await resolveCapacityWorkdayAssignmentIntent({ all: async () => [] } as never, - { ...run, teamId: 'team', parameters: {} } as never, { id: 'project-1' } as never, - { slug: 'reviewer', activityType: 'reviewing', planningIntent: intent, outputContract: { modelMutations: ['proposal_feedback:create'] } } as never); - expect(resolved).toMatchObject({ artifactKind: 'proposal_feedback_note', subjectModel: 'proposal', subjectId: null }); - expect(bindPlanningContentIntent(resolved, source)).toMatchObject({ subjectId: 'task', artifactKind: 'proposal_feedback_note' }); - expect(resolved.objective).not.toContain('No generated proposal'); - }); - it('binds the selected existing proposal instead of falling back to an objective', () => { - expect(bindPlanningContentIntent(intent, source)).toMatchObject({ subjectId: 'task', subjectPath: 'proposals/task.md', relatedArtifact: { model: 'proposal', commitSha: commit, digest: 'b'.repeat(64) } }); - }); - it('preserves an explicitly assigned subject or different model', () => { - const explicit = { ...intent, subjectId: 'other' }, objective = { ...intent, subjectModel: 'objective' }; - expect(bindPlanningContentIntent(explicit, source)).toBe(explicit); - expect(bindPlanningContentIntent(objective, source)).toBe(objective); - }); -}); -describe('planning subject priority', () => { - it('binds estimating activity to proposal authority before objectives', () => { - const selected = preferredContentSource({ slug: 'sdk/engineer', activityType: 'estimating', handler: 'estimate' } as never, [ - { sourceType: 'objective', sourceId: 'objective:core', priority: 80, payload: {} }, - { sourceType: 'proposal', sourceId: 'proposal:golden', priority: 70, payload: {} }, - ]); - expect(selected?.sourceId).toBe('proposal:golden'); - }); -}); -describe('workday library source custody', () => { - it.each(['refs/remotes/origin/staging', 'refs/heads/staging', 'staging', 'main', commit])('uses selected %s and freezes subsequent collections', async ref => { - await listTreeDxPlanningDemandSources(fixture(ref), run, { id: 'project-1', slug: 'sdk' }); - expect(mocks.resolve.mock.calls[0][1]).not.toHaveProperty('repositoryId'); - expect(mocks.search.mock.calls.map(([input]) => input.ref)).toEqual([ref === commit ? commit : ref === 'main' ? 'refs/heads/main' : 'refs/heads/staging', commit, commit, commit]); - expect(mocks.search.mock.calls[0][0].paths).toEqual(['objectives/**']); - }); - it('uses the bound content root rather than source project metadata', async () => { - await listTreeDxPlanningDemandSources(fixture('staging', 'teams/docs'), run, { id: 'project-1', metadata: { architecture: { contentPath: 'wrong' } } }); - expect(mocks.search.mock.calls[0][0].paths).toEqual(['teams/docs/objectives/**']); - }); - it.each(['', 'refs/heads/staging', 'b'.repeat(40)])('rejects nonimmutable or changed readback %s', async resolvedRef => { - mocks.search.mockResolvedValueOnce({ resolvedRef: commit, results: [] }).mockResolvedValueOnce({ resolvedRef, results: [] }); - await expect(listTreeDxPlanningDemandSources(fixture(), run, { id: 'project-1' })).rejects.toMatchObject({ code: 'capacity_workday_content_snapshot_invalid' }); - }); - it('rejects missing selected ref', async () => { - await expect(listTreeDxPlanningDemandSources(fixture(''), run, { id: 'project-1' })).rejects.toMatchObject({ code: 'capacity_workday_content_ref_missing' }); - expect(mocks.search).not.toHaveBeenCalled(); - }); - it('rejects unsafe content root before querying', async () => { - await expect(listTreeDxPlanningDemandSources(fixture('staging', '../other'), run, { id: 'project-1' })).rejects.toThrow(); - expect(mocks.search).not.toHaveBeenCalled(); - }); - it('carries immutable provenance and retains content validation', async () => { - mocks.search.mockResolvedValueOnce({ resolvedRef: commit, results: [{ path: 'objectives/core.mdx', frontmatter: { title: 'Core' }, body: 'Source body' }] }); - const sources = await listTreeDxPlanningDemandSources(fixture(), run, { id: 'project-1' }); - expect(sources[0].payload).toMatchObject({ contentBaseRef: commit, commitSha: commit, contentPath: 'objectives/core.mdx' }); - expect(mocks.validate).toHaveBeenCalledWith('objective', { title: 'Core' }); - }); - it('reads exact proposal bytes for version-safe feedback', async () => { - mocks.search.mockResolvedValueOnce({ resolvedRef: commit, results: [] }).mockResolvedValueOnce({ resolvedRef: commit, results: [] }) - .mockResolvedValueOnce({ resolvedRef: commit, results: [{ path: 'proposals/task.md', frontmatter: { title: 'Task' } }] }); - mocks.read.mockResolvedValue({ resolvedRef: commit, files: [{ path: 'proposals/task.md', content: 'abc' }] }); - const sources = await listTreeDxPlanningDemandSources(fixture(), run, { id: 'project-1' }); - expect(sources[0].payload.digest).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); - expect(mocks.read.mock.calls[0][0]).toMatchObject({ ref: commit, parseFrontmatter: false }); - }); -}); diff --git a/tests/unit/control-plane/capacity/policy/chat-activity-profile-policy.test.ts b/tests/unit/control-plane/capacity/policy/chat-activity-profile-policy.test.ts index 97b9d1be..c44bb06e 100644 --- a/tests/unit/control-plane/capacity/policy/chat-activity-profile-policy.test.ts +++ b/tests/unit/control-plane/capacity/policy/chat-activity-profile-policy.test.ts @@ -1,7 +1,20 @@ import { describe, expect, it } from 'vitest'; import { capacityWorkdayAgentsFromClasses } from '../../../../../src/api/capacity/services/capacity/workdays/policy/workday-agent-policy.ts'; +import { compileDefaultChatActivityProfile } from '../../../../../src/api/capacity/policy/workdays/chat-activity-profile.ts'; +import { compileAgentAuthoritySnapshot } from '../../../../../src/api/capacity/policy/authority/agent-authority-presets.ts'; describe('chat activity profile policy', () => { + it('never grants retired TreeDX assignment content or operational tools', () => { + const profile = compileDefaultChatActivityProfile('sdk/architect'); + const authority = compileAgentAuthoritySnapshot('chat', profile); + const retired = /assignment_(?:plan|status|summary)/u; + expect(Object.keys(profile.permissions?.content ?? {})).not.toEqual(expect.arrayContaining([ + 'assignment_plan', 'assignment_status', 'assignment_summary', + ])); + expect(profile.tools?.allowed?.filter((tool) => retired.test(tool))).toEqual([]); + expect(authority.tools.allowed.filter((tool) => retired.test(tool))).toEqual([]); + }); + it('preserves provider-neutral execution and capability settings from the agent profile', () => { const capabilities = [{ capabilityId: 'treeseed.coordination.conversation', versionRange: '^1.0.0', requirement: 'required' }]; const agents = capacityWorkdayAgentsFromClasses([{ diff --git a/tests/unit/control-plane/capacity/workdays/acting-content-base-ref.test.ts b/tests/unit/control-plane/capacity/workdays/acting-content-base-ref.test.ts deleted file mode 100644 index 80ef013b..00000000 --- a/tests/unit/control-plane/capacity/workdays/acting-content-base-ref.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveAssignmentContentBaseRef } from '../../../../../src/api/capacity/services/capacity/assignments/planning/content-base-ref.ts'; -import { actingArtifactKinds } from '../../../../../src/api/capacity/services/support/acting-demand-source.ts'; - -describe('acting assignment content base ref', () => { - it('keeps the TreeDX content workspace on its frozen library commit', () => { - const contentBaseRef = '71cae536e1e7248a2a24e1f20b0c1cc451d816f3'; - expect(resolveAssignmentContentBaseRef({ - contentBaseRef, - decisionInput: { input: { exactBaseRef: '0b70da01bbac7492ca783054861dbbe5690ea36f' } }, - })).toBe(contentBaseRef); - }); - - it('does not confuse the source repository commit with TreeDX content identity', () => { - expect(resolveAssignmentContentBaseRef({ - decisionInput: { input: { exactBaseRef: '0b70da01bbac7492ca783054861dbbe5690ea36f' } }, - })).toBe('refs/heads/main'); - }); -}); - -describe('acting source publication', () => { - it('authorizes a durable source candidate alongside the governed stage output', () => { - expect(actingArtifactKinds([{ outputType: 'failing_test_proof' }])).toEqual(['source-candidate', 'failing_test_proof']); - }); -}); diff --git a/tests/unit/control-plane/capacity/workdays/assignments/workday-assignment-context-graph-custody.test.ts b/tests/unit/control-plane/capacity/workdays/assignments/workday-assignment-context-graph-custody.test.ts new file mode 100644 index 00000000..62924a4c --- /dev/null +++ b/tests/unit/control-plane/capacity/workdays/assignments/workday-assignment-context-graph-custody.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; +import { listCapacityWorkdayContentArtifactRefs } from '../../../../../../src/api/capacity/services/capacity/workdays/assignments/workday-assignment-context-service.ts'; + +describe('workday assignment context custody', () => { + it('reads completed artifacts from assignments owned by the workday, without a demand join', async () => { + const all = vi.fn(async () => []); + const refs = await listCapacityWorkdayContentArtifactRefs( + { all } as never, + { id: 'run-1', teamId: 'team-1' } as never, + 'project-1', + ); + expect(refs).toEqual([]); + expect(all).toHaveBeenCalledOnce(); + const [sql, params] = all.mock.calls[0]!; + expect(sql).toContain('assignment.work_day_id = ?'); + expect(sql).not.toContain('capacity_workday_demands'); + expect(params).toEqual(['team-1', 'project-1', 'run-1', 200]); + }); +}); diff --git a/tests/unit/control-plane/capacity/workdays/governed-proposal-selection.test.ts b/tests/unit/control-plane/capacity/workdays/governed-proposal-selection.test.ts index dc70c4bd..545fd03c 100644 --- a/tests/unit/control-plane/capacity/workdays/governed-proposal-selection.test.ts +++ b/tests/unit/control-plane/capacity/workdays/governed-proposal-selection.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { requiresGovernedPlanningProposal } from '../../../../../src/api/capacity/services/capacity/workdays/scheduling/workday-scheduling-service.ts'; describe('governed workday proposal selection', () => { - it('requires a proposal for estimating, not autonomous planning', () => { + it('identifies estimating selection while permitting a zero-proposal autonomous start', () => { expect(requiresGovernedPlanningProposal({ executionKind: 'workday', parameters: { planningOnly: true } } as never)).toBe(false); expect(requiresGovernedPlanningProposal({ executionKind: 'workday', parameters: { agentSelection: { activityTypes: ['estimating'] } } } as never)).toBe(true); expect(requiresGovernedPlanningProposal({ executionKind: 'workday', parameters: { planningOnly: false } } as never)).toBe(false); diff --git a/tests/unit/control-plane/capacity/workdays/lane-supply.test.ts b/tests/unit/control-plane/capacity/workdays/lane-supply.test.ts deleted file mode 100644 index 739a58d5..00000000 --- a/tests/unit/control-plane/capacity/workdays/lane-supply.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect, it } from 'vitest'; -import { selectWorkdayDemandSupply } from '../../../../../src/api/capacity/repositories/capacity/workdays/workday-demand-supply.ts'; - -it('selects the workday offer rather than repeatedly denying a higher-ranked communication-only offer', async () => { - const capability = 'treeseed.engineering.review'; - const offer = (id: string, purpose: string) => ({ id, status: 'available', capabilities: [capability], lanes: [{ id, purpose }] }); - const database = { - first: async () => ({ metadata_json: '{}' }), - all: async (sql: string) => sql.includes('availability_sessions') ? [{ id: 'session', membership_id: 'member', capacity_provider_id: 'provider', execution_providers_json: JSON.stringify([offer('a-conversation', 'communication'), offer('z-engineering', 'workday')]) }] - : [{ id: 'grant', membership_id: 'member', execution_provider_ids_json: '[]', allowed_modes_json: '["planning"]', capabilities_json: JSON.stringify([capability]) }], - } as any; - const demand = (kind: string) => ({ id: 'demand', team_id: 'team', project_id: 'sdk', primary_provider_id: 'provider', mode: 'planning', metadata_json: JSON.stringify({ executionKind: kind, capabilityDemand: { resolved: [{ id: capability }] } }) }); - const workday = await selectWorkdayDemandSupply(database, demand('workday'), new Date().toISOString()); - expect(workday.selected?.executionProviderId).toBe('z-engineering'); - expect(workday.rejected).toEqual([expect.objectContaining({ candidate: expect.objectContaining({ executionProviderId: 'a-conversation' }), reasons: ['status:unavailable'] })]); - expect((await selectWorkdayDemandSupply(database, demand('conversation'), new Date().toISOString())).selected?.executionProviderId).toBe('a-conversation'); -}); diff --git a/tests/unit/control-plane/capacity/workdays/lifecycle/workday-admission-fence.test.ts b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-admission-fence.test.ts new file mode 100644 index 00000000..6b5b93c4 --- /dev/null +++ b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-admission-fence.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fenceCapacityWorkdayAdmission } from '../../../../../../src/api/capacity/services/capacity/workdays/lifecycle/workday-admission-fence-service.ts'; + +const runGet = vi.hoisted(() => vi.fn()); +const advance = vi.hoisted(() => vi.fn()); +const reconcile = vi.hoisted(() => vi.fn()); +vi.mock('../../../../../../src/api/capacity/repositories/capacity/workdays/workday-run.ts', () => ({ + CapacityWorkdayRunRepository: class { get = runGet; }, +})); +vi.mock('../../../../../../src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts', () => ({ advanceLivingWorkday: advance })); +vi.mock('../../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', () => ({ reconcileExecutionGraph: reconcile })); + +describe('living workday admission fence', () => { + it('counts direct living-graph assignments without demand rows', async () => { + runGet.mockResolvedValue({ id: 'run', status: 'completed', parameters: {} }); + const queries: string[] = []; + const store = { + first: vi.fn(async (query: string) => { + queries.push(query); + return { total: 1, completed: 0, failed: 0, non_terminal: 1, unsettled: 0 }; + }), + all: vi.fn(async (query: string) => { queries.push(query); return [{ id: 'assignment', status: 'running' }]; }), + }; + const fence = await fenceCapacityWorkdayAdmission(store as never, 'team', 'run'); + expect(fence).toMatchObject({ ready: false, assignments: { total: 1, nonTerminal: 1 }, + problemAssignmentIds: ['assignment'] }); + expect(queries.join('\n')).not.toContain('capacity_workday_demands'); + expect(queries.filter((query) => query.includes('FROM capacity_provider_assignments')) + .every((query) => query.includes('assignment.work_day_id = ?'))).toBe(true); + }); + it('fails closed for a running workday without an applied plan', async () => { + runGet.mockResolvedValue({ id: 'run', status: 'running', parameters: {} }); + await expect(fenceCapacityWorkdayAdmission({} as never, 'team', 'run')) + .rejects.toMatchObject({ code: 'capacity_workday_plan_missing' }); + }); + it('closes and reconciles the authoritative living plan before reporting admission closed', async () => { + const run = { id: 'run', status: 'running', parameters: { appliedPlan: { id: 'run' } } }; + runGet.mockResolvedValue(run); + advance.mockResolvedValue({ changed: true, plan: { state: 'closing' }, status: 'running' }); + reconcile.mockResolvedValue(undefined); + const store = { first: vi.fn(async () => ({ total: 0, completed: 0, failed: 0, non_terminal: 0, unsettled: 0 })), + all: vi.fn(async () => []) }; + const fence = await fenceCapacityWorkdayAdmission(store as never, 'team', 'run'); + expect(advance).toHaveBeenCalledWith(store, run, expect.any(String), true); + expect(reconcile).toHaveBeenCalledWith(store, 'team'); + expect(fence).toMatchObject({ admissionClosed: true, ready: true, successful: true }); + }); +}); diff --git a/tests/unit/control-plane/capacity/workdays/lifecycle/workday-assignment-evidence.test.ts b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-assignment-evidence.test.ts new file mode 100644 index 00000000..862c6ea1 --- /dev/null +++ b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-assignment-evidence.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CapacityWorkdayRecoveryRepository } from '../../../../../../src/api/capacity/repositories/capacity/workdays/workday-recovery.ts'; + +const assignmentId = 'assignment-1'; +const result = { + schemaVersion: 'treeseed.assignment-result/v1', id: 'result-1', assignmentId, + status: 'completed', summary: 'Published one exact reference.', + references: [{ kind: 'treedx', projectId: 'project', repository: 'library', + commit: 'a'.repeat(40), path: 'knowledge/result.mdx' }], + verification: [], usage: { elapsedSeconds: 12 }, diagnostics: [], completedAt: '2026-09-20T12:00:00.000Z', +}; + +describe('workday assignment-result evidence', () => { + it('reads canonical results and content-integration audits without mode-run records', async () => { + const all = vi.fn(async (sql: string) => sql.includes('FROM capacity_provider_assignments') + ? [{ id: assignmentId, assignment_result_json: JSON.stringify(result) }] + : [{ id: 'audit-1', target_id: assignmentId, event_type: 'assignment.content.integrated' }]); + const repository = new CapacityWorkdayRecoveryRepository({ ensureInitialized: vi.fn(), all } as never); + expect(await repository.assignmentEvidence('team', 'workday')).toMatchObject({ + contentArtifactCount: 1, requiredContentOutcomeAssignments: 1, + integratedContentOutcomeAssignments: 1, unresolvedContentOutcomeAssignments: 0, + }); + expect(all.mock.calls.map(([sql]) => sql).join('\n')).not.toContain('agent_mode_runs'); + }); + + it('rejects a completed assignment missing its immutable result', async () => { + const repository = new CapacityWorkdayRecoveryRepository({ ensureInitialized: vi.fn(), + all: vi.fn(async () => [{ id: assignmentId, assignment_result_json: null }]) } as never); + await expect(repository.assignmentEvidence('team', 'workday')).rejects.toMatchObject({ + code: 'capacity_durable_json_invalid', + }); + }); +}); diff --git a/tests/unit/control-plane/capacity/workdays/lifecycle/workday-recovery-graph-custody.test.ts b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-recovery-graph-custody.test.ts new file mode 100644 index 00000000..120f228c --- /dev/null +++ b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-recovery-graph-custody.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CapacityWorkdayRecoveryRepository } from '../../../../../../src/api/capacity/repositories/capacity/workdays/workday-recovery.ts'; + +describe('workday recovery graph custody', () => { + it('finds ready graph work and assignment evidence by the canonical workday IDs', async () => { + const queries: string[] = []; + const database = { + ensureInitialized: vi.fn(async () => undefined), + first: vi.fn(async (query: string) => { + queries.push(query); + return query.includes('FROM execution_nodes') ? { id: 'ready-node' } : null; + }), + }; + const run = { id: 'run', teamId: 'team', actual: {} }; + const state = await new CapacityWorkdayRecoveryRepository(database as never).recoveryState(run as never); + expect(state).toMatchObject({ hasReadyNodes: true, hasUnfinishedAssignments: false }); + expect(queries.join('\n')).not.toContain('capacity_workday_demands'); + expect(queries.join('\n')).toContain('assignment.work_day_id = ?'); + expect(queries.join('\n')).toContain("workday_id = ? AND status = 'ready'"); + }); +}); diff --git a/tests/unit/control-plane/capacity/workdays/lifecycle/workday-terminalization-graph-custody.test.ts b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-terminalization-graph-custody.test.ts new file mode 100644 index 00000000..f513ea94 --- /dev/null +++ b/tests/unit/control-plane/capacity/workdays/lifecycle/workday-terminalization-graph-custody.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { terminalizeCapacityWorkdayAssignments } from '../../../../../../src/api/capacity/services/capacity/workdays/lifecycle/workday-assignment-terminalization-service.ts'; + +describe('workday terminalization graph custody', () => { + it('cancels unclaimed graph nodes without touching retired demand rows', async () => { + const run = vi.fn(async (_sql: string, _params?: unknown[]) => ({})); + const database = { + ensureInitialized: vi.fn(async () => undefined), + first: vi.fn(async (_sql: string, _params?: unknown[]) => ({ assignment_count: 0, completed_assignments: 0, failed_assignments: 0, unfinished_assignments: 0 })), + all: vi.fn(async (_sql: string, _params?: unknown[]) => []), run, + }; + await terminalizeCapacityWorkdayAssignments(database as never, 'team', 'run', { now: '2026-09-20T00:00:00.000Z' }); + expect(run).toHaveBeenCalledOnce(); + const [sql, params] = run.mock.calls[0]!; + expect(sql).toContain("UPDATE execution_nodes SET status='cancelled'"); + expect(sql).toContain("status IN ('proposed','blocked','ready')"); + expect(params).toEqual(['2026-09-20T00:00:00.000Z', 'team', 'run']); + for (const [query] of [...database.first.mock.calls, ...database.all.mock.calls]) { + expect(query).not.toContain('envelope.id = assignment.work_day_id'); + if (query.includes('capacity_provider_assignments assignment')) expect(query).toContain('run.id = assignment.work_day_id'); + } + }); +}); diff --git a/tests/unit/control-plane/capacity/workdays/provisioning-demand-selection.test.ts b/tests/unit/control-plane/capacity/workdays/provisioning-demand-selection.test.ts deleted file mode 100644 index ec6de8ec..00000000 --- a/tests/unit/control-plane/capacity/workdays/provisioning-demand-selection.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CapacityWorkdayDemandRepository, capacityDemandRunDeadlineOpen } from '../../../../../src/api/capacity/repositories/capacity/workdays/workday-demand.ts'; - -describe('provisioning demand selection', () => { - it('retries only active unleased assignments and cannot let historical failures starve new work', async () => { - const all = vi.fn(async () => []); - const repository = new CapacityWorkdayDemandRepository({ all } as never); - await repository.listProvisioning('team', 'provider'); - const sql = String(all.mock.calls[0]?.[0]); - expect(sql).toContain("run.status = 'running'"); - expect(sql).toContain("assignment.status = 'pending'"); - expect(sql).toContain("assignment.lease_state = 'unleased'"); - }); -}); - -describe('claimable workday deadline', () => { - it('excludes expired runs and accepts a current or unbounded run', () => { - const now = '2026-09-12T12:00:00.000Z'; - expect(capacityDemandRunDeadlineOpen({ deadlineAt: '2026-09-12T11:59:59.000Z' }, now)).toBe(false); - expect(capacityDemandRunDeadlineOpen(JSON.stringify({ deadlineAt: '2026-09-12T12:00:01.000Z' }), now)).toBe(true); - expect(capacityDemandRunDeadlineOpen({}, now)).toBe(true); - }); -}); diff --git a/tests/unit/control-plane/capacity/workspaces/source-workspace-service.test.ts b/tests/unit/control-plane/capacity/workspaces/source-workspace-service.test.ts index 44a9f70d..f8ece215 100644 --- a/tests/unit/control-plane/capacity/workspaces/source-workspace-service.test.ts +++ b/tests/unit/control-plane/capacity/workspaces/source-workspace-service.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createSourceCredentialRecipient, openSourceCredential } from '@treeseed/deployment/security/source'; import { assignmentAttemptSchema } from '@treeseed/sdk/agent-capacity'; -import { createSourceWorkspaceService, assertSourceAssignmentLease, assignmentSourceMode } from '../../../../../src/api/control-plane/repositories/providers/source/source-workspace-service.ts'; +import { createSourceWorkspaceService, assertSourceAssignmentLease, assignmentSourceMode, assignmentPredecessorSourceCommits } from '../../../../../src/api/control-plane/repositories/providers/source/source-workspace-service.ts'; const mocks = vi.hoisted(() => ({ credential: vi.fn(), authority: vi.fn() })); vi.mock('../../../../../src/security/provider-credential-authority.ts', () => ({ resolveGitHubSourceAuthority: mocks.credential })); @@ -30,7 +30,7 @@ function fixture(overrides: Record = {}) { const canonicalAttempt = { schemaVersion: 'treeseed.assignment-attempt/v1', id: 'assignment', idempotencyKey: 'assignment', teamId: 'team', projectId: 'project', - workdayId: 'workday', nodeId: 'node', workItemId: 'tests-first', nodeRevision: 1, graphRevision: 1, + workdayId: 'workday', nodeId: 'node', agentClass: 'tester', workItemId: 'tests-first', nodeRevision: 1, graphRevision: 1, sourceRef: { store: 'treedx', model: 'proposal', id: 'proposal', revision: 1, digest: `sha256:${'1'.repeat(64)}` }, authorityRefs: [{ store: 'postgresql', model: 'decision', id: 'decision', revision: 1, digest: `sha256:${'4'.repeat(64)}` }], effectiveProfile: { profileRef: { store: 'treedx', model: 'agent', id: 'sdk/tester', revision: 1, digest: `sha256:${'2'.repeat(64)}` }, @@ -47,6 +47,22 @@ const canonicalAttempt = { status: 'created', createdAt: now.toISOString(), } as const; +it('authorizes only exact same-repository Git predecessors from the immutable attempt', () => { + const approved = 'b'.repeat(40); + const attempt = { ...canonicalAttempt, predecessorResultIds: ['approved'] }; + const result = { schemaVersion: 'treeseed.assignment-result/v1', id: 'approved', assignmentId: 'earlier', + status: 'completed', summary: 'Candidate committed.', references: [ + { kind: 'git', repository: 'repository', commit: approved }, + { kind: 'git', repository: 'other-repository', commit: 'c'.repeat(40) }], + verification: [], usage: { elapsedSeconds: 1 }, diagnostics: [], completedAt: now.toISOString() }; + const row = { assignment_attempt_json: JSON.stringify(attempt), workspace_context_json: JSON.stringify({ predecessorResults: [result] }) }; + expect(assignmentPredecessorSourceCommits(row, ['repository', 'example/project'], commit)).toEqual([approved]); + expect(() => assignmentPredecessorSourceCommits({ ...row, workspace_context_json: JSON.stringify({ predecessorResults: [] }) }, + ['repository'], commit)).toThrow('do not match'); + expect(() => assignmentPredecessorSourceCommits({ ...row, workspace_context_json: JSON.stringify({ predecessorResults: [{ ...result, id: 'different' }] }) }, + ['repository'], commit)).toThrow('invalid predecessor'); +}); + describe('provider source workspace authorization', () => { it('pins a revision and seals the credential to the exact current assignment and host key', async () => { const f = fixture(); @@ -116,5 +132,9 @@ describe('provider source workspace authorization', () => { dependent.workspace.baseCommit = '9'.repeat(40); expect(assignmentSourceMode({ ...base, assignment_attempt_json: JSON.stringify(dependent) })).toMatchObject({ mode: 'work', acquisition: 'simulation-local', publication: 'simulation-branch' }); + const integration = structuredClone(canonicalAttempt); + integration.predecessorResultIds = ['approved-actor-1', 'approved-actor-2']; + expect(assignmentSourceMode({ ...base, assignment_attempt_json: JSON.stringify(integration) })).toMatchObject({ + mode: 'work', acquisition: 'simulation-local', publication: 'simulation-branch' }); }); }); diff --git a/tests/unit/control-plane/providers/assignments/provider-assignment-settlement.test.ts b/tests/unit/control-plane/providers/assignments/provider-assignment-settlement.test.ts index 6e8e11cd..b3b1e46b 100644 --- a/tests/unit/control-plane/providers/assignments/provider-assignment-settlement.test.ts +++ b/tests/unit/control-plane/providers/assignments/provider-assignment-settlement.test.ts @@ -20,6 +20,14 @@ describe('provider assignment settlement', () => { settleCapacityReservationExactlyOnce.mockReset().mockResolvedValue({ replayed: false, entry: { id: 'entry-1' } }); }); + it('rejects retired mode-run identity before any settlement or assignment lookup', async () => { + const store = { first: vi.fn() } as never; + const service = createProviderAssignmentService(store); + await expect(service.settle(auth, 'assignment-1', { modeRunId: 'retired-run', activeSeconds: 1, elapsedSeconds: 1 }, 'key')) + .rejects.toMatchObject({ code: 'mode_run_contract_retired', status: 400 }); + expect(settleCapacityReservationExactlyOnce).not.toHaveBeenCalled(); + }); + it('closes the canonical suspended conversation checkpoint after durable settlement', async () => { const returnProviderAssignment = vi.fn().mockResolvedValue({ assignment: { id: 'assignment-1' } }); const store = { diff --git a/tests/unit/control-plane/providers/assignments/provider-discussion-suspension.test.ts b/tests/unit/control-plane/providers/assignments/provider-discussion-suspension.test.ts index 282e95f3..9ee6c78a 100644 --- a/tests/unit/control-plane/providers/assignments/provider-discussion-suspension.test.ts +++ b/tests/unit/control-plane/providers/assignments/provider-discussion-suspension.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { suspendAssignmentForDiscussionResponse } from '../../../../../src/api/capacity/services/capacity/assignments/lifecycle/assignment-discussion-suspension-service.ts'; +import { closeSuspendedConversationExecution, suspendAssignmentForDiscussionResponse } from '../../../../../src/api/capacity/services/capacity/assignments/lifecycle/assignment-discussion-suspension-service.ts'; describe('provider discussion suspension', () => { it('atomically suspends the assignment and invocation before exposing a returned assignment', async () => { @@ -24,4 +24,16 @@ describe('provider discussion suspension', () => { expect(operations.at(-1)!.query).toContain("SET status='suspended'"); expect(operations.at(-1)!.query).toContain("assignment.status='returned'"); }); + + it('closes the exact conversation workday without a demand lookup', async () => { + const batch = vi.fn(async (_operations: unknown[]) => []); + const first = vi.fn().mockResolvedValueOnce({ final_message_ref: 'discussion-messages/response.mdx' }) + .mockResolvedValueOnce({ status: 'degraded' }); + await closeSuspendedConversationExecution({ first, batch } as never, { + id: 'assignment-1', teamId: 'team-1', invocationId: 'invocation-1', workDayId: 'conversation-1', + } as never); + const operations = batch.mock.calls[0]![0] as unknown as Array<{ query: string; params: unknown[] }>; + expect(operations.map((entry) => entry.query).join('\n')).not.toContain('capacity_workday_demands'); + expect(operations.every((entry) => entry.params.includes('conversation-1'))).toBe(true); + }); }); diff --git a/tests/unit/control-plane/providers/assignments/provider-lease-candidates.test.ts b/tests/unit/control-plane/providers/assignments/provider-lease-candidates.test.ts index 0701e973..0c9622da 100644 --- a/tests/unit/control-plane/providers/assignments/provider-lease-candidates.test.ts +++ b/tests/unit/control-plane/providers/assignments/provider-lease-candidates.test.ts @@ -19,7 +19,8 @@ describe('provider lease candidate scope', () => { expect(all).toHaveBeenCalledOnce(); const [query, parameters] = all.mock.calls[0]!; expect(query).toContain('execution_provider_id IN (?)'); - expect(query).toContain("workday.status IN ('completed','cancelled')"); + expect(query).toContain('FROM capacity_workday_runs workday'); + expect(query).toContain("workday.status IN ('completed','cancelled','failed','degraded')"); expect(parameters).toEqual(['team', 'provider', 'codex-implementation']); }); }); diff --git a/tests/unit/control-plane/providers/assignments/provider-workflow-living-authority.test.ts b/tests/unit/control-plane/providers/assignments/provider-workflow-living-authority.test.ts new file mode 100644 index 00000000..47e7f8ec --- /dev/null +++ b/tests/unit/control-plane/providers/assignments/provider-workflow-living-authority.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { createProviderWorkflowService } from '../../../../../src/api/control-plane/repositories/providers/provider-workflow-service.ts'; + +const principal = { membershipId: 'membership', teamId: 'team', capacityProviderId: 'provider', scopes: ['provider:assignments:write'] }; +const assignment = { + id: 'assignment', membershipId: 'membership', teamId: 'team', capacityProviderId: 'provider', + status: 'leased', leaseState: 'leased', leaseToken: 'lease', leaseExpiresAt: '9999-01-01T00:00:00Z', + mode: 'acting', decisionId: 'decision', reservationId: 'reservation', executionNodeId: 'node', + capabilityHandles: { workflowOperations: [] }, +}; + +describe('provider workflow living-graph authority', () => { + it('accepts node and reservation provenance without a retired allocation set', async () => { + const service = createProviderWorkflowService({ getProviderAssignment: async () => assignment } as never); + await expect(service.dispatch({ principal }, assignment.id, 'operation', { leaseToken: 'lease' })) + .rejects.toMatchObject({ code: 'assignment_workflow_handle_denied' }); + }); + + it('denies missing living-graph reservation or node', async () => { + for (const missing of ['reservationId', 'executionNodeId'] as const) { + const service = createProviderWorkflowService({ getProviderAssignment: async () => ({ ...assignment, [missing]: null }) } as never); + await expect(service.dispatch({ principal }, assignment.id, 'operation', { leaseToken: 'lease' })) + .rejects.toMatchObject({ code: 'assignment_workflow_acting_readiness_required' }); + } + }); +}); diff --git a/tests/unit/control-plane/providers/provider-assignment-activity-custody.test.ts b/tests/unit/control-plane/providers/provider-assignment-activity-custody.test.ts new file mode 100644 index 00000000..5fb4cb4d --- /dev/null +++ b/tests/unit/control-plane/providers/provider-assignment-activity-custody.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { assignmentActivityType, assignmentWorkdayRunId } from '../../../../src/api/control-plane/repositories/providers/provider-assignment-support.ts'; + +describe('provider assignment activity custody', () => { + it('reads only the pinned attempt profile in serialized and durable forms', () => { + const attempt = { effectiveProfile: { activity: 'estimating' } }; + expect(assignmentActivityType({ assignmentAttempt: attempt, decisionInput: { activityType: 'acting' } })).toBe('estimating'); + expect(assignmentActivityType({ assignment_attempt_json: JSON.stringify(attempt) })).toBe('estimating'); + expect(assignmentActivityType({ decisionInput: { activityType: 'acting' } })).toBeNull(); + expect(assignmentWorkdayRunId({ workDayId: 'run-1', metadata: { workdayRunId: 'wrong' } })).toBe('run-1'); + expect(assignmentWorkdayRunId({ work_day_id: 'run-2' })).toBe('run-2'); + expect(assignmentWorkdayRunId({ metadata: { workdayRunId: 'wrong' } })).toBeNull(); + }); +}); diff --git a/tests/unit/control-plane/providers/provider-assignment-operations.test.ts b/tests/unit/control-plane/providers/provider-assignment-operations.test.ts index 02fb9ba6..ced06618 100644 --- a/tests/unit/control-plane/providers/provider-assignment-operations.test.ts +++ b/tests/unit/control-plane/providers/provider-assignment-operations.test.ts @@ -5,7 +5,7 @@ import { createProviderAssignmentOperations } from '../../../../src/api/control- function dependencies() { return { providerAssignments: { sourceWorkspace: vi.fn(), next: vi.fn(), show: vi.fn(), explain: vi.fn(), renew: vi.fn(), startExecution: vi.fn(), startCloseout: vi.fn(), - preflight: vi.fn(), respondToDiscussion: vi.fn(), acknowledgeCommunication: vi.fn(), traceCommunication: vi.fn(), returnAssignment: vi.fn(), complete: vi.fn(), fail: vi.fn(), reportUsage: vi.fn(), settle: vi.fn(), createModeRun: vi.fn(), createEvent: vi.fn() }, + respondToDiscussion: vi.fn(), acknowledgeCommunication: vi.fn(), traceCommunication: vi.fn(), returnAssignment: vi.fn(), complete: vi.fn(), fail: vi.fn(), reportUsage: vi.fn(), settle: vi.fn(), createEvent: vi.fn() }, providerSignals: vi.fn(), providerWorkflows: { dispatch: vi.fn(), show: vi.fn() }, } as any; @@ -19,12 +19,12 @@ describe('provider assignment operation catalog', () => { CONTROL_PLANE_OPERATIONS.providers.nextAssignment, CONTROL_PLANE_OPERATIONS.providers.assignment, CONTROL_PLANE_OPERATIONS.providers.assignmentExplanation, CONTROL_PLANE_OPERATIONS.providers.renewAssignment, CONTROL_PLANE_OPERATIONS.providers.startExecution, CONTROL_PLANE_OPERATIONS.providers.startCloseout, - CONTROL_PLANE_OPERATIONS.providers.completionPreflight, CONTROL_PLANE_OPERATIONS.providers.discussionResponse, + CONTROL_PLANE_OPERATIONS.providers.discussionResponse, CONTROL_PLANE_OPERATIONS.providers.notificationAcknowledge, CONTROL_PLANE_OPERATIONS.providers.traceEvent, CONTROL_PLANE_OPERATIONS.providers.returnAssignment, CONTROL_PLANE_OPERATIONS.providers.completeAssignment, CONTROL_PLANE_OPERATIONS.providers.failAssignment, CONTROL_PLANE_OPERATIONS.providers.reportUsage, CONTROL_PLANE_OPERATIONS.providers.settleAssignment, - CONTROL_PLANE_OPERATIONS.providers.createModeRun, CONTROL_PLANE_OPERATIONS.providers.createEvent, + CONTROL_PLANE_OPERATIONS.providers.createEvent, CONTROL_PLANE_OPERATIONS.providers.publishSignal, CONTROL_PLANE_OPERATIONS.providers.dispatchWorkflow, CONTROL_PLANE_OPERATIONS.providers.workflowRun, ]); diff --git a/tests/unit/control-plane/providers/provider-assignment-timestamp.test.ts b/tests/unit/control-plane/providers/provider-assignment-timestamp.test.ts index a23adc1d..5c28ff2d 100644 --- a/tests/unit/control-plane/providers/provider-assignment-timestamp.test.ts +++ b/tests/unit/control-plane/providers/provider-assignment-timestamp.test.ts @@ -15,8 +15,8 @@ describe('provider assignment workday identity', () => { expect(assignmentWorkdayRunId({ workDayId: 'workday-run-1', metadata: {} })).toBe('workday-run-1'); }); - it('continues to read the planning metadata representation', () => { - expect(assignmentWorkdayRunId({ metadata: { workdayRunId: 'workday-run-1' } })).toBe('workday-run-1'); + it('does not recover a workday from retired metadata', () => { + expect(assignmentWorkdayRunId({ metadata: { workdayRunId: 'workday-run-1' } })).toBeNull(); }); it('keeps the durable assignment field authoritative', () => { diff --git a/tests/unit/control-plane/seeds/agents/project-proposal-types.test.ts b/tests/unit/control-plane/seeds/agents/project-proposal-types.test.ts deleted file mode 100644 index 0e1f6da6..00000000 --- a/tests/unit/control-plane/seeds/agents/project-proposal-types.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { readProjectProposalTypes } from '../../../../../src/control-plane/seeds/apply-support/projects/projects-core/project-proposal-types.ts'; - -const ref = 'a'.repeat(40); -const path = '.treeseed/governance/proposal-types/implementation.yaml'; -const contract = { schemaVersion: 'treeseed.proposal-type/v1', id: 'implementation', label: 'Implementation', description: 'A bounded change.' }; -function fixture() { - const listing = { resolvedRef: ref, entries: [{ path }], page: { hasMore: false } }; - const read = { resolvedRef: ref, files: [{ path, content: JSON.stringify(contract) }] }; - const query = { listPaths: vi.fn(async () => listing), readFile: vi.fn(async () => read) }; - return { listing, read, query, client: { query } as unknown as Parameters[0] }; -} -describe('project proposal contracts', () => { - it('reads and validates every contract at the exact agent-definition commit', async () => { - const f = fixture(); - expect(await readProjectProposalTypes(f.client, 'repo-1', ref)).toEqual({ implementation: contract }); - expect(f.query.readFile).toHaveBeenCalledWith('repo-1', expect.objectContaining({ ref, paths: [path], allowProtected: true })); - }); - it('allows a verified empty catalog, never inventing proposal types', async () => { - const f = fixture(); f.listing.entries = []; - expect(await readProjectProposalTypes(f.client, 'repo-1', ref)).toEqual({}); - expect(f.query.readFile).not.toHaveBeenCalled(); - }); - it.each(['moving-ref', 'truncated', 'duplicate-path', 'wrong-path', 'moved-read', 'missing-file', 'invalid-contract', 'wrong-id'])('rejects %s', async (kind) => { - const f = fixture(); - if (kind === 'moving-ref') f.listing.resolvedRef = 'b'.repeat(40); - if (kind === 'truncated') f.listing.page.hasMore = true; - if (kind === 'duplicate-path') f.listing.entries.push({ path }); - if (kind === 'wrong-path') f.listing.entries[0]!.path = '../escape.yaml'; - if (kind === 'moved-read') f.read.resolvedRef = 'b'.repeat(40); - if (kind === 'missing-file') f.read.files = []; - if (kind === 'invalid-contract') f.read.files[0]!.content = '{}'; - if (kind === 'wrong-id') f.read.files[0]!.content = JSON.stringify({ ...contract, id: 'other' }); - await expect(readProjectProposalTypes(f.client, 'repo-1', ref)).rejects.toThrow(); - }); - it('rejects moving refs before any request', async () => { - const f = fixture(); - await expect(readProjectProposalTypes(f.client, 'repo-1', 'staging')).rejects.toThrow('immutable'); - expect(f.query.listPaths).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts b/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts index 87eb707e..c7c21bcc 100644 --- a/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts +++ b/tests/unit/control-plane/seeds/project-knowledge-binding.test.ts @@ -1,7 +1,27 @@ -import { describe, expect, it } from 'vitest'; -import { ensureProjectKnowledgeBinding } from '../../../../src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { ensureProjectKnowledgeBinding, reconcileProjectAgentClasses } from '../../../../src/control-plane/seeds/apply-support/projects/projects-core/project-knowledge-binding.ts'; describe('seed TreeDX knowledge binding', () => { + it('rejects a moved or mismatched agent source before updating the class projection', async () => { + const path = 'agents/architect.mdx'; + const discoveredRef = 'a'.repeat(40); + const run = vi.fn(); + const store = { run, first: vi.fn() }; + const client = { query: { readFile: vi.fn(async () => ({ query: { + resolvedRef: 'b'.repeat(40), files: [{ path, frontmatter: {} }], + } })) } }; + const input = { store, client: client as never, repositoryId: 'sdk-library', projectId: 'sdk', + teamId: 'team', projectSlug: 'api', ref: discoveredRef, discoveredRef, paths: [path] }; + await expect(reconcileProjectAgentClasses(input)).rejects.toThrow('moved after discovery'); + client.query.readFile.mockResolvedValue({ query: { resolvedRef: discoveredRef, + files: [{ path: 'agents/other.mdx', frontmatter: {} }] } }); + await expect(reconcileProjectAgentClasses(input)).rejects.toThrow('outside the discovered paths'); + client.query.readFile.mockResolvedValue({ query: { resolvedRef: discoveredRef, + files: [{ path, frontmatter: { id: 'invalid' } }] } }); + await expect(reconcileProjectAgentClasses(input)).rejects.toThrow('is invalid'); + expect(run).not.toHaveBeenCalled(); + }); + it('creates and binds a missing deterministic project repository', async () => { const calls: Array<{ method: string; path: string; body?: unknown }> = []; const bindings: unknown[] = []; From 6179191df7e7383663c63988bdb9f6bb4f6e808f Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 20 Sep 2026 21:59:30 -0400 Subject: [PATCH 2/8] Require exact endpoint grants for cross-project graph links --- .../services/capacity/execution/exact-dependency-links.ts | 2 +- .../execution/graph/relations/exact-dependency-links.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/capacity/services/capacity/execution/exact-dependency-links.ts b/src/api/capacity/services/capacity/execution/exact-dependency-links.ts index 0aaf326d..d50ee840 100644 --- a/src/api/capacity/services/capacity/execution/exact-dependency-links.ts +++ b/src/api/capacity/services/capacity/execution/exact-dependency-links.ts @@ -21,7 +21,7 @@ export async function loadTeamExactDependencyLinks(store: any, sources: Executab const grant = await resolveKnowledgeGatewayConnection(store, { projectId: source.projectId, write: false, relationPaths: true, readRefs: [source.commit], }); - const allowed = grant?.allowedPaths.some((pattern) => pattern === '**' || pattern === source.path + const allowed = grant?.allowedPaths?.some((pattern) => pattern === '**' || pattern === source.path || (pattern.endsWith('/**') && source.path.startsWith(pattern.slice(0, -3) + '/'))); if (!grant || grant.repositoryId !== source.repository || !allowed) { throw new Error(`TreeDX exact dependency endpoint read grant is missing for project ${source.projectId}.`); diff --git a/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts b/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts index 4bf40eb4..b22eed43 100644 --- a/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/relations/exact-dependency-links.test.ts @@ -46,9 +46,9 @@ describe('exact TreeDX dependency intake', () => { }); it('rejects a moved note snapshot', async () => { - resolve.mockResolvedValue({ repositoryId: 'sdk-library', publicationRef: 'refs/heads/staging', + resolve.mockImplementation(async (_store, input) => ({ repositoryId: `${input.projectId}-library`, publicationRef: 'refs/heads/staging', allowedPaths: ['proposals/**', 'notes/**'], client: { queryGraph: async () => graph, - readRepositoryFile: async () => ({ resolvedRef: 'd'.repeat(40), file: { content: 'content', frontmatter: note } }) } }); + readRepositoryFile: async () => ({ resolvedRef: 'd'.repeat(40), file: { content: 'content', frontmatter: note } }) } })); await expect(loadTeamExactDependencyLinks({}, [sdk, api] as never)).rejects.toThrow('moved during read'); }); From fba08896f5a7dcb2c1aca0006b4d0d9060dc98a8 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 20 Sep 2026 22:42:53 -0400 Subject: [PATCH 3/8] Project reviewable proposal drafts and exact feedback gates --- .../execution/execution-graph-projector.ts | 51 +++++++++++++++-- .../assignments/context/review-result.ts | 16 ++++-- .../lifecycle/assignment-lifecycle-service.ts | 2 +- .../execution/executable-proposal-source.ts | 13 +++-- .../execution/proposal-planning-source.ts | 54 ++++++++++++++---- .../governance/governance-service.ts | 43 +++++++++++--- .../execution/execution-graph-service.ts | 11 +++- src/api/discussions/discussion-service.ts | 2 +- src/api/governance/executable-proposal.ts | 11 ++++ .../governance-proposal-readiness.ts | 11 ++-- ...reate-governance-decision-from-proposal.ts | 6 +- src/api/support/app.ts | 2 +- .../graph/executable-proposal-source.test.ts | 57 ++++++++++++++++++- ...living-execution-graph-persistence.test.ts | 18 ++++++ .../graph/living-execution-graph.test.ts | 56 ++++++++++++++++-- .../capacity/execution/review-result.test.ts | 8 ++- .../control-plane/governance-service.test.ts | 21 ++++++- .../governance/draft-graph-readiness.test.ts | 38 +++++++++++++ 18 files changed, 358 insertions(+), 62 deletions(-) create mode 100644 tests/unit/control-plane/governance/draft-graph-readiness.test.ts diff --git a/src/api/capacity/policy/execution/execution-graph-projector.ts b/src/api/capacity/policy/execution/execution-graph-projector.ts index c34d3227..bc3a7f7c 100644 --- a/src/api/capacity/policy/execution/execution-graph-projector.ts +++ b/src/api/capacity/policy/execution/execution-graph-projector.ts @@ -22,6 +22,7 @@ export interface ExecutableProposalSource { digest: string; proposalRevision: number; frontmatter: Row; + feedback?: Array<{ id: string; kind: 'concern' | 'question'; resolved: boolean; sourceRef: ExactEntityReference }>; decision: { id: string; revision: number; digest: string; current: boolean } | null; } @@ -37,7 +38,7 @@ export interface VerifiedDependencyLink { sourceRef: ExactEntityReference; } -const RULE_REVISION = 2; +const RULE_REVISION = 3; const record = (value: unknown): Row => value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; const text = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; const rows = (value: unknown): Row[] => Array.isArray(value) ? value.map(record) : []; @@ -164,7 +165,8 @@ function proposalReviewNode(source: ExecutableProposalSource, profile: ReturnTyp sourceRef.digest, RULE_REVISION, 'proposal-review', 'reviewer']), teamId: source.teamId, projectId: source.projectId, workItemId: 'proposal-review', kind: 'reviewing', pairRole: null, sourceRef, authorityRefs: [sourceRef], - ruleRevision: RULE_REVISION, nodeRevision: 1, agentClass: 'reviewer', status: 'ready', + ruleRevision: RULE_REVISION, nodeRevision: 1, agentClass: 'reviewer', + status: source.decision?.current ? 'completed' : 'ready', estimate: proposalReviewEstimate(source.frontmatter), requiredCapabilities: ['treeseed.engineering.review'], requestedPermissions: profile.permissions, workspace: 'treedx', acceptanceCriteria: [ @@ -175,6 +177,34 @@ function proposalReviewNode(source: ExecutableProposalSource, profile: ReturnTyp }); } +function decisionConditionNode(source: ExecutableProposalSource, revision: number): ExecutionNode { + const sourceRef = proposalRef(source); + const authorityRef = decisionRef(source); + return executionNodeSchema.parse({ schemaVersion: 'treeseed.execution-node/v1', + id: deterministicId('node', [source.teamId, source.projectId, sourceRef.id, sourceRef.revision, + sourceRef.digest, RULE_REVISION, 'accepted-decision']), + teamId: source.teamId, projectId: source.projectId, kind: 'condition', pairRole: null, + sourceRef, authorityRefs: authorityRef ? [authorityRef] : [], + ruleRevision: RULE_REVISION, nodeRevision: 1, + status: source.decision?.current && (source.feedback ?? []).every((feedback) => feedback.resolved) ? 'completed' : 'blocked', + condition: { conditionType: 'authority', subjectRef: sourceRef, expectedState: 'accepted' }, + graphRevisionCreated: revision, graphRevisionUpdated: revision, + }); +} + +function feedbackConditionNode(source: ExecutableProposalSource, feedback: NonNullable[number], revision: number): ExecutionNode { + const sourceRef = proposalRef(source); + return executionNodeSchema.parse({ schemaVersion: 'treeseed.execution-node/v1', + id: deterministicId('node', [source.teamId, source.projectId, sourceRef.id, sourceRef.revision, + sourceRef.digest, RULE_REVISION, 'blocking-feedback', feedback.id]), + teamId: source.teamId, projectId: source.projectId, kind: 'condition', pairRole: null, + sourceRef, authorityRefs: [], ruleRevision: RULE_REVISION, nodeRevision: 1, + status: feedback.resolved ? 'completed' : 'blocked', + condition: { conditionType: 'question', subjectRef: feedback.sourceRef, expectedState: `feedback:${feedback.id}:resolved` }, + graphRevisionCreated: revision, graphRevisionUpdated: revision, + }); +} + /** * Compile exact accepted proposal revisions into the one team execution graph. * Estimates define executable units and dependency intent. Provider selection is @@ -202,10 +232,17 @@ export function projectTeamExecutionGraph(input: { const exactSource = { ...source, frontmatter: proposal }; const sourceRef = proposalRef(exactSource); changedSourceRefs.push(sourceRef); - if (!source.decision?.current) { - nodes.push(proposalReviewNode(exactSource, - profileFor(input.profiles, source.projectId, 'reviewer', 'reviewing'), input.revision)); - continue; + const review = proposalReviewNode(exactSource, + profileFor(input.profiles, source.projectId, 'reviewer', 'reviewing'), input.revision); + const authority = decisionConditionNode(exactSource, input.revision); + nodes.push(review, authority); + edges.push(edge({ teamId: source.teamId, fromNodeId: review.id, toNodeId: authority.id, + provenance: 'governance', sourceRef }, input.revision)); + for (const feedback of source.feedback ?? []) { + const condition = feedbackConditionNode(exactSource, feedback, input.revision); + nodes.push(condition); + edges.push(edge({ teamId: source.teamId, fromNodeId: condition.id, toNodeId: authority.id, + provenance: 'governance', sourceRef }, input.revision)); } const workItems = rows(record(proposal.executionPlan).workItems); const actorByItem = new Map(); @@ -216,6 +253,8 @@ export function projectTeamExecutionGraph(input: { profile: profileFor(input.profiles, source.projectId, text(workItem.agentClass), 'acting'), revision: input.revision, pairRole: 'actor' }); nodes.push(actor); + edges.push(edge({ teamId: source.teamId, fromNodeId: authority.id, toNodeId: actor.id, + provenance: 'governance', sourceRef }, input.revision)); actorByItem.set(text(workItem.id), actor); if (text(workItem.review) === 'required') { const reviewer = workNode({ source: exactSource, workItem, diff --git a/src/api/capacity/services/capacity/assignments/context/review-result.ts b/src/api/capacity/services/capacity/assignments/context/review-result.ts index c7a1f788..611aa769 100644 --- a/src/api/capacity/services/capacity/assignments/context/review-result.ts +++ b/src/api/capacity/services/capacity/assignments/context/review-result.ts @@ -1,4 +1,5 @@ import { assignmentResultSchema } from '@treeseed/sdk/agent-capacity'; +import { createHash } from 'node:crypto'; import { validatePortableContentData } from '@treeseed/sdk/content-validation'; import { CapacityGovernanceError, type CapacityGovernanceDatabase } from '../../../../database.ts'; import type { DurableProviderAssignment } from '../../../../repositories/capacity/assignments/assignment.ts'; @@ -74,12 +75,14 @@ export async function resolveReviewDisposition(store: CapacityGovernanceDatabase export interface ProposalReviewResolution { disposition: 'approved' | 'rejected' | 'deferred'; reference: Extract['references'][number], { kind: 'treedx' }>; + sourceRef: { store: 'treedx'; model: 'decision'; id: string; revision: number; digest: string; + repository: string; commit: string; path: string }; } /** Validate proposal-review output without turning it into acting-work review. */ export async function resolveProposalReviewDisposition(store: CapacityGovernanceDatabase, assignment: DurableProviderAssignment, result: ReturnType, - readDecision: (reference: Extract['references'][number], { kind: 'treedx' }>) => Promise = async (reference) => { + readDecision: (reference: Extract['references'][number], { kind: 'treedx' }>) => Promise<{ frontmatter: Row; source: string } | null> = async (reference) => { const connection = await resolveKnowledgeGatewayConnection(store, { projectId: assignment.projectId, write: false, relationPaths: true, readRefs: [reference.commit], }); @@ -88,7 +91,7 @@ export async function resolveProposalReviewDisposition(store: CapacityGovernance ref: reference.commit, path: reference.path, encoding: 'utf8', parseFrontmatter: true, allowProtected: true })); if (text(response.resolvedRef) !== reference.commit) return null; const file = record(response.file ?? (Array.isArray(response.files) ? response.files[0] : null)); - return record(file.frontmatter); + return typeof file.content === 'string' ? { frontmatter: record(file.frontmatter), source: file.content } : null; }, ): Promise { const node = assignment.executionNodeId ? await store.first( @@ -98,13 +101,18 @@ export async function resolveProposalReviewDisposition(store: CapacityGovernance if (text(node?.kind) !== 'reviewing' || node?.pair_role != null) return null; const sourceRef = record(node?.source_ref_json); for (const reference of result.references.filter((candidate) => candidate.kind === 'treedx')) { - const parsed = validatePortableContentData('decision', await readDecision(reference)); + const content = await readDecision(reference); + if (!content) continue; + const parsed = validatePortableContentData('decision', content.frontmatter); if (!parsed.ok) continue; const decision = record(parsed.data); if (decision.decisionClass !== 'proposal' || decision.projectId !== assignment.projectId || stable(record(decision.subjectRef)) !== stable(sourceRef) || !['approved', 'rejected', 'deferred'].includes(text(decision.disposition))) continue; - return { disposition: decision.disposition as ProposalReviewResolution['disposition'], reference }; + return { disposition: decision.disposition as ProposalReviewResolution['disposition'], reference, + sourceRef: { store: 'treedx', model: 'decision', id: text(decision.id), revision: 1, + digest: `sha256:${createHash('sha256').update(content.source).digest('hex')}`, + repository: reference.repository, commit: reference.commit, path: reference.path } }; } throw new CapacityGovernanceError('proposal_review_decision_required', 'Reviewer completion requires one proposal decision bound to the exact proposal source.', 409, diff --git a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts index 2102334e..79d0291e 100644 --- a/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts +++ b/src/api/capacity/services/capacity/assignments/lifecycle/assignment-lifecycle-service.ts @@ -311,7 +311,7 @@ export class ProviderAssignmentLifecycleService { kind: proposalReview.disposition === 'approved' ? 'support' : 'concern', feedbackStatus: proposalReview.disposition === 'approved' ? 'resolved' : 'open', proposalVersion: assignment.assignmentAttempt?.sourceRef.revision, - decisionRef: proposalReview.reference, + decisionRef: proposalReview.sourceRef, }, }); } diff --git a/src/api/capacity/services/capacity/execution/executable-proposal-source.ts b/src/api/capacity/services/capacity/execution/executable-proposal-source.ts index 3347c2c8..e1450455 100644 --- a/src/api/capacity/services/capacity/execution/executable-proposal-source.ts +++ b/src/api/capacity/services/capacity/execution/executable-proposal-source.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; import { CapacityOperationError } from '../../../../control-plane/repositories/capacity/capacity-operation-error.ts'; -import { readExactProposal } from '../../../../governance/executable-proposal.ts'; +import { hasCompleteExecutablePlan, readExactProposal } from '../../../../governance/executable-proposal.ts'; import type { ExecutableProposalSource } from '../../../policy/execution/execution-graph-projector.ts'; +import { loadProposalBlockingFeedback } from './proposal-planning-source.ts'; type Row = Record; const record = (value: unknown): Row => { @@ -17,7 +18,7 @@ const stable = (value: unknown): string => { return JSON.stringify(value); }; -/** Load ready proposals for governance review and accepted proposals for work. */ +/** Load structurally complete drafts for review and accepted proposals for work. */ export async function loadTeamExecutableProposalSources(store: any, teamId: string, projectId?: string): Promise { const rows = await store.all(`SELECT p.id AS proposal_id,p.project_id,p.active_version,p.active_content_hash,p.metadata_json, @@ -46,9 +47,10 @@ export async function loadTeamExecutableProposalSources(store: any, teamId: stri throw Object.assign(new CapacityOperationError(Number(value.status ?? 409), value.code ?? 'proposal_execution_plan_invalid', error instanceof Error ? error.message : 'The accepted proposal could not be read.'), { diagnostics: (error as { diagnostics?: unknown }).diagnostics }); } - if (!accepted && exact.definition.status !== 'ready') continue; - if (accepted && exact.definition.status !== 'ready' && exact.definition.status !== 'decided') throw new CapacityOperationError( - 409, 'proposal_execution_status_invalid', 'An accepted decision references a proposal that was not ready for execution.'); + if (!accepted && (!['draft', 'discussing', 'ready'].includes(text(exact.definition.status)) + || !hasCompleteExecutablePlan(exact.definition))) continue; + if (accepted && (exact.definition.status === 'withdrawn' || !hasCompleteExecutablePlan(exact.definition))) throw new CapacityOperationError( + 409, 'proposal_execution_plan_invalid', 'An accepted decision references an incomplete or withdrawn execution plan.'); if (accepted && stable(recordedRef) !== stable(exact.ref)) throw new CapacityOperationError( 409, 'proposal_decision_ref_stale', 'The accepted decision does not match the current exact proposal revision.'); const selectedProjectId = text(row.project_id); @@ -56,6 +58,7 @@ export async function loadTeamExecutableProposalSources(store: any, teamId: stri teamId, projectId: selectedProjectId, repository: exact.ref.repository!, path: exact.ref.path!, commit: exact.ref.commit!, digest: exact.ref.digest!, proposalRevision: Number(accepted ? row.proposal_version : row.active_version), frontmatter: exact.definition, + feedback: await loadProposalBlockingFeedback(store, text(row.proposal_id)), decision: accepted ? { id: text(row.accepted_decision_id), revision: 1, digest: `sha256:${createHash('sha256').update(stable(decisionRecord)).digest('hex')}`, diff --git a/src/api/capacity/services/capacity/execution/proposal-planning-source.ts b/src/api/capacity/services/capacity/execution/proposal-planning-source.ts index a5e407a3..0e0518be 100644 --- a/src/api/capacity/services/capacity/execution/proposal-planning-source.ts +++ b/src/api/capacity/services/capacity/execution/proposal-planning-source.ts @@ -1,4 +1,5 @@ import type { GovernanceProposalReadiness } from '../../../../../governance/proposal-readiness.ts'; +import { exactEntityReferenceSchema, type ExactEntityReference } from '@treeseed/sdk/agent-capacity'; type Row = Record; @@ -39,6 +40,45 @@ export interface ProposalPlanningSource { }>; } +export interface BlockingProposalFeedback { + id: string; + kind: 'concern' | 'question'; + sourceRef: ExactEntityReference; + message: string; + contentPath: string; + commit: string; + digest: string; + resolved: boolean; +} + +/** Governance events index the current state of exact TreeDX feedback; they are + * not a second content store. Keep resolved entries so graph conditions can + * transition to completed instead of silently disappearing. */ +export async function loadProposalBlockingFeedback(store: any, proposalId: string): Promise { + const feedbackRows = await store.all(`SELECT id,message,evidence_json FROM governance_events + WHERE proposal_id = ? AND event_type = 'proposal.discussion' ORDER BY created_at ASC LIMIT 500`, [proposalId]); + const resolved = new Set(feedbackRows.flatMap((event: Row) => { + const evidence = record(event.evidence_json), ref = record(evidence.resolutionRef); + const valid = exactEntityReferenceSchema.safeParse(ref); + return text(evidence.resolvesEventId) && valid.success && valid.data.store === 'treedx' + && valid.data.model === 'discussion-message' ? [text(evidence.resolvesEventId)] : []; + })); + return feedbackRows.flatMap((event: Row) => { + const evidence = record(event.evidence_json), kind = text(evidence.kind); + if (!['concern', 'question'].includes(kind) || text(evidence.feedbackSeverity) === 'advisory') return []; + const parsed = exactEntityReferenceSchema.safeParse(evidence.questionRef ?? evidence.decisionRef); + if (!parsed.success || parsed.data.store !== 'treedx' || !['question', 'decision'].includes(parsed.data.model)) { + throw Object.assign(new Error('Blocking proposal feedback lacks exact TreeDX content authority.'), { + status: 409, code: 'proposal_feedback_source_ref_missing', feedbackId: text(event.id), + }); + } + return [{ id: text(event.id), kind: kind as BlockingProposalFeedback['kind'], message: text(event.message), + sourceRef: parsed.data, + contentPath: text(evidence.contentPath), commit: text(evidence.commitSha), digest: text(evidence.digest), + resolved: resolved.has(text(event.id)) }]; + }); +} + function strings(value: unknown): string[] { return Array.isArray(value) ? [...new Set(value.map(text).filter(Boolean))].sort() : []; } @@ -56,18 +96,8 @@ export async function loadTeamProposalPlanningSources(store: any, teamId: string if (!repositoryId || !path || !/^[a-f0-9]{40}$/u.test(commit) || !contentDigest) continue; const readiness = await store.governanceProposalReadiness(text(row.id)); if (!readiness) continue; - const feedbackRows = await store.all(`SELECT id,message,evidence_json FROM governance_events - WHERE proposal_id = ? AND event_type = 'proposal.discussion' ORDER BY created_at ASC LIMIT 500`, [text(row.id)]); - const resolved = new Set(feedbackRows.map((event: Row) => text(record(event.evidence_json).resolvesEventId)).filter(Boolean)); - const openBlockingFeedback = feedbackRows.flatMap((event: Row) => { - const evidence = record(event.evidence_json), kind = text(evidence.kind); - if (!['concern','question'].includes(kind) || text(evidence.feedbackSeverity) === 'advisory' - || ['resolved','withdrawn'].includes(text(evidence.feedbackStatus)) || resolved.has(text(event.id))) return []; - const contentPath = text(evidence.contentPath), feedbackCommit = text(evidence.commitSha), feedbackDigest = text(evidence.digest); - if (!contentPath || !/^[a-f0-9]{40}$/u.test(feedbackCommit) || !feedbackDigest) return []; - return [{ id: text(event.id), kind: kind as 'concern' | 'question', message: text(event.message), - contentPath, commit: feedbackCommit, digest: feedbackDigest }]; - }); + const openBlockingFeedback = (await loadProposalBlockingFeedback(store, text(row.id))) + .filter((feedback) => !feedback.resolved && feedback.contentPath && /^[a-f0-9]{40}$/u.test(feedback.commit) && feedback.digest); sources.push({ teamId: text(row.team_id), projectId: text(row.project_id), id: text(row.id), revision: Number(row.active_version), digest: text(row.active_content_hash), status: text(row.status), title: text(row.title), summary: text(row.summary), body: text(row.body), repositoryId, path, commit, contentDigest, createdById: text(row.created_by_id) || null, diff --git a/src/api/control-plane/governance/governance-service.ts b/src/api/control-plane/governance/governance-service.ts index afc9a155..b9cf1024 100644 --- a/src/api/control-plane/governance/governance-service.ts +++ b/src/api/control-plane/governance/governance-service.ts @@ -1,6 +1,9 @@ import { commitProposalVersionContent } from './proposal-version-content.ts'; +import { createHash } from 'node:crypto'; import { governanceContentHash } from '../../persistence/store.ts'; import { reconcileExecutionGraph } from '../repositories/capacity/execution/execution-graph-service.ts'; +import { resolveKnowledgeGatewayConnection } from '../../knowledge/gateway-treedx-connection.ts'; +import { exactEntityReferenceSchema } from '@treeseed/sdk/agent-capacity'; type Principal = { id: string; roles?: string[]; permissions?: string[]; metadata?: Record } | undefined; @@ -104,7 +107,7 @@ function approvalState(decision: string) { return 'rejected'; } -export function createGovernanceService(store: any) { +export function createGovernanceService(store: any, discussions: { create: (principal: Principal, body: Record, idempotencyKey: string) => Promise }) { return { async approvals(principal: Principal, projectId: string, query: Record) { const project = await projectFor(store, principal, projectId, 'projects:read:team'); @@ -197,21 +200,43 @@ export function createGovernanceService(store: any) { throw new GovernanceServiceError(404, 'proposal_feedback_not_found', 'Unknown blocking proposal feedback.'); } const existing = events.find((event: any) => event.evidence?.resolvesEventId === feedbackId); - if (existing) return { proposalId, feedbackId, resolution: existing, readiness: await store.governanceProposalReadiness(proposalId), idempotentReplay: true }; - const provenance = proposal.metadata?.contentProvenance ?? {}; - const contentPath = optionalText(provenance.contentPath) ?? optionalText(provenance.path); - const commitSha = optionalText(provenance.commitSha); - const digest = optionalText(provenance.digest) ?? optionalText(proposal.activeContentHash); - if (!contentPath || !commitSha || !digest) { - throw new GovernanceServiceError(409, 'proposal_feedback_resolution_provenance_missing', 'The current proposal revision lacks immutable TreeDX provenance.'); + if (existing) { + const ref = exactEntityReferenceSchema.safeParse(existing.evidence?.resolutionRef); + if (!ref.success || ref.data.store !== 'treedx' || ref.data.model !== 'discussion-message') { + throw new GovernanceServiceError(409, 'proposal_feedback_resolution_not_authoritative', + 'The feedback resolution lacks exact TreeDX content authority.'); + } + return { proposalId, feedbackId, resolution: existing, readiness: await store.governanceProposalReadiness(proposalId), idempotentReplay: true }; } try { + const authored = await discussions.create(principal, { teamId: proposal.teamId, projectId, + body: message, intent: 'discuss', topic: `Proposal ${proposalId} feedback`, + contextRefs: [], recipients: [] }, `proposal-feedback-resolution:${proposalId}:${feedbackId}:${proposal.activeVersion}`); + const contentPath = optionalText(authored.message?.path); + const commitSha = optionalText(authored.commitSha); + if (!contentPath || !commitSha) throw new GovernanceServiceError(503, + 'proposal_feedback_resolution_content_missing', 'The feedback resolution has no exact TreeDX message.'); + const connection = await resolveKnowledgeGatewayConnection(store, { projectId, write: false, + communicationPaths: true, readRefs: [commitSha] }); + if (!connection) throw new GovernanceServiceError(503, 'proposal_feedback_resolution_content_unavailable', + 'The feedback resolution cannot be read from TreeDX.'); + const read = record(await connection.client.readRepositoryFile({ repoId: connection.repositoryId, + ref: commitSha, path: contentPath, encoding: 'utf8', parseFrontmatter: false, allowProtected: true })); + const file = record(read.file ?? (Array.isArray(read.files) ? read.files[0] : null)); + if (read.resolvedRef !== commitSha || file.path !== contentPath || typeof file.content !== 'string') { + throw new GovernanceServiceError(503, 'proposal_feedback_resolution_readback_failed', + 'The exact TreeDX feedback resolution could not be verified.'); + } + const digest = `sha256:${createHash('sha256').update(file.content).digest('hex')}`; + const resolutionRef = { store: 'treedx', model: 'discussion-message', + id: optionalText(authored.message?.id) ?? contentPath, revision: 1, digest, + repository: connection.repositoryId, path: contentPath, commit: commitSha }; const resolution = await store.recordGovernanceEvent({ id: `proposal-feedback-resolution:${feedbackId}:${proposal.activeVersion}`, eventType: 'proposal.discussion', actorType: actorType(principal), actorId: principal!.id, teamId: proposal.teamId, projectId, proposalId, proposalVersion: proposal.activeVersion, message, evidence: { kind: 'response', feedbackSeverity: 'advisory', feedbackStatus: 'resolved', resolvesEventId: feedbackId, - contentPath, commitSha, digest, proposalVersion: proposal.activeVersion }, + contentPath, commitSha, digest, resolutionRef, proposalVersion: proposal.activeVersion }, }); await reconcileExecutionGraph(store, proposal.teamId, { projectId }, `proposal-feedback-resolution:${proposal.id}:${feedbackId}:${proposal.activeVersion}`); diff --git a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts index 46238178..9e28e62b 100644 --- a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts +++ b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts @@ -136,14 +136,16 @@ export function applyOperationalState(current: TeamGraph, projected: TeamGraph, const activeIds = new Set(projected.nodes.map((node) => node.id)); const nodes = projected.nodes.map((node) => { const prior = priorById.get(node.id); - const operational = prior && ['assigned', 'running', 'completed', 'failed', 'cancelled'].includes(prior.status); + const operational = prior && node.kind !== 'condition' + && ['assigned', 'running', 'completed', 'failed', 'cancelled'].includes(prior.status); if (operational) return { ...prior, graphRevisionUpdated: revision }; const semantic = (value: ExecutionNode) => { const { status: _status, nodeRevision: _nodeRevision, graphRevisionCreated: _created, graphRevisionUpdated: _updated, ...rest } = value; return rest; }; - const changed = prior && stable(semantic(prior)) !== stable(semantic(node)); + const changed = prior && (stable(semantic(prior)) !== stable(semantic(node)) + || (node.kind === 'condition' && prior.status !== node.status)); const nodeRevision = prior ? prior.status === 'stale' || changed ? prior.nodeRevision + 1 : Math.max(prior.nodeRevision, node.nodeRevision) : node.nodeRevision; @@ -159,6 +161,11 @@ export function applyOperationalState(current: TeamGraph, projected: TeamGraph, : { ...prior, status: 'stale', nodeRevision: prior.nodeRevision + 1, graphRevisionUpdated: revision }); } const activeNodes = new Map(nodes.map((node) => [node.id, node])); + for (const node of nodes.filter((candidate) => candidate.kind === 'condition' && candidate.status === 'completed')) { + const predecessors = projected.edges.filter((edge) => edge.toNodeId === node.id) + .map((edge) => activeNodes.get(edge.fromNodeId)); + if (predecessors.some((candidate) => candidate?.status !== 'completed')) node.status = 'blocked'; + } for (const node of nodes) { if (node.status === 'proposed' || node.status === 'stale' || ['assigned', 'running', 'completed', 'failed', 'cancelled'].includes(node.status)) continue; diff --git a/src/api/discussions/discussion-service.ts b/src/api/discussions/discussion-service.ts index 803d527f..4bff2aec 100644 --- a/src/api/discussions/discussion-service.ts +++ b/src/api/discussions/discussion-service.ts @@ -165,7 +165,7 @@ export function createDiscussionService(dependencies: { store: any; capacity: an communication: record(body.communication), addressRequirements: record(body.addressRequirements), durationSeconds: Math.max(60, Math.min(3600, Number(body.durationSeconds ?? 900))), requestedById: principal.id }); } - return { discussion: { id: discussionId }, message: replay, invocations, replayed: true }; + return { discussion: { id: discussionId }, message: replay, commitSha: text(replay.immutableRef), invocations, replayed: true }; } contextRefs = await validateDiscussionContextRefs({ store, projectId, teamId, values: body.contextRefs }); const existing = text(body.discussionId) ? await loadDiscussions({ store, projectId, discussionId, diff --git a/src/api/governance/executable-proposal.ts b/src/api/governance/executable-proposal.ts index 59bc6172..712741ae 100644 --- a/src/api/governance/executable-proposal.ts +++ b/src/api/governance/executable-proposal.ts @@ -11,6 +11,17 @@ const record = (value: unknown): Row => { }; const text = (...values: unknown[]): string => String(values.find((value) => typeof value === 'string' && value.trim()) ?? '').trim(); +/** A draft may enter the graph only after its work units have genuine bounded + * estimates. Readiness and source selection must use the same structural gate. */ +export function hasCompleteExecutablePlan(proposal: Row): boolean { + const plan = record(proposal.executionPlan); + return Array.isArray(plan.workItems) && plan.workItems.length > 0 && plan.workItems.every((value: unknown) => { + const item = record(value); + return Number(record(item.estimate).minimumSeconds) > 0 + && (item.review !== 'required' || Number(record(item.reviewEstimate).minimumSeconds) > 0); + }); +} + function repositoryFile(value: unknown): Row { const response = record(value); return record(response.file ?? (Array.isArray(response.files) ? response.files[0] : null)); diff --git a/src/api/store/governance/policy/contracts/governance-proposal-readiness.ts b/src/api/store/governance/policy/contracts/governance-proposal-readiness.ts index 059720eb..a2e98ed3 100644 --- a/src/api/store/governance/policy/contracts/governance-proposal-readiness.ts +++ b/src/api/store/governance/policy/contracts/governance-proposal-readiness.ts @@ -1,6 +1,7 @@ import type { GovernanceProposalReadiness } from '../../../../governance/proposal-readiness.ts'; import type { ControlPlaneStore } from '../../../../persistence/store.ts'; -import { readExactProposal } from '../../../../governance/executable-proposal.ts'; +import { hasCompleteExecutablePlan, readExactProposal } from '../../../../governance/executable-proposal.ts'; +import { loadProposalBlockingFeedback } from '../../../../capacity/services/capacity/execution/proposal-planning-source.ts'; type Row = Record; function record(value: unknown): Row { if (value && typeof value === 'object' && !Array.isArray(value)) return value as Row; if (typeof value === 'string') try { return record(JSON.parse(value)); } catch { return {}; } return {}; } @@ -11,19 +12,15 @@ export async function governanceProposalReadinessMethod(this: ControlPlaneStore, if (!proposal) return null; const proposalVersion = Number(proposal.activeVersion ?? 0); const events = await this.all(`SELECT id, actor_id, event_type, evidence_json FROM governance_events WHERE proposal_id = ? ORDER BY created_at ASC LIMIT 500`, [proposalId]); - const resolved = new Set(events.map((row) => text(record(row.evidence_json).resolvesEventId)).filter(Boolean)); const discussions = events.map((row) => ({ ...row, evidence: record(row.evidence_json) })).filter((row) => row.event_type === 'proposal.discussion'); - const blockers = discussions.filter((row) => ['question', 'concern'].includes(text(row.evidence.kind)) - && text(row.evidence.feedbackSeverity) !== 'advisory' - && !['resolved', 'withdrawn'].includes(text(row.evidence.feedbackStatus)) - && !resolved.has(text(row.id))); + const blockers = (await loadProposalBlockingFeedback(this, proposalId)).filter((feedback) => !feedback.resolved); const reviews = discussions.filter((row) => ['support', 'concern'].includes(text(row.evidence.kind)) && text(row.actor_id) !== text(proposal.createdById) && Number(row.evidence.proposalVersion) === proposalVersion); let executionPlanReady = false; let exactSourceProblem: string | null = null; if (proposal.projectId) try { const exact = await readExactProposal(this, proposal as unknown as Row); - executionPlanReady = exact.definition.status === 'ready' && Boolean(exact.definition.executionPlan); + executionPlanReady = exact.definition.status !== 'withdrawn' && hasCompleteExecutablePlan(exact.definition); } catch (error) { exactSourceProblem = text(record(error).code) || (error instanceof Error ? error.message : 'proposal_exact_source_invalid'); } diff --git a/src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts b/src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts index 03af7b25..13d0dacc 100644 --- a/src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts +++ b/src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { isoNow,ControlPlaneStore,serializeGovernanceDecision } from "../../../../persistence/store.ts"; import { resolveDecisionDependencySnapshots } from '../../../../governance/decision-authority.ts'; import { reconcileExecutionGraph } from '../../../../control-plane/repositories/capacity/execution/execution-graph-service.ts'; -import { readExactProposal } from '../../../../governance/executable-proposal.ts'; +import { hasCompleteExecutablePlan, readExactProposal } from '../../../../governance/executable-proposal.ts'; export async function createGovernanceDecisionFromProposalMethod(this: ControlPlaneStore, proposalId, input: any = {}) { await this.ensureInitialized(); const proposal = await this.getGovernanceProposal(proposalId); @@ -33,8 +33,8 @@ export async function createGovernanceDecisionFromProposalMethod(this: ControlPl error.status = 409; error.code = dependencyResult.validation.code; error.details = { dependency: dependencyResult.reference }; throw error; } const exact = await readExactProposal(this, proposal); - if (exact.definition.status !== 'ready' || !exact.definition.executionPlan) { - const error: Error & Record = new Error('An accepted decision requires one ready proposal-owned execution plan.'); + if (exact.definition.status === 'withdrawn' || !hasCompleteExecutablePlan(exact.definition)) { + const error: Error & Record = new Error('An accepted decision requires one complete proposal-owned execution plan.'); error.status = 409; error.code = 'governance_decision_execution_plan_required'; throw error; } const proposalRef = exact.ref; diff --git a/src/api/support/app.ts b/src/api/support/app.ts index 34996faa..80f57028 100644 --- a/src/api/support/app.ts +++ b/src/api/support/app.ts @@ -238,7 +238,7 @@ export function createPlatformApiApp(options: any = {}) { const knowledgeReader = createKnowledgeReaderService({ store, options }); const discussions = createDiscussionService({ store, capacity, sessionEvents }); const communications = createCommunicationService(capacity, discussions, store, diagnosticEnvelopes); - const governance = createGovernanceService(store); + const governance = createGovernanceService(store, discussions); const services = { ...createServiceConnectionService(store), ...createServiceCredentials(store) }; const inbox = createInboxService({ store, discussions, communications, governance }); const operations = createApiControlPlaneOperations({ store, capacity, services, diff --git a/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts b/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts index 48b59bdb..4f5885c7 100644 --- a/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/executable-proposal-source.test.ts @@ -1,11 +1,66 @@ import { describe, expect, it, vi } from 'vitest'; const exactProposal = vi.hoisted(() => vi.fn()); -vi.mock('../../../../../../src/api/governance/executable-proposal.ts', () => ({ readExactProposal: exactProposal })); +vi.mock('../../../../../../src/api/governance/executable-proposal.ts', async (importOriginal) => ({ + ...await importOriginal(), + readExactProposal: exactProposal, +})); import { loadTeamExecutableProposalSources } from '../../../../../../src/api/capacity/services/capacity/execution/executable-proposal-source.ts'; +import { loadProposalBlockingFeedback } from '../../../../../../src/api/capacity/services/capacity/execution/proposal-planning-source.ts'; + +const questionRef = { store: 'treedx', model: 'question', id: 'question', revision: 1, + digest: `sha256:${'c'.repeat(64)}`, repository: 'repository', commit: 'b'.repeat(40), path: 'questions/question.mdx' }; describe('executable proposal source selection', () => { + it('does not clear a graph blocker from a database-only resolution', async () => { + const all = vi.fn(async () => [ + { id: 'question', evidence_json: { kind: 'question', questionRef, feedbackStatus: 'open' } }, + { id: 'answer', evidence_json: { kind: 'response', resolvesEventId: 'question', feedbackStatus: 'resolved' } }, + ]); + await expect(loadProposalBlockingFeedback({ all }, 'proposal')).resolves.toMatchObject([ + { id: 'question', resolved: false }, + ]); + }); + it('refuses a blocking question with no exact TreeDX source', async () => { + const all = vi.fn(async () => [{ id: 'question', evidence_json: { kind: 'question' } }]); + await expect(loadProposalBlockingFeedback({ all }, 'proposal')).rejects.toMatchObject({ + code: 'proposal_feedback_source_ref_missing', feedbackId: 'question', + }); + }); + it('clears a graph blocker only from an exact TreeDX resolution reference', async () => { + const all = vi.fn(async () => [ + { id: 'question', evidence_json: { kind: 'question', questionRef, feedbackStatus: 'open' } }, + { id: 'answer', evidence_json: { kind: 'response', resolvesEventId: 'question', resolutionRef: { + store: 'treedx', model: 'discussion-message', id: 'answer', revision: 1, repository: 'repository', + path: 'discussion-messages/answer.mdx', commit: 'b'.repeat(40), digest: `sha256:${'c'.repeat(64)}`, + } } }, + ]); + await expect(loadProposalBlockingFeedback({ all }, 'proposal')).resolves.toMatchObject([ + { id: 'question', resolved: true }, + ]); + }); + it('includes a complete draft and preserves governed feedback transitions', async () => { + const all = vi.fn(async (query: string) => query.includes('FROM governance_events') ? [{ + id: 'question-1', message: 'Resolve the boundary.', evidence_json: { kind: 'question', questionRef, feedbackStatus: 'open' }, + }] : [{ proposal_id: 'proposal', project_id: 'project', active_version: 1, + active_content_hash: 'a'.repeat(64), metadata_json: {}, decision_id: null }]); + exactProposal.mockResolvedValueOnce({ ref: { repository: 'treeseed-ai/sdk', path: 'proposals/one.md', + commit: 'b'.repeat(40), digest: `sha256:${'a'.repeat(64)}` }, definition: { + status: 'draft', executionPlan: { workItems: [{ estimate: { minimumSeconds: 10 }, + review: 'required', reviewEstimate: { minimumSeconds: 5 } }] }, + } }); + const sources = await loadTeamExecutableProposalSources({ all }, 'team', 'project'); + expect(sources).toHaveLength(1); + expect(sources[0]).toMatchObject({ decision: null, feedback: [{ id: 'question-1', resolved: false }] }); + }); + + it('keeps an incomplete draft in planning rather than executable graph demand', async () => { + const all = vi.fn(async () => [{ proposal_id: 'proposal', project_id: 'project', active_version: 1, + active_content_hash: 'a'.repeat(64), metadata_json: {}, decision_id: null }]); + exactProposal.mockResolvedValueOnce({ ref: {}, definition: { status: 'draft', executionPlan: { workItems: [{ review: 'required' }] } } }); + await expect(loadTeamExecutableProposalSources({ all }, 'team', 'project')).resolves.toEqual([]); + }); it('keeps pre-cutover accepted decisions as history without reading them as demand', async () => { const all = vi.fn(async () => [{ proposal_id: 'legacy-proposal', project_id: 'project', active_version: 1, diff --git a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts index 2fa6ef18..1704150a 100644 --- a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph-persistence.test.ts @@ -108,6 +108,24 @@ describe('normalized living execution graph persistence', () => { ]); }); + it('reopens a completed feedback condition and blocks dependent work', () => { + const condition = { ...node('completed'), id: 'question', kind: 'condition' as const, pairRole: null, + workItemId: undefined, agentClass: undefined, estimate: undefined, requiredCapabilities: undefined, + requestedPermissions: undefined, workspace: undefined, + condition: { conditionType: 'question' as const, subjectRef: sourceRef, expectedState: 'feedback:q:resolved' } }; + const actor = { ...node('ready'), id: 'actor' }; + const current = { ...graph(1, [condition, actor]), edges: [{ + schemaVersion: 'treeseed.execution-edge/v1' as const, id: 'condition-edge', teamId: 'team', + fromNodeId: condition.id, toNodeId: actor.id, provenance: 'governance' as const, + graphRevisionCreated: 1, + }] }; + const projected = { ...graph(2, [{ ...condition, status: 'blocked' as const }, { ...actor, status: 'blocked' as const }]), + edges: current.edges }; + const result = applyOperationalState(current, projected, 2); + expect(result.nodes.find((entry) => entry.id === 'question')).toMatchObject({ status: 'blocked', nodeRevision: 2 }); + expect(result.nodes.find((entry) => entry.id === 'actor')?.status).toBe('blocked'); + }); + it('revises an unassigned node when projected assignment semantics change', () => { const current = graph(1, [node('ready')]); const projected = { ...node('blocked'), estimate: { minimumSeconds: 1, expectedSeconds: 5, maximumSeconds: 30 } }; diff --git a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts index 375ddcfb..df3eaf6a 100644 --- a/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/living-execution-graph.test.ts @@ -4,6 +4,7 @@ import { projectTeamExecutionGraph, type ExecutableProposalSource, } from '../../../../../../src/api/capacity/policy/execution/execution-graph-projector.ts'; +import { applyOperationalState } from '../../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts'; const permissions = { content: { read: ['proposal', 'decision', 'knowledge'] as const, write: [] }, @@ -94,7 +95,7 @@ function source(items = [ describe('proposal-owned living execution graph projection', () => { it('derives stable actor and reviewer nodes from each accepted work item', () => { const graph = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [source()], profiles, createdAt: '2026-09-13T12:00:00.000Z' }); - expect(graph.nodes).toHaveLength(6); + expect(graph.nodes).toHaveLength(8); expect(graph.nodes.filter((node) => node.pairRole === 'actor')).toHaveLength(3); expect(graph.nodes.filter((node) => node.pairRole === 'reviewer')).toHaveLength(3); expect(graph.edges.filter((edge) => edge.provenance === 'review-pair')).toHaveLength(3); @@ -113,8 +114,12 @@ describe('proposal-owned living execution graph projection', () => { expect.objectContaining({ fromNodeId: reviewer('tests').id, toNodeId: actor('implementation').id, provenance: 'work-item' }), expect.objectContaining({ fromNodeId: reviewer('tests').id, toNodeId: actor('implementation').id, provenance: 'profile-agent' }), ])); - expect(actor('architecture').status).toBe('ready'); + expect(actor('architecture').status).toBe('blocked'); expect(actor('tests').status).toBe('blocked'); + expect(graph.edges).toContainEqual(expect.objectContaining({ + fromNodeId: graph.nodes.find((node) => node.condition?.conditionType === 'authority')?.id, + toNodeId: actor('architecture').id, provenance: 'governance', + })); }); it('projects an exact cross-project TreeDX link from approved reviewer to dependent actor', () => { @@ -151,16 +156,55 @@ describe('proposal-owned living execution graph projection', () => { expect(JSON.stringify(first)).not.toMatch(/providerId|outputType|produces|artifactManifest|sourceCandidate/u); }); - it('projects exactly one proposal Reviewer without current decision authority', () => { + it('projects review and proposed work without granting draft acting authority', () => { const candidate = source(); candidate.decision = null; candidate.frontmatter.status = 'ready'; const graph = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [candidate], profiles }); - expect(graph.nodes).toEqual([expect.objectContaining({ kind: 'reviewing', pairRole: null, + expect(graph.nodes).toContainEqual(expect.objectContaining({ kind: 'reviewing', pairRole: null, agentClass: 'reviewer', status: 'ready', workspace: 'treedx', sourceRef: expect.objectContaining({ id: 'agent-runtime' }), requiredCapabilities: ['treeseed.engineering.review'], - estimate: { minimumSeconds: 30, expectedSeconds: 60, maximumSeconds: 90 } })]); - expect(graph.edges).toEqual([]); + estimate: { minimumSeconds: 30, expectedSeconds: 60, maximumSeconds: 90 } })); + expect(graph.nodes.filter((node) => node.pairRole === 'actor').every((node) => node.status === 'proposed')).toBe(true); + expect(graph.nodes).toContainEqual(expect.objectContaining({ kind: 'condition', status: 'blocked', + condition: expect.objectContaining({ conditionType: 'authority', expectedState: 'accepted' }) })); + }); + + it('retains resolved feedback conditions and blocks acting until questions and decision are complete', () => { + const candidate = source(); + candidate.decision = null; + candidate.frontmatter.status = 'draft'; + const questionRef = { store: 'treedx' as const, model: 'question', id: 'question-1', revision: 1, + digest: `sha256:${'d'.repeat(64)}`, repository: candidate.repository, + commit: candidate.commit, path: 'questions/question-1.mdx' }; + candidate.feedback = [{ id: 'question-1', kind: 'question', resolved: false, sourceRef: questionRef }]; + const draft = projectTeamExecutionGraph({ teamId: 'team', revision: 1, sources: [candidate], profiles }); + const question = draft.nodes.find((node) => node.condition?.conditionType === 'question')!; + const authority = draft.nodes.find((node) => node.condition?.conditionType === 'authority')!; + const review = draft.nodes.find((node) => node.kind === 'reviewing' && !node.pairRole)!; + expect(question.status).toBe('blocked'); + expect(review.status).toBe('ready'); + expect(draft.edges).toContainEqual(expect.objectContaining({ fromNodeId: question.id, toNodeId: authority.id })); + const resolved = projectTeamExecutionGraph({ teamId: 'team', revision: 2, + sources: [{ ...candidate, feedback: [{ id: 'question-1', kind: 'question', resolved: true, sourceRef: questionRef }] }], profiles }); + expect(resolved.nodes.find((node) => node.id === question.id)?.status).toBe('completed'); + expect(resolved.nodes.find((node) => node.id === authority.id)?.status).toBe('blocked'); + const accepted = projectTeamExecutionGraph({ teamId: 'team', revision: 3, + sources: [{ ...candidate, decision: source().decision, feedback: [{ id: 'question-1', kind: 'question', resolved: true, sourceRef: questionRef }] }], profiles }); + expect(accepted.nodes.find((node) => node.id === question.id)?.status).toBe('completed'); + expect(accepted.nodes.find((node) => node.id === authority.id)?.status).toBe('completed'); + const state = (projection: typeof draft) => ({ teamId: 'team', revision: projection.revision.revision, + digest: projection.revision.graphDigest, nodes: projection.nodes, edges: projection.edges }); + const afterResolution = applyOperationalState(state(draft), state(resolved), 2); + const afterDecision = applyOperationalState(afterResolution, state(accepted), 3); + const architecture = afterDecision.nodes.find((node) => node.workItemId === 'architecture' && node.pairRole === 'actor')!; + expect(afterResolution.nodes.find((node) => node.id === architecture.id)?.status).toBe('proposed'); + expect(architecture.status).toBe('ready'); + const reopened = projectTeamExecutionGraph({ teamId: 'team', revision: 4, + sources: [{ ...candidate, decision: source().decision, feedback: [{ id: 'question-1', kind: 'question', resolved: false, sourceRef: questionRef }] }], profiles }); + const afterReopen = applyOperationalState(afterDecision, state(reopened), 4); + expect(afterReopen.nodes.find((node) => node.id === authority.id)?.status).toBe('blocked'); + expect(afterReopen.nodes.find((node) => node.id === architecture.id)?.status).toBe('blocked'); }); it('keeps work blocked when a standing agent dependency has no concrete work item', () => { diff --git a/tests/unit/control-plane/capacity/execution/review-result.test.ts b/tests/unit/control-plane/capacity/execution/review-result.test.ts index fd5804cc..ed5760e0 100644 --- a/tests/unit/control-plane/capacity/execution/review-result.test.ts +++ b/tests/unit/control-plane/capacity/execution/review-result.test.ts @@ -60,10 +60,14 @@ describe('review result authority', () => { const proposalDecision = { ...decision, decisionClass: 'proposal', disposition: 'approved', subjectRef: proposalRef }; await expect(resolveProposalReviewDisposition(store as never, { id: 'review-assignment', teamId: 'team', projectId: 'project', executionNodeId: 'reviewer', executionNodeRevision: 1 } as never, - result, async () => proposalDecision)).resolves.toEqual({ disposition: 'approved', reference: result.references[0] }); + result, async () => ({ frontmatter: proposalDecision, source: 'exact decision bytes' }))).resolves.toMatchObject({ + disposition: 'approved', reference: result.references[0], + sourceRef: { store: 'treedx', model: 'decision', id: proposalDecision.id, + repository: result.references[0].repository, commit: result.references[0].commit }, + }); await expect(resolveProposalReviewDisposition(store as never, { id: 'review-assignment', teamId: 'team', projectId: 'project', executionNodeId: 'reviewer', executionNodeRevision: 1 } as never, - result, async () => ({ ...proposalDecision, subjectRef: { ...proposalRef, revision: 1 } }))) + result, async () => ({ frontmatter: { ...proposalDecision, subjectRef: { ...proposalRef, revision: 1 } }, source: 'other bytes' }))) .rejects.toMatchObject({ code: 'proposal_review_decision_required' }); }); }); diff --git a/tests/unit/control-plane/governance-service.test.ts b/tests/unit/control-plane/governance-service.test.ts index 925feaba..2f862e5e 100644 --- a/tests/unit/control-plane/governance-service.test.ts +++ b/tests/unit/control-plane/governance-service.test.ts @@ -4,6 +4,11 @@ import { commitProposalVersionContent } from '../../../src/api/control-plane/gov vi.mock('../../../src/api/control-plane/governance/proposal-version-content.ts', () => ({ commitProposalVersionContent: vi.fn() })); vi.mock('../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', () => ({ reconcileExecutionGraph: vi.fn(async () => ({ replayed: false })) })); +vi.mock('../../../src/api/knowledge/gateway-treedx-connection.ts', () => ({ resolveKnowledgeGatewayConnection: vi.fn(async () => ({ + repositoryId: 'repository', client: { readRepositoryFile: async ({ ref, path }: { ref: string; path: string }) => ({ + resolvedRef: ref, file: { path, content: '---\ntitle: Resolution\n---\nResolved.' }, + }) }, +})) })); function fixture(proposalProjectId = 'project-1') { const store = { @@ -20,7 +25,9 @@ function fixture(proposalProjectId = 'project-1') { listApprovalRequestsForProject: vi.fn(async () => []), decideApprovalRequest: vi.fn(async (_id, input) => ({ id: 'approval-1', state: input.state, decision: input.decision })), }; - return { store, service: createGovernanceService(store), principal: { id: 'user-1', roles: ['member'] } }; + const discussions = { create: vi.fn(async () => ({ message: { id: 'resolution', path: 'discussion-messages/resolution.mdx' }, + commitSha: 'b'.repeat(40) })) }; + return { store, discussions, service: createGovernanceService(store, discussions), principal: { id: 'user-1', roles: ['member'] } }; } describe('governance service mutation boundaries', () => { @@ -69,9 +76,19 @@ describe('governance service mutation boundaries', () => { expect(result).toMatchObject({ proposalId: 'proposal-1', feedbackId: 'feedback-1', idempotentReplay: false }); expect(store.recordGovernanceEvent).toHaveBeenCalledWith(expect.objectContaining({ proposalVersion: 3, evidence: expect.objectContaining({ kind: 'response', feedbackStatus: 'resolved', - resolvesEventId: 'feedback-1', contentPath: 'proposals/test.mdx', commitSha: 'a'.repeat(40), digest: 'digest-3' }), + resolvesEventId: 'feedback-1', contentPath: 'discussion-messages/resolution.mdx', commitSha: 'b'.repeat(40), + resolutionRef: expect.objectContaining({ model: 'discussion-message', repository: 'repository' }) }), })); }); + it('does not clear feedback when TreeDX authoring fails', async () => { + const { store, discussions, service, principal } = fixture(); + discussions.create.mockRejectedValueOnce(new Error('TreeDX unavailable')); + await expect(service.resolveProposalFeedback(principal, 'project-1', 'proposal-1', 'feedback-1', + { message: 'Resolved.', expectedProposalVersion: 3 }, '3')).rejects.toMatchObject({ + code: 'governance_proposal_feedback_resolution_failed', + }); + expect(store.recordGovernanceEvent).not.toHaveBeenCalled(); + }); it('checks project ownership before any proposal mutation', async () => { const { store, service, principal } = fixture('project-2'); diff --git a/tests/unit/control-plane/governance/draft-graph-readiness.test.ts b/tests/unit/control-plane/governance/draft-graph-readiness.test.ts new file mode 100644 index 00000000..50e905a3 --- /dev/null +++ b/tests/unit/control-plane/governance/draft-graph-readiness.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/api/governance/executable-proposal.ts', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, readExactProposal: vi.fn(async () => ({ definition: { + status: 'draft', executionPlan: { workItems: [{ estimate: { minimumSeconds: 10 }, + review: 'required', reviewEstimate: { minimumSeconds: 5 } }] }, + } })) }; +}); +vi.mock('../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', + () => ({ reconcileExecutionGraph: vi.fn(async () => undefined) })); + +import { governanceProposalReadinessMethod } from '../../../../src/api/store/governance/policy/contracts/governance-proposal-readiness.ts'; +import { createGovernanceDecisionFromProposalMethod } from '../../../../src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts'; + +describe('draft proposal graph readiness', () => { + it('uses the exact complete draft and independent Reviewer result without a second ready-content revision', async () => { + const store = { + getGovernanceProposal: async () => ({ id: 'proposal', projectId: 'project', activeVersion: 1, createdById: 'author' }), + all: async () => [{ id: 'review', actor_id: 'reviewer', event_type: 'proposal.discussion', + evidence_json: { kind: 'support', proposalVersion: 1 } }], + }; + const readiness = await governanceProposalReadinessMethod.call(store as never, 'proposal'); + expect(readiness).toMatchObject({ executionPlanReady: true, independentReviewCount: 1, + unresolvedBlockerCount: 0, votingReady: true }); + }); + it('accepts the same reviewed draft version as decision authority', async () => { + const proposal = { id: 'proposal', teamId: 'team', projectId: 'project', activeVersion: 1, + activeContentHash: 'digest', metadata: {}, governanceProviderId: 'default' }; + const run = vi.fn(async () => undefined); + const store = { ensureInitialized: async () => undefined, getGovernanceProposal: async () => proposal, + getGovernanceDecision: async () => ({ id: 'decision' }), first: async () => null, + effectiveGovernanceVotes: async () => [], run, recordGovernanceEvent: async () => undefined }; + await expect(createGovernanceDecisionFromProposalMethod.call(store as never, proposal.id)) + .resolves.toEqual({ id: 'decision' }); + expect(run).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO governance_decisions'), expect.any(Array)); + }); +}); From 029090d66477fd0d8a8011ae33633cd89230778b Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 20 Sep 2026 23:49:33 -0400 Subject: [PATCH 4/8] Cut over governed agent content contracts --- ...40_remove_retired_allocation_authority.sql | 22 +++++- src/api/content/content-validation.ts | 5 +- src/api/control-plane/inbox/inbox-service.ts | 2 +- .../knowledge/knowledge-workspace-service.ts | 71 +++++++++++++++---- src/api/discussions/content.ts | 49 +++++++++---- src/api/discussions/discussion-service.ts | 6 +- src/api/knowledge/runtime/authoring.ts | 7 +- src/api/knowledge/runtime/catalog.ts | 20 ++++-- src/api/teams/managed-team-library-service.ts | 49 ++++++++----- .../retired-allocation-removal.test.ts | 20 +++--- .../discussion-operations.test.ts | 6 +- .../discussion-content-cutover.test.ts | 51 +++++++++++++ .../discussion-targeted-read.test.ts | 2 +- .../knowledge/workspaces/book-repin.test.ts | 43 +++++++++++ .../workspaces/objective-workspace.test.ts | 2 +- .../managed-team-library-content.test.ts | 9 ++- 16 files changed, 292 insertions(+), 72 deletions(-) create mode 100644 tests/unit/control-plane/discussions/discussion-content-cutover.test.ts create mode 100644 tests/unit/control-plane/knowledge/workspaces/book-repin.test.ts diff --git a/drizzle/control-plane/0040_remove_retired_allocation_authority.sql b/drizzle/control-plane/0040_remove_retired_allocation_authority.sql index 39405fc7..e73c6aad 100644 --- a/drizzle/control-plane/0040_remove_retired_allocation_authority.sql +++ b/drizzle/control-plane/0040_remove_retired_allocation_authority.sql @@ -2,14 +2,30 @@ -- allocation sets and decision graphs must not remain writable authorities. DO $$ BEGIN - IF EXISTS (SELECT 1 FROM capacity_workday_runs WHERE status IN ('queued', 'running', 'degraded')) THEN - RAISE EXCEPTION 'drain active workdays before removing retired allocation authority'; - END IF; IF EXISTS (SELECT 1 FROM capacity_provider_assignments WHERE status IN ('pending', 'leased', 'running')) THEN RAISE EXCEPTION 'drain active assignments before removing retired allocation authority'; END IF; END $$; +-- With no executable assignment left, these rows cannot make further progress. +-- Settle the one-time architecture cutover explicitly instead of leaving the +-- API unable to start merely to call its ordinary stop operation. +UPDATE capacity_reservations +SET state = 'released', + released_seconds = GREATEST(released_seconds, reserved_seconds - active_seconds), + updated_at = CURRENT_TIMESTAMP::text +WHERE state IN ('reserved', 'consuming', 'continuation_required') + AND work_day_id IN ( + SELECT id FROM capacity_workday_runs WHERE status IN ('queued', 'running', 'degraded') + ); + +UPDATE capacity_workday_runs +SET status = 'cancelled', + completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP::text), + updated_at = CURRENT_TIMESTAMP::text, + error_json = '{"code":"architecture_contract_cutover"}' +WHERE status IN ('queued', 'running', 'degraded'); + ALTER TABLE capacity_provider_assignments DROP CONSTRAINT IF EXISTS fk_capacity_provider_assignments_allocation; ALTER TABLE capacity_reservations DROP CONSTRAINT IF EXISTS fk_capacity_reservations_allocation; ALTER TABLE capacity_reservations DROP CONSTRAINT IF EXISTS chk_capacity_reservations_allocation_version; diff --git a/src/api/content/content-validation.ts b/src/api/content/content-validation.ts index eb4e80e6..55a365d6 100644 --- a/src/api/content/content-validation.ts +++ b/src/api/content/content-validation.ts @@ -27,7 +27,10 @@ export function validateContentRecord(model: ContentModel, source: string): Cont }], }; } - return validateContentFrontmatter(model, parseFrontmatterDocument(source).frontmatter); + const document = parseFrontmatterDocument(source); + return validateContentFrontmatter(model, model === 'discussion_message' + ? { ...document.frontmatter, body: document.body.trim() } + : document.frontmatter); } catch (error) { return { ok: false, diff --git a/src/api/control-plane/inbox/inbox-service.ts b/src/api/control-plane/inbox/inbox-service.ts index 3e4bca8e..3e7cf6bf 100644 --- a/src/api/control-plane/inbox/inbox-service.ts +++ b/src/api/control-plane/inbox/inbox-service.ts @@ -57,7 +57,7 @@ export function createInboxService(input: { store: any; discussions: any; commun if (!row.discussion_id) return []; const history = await loadDiscussions({ store, projectId: row.project_id, discussionId: row.discussion_id, collection: 'messages', limit: 500 }).catch(() => ({ messages: [] })); return history.messages.map((message: Row) => { const fm = record(message.frontmatter); return { id: text(message.id), parentId: text(fm.replyTo) || null, - kind: text(fm.inboxIntent, text(fm.intent) === 'answer' ? 'answer' : fm.replyTo ? 'reply' : 'comment'), authorId: text(fm.authorId,'unknown'), authorLabel: text(message.authorLabel,text(fm.authorId,'unknown')), + kind: text(fm.inboxIntent, text(fm.intent) === 'answer' ? 'answer' : fm.replyToRef ? 'reply' : 'comment'), authorId: text(fm.authorId,'unknown'), authorLabel: text(message.authorLabel,text(fm.authorId,'unknown')), markdown: text(message.body), createdAt: timestamp(fm.createdAt), provenance: { repositoryId: null, contentPath: text(message.path)||null, commitSha: text(fm.commitSha)||null, digest: createHash('sha256').update(text(message.body)).digest('hex') } }; }); } async function syncAgentAnswer(row: Row) { diff --git a/src/api/control-plane/knowledge/knowledge-workspace-service.ts b/src/api/control-plane/knowledge/knowledge-workspace-service.ts index 89eaabba..9217944c 100644 --- a/src/api/control-plane/knowledge/knowledge-workspace-service.ts +++ b/src/api/control-plane/knowledge/knowledge-workspace-service.ts @@ -4,6 +4,7 @@ import { treeDxWorkspaceId } from '../../knowledge/workspaces/identity.ts'; import { applyTextChangeset } from '../../knowledge/changesets/apply-text-changeset.ts'; import { parseBook, parseKnowledgePage } from '../../knowledge/runtime/catalog.ts'; import { requireKnowledgePageBookPath } from '../../knowledge/snapshot-projects.ts'; +import { listKnowledgeContentPaths } from '../../knowledge/read-model/repository-paths.ts'; import { serializeBookDraft, serializeKnowledgePageDraft } from '../../knowledge/runtime/authoring.ts'; import { projectTreeDxCommitSignals } from '../../capacity/services/treedx/repositories/treedx-change-projector.ts'; import { recordTreeDxAuthoringState } from '../../capacity/services/treedx/repositories/treedx-authoring-journal.ts'; @@ -128,7 +129,7 @@ export function createKnowledgeWorkspaceService(store: any, reader: { projectCat if (!['draft', 'changes-requested'].includes(access.workspace.status)) throw new KnowledgeOperationError(409, 'knowledge_workspace_locked', 'This draft is locked while it is in review or publication.'); if (Number(input.version) !== access.workspace.version) throw new KnowledgeOperationError(409, 'stale_workspace', 'The draft changed. Reload before saving.'); const connection = await resolveKnowledgeGatewayConnection(store, { projectId: access.workspace.projectId, - write: true, workspaceRefs: [access.workspace.branchName], authoringPaths: true }); + write: true, workspaceRefs: [access.workspace.branchName, access.workspace.baseCommitSha], authoringPaths: true }); if (!connection) throw new KnowledgeOperationError(503, 'knowledge_repository_unavailable', 'The project knowledge repository is unavailable.'); const sourcePath = text(input.sourcePath); if (input.kind === 'proposal-type') { @@ -196,7 +197,7 @@ export function createKnowledgeWorkspaceService(store: any, reader: { projectCat targetType: 'agent_profile', targetId: sourcePath, data: { workspaceId, projectId: access.workspace.projectId, path: sourcePath } }); return { result, workspace: updated.workspace }; } - let before: string | null = null, status: 'published' | 'archived' = 'published'; + let before: string | null = null, status: 'published' | 'archived' = 'published', bookRevision = 0; if (sourcePath) { const current = await connection.client.readFile({ workspaceId: access.workspace.treeDxWorkspaceId, path: sourcePath }); before = current.content; @@ -204,6 +205,7 @@ export function createKnowledgeWorkspaceService(store: any, reader: { projectCat const currentDefinition = input.kind === 'book' ? parseBook({ path: sourcePath, raw: current.content }) : parseKnowledgePage({ path: sourcePath, raw: current.content }); status = currentDefinition.status === 'archived' ? 'archived' : 'published'; + if (input.kind === 'book' && 'revision' in currentDefinition) bookRevision = currentDefinition.revision; } catch { // Invalid historical documents may only be replaced by a complete draft // that passes the current serializer below. No compatibility projection @@ -211,20 +213,54 @@ export function createKnowledgeWorkspaceService(store: any, reader: { projectCat status = 'published'; } } + let contentInput=input; + if(input.kind==='page'&&!input.bookRef) { + const selectedBookId=text(input.bookId); + if(!selectedBookId)throw new KnowledgeOperationError(422,'knowledge_book_required','Choose a Book for this page.'); + const catalog=await reader.projectCatalog(access.principal,access.workspace.projectId); + const selectedBook=(catalog.books??[]).find((entry:any)=>entry.id===selectedBookId); + const selectedPath=text(selectedBook?.source?.path); + if(!selectedPath)throw new KnowledgeOperationError(422,'knowledge_book_unavailable','The selected Book is unavailable. Publish it before adding pages.'); + const bookFile=await connection.client.readFile({workspaceId:access.workspace.treeDxWorkspaceId,path:selectedPath}); + const book=parseBook({path:selectedPath,raw:bookFile.content}); + if(book.id!==selectedBookId||book.projectId!==access.workspace.projectId)throw new KnowledgeOperationError(409,'knowledge_book_moved','The selected Book changed. Reload before saving.'); + contentInput={...input,bookRef:{store:'treedx',model:'book',id:book.id,path:selectedPath,revision:book.revision,digest:book.sourceDigest}}; + } let content; - try { content = input.kind === 'book' ? bookDocument(input, status) : pageDocument(input, status); } + try { content = input.kind === 'book' + ? bookDocument(contentInput, access.workspace.projectId, status, bookRevision + 1) + : pageDocument(contentInput, access.workspace.projectId, status); } catch (error) { throw new KnowledgeOperationError(422, 'invalid_knowledge_content', error instanceof Error ? error.message : 'Invalid knowledge content.'); } const slug = text(input.slug); const derivedPath = input.kind === 'book' ? projectLibraryPath(connection.contentPath, 'books', `${slug}.md`) - : projectLibraryPath(connection.contentPath, 'knowledge', text(input.bookId), `${slug}.md`); + : projectLibraryPath(connection.contentPath, 'knowledge', text((contentInput.bookRef as Record | undefined)?.id), `${slug}.md`); const move = Boolean(sourcePath && sourcePath !== derivedPath && input.move === true); const path = move ? derivedPath : sourcePath || derivedPath; if (!allowedKnowledgePath(access.workspace, path)) throw new KnowledgeOperationError(422, 'knowledge_path_invalid', 'The knowledge path is outside this workspace.'); if (sourcePath && sourcePath !== derivedPath && !move) throw new KnowledgeOperationError(422, 'knowledge_path_move_required', 'Changing a knowledge path requires an explicit move operation.'); + const pageChanges: Array<{path:string;before:string;after:string}> = []; + if(input.kind==='book'&&sourcePath) { + const nextBook=parseBook({path,raw:content}); + const pagesRoot=`${projectLibraryPath(connection.contentPath,'knowledge',nextBook.id)}/`; + const listed=await listKnowledgeContentPaths(connection,access.workspace.baseCommitSha); + const workspaceStatus=await connection.client.status({workspaceId:access.workspace.treeDxWorkspaceId}); + const deleted=new Set((workspaceStatus.changes??[]).filter((change:any)=>change.status==='deleted').map((change:any)=>String(change.path))); + const pagePaths=new Set([ + ...(listed.entries??[]).map((entry:any)=>String(entry.path??'')), + ...(workspaceStatus.changes??[]).map((change:any)=>String(change.path??'')), + ].filter((candidate)=>candidate.startsWith(pagesRoot)&&/\.mdx?$/u.test(candidate)&&!deleted.has(candidate))); + for(const pagePath of [...pagePaths].sort()) { + const original=(await connection.client.readFile({workspaceId:access.workspace.treeDxWorkspaceId,path:pagePath})).content; + const page=parseKnowledgePage({path:pagePath,raw:original}); + if(page.bookRef.id!==nextBook.id||page.projectId!==nextBook.projectId)throw new KnowledgeOperationError(422,'knowledge_book_page_mismatch','The Book has a page with different ownership.'); + const updated=serializeKnowledgePageDraft({...page,bookRef:{...page.bookRef,path,revision:nextBook.revision,digest:nextBook.sourceDigest!}}); + if(updated!==original)pageChanges.push({path:pagePath,before:original,after:updated}); + } + } const result = await applyTextChangeset({ client: connection.client, workspace: { workspaceId: access.workspace.treeDxWorkspaceId, baseCommitSha: access.workspace.baseCommitSha, baseRef: access.workspace.baseRef }, changes: move - ? [{ path: sourcePath, before, after: null }, { path, before: null, after: content }] - : [{ path, before, after: content }], + ? [{ path: sourcePath, before, after: null }, { path, before: null, after: content }, ...pageChanges] + : [{ path, before, after: content }, ...pageChanges], idempotencyKey: `knowledge-content-${workspaceId}-${access.workspace.version}` }); const updated = await store.updateKnowledgeWorkspace(workspaceId, { version: access.workspace.version, status: 'draft' }); if (!updated.ok) throw new KnowledgeOperationError(409, 'stale_workspace', 'The draft changed. Reload before saving.'); @@ -261,6 +297,13 @@ export function createKnowledgeWorkspaceService(store: any, reader: { projectCat try { const page=parseKnowledgePage({path:changedPath,raw:file.content}); requireKnowledgePageBookPath(changedPath,pageRoot,page); + const bookPath=String(page.bookRef.path ?? ''); + const bookRoot=`${projectLibraryPath(connection.contentPath,'books')}/`; + if(!bookPath.startsWith(bookRoot)||!bookPath.endsWith('.md'))throw new Error('The exact Book path is outside this project library.'); + const bookFile=await connection.client.readFile({workspaceId:access.workspace.treeDxWorkspaceId,path:bookPath}); + const book=parseBook({path:bookPath,raw:bookFile.content}); + if(book.id!==page.bookRef.id||book.projectId!==page.projectId||book.revision!==page.bookRef.revision + ||book.sourceDigest!==page.bookRef.digest)throw new Error('The page does not pin the current exact Book revision and digest.'); } catch(error) { throw new KnowledgeOperationError(422,'knowledge_page_book_path_required',error instanceof Error?error.message:'Every knowledge document must be a valid page inside its declared book.'); } @@ -339,10 +382,13 @@ function canonicalPath(page: any) { return `/t/${encodeURIComponent(page.source.teamSlug)}/books/${encodeURIComponent(page.source.bookSlug ?? page.bookId)}/${page.slug.split('/').map(encodeURIComponent).join('/')}`; } -function pageDocument(body: Record, status: 'published' | 'archived') { - return serializeKnowledgePageDraft({ id: text(body.id), bookId: text(body.bookId), slug: text(body.slug), title: text(body.title), +function pageDocument(body: Record, projectId: string, status: 'published' | 'archived') { + return serializeKnowledgePageDraft({ id: text(body.id), projectId, + bookRef: body.bookRef as import('@treeseed/sdk/agent-capacity').ExactEntityReference, + bookId: text((body.bookRef as Record | undefined)?.id), + slug: text(body.slug), title: text(body.title), summary: text(body.summary), status, visibility: body.visibility ?? 'team', order: Number(body.order ?? 0), - parentId: text(body.parentId) || undefined, tags: list(body.tags), contributors: list(body.contributors), + parentId: text(body.parentId) || undefined, groupIds: list(body.groupIds), contributors: list(body.contributors), relatedBookIds: list(body.relatedBookIds), relatedKnowledgeIds: list(body.relatedKnowledgeIds), relatedNoteIds: list(body.relatedNoteIds), relatedQuestionIds: list(body.relatedQuestionIds), relatedObjectiveIds: list(body.relatedObjectiveIds), relatedProposalIds: list(body.relatedProposalIds), relatedDecisionIds: list(body.relatedDecisionIds), guaranteeIds: list(body.guaranteeIds), @@ -351,10 +397,11 @@ function pageDocument(body: Record, status: 'published' | 'arch keywords: list(body.keywords), documentationUrls: list(body.documentationUrls) }, bodyMarkdown: String(body.bodyMarkdown ?? '') }); } -function bookDocument(body: Record, status: 'published' | 'archived') { - return serializeBookDraft({ id: text(body.id), slug: text(body.slug), title: text(body.title), summary: text(body.summary), +function bookDocument(body: Record, projectId: string, status: 'published' | 'archived', revision: number) { + return serializeBookDraft({ id: text(body.id), projectId, revision, + slug: text(body.slug), title: text(body.title), summary: text(body.summary), description: text(body.description) || text(body.summary), status, visibility: body.visibility ?? 'team', order: Number(body.order ?? 0), - topics: list(body.topics), audience: list(body.audience), relatedBookIds: list(body.relatedBookIds), + groupIds: list(body.groupIds), audience: list(body.audience), relatedBookIds: list(body.relatedBookIds), editorialCoreNoteId: text(body.editorialCoreNoteId) || undefined, packPolicy: body.packPolicy ?? 'allowed', cover: body.cover && typeof body.cover === 'object' ? body.cover : undefined }); } diff --git a/src/api/discussions/content.ts b/src/api/discussions/content.ts index db567c21..cdc273a6 100644 --- a/src/api/discussions/content.ts +++ b/src/api/discussions/content.ts @@ -223,17 +223,10 @@ export async function commitDiscussionMessage(input: { const eventPath = projectLibraryPath(root, 'discussion-events', slug(discussionId), `${now.replace(/[^0-9]/gu, '')}-${messageId}.mdx`); const authorId = text(input.principal.id, 'unknown-user'); const authorName = text(input.principal.displayName, input.principal.name, authorId); - const discussion = serializeFrontmatterDocument({ title: topic, topic, status: 'active', teamId: input.teamId, projectId: input.projectId, visibility: 'team', participantIds: [authorId], agentIds: mentions, createdAt: now, updatedAt: now }, `# ${topic}\n`); - const message = serializeFrontmatterDocument({ title: `${authorName}: ${topic}`.slice(0, 120), discussionId, authorId, authorType: input.authorType ?? 'user', intent: input.intent, - mentionedAgents, recipientIds: recipients, fileRefs: Array.isArray(input.fileRefs) ? input.fileRefs : [], contextRefs: input.contextRefs ?? [], - ...(input.inboxIntent ? { inboxIntent: input.inboxIntent } : {}), - ...(input.replyTo ? { replyTo: input.replyTo } : {}), sourceMessageRefs: input.sourceMessageRefs ?? [], - ...(input.authorAgentId ? { authorAgentId: input.authorAgentId } : {}), ...(input.handoffId ? { handoffId: input.handoffId } : {}), - ...(input.parentWorkdayId ? { parentWorkdayId: input.parentWorkdayId } : {}), ...(input.resultingOperationId ? { resultingOperationId: input.resultingOperationId } : {}), createdAt: now }, `${input.body}\n`); - const event = serializeFrontmatterDocument({ title: 'Message committed', discussionId, messageId, phase: 'message.committed', sequence: Date.now(), occurredAt: now, metrics: {}, refs: [messagePath] }, `The user message was committed to TreeDX before assignment dispatch.\n`); - for (const [path, source] of [[discussionPath, discussion], [messagePath, message], [eventPath, event]]) { - assertDiscussionContent(path, source); - } + const discussion = serializeFrontmatterDocument({ schemaVersion: 'treeseed.discussion/v1', id: discussionId, + projectId: input.projectId, subjectRef: { store: 'postgresql', model: 'project', id: input.projectId }, + status: 'open', participantClasses: [], title: topic, topic, teamId: input.teamId, + visibility: 'team', participantIds: [authorId], agentIds: mentions, createdAt: now, updatedAt: now }, `# ${topic}\n`); const branchName = authoringRef || `refs/heads/${connection.authoringBranch.replace(/^refs\/heads\//u, '')}`; const session = await openDiscussionWorkspace({ store:input.store,connection,projectId:input.projectId,baseRef:connection.baseRef,branchName, operationKey:discussionWorkspaceOperationKey('message',`${discussionId}\n${messageId}`), @@ -241,8 +234,36 @@ export async function commitDiscussionMessage(input: { }); const workspace = session.workspace; try { + const creating=input.createDiscussion===true||!input.discussionId; + const currentDiscussion=creating?discussion:(await connection.client.readFile({workspaceId:workspace.workspaceId,path:discussionPath})).content; + assertDiscussionContent(discussionPath,currentDiscussion); + const discussionRef={store:'treedx' as const,model:'discussion',id:discussionId,path:discussionPath, + revision:1,digest:`sha256:${createHash('sha256').update(currentDiscussion).digest('hex')}`}; + const messageReference=async(value:string)=>{ + const prefix=projectLibraryPath(root,'discussion-messages'); + const path=value.startsWith(`${prefix}/`)?value:projectLibraryPath(root,'discussion-messages',slug(discussionId),`${value}.mdx`); + if(!path.startsWith(`${prefix}/`)||path.split('/').includes('..'))throw new Error('Discussion message reference escaped its project library.'); + const history=await loadDiscussions({store:input.store,projectId:input.projectId,exactPaths:[path],collection:'messages',limit:1}); + const existing=history.messages.find((item:Row)=>item.path===path); + if(!existing||!/^[a-f0-9]{40}$/u.test(text(existing.immutableRef)))throw new Error('The referenced Discussion message has no exact readable TreeDX commit.'); + return {store:'treedx' as const,model:'discussion-message',id:text(record(existing.frontmatter).id),path, + commit:text(existing.immutableRef)}; + }; + const replyToRef=input.replyTo?await messageReference(input.replyTo):undefined; + const sourceMessageRefs=await Promise.all((input.sourceMessageRefs??[]).map(messageReference)); + const message=serializeFrontmatterDocument({schemaVersion:'treeseed.discussion-message/v1',id:messageId, + discussionRef,authorRef:{store:'postgresql',model:input.authorType==='agent'?'agent':'user',id:authorId}, + title:`${authorName}: ${topic}`.slice(0,120),discussionId,authorId,authorType:input.authorType??'user',intent:input.intent, + mentionedAgents,recipientIds:recipients,fileRefs:Array.isArray(input.fileRefs)?input.fileRefs:[],contextRefs:input.contextRefs??[], + ...(input.inboxIntent?{inboxIntent:input.inboxIntent}:{}),...(replyToRef?{replyToRef}:{}),sourceMessageRefs, + ...(input.authorAgentId?{authorAgentId:input.authorAgentId}:{}),...(input.handoffId?{handoffId:input.handoffId}:{}), + ...(input.parentWorkdayId?{parentWorkdayId:input.parentWorkdayId}:{}), + ...(input.resultingOperationId?{resultingOperationId:input.resultingOperationId}:{}),createdAt:now},`${input.body}\n`); + const event=serializeFrontmatterDocument({title:'Message committed',discussionId,messageId,phase:'message.committed',sequence:Date.now(),occurredAt:now,metrics:{},refs:[messagePath]},`The user message was committed to TreeDX before assignment dispatch.\n`); + assertDiscussionContent(messagePath,message); + assertDiscussionContent(eventPath,event); const changeset = await applyTextChangeset({ client: connection.client, workspace, changes: [ - ...(input.createDiscussion === true || !input.discussionId ? [{ path: discussionPath, before: null, after: discussion }] : []), + ...(creating ? [{ path: discussionPath, before: null, after: discussion }] : []), { path: messagePath, before: null, after: message }, { path: eventPath, before: null, after: event }, ] }).catch((error: unknown) => { throw new DiscussionAuthoringError('changeset', error); }); @@ -408,7 +429,7 @@ export async function appendDiscussionEvent(input: { } } -export async function changeDiscussionStatus(input:{store:any;projectId:string;teamId:string;discussionId:string;status:'active'|'archived';principal:Row}){ +export async function changeDiscussionStatus(input:{store:any;projectId:string;teamId:string;discussionId:string;status:'open'|'resolved'|'closed';principal:Row}){ const connection=await resolveKnowledgeGatewayConnection(input.store,{projectId:input.projectId,write:true,communicationPaths:true}); if(!connection)throw new Error('The project TreeDX repository is unavailable for Discussion lifecycle changes.'); const path=projectLibraryPath(connection.contentPath,'discussions',`${slug(input.discussionId)}.mdx`); const branchName=`refs/heads/${connection.authoringBranch.replace(/^refs\/heads\//u,'')}`; @@ -416,7 +437,7 @@ export async function changeDiscussionStatus(input:{store:any;projectId:string;t if(!file)throw Object.assign(new Error('Unknown Discussion.'),{status:404,code:'discussion_not_found'}); const before=text(file.content); const parsed=parseFrontmatterDocument(before); const prior=text(parsed.frontmatter.status); if(prior===input.status)return {discussionId:input.discussionId,path,status:input.status,replayed:true,commitSha:text((read as Row).resolvedRef,branchName)}; - const now=new Date().toISOString(); const after=serializeFrontmatterDocument({...parsed.frontmatter,status:input.status,...((prior==='open'||prior==='resolved')?{legacyStatus:prior}:{}),updatedAt:now},parsed.body); assertDiscussionContent(path,after); + const now=new Date().toISOString(); const after=serializeFrontmatterDocument({...parsed.frontmatter,status:input.status,updatedAt:now},parsed.body); assertDiscussionContent(path,after); const session=await openDiscussionWorkspace({store:input.store,connection,projectId:input.projectId,baseRef:connection.baseRef,branchName, operationKey:discussionWorkspaceOperationKey('status',`${input.discussionId}\n${input.status}`)}); const workspace=session.workspace; try{ diff --git a/src/api/discussions/discussion-service.ts b/src/api/discussions/discussion-service.ts index 4bff2aec..0eaaaa69 100644 --- a/src/api/discussions/discussion-service.ts +++ b/src/api/discussions/discussion-service.ts @@ -207,13 +207,13 @@ export function createDiscussionService(dependencies: { store: any; capacity: an async updateStatus(principal: Principal, discussionId: string, body: Record, idempotencyKey?: string) { const projectId = text(body.projectId); - const status = body.status === 'active' ? 'active' : body.status === 'archived' ? 'archived' : null; + const status = body.status === 'open' || body.status === 'resolved' || body.status === 'closed' ? body.status : null; if (!projectId || !status) throw new DiscussionServiceError(422, 'discussion_status_invalid', - 'Discussion lifecycle requires a project and active or archived status.'); + 'Discussion lifecycle requires a project and open, resolved, or closed status.'); const project = await projectFor(store, principal, projectId); try { const result = await changeDiscussionStatus({ store, projectId, teamId: project.teamId, discussionId, status, principal }); - if (status === 'archived') await cancelArchivedDiscussionCapacity(store, projectId, discussionId, + if (status === 'closed') await cancelArchivedDiscussionCapacity(store, projectId, discussionId, (teamId, assignmentId, key) => invocationStore.cancelCapacityAssignment(teamId, assignmentId, { idempotencyKey: key, reason: 'The source Discussion was archived.' })); await sessionEvents.publish({ eventType: 'discussion.lifecycle', teamId: project.teamId, projectId, resourceId: discussionId, diff --git a/src/api/knowledge/runtime/authoring.ts b/src/api/knowledge/runtime/authoring.ts index 2730d170..49e07eed 100644 --- a/src/api/knowledge/runtime/authoring.ts +++ b/src/api/knowledge/runtime/authoring.ts @@ -9,13 +9,15 @@ const safeSlug = /^[a-z0-9]+(?:[/-][a-z0-9]+)*$/u; function pageFrontmatter(input: Omit) { return { - schemaVersion: KNOWLEDGE_PAGE_SCHEMA_VERSION, id: input.id, bookId: input.bookId, slug: input.slug, + schemaVersion: KNOWLEDGE_PAGE_SCHEMA_VERSION, id: input.id, projectId: input.projectId, + bookRef: input.bookRef, slug: input.slug, title: input.title, summary: input.summary, status: input.status, visibility: input.visibility, order: input.order, ...(input.parentId ? { parentId: input.parentId } : {}), groupIds: input.groupIds, contributors: input.contributors, relatedBookIds: input.relatedBookIds, relatedKnowledgeIds: input.relatedKnowledgeIds, relatedNoteIds: input.relatedNoteIds, relatedQuestionIds: input.relatedQuestionIds, relatedObjectiveIds: input.relatedObjectiveIds, relatedProposalIds: input.relatedProposalIds, relatedDecisionIds: input.relatedDecisionIds, + ...(input.relatedRefs?.length ? { relatedRefs: input.relatedRefs } : {}), guaranteeIds: input.guaranteeIds, audiences: input.audiences, capabilityIds: input.context.capabilityIds, routePatterns: input.context.routePatterns, @@ -38,7 +40,8 @@ export function serializeBookDraft(input: Omit) if (coverImage && (!coverImage.startsWith('/') || coverImage.includes('..') || /[?#]/u.test(coverImage))) { throw new Error('Book covers must use a safe root-relative project asset path.'); } - const raw = serializeFrontmatterDocument({ schemaVersion: BOOK_SCHEMA_VERSION, ...input }, '\n'); + const { sourceDigest: _derivedDigest, sourcePath: _derivedPath, ...content } = input; + const raw = serializeFrontmatterDocument({ schemaVersion: BOOK_SCHEMA_VERSION, ...content }, '\n'); parseBook({ path: `${input.slug}.md`, raw }); return raw; } diff --git a/src/api/knowledge/runtime/catalog.ts b/src/api/knowledge/runtime/catalog.ts index b652e472..24bbc720 100644 --- a/src/api/knowledge/runtime/catalog.ts +++ b/src/api/knowledge/runtime/catalog.ts @@ -46,7 +46,10 @@ export function parseBook(input: { path: string; raw: string }): BookDefinition if (!KNOWLEDGE_STATUSES.includes(status as never)) throw new Error(`Book ${input.path} has invalid status.`); const book = { schemaVersion: BOOK_SCHEMA_VERSION, - id: text(data, 'id', input.path), slug: text(data, 'slug', input.path), title: text(data, 'title', input.path), + id: text(data, 'id', input.path), projectId: text(data, 'projectId', input.path), + revision: Number(data.revision), sourceDigest: `sha256:${createHash('sha256').update(input.raw).digest('hex')}`, + sourcePath: input.path, + slug: text(data, 'slug', input.path), title: text(data, 'title', input.path), summary: text(data, 'summary', input.path), description: String(data.description ?? data.summary ?? '').trim(), status: status as BookDefinition['status'], visibility: visibility as BookDefinition['visibility'], order: Number(data.order ?? 0), groupIds: list(data.groupIds), audience: list(data.audience), @@ -66,23 +69,26 @@ export function parseBook(input: { path: string; raw: string }): BookDefinition export function parseKnowledgePage(input: { path: string; raw: string; updatedAt?: string; sourcePackage?: string }): KnowledgePageDefinition { const parsed = parseFrontmatterDocument(input.raw); const data = parsed.frontmatter; - assertPortableContentData('knowledge', data, { path: input.path }); + assertPortableContentData('knowledge', { ...data, body: parsed.body }, { path: input.path }); if (data.schemaVersion !== KNOWLEDGE_PAGE_SCHEMA_VERSION) throw new Error(`Knowledge page ${input.path} must use ${KNOWLEDGE_PAGE_SCHEMA_VERSION}.`); const visibility = String(data.visibility ?? 'public'); const status = String(data.status ?? 'published'); if (!KNOWLEDGE_VISIBILITIES.includes(visibility as never)) throw new Error(`Knowledge page ${input.path} has invalid visibility.`); if (!KNOWLEDGE_STATUSES.includes(status as never)) throw new Error(`Knowledge page ${input.path} has invalid status.`); const bodyMarkdown = validateKnowledgeMarkdown(parsed.body); + const bookRef = data.bookRef as KnowledgePageDefinition['bookRef']; const page = { schemaVersion: KNOWLEDGE_PAGE_SCHEMA_VERSION, - id: text(data, 'id', input.path), bookId: text(data, 'bookId', input.path), slug: text(data, 'slug', input.path), - title: text(data, 'title', input.path), summary: text(data, 'summary', input.path), + id: text(data, 'id', input.path), projectId: text(data, 'projectId', input.path), + bookRef, bookId: bookRef.id, slug: text(data, 'slug', input.path), + title: text(data, 'title', input.path), summary: String(data.summary ?? data.title ?? '').trim(), status: status as KnowledgePageDefinition['status'], visibility: visibility as KnowledgePageDefinition['visibility'], order: Number(data.order ?? 0), parentId: data.parentId ? String(data.parentId) : undefined, groupIds: list(data.groupIds), contributors: list(data.contributors), relatedBookIds: list(data.relatedBookIds ?? data.relatedBooks), relatedKnowledgeIds: list(data.relatedKnowledgeIds ?? data.relatedTopics), relatedNoteIds: list(data.relatedNoteIds), relatedQuestionIds: list(data.relatedQuestionIds), relatedObjectiveIds: list(data.relatedObjectiveIds), relatedProposalIds: list(data.relatedProposalIds), relatedDecisionIds: list(data.relatedDecisionIds), + relatedRefs: Array.isArray(data.relatedRefs) ? data.relatedRefs as KnowledgePageDefinition['relatedRefs'] : undefined, guaranteeIds: list(data.guaranteeIds), audiences: { primary: list((data.audiences as Record | undefined)?.primary ?? data.audience), @@ -97,7 +103,7 @@ export function parseKnowledgePage(input: { path: string; raw: string; updatedAt revision: createHash('sha256').update(input.raw).digest('hex'), sourcePackage: input.sourcePackage, }; if (!identifier.test(page.id)) throw new Error(`Knowledge page ${input.path} has invalid id.`); - if (!identifier.test(page.bookId)) throw new Error(`Knowledge page ${input.path} has invalid bookId.`); + if (!identifier.test(page.bookId)) throw new Error(`Knowledge page ${input.path} has invalid book reference.`); if (!slugPattern.test(page.slug)) throw new Error(`Knowledge page ${input.path} has invalid slug.`); return page; } @@ -139,6 +145,10 @@ export function validateKnowledgeCatalog(books: BookDefinition[], pages: Knowled for (const page of pages) { const book = byId.get(page.bookId); if (!book) throw new Error(`Knowledge page "${page.id}" references missing book "${page.bookId}".`); + if (page.projectId !== book.projectId || page.bookRef.path !== book.sourcePath || page.bookRef.revision !== book.revision + || page.bookRef.digest !== book.sourceDigest) { + throw new Error(`Knowledge page "${page.id}" does not pin the exact owning Book revision and digest.`); + } if (visibilityRank[page.visibility] < visibilityRank[book.visibility]) { throw new Error(`Knowledge page "${page.id}" is more visible than book "${book.id}".`); } diff --git a/src/api/teams/managed-team-library-service.ts b/src/api/teams/managed-team-library-service.ts index fd3ac7f6..de7d2805 100644 --- a/src/api/teams/managed-team-library-service.ts +++ b/src/api/teams/managed-team-library-service.ts @@ -4,13 +4,16 @@ import { createR2PublicationClient } from '../providers/cloudflare/r2-publicatio import { enqueueTreeDxCommitReplication } from '../capacity/services/treedx/repositories/treedx-commit-replication.ts'; import { isManagedTeamLibraryRepositoryName,managedTeamLibraryRepositoryName } from '../store/teams/contracts/managed-library/ensure-managed-team-library-project.ts'; import { resolveGitHubRepositoryCreationAuthority } from '../../security/provider-credential-authority.ts'; +import { createHash } from 'node:crypto'; const text=(...values:unknown[])=>values.find((value)=>typeof value==='string'&&value.trim())?.toString().trim()??''; const record=(value:unknown):Record=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record:{}; -const teamBook=`--- -schemaVersion: treeseed.book/v2 +const teamBook=(projectId:string)=>`--- +schemaVersion: treeseed.book/v3 id: team-operations +projectId: ${projectId} +revision: 1 slug: team-operations title: Team Operations summary: Shared operating standards for communication, coordination, governance, delegation, and research. @@ -18,17 +21,24 @@ description: The canonical operating standards used by people and agents working status: published visibility: team order: 0 -topics: [] +groupIds: [] audience: [team-members, team-agents] relatedBookIds: [] packPolicy: allowed --- `; -function teamPage(input:{id:string;slug:string;title:string;summary:string;body:string;order:number;keywords:string[]}) { +function teamPage(input:{id:string;slug:string;title:string;summary:string;body:string;order:number;keywords:string[]},projectId:string,bookDigest:string) { return `--- -schemaVersion: treeseed.knowledge-page/v1 +schemaVersion: treeseed.knowledge-page/v2 id: ${input.id} -bookId: team-operations +projectId: ${projectId} +bookRef: + store: treedx + model: book + id: team-operations + path: books/team-operations.md + revision: 1 + digest: ${bookDigest} slug: ${input.slug} title: ${input.title} summary: ${input.summary} @@ -57,19 +67,24 @@ ${input.body} `; } -export const managedTeamLibrarySeedFiles:Readonly>={ +export function managedTeamLibrarySeedFiles(projectId:string):Readonly> { + const book=teamBook(projectId); + const bookDigest=`sha256:${createHash('sha256').update(book).digest('hex')}`; + const page=(input:Parameters[0])=>teamPage(input,projectId,bookDigest); + return { 'README.md':'# Team Library\n\nSystem-managed, team-wide knowledge for communication, governance, research, management delegation, and cross-project coordination.\n', - 'objectives/core.mdx':'---\nid: team-core\ntitle: Team Core Objective\nstatus: active\ngroup_ids: []\n---\n\nBuild a coherent engineering team whose projects share trustworthy knowledge, coordinate explicitly, and preserve project-scoped authority.\n', - 'books/team-operations.md':teamBook, - 'knowledge/team-operations/communication-standards.md':teamPage({id:'team-communication-standards',slug:'communication-standards',title:'Team Communication Standards',summary:'How the team communicates decisions, uncertainty, evidence, owners, and next actions.',order:10,keywords:['communication','decisions','evidence'],body:'Communicate decisions, uncertainty, evidence, owners, and next actions clearly. Use cross-project discussions for coordination without weakening project-scoped writes.'}), - 'knowledge/team-operations/governance.md':teamPage({id:'team-governance',slug:'governance',title:'Team Governance',summary:'How questions, proposals, decisions, and releases remain distinct and governed.',order:20,keywords:['governance','proposals','decisions'],body:'Questions request clarification. Proposals request governed change. Approved or rejected proposals produce decisions. Releases package completed or preliminary work independently from those decisions.'}), - 'knowledge/team-operations/research-and-citation.md':teamPage({id:'team-research-citation',slug:'research-and-citation',title:'Research and Citation',summary:'Standards for evidence, inference, citations, uncertainty, and reproducibility.',order:30,keywords:['research','citations','evidence'],body:'Distinguish evidence from inference, cite authoritative sources, report uncertainty, and preserve enough provenance for another contributor to verify the conclusion.'}), - 'knowledge/team-operations/management-delegation.md':teamPage({id:'team-management-delegation',slug:'management-delegation',title:'Management Delegation',summary:'How direction becomes bounded, verifiable delegated work.',order:40,keywords:['management','delegation','authority'],body:'Translate direction into explicit outcomes, constraints, owners, dependencies, verification, and escalation conditions. Never broaden authority implicitly.'}), - 'knowledge/team-operations/cross-project-coordination.md':teamPage({id:'team-cross-project-coordination',slug:'cross-project-coordination',title:'Cross-project Coordination',summary:'How agents coordinate across projects while preserving project-scoped write authority.',order:50,keywords:['coordination','projects','authority'],body:'Read across authorized team projects to understand consequences. Keep every write and commit bound to the assignment owning project.'}), - 'knowledge/team-operations/knowledge-authoring.md':teamPage({id:'team-knowledge-authoring',slug:'knowledge-authoring',title:'Knowledge Authoring',summary:'How agents place durable knowledge in books without mixing it with operational records.',order:60,keywords:['knowledge','books','authoring'],body:'Every ordinary document in the knowledge directory is a page in a declared book. Create the book definition at `books/{book}.md` and place each page at `knowledge/{book}/{page}.md`, with matching `bookId` and page slug metadata. Do not write miscellaneous or catch-all entries directly under `knowledge/`. Questions, proposals, decisions, releases, agent definitions, context queries, tests, and other operational records remain in their dedicated collections rather than being disguised as book pages.'}), + 'objectives/core.mdx':`---\nschemaVersion: treeseed.objective/v1\nid: team-core\nprojectId: ${projectId}\ntitle: Team Core Objective\noutcome: Build a coherent engineering team with trustworthy shared knowledge and project-scoped authority.\nstatus: active\nevidenceRefs: []\n---\n`, + 'books/team-operations.md':book, + 'knowledge/team-operations/communication-standards.md':page({id:'team-communication-standards',slug:'communication-standards',title:'Team Communication Standards',summary:'How the team communicates decisions, uncertainty, evidence, owners, and next actions.',order:10,keywords:['communication','decisions','evidence'],body:'Communicate decisions, uncertainty, evidence, owners, and next actions clearly. Use cross-project discussions for coordination without weakening project-scoped writes.'}), + 'knowledge/team-operations/governance.md':page({id:'team-governance',slug:'governance',title:'Team Governance',summary:'How questions, proposals, decisions, and releases remain distinct and governed.',order:20,keywords:['governance','proposals','decisions'],body:'Questions request clarification. Proposals request governed change. Approved or rejected proposals produce decisions. Releases package completed or preliminary work independently from those decisions.'}), + 'knowledge/team-operations/research-and-citation.md':page({id:'team-research-citation',slug:'research-and-citation',title:'Research and Citation',summary:'Standards for evidence, inference, citations, uncertainty, and reproducibility.',order:30,keywords:['research','citations','evidence'],body:'Distinguish evidence from inference, cite authoritative sources, report uncertainty, and preserve enough provenance for another contributor to verify the conclusion.'}), + 'knowledge/team-operations/management-delegation.md':page({id:'team-management-delegation',slug:'management-delegation',title:'Management Delegation',summary:'How direction becomes bounded, verifiable delegated work.',order:40,keywords:['management','delegation','authority'],body:'Translate direction into explicit outcomes, constraints, owners, dependencies, verification, and escalation conditions. Never broaden authority implicitly.'}), + 'knowledge/team-operations/cross-project-coordination.md':page({id:'team-cross-project-coordination',slug:'cross-project-coordination',title:'Cross-project Coordination',summary:'How agents coordinate across projects while preserving project-scoped write authority.',order:50,keywords:['coordination','projects','authority'],body:'Read across authorized team projects to understand consequences. Keep every write and commit bound to the assignment owning project.'}), + 'knowledge/team-operations/knowledge-authoring.md':page({id:'team-knowledge-authoring',slug:'knowledge-authoring',title:'Knowledge Authoring',summary:'How agents place durable knowledge in books without mixing it with operational records.',order:60,keywords:['knowledge','books','authoring'],body:'Every ordinary document in the knowledge directory is a page in a declared book. Create the book definition at `books/{book}.md` and place each page at `knowledge/{book}/{page}.md`, with an exact `bookRef` and page slug. Do not write miscellaneous or catch-all entries directly under `knowledge/`. Questions, proposals, decisions, releases, agent definitions, context queries, tests, and other operational records remain in their dedicated collections rather than being disguised as book pages.'}), 'agent-context-queries/team-shared-foundations.mdx':'---\nid: team-shared-foundations\ntitle: Team Shared Foundations\ndescription: Retrieve team communication, governance, research, delegation, cross-project coordination, and knowledge-authoring guidance.\nrevision: 2\nmaturity: validated\npurpose: research\nquery: communication governance research citation management delegation cross-project coordination knowledge books authoring\ntarget:\n kind: content\n paths: [/knowledge/team-operations/**]\nrelations: [related, references]\ndepth: 1\nresultLimit: 20\ncontextBudget:\n maxItems: 20\n maxCharacters: 24000\ntokenBudget: 6000\nformat: summary\nsources:\n - scope: current-project\nrequirement: preferred\npriority: 80\nsummarization: deterministic\nfilters: {}\n---\n\nTeam-wide foundations used by project agent context queries.\n', 'agent-tests/team-shared-foundations.mdx':'---\nid: team-shared-foundations-test\nagent: system-team-library\nkind: context-query\nqueryRef:\n id: team-shared-foundations\n revision: 2\ntestRef: team-shared-foundations-test-v2\nexpectedIdentities: []\nexpectedRelations: []\nexpectedPaths: []\nexpectedSchemaVersions: []\nresultBounds:\n min: 0\n max: 20\nbudget:\n maxContextItems: 20\n maxTokens: 6000\nmaxLatencyMs: 10000\n---\n\nVerifies that the managed Team Library query compiles and executes within its declared bounds.\n', -}; + }; +} export async function reconcileManagedTeamLibrary(store:any,teamId:string,env:NodeJS.ProcessEnv=process.env) { const team=await store.getTeam(teamId),project=await store.ensureManagedTeamLibraryProject(teamId); @@ -92,7 +107,7 @@ export async function reconcileManagedTeamLibrary(store:any,teamId:string,env:No const repositoryName=text(projectLibrary.repositoryName,managedTeamLibraryRepositoryName(teamId)); if(!isManagedTeamLibraryRepositoryName(teamId,repositoryName))throw new Error('Managed Team Library repository identity does not match its owning team.'); const repositoryAuthority=await resolveGitHubRepositoryCreationAuthority({store,teamId,owner,env,fetchImpl:store.config?.fetchImpl}); - const provider=await reconcileLibraryProvider({store,teamId,projectId:String(project.id),projectSlug:'team',owner,name:repositoryName,visibility:projectLibrary.repositoryPolicy?.visibility??'private',lifecycle:projectLibrary.repositoryPolicy?.lifecycle??'create-or-adopt',env,fetchImpl:store.config?.fetchImpl,seedFiles:projectLibrary.repositoryPolicy?undefined:managedTeamLibrarySeedFiles,repositoryAuthority}); + const provider=await reconcileLibraryProvider({store,teamId,projectId:String(project.id),projectSlug:'team',owner,name:repositoryName,visibility:projectLibrary.repositoryPolicy?.visibility??'private',lifecycle:projectLibrary.repositoryPolicy?.lifecycle??'create-or-adopt',env,fetchImpl:store.config?.fetchImpl,seedFiles:projectLibrary.repositoryPolicy?undefined:managedTeamLibrarySeedFiles(String(project.id)),repositoryAuthority}); const binding=await ensureProjectKnowledgeBinding({store,projectId:String(project.id),teamId,projectSlug:'team',libraryRoot:'.',libraryRef:'refs/remotes/origin/staging',libraryRepositoryUrl:`https://github.com/${owner}/${repositoryName}.git`,libraryDefaultBranch:'main',libraryCredentialId:provider.credentialId,expectedUpstreamHeads:provider.heads,env}); const now=new Date().toISOString(); await enqueueTreeDxCommitReplication(store,{teamId,projectId:String(project.id),commitSha:binding.resolvedRef,sourceRef:binding.sourceRef,createdAt:now}); diff --git a/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts b/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts index 47f9740f..b3d6dbd1 100644 --- a/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/migrations/retired-allocation-removal.test.ts @@ -6,11 +6,12 @@ const migration = readFileSync('drizzle/control-plane/0040_remove_retired_alloca async function database() { const db = new PGlite(); - await db.exec(`CREATE TABLE capacity_workday_runs(id text PRIMARY KEY, status text NOT NULL); + await db.exec(`CREATE TABLE capacity_workday_runs(id text PRIMARY KEY, status text NOT NULL, completed_at text, updated_at text NOT NULL DEFAULT '', error_json text NOT NULL DEFAULT '{}'); CREATE TABLE capacity_allocation_sets(id text PRIMARY KEY); CREATE TABLE decision_assignment_graphs(id text PRIMARY KEY); CREATE TABLE capacity_provider_assignments(id text PRIMARY KEY, status text NOT NULL DEFAULT 'completed', allocation_set_id text REFERENCES capacity_allocation_sets(id)); - CREATE TABLE capacity_reservations(id text PRIMARY KEY, allocation_set_id text REFERENCES capacity_allocation_sets(id), + CREATE TABLE capacity_reservations(id text PRIMARY KEY, work_day_id text, state text NOT NULL DEFAULT 'reserved', reserved_seconds integer NOT NULL DEFAULT 1, + active_seconds integer NOT NULL DEFAULT 0, released_seconds integer NOT NULL DEFAULT 0, updated_at text NOT NULL DEFAULT '', allocation_set_id text REFERENCES capacity_allocation_sets(id), allocation_version integer, allocation_slice_ids_json text NOT NULL DEFAULT '[]', CONSTRAINT chk_capacity_reservations_allocation_version CHECK (allocation_version IS NULL OR allocation_version >= 1));`); return db; @@ -22,7 +23,7 @@ it('removes only retired allocation and decision authorities while retaining res await db.exec(`INSERT INTO capacity_allocation_sets VALUES ('old'); INSERT INTO decision_assignment_graphs VALUES ('old'); INSERT INTO capacity_provider_assignments(id,allocation_set_id) VALUES ('assignment','old'); - INSERT INTO capacity_reservations VALUES ('reservation','old',1,'["slice"]');`); + INSERT INTO capacity_reservations(id,allocation_set_id,allocation_version,allocation_slice_ids_json) VALUES ('reservation','old',1,'["slice"]');`); await db.exec(migration); expect((await db.query('SELECT id FROM capacity_reservations')).rows).toEqual([{ id: 'reservation' }]); expect((await db.query('SELECT id FROM capacity_provider_assignments')).rows).toEqual([{ id: 'assignment' }]); @@ -32,13 +33,16 @@ it('removes only retired allocation and decision authorities while retaining res } finally { await db.close(); } }, 15_000); -it('refuses to remove an allocation authority while workdays are running', async () => { +it('cancels orphaned workdays and releases their unused reservation during the one-time cutover', async () => { const db = await database(); try { - await db.exec("INSERT INTO capacity_workday_runs VALUES ('workday','running')"); - await expect(db.exec(migration)).rejects.toThrow('drain active workdays'); - expect((await db.query("SELECT to_regclass('capacity_allocation_sets') AS name")).rows) - .toEqual([{ name: 'capacity_allocation_sets' }]); + await db.exec("INSERT INTO capacity_workday_runs(id,status) VALUES ('workday','running')"); + await db.exec("INSERT INTO capacity_reservations(id,work_day_id,reserved_seconds,active_seconds) VALUES ('reservation','workday',10,3)"); + await db.exec(migration); + expect((await db.query("SELECT status,error_json FROM capacity_workday_runs")).rows) + .toEqual([{ status: 'cancelled', error_json: '{"code":"architecture_contract_cutover"}' }]); + expect((await db.query("SELECT state,released_seconds FROM capacity_reservations")).rows) + .toEqual([{ state: 'released', released_seconds: 7 }]); } finally { await db.close(); } }, 15_000); diff --git a/tests/unit/control-plane/discussion-operations.test.ts b/tests/unit/control-plane/discussion-operations.test.ts index 93d58d7a..e539369f 100644 --- a/tests/unit/control-plane/discussion-operations.test.ts +++ b/tests/unit/control-plane/discussion-operations.test.ts @@ -7,7 +7,7 @@ describe('discussion catalog operations', () => { const discussions = { list: vi.fn(async () => ({ discussions: [] })), create: vi.fn(async () => ({ discussion: { id: 'discussion-1' }, replayed: false })), - updateStatus: vi.fn(async () => ({ discussionId: 'discussion-1', status: 'archived' })), + updateStatus: vi.fn(async () => ({ discussionId: 'discussion-1', status: 'closed' })), }; const operations = createDiscussionOperations({ discussions }); expect(operations.map((operation) => operation.binding)).toEqual([ @@ -18,8 +18,8 @@ describe('discussion catalog operations', () => { const context = { interface: 'rest' as const, requestId: 'request-1', idempotencyKey: 'key-1', principal: { id: 'user-1' } }; await operations[1].handler({ path: {}, query: {}, body: { teamId: 'team-1', body: 'Hello' } }, context); expect(discussions.create).toHaveBeenCalledWith(context.principal, { teamId: 'team-1', body: 'Hello' }, 'key-1'); - await operations[2].handler({ path: { discussionId: 'discussion-1' }, query: {}, body: { projectId: 'project-1', status: 'archived' } }, context); + await operations[2].handler({ path: { discussionId: 'discussion-1' }, query: {}, body: { projectId: 'project-1', status: 'closed' } }, context); expect(discussions.updateStatus).toHaveBeenCalledWith(context.principal, 'discussion-1', - { projectId: 'project-1', status: 'archived' }, 'key-1'); + { projectId: 'project-1', status: 'closed' }, 'key-1'); }); }); diff --git a/tests/unit/control-plane/discussions/discussion-content-cutover.test.ts b/tests/unit/control-plane/discussions/discussion-content-cutover.test.ts new file mode 100644 index 00000000..6fadf87d --- /dev/null +++ b/tests/unit/control-plane/discussions/discussion-content-cutover.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { parseFrontmatterDocument } from '../../../../src/api/content/frontmatter.ts'; + +const mocks = vi.hoisted(() => ({ + changes: [] as Array<{ path: string; after: string }>, + commit: vi.fn(), readRepositoryFiles: vi.fn(), +})); + +vi.mock('../../../../src/api/knowledge/gateway-treedx-connection.ts', async (original) => ({ + ...await original(), + projectLibraryPath: (...parts: string[]) => parts.filter(Boolean).join('/'), + resolveKnowledgeGatewayConnection: vi.fn(async () => ({ repositoryId: 'repo-1', contentPath: '', baseRef: 'staging', + authoringBranch: 'staging', client: { commit: mocks.commit, readRepositoryFiles: mocks.readRepositoryFiles } })), +})); +vi.mock('../../../../src/api/discussions/discussion-workspace.ts', () => ({ + discussionWorkspaceOperationKey: () => 'test-operation', + openDiscussionWorkspace: vi.fn(async () => ({ workspace: { workspaceId: 'workspace-1' }, close: vi.fn(async () => undefined) })), +})); +vi.mock('../../../../src/api/knowledge/changesets/apply-text-changeset.ts', () => ({ + applyTextChangeset: vi.fn(async ({ changes }) => { mocks.changes = changes; return { applied: true }; }), +})); +vi.mock('../../../../src/api/capacity/services/treedx/repositories/treedx-change-projector.ts', () => ({ projectTreeDxCommitSignals: vi.fn(async () => undefined) })); +vi.mock('../../../../src/api/capacity/services/treedx/repositories/treedx-authoring-journal.ts', () => ({ + recordTreeDxAuthoringState: vi.fn(async () => undefined), listReadableTreeDxAuthoringState: vi.fn(async () => []), +})); +vi.mock('../../../../src/api/realtime/session-events.ts', () => ({ persistSessionEvent: vi.fn(async () => undefined) })); + +import { commitDiscussionMessage } from '../../../../src/api/discussions/content.ts'; + +describe('canonical Discussion authoring', () => { + it('commits a new thread and message with an exact same-changeset Discussion reference', async () => { + mocks.commit.mockResolvedValue({ commitSha: 'a'.repeat(40), branchName: 'refs/heads/staging', + changedPaths: ['discussions/chat-1.mdx', 'discussion-messages/chat-1/message-1.mdx'] }); + mocks.readRepositoryFiles.mockImplementation(async ({ ref, paths }) => ({ resolvedRef: ref, + files: paths.map((path: string) => ({ path, content: mocks.changes.find((change) => change.path === path)?.after })) })); + const result = await commitDiscussionMessage({ store: {}, projectId: 'project-1', teamId: 'team-1', + principal: { id: 'user-1', displayName: 'Test User' }, body: 'Hello team.', intent: 'discuss', + discussionId: 'chat-1', messageId: 'message-1', createDiscussion: true }); + const threadSource = mocks.changes.find((change) => change.path === 'discussions/chat-1.mdx')?.after ?? ''; + const messageSource = mocks.changes.find((change) => change.path === 'discussion-messages/chat-1/message-1.mdx')?.after ?? ''; + const thread = parseFrontmatterDocument(threadSource).frontmatter; + const message = parseFrontmatterDocument(messageSource).frontmatter; + expect(thread).toMatchObject({ schemaVersion: 'treeseed.discussion/v1', id: 'chat-1', projectId: 'project-1', status: 'open' }); + expect(message.discussionRef).toMatchObject({ store: 'treedx', model: 'discussion', id: 'chat-1', + path: 'discussions/chat-1.mdx', revision: 1, + digest: `sha256:${createHash('sha256').update(threadSource).digest('hex')}` }); + expect(message.authorRef).toMatchObject({ store: 'postgresql', model: 'user', id: 'user-1' }); + expect(result.commitSha).toBe('a'.repeat(40)); + }); +}); diff --git a/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts b/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts index c7c5b644..ca5b0ebd 100644 --- a/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts +++ b/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts @@ -25,7 +25,7 @@ describe('targeted Discussion reads', () => { mocks.listRepositoryPaths.mockReset(); mocks.readRepositoryFiles.mockReset().mockImplementation(async ({ ref, paths }) => ({ resolvedRef: ref, - files: paths.map((path: string) => ({ path, content: `---\ntitle: Direct message\ndiscussionId: discussion-1\nauthorId: user-1\nauthorType: user\nintent: discuss\ncreatedAt: 2026-08-31T12:00:00.000Z\n---\nHello\n` })), + files: paths.map((path: string) => ({ path, content: `---\nschemaVersion: treeseed.discussion-message/v1\nid: ${path.split('/').at(-1)?.replace('.mdx','')}\ndiscussionRef:\n store: treedx\n model: discussion\n id: discussion-1\n path: discussions/discussion-1.mdx\n revision: 1\n digest: sha256:${'a'.repeat(64)}\nauthorRef:\n store: postgresql\n model: user\n id: user-1\ndiscussionId: discussion-1\nauthorId: user-1\nauthorType: user\nintent: discuss\ncreatedAt: 2026-08-31T12:00:00.000Z\n---\nHello\n` })), })); }); diff --git a/tests/unit/control-plane/knowledge/workspaces/book-repin.test.ts b/tests/unit/control-plane/knowledge/workspaces/book-repin.test.ts new file mode 100644 index 00000000..1788bef7 --- /dev/null +++ b/tests/unit/control-plane/knowledge/workspaces/book-repin.test.ts @@ -0,0 +1,43 @@ +import { describe,expect,it,vi } from 'vitest'; +import { createKnowledgeWorkspaceService } from '../../../../../src/api/control-plane/knowledge/knowledge-workspace-service.ts'; +import { managedTeamLibrarySeedFiles } from '../../../../../src/api/teams/managed-team-library-service.ts'; +import { resolveKnowledgeGatewayConnection } from '../../../../../src/api/knowledge/gateway-treedx-connection.ts'; + +vi.mock('../../../../../src/api/knowledge/gateway-treedx-connection.ts',async(original)=>({ + ...await original(), + resolveKnowledgeGatewayConnection:vi.fn(), +})); + +describe('Book revision custody',()=>{ + it('repins every existing page in the same governed changeset as its Book update',async()=>{ + const seed=managedTeamLibrarySeedFiles('project'); + const bookPath='books/team-operations.md'; + const pagePath='knowledge/team-operations/communication-standards.md'; + const workspace={id:'draft-1',projectId:'project',actorUserId:'author',status:'draft',version:1, + treeDxWorkspaceId:'remote',baseCommitSha:'a'.repeat(40),baseRef:'staging', + branchName:'refs/heads/knowledge/draft-1',allowedPaths:['books/**','knowledge/**']}; + const client={ + readFile:vi.fn(async({path}:{path:string})=>({sha:'existing-sha',content:seed[path]})), + listRepositoryPaths:vi.fn(async()=>({resolvedRef:'a'.repeat(40),entries:[{path:bookPath},{path:pagePath}],page:{hasMore:false}})), + status:vi.fn(async()=>({changes:[]})), + applyChangeset:vi.fn(async(_input:{patch:string})=>({applied:true})), + }; + vi.mocked(resolveKnowledgeGatewayConnection).mockResolvedValue({client,contentPath:'.',repositoryId:'repo-1'} as never); + const store={ + getKnowledgeWorkspace:vi.fn(async()=>workspace), + getProjectDetails:vi.fn(async()=>({project:{id:'project',teamId:'team'}})), + principalCanAccessTeam:vi.fn(async()=>true), + getTeamAccessSummary:vi.fn(async()=>({permissions:['knowledge:author']})), + updateKnowledgeWorkspace:vi.fn(async()=>({ok:true,workspace:{...workspace,version:2}})), + recordAuditEvent:vi.fn(async()=>undefined), + }; + const service=createKnowledgeWorkspaceService(store,{projectCatalog:vi.fn(async()=>({books:[],pages:[]}))}); + await service.updateContent({id:'author'},workspace.id,{kind:'book',version:1,sourcePath:bookPath, + id:'team-operations',slug:'team-operations',title:'Team Operations',summary:'Updated team standards.', + visibility:'team',order:0}); + const patch=client.applyChangeset.mock.calls[0]?.[0]?.patch ?? ''; + expect(patch).toContain(bookPath); + expect(patch).toContain(pagePath); + expect(patch).toContain('revision: 2'); + }); +}); diff --git a/tests/unit/control-plane/knowledge/workspaces/objective-workspace.test.ts b/tests/unit/control-plane/knowledge/workspaces/objective-workspace.test.ts index bb460d66..789fd48c 100644 --- a/tests/unit/control-plane/knowledge/workspaces/objective-workspace.test.ts +++ b/tests/unit/control-plane/knowledge/workspaces/objective-workspace.test.ts @@ -70,7 +70,7 @@ describe('objective TreeDX workspace authority', () => { const { client, service, workspace } = fixture(); const result = await service.updateContent({ id: 'author' }, workspace.id, { kind: 'operational-content', version: 1, create: true, sourcePath: 'objectives/core.md', - content: '---\ntitle: Core objective\ndescription: Govern the project through TreeDX.\nstatus: live\ntime_horizon: long-term\n---\n\n# Core objective\n', + content: '---\nschemaVersion: treeseed.objective/v1\nid: core\nprojectId: project\ntitle: Core objective\noutcome: Govern the project through TreeDX.\nstatus: active\n---\n', }); expect(result).toMatchObject({ workspace: { version: 2 } }); expect(client.applyChangeset).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/tests/unit/control-plane/teams/managed-team-library-content.test.ts b/tests/unit/control-plane/teams/managed-team-library-content.test.ts index 26df9424..77b10756 100644 --- a/tests/unit/control-plane/teams/managed-team-library-content.test.ts +++ b/tests/unit/control-plane/teams/managed-team-library-content.test.ts @@ -2,10 +2,14 @@ import { describe,expect,it } from 'vitest'; import { managedTeamLibrarySeedFiles } from '../../../../src/api/teams/managed-team-library-service.ts'; import { parseBook,parseKnowledgePage,validateKnowledgeCatalog } from '../../../../src/api/knowledge/runtime/catalog.ts'; import { requireKnowledgePageBookPath } from '../../../../src/api/knowledge/snapshot-projects.ts'; +import { parseFrontmatterDocument } from '../../../../src/api/content/frontmatter.ts'; +import { validatePortableContentData } from '@treeseed/sdk/content-validation'; describe('managed Team Library content',()=>{ it('creates only book-owned documents in the knowledge directory',()=>{ - const entries=Object.entries(managedTeamLibrarySeedFiles); + const entries=Object.entries(managedTeamLibrarySeedFiles('team-library-project')); + const objective=entries.find(([path])=>path==='objectives/core.mdx')?.[1]; + expect(validatePortableContentData('objective',parseFrontmatterDocument(objective??'').frontmatter).ok).toBe(true); const books=entries.filter(([path])=>path.startsWith('books/')).map(([path,raw])=>parseBook({path,raw})); const pages=entries.filter(([path])=>path.startsWith('knowledge/')).map(([path,raw])=>{ expect(path.split('/')).toHaveLength(3); @@ -14,6 +18,9 @@ describe('managed Team Library content',()=>{ return page; }); validateKnowledgeCatalog(books,pages); + expect(()=>validateKnowledgeCatalog(books,[{...pages[0]!,bookRef:{...pages[0]!.bookRef,digest:`sha256:${'0'.repeat(64)}`}},...pages.slice(1)])).toThrow(/exact owning Book/); + expect(()=>validateKnowledgeCatalog(books,[{...pages[0]!,bookRef:{...pages[0]!.bookRef,revision:2}},...pages.slice(1)])).toThrow(/exact owning Book/); + expect(()=>validateKnowledgeCatalog(books,[{...pages[0]!,bookRef:{...pages[0]!.bookRef,path:'books/another.md'}},...pages.slice(1)])).toThrow(/exact owning Book/); expect(books.map((book)=>book.id)).toEqual(['team-operations']); expect(pages).toHaveLength(6); expect(pages.map((page)=>page.id)).toContain('team-knowledge-authoring'); From 6486de1714d4eb9586a934c5c707f47636036c98 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sun, 20 Sep 2026 23:57:21 -0400 Subject: [PATCH 5/8] Align context query fixture with knowledge contract --- .../control-plane/knowledge/context-query-runtime.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/control-plane/knowledge/context-query-runtime.test.ts b/tests/unit/control-plane/knowledge/context-query-runtime.test.ts index 4d2405e4..b9df8af8 100644 --- a/tests/unit/control-plane/knowledge/context-query-runtime.test.ts +++ b/tests/unit/control-plane/knowledge/context-query-runtime.test.ts @@ -15,16 +15,16 @@ describe('context query runtime', () => { test: { queryRef: { id: 'guide-work', revision: 1 }, testRef: 'guide-work-v1', expectedIdentities: ['guide.work'], expectedRelations: [], - expectedPaths: ['knowledge/guide/work.md'], expectedSchemaVersions: ['treeseed.knowledge-page/v1'], + expectedPaths: ['knowledge/guide/work.md'], expectedSchemaVersions: ['treeseed.knowledge-page/v2'], resultBounds: { min: 1, max: 1 }, budget: { maxContextItems: 1, maxTokens: 500 }, }, execute: async () => ({ nodes: [{ node: { id: 'file:1', path: 'knowledge/guide/work.md', - data: { frontmatter: { id: 'guide.work', schemaVersion: 'treeseed.knowledge-page/v1' } } } }], edges: [] }), + data: { frontmatter: { id: 'guide.work', schemaVersion: 'treeseed.knowledge-page/v2' } } } }], edges: [] }), }); expect(report.status).toBe('passing'); expect(report.stats).toMatchObject({ itemCount: 1, identities: expect.arrayContaining(['guide.work']), - paths: ['knowledge/guide/work.md'], schemaVersions: ['treeseed.knowledge-page/v1'] }); + paths: ['knowledge/guide/work.md'], schemaVersions: ['treeseed.knowledge-page/v2'] }); }); it('uses TreeDX token diagnostics when composing query-set budgets',async()=>{ From c18519e9a5d21cac8073b531ab929edf848edaca Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Mon, 21 Sep 2026 00:10:38 -0400 Subject: [PATCH 6/8] Place proposal readiness tests under functional ownership --- .../{ => proposals}/draft-graph-readiness.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename tests/unit/control-plane/governance/{ => proposals}/draft-graph-readiness.test.ts (80%) diff --git a/tests/unit/control-plane/governance/draft-graph-readiness.test.ts b/tests/unit/control-plane/governance/proposals/draft-graph-readiness.test.ts similarity index 80% rename from tests/unit/control-plane/governance/draft-graph-readiness.test.ts rename to tests/unit/control-plane/governance/proposals/draft-graph-readiness.test.ts index 50e905a3..138aae57 100644 --- a/tests/unit/control-plane/governance/draft-graph-readiness.test.ts +++ b/tests/unit/control-plane/governance/proposals/draft-graph-readiness.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it, vi } from 'vitest'; -vi.mock('../../../../src/api/governance/executable-proposal.ts', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../../../../../src/api/governance/executable-proposal.ts', async (importOriginal) => { + const original = await importOriginal(); return { ...original, readExactProposal: vi.fn(async () => ({ definition: { status: 'draft', executionPlan: { workItems: [{ estimate: { minimumSeconds: 10 }, review: 'required', reviewEstimate: { minimumSeconds: 5 } }] }, } })) }; }); -vi.mock('../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', +vi.mock('../../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', () => ({ reconcileExecutionGraph: vi.fn(async () => undefined) })); -import { governanceProposalReadinessMethod } from '../../../../src/api/store/governance/policy/contracts/governance-proposal-readiness.ts'; -import { createGovernanceDecisionFromProposalMethod } from '../../../../src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts'; +import { governanceProposalReadinessMethod } from '../../../../../src/api/store/governance/policy/contracts/governance-proposal-readiness.ts'; +import { createGovernanceDecisionFromProposalMethod } from '../../../../../src/api/store/governance/policy/creation/create-governance-decision-from-proposal.ts'; describe('draft proposal graph readiness', () => { it('uses the exact complete draft and independent Reviewer result without a second ready-content revision', async () => { From e9cc07edb59c6bb6909343f30d5c18bc3286496f Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Mon, 21 Sep 2026 00:43:41 -0400 Subject: [PATCH 7/8] Adopt canonical SDK and Deployment contracts --- package-lock.json | 25 ++++++++++++------------- package.json | 4 ++-- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60d97697..b7cc8f1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ "@modelcontextprotocol/server": "2.0.0", "@octokit/auth-app": "^8.2.0", "@react-email/render": "^2.0.8", - "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.299/treeseed-deployment-runtime-0.1.0-rc.299.tgz", + "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "0.13.0-rc.119", + "@treeseed/sdk": "0.13.0-rc.122", "@treeseed/treedx": "0.3.0-rc.17", "drizzle-orm": "^0.45.2", "hono": "4.13.3", @@ -1876,12 +1876,12 @@ "license": "MIT" }, "node_modules/@treeseed/deployment": { - "version": "0.1.0-rc.299", - "resolved": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.299/treeseed-deployment-runtime-0.1.0-rc.299.tgz", - "integrity": "sha512-ciQ1kWXuTTUeU6szD/QCk4+0ZTORPRuhsHYI0idQJl9vL1oWGZMHgsnW9Jvz9kyonCSqNYceQ7EJqn9Ull/lRw==", + "version": "0.1.0-rc.321", + "resolved": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", + "integrity": "sha512-w2Clo3KNt73/IkDQIQKzInzVx4GDxInCig4LXkN1ewzZIFtv08GJTKcnKPkCk6XTfNTnLrq66ewz2KK/sxZBxg==", "dependencies": { "@treeseed/identity": "0.1.0-rc.23", - "@treeseed/sdk": "0.13.0-rc.112", + "@treeseed/sdk": "0.13.0-rc.121", "pg": "8.22.0", "tar": "7.5.22", "wrangler": "4.128.0", @@ -1923,9 +1923,9 @@ } }, "node_modules/@treeseed/deployment/node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.112", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.112.tgz", - "integrity": "sha512-ppO8mDan/FeVla0Dvq5Nr14c0O3nX/VJG6/1cgLJQtI0sZtFp3VmUPS11aOxo9D6u5igdrjvChtEssqBnTtu7Q==", + "version": "0.13.0-rc.121", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.121.tgz", + "integrity": "sha512-AKf+Ge2qXCMsMJziuY0UBjF76zNE5vVcTGK6li5N2NX6MZ6L1F+aCtX4Bv9eXVkDBBBzytAfjjuOCTcTe81i6g==", "dependencies": { "@treeseed/treedx": "0.3.0-rc.17", "esbuild": "^0.28.0", @@ -2000,10 +2000,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.119", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.119.tgz", - "integrity": "sha512-lEsa4L4ji2BQvzWxIZpz9MgIlFqnpht0XBnw5YWbljhcNT43worVfX8LY7jerukYAsTovULA/OhGJ9LSoWf4Sw==", - "license": "Apache-2.0", + "version": "0.13.0-rc.122", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.122.tgz", + "integrity": "sha512-JKk6yDA1SDV2rWscnr95XsIGUdOcTYC8Ac6adQXkXArMriSVn0g25IPOntj2l2TK8s83KoKUmPaa9QfXbqg5jg==", "dependencies": { "@treeseed/treedx": "0.3.0-rc.17", "esbuild": "^0.28.0", diff --git a/package.json b/package.json index 2ad8d4db..42392867 100644 --- a/package.json +++ b/package.json @@ -69,9 +69,9 @@ "@modelcontextprotocol/server": "2.0.0", "@octokit/auth-app": "^8.2.0", "@react-email/render": "^2.0.8", - "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.299/treeseed-deployment-runtime-0.1.0-rc.299.tgz", + "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "0.13.0-rc.119", + "@treeseed/sdk": "0.13.0-rc.122", "@treeseed/treedx": "0.3.0-rc.17", "drizzle-orm": "^0.45.2", "hono": "4.13.3", From a447f5455a7d8ad99a2dd1bcc9bf4f756264d27c Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Mon, 21 Sep 2026 00:50:44 -0400 Subject: [PATCH 8/8] Verify API against canonical SDK artifact --- .github/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 9ef7ec89..4d6629a1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies timeout-minutes: 20 run: npm ci --ignore-scripts --workspaces=false --no-audit --no-fund - - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@5bf0a7bc3e9090f0885abb915101d33fe5d28df8 + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@1186bff3b400fe442642013b2de93e7f0d4939df with: github-token: ${{ github.token }} paths: |