@agent-pattern-labs/iso-postflight
Advanced tools
+108
-12
@@ -15,5 +15,7 @@ import { isJsonObject, isJsonValue } from "./json.js"; | ||
| throw new Error("postflight config workflows must be an array"); | ||
| const workflows = input.workflows.map((workflow, index) => normalizeWorkflow(workflow, `workflows[${index}]`)); | ||
| assertUnique(workflows.map((workflow) => workflow.name), "workflow name"); | ||
| return { | ||
| version: 1, | ||
| workflows: input.workflows.map((workflow, index) => normalizeWorkflow(workflow, `workflows[${index}]`)), | ||
| workflows, | ||
| }; | ||
@@ -27,5 +29,8 @@ } | ||
| throw new Error("postflight plan rounds must be an array"); | ||
| const rounds = input.rounds.map((round, index) => normalizePlanRound(round, index)); | ||
| assertUnique(rounds.map((round) => String(round.index)), "round index"); | ||
| assertUnique(rounds.flatMap((round) => round.candidates.map((candidate) => candidate.id)), "plan candidate id"); | ||
| return { | ||
| workflow, | ||
| rounds: input.rounds.map((round, index) => normalizePlanRound(round, index)), | ||
| rounds, | ||
| postSteps: optionalSteps(input.postSteps, "plan.postSteps") ?? [], | ||
@@ -48,2 +53,3 @@ }; | ||
| const workflow = selectWorkflow(config, options.workflow || plan.workflow); | ||
| validateRelationships(workflow, plan, observations, options); | ||
| const outcomeMap = new Map(observations.outcomes.map((outcome) => [outcome.candidateId, outcome])); | ||
@@ -55,3 +61,3 @@ const dispatchSet = new Set([ | ||
| const rounds = plan.rounds.map((round) => settleRound(workflow, round, outcomeMap, dispatchSet)); | ||
| const postSteps = settlePostSteps(plan.postSteps.length ? plan.postSteps : workflow.postSteps, observations.steps); | ||
| const postSteps = settlePostSteps(mergePostSteps(workflow.postSteps, plan.postSteps), observations.steps); | ||
| const issues = [ | ||
@@ -87,2 +93,11 @@ ...rounds.flatMap((round) => round.candidates.flatMap((candidate) => candidate.issues)), | ||
| const skipStatuses = optionalStringArray(input.skipStatuses, `${label}.skipStatuses`) ?? DEFAULT_SKIP_STATUSES; | ||
| const inFlightStatuses = optionalStringArray(input.inFlightStatuses, `${label}.inFlightStatuses`) ?? DEFAULT_IN_FLIGHT_STATUSES; | ||
| const replacementStatuses = optionalStringArray(input.replacementStatuses, `${label}.replacementStatuses`) ?? []; | ||
| validateStatusCategories(label, { | ||
| successStatuses, | ||
| failureStatuses, | ||
| skipStatuses, | ||
| inFlightStatuses, | ||
| replacementStatuses, | ||
| }); | ||
| const terminalStatuses = optionalStringArray(input.terminalStatuses, `${label}.terminalStatuses`) ?? [ | ||
@@ -100,4 +115,4 @@ ...successStatuses, | ||
| skipStatuses, | ||
| inFlightStatuses: optionalStringArray(input.inFlightStatuses, `${label}.inFlightStatuses`) ?? DEFAULT_IN_FLIGHT_STATUSES, | ||
| replacementStatuses: optionalStringArray(input.replacementStatuses, `${label}.replacementStatuses`) ?? [], | ||
| inFlightStatuses, | ||
| replacementStatuses, | ||
| requiredArtifacts: optionalRequiredArtifacts(input.requiredArtifacts, `${label}.requiredArtifacts`) ?? [], | ||
@@ -138,3 +153,5 @@ postSteps: optionalSteps(input.postSteps, `${label}.postSteps`) ?? [], | ||
| throw new Error(`${label} must be an array`); | ||
| return input.map((dispatch, index) => normalizeDispatch(dispatch, `${label}[${index}]`)); | ||
| const dispatches = input.map((dispatch, index) => normalizeDispatch(dispatch, `${label}[${index}]`)); | ||
| assertUnique(dispatches.map((dispatch) => dispatch.candidateId), "dispatch candidate id"); | ||
| return dispatches; | ||
| } | ||
@@ -155,3 +172,5 @@ function normalizeDispatch(input, label) { | ||
| throw new Error(`${label} must be an array`); | ||
| return input.map((outcome, index) => normalizeOutcome(outcome, `${label}[${index}]`)); | ||
| const outcomes = input.map((outcome, index) => normalizeOutcome(outcome, `${label}[${index}]`)); | ||
| assertUnique(outcomes.map((outcome) => outcome.candidateId), "outcome candidate id"); | ||
| return outcomes; | ||
| } | ||
@@ -172,4 +191,7 @@ function normalizeOutcome(input, label) { | ||
| return []; | ||
| if (Array.isArray(input)) | ||
| return input.map((artifact, index) => normalizeArtifact(artifact, `${label}[${index}]`)); | ||
| if (Array.isArray(input)) { | ||
| const artifacts = input.map((artifact, index) => normalizeArtifact(artifact, `${label}[${index}]`)); | ||
| assertUnique(artifacts.map((artifact) => artifact.id), "artifact id"); | ||
| return artifacts; | ||
| } | ||
| if (!isJsonObject(input)) | ||
@@ -203,3 +225,5 @@ throw new Error(`${label} must be an array or object`); | ||
| throw new Error(`${label} must be an array`); | ||
| return input.map((step, index) => normalizeStepObservation(step, `${label}[${index}]`)); | ||
| const steps = input.map((step, index) => normalizeStepObservation(step, `${label}[${index}]`)); | ||
| assertUnique(steps.map((step) => step.id), "step observation id"); | ||
| return steps; | ||
| } | ||
@@ -220,3 +244,5 @@ function normalizeStepObservation(input, label) { | ||
| throw new Error(`${label} must be an array`); | ||
| return input.map((artifact, index) => normalizeRequiredArtifact(artifact, `${label}[${index}]`)); | ||
| const artifacts = input.map((artifact, index) => normalizeRequiredArtifact(artifact, `${label}[${index}]`)); | ||
| assertUnique(artifacts.map((artifact) => artifact.id), "required artifact id"); | ||
| return artifacts; | ||
| } | ||
@@ -237,3 +263,5 @@ function normalizeRequiredArtifact(input, label) { | ||
| throw new Error(`${label} must be an array`); | ||
| return input.map((step, index) => normalizeStep(step, `${label}[${index}]`)); | ||
| const steps = input.map((step, index) => normalizeStep(step, `${label}[${index}]`)); | ||
| assertUnique(steps.map((step) => step.id), "post step id"); | ||
| return steps; | ||
| } | ||
@@ -485,2 +513,22 @@ function normalizeStep(input, label) { | ||
| } | ||
| function validateRelationships(workflow, plan, observations, options) { | ||
| if (options.workflow && plan.workflow && options.workflow !== plan.workflow) { | ||
| throw new Error(`plan workflow "${plan.workflow}" does not match requested workflow "${options.workflow}"`); | ||
| } | ||
| const candidateIds = new Set(plan.rounds.flatMap((round) => round.candidates.map((candidate) => candidate.id))); | ||
| for (const dispatch of observations.dispatches) { | ||
| if (!candidateIds.has(dispatch.candidateId)) | ||
| throw new Error(`foreign dispatch candidate id "${dispatch.candidateId}" is not in the plan`); | ||
| } | ||
| for (const outcome of observations.outcomes) { | ||
| if (!candidateIds.has(outcome.candidateId)) | ||
| throw new Error(`foreign outcome candidate id "${outcome.candidateId}" is not in the plan`); | ||
| } | ||
| const effectiveSteps = mergePostSteps(workflow.postSteps, plan.postSteps); | ||
| const effectiveStepIds = new Set(effectiveSteps.map((step) => step.id)); | ||
| for (const step of observations.steps) { | ||
| if (!effectiveStepIds.has(step.id)) | ||
| throw new Error(`foreign step observation id "${step.id}" is not in the plan`); | ||
| } | ||
| } | ||
| function normalizeMeta(input, label) { | ||
@@ -529,2 +577,50 @@ if (input === undefined) | ||
| } | ||
| function validateStatusCategories(label, categories) { | ||
| const exclusive = [ | ||
| ["successStatuses", categories.successStatuses], | ||
| ["failureStatuses", categories.failureStatuses], | ||
| ["skipStatuses", categories.skipStatuses], | ||
| ["inFlightStatuses", categories.inFlightStatuses], | ||
| ]; | ||
| for (let index = 0; index < exclusive.length; index += 1) { | ||
| for (let otherIndex = index + 1; otherIndex < exclusive.length; otherIndex += 1) { | ||
| const [leftName, left] = exclusive[index]; | ||
| const [rightName, right] = exclusive[otherIndex]; | ||
| assertNoStatusOverlap(label, leftName, left, rightName, right); | ||
| } | ||
| } | ||
| // Replacement is a refinement of failure when the two overlap. It must not | ||
| // compete with success, skip, or in-flight classification. | ||
| assertNoStatusOverlap(label, "replacementStatuses", categories.replacementStatuses, "successStatuses", categories.successStatuses); | ||
| assertNoStatusOverlap(label, "replacementStatuses", categories.replacementStatuses, "skipStatuses", categories.skipStatuses); | ||
| assertNoStatusOverlap(label, "replacementStatuses", categories.replacementStatuses, "inFlightStatuses", categories.inFlightStatuses); | ||
| } | ||
| function assertNoStatusOverlap(label, leftName, left, rightName, right) { | ||
| const rightStatuses = statusSet(right); | ||
| const overlap = left.map(normalizeStatus).find((status) => rightStatuses.has(status)); | ||
| if (overlap) { | ||
| throw new Error(`${label} status "${overlap}" appears in both ${leftName} and ${rightName}`); | ||
| } | ||
| } | ||
| function mergePostSteps(required, planned) { | ||
| const order = []; | ||
| const byId = new Map(); | ||
| for (const step of [...required, ...planned]) { | ||
| if (!byId.has(step.id)) | ||
| order.push(step.id); | ||
| const previous = byId.get(step.id); | ||
| byId.set(step.id, previous | ||
| ? { ...previous, ...step, command: step.command ?? previous.command } | ||
| : step); | ||
| } | ||
| return order.map((id) => byId.get(id)); | ||
| } | ||
| function assertUnique(values, label) { | ||
| const seen = new Set(); | ||
| for (const value of values) { | ||
| if (seen.has(value)) | ||
| throw new Error(`duplicate ${label} "${value}"`); | ||
| seen.add(value); | ||
| } | ||
| } | ||
| //# sourceMappingURL=postflight.js.map |
+1
-1
| { | ||
| "name": "@agent-pattern-labs/iso-postflight", | ||
| "version": "0.1.1", | ||
| "version": "0.1.2", | ||
| "description": "Deterministic postflight settlement for AI-agent workflows: reconcile dispatch plans, observed outcomes, artifacts, and post-steps without model calls.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+13
-0
@@ -107,2 +107,15 @@ # @agent-pattern-labs/iso-postflight | ||
| Candidate, round, outcome, dispatch, artifact, and step IDs must be unique and | ||
| candidate observations must refer to IDs declared by the selected plan. | ||
| Required artifact IDs are requirements, not an allowlist: outcomes may include | ||
| additional artifact evidence. Workflow post-steps always remain required; | ||
| plan post-steps can override matching presentation details or add supplemental | ||
| steps, but cannot remove workflow requirements. Step observations must refer to | ||
| one of those effective workflow or plan steps. | ||
| Success, failure, skip, and in-flight status categories are compared after | ||
| trimming and case-folding and must not overlap. Replacement statuses may | ||
| overlap failure statuses intentionally—replacement is checked first—but cannot | ||
| overlap success, skip, or in-flight statuses. | ||
| ## Outcome shape | ||
@@ -109,0 +122,0 @@ |
54812
11.1%1201
8.69%172
8.18%