From 778c8eca2fd8c9daf021b892e9f050706d606463 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Thu, 17 Sep 2026 02:06:41 -0400 Subject: [PATCH 1/3] Route recurring workdays through canonical intent admission and preserve custody mode --- .github/workflows/verify.yml | 2 +- ...034_recurring_workday_canonical_intent.sql | 18 +++++ .../capacity/workdays/workday-envelope.ts | 3 +- .../scheduling/workday-preflight-service.ts | 11 +-- .../scheduling/workday-schedule-service.ts | 79 ++++++++----------- .../recurring-canonical-intent.test.ts | 75 ++++++++++++++++++ .../workdays/selection-preflight.test.ts | 8 ++ 7 files changed, 144 insertions(+), 52 deletions(-) create mode 100644 drizzle/control-plane/0034_recurring_workday_canonical_intent.sql create mode 100644 tests/unit/control-plane/capacity/workdays/scheduling/recurring-canonical-intent.test.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 7d558152..16de680f 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@641fdc5a45d76d30ffa52cd51752043846bb3d44 + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@410e9077c370426f156cc1b1b3666eaa88408228 with: github-token: ${{ github.token }} paths: | diff --git a/drizzle/control-plane/0034_recurring_workday_canonical_intent.sql b/drizzle/control-plane/0034_recurring_workday_canonical_intent.sql new file mode 100644 index 00000000..e5599430 --- /dev/null +++ b/drizzle/control-plane/0034_recurring_workday_canonical_intent.sql @@ -0,0 +1,18 @@ +ALTER TABLE "capacity_workday_schedules" ADD COLUMN "intent_json" text; +--> statement-breakpoint +UPDATE "capacity_workday_schedules" SET "intent_json" = jsonb_strip_nulls(jsonb_build_object( + 'schemaVersion', 'treeseed.workday-intent/v1', 'teamId', "team_id", 'profileId', 'default', + 'projects', "project_ids_json"::jsonb, 'startsAt', "next_run_at", 'durationSeconds', "duration_seconds", + 'planningOnly', "planning_only" = 1, + 'agentSelection', (SELECT jsonb_object_agg(key, value) FROM jsonb_each("agent_selection_json"::jsonb) + WHERE key IN ('agentSlugs', 'activityTypes', 'classIds', 'classSlugs') + AND jsonb_typeof(value) = 'array' AND value <> '[]'::jsonb), + 'operatorConstraints', jsonb_build_object('providerIds', jsonb_build_array("capacity_provider_id"), 'maxConcurrency', "max_active_assignments") +))::text; +--> statement-breakpoint +ALTER TABLE "capacity_workday_schedules" ALTER COLUMN "intent_json" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "capacity_workday_schedules" + DROP COLUMN "capacity_provider_id", DROP COLUMN "project_ids_json", DROP COLUMN "agent_selection_json", + DROP COLUMN "duration_seconds", DROP COLUMN "max_active_assignments", DROP COLUMN "available_seconds", + DROP COLUMN "time_policy_json", DROP COLUMN "planning_only", DROP COLUMN "publication_policy_json"; diff --git a/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts b/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts index c2162975..c287a47f 100644 --- a/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts +++ b/src/api/capacity/repositories/capacity/workdays/workday-envelope.ts @@ -33,7 +33,6 @@ export interface CreateWorkdayCapacityEnvelopeInput { completedAt?: string | null; environment?: string | null; availableSeconds?: number | null; - timePolicy?: JsonRecord; envelope?: JsonRecord; metadata?: JsonRecord; } @@ -105,7 +104,7 @@ export class WorkdayCapacityEnvelopeRepository { 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), timePolicy: object(input.timePolicy), + availableSeconds: amount(input.availableSeconds, null), metadata: object(suppliedEnvelope.metadata ?? metadata), ...suppliedEnvelope, }; diff --git a/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts b/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts index d88fd70e..086474b0 100644 --- a/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts +++ b/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts @@ -33,7 +33,7 @@ function digest(value:unknown):string { return `sha256:${sha256(canonicalJson(va function diagnosticsError(code:string,message:string,diagnostics:unknown):never { throw new CapacityGovernanceError(code,message,400,{diagnostics}); } export function parsePublicWorkdayIntent(teamId:string,input:JsonRecord):WorkdayIntent { - const allowed=new Set(['schemaVersion','teamId','profileId','projects','startsAt','endsAt','durationSeconds','objectiveFilters','planningOnly','proposalIds','decisionIds','operatorConstraints','agentSelection','allocation']); + const allowed=new Set(['schemaVersion','teamId','profileId','projects','executionMode','startsAt','endsAt','durationSeconds','objectiveFilters','planningOnly','proposalIds','decisionIds','operatorConstraints','agentSelection','allocation']); const forbidden=Object.keys(input).filter((key)=>!allowed.has(key)); if(forbidden.length) diagnosticsError('workday_intent_derived_fields_forbidden','Workday preflight accepts high-level intent only.',forbidden.map((path)=>({code:'field_forbidden',path}))); if(input.teamId!==undefined&&text(input.teamId)!==teamId) diagnosticsError('workday_intent_team_mismatch','Workday intent team must match the route team.',[{code:'team_mismatch',path:'teamId'}]); @@ -46,6 +46,7 @@ export function parsePublicWorkdayIntent(teamId:string,input:JsonRecord):Workday const intent:WorkdayIntent={ schemaVersion:'treeseed.workday-intent/v1', teamId, profileId:text(input.profileId)||'default', projects, startsAt, + ...(input.executionMode !== undefined ? { executionMode: input.executionMode as WorkdayIntent['executionMode'] } : {}), ...(input.endsAt!==undefined?{endsAt:text(input.endsAt)}:{}), ...(input.durationSeconds!==undefined?{durationSeconds:Number(input.durationSeconds)}:{}), ...(Array.isArray(input.objectiveFilters)?{objectiveFilters:input.objectiveFilters.map(text).filter(Boolean)}:{}), @@ -98,7 +99,7 @@ export class WorkdayPreflightService { } const runInput:JsonRecord={ id:`workday-${id}`,capacityProviderId:providerId,status:'running',startedAt:startsAt,requestedById, - executionMode:'simulation',executionKind:'workday',triggerKind:'manual', + executionMode:intent.executionMode ?? 'simulation',executionKind:'workday',triggerKind:'manual', environment:'local',scenarioId:`profile:${intent.profileId}`, parameters:{ ...policy, ...intent.allocation, policyId:profile.id, policyRevision:profile.revision, profileId:intent.profileId,projectSlugs:intent.projects==='all'?[]:intent.projects, @@ -156,9 +157,9 @@ export class WorkdayPreflightService { return {receipt,intent,runInput}; } - async preflight(teamId:string,intent:WorkdayIntent,requestedById:string|null):Promise { - const stored=await this.compile(teamId,intent,requestedById,randomUUID()); - await this.store.run(`INSERT INTO capacity_operation_receipts (id,team_id,operation,idempotency_key,request_digest,resource_type,resource_id,response_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,[ + async preflight(teamId:string,intent:WorkdayIntent,requestedById:string|null,id=randomUUID()):Promise { + const stored=await this.compile(teamId,intent,requestedById,id); + await this.store.run(`INSERT INTO capacity_operation_receipts (id,team_id,operation,idempotency_key,request_digest,resource_type,resource_id,response_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT (team_id,operation,idempotency_key) DO UPDATE SET request_digest=EXCLUDED.request_digest,response_json=EXCLUDED.response_json,updated_at=EXCLUDED.updated_at`,[ randomUUID(),teamId,'workday.preflight',stored.receipt.id,stored.receipt.intentDigest,'workday_preflight',stored.receipt.id,canonicalJson(stored),new Date().toISOString(),new Date().toISOString(), ]); return stored.receipt; diff --git a/src/api/capacity/services/capacity/workdays/scheduling/workday-schedule-service.ts b/src/api/capacity/services/capacity/workdays/scheduling/workday-schedule-service.ts index 380c771d..4f5e3dc4 100644 --- a/src/api/capacity/services/capacity/workdays/scheduling/workday-schedule-service.ts +++ b/src/api/capacity/services/capacity/workdays/scheduling/workday-schedule-service.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { validateWorkdayTimePolicy, type CapacityWorkdayRunRecord, type CapacityWorkdayScheduleRecord } from '@treeseed/sdk/agent-capacity'; -import { normalizeWorkdayAgentSelection } from '../../../../policy/workdays/workday.ts'; +import type { CapacityWorkdayRunRecord, CapacityWorkdayScheduleRecord } from '@treeseed/sdk/agent-capacity'; +import { parsePublicWorkdayIntent, WorkdayPreflightService } from './workday-preflight-service.ts'; +import type { WorkdayStartReceipt } from '@treeseed/sdk/operator-contracts'; import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import { CapacityGovernanceError } from '../../../../database.ts'; -import type { WorkdayProject } from '../policy/workday-project-policy.ts'; type Row = Record; type Status = CapacityWorkdayScheduleRecord['status']; @@ -13,35 +13,23 @@ const SCHEDULE_STATUSES = new Set(['active', 'paused', 'completed', 'fai function json(value: unknown, fallback: T): T { try { return typeof value === 'string' ? JSON.parse(value) as T : value as T; } catch { return fallback; } } function text(value: unknown, fallback = '') { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } function integer(value: unknown, fallback: number, minimum: number) { const parsed = Number(value ?? fallback); if (!Number.isInteger(parsed) || parsed < minimum) throw new CapacityGovernanceError('capacity_workday_schedule_value_invalid', `Schedule value must be an integer of at least ${minimum}.`, 400); return parsed; } -function strings(value: unknown) { return Array.isArray(value) ? [...new Set(value.map(String).map((entry) => entry.trim()).filter(Boolean))] : []; } -function publicationPolicy(value: unknown): CapacityWorkdayScheduleRecord['publicationPolicy'] { - const input = value && typeof value === 'object' && !Array.isArray(value) ? value as Row : {}; - return { bookIds: strings(input.bookIds), target: input.target === 'production' ? 'production' : 'staging', cohortMode: 'accepted', - requireTechnicalReview: input.requireTechnicalReview !== false, requireAudienceReview: input.requireAudienceReview !== false, - requireGraphReviewWhenStructural: input.requireGraphReviewWhenStructural !== false, simulatedHumanApproval: input.simulatedHumanApproval === true }; -} -function timePolicy(value: unknown, planningOnly: boolean) { - const candidate = value && typeof value === 'object' && !Array.isArray(value) ? value : { cooperativePlanningPercent: planningOnly ? 90 : 25, governedExecutionPercent: planningOnly ? 0 : 65, reservePercent: 10 }; - const validation = validateWorkdayTimePolicy(candidate); - if (!validation.ok || !validation.value) throw new CapacityGovernanceError('capacity_workday_time_policy_invalid', 'Workday time policy must contain Plan, Execute, and Reserve percentages totaling 100.', 400, { diagnostics: validation.diagnostics }); - if (planningOnly && validation.value.governedExecutionPercent !== 0) throw new CapacityGovernanceError('capacity_workday_planning_only_execution_invalid', 'Planning-only schedules require zero governed execution time.', 400); - return validation.value; +function inputKeys(input: Row, allowed: string[]) { + const unexpected = Object.keys(input).filter(key => !allowed.includes(key)); + if (unexpected.length) throw new CapacityGovernanceError('capacity_workday_schedule_fields_invalid', `Unsupported schedule fields: ${unexpected.join(', ')}. Use canonical workday intent.`, 400); } export function serializeWorkdaySchedule(row: Row | null): CapacityWorkdayScheduleRecord | null { if (!row) return null; const status = String(row.status) as Status; if (!SCHEDULE_STATUSES.has(status)) throw new CapacityGovernanceError('capacity_workday_schedule_corrupt', 'Schedule status is invalid.', 500); - return { id: String(row.id), teamId: String(row.team_id), projectIds: json(row.project_ids_json, []), status, purpose: String(row.purpose), - capacityProviderId: String(row.capacity_provider_id), agentSelection: normalizeWorkdayAgentSelection(json(row.agent_selection_json, {})), - cadenceSeconds: Number(row.cadence_seconds), durationSeconds: Number(row.duration_seconds), maxActiveAssignments: Number(row.max_active_assignments), - availableSeconds: Number(row.available_seconds), timePolicy: timePolicy(json(row.time_policy_json, {}), Number(row.planning_only) === 1), planningOnly: Number(row.planning_only) === 1, - publicationPolicy: publicationPolicy(json(row.publication_policy_json, {})), lastRunId: row.last_run_id ? String(row.last_run_id) : null, + return { id: String(row.id), teamId: String(row.team_id), status, purpose: String(row.purpose), + cadenceSeconds: Number(row.cadence_seconds), intent: parsePublicWorkdayIntent(String(row.team_id), json(row.intent_json, {})), + lastRunId: row.last_run_id ? String(row.last_run_id) : null, nextRunAt: String(row.next_run_at), stateVersion: Number(row.state_version), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; } interface ScheduleStore extends CapacityGovernanceDatabase { getCapacityWorkdayRun(teamId: string, runId: string): Promise; createCapacityWorkdayRun(teamId: string, input: Row): Promise; - listTeamProjects(teamId: string): Promise; + preflightCapacityWorkdayRunRequest(teamId: string, input: Row): Promise; } export class CapacityWorkdayScheduleService { @@ -49,30 +37,26 @@ export class CapacityWorkdayScheduleService { async get(teamId: string, id: string) { await this.store.ensureInitialized(); return serializeWorkdaySchedule(await this.store.first('SELECT * FROM capacity_workday_schedules WHERE id = ? AND team_id = ?', [id, teamId])); } async list(teamId: string) { await this.store.ensureInitialized(); return (await this.store.all('SELECT * FROM capacity_workday_schedules WHERE team_id = ? ORDER BY created_at DESC, id DESC LIMIT 200', [teamId])).map(serializeWorkdaySchedule); } async create(teamId: string, input: Row) { - await this.store.ensureInitialized(); const now = new Date().toISOString(); const projectIds = strings(input.projectIds); - if (!projectIds.length) throw new CapacityGovernanceError('capacity_workday_schedule_projects_required', 'A schedule requires at least one project id.', 400); - if (input.availableCredits !== undefined) throw new CapacityGovernanceError('capacity_workday_schedule_legacy_credits_rejected', 'Recurring schedules use agent time, not credits.', 409); - const durationSeconds = integer(input.durationSeconds, 1800, 60); const maxActiveAssignments = integer(input.maxActiveAssignments, 3, 1); - const availableSeconds = integer(input.availableSeconds, durationSeconds * maxActiveAssignments, 1); const planningOnly = input.planningOnly !== false; const policy = timePolicy(input.timePolicy, planningOnly); + inputKeys(input, ['id', 'purpose', 'intent', 'cadenceSeconds', 'nextRunAt']); + await this.store.ensureInitialized(); const now = new Date().toISOString(); + const intent = parsePublicWorkdayIntent(teamId, input.intent as Row ?? {}); const id = text(input.id, randomUUID()); const nextRunAt = text(input.nextRunAt, now); if (!Number.isFinite(Date.parse(nextRunAt))) throw new CapacityGovernanceError('capacity_workday_schedule_time_invalid', 'nextRunAt must be a valid ISO timestamp.', 400); - await this.store.run(`INSERT INTO capacity_workday_schedules (id, team_id, capacity_provider_id, status, purpose, project_ids_json, agent_selection_json, cadence_seconds, duration_seconds, max_active_assignments, available_seconds, time_policy_json, planning_only, publication_policy_json, last_run_id, next_run_at, state_version, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, ?, ?)`, - [id, teamId, text(input.capacityProviderId), text(input.purpose, 'Recurring editorial workday'), JSON.stringify(projectIds), JSON.stringify(normalizeWorkdayAgentSelection(input.agentSelection)), integer(input.cadenceSeconds, 3600, 60), durationSeconds, maxActiveAssignments, availableSeconds, JSON.stringify(policy), planningOnly ? 1 : 0, JSON.stringify(publicationPolicy(input.publicationPolicy)), nextRunAt, now, now]); + await this.store.run(`INSERT INTO capacity_workday_schedules (id, team_id, status, purpose, cadence_seconds, intent_json, last_run_id, next_run_at, state_version, created_at, updated_at) VALUES (?, ?, 'active', ?, ?, ?, NULL, ?, 1, ?, ?)`, + [id, teamId, text(input.purpose, 'Recurring workday'), integer(input.cadenceSeconds, 3600, 60), JSON.stringify(intent), nextRunAt, now, now]); return this.get(teamId, id); } async update(teamId: string, id: string, input: Row) { + inputKeys(input, ['stateVersion', 'status', 'purpose', 'intent', 'cadenceSeconds', 'nextRunAt']); const current = await this.get(teamId, id); if (!current) return null; const expected = integer(input.stateVersion, current.stateVersion, 1); if (expected !== current.stateVersion) throw new CapacityGovernanceError('capacity_workday_schedule_version_stale', 'Schedule changed after inspection.', 409); const status = input.status === undefined ? current.status : String(input.status) as Status; if (!SCHEDULE_STATUSES.has(status)) throw new CapacityGovernanceError('capacity_workday_schedule_status_invalid', 'Schedule status is invalid.', 400); - const next = { ...current, status, purpose: text(input.purpose, current.purpose), projectIds: input.projectIds ? strings(input.projectIds) : current.projectIds, - agentSelection: input.agentSelection ? normalizeWorkdayAgentSelection(input.agentSelection) : current.agentSelection, - cadenceSeconds: integer(input.cadenceSeconds, current.cadenceSeconds, 60), durationSeconds: integer(input.durationSeconds, current.durationSeconds, 60), - maxActiveAssignments: integer(input.maxActiveAssignments, current.maxActiveAssignments, 1), availableSeconds: integer(input.availableSeconds, current.availableSeconds, 1), - planningOnly: input.planningOnly === undefined ? current.planningOnly : input.planningOnly === true, - publicationPolicy: input.publicationPolicy ? publicationPolicy(input.publicationPolicy) : current.publicationPolicy, + const next = { ...current, status, purpose: text(input.purpose, current.purpose), + intent: input.intent === undefined ? current.intent : parsePublicWorkdayIntent(teamId, input.intent as Row), + cadenceSeconds: integer(input.cadenceSeconds, current.cadenceSeconds, 60), nextRunAt: text(input.nextRunAt, current.nextRunAt), stateVersion: current.stateVersion + 1, updatedAt: new Date().toISOString() }; - const nextTimePolicy = timePolicy(input.timePolicy ?? current.timePolicy, next.planningOnly); - await this.store.run(`UPDATE capacity_workday_schedules SET status = ?, purpose = ?, project_ids_json = ?, agent_selection_json = ?, cadence_seconds = ?, duration_seconds = ?, max_active_assignments = ?, available_seconds = ?, time_policy_json = ?, planning_only = ?, publication_policy_json = ?, next_run_at = ?, state_version = ?, updated_at = ? WHERE id = ? AND team_id = ? AND state_version = ?`, - [next.status, next.purpose, JSON.stringify(next.projectIds), JSON.stringify(next.agentSelection), next.cadenceSeconds, next.durationSeconds, next.maxActiveAssignments, next.availableSeconds, JSON.stringify(nextTimePolicy), next.planningOnly ? 1 : 0, JSON.stringify(next.publicationPolicy), next.nextRunAt, next.stateVersion, next.updatedAt, id, teamId, current.stateVersion]); + if (!Number.isFinite(Date.parse(next.nextRunAt))) throw new CapacityGovernanceError('capacity_workday_schedule_time_invalid', 'nextRunAt must be a valid ISO timestamp.', 400); + await this.store.run(`UPDATE capacity_workday_schedules SET status = ?, purpose = ?, cadence_seconds = ?, intent_json = ?, next_run_at = ?, state_version = ?, updated_at = ? WHERE id = ? AND team_id = ? AND state_version = ?`, + [next.status, next.purpose, next.cadenceSeconds, JSON.stringify(next.intent), next.nextRunAt, next.stateVersion, next.updatedAt, id, teamId, current.stateVersion]); const updated = await this.get(teamId, id); if (updated?.stateVersion !== next.stateVersion) throw new CapacityGovernanceError('capacity_workday_schedule_version_stale', 'Schedule changed concurrently.', 409); return updated; } async tick(teamId: string, id: string, now = new Date().toISOString()) { @@ -97,11 +81,18 @@ export class CapacityWorkdayScheduleService { return { considered: rows.length, created, failures }; } private async createClaimedRun(schedule: CapacityWorkdayScheduleRecord, runId: string, now: string) { - const existing = await this.store.getCapacityWorkdayRun(schedule.teamId, runId); if (existing) return { schedule, run: existing, action: 'replayed' }; - const allProjects = await this.store.listTeamProjects(schedule.teamId); const projects = schedule.projectIds.map((id) => allProjects.find((project) => project.id === id)).filter((project): project is WorkdayProject => Boolean(project)); - if (projects.length !== schedule.projectIds.length) throw new CapacityGovernanceError('capacity_workday_schedule_project_missing', 'A scheduled project is unavailable.', 409); - const run = await this.store.createCapacityWorkdayRun(schedule.teamId, { id: runId, capacityProviderId: schedule.capacityProviderId, scenarioId: schedule.purpose, status: 'running', environment: 'local', startedAt: now, - parameters: { purpose: schedule.purpose, projects: projects.map((project) => project.slug ?? project.id), durationSeconds: schedule.durationSeconds, maxActiveAssignments: schedule.maxActiveAssignments, availableSeconds: schedule.availableSeconds, timePolicy: schedule.timePolicy, planningOnly: schedule.planningOnly, agentSelection: schedule.agentSelection, publicationPolicy: schedule.publicationPolicy, scheduleId: schedule.id } }); - return { schedule: await this.get(schedule.teamId, schedule.id), run, action: 'created' }; + const idempotencyKey = `workday-schedule:${schedule.id}:${runId}`; + const saved = await this.store.first(`SELECT response_json FROM capacity_operation_receipts WHERE team_id = ? AND operation = 'workday.start' AND idempotency_key = ? LIMIT 1`, [schedule.teamId, idempotencyKey]); + let receipt: WorkdayStartReceipt; + if (saved) receipt = json(saved.response_json, null)!; + else { + const service = new WorkdayPreflightService(this.store); + const { endsAt, ...intent } = schedule.intent; + const preflight = await service.preflight(schedule.teamId, parsePublicWorkdayIntent(schedule.teamId, { ...intent, startsAt: schedule.updatedAt, + ...(endsAt ? { durationSeconds: Math.floor((Date.parse(endsAt) - Date.parse(intent.startsAt)) / 1000) } : {}) }), null, runId); + receipt = await service.start(schedule.teamId, { preflightId: preflight.id, preflightDigest: preflight.preflightDigest, idempotencyKey }, null); + } + await this.store.run(`UPDATE capacity_workday_schedules SET last_run_id = ?, updated_at = ? WHERE id = ? AND team_id = ? AND last_run_id = ?`, [receipt.workdayId, now, schedule.id, schedule.teamId, runId]); + return { schedule: await this.get(schedule.teamId, schedule.id), run: await this.store.getCapacityWorkdayRun(schedule.teamId, receipt.workdayId), action: saved ? 'replayed' : 'created' }; } } diff --git a/tests/unit/control-plane/capacity/workdays/scheduling/recurring-canonical-intent.test.ts b/tests/unit/control-plane/capacity/workdays/scheduling/recurring-canonical-intent.test.ts new file mode 100644 index 00000000..3a2bed4c --- /dev/null +++ b/tests/unit/control-plane/capacity/workdays/scheduling/recurring-canonical-intent.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import pg from 'pg'; +import { CapacityWorkdayScheduleService, serializeWorkdaySchedule } from '../../../../../../src/api/capacity/services/capacity/workdays/scheduling/workday-schedule-service.ts'; +import { WorkdayPreflightService } from '../../../../../../src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts'; + +const intent = { schemaVersion: 'treeseed.workday-intent/v1', teamId: 'team', profileId: 'default', projects: ['sdk'], + startsAt: '2026-09-17T00:00:00.000Z', durationSeconds: 600, executionMode: 'production', + allocation: { planningPercent: 20, allocationWeight: 2, projectPercentages: { sdk: 100 } } }; +const row = () => ({ id: 'schedule', team_id: 'team', status: 'active', purpose: 'Useful work', intent_json: JSON.stringify(intent), + cadence_seconds: 600, last_run_id: 'claimed', next_run_at: '2026-09-17T00:10:00.000Z', state_version: 2, created_at: intent.startsAt, updated_at: intent.startsAt }); + +describe('recurring workdays use canonical admission', () => { + afterEach(() => vi.restoreAllMocks()); + it('routes the claimed occurrence through preflight/start with only high-level intent', async () => { + const preflight = vi.spyOn(WorkdayPreflightService.prototype, 'preflight').mockResolvedValue({ id: 'preflight', preflightDigest: 'digest' } as never); + const start = vi.spyOn(WorkdayPreflightService.prototype, 'start').mockResolvedValue({ workdayId: 'workday-real' } as never); + const current = row(); + const direct = vi.fn(); + const store = { ensureInitialized: async () => {}, all: async () => [], createCapacityWorkdayRun: direct, + first: async (sql: string) => sql.includes('capacity_operation_receipts') ? null : current, + getCapacityWorkdayRun: async (_team: string, id: string) => id === 'workday-real' ? { id, status: 'running' } : null, + run: async (_sql: string, args: unknown[]) => { current.last_run_id = String(args[0]); } }; + const service = new CapacityWorkdayScheduleService(store as never); + const now = '2026-09-17T01:00:00.000Z'; + const result = await service.tick('team', 'schedule', now); + expect(preflight).toHaveBeenCalledWith('team', { ...intent, startsAt: current.updated_at }, null, 'claimed'); + expect(start).toHaveBeenCalledWith('team', { preflightId: 'preflight', preflightDigest: 'digest', idempotencyKey: 'workday-schedule:schedule:claimed' }, null); + expect(direct).not.toHaveBeenCalled(); + expect(result).toMatchObject({ action: 'created', run: { id: 'workday-real' }, schedule: { lastRunId: 'workday-real' } }); + }); + it('recovers the same start receipt without a second preflight or run', async () => { + const preflight = vi.spyOn(WorkdayPreflightService.prototype, 'preflight'); + const current = row(); + const store = { ensureInitialized: async () => {}, + first: async (sql: string) => sql.includes('capacity_operation_receipts') ? { response_json: JSON.stringify({ workdayId: 'restored' }) } : current, + getCapacityWorkdayRun: async (_team: string, id: string) => id === 'restored' ? { id, status: 'running' } : null, + run: async (_sql: string, args: unknown[]) => { current.last_run_id = String(args[0]); } }; + const result = await new CapacityWorkdayScheduleService(store as never).tick('team', 'schedule'); + expect(result).toMatchObject({ action: 'replayed', run: { id: 'restored' } }); + expect(preflight).not.toHaveBeenCalled(); + }); + it('rejects retired schedule percentages and budgets rather than translating them', async () => { + const service = new CapacityWorkdayScheduleService({} as never); + for (const field of ['timePolicy', 'availableSeconds', 'maxActiveAssignments', 'projectIds']) { + await expect(service.create('team', { intent, [field]: {} })).rejects.toThrow('Unsupported schedule fields'); + } + expect(serializeWorkdaySchedule(row())?.intent).toEqual(intent); + }); +}); + +describe.skipIf(!process.env.TREESEED_TEST_POSTGRES_URL)('native PostgreSQL recurring-intent migration', () => { + it('preserves schedule identity while removing all obsolete allocation columns', async () => { + const connection = new URL(process.env.TREESEED_TEST_POSTGRES_URL!); + if (!['127.0.0.1', 'localhost'].includes(connection.hostname) || connection.pathname !== '/postgres') throw new Error('Use local disposable PostgreSQL.'); + const admin = new pg.Pool({ connectionString: connection.href }), name = `treeseed_schedule_test_${randomUUID().replaceAll('-', '')}`; + let pool: pg.Pool | undefined; + try { + await admin.query(`CREATE DATABASE "${name}"`); connection.pathname = `/${name}`; + pool = new pg.Pool({ connectionString: connection.href }); + const baseline = readFileSync('drizzle/control-plane/0000_control_plane.sql', 'utf8'); + const table = baseline.match(/CREATE TABLE "capacity_workday_schedules" \([\s\S]*?\n\);/u)?.[0]; + if (!table) throw new Error('Schedule baseline missing'); + await pool.query(table); + await pool.query(`INSERT INTO capacity_workday_schedules VALUES ('schedule','team','provider','paused','Useful work','["sdk"]','{}',600,600,1,600,'{}',0,'{}',NULL,'2026-09-17T00:00:00.000Z',1,'prior','prior')`); + await pool.query(readFileSync('drizzle/control-plane/0034_recurring_workday_canonical_intent.sql', 'utf8')); + const saved = (await pool.query('SELECT * FROM capacity_workday_schedules')).rows[0]; + expect(serializeWorkdaySchedule(saved)).toMatchObject({ id: 'schedule', status: 'paused', intent: { projects: ['sdk'], durationSeconds: 600, operatorConstraints: { providerIds: ['provider'], maxConcurrency: 1 } } }); + for (const retired of ['time_policy_json', 'available_seconds', 'publication_policy_json', 'planning_only']) expect(saved).not.toHaveProperty(retired); + } finally { + await pool?.end(); await admin.query(`DROP DATABASE IF EXISTS "${name}"`); await admin.end(); + } + }); +}); diff --git a/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts b/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts index e60b852e..c421decb 100644 --- a/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts +++ b/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts @@ -32,6 +32,14 @@ function fixture() { } describe('public workday selection custody', () => { + it('preserves explicit production custody and defaults omitted mode to simulation', async () => { + const f = fixture(); + await f.service.preflight('team', parsePublicWorkdayIntent('team', { ...input(), executionMode: 'production' }), 'actor'); + expect(f.stored().runInput.executionMode).toBe('production'); + await f.service.preflight('team', parsePublicWorkdayIntent('team', input()), 'actor'); + expect(f.stored().runInput.executionMode).toBe('simulation'); + expect(() => parsePublicWorkdayIntent('team', { ...input(), executionMode: 'other' })).toThrow(/invalid/u); + }); it('projects inherited team targets onto selected projects without filtering explicit overrides', async () => { const f = fixture(); f.store.first.mockImplementation(async (sql: string) => sql.includes('FROM teams') ? { metadata_json: JSON.stringify({ workdayProfile: { From 3f8ae6be1cd90a5e9781604765709c7bf6d266a9 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Thu, 17 Sep 2026 02:11:55 -0400 Subject: [PATCH 2/3] Retain deterministic allocation explanations and replayable canonical intent --- .github/workflows/verify.yml | 2 +- .../assignments/admission/living-allocation-inputs.ts | 5 +++-- .../assignments/admission/living-execution-admission.ts | 5 +++-- .../planning/execution/assignment-attempt-builder.ts | 2 +- .../planning/execution/living-execution-assignment.ts | 2 +- .../workdays/scheduling/workday-preflight-service.ts | 3 ++- .../capacity/execution/admission/allocation-inputs.test.ts | 2 ++ .../capacity/workdays/selection-preflight.test.ts | 3 +++ 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 16de680f..e4bedcc7 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@410e9077c370426f156cc1b1b3666eaa88408228 + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@2869a1e963f1674408a0036b61a60700122bba3b with: github-token: ${{ github.token }} paths: | 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 02b97846..b67a8d43 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 @@ -4,7 +4,8 @@ import type { CapacityGovernanceDatabase } from '../../../../database.ts'; import type { DurableCapacityWorkdayRun } from '../../../../repositories/capacity/workdays/workday-run.ts'; import type { ProviderSynthesisExecutionProvider } from '../../providers/provider-synthesis-context-service.ts'; -export type LivingAllocationInputs = Record; +export type LivingAllocationInputs = Record[string] }>; /** Read existing graph/reservation/usage authority; no performance or allocation store. */ export async function livingAllocationInputs(store: CapacityGovernanceDatabase, input: { @@ -66,7 +67,7 @@ export async function livingAllocationInputs(store: CapacityGovernanceDatabase, OR (assignment.status='failed' AND assignment.lifecycle_code='assignment_timeout')) ORDER BY usage.created_at DESC,usage.id DESC LIMIT 20`, [input.capacityProviderId, provider.id, input.agentClass, limits.modelConfigurationId, input.capabilityId, input.activity]); - result[provider.id] = { constraints: [{ id: 'workday-phase-share', remainingSeconds: shares[input.run.id]?.availableSeconds ?? 0 }], + result[provider.id] = { opportunity: shares[input.run.id]!, constraints: [{ id: 'workday-phase-share', remainingSeconds: shares[input.run.id]?.availableSeconds ?? 0 }], measurements: rows.map(row => ({ id: String(row.id), completedAt: String(row.created_at), expectedSeconds: Number(row.expected_seconds), allocatedSeconds: Number(row.allocated_seconds), activeSeconds: Number(row.active_seconds), outcome: row.lifecycle_code === 'assignment_timeout' ? 'expired' : 'completed' })) }; } 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 65c65b21..b53e6406 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 @@ -1,4 +1,4 @@ -import type { AssignmentAttempt, CapabilityAccountingLimits, calculateAssignmentAllocation } from '@treeseed/sdk/agent-capacity'; +import type { AssignmentAttempt, CapabilityAccountingLimits, calculateAssignmentAllocation, allocateWorkdayCapacity, selectFairReadyNode } from '@treeseed/sdk/agent-capacity'; import { randomUUID } from 'node:crypto'; import { capabilityCounterClaims, initializeCapabilityCounters, commitCapabilityCounters } from './capability-counter-claims.ts'; import type { CapacityGovernanceDatabase } from '../../../../database.ts'; @@ -17,7 +17,8 @@ type JsonRecord = Record; export async function admitLivingExecutionAssignment(store: Store, input: { principal: ProviderLeasePrincipal; assignment: AssignmentAttempt; - allocation: ReturnType; + allocation: ReturnType & { + opportunity: ReturnType[string]; selection: ReturnType }; accountingLimits: CapabilityAccountingLimits; projectAgentClassId: string; providerSessionId: string; 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 d9b07b4d..ebcc8531 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 @@ -205,6 +205,6 @@ export function buildAssignmentAttempt(input: { deadline, leaseId: id('lease', [assignmentId]), reservationId: id('reservation', [assignmentId]), attempt: input.attempt, status: 'created', createdAt: input.now, }); - return { assignment, allocation, accountingLimits: limits, executionProviderId: selected.provider.id, laneId: selected.lane.id, + return { assignment, allocation: { ...allocation, opportunity: allocationInputs.opportunity }, accountingLimits: limits, executionProviderId: selected.provider.id, laneId: selected.lane.id, lanePurpose: communication ? 'communication' : 'workday' }; } 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 16430e61..6c2710b5 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 @@ -177,7 +177,7 @@ 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, + return await admitLivingExecutionAssignment(store, { principal, assignment: attempt, allocation: { ...selected.allocation, selection: selectedNode }, accountingLimits: selected.accountingLimits, projectAgentClassId: candidate.projectAgentClassId, providerSessionId, executionProviderId: selected.executionProviderId, laneId: selected.laneId, diff --git a/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts b/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts index 086474b0..332bcac7 100644 --- a/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts +++ b/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts @@ -63,7 +63,8 @@ export function parsePublicWorkdayIntent(teamId:string,input:JsonRecord):Workday const diagnostics=validateWorkdayIntent(intent); if(projects!=='all'&&!projects.length) diagnostics.push({code:'projects_required',path:'projects',message:'Select at least one project or all.'}); if(diagnostics.length) diagnosticsError('workday_intent_invalid','Workday intent is invalid.',diagnostics); - if(intent.agentSelection!==undefined) intent.agentSelection=normalizeWorkdayAgentSelection(intent.agentSelection); + if(intent.agentSelection!==undefined) intent.agentSelection=Object.fromEntries(Object.entries(normalizeWorkdayAgentSelection(intent.agentSelection)) + .filter(([,value])=>!Array.isArray(value)||value.length>0)) as WorkdayIntent['agentSelection']; return intent; } 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 2728e446..91b80024 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 @@ -22,6 +22,8 @@ describe('live allocation ledger inputs', () => { const result = await livingAllocationInputs(store as never, { run: run as never, runs: [run as never], providers: [provider as never], capacityProviderId: 'provider', capabilityId: 'implementation', agentClass: 'engineer', activity: 'act', now }); expect(result['codex-implementation']?.constraints[0]?.remainingSeconds).toBe(700); + expect(result['codex-implementation']?.opportunity).toMatchObject({ weight: 1, totalEligibleWeight: 1, + committedSeconds: 0, remainingSupplySeconds: 700, shareSeconds: 700, phase: 'acting', availableSeconds: 700 }); expect(store.all.mock.calls[0]![0]).toContain("NULLIF(assignment.assignment_attempt_json::jsonb->'provider'->>'modelConfigurationId','') IS NULL"); }); it('calibrates productive deadline expiration, not uncertain lease recovery', async () => { diff --git a/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts b/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts index c421decb..667a3a11 100644 --- a/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts +++ b/tests/unit/control-plane/capacity/workdays/selection-preflight.test.ts @@ -91,6 +91,9 @@ describe('public workday selection custody', () => { expect(parsePublicWorkdayIntent('team', unselected).agentSelection).toBeUndefined(); for (const invalid of [{}, null, { agentSlugs: [] }, { agentSlugs: [''] }, { activityTypes: ['acting'] }]) expect(() => parsePublicWorkdayIntent('team', { ...input(), agentSelection: invalid })).toThrow(/invalid/u); expect(parsePublicWorkdayIntent('team', { ...input(), agentSelection: { ...agentSelection, agentSlugs: [' reviewer ', 'reviewer'] } }).agentSelection?.agentSlugs).toEqual(['reviewer']); + const canonical = parsePublicWorkdayIntent('team', input()); + expect(parsePublicWorkdayIntent('team', canonical as unknown as Record)).toEqual(canonical); + expect(canonical.agentSelection).not.toHaveProperty('classIds'); }); it('normalizes explicit accepted-decision selection and rejects malformed selection', () => { expect(parsePublicWorkdayIntent('team', { ...input(), decisionIds: [' decision-b ', 'decision-a', 'decision-b'] }).decisionIds).toEqual(['decision-a', 'decision-b']); From 7e629cb1401068766f842d7fb2219c82cd3e5470 Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Thu, 17 Sep 2026 02:15:07 -0400 Subject: [PATCH 3/3] Pin the verified protected SDK artifact rather than the PR merge preview --- .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 e4bedcc7..9ef7ec89 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@2869a1e963f1674408a0036b61a60700122bba3b + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@5bf0a7bc3e9090f0885abb915101d33fe5d28df8 with: github-token: ${{ github.token }} paths: |