@tea-agent/loop-agent 0.24.10 → 0.24.11-beta.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/CHANGELOG.md +12 -0
- package/dist/commands/init.js +2 -16
- package/dist/executors/shell-executor.js +6 -122
- package/dist/worker/cli.js +14 -59
- package/dist/worker/delivery/git-transaction.js +7 -32
- package/dist/worker/delivery/package.js +5 -9
- package/dist/worker/delivery/verification-bundle.js +5 -26
- package/dist/worker/observe/routes.js +0 -17
- package/dist/worker/observe/static/api.js +0 -9
- package/dist/worker/observe/static/constants.js +0 -9
- package/dist/worker/observe/static/state.js +0 -14
- package/dist/worker/observe/static/styles.css +0 -74
- package/dist/worker/observe/static/views/dag-inspector.js +15 -371
- package/dist/worker/outcomes/projector.js +1 -5
- package/dist/worker/runner/run-ready.js +1 -41
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -75
- package/dist/workflows/dag/backend-test-result-contract.js +0 -103
- package/dist/workflows/dag/frontend-test-result-contract.js +41 -106
- package/dist/workflows/dag/init-hybrid.js +14 -13
- package/dist/workflows/dag/node-execution.js +2 -3
- package/dist/workflows/dag/types.js +0 -2
- package/dist/workflows/dag/validate.js +2 -3
- package/docs/architecture/worker-and-feature.md +11 -0
- package/docs/init-surface.manifest.json +3 -0
- package/docs/templates/agent-dag.schema.json +9 -0
- package/docs/templates/frontend-implementation-contract.schema.json +2 -2
- package/package.json +1 -1
- package/skills/frontend-bounded-implement/SKILL.md +53 -0
- package/skills/frontend-implementation/SKILL.md +3 -7
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/dist/worker/observe/dag-run-artifacts.js +0 -90
- package/dist/worker/observe/node-input.js +0 -444
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"sourceBinding": { "$ref": "#/$defs/sourceBinding" },
|
|
11
11
|
"riskLevel": { "enum": ["small", "standard", "high-risk"] },
|
|
12
12
|
"targets": { "type": "object", "additionalProperties": false, "required": ["files"], "properties": { "files": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/path" } }, "routes": { "type": "array", "items": { "type": "string", "pattern": "^/" } }, "publicApiChanges": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
|
|
13
|
-
"requirements": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "implementationTargets", "verificationTargetIds"], "properties": { "id": { "$ref": "#/$defs/requirementId" }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "evidenceGap": { "$ref": "#/$defs/gap" } } } },
|
|
13
|
+
"requirements": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "expectedOutcome", "implementationTargets", "verificationTargetIds"], "properties": { "id": { "$ref": "#/$defs/requirementId" }, "expectedOutcome": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "evidenceGap": { "$ref": "#/$defs/gap" } } } },
|
|
14
14
|
"uiStates": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "applicable"], "properties": { "name": { "type": "string", "minLength": 1 }, "applicable": { "type": "boolean" }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "notApplicableReason": { "type": "string", "minLength": 1 } }, "allOf": [{ "if": { "properties": { "applicable": { "const": true } }, "required": ["applicable"] }, "then": { "required": ["expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "implementationTargets": { "minItems": 1 }, "verificationTargetIds": { "minItems": 1 } } } }, { "if": { "properties": { "applicable": { "const": false } }, "required": ["applicable"] }, "then": { "required": ["notApplicableReason"] } }] } },
|
|
15
|
-
"interactions": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "implementationTargets", "verificationTargetIds"], "properties": { "name": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string" } } } } },
|
|
15
|
+
"interactions": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "trigger", "expectedBehavior", "implementationTargets", "verificationTargetIds"], "properties": { "name": { "type": "string", "minLength": 1 }, "trigger": { "type": "string", "minLength": 1 }, "expectedBehavior": { "type": "string", "minLength": 1 }, "implementationTargets": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "verificationTargetIds": { "type": "array", "items": { "type": "string" } } } } },
|
|
16
16
|
"mockApi": { "type": "object", "additionalProperties": false, "required": ["strategy", "productionDefaultOff", "activation", "endpoints"], "properties": { "strategy": { "enum": ["native", "browser-intercept", "request-adapter", "not-needed"] }, "productionDefaultOff": { "const": true }, "activation": { "type": "string", "minLength": 1 }, "endpoints": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["method", "path"], "properties": { "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] }, "path": { "type": "string", "pattern": "^/" }, "fixture": { "$ref": "#/$defs/path" }, "consumer": { "$ref": "#/$defs/path" } } } } } },
|
|
17
17
|
"designEvidence": { "type": "object", "additionalProperties": false, "required": ["source", "paths", "conflicts"], "properties": { "source": { "type": "string", "minLength": 1 }, "paths": { "type": "array", "items": { "$ref": "#/$defs/path" } }, "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } } },
|
|
18
18
|
"verificationTargets": { "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "type", "commandLabel", "file", "requirementIds", "uiStates"], "properties": { "id": { "type": "string", "minLength": 1 }, "type": { "enum": ["static", "unit", "component", "integration", "mock"] }, "commandLabel": { "type": "string", "minLength": 1 }, "file": { "$ref": "#/$defs/path" }, "symbol": { "type": "string", "minLength": 1 }, "requirementIds": { "type": "array", "items": { "$ref": "#/$defs/requirementId" } }, "uiStates": { "type": "array", "items": { "type": "string", "minLength": 1 } } } } },
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: frontend-bounded-implement
|
|
3
|
+
description: >-
|
|
4
|
+
Use only for frontend implementation and repair writers after the canonical
|
|
5
|
+
contract gate has authorized a bounded write set.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Frontend Bounded Implement
|
|
9
|
+
|
|
10
|
+
## Authoritative Inputs
|
|
11
|
+
|
|
12
|
+
- For implementation, use the run-owned
|
|
13
|
+
`contracts/frontend-implementation-contract.json`.
|
|
14
|
+
- For repair, also use `contracts/frontend-repair-assessment.json`.
|
|
15
|
+
- Treat requirement `expectedOutcome`, interaction `trigger` /
|
|
16
|
+
`expectedBehavior`, target files, UI states, Mock/API strategy, and verification
|
|
17
|
+
targets as closed decisions. Do not rediscover or reinterpret them.
|
|
18
|
+
|
|
19
|
+
## Exploration Boundary
|
|
20
|
+
|
|
21
|
+
- Read the canonical contract, its declared target files, and only directly
|
|
22
|
+
imported/called local code needed to edit those targets safely.
|
|
23
|
+
- Do not open task sources, plans, reviews, OpenSpec, `ai_workspace/`, knowledge
|
|
24
|
+
bases, or unrelated repository areas. Do not recursively search the repository.
|
|
25
|
+
- Start the bounded edit after confirming the contract and targets. Optional
|
|
26
|
+
context is not a reason to postpone implementation.
|
|
27
|
+
- If behavior is still ambiguous or a required target is outside `writeSet`, stop
|
|
28
|
+
with `IMPLEMENTATION_OUTCOME: blocked`; do not compensate with broader scouting.
|
|
29
|
+
|
|
30
|
+
## Implementation Discipline
|
|
31
|
+
|
|
32
|
+
- Work test-first where behavior changes. Preserve unrelated files and stay inside
|
|
33
|
+
`allowedPaths` and `writeSet`.
|
|
34
|
+
- Implement every requirement outcome, interaction, and applicable UI state
|
|
35
|
+
assigned to the writer. Keep the real request path enabled by default and use
|
|
36
|
+
only the approved Mock/API strategy.
|
|
37
|
+
- Do not add dependencies unless the contract approves them. Never relax lint,
|
|
38
|
+
type, test, or build configuration; never add `.skip` or `.only`.
|
|
39
|
+
- Run only the focused checks already frozen by the task. Leave full verification
|
|
40
|
+
to downstream deterministic nodes.
|
|
41
|
+
|
|
42
|
+
## Required Outcome
|
|
43
|
+
|
|
44
|
+
The first non-empty response line must be exactly one of:
|
|
45
|
+
|
|
46
|
+
- `IMPLEMENTATION_OUTCOME: changed` — a non-empty bounded diff was produced.
|
|
47
|
+
- `IMPLEMENTATION_OUTCOME: already-satisfied` — the contract was already met and
|
|
48
|
+
no file changed.
|
|
49
|
+
- `IMPLEMENTATION_OUTCOME: blocked` — implementation cannot proceed without
|
|
50
|
+
guessing or exceeding the write boundary.
|
|
51
|
+
|
|
52
|
+
Then report changed files, requirements/interactions implemented, tests or focused
|
|
53
|
+
checks attempted, and residual risks. Never report `changed` for an empty diff.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: frontend-implementation
|
|
3
3
|
description: >-
|
|
4
|
-
Use for frontend contract, scout, mock strategy,
|
|
4
|
+
Use for frontend contract, scout, mock strategy, and plan DAG nodes.
|
|
5
5
|
references:
|
|
6
6
|
- path: references/node-contracts.md
|
|
7
7
|
required: true
|
|
@@ -29,12 +29,8 @@ rules, commands, or retrievals. Scout/planners locate and explicitly read applic
|
|
|
29
29
|
specs; only successful paired reads count. Lockfile-only, fixture-only, or unread
|
|
30
30
|
search hits do not prove a reusable Mock service.
|
|
31
31
|
|
|
32
|
-
This broad discovery rule applies only to contract, scout, and plan phases.
|
|
33
|
-
`frontend-implement
|
|
34
|
-
`contracts/frontend-implementation-contract.json` is the approved semantic input;
|
|
35
|
-
do not repeat task-source, OpenSpec, AI workspace, plan, or repository-wide
|
|
36
|
-
discovery. For `frontend-repair-pi`, consume that contract plus
|
|
37
|
-
`contracts/frontend-repair-assessment.json` and make only the bounded repair.
|
|
32
|
+
This broad discovery rule applies only to contract, scout, and plan phases.
|
|
33
|
+
Implementation and repair use the separate `frontend-bounded-implement` skill.
|
|
38
34
|
|
|
39
35
|
## Implementation Discipline
|
|
40
36
|
|
|
@@ -6,9 +6,9 @@ Pre-write nodes are read-only. Preserve IDs, labels, commands, language, require
|
|
|
6
6
|
|
|
7
7
|
- **`frontend-contract-pi`**: `Scope`, `Non-goals`, `Acceptance Criteria`, `UI States`, `Target Runtime Environment`, `Risks`, `Verification Expectations`. No guessed requirements.
|
|
8
8
|
- **`frontend-scout-pi`**: routes, components, tokens, data/API/Mock, scripts, tests, assets. Fact vs inference vs gap. Query the knowledge base when available and always search+read `openspec/schemas/`, `openspec/project-specs/`, and `ai_workspace/` before repo fallback. Output stack, routes, components, styling, conventions, state/data, test entry points, reuse, risks.
|
|
9
|
-
- **`frontend-plan-pi` + conditional design loop**: AC → steps, in-bound files, applicable UI states, reuse, deps, Mock/API strategy, activation/rollback, frozen verify entrypoints, real-integration gap, and exactly one `frontend-implementation-contract-v1` JSON object. Use `uiStates: []` for logic-only changes with no user-visible UI state; do not invent UI states. Applicable states require behavior/implementation/verification, while non-applicable states require a reason and omit empty behavior placeholders. Prefer native Mock; browser intercept only with existing e2e; request-adapter only for a reversible seam. `auto` may select `not-needed` when no project Mock capability exists, while keeping real requests default and recording the gap; `required` cannot. Initial design pass uses the original plan; only exact `request-revision` runs read-only revision plus final review. Small-risk runs one design review only.
|
|
9
|
+
- **`frontend-plan-pi` + conditional design loop**: AC → explicit observable `expectedOutcome`, interactions → explicit `trigger` + `expectedBehavior`, then steps, in-bound files, applicable UI states, reuse, deps, Mock/API strategy, activation/rollback, frozen verify entrypoints, real-integration gap, and exactly one `frontend-implementation-contract-v1` JSON object. IDs plus file paths are not sufficient behavior semantics. Use `uiStates: []` for logic-only changes with no user-visible UI state; do not invent UI states. Applicable states require behavior/implementation/verification, while non-applicable states require a reason and omit empty behavior placeholders. Prefer native Mock; browser intercept only with existing e2e; request-adapter only for a reversible seam. `auto` may select `not-needed` when no project Mock capability exists, while keeping real requests default and recording the gap; `required` cannot. Initial design pass uses the original plan; only exact `request-revision` runs read-only revision plus final review. Small-risk runs one design review only.
|
|
10
10
|
- **`frontend-prewrite-gate-shell`**: the sole write authorization. Resolve effective plan/review, require exact pass, retain every REQ/BR/AC id, enforce Mock policy, validate schema/source binding, and materialize `contracts/frontend-implementation-contract.json`. Fallback is allowed only when a conditional primary is absent; an existing malformed primary fails closed. Generation-time blocked Mock produces one deterministic blocking shell node and no writer.
|
|
11
|
-
- **`frontend-implement-pi`**: sole exclusive writer. Stay in `writeSet`; real requests default-on; Mock reversible, dev/test-only, production-off. Atomic handler/intercept/adapter with consumer+tests. Stop on forbidden paths or guesses.
|
|
11
|
+
- **`frontend-implement-pi`**: sole regular exclusive writer and consumer of `frontend-bounded-implement`, not this discovery skill. Stay in `writeSet`; real requests default-on; Mock reversible, dev/test-only, production-off. Atomic handler/intercept/adapter with consumer+tests. Stop on forbidden paths or guesses. First line must be `IMPLEMENTATION_OUTCOME: changed|already-satisfied|blocked`; runtime checks it against the attributed diff. Optional mock-verify when frozen; static+behavior always; behavior must prove page consumption. Skipped-Mock `not-needed` keeps real integration pending unless the real backend path has fresh evidence.
|
|
12
12
|
|
|
13
13
|
## Contract / trace / stages (M1–M2)
|
|
14
14
|
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
4
|
-
export const DAG_RUN_LIFECYCLES = ["active", "completed", "paused"];
|
|
5
|
-
function isPathInside(root, target) {
|
|
6
|
-
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
7
|
-
const normalizedTarget = path.normalize(target);
|
|
8
|
-
return (normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot));
|
|
9
|
-
}
|
|
10
|
-
function normalizeRelativeSegments(relativeSegments) {
|
|
11
|
-
if (!Array.isArray(relativeSegments) || relativeSegments.length === 0) {
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
const segments = [];
|
|
15
|
-
for (const segment of relativeSegments) {
|
|
16
|
-
if (typeof segment !== "string" || !segment)
|
|
17
|
-
return null;
|
|
18
|
-
if (segment === "." || segment === "..")
|
|
19
|
-
return null;
|
|
20
|
-
if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
segments.push(segment);
|
|
24
|
-
}
|
|
25
|
-
return segments;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Resolve a path under `.harness/dag-runs/{lifecycle}/{dagRunId}/...`.
|
|
29
|
-
* Fail closed on invalid ids, path escape, or the same runId appearing in
|
|
30
|
-
* multiple lifecycle directories.
|
|
31
|
-
*/
|
|
32
|
-
export function resolveDagRunArtifact(repoRoot, dagRunId, relativeSegments) {
|
|
33
|
-
if (!isSafeObservabilityIdentifier(dagRunId)) {
|
|
34
|
-
return { ok: false, reason: "invalid-id" };
|
|
35
|
-
}
|
|
36
|
-
const segments = normalizeRelativeSegments(relativeSegments);
|
|
37
|
-
if (!segments) {
|
|
38
|
-
return { ok: false, reason: "unsafe-path" };
|
|
39
|
-
}
|
|
40
|
-
for (const segment of segments) {
|
|
41
|
-
if (segment !== "run.json" &&
|
|
42
|
-
segment !== "state.json" &&
|
|
43
|
-
!isSafeObservabilityIdentifier(segment) &&
|
|
44
|
-
!/^[a-zA-Z0-9._-]+$/.test(segment)) {
|
|
45
|
-
// Allow known artifact filenames under a validated node id prefix.
|
|
46
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(segment)) {
|
|
47
|
-
return { ok: false, reason: "unsafe-path" };
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
|
|
52
|
-
const matches = [];
|
|
53
|
-
for (const lifecycle of DAG_RUN_LIFECYCLES) {
|
|
54
|
-
const runDir = path.join(dagRunsRoot, lifecycle, dagRunId);
|
|
55
|
-
const absolutePath = path.resolve(runDir, ...segments);
|
|
56
|
-
if (!isPathInside(dagRunsRoot, absolutePath))
|
|
57
|
-
continue;
|
|
58
|
-
const relative = path.relative(dagRunsRoot, absolutePath);
|
|
59
|
-
const parts = relative.split(path.sep).filter(Boolean);
|
|
60
|
-
if (parts[0] !== lifecycle || parts[1] !== dagRunId)
|
|
61
|
-
continue;
|
|
62
|
-
if (!existsSync(absolutePath))
|
|
63
|
-
continue;
|
|
64
|
-
matches.push({
|
|
65
|
-
lifecycle,
|
|
66
|
-
runDir,
|
|
67
|
-
absolutePath,
|
|
68
|
-
relativePath: path.join(lifecycle, dagRunId, ...segments),
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
if (matches.length === 0) {
|
|
72
|
-
return { ok: false, reason: "not-found" };
|
|
73
|
-
}
|
|
74
|
-
if (matches.length > 1) {
|
|
75
|
-
return { ok: false, reason: "ambiguous" };
|
|
76
|
-
}
|
|
77
|
-
return { ok: true, result: matches[0] };
|
|
78
|
-
}
|
|
79
|
-
export function resolveDagRunJson(repoRoot, dagRunId) {
|
|
80
|
-
return resolveDagRunArtifact(repoRoot, dagRunId, ["run.json"]);
|
|
81
|
-
}
|
|
82
|
-
export function resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId) {
|
|
83
|
-
if (!isSafeObservabilityIdentifier(nodeId)) {
|
|
84
|
-
return { ok: false, reason: "invalid-id" };
|
|
85
|
-
}
|
|
86
|
-
return resolveDagRunArtifact(repoRoot, dagRunId, [
|
|
87
|
-
nodeId,
|
|
88
|
-
"prompt.redacted.md",
|
|
89
|
-
]);
|
|
90
|
-
}
|
|
@@ -1,444 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { redactSecrets, truncateUtf8Preview, } from "../../shared/preview.js";
|
|
4
|
-
import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
|
|
5
|
-
import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
|
|
6
|
-
export const NODE_INPUT_SCHEMA_VERSION = 1;
|
|
7
|
-
export const NODE_INPUT_PREVIEW_MAX_BYTES = 64 * 1024;
|
|
8
|
-
const SHELL_GATE_KEYS = [
|
|
9
|
-
"verdictGate",
|
|
10
|
-
"projectGovernanceGate",
|
|
11
|
-
"requirementCoverageGate",
|
|
12
|
-
"jsonArtifactGate",
|
|
13
|
-
"frontendPrewriteGate",
|
|
14
|
-
"frontendLintBaseline",
|
|
15
|
-
"frontendVerificationBundle",
|
|
16
|
-
"frontendReviewContext",
|
|
17
|
-
"backendTestPipeline",
|
|
18
|
-
"verifyEvidence",
|
|
19
|
-
"repairArtifactGate",
|
|
20
|
-
];
|
|
21
|
-
const PROMPT_FINGERPRINT_RE = /<prompt\s+sha256=([a-f0-9]{12})\s+len=(\d+)\s*>/i;
|
|
22
|
-
function unavailableBody(dagRunId, nodeId, reason, warnings = []) {
|
|
23
|
-
return {
|
|
24
|
-
schemaVersion: NODE_INPUT_SCHEMA_VERSION,
|
|
25
|
-
dagRunId,
|
|
26
|
-
nodeId,
|
|
27
|
-
available: false,
|
|
28
|
-
availabilityReason: reason,
|
|
29
|
-
source: null,
|
|
30
|
-
summary: null,
|
|
31
|
-
dependency: null,
|
|
32
|
-
prompt: null,
|
|
33
|
-
executorInput: null,
|
|
34
|
-
boundaries: null,
|
|
35
|
-
promptFingerprint: null,
|
|
36
|
-
warnings,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
function redactString(value) {
|
|
40
|
-
if (typeof value !== "string")
|
|
41
|
-
return null;
|
|
42
|
-
return redactSecrets(value);
|
|
43
|
-
}
|
|
44
|
-
function redactStringList(values) {
|
|
45
|
-
if (!Array.isArray(values))
|
|
46
|
-
return [];
|
|
47
|
-
return values
|
|
48
|
-
.filter((item) => typeof item === "string")
|
|
49
|
-
.map((item) => redactSecrets(item));
|
|
50
|
-
}
|
|
51
|
-
function truncatedField(text, maxBytes = NODE_INPUT_PREVIEW_MAX_BYTES) {
|
|
52
|
-
const redacted = redactSecrets(text);
|
|
53
|
-
const truncated = truncateUtf8Preview(redacted, maxBytes);
|
|
54
|
-
return {
|
|
55
|
-
text: truncated,
|
|
56
|
-
truncated: truncated !== redacted,
|
|
57
|
-
maxBytes,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
export function parsePromptRedactedFingerprint(markdown) {
|
|
61
|
-
const match = PROMPT_FINGERPRINT_RE.exec(markdown);
|
|
62
|
-
if (!match) {
|
|
63
|
-
return { ok: false, error: "fingerprint-format-mismatch" };
|
|
64
|
-
}
|
|
65
|
-
const sha256Prefix = match[1];
|
|
66
|
-
const lengthChars = Number(match[2]);
|
|
67
|
-
if (!Number.isSafeInteger(lengthChars) || lengthChars < 0) {
|
|
68
|
-
return { ok: false, error: "fingerprint-length-invalid" };
|
|
69
|
-
}
|
|
70
|
-
return { ok: true, sha256Prefix, lengthChars };
|
|
71
|
-
}
|
|
72
|
-
function isLikelyDynamicExpandedNode(nodeId, tasks) {
|
|
73
|
-
for (const parent of tasks) {
|
|
74
|
-
const parentId = typeof parent.id === "string" ? parent.id : "";
|
|
75
|
-
const expansion = parent.dynamicExpansion;
|
|
76
|
-
if (expansion && typeof expansion === "object") {
|
|
77
|
-
const childIdPrefix = typeof expansion.childIdPrefix ===
|
|
78
|
-
"string"
|
|
79
|
-
? expansion.childIdPrefix
|
|
80
|
-
: "";
|
|
81
|
-
if (childIdPrefix) {
|
|
82
|
-
const prefix = `${childIdPrefix}-`;
|
|
83
|
-
if (nodeId.startsWith(prefix)) {
|
|
84
|
-
const suffix = nodeId.slice(prefix.length);
|
|
85
|
-
if (/^\d{4}$/.test(suffix) && Number(suffix) >= 1) {
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const loop = parent.dynamicLoopUntil;
|
|
92
|
-
if (loop && typeof loop === "object" && parentId) {
|
|
93
|
-
const prefix = `${parentId}-r`;
|
|
94
|
-
if (nodeId.startsWith(prefix)) {
|
|
95
|
-
const rest = nodeId.slice(prefix.length);
|
|
96
|
-
const dash = rest.indexOf("-");
|
|
97
|
-
if (dash > 0) {
|
|
98
|
-
const iterationText = rest.slice(0, dash);
|
|
99
|
-
if (/^\d+$/.test(iterationText) && Number(iterationText) >= 1) {
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
function shellGateNames(shell) {
|
|
109
|
-
const gates = [];
|
|
110
|
-
for (const key of SHELL_GATE_KEYS) {
|
|
111
|
-
if (shell[key] != null)
|
|
112
|
-
gates.push(key);
|
|
113
|
-
}
|
|
114
|
-
return gates;
|
|
115
|
-
}
|
|
116
|
-
function resolveModelHint(spec, task) {
|
|
117
|
-
const complexity = typeof task.complexity === "string" ? task.complexity : null;
|
|
118
|
-
const executorModels = spec.executorModels;
|
|
119
|
-
if (complexity &&
|
|
120
|
-
executorModels &&
|
|
121
|
-
typeof executorModels === "object" &&
|
|
122
|
-
!Array.isArray(executorModels)) {
|
|
123
|
-
const pi = executorModels.pi;
|
|
124
|
-
if (pi && typeof pi === "object" && !Array.isArray(pi)) {
|
|
125
|
-
const model = pi[complexity];
|
|
126
|
-
if (typeof model === "string" && model.trim()) {
|
|
127
|
-
return redactSecrets(model);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const defaults = spec.defaults;
|
|
132
|
-
if (defaults && typeof defaults === "object" && !Array.isArray(defaults)) {
|
|
133
|
-
const model = defaults.model;
|
|
134
|
-
if (typeof model === "string" && model.trim()) {
|
|
135
|
-
return redactSecrets(model);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warnings) {
|
|
141
|
-
const defaults = spec.defaults && typeof spec.defaults === "object"
|
|
142
|
-
? spec.defaults
|
|
143
|
-
: undefined;
|
|
144
|
-
const executorDeclared = typeof task.executor === "string";
|
|
145
|
-
const executor = executorDeclared
|
|
146
|
-
? String(task.executor)
|
|
147
|
-
: "pi";
|
|
148
|
-
const executorSource = executorDeclared
|
|
149
|
-
? "task"
|
|
150
|
-
: "schema-default";
|
|
151
|
-
const writePolicyDeclared = typeof task.writePolicy === "string";
|
|
152
|
-
let writePolicy = null;
|
|
153
|
-
let writePolicySource = null;
|
|
154
|
-
if (writePolicyDeclared) {
|
|
155
|
-
writePolicy = String(task.writePolicy);
|
|
156
|
-
writePolicySource = "task";
|
|
157
|
-
}
|
|
158
|
-
else if (typeof defaults?.writePolicy === "string") {
|
|
159
|
-
writePolicy = defaults.writePolicy;
|
|
160
|
-
writePolicySource = "defaults";
|
|
161
|
-
}
|
|
162
|
-
const skillSpec = {
|
|
163
|
-
defaults,
|
|
164
|
-
skillsByRole: spec.skillsByRole && typeof spec.skillsByRole === "object"
|
|
165
|
-
? spec.skillsByRole
|
|
166
|
-
: undefined,
|
|
167
|
-
};
|
|
168
|
-
const skills = resolveDagNodeSkills(skillSpec, {
|
|
169
|
-
role: typeof task.role === "string" ? task.role : undefined,
|
|
170
|
-
skills: Array.isArray(task.skills)
|
|
171
|
-
? task.skills.filter((s) => typeof s === "string")
|
|
172
|
-
: undefined,
|
|
173
|
-
}).map((skill) => redactSecrets(skill));
|
|
174
|
-
const promptRaw = typeof task.subtask_prompt === "string" ? task.subtask_prompt : "";
|
|
175
|
-
const promptBody = truncatedField(promptRaw);
|
|
176
|
-
const promptSourceRaw = task.subtask_prompt_source;
|
|
177
|
-
let promptSource = null;
|
|
178
|
-
if (promptSourceRaw && typeof promptSourceRaw === "object") {
|
|
179
|
-
const src = promptSourceRaw;
|
|
180
|
-
if (src.type === "markdown" && typeof src.path === "string") {
|
|
181
|
-
promptSource = {
|
|
182
|
-
type: "markdown",
|
|
183
|
-
path: redactSecrets(src.path),
|
|
184
|
-
sha256Prefix: typeof src.sha256 === "string" && src.sha256.length >= 12
|
|
185
|
-
? src.sha256.slice(0, 12)
|
|
186
|
-
: null,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const shellRaw = task.shell && typeof task.shell === "object" && !Array.isArray(task.shell)
|
|
191
|
-
? task.shell
|
|
192
|
-
: null;
|
|
193
|
-
const staticRaw = task.static && typeof task.static === "object" && !Array.isArray(task.static)
|
|
194
|
-
? task.static
|
|
195
|
-
: null;
|
|
196
|
-
let kind = "unknown";
|
|
197
|
-
if (executor === "shell" || shellRaw)
|
|
198
|
-
kind = "shell";
|
|
199
|
-
else if (executor === "static" || staticRaw)
|
|
200
|
-
kind = "static";
|
|
201
|
-
else if (executor === "pi")
|
|
202
|
-
kind = "pi";
|
|
203
|
-
const shell = shellRaw
|
|
204
|
-
? {
|
|
205
|
-
commands: redactStringList(shellRaw.commands),
|
|
206
|
-
preset: typeof shellRaw.preset === "string"
|
|
207
|
-
? redactSecrets(shellRaw.preset)
|
|
208
|
-
: null,
|
|
209
|
-
cwd: typeof shellRaw.cwd === "string"
|
|
210
|
-
? redactSecrets(shellRaw.cwd)
|
|
211
|
-
: null,
|
|
212
|
-
timeoutMs: typeof shellRaw.timeoutMs === "number" &&
|
|
213
|
-
Number.isFinite(shellRaw.timeoutMs)
|
|
214
|
-
? shellRaw.timeoutMs
|
|
215
|
-
: null,
|
|
216
|
-
nonZeroExitPolicy: typeof shellRaw.nonZeroExitPolicy === "string"
|
|
217
|
-
? shellRaw.nonZeroExitPolicy
|
|
218
|
-
: null,
|
|
219
|
-
gates: shellGateNames(shellRaw),
|
|
220
|
-
}
|
|
221
|
-
: null;
|
|
222
|
-
const staticPreview = staticRaw && typeof staticRaw.resultMarkdown === "string"
|
|
223
|
-
? truncatedField(staticRaw.resultMarkdown)
|
|
224
|
-
: null;
|
|
225
|
-
const staticBlock = staticRaw
|
|
226
|
-
? {
|
|
227
|
-
status: typeof staticRaw.status === "string" ? staticRaw.status : null,
|
|
228
|
-
resultPreview: staticPreview?.text ?? "",
|
|
229
|
-
truncated: staticPreview?.truncated ?? false,
|
|
230
|
-
maxBytes: staticPreview?.maxBytes ?? NODE_INPUT_PREVIEW_MAX_BYTES,
|
|
231
|
-
}
|
|
232
|
-
: null;
|
|
233
|
-
return {
|
|
234
|
-
schemaVersion: NODE_INPUT_SCHEMA_VERSION,
|
|
235
|
-
dagRunId,
|
|
236
|
-
nodeId,
|
|
237
|
-
available: true,
|
|
238
|
-
availabilityReason: "available",
|
|
239
|
-
source: {
|
|
240
|
-
kind: "frozen-run-spec",
|
|
241
|
-
artifact: "run.json#tasks",
|
|
242
|
-
lifecycle,
|
|
243
|
-
},
|
|
244
|
-
summary: {
|
|
245
|
-
executor: redactString(executor),
|
|
246
|
-
executorSource,
|
|
247
|
-
role: typeof task.role === "string" ? redactSecrets(task.role) : null,
|
|
248
|
-
complexity: typeof task.complexity === "string"
|
|
249
|
-
? task.complexity
|
|
250
|
-
: null,
|
|
251
|
-
writePolicy: writePolicy ? redactSecrets(writePolicy) : null,
|
|
252
|
-
writePolicySource,
|
|
253
|
-
toolProfile: typeof task.toolProfile === "string"
|
|
254
|
-
? redactSecrets(task.toolProfile)
|
|
255
|
-
: null,
|
|
256
|
-
},
|
|
257
|
-
dependency: {
|
|
258
|
-
dependsOn: redactStringList(task.depends_on),
|
|
259
|
-
dependsPolicy: typeof task.dependsPolicy === "string" ? task.dependsPolicy : null,
|
|
260
|
-
failureAwareDependsOn: redactStringList(task.failureAwareDependsOn),
|
|
261
|
-
runIf: typeof task.runIf === "string" ? redactSecrets(task.runIf) : null,
|
|
262
|
-
},
|
|
263
|
-
prompt: {
|
|
264
|
-
format: "markdown",
|
|
265
|
-
text: promptBody.text,
|
|
266
|
-
truncated: promptBody.truncated,
|
|
267
|
-
maxBytes: promptBody.maxBytes,
|
|
268
|
-
source: promptSource,
|
|
269
|
-
},
|
|
270
|
-
executorInput: {
|
|
271
|
-
kind,
|
|
272
|
-
skills,
|
|
273
|
-
modelHint: resolveModelHint(spec, task),
|
|
274
|
-
shell,
|
|
275
|
-
static: staticBlock,
|
|
276
|
-
},
|
|
277
|
-
boundaries: {
|
|
278
|
-
allowedPaths: redactStringList(task.allowedPaths),
|
|
279
|
-
forbiddenPaths: redactStringList(task.forbiddenPaths),
|
|
280
|
-
writeSet: redactStringList(task.writeSet),
|
|
281
|
-
outputContract: typeof task.outputContract === "string"
|
|
282
|
-
? truncatedField(task.outputContract).text
|
|
283
|
-
: null,
|
|
284
|
-
outputMode: typeof task.outputMode === "string" ? task.outputMode : null,
|
|
285
|
-
firstProtocolLine: typeof task.firstProtocolLine === "string"
|
|
286
|
-
? redactSecrets(task.firstProtocolLine)
|
|
287
|
-
: null,
|
|
288
|
-
},
|
|
289
|
-
promptFingerprint: fingerprint,
|
|
290
|
-
warnings,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
async function loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings) {
|
|
294
|
-
const resolved = resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId);
|
|
295
|
-
if (!resolved.ok) {
|
|
296
|
-
if (resolved.reason === "ambiguous") {
|
|
297
|
-
warnings.push("Ambiguous prompt.redacted.md lifecycle copies.");
|
|
298
|
-
}
|
|
299
|
-
return {
|
|
300
|
-
exists: false,
|
|
301
|
-
sha256Prefix: null,
|
|
302
|
-
lengthChars: null,
|
|
303
|
-
artifact: null,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
const artifact = path.posix.join(nodeId, "prompt.redacted.md");
|
|
307
|
-
try {
|
|
308
|
-
const markdown = await readFile(resolved.result.absolutePath, "utf-8");
|
|
309
|
-
const parsed = parsePromptRedactedFingerprint(markdown);
|
|
310
|
-
if (!parsed.ok) {
|
|
311
|
-
warnings.push(`prompt.redacted.md parse failed: ${parsed.error}`);
|
|
312
|
-
return {
|
|
313
|
-
exists: true,
|
|
314
|
-
sha256Prefix: null,
|
|
315
|
-
lengthChars: null,
|
|
316
|
-
artifact,
|
|
317
|
-
parseError: parsed.error,
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
return {
|
|
321
|
-
exists: true,
|
|
322
|
-
sha256Prefix: parsed.sha256Prefix,
|
|
323
|
-
lengthChars: parsed.lengthChars,
|
|
324
|
-
artifact,
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
warnings.push("prompt.redacted.md could not be read.");
|
|
329
|
-
return {
|
|
330
|
-
exists: true,
|
|
331
|
-
sha256Prefix: null,
|
|
332
|
-
lengthChars: null,
|
|
333
|
-
artifact,
|
|
334
|
-
parseError: "read-failed",
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Build schemaVersion 1 node-input projection for Observe / Inspect.
|
|
340
|
-
* Never returns the raw task object.
|
|
341
|
-
*/
|
|
342
|
-
export async function buildDagNodeInput(repoRoot, dagRunId, nodeId) {
|
|
343
|
-
const runResolved = resolveDagRunJson(repoRoot, dagRunId);
|
|
344
|
-
if (!runResolved.ok) {
|
|
345
|
-
if (runResolved.reason === "invalid-id") {
|
|
346
|
-
return {
|
|
347
|
-
ok: false,
|
|
348
|
-
status: 400,
|
|
349
|
-
body: { error: "Invalid dag run or node identifier" },
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
if (runResolved.reason === "ambiguous") {
|
|
353
|
-
return {
|
|
354
|
-
ok: false,
|
|
355
|
-
status: 409,
|
|
356
|
-
body: { error: "Ambiguous dag run lifecycle" },
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
if (runResolved.reason === "not-found") {
|
|
360
|
-
return {
|
|
361
|
-
ok: false,
|
|
362
|
-
status: 404,
|
|
363
|
-
body: { error: "DAG run not found" },
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
return {
|
|
367
|
-
ok: false,
|
|
368
|
-
status: 400,
|
|
369
|
-
body: { error: "Invalid or unsafe path" },
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
let raw;
|
|
373
|
-
try {
|
|
374
|
-
raw = JSON.parse(await readFile(runResolved.result.absolutePath, "utf-8"));
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
return {
|
|
378
|
-
ok: false,
|
|
379
|
-
status: 422,
|
|
380
|
-
body: { error: "run.json is unreadable or invalid JSON" },
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
384
|
-
return {
|
|
385
|
-
ok: false,
|
|
386
|
-
status: 422,
|
|
387
|
-
body: { error: "run.json is not a DAG spec object" },
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
const spec = raw;
|
|
391
|
-
const tasks = Array.isArray(spec.tasks) ? spec.tasks : null;
|
|
392
|
-
if (!tasks) {
|
|
393
|
-
return {
|
|
394
|
-
ok: true,
|
|
395
|
-
status: 200,
|
|
396
|
-
body: unavailableBody(dagRunId, nodeId, "run-spec-unavailable", [
|
|
397
|
-
"run.json has no tasks[] array.",
|
|
398
|
-
]),
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
const taskRecords = tasks.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
|
|
402
|
-
const task = taskRecords.find((item) => typeof item.id === "string" && item.id === nodeId);
|
|
403
|
-
if (!task) {
|
|
404
|
-
if (isLikelyDynamicExpandedNode(nodeId, taskRecords)) {
|
|
405
|
-
return {
|
|
406
|
-
ok: true,
|
|
407
|
-
status: 200,
|
|
408
|
-
body: unavailableBody(dagRunId, nodeId, "unsupported-dynamic-node", [
|
|
409
|
-
"Runtime-expanded node input is not projected in schemaVersion 1.",
|
|
410
|
-
]),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
return {
|
|
414
|
-
ok: true,
|
|
415
|
-
status: 200,
|
|
416
|
-
body: unavailableBody(dagRunId, nodeId, "task-not-found"),
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
const warnings = [];
|
|
420
|
-
const fingerprint = await loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings);
|
|
421
|
-
// Reject unknown nested leakage by projecting only through allowlist helpers.
|
|
422
|
-
const body = projectTask(dagRunId, nodeId, runResolved.result.lifecycle, spec, task, fingerprint, warnings);
|
|
423
|
-
// Defense-in-depth: ensure raw task never sneaks in.
|
|
424
|
-
if ("task" in body) {
|
|
425
|
-
return {
|
|
426
|
-
ok: false,
|
|
427
|
-
status: 422,
|
|
428
|
-
body: { error: "projection leaked raw task" },
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
return { ok: true, status: 200, body };
|
|
432
|
-
}
|
|
433
|
-
/** Test helper: ensure response JSON never contains raw task keys. */
|
|
434
|
-
export function assertNoRawTaskLeak(body) {
|
|
435
|
-
if (!body || typeof body !== "object")
|
|
436
|
-
return true;
|
|
437
|
-
const record = body;
|
|
438
|
-
if ("task" in record)
|
|
439
|
-
return false;
|
|
440
|
-
if ("subtask_prompt" in record)
|
|
441
|
-
return false;
|
|
442
|
-
const serialized = JSON.stringify(body);
|
|
443
|
-
return !serialized.includes('"subtask_prompt":');
|
|
444
|
-
}
|