@tea-agent/loop-agent 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +59 -11
- package/README.md +1 -1
- package/dist/application/evaluation/alias.js +184 -0
- package/dist/application/evaluation/budget.js +192 -0
- package/dist/application/evaluation/campaign-hash.js +47 -0
- package/dist/application/evaluation/campaign-matrix.js +372 -0
- package/dist/application/evaluation/campaign-scorecard.js +135 -0
- package/dist/application/evaluation/campaign.js +370 -0
- package/dist/application/evaluation/candidate.js +23 -6
- package/dist/application/evaluation/corpus-hash.js +38 -0
- package/dist/application/evaluation/corpus.js +56 -0
- package/dist/application/evaluation/experiment.js +294 -0
- package/dist/application/evaluation/ignition.js +198 -0
- package/dist/application/evaluation/integrity-audit.js +162 -0
- package/dist/application/evaluation/outer-loop.js +132 -0
- package/dist/application/evaluation/pi-cell-executor.js +39 -0
- package/dist/application/evaluation/private-verifier.js +46 -0
- package/dist/application/evaluation/promotion-policy.js +151 -0
- package/dist/application/evaluation/proposer.js +98 -0
- package/dist/application/evaluation/types.js +522 -0
- package/dist/cli/command-definitions.js +19 -3
- package/dist/commands/eval.js +1176 -13
- package/dist/commands/init.js +4 -1
- package/dist/infrastructure/evaluation/alias-store.js +199 -0
- package/dist/infrastructure/evaluation/campaign-store.js +154 -0
- package/dist/infrastructure/evaluation/corpus-store.js +181 -0
- package/dist/infrastructure/evaluation/experiment-store.js +124 -0
- package/dist/infrastructure/evaluation/ignition-store.js +82 -0
- package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
- package/dist/infrastructure/evaluation/proposer-store.js +78 -0
- package/dist/worker/cli.js +6 -3
- package/dist/worker/delivery/final-verification.js +96 -8
- package/dist/worker/delivery/package.js +23 -4
- package/dist/worker/delivery/verification-bundle.js +521 -0
- package/dist/worker/feature/fullstack-validate.js +337 -0
- package/dist/worker/feature/profile-schema.js +44 -0
- package/dist/worker/feature/ready-plan-projection.js +1 -0
- package/dist/worker/feature/reducer.js +2 -0
- package/dist/worker/feature/review.js +106 -11
- package/dist/worker/materialize/harness-task-materializer.js +5 -0
- package/dist/worker/observability/read-model.js +7 -0
- package/dist/worker/observe/static/views/task.js +1 -0
- package/dist/worker/outcomes/adapters.js +144 -0
- package/dist/worker/outcomes/evidence-tokens.js +29 -0
- package/dist/worker/outcomes/gate.js +40 -0
- package/dist/worker/outcomes/projector.js +185 -0
- package/dist/worker/outcomes/registry.js +1 -0
- package/dist/worker/outcomes/store.js +131 -0
- package/dist/worker/outcomes/types.js +79 -0
- package/dist/worker/report/morning-report.js +4 -3
- package/dist/worker/run-task/run-task.js +85 -2
- package/dist/worker/runner/run-ready.js +32 -1
- package/dist/worker/task-graph/acceptance-schema.js +12 -0
- package/dist/worker/task-graph/ready-planner.js +131 -0
- package/dist/worker/task-graph/task-graph-schema.js +31 -0
- package/dist/worker/task-graph/validate.js +44 -4
- package/dist/worker/task-spec/schema.js +9 -0
- package/dist/worker/task-spec/validate.js +39 -0
- package/dist/worker/task-spec/workflow-routing.js +149 -0
- package/dist/workflows/dag/budget-enforcement.js +67 -0
- package/dist/workflows/dag/context-policy.js +137 -0
- package/dist/workflows/dag/knowledge-curator.js +3 -0
- package/dist/workflows/dag/node-execution.js +11 -4
- package/dist/workflows/dag/prompt.js +1 -1
- package/dist/workflows/dag/runner.js +43 -16
- package/dist/workflows/dag/skill-snapshot.js +11 -7
- package/dist/workflows/dag/types.js +18 -0
- package/docs/init-surface.manifest.json +3 -0
- package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
- package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
- package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
- package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
- package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
- package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
- package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
- package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
- package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
- package/docs/templates/product-line/AGENTS.md +1 -0
- package/docs/templates/product-line/README.md +17 -0
- package/docs/templates/product-line/acceptance.yaml +9 -0
- package/docs/templates/product-line/feature.yaml +11 -0
- package/docs/templates/product-line/task-graph.yaml +8 -0
- package/docs/templates/product-line/task.yaml +4 -0
- package/harness.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { splitStructuredArtifactIdentity } from "./evidence-tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shared artifact extraction. Every successful worker run produces a canonical
|
|
5
|
+
* `run_record` (the worker-run-record.json) and a `dag` (the generated DAG
|
|
6
|
+
* JSON). These are the deterministic `kind` tokens the required-output gate
|
|
7
|
+
* matches literally. Workflow adapters extend this with workflow-specific
|
|
8
|
+
* artifacts surfaced from the DAG report (e.g. structured JSON artifact gates).
|
|
9
|
+
*
|
|
10
|
+
* Adapters never invent tokens outside the canonical facts: if a report node
|
|
11
|
+
* did not emit a structured artifact, no artifact is added for it.
|
|
12
|
+
*/
|
|
13
|
+
function canonicalArtifacts(input) {
|
|
14
|
+
const artifacts = [];
|
|
15
|
+
if (input.runRecordPath) {
|
|
16
|
+
artifacts.push({
|
|
17
|
+
path: toRepoRelativePath(input.repoRoot, input.runRecordPath),
|
|
18
|
+
sha256: "",
|
|
19
|
+
kind: "run_record",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (input.dagPath) {
|
|
23
|
+
artifacts.push({
|
|
24
|
+
path: toRepoRelativePath(input.repoRoot, input.dagPath),
|
|
25
|
+
sha256: "",
|
|
26
|
+
kind: "dag_json",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return artifacts;
|
|
30
|
+
}
|
|
31
|
+
function toRepoRelativePath(repoRoot, candidate) {
|
|
32
|
+
return path.relative(repoRoot, candidate) || candidate;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pull structured JSON artifacts emitted by DAG nodes (jsonArtifactGate
|
|
36
|
+
* outputs). The adapter only carries path+sha256 when both are present in the
|
|
37
|
+
* report; the projector re-validates the sha256 against the file on disk.
|
|
38
|
+
*/
|
|
39
|
+
function structuredArtifactsFromReport(run) {
|
|
40
|
+
if (!run?.nodes)
|
|
41
|
+
return [];
|
|
42
|
+
const artifacts = [];
|
|
43
|
+
for (const node of run.nodes) {
|
|
44
|
+
if (node.structuredArtifactPath &&
|
|
45
|
+
node.structuredArtifactSha256 &&
|
|
46
|
+
node.structuredArtifactSchemaId) {
|
|
47
|
+
const identity = splitStructuredArtifactIdentity(node.structuredArtifactSchemaId);
|
|
48
|
+
artifacts.push({
|
|
49
|
+
path: node.structuredArtifactPath,
|
|
50
|
+
sha256: node.structuredArtifactSha256,
|
|
51
|
+
kind: identity.kind,
|
|
52
|
+
schemaId: identity.schemaId,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return artifacts;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `agent-dag` — the default standard agent DAG. No dedicated test/integration
|
|
60
|
+
* harness; integration status is `mock:false, real:false` (neither a mock nor a
|
|
61
|
+
* declared real integration). The canonical artifacts are run_record + dag.
|
|
62
|
+
*/
|
|
63
|
+
class AgentDagAdapter {
|
|
64
|
+
workflow = "agent-dag";
|
|
65
|
+
project(input) {
|
|
66
|
+
return {
|
|
67
|
+
artifacts: [
|
|
68
|
+
...canonicalArtifacts(input),
|
|
69
|
+
...structuredArtifactsFromReport(input.dagReportRun),
|
|
70
|
+
],
|
|
71
|
+
acceptanceCoverage: [...input.acceptanceRefs],
|
|
72
|
+
integrationStatus: { mock: false, real: false },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `frontend-implementation` — produces a frontend implementation contract
|
|
78
|
+
* structured artifact. No automated real integration by default.
|
|
79
|
+
*/
|
|
80
|
+
class FrontendImplementationAdapter {
|
|
81
|
+
workflow = "frontend-implementation";
|
|
82
|
+
project(input) {
|
|
83
|
+
return {
|
|
84
|
+
artifacts: [
|
|
85
|
+
...canonicalArtifacts(input),
|
|
86
|
+
...structuredArtifactsFromReport(input.dagReportRun),
|
|
87
|
+
],
|
|
88
|
+
acceptanceCoverage: [...input.acceptanceRefs],
|
|
89
|
+
integrationStatus: { mock: false, real: false },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* `backend-test` — generates + executes backend tests. Integration status is
|
|
95
|
+
* `real: true` only when the report completed (a real test execution); mock is
|
|
96
|
+
* always distinct and never merged into the real flag.
|
|
97
|
+
*/
|
|
98
|
+
class BackendTestAdapter {
|
|
99
|
+
workflow = "backend-test";
|
|
100
|
+
project(input) {
|
|
101
|
+
const real = input.reportDecision.succeeded;
|
|
102
|
+
return {
|
|
103
|
+
artifacts: [
|
|
104
|
+
...canonicalArtifacts(input),
|
|
105
|
+
...structuredArtifactsFromReport(input.dagReportRun),
|
|
106
|
+
],
|
|
107
|
+
acceptanceCoverage: [...input.acceptanceRefs],
|
|
108
|
+
integrationStatus: { mock: false, real },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* `frontend-test` — browser/UI tests. Distinct from backend-test: `real` is
|
|
114
|
+
* true only on a successful report; mock is never merged with real.
|
|
115
|
+
*/
|
|
116
|
+
class FrontendTestAdapter {
|
|
117
|
+
workflow = "frontend-test";
|
|
118
|
+
project(input) {
|
|
119
|
+
const real = input.reportDecision.succeeded;
|
|
120
|
+
return {
|
|
121
|
+
artifacts: [
|
|
122
|
+
...canonicalArtifacts(input),
|
|
123
|
+
...structuredArtifactsFromReport(input.dagReportRun),
|
|
124
|
+
],
|
|
125
|
+
acceptanceCoverage: [...input.acceptanceRefs],
|
|
126
|
+
integrationStatus: { mock: false, real },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const ADAPTERS = new Map([
|
|
131
|
+
["agent-dag", new AgentDagAdapter()],
|
|
132
|
+
["frontend-implementation", new FrontendImplementationAdapter()],
|
|
133
|
+
["backend-test", new BackendTestAdapter()],
|
|
134
|
+
["frontend-test", new FrontendTestAdapter()],
|
|
135
|
+
]);
|
|
136
|
+
export const OUTCOME_ADAPTER_WORKFLOWS = [...ADAPTERS.keys()];
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the outcome adapter for a workflow. Unknown workflows return
|
|
139
|
+
* `undefined` so the projector can fail closed deterministically — there is no
|
|
140
|
+
* silent fallback.
|
|
141
|
+
*/
|
|
142
|
+
export function getOutcomeAdapter(workflow) {
|
|
143
|
+
return ADAPTERS.get(workflow);
|
|
144
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared evidence / artifact identity helpers for Outcome gates, Feature review,
|
|
3
|
+
* and Feature Verification Bundle projection.
|
|
4
|
+
*/
|
|
5
|
+
/** Canonical required-output / required_evidence token for shell exit-zero. */
|
|
6
|
+
export const SHELL_VERIFICATION_TOKEN = "shell_verification";
|
|
7
|
+
/**
|
|
8
|
+
* Accept the canonical underscore form and the legacy hyphen form used in early
|
|
9
|
+
* fullstack packets so dual-coverage and bundle evidence stay aligned.
|
|
10
|
+
*/
|
|
11
|
+
export function isShellVerificationToken(token) {
|
|
12
|
+
return token === SHELL_VERIFICATION_TOKEN || token === "shell-verification";
|
|
13
|
+
}
|
|
14
|
+
/** Match an evidence token against artifact `kind` or `schemaId`. */
|
|
15
|
+
export function artifactMatchesEvidenceToken(artifact, token) {
|
|
16
|
+
return artifact.kind === token || artifact.schemaId === token;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Derive stable `kind` + versioned `schemaId` from a DAG structured-artifact
|
|
20
|
+
* schema id (e.g. `backend-test-result-v1` → kind `backend-test-result`).
|
|
21
|
+
* Unversioned ids keep kind === schemaId.
|
|
22
|
+
*/
|
|
23
|
+
export function splitStructuredArtifactIdentity(schemaId) {
|
|
24
|
+
const match = /^(.*)-v\d+$/.exec(schemaId);
|
|
25
|
+
if (match?.[1]) {
|
|
26
|
+
return { kind: match[1], schemaId };
|
|
27
|
+
}
|
|
28
|
+
return { kind: schemaId, schemaId };
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isShellVerificationToken, } from "./evidence-tokens.js";
|
|
2
|
+
export { artifactMatchesEvidenceToken, isShellVerificationToken, } from "./evidence-tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic required-output gate.
|
|
5
|
+
*
|
|
6
|
+
* The gate runs *after* a successful DAG report. It performs a **literal
|
|
7
|
+
* string match** of each known `outputs.required` token against produced
|
|
8
|
+
* artifact kinds (and the shell-verification exit-zero fact).
|
|
9
|
+
*
|
|
10
|
+
* Known tokens (`run_record`, `dag_json`, `shell_verification` / legacy
|
|
11
|
+
* `shell-verification`) fail closed when absent. Unknown legacy tokens are
|
|
12
|
+
* intentionally ignored for compatibility — they never invent a mapping and
|
|
13
|
+
* never block promotion. An empty `required` list always passes.
|
|
14
|
+
*
|
|
15
|
+
* This gate is the success-criteria guardrail: "DAG report succeeded but a
|
|
16
|
+
* known required output is missing ⇒ the Task must not be marked Done."
|
|
17
|
+
*/
|
|
18
|
+
export function checkRequiredOutputs(envelope, requiredOutputs) {
|
|
19
|
+
if (requiredOutputs.length === 0) {
|
|
20
|
+
return { passed: true, missing: [] };
|
|
21
|
+
}
|
|
22
|
+
const producedKinds = new Set(envelope.artifacts
|
|
23
|
+
.map((artifact) => artifact.kind)
|
|
24
|
+
.filter((value) => Boolean(value)));
|
|
25
|
+
const missing = [];
|
|
26
|
+
for (const token of requiredOutputs) {
|
|
27
|
+
if (isShellVerificationToken(token)) {
|
|
28
|
+
if (!envelope.shellVerification?.exitZero)
|
|
29
|
+
missing.push(token);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (KNOWN_ARTIFACT_OUTPUTS.has(token) && !producedKinds.has(token)) {
|
|
33
|
+
missing.push(token);
|
|
34
|
+
}
|
|
35
|
+
// Unknown tokens: intentionally ignored (legacy compatibility).
|
|
36
|
+
}
|
|
37
|
+
return { passed: missing.length === 0, missing };
|
|
38
|
+
}
|
|
39
|
+
/** M2 deliberately resolves only stable, deterministic legacy tokens. */
|
|
40
|
+
const KNOWN_ARTIFACT_OUTPUTS = new Set(["run_record", "dag_json"]);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { sha256File } from "../pool/run-store.js";
|
|
4
|
+
import { taskOutcomeEnvelopeV1Schema } from "./types.js";
|
|
5
|
+
import { getOutcomeAdapter } from "./adapters.js";
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic path-containment guard. Mirrors run-task's `isPathInside`: a
|
|
8
|
+
* path must resolve *inside* the repo root and must not escape it. Absolute
|
|
9
|
+
* paths outside the repo are rejected so an adapter can never point at an
|
|
10
|
+
* arbitrary filesystem location. Completed DAG run facts live under
|
|
11
|
+
* `.harness/dag-runs/`; outcome artifacts must not be read from outside the
|
|
12
|
+
* repo root.
|
|
13
|
+
*/
|
|
14
|
+
export function isRepoRelativePath(repoRoot, candidate) {
|
|
15
|
+
if (!candidate)
|
|
16
|
+
return false;
|
|
17
|
+
const resolved = path.resolve(repoRoot, candidate);
|
|
18
|
+
const relative = path.relative(path.resolve(repoRoot), resolved);
|
|
19
|
+
return (relative !== "" &&
|
|
20
|
+
!relative.startsWith("..") &&
|
|
21
|
+
!path.isAbsolute(relative));
|
|
22
|
+
}
|
|
23
|
+
function contractError(reason, missingOutputs) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
category: "ContractMismatch",
|
|
27
|
+
reason,
|
|
28
|
+
...(missingOutputs && missingOutputs.length > 0 ? { missingOutputs } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function envFailure(reason) {
|
|
32
|
+
return { ok: false, category: "EnvFailure", reason };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Project a completed worker run into a {@link TaskOutcomeEnvelopeV1}.
|
|
36
|
+
*
|
|
37
|
+
* The projector is the single deterministic gate between canonical facts and a
|
|
38
|
+
* successful outcome envelope:
|
|
39
|
+
* 1. Identity: `taskSpec.feature_id === runRecord.featureId`, `taskId` match,
|
|
40
|
+
* and `workerRunId` ownership (the run record must own the worker run id).
|
|
41
|
+
* 2. Workflow: an adapter must exist for the resolved workflow — no fallback.
|
|
42
|
+
* 3. Artifacts: every artifact path must be repo-relative; sha256 declared by
|
|
43
|
+
* the adapter is re-computed from the file on disk and compared. A missing
|
|
44
|
+
* file or hash mismatch is `EnvFailure` / `ContractMismatch` respectively.
|
|
45
|
+
* 4. Schema: the assembled envelope must parse against the strict schema.
|
|
46
|
+
*
|
|
47
|
+
* A projection failure is never turned into a successful Task Pool fact.
|
|
48
|
+
*/
|
|
49
|
+
export async function projectOutcome(input) {
|
|
50
|
+
// 1. Identity.
|
|
51
|
+
if (input.taskSpec.feature_id !== input.runRecord.featureId) {
|
|
52
|
+
return contractError(`feature_id mismatch: taskSpec=${input.taskSpec.feature_id} runRecord=${input.runRecord.featureId}`);
|
|
53
|
+
}
|
|
54
|
+
if (input.taskSpec.id !== input.runRecord.taskId) {
|
|
55
|
+
return contractError(`taskId mismatch: taskSpec=${input.taskSpec.id} runRecord=${input.runRecord.taskId}`);
|
|
56
|
+
}
|
|
57
|
+
if (input.workerRunId !== input.runRecord.workerRunId) {
|
|
58
|
+
return contractError(`workerRunId ownership mismatch: input=${input.workerRunId} runRecord=${input.runRecord.workerRunId}`);
|
|
59
|
+
}
|
|
60
|
+
// 2. Workflow adapter (no silent fallback).
|
|
61
|
+
const workflow = (input.runRecord.workflow ?? resolveLegacyWorkflow(input));
|
|
62
|
+
if (!workflow) {
|
|
63
|
+
return contractError(`outcome projection requires a resolved workflow; run record has none`);
|
|
64
|
+
}
|
|
65
|
+
const adapter = getOutcomeAdapter(workflow);
|
|
66
|
+
if (!adapter) {
|
|
67
|
+
return contractError(`no outcome adapter for workflow "${workflow}"`);
|
|
68
|
+
}
|
|
69
|
+
// 3. Adapter projection (pure; no FS).
|
|
70
|
+
const adapterProjection = adapter.project(input);
|
|
71
|
+
// 4. Artifact path + hash validation.
|
|
72
|
+
const validatedArtifacts = [];
|
|
73
|
+
for (const artifact of adapterProjection.artifacts) {
|
|
74
|
+
if (!isRepoRelativePath(input.repoRoot, artifact.path)) {
|
|
75
|
+
return contractError(`artifact path is not repo-relative: ${artifact.path}`);
|
|
76
|
+
}
|
|
77
|
+
const resolved = path.resolve(input.repoRoot, artifact.path);
|
|
78
|
+
let exists;
|
|
79
|
+
try {
|
|
80
|
+
await stat(resolved);
|
|
81
|
+
exists = true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
exists = false;
|
|
85
|
+
}
|
|
86
|
+
if (!exists) {
|
|
87
|
+
return envFailure(`artifact file is missing: ${artifact.path}`);
|
|
88
|
+
}
|
|
89
|
+
const recomputed = await safeSha256File(resolved);
|
|
90
|
+
if (recomputed === undefined) {
|
|
91
|
+
return envFailure(`artifact file is unreadable: ${artifact.path}`);
|
|
92
|
+
}
|
|
93
|
+
if (artifact.sha256 && artifact.sha256 !== recomputed) {
|
|
94
|
+
return contractError(`artifact sha256 mismatch for ${artifact.path}: declared=${artifact.sha256} actual=${recomputed}`);
|
|
95
|
+
}
|
|
96
|
+
validatedArtifacts.push({
|
|
97
|
+
path: artifact.path,
|
|
98
|
+
sha256: recomputed,
|
|
99
|
+
...(artifact.kind ? { kind: artifact.kind } : {}),
|
|
100
|
+
...(artifact.schemaId ? { schemaId: artifact.schemaId } : {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// 5. Shell verification from report decision (exit-zero = report succeeded).
|
|
104
|
+
const shellVerification = {
|
|
105
|
+
exitZero: input.reportDecision.succeeded,
|
|
106
|
+
...(input.reportDecision.reason
|
|
107
|
+
? { evidenceRef: input.reportDecision.reason }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
const controllerIdentity = input.runRecord.controllerIdentity
|
|
111
|
+
? {
|
|
112
|
+
packageName: input.runRecord.controllerIdentity.packageName,
|
|
113
|
+
packageVersion: input.runRecord.controllerIdentity.packageVersion,
|
|
114
|
+
}
|
|
115
|
+
: undefined;
|
|
116
|
+
const sourceBinding = input.sourceBinding ?? deriveSourceBinding(input);
|
|
117
|
+
const outcomeStatus = input.reportDecision.succeeded ? "succeeded" : "failed";
|
|
118
|
+
const outcomeFailure = outcomeStatus === "failed"
|
|
119
|
+
? {
|
|
120
|
+
category: "EnvFailure",
|
|
121
|
+
reason: input.reportDecision.reason || "DAG report did not succeed",
|
|
122
|
+
}
|
|
123
|
+
: undefined;
|
|
124
|
+
const envelope = {
|
|
125
|
+
schemaVersion: 1,
|
|
126
|
+
identity: {
|
|
127
|
+
featureId: input.runRecord.featureId,
|
|
128
|
+
taskId: input.runRecord.taskId,
|
|
129
|
+
workflow,
|
|
130
|
+
workerRunId: input.runRecord.workerRunId,
|
|
131
|
+
...(input.runRecord.harnessTaskId
|
|
132
|
+
? { harnessTaskId: input.runRecord.harnessTaskId }
|
|
133
|
+
: {}),
|
|
134
|
+
},
|
|
135
|
+
sourceBinding,
|
|
136
|
+
acceptanceCoverage: adapterProjection.acceptanceCoverage,
|
|
137
|
+
artifacts: validatedArtifacts,
|
|
138
|
+
shellVerification,
|
|
139
|
+
integrationStatus: adapterProjection.integrationStatus,
|
|
140
|
+
...(controllerIdentity ? { controllerIdentity } : {}),
|
|
141
|
+
createdAt: input.now.toISOString(),
|
|
142
|
+
outcomeStatus,
|
|
143
|
+
...(outcomeFailure ? { outcomeFailure } : {}),
|
|
144
|
+
};
|
|
145
|
+
// 6. Strict schema parse.
|
|
146
|
+
const parsed = taskOutcomeEnvelopeV1Schema.safeParse(envelope);
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
return contractError(`outcome envelope failed schema validation: ${parsed.error.message}`);
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, envelope: parsed.data };
|
|
151
|
+
}
|
|
152
|
+
function deriveSourceBinding(input) {
|
|
153
|
+
// TaskSpec paths may point into the Feature Packet (and need not be copied
|
|
154
|
+
// into an isolated test repo); the materializer owns their immutable copy.
|
|
155
|
+
// Preserve the declared binding here without treating availability as a
|
|
156
|
+
// successful artifact claim.
|
|
157
|
+
return { sourceFiles: [input.taskSpecPath] };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Legacy fallback for run records written before the additive `workflow` field.
|
|
161
|
+
* Resolves deterministically from the TaskSpec execution.workflow so M2 never
|
|
162
|
+
* changes Ready Planner routing.
|
|
163
|
+
*/
|
|
164
|
+
function resolveLegacyWorkflow(input) {
|
|
165
|
+
return input.taskSpec.execution?.workflow;
|
|
166
|
+
}
|
|
167
|
+
async function safeSha256File(filePath) {
|
|
168
|
+
try {
|
|
169
|
+
return await sha256File(filePath);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// read-side helper kept for symmetry with store.ts callers that need raw bytes.
|
|
176
|
+
export async function readRepoFile(repoRoot, relativePath) {
|
|
177
|
+
if (!isRepoRelativePath(repoRoot, relativePath))
|
|
178
|
+
return undefined;
|
|
179
|
+
try {
|
|
180
|
+
return await readFile(path.resolve(repoRoot, relativePath), "utf-8");
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { assertSafeTaskPoolId, getTaskPoolRoot, sha256Text, } from "../pool/run-store.js";
|
|
5
|
+
import { TASK_POOL_STATE_ERROR_CODES, TaskPoolStateError, } from "../pool/types.js";
|
|
6
|
+
import { taskOutcomeEnvelopeV1Schema } from "./types.js";
|
|
7
|
+
/** Canonical M2 projection root, shared with other Task Pool artifacts. */
|
|
8
|
+
export const OUTCOMES_RELATIVE_ROOT = "artifacts";
|
|
9
|
+
export function getOutcomesRoot(repoRoot) {
|
|
10
|
+
return path.join(getTaskPoolRoot(repoRoot), OUTCOMES_RELATIVE_ROOT);
|
|
11
|
+
}
|
|
12
|
+
export function getOutcomePath(repoRoot, workerRunId) {
|
|
13
|
+
assertSafeTaskPoolId(workerRunId, "outcome workerRunId");
|
|
14
|
+
const outcomePath = path.join(getOutcomesRoot(repoRoot), workerRunId, "task-outcome.json");
|
|
15
|
+
assertWithinOutcomesRoot(repoRoot, outcomePath);
|
|
16
|
+
return outcomePath;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Path-containment guard mirroring run-store's `assertWithinStatesRoot`. An
|
|
20
|
+
* outcome path must resolve strictly under `.harness/task-pool/outcomes/` so a
|
|
21
|
+
* crafted workerRunId can never escape to arbitrary repo paths or, crucially,
|
|
22
|
+
* into `.harness/dag-runs/**` completed facts.
|
|
23
|
+
*/
|
|
24
|
+
export function assertWithinOutcomesRoot(repoRoot, candidatePath) {
|
|
25
|
+
const outcomesRoot = path.resolve(getOutcomesRoot(repoRoot));
|
|
26
|
+
const resolved = path.resolve(candidatePath);
|
|
27
|
+
const relative = path.relative(outcomesRoot, resolved);
|
|
28
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
29
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH, `Task Pool outcome path escapes outcomes root: ${candidatePath}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Persist a {@link TaskOutcomeEnvelopeV1} atomically under the worker-run
|
|
34
|
+
* isolated outcomes dir. Returns the absolute path and the sha256 of the
|
|
35
|
+
* written bytes.
|
|
36
|
+
*
|
|
37
|
+
* - Re-validates the envelope against the strict schema before writing (a
|
|
38
|
+
* projection failure must never become a stored successful fact).
|
|
39
|
+
* - Atomic write via mkdtemp-style temp + rename + unlink-tmp, mirroring
|
|
40
|
+
* `writeTaskPoolState`.
|
|
41
|
+
* - Never writes under `.harness/dag-runs/**`; the containment guard rejects
|
|
42
|
+
* any escape from the outcomes root.
|
|
43
|
+
*/
|
|
44
|
+
export async function writeOutcome(repoRoot, envelope) {
|
|
45
|
+
const parsed = taskOutcomeEnvelopeV1Schema.safeParse(envelope);
|
|
46
|
+
if (!parsed.success) {
|
|
47
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH, `refusing to write invalid outcome envelope: ${parsed.error.message}`);
|
|
48
|
+
}
|
|
49
|
+
const safe = parsed.data;
|
|
50
|
+
assertSafeTaskPoolId(safe.identity.workerRunId, "outcome workerRunId");
|
|
51
|
+
const outcomePath = getOutcomePath(repoRoot, safe.identity.workerRunId);
|
|
52
|
+
await mkdir(path.dirname(outcomePath), { recursive: true });
|
|
53
|
+
const payload = `${JSON.stringify(safe, null, 2)}\n`;
|
|
54
|
+
const tempPath = path.join(path.dirname(outcomePath), `.${path.basename(outcomePath)}.${randomUUID()}.tmp`);
|
|
55
|
+
try {
|
|
56
|
+
await writeFile(tempPath, payload, "utf-8");
|
|
57
|
+
await rename(tempPath, outcomePath);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await unlink(tempPath).catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
const outcomeSha256 = await sha256Text(payload);
|
|
63
|
+
return { outcomePath, outcomeSha256 };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read an outcome envelope by workerRunId. Returns `undefined` when absent or
|
|
67
|
+
* semantically invalid (read-models degrade gracefully rather than throwing).
|
|
68
|
+
*/
|
|
69
|
+
export async function readOutcome(repoRoot, workerRunId) {
|
|
70
|
+
const outcomePath = getOutcomePath(repoRoot, workerRunId);
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = await readFile(outcomePath, "utf-8");
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isNotFound(error))
|
|
77
|
+
return undefined;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
const parsed = taskOutcomeEnvelopeV1Schema.safeParse(JSON.parse(raw));
|
|
81
|
+
return parsed.success ? parsed.data : undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read an outcome only when the Task Pool run's path and byte hash still bind
|
|
85
|
+
* it to the expected worker run. Callers receive `undefined` on any mismatch
|
|
86
|
+
* and therefore naturally fail the M3 artifact gate closed.
|
|
87
|
+
*/
|
|
88
|
+
export async function readVerifiedOutcome(input) {
|
|
89
|
+
if (!input.outcomePath || !input.outcomeSha256)
|
|
90
|
+
return undefined;
|
|
91
|
+
const outcomePath = input.outcomePath;
|
|
92
|
+
const expectedPath = getOutcomePath(input.repoRoot, input.workerRunId);
|
|
93
|
+
// Compare canonical (realpath-resolved) locations so a repo root reached
|
|
94
|
+
// via a symlink alias (e.g. macOS /var -> /private/var) cannot break the
|
|
95
|
+
// hash-bound outcome binding. Both paths must still resolve to the same
|
|
96
|
+
// canonical file inside the outcomes root.
|
|
97
|
+
let canonicalExpected = expectedPath;
|
|
98
|
+
let canonicalInput = outcomePath;
|
|
99
|
+
try {
|
|
100
|
+
const [resolvedExpected, resolvedInput] = await Promise.all([
|
|
101
|
+
realpath(expectedPath).catch(() => expectedPath),
|
|
102
|
+
realpath(outcomePath).catch(() => outcomePath),
|
|
103
|
+
]);
|
|
104
|
+
canonicalExpected = resolvedExpected;
|
|
105
|
+
canonicalInput = resolvedInput;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Fall back to lexical comparison when realpath is unavailable.
|
|
109
|
+
}
|
|
110
|
+
if (path.resolve(canonicalInput) !== path.resolve(canonicalExpected))
|
|
111
|
+
return undefined;
|
|
112
|
+
let raw;
|
|
113
|
+
try {
|
|
114
|
+
raw = await readFile(expectedPath, "utf-8");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (await sha256Text(raw) !== input.outcomeSha256)
|
|
120
|
+
return undefined;
|
|
121
|
+
const parsed = taskOutcomeEnvelopeV1Schema.safeParse(JSON.parse(raw));
|
|
122
|
+
if (!parsed.success || parsed.data.identity.workerRunId !== input.workerRunId)
|
|
123
|
+
return undefined;
|
|
124
|
+
return parsed.data;
|
|
125
|
+
}
|
|
126
|
+
function isNotFound(error) {
|
|
127
|
+
return Boolean(error &&
|
|
128
|
+
typeof error === "object" &&
|
|
129
|
+
"code" in error &&
|
|
130
|
+
error.code === "ENOENT");
|
|
131
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { workflowSchema } from "../task-spec/workflow-routing.js";
|
|
3
|
+
/** Worker-owned, serialized source binding; never imports the DAG runtime. */
|
|
4
|
+
export const outcomeSourceBindingSchema = z.object({
|
|
5
|
+
sourceFiles: z.array(z.string().min(1)).optional(),
|
|
6
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
7
|
+
}).passthrough();
|
|
8
|
+
/**
|
|
9
|
+
* Task Outcome Envelope v1 — derived projection of a completed DAG run.
|
|
10
|
+
*
|
|
11
|
+
* M2 adds this envelope as a *read-only* view over canonical completed DAG
|
|
12
|
+
* facts + TaskSpec + worker run record. It never mutates completed DAG facts,
|
|
13
|
+
* the DAG runtime, executors, Delivery, Follow-up, Final Verification, or
|
|
14
|
+
* Ready Planner dependency behavior. Projection failures fail closed and are
|
|
15
|
+
* classified into existing {@link OutcomeFailureCategory} values only.
|
|
16
|
+
*/
|
|
17
|
+
export const OUTCOME_SCHEMA_VERSION = 1;
|
|
18
|
+
/** Failure categories reuse the existing ProductLineFailureCategory taxonomy. */
|
|
19
|
+
export const outcomeFailureCategorySchema = z.enum([
|
|
20
|
+
"ContractMismatch",
|
|
21
|
+
"EnvFailure",
|
|
22
|
+
]);
|
|
23
|
+
/** Workflow-bound outcome adapter identity. */
|
|
24
|
+
export const OUTCOME_WORKFLOWS = [
|
|
25
|
+
"agent-dag",
|
|
26
|
+
"frontend-implementation",
|
|
27
|
+
"backend-test",
|
|
28
|
+
"frontend-test",
|
|
29
|
+
];
|
|
30
|
+
export const outcomeArtifactSchema = z.object({
|
|
31
|
+
path: z.string().min(1),
|
|
32
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/, "sha256 must be lowercase hex"),
|
|
33
|
+
/** Stable artifact family (e.g. `backend-test-result`). */
|
|
34
|
+
kind: z.string().min(1).optional(),
|
|
35
|
+
/** Versioned schema id (e.g. `backend-test-result-v1`); orthogonal to kind. */
|
|
36
|
+
schemaId: z.string().min(1).optional(),
|
|
37
|
+
});
|
|
38
|
+
export const shellVerificationSchema = z.object({
|
|
39
|
+
exitZero: z.boolean(),
|
|
40
|
+
command: z.string().optional(),
|
|
41
|
+
evidenceRef: z.string().optional(),
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Integration status is strictly split: `mock` and `real` are independent so a
|
|
45
|
+
* mock adapter can never be mistaken for a real integration and vice versa.
|
|
46
|
+
*/
|
|
47
|
+
export const integrationStatusSchema = z.object({
|
|
48
|
+
mock: z.boolean(),
|
|
49
|
+
real: z.boolean(),
|
|
50
|
+
});
|
|
51
|
+
export const outcomeFailureSchema = z.object({
|
|
52
|
+
category: outcomeFailureCategorySchema,
|
|
53
|
+
reason: z.string().min(1),
|
|
54
|
+
missingOutputs: z.array(z.string().min(1)).optional(),
|
|
55
|
+
});
|
|
56
|
+
export const taskOutcomeEnvelopeV1Schema = z.object({
|
|
57
|
+
schemaVersion: z.literal(OUTCOME_SCHEMA_VERSION),
|
|
58
|
+
identity: z.object({
|
|
59
|
+
featureId: z.string().min(1),
|
|
60
|
+
taskId: z.string().min(1),
|
|
61
|
+
workflow: workflowSchema,
|
|
62
|
+
workerRunId: z.string().min(1),
|
|
63
|
+
harnessTaskId: z.string().min(1).optional(),
|
|
64
|
+
}),
|
|
65
|
+
sourceBinding: outcomeSourceBindingSchema.optional(),
|
|
66
|
+
acceptanceCoverage: z.array(z.string().min(1)).default([]),
|
|
67
|
+
artifacts: z.array(outcomeArtifactSchema).default([]),
|
|
68
|
+
shellVerification: shellVerificationSchema.optional(),
|
|
69
|
+
integrationStatus: integrationStatusSchema,
|
|
70
|
+
controllerIdentity: z
|
|
71
|
+
.object({
|
|
72
|
+
packageName: z.string().min(1),
|
|
73
|
+
packageVersion: z.string().min(1),
|
|
74
|
+
})
|
|
75
|
+
.optional(),
|
|
76
|
+
createdAt: z.string().datetime(),
|
|
77
|
+
outcomeStatus: z.enum(["succeeded", "failed"]),
|
|
78
|
+
outcomeFailure: outcomeFailureSchema.optional(),
|
|
79
|
+
});
|
|
@@ -44,13 +44,13 @@ export async function renderMorningReport(options) {
|
|
|
44
44
|
"",
|
|
45
45
|
"## Results",
|
|
46
46
|
"",
|
|
47
|
-
"| Task | Status | Run | Failure | Next | Artifacts |",
|
|
48
|
-
"
|
|
47
|
+
"| Task | Workflow | Status | Run | Failure | Next | Artifacts |",
|
|
48
|
+
"|---|---|---|---|---|---|---|",
|
|
49
49
|
];
|
|
50
50
|
for (const run of runs) {
|
|
51
51
|
const followUp = await followUpSummary(run, options.repoRoot);
|
|
52
52
|
const identity = `${run.featureId}/${run.taskId}`;
|
|
53
|
-
lines.push(`| ${identity} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
|
|
53
|
+
lines.push(`| ${identity} | ${run.workflow ?? "-"} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
|
|
54
54
|
}
|
|
55
55
|
if (followUps > 0) {
|
|
56
56
|
lines.push("", "## Human Actions", "");
|
|
@@ -94,6 +94,7 @@ async function artifactSummary(run) {
|
|
|
94
94
|
["report", run.failureArtifacts?.reportMarkdownArtifactPath],
|
|
95
95
|
["doctor", run.failureArtifacts?.doctorMarkdownArtifactPath],
|
|
96
96
|
["closeout", run.failureArtifacts?.closeoutDraftPath],
|
|
97
|
+
["outcome", run.outcomePath],
|
|
97
98
|
].filter((entry) => Boolean(entry[1]));
|
|
98
99
|
if (artifacts.length === 0)
|
|
99
100
|
return "-";
|