@tea-agent/loop-agent 0.16.25 → 0.17.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 +46 -0
- package/README.md +14 -3
- package/dist/cli/command-definitions.js +43 -0
- package/dist/cli/program.js +26 -0
- package/dist/commands/dag-approve.js +4 -0
- package/dist/commands/dag-resume.js +1 -0
- package/dist/commands/dag-validate.js +6 -0
- package/dist/commands/operator.js +44 -0
- package/dist/commands/task-contract.js +271 -0
- package/dist/executors/dag-pi-executor.js +118 -16
- package/dist/executors/pi-executor.js +206 -13
- package/dist/executors/pi-sdk-executor.js +21 -6
- package/dist/executors/shell-executor.js +85 -8
- package/dist/executors/shell-presets.js +16 -3
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/shared/operator/capabilities.js +255 -0
- package/dist/shared/operator/envelope.js +59 -0
- package/dist/shared/operator/index.js +4 -0
- package/dist/shared/operator/registry.js +38 -0
- package/dist/shared/operator/types.js +5 -0
- package/dist/task/contract/adopt.js +166 -0
- package/dist/task/contract/apply.js +326 -0
- package/dist/task/contract/canonicalize.js +60 -0
- package/dist/task/contract/constants.js +29 -0
- package/dist/task/contract/diff.js +177 -0
- package/dist/task/contract/hash.js +42 -0
- package/dist/task/contract/import-revision.js +96 -0
- package/dist/task/contract/index.js +17 -0
- package/dist/task/contract/journal.js +155 -0
- package/dist/task/contract/lock.js +153 -0
- package/dist/task/contract/observe.js +296 -0
- package/dist/task/contract/paths.js +19 -0
- package/dist/task/contract/project.js +170 -0
- package/dist/task/contract/recover.js +312 -0
- package/dist/task/contract/request-ledger.js +37 -0
- package/dist/task/contract/schema.js +151 -0
- package/dist/task/contract/transaction.js +160 -0
- package/dist/task/contract/types.js +1 -0
- package/dist/task/contract/validate-draft.js +106 -0
- package/dist/task/index.js +3 -0
- package/dist/task/operator/capabilities.js +6 -0
- package/dist/task/operator/envelope.js +2 -0
- package/dist/task/operator/index.js +5 -0
- package/dist/task/operator/registry.js +2 -0
- package/dist/task/operator/types.js +1 -0
- package/dist/task/runtime.js +5 -1
- package/dist/task/source-references.js +7 -0
- package/dist/worker/cli.js +150 -32
- package/dist/worker/console/app-data.js +185 -0
- package/dist/worker/console/dag-confirmation.js +313 -0
- package/dist/worker/console/doctor.js +169 -0
- package/dist/worker/console/draft-store.js +80 -0
- package/dist/worker/console/index.js +15 -0
- package/dist/worker/console/interview/assessment.js +67 -0
- package/dist/worker/console/interview/session.js +100 -0
- package/dist/worker/console/interview/tools.js +109 -0
- package/dist/worker/console/loopback.js +16 -0
- package/dist/worker/console/observe-health-match.js +174 -0
- package/dist/worker/console/observe-link.js +33 -0
- package/dist/worker/console/operation-runner.js +166 -0
- package/dist/worker/console/operation-sse.js +158 -0
- package/dist/worker/console/operation-store.js +147 -0
- package/dist/worker/console/operator-actions.js +769 -0
- package/dist/worker/console/pi-readiness.js +94 -0
- package/dist/worker/console/recovery-cta.js +133 -0
- package/dist/worker/console/repo-fingerprint.js +29 -0
- package/dist/worker/console/resource-loader.js +95 -0
- package/dist/worker/console/routes.js +368 -0
- package/dist/worker/console/security.js +126 -0
- package/dist/worker/console/server.js +149 -0
- package/dist/worker/console/sibling-controller.js +28 -0
- package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
- package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
- package/dist/worker/console/static/index.html +13 -0
- package/dist/worker/console/vite.config.js +27 -0
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/health.js +57 -0
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +142 -27
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
- package/dist/workflows/dag/frontend-repair.js +1 -10
- package/dist/workflows/dag/init-hybrid.js +372 -115
- package/dist/workflows/dag/node-execution.js +57 -6
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/retry-policy.js +16 -1
- package/dist/workflows/dag/runner.js +9 -0
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/task-contract-binding.js +138 -0
- package/dist/workflows/dag/types.js +84 -10
- package/dist/workflows/dag/validate.js +53 -7
- package/docs/README.md +2 -0
- package/docs/architecture/evolution.md +2 -0
- package/docs/architecture/system-overview.md +6 -0
- package/docs/architecture/worker-and-feature.md +7 -0
- package/docs/templates/agent-dag.schema.json +64 -2
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
- package/docs/templates/backend-test-dag.json +26 -154
- package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
- package/package.json +8 -2
- package/skills/agent-worker/SKILL.md +1 -0
- package/skills/agent-worker/references/agent-worker-operator.md +3 -2
- package/skills/frontend-design-review/SKILL.md +25 -16
- package/skills/frontend-implementation/references/node-contracts.md +5 -5
- package/skills/loop-agent/references/command-reference.md +48 -1
- package/skills/loop-agent/references/hybrid-dag.md +4 -4
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Loop Operator Console</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CbnMgdWa.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Dnj0RVs8.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { defineConfig } from "vite";
|
|
4
|
+
const consoleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const repoRoot = path.resolve(consoleDir, "../../..");
|
|
6
|
+
/**
|
|
7
|
+
* Vite SPA build for Loop Operator Console.
|
|
8
|
+
* Output: dist/worker/console/static (no source maps in publish tarball).
|
|
9
|
+
*/
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
root: path.join(consoleDir, "static-src"),
|
|
12
|
+
base: "/",
|
|
13
|
+
build: {
|
|
14
|
+
outDir: path.join(repoRoot, "dist/worker/console/static"),
|
|
15
|
+
emptyOutDir: true,
|
|
16
|
+
sourcemap: false,
|
|
17
|
+
// Keep Phase 1 shell small; no code-split complexity required.
|
|
18
|
+
rollupOptions: {
|
|
19
|
+
input: path.join(consoleDir, "static-src/index.html"),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// React is a devDependency; esbuild jsx transform avoids needing @vitejs/plugin-react.
|
|
23
|
+
esbuild: {
|
|
24
|
+
jsx: "automatic",
|
|
25
|
+
jsxImportSource: "react",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -122,22 +122,51 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
122
122
|
const changes = await readChanges(transaction.repoRoot);
|
|
123
123
|
const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
|
|
124
124
|
if (outcome.status === "succeeded") {
|
|
125
|
-
|
|
125
|
+
const allowedPaths = expandAllowedPathsForWorkflow(resolveWorkflow(outcome.taskSpec).workflow, outcome.taskSpec.constraints.allowed_paths);
|
|
126
|
+
// Evidence under allowed_paths may be gitignored for local convenience (e.g.
|
|
127
|
+
// reports/welcome/** smoke JSON) but must still checkpoint for Task Pool Done.
|
|
128
|
+
// Sensitive / out-of-scope ignored files remain fail-closed; ephemeral tool
|
|
129
|
+
// caches neither block nor force-add.
|
|
130
|
+
const forceAddIgnored = [];
|
|
131
|
+
const blockedIgnored = [];
|
|
132
|
+
for (const entry of newIgnored) {
|
|
133
|
+
if (isEphemeralToolCachePath(entry))
|
|
134
|
+
continue;
|
|
135
|
+
if (isSensitivePath(entry)) {
|
|
136
|
+
blockedIgnored.push(entry);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (allowedPaths.some((glob) => matchesGlob(entry, glob))) {
|
|
140
|
+
forceAddIgnored.push(entry);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
blockedIgnored.push(entry);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (blockedIgnored.length > 0) {
|
|
147
|
+
throw new Error(`task created ignored files outside the Git checkpoint: ${blockedIgnored.join(", ")}`);
|
|
148
|
+
}
|
|
149
|
+
const checkpointFiles = [...changes, ...forceAddIgnored];
|
|
150
|
+
if (checkpointFiles.length === 0) {
|
|
126
151
|
throw new Error(`successful task produced no checkpointable changes: ${outcome.taskSpec.id}`);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
}
|
|
153
|
+
auditChangedPaths(checkpointFiles, outcome.taskSpec);
|
|
154
|
+
if (changes.length > 0) {
|
|
155
|
+
await git(transaction.repoRoot, ["add", "--", ...changes]);
|
|
156
|
+
}
|
|
157
|
+
if (forceAddIgnored.length > 0) {
|
|
158
|
+
await git(transaction.repoRoot, ["add", "-f", "--", ...forceAddIgnored]);
|
|
159
|
+
}
|
|
131
160
|
const message = commitMessage(outcome.taskSpec, outcome.workerRunId);
|
|
132
161
|
await git(transaction.repoRoot, ["commit", "-m", message]);
|
|
133
162
|
const commit = await git(transaction.repoRoot, ["rev-parse", "HEAD"]);
|
|
134
|
-
const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles:
|
|
163
|
+
const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: checkpointFiles, createdAt: (outcome.now ?? new Date()).toISOString() };
|
|
135
164
|
const previousCheckpoint = current.lastCheckpoint;
|
|
136
165
|
try {
|
|
137
166
|
await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
|
|
138
167
|
await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
|
|
139
168
|
const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
|
|
140
|
-
assertIgnoredBaselineUnchanged(current.ignoredBaseline, afterCommitIgnored);
|
|
169
|
+
assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path)));
|
|
141
170
|
}
|
|
142
171
|
catch (error) {
|
|
143
172
|
const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
|
|
@@ -162,7 +191,13 @@ export async function finalizeGitTask(transaction, outcome) {
|
|
|
162
191
|
}
|
|
163
192
|
transaction.record = current;
|
|
164
193
|
await assertClean(transaction.repoRoot);
|
|
165
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
status: "checkpointed",
|
|
196
|
+
taskId: outcome.taskSpec.id,
|
|
197
|
+
workerRunId: outcome.workerRunId,
|
|
198
|
+
commit,
|
|
199
|
+
changedFiles: checkpointFiles,
|
|
200
|
+
};
|
|
166
201
|
}
|
|
167
202
|
const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
|
|
168
203
|
await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { findPackageRoot, readPackageName, readPackageVersion, } from "../../shared/package-metadata.js";
|
|
4
|
+
import { repoFingerprintV1 } from "../console/repo-fingerprint.js";
|
|
5
|
+
/** Observe `/api/health` schema version (independent of Console product health). */
|
|
6
|
+
export const OBSERVE_HEALTH_SCHEMA_VERSION = 1;
|
|
7
|
+
function routeExists(routes, method, pathname) {
|
|
8
|
+
return routes.some((route) => route.method === method && route.pattern.test(pathname));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Derive capability flags from live ROUTES matchers (single source of truth).
|
|
12
|
+
* Synthetic paths exercise patterns; do not maintain a second handwritten table.
|
|
13
|
+
*/
|
|
14
|
+
export function deriveObserveRouteCapabilities(routes) {
|
|
15
|
+
return {
|
|
16
|
+
dagRun: routeExists(routes, "GET", "/api/dag-runs/example-run"),
|
|
17
|
+
task: routeExists(routes, "GET", "/api/tasks/example-task") ||
|
|
18
|
+
routeExists(routes, "GET", "/api/features/example-feature/tasks/example-task"),
|
|
19
|
+
feature: routeExists(routes, "GET", "/api/features/example-feature/tasks/example-task"),
|
|
20
|
+
workerRun: routeExists(routes, "GET", "/api/runs/example-run"),
|
|
21
|
+
batch: routeExists(routes, "GET", "/api/batches/example-batch"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function resolveObservePackageIdentity(fromFileUrl = import.meta.url) {
|
|
25
|
+
const startDir = path.dirname(fileURLToPath(fromFileUrl));
|
|
26
|
+
const packageRoot = findPackageRoot(startDir);
|
|
27
|
+
const packageName = packageRoot
|
|
28
|
+
? (readPackageName(packageRoot) ?? "unknown")
|
|
29
|
+
: "unknown";
|
|
30
|
+
const packageVersion = packageRoot
|
|
31
|
+
? (readPackageVersion(packageRoot) ?? "0.0.0")
|
|
32
|
+
: "0.0.0";
|
|
33
|
+
return { packageName, packageVersion };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build versioned Observe health DTO for GET /api/health.
|
|
37
|
+
*/
|
|
38
|
+
export function buildObserveHealthV1(input) {
|
|
39
|
+
const { packageName, packageVersion } = resolveObservePackageIdentity(input.fromFileUrl);
|
|
40
|
+
let repoFingerprint;
|
|
41
|
+
try {
|
|
42
|
+
repoFingerprint = repoFingerprintV1(input.repoRoot);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
repoFingerprint = `error:${error instanceof Error ? error.message : String(error)}`;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
schemaVersion: OBSERVE_HEALTH_SCHEMA_VERSION,
|
|
50
|
+
repoRoot: input.repoRoot,
|
|
51
|
+
repoFingerprint,
|
|
52
|
+
packageName,
|
|
53
|
+
packageVersion,
|
|
54
|
+
routeCapabilities: deriveObserveRouteCapabilities(input.routes),
|
|
55
|
+
generatedAt: input.generatedAt ?? new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
const ALLOWED_ARTIFACT_EXTENSIONS = new Set([
|
|
4
5
|
".csv",
|
|
@@ -91,3 +92,83 @@ function isPathInside(root, target) {
|
|
|
91
92
|
const normalizedTarget = path.normalize(target);
|
|
92
93
|
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Safe repo-relative file resolver for spec-evidence previews (binding sources).
|
|
97
|
+
*
|
|
98
|
+
* Enforces AC-003: rejects `..`, absolute paths (POSIX + Windows drive),
|
|
99
|
+
* directory, binary content, and symlink escape via realpath re-check.
|
|
100
|
+
*
|
|
101
|
+
* Unlike resolveArtifactPath (which only allows .harness artifact roots), this
|
|
102
|
+
* allows any repo-relative text file that was already validated as an evidence
|
|
103
|
+
* member by the caller. Returns { absPath, size } on success, throws otherwise.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveRepoFilePreview(repoRoot, relPath) {
|
|
106
|
+
if (!relPath || typeof relPath !== "string") {
|
|
107
|
+
throw new Error("Invalid path");
|
|
108
|
+
}
|
|
109
|
+
if (path.isAbsolute(relPath)) {
|
|
110
|
+
throw new Error("Absolute paths are not allowed");
|
|
111
|
+
}
|
|
112
|
+
// Windows drive letters (e.g. C:\) or POSIX absolute already covered, plus
|
|
113
|
+
// explicit backslash-drive form used in tests.
|
|
114
|
+
if (/^[A-Za-z]:[\\/]/.test(relPath)) {
|
|
115
|
+
throw new Error("Absolute paths are not allowed");
|
|
116
|
+
}
|
|
117
|
+
// Reject any path segment that is ".." (handles ../, nested .., ./..).
|
|
118
|
+
const preSegments = relPath.split(/[\\/]/).filter(Boolean);
|
|
119
|
+
if (preSegments.includes("..")) {
|
|
120
|
+
throw new Error("Path traversal is not allowed");
|
|
121
|
+
}
|
|
122
|
+
if (!isAllowedArtifactTextPath(relPath)) {
|
|
123
|
+
throw new Error("Binary or disallowed file type");
|
|
124
|
+
}
|
|
125
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
126
|
+
let realRepoRoot;
|
|
127
|
+
try {
|
|
128
|
+
realRepoRoot = realpathSync(resolvedRepoRoot);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
realRepoRoot = resolvedRepoRoot;
|
|
132
|
+
}
|
|
133
|
+
const candidate = path.resolve(realRepoRoot, relPath);
|
|
134
|
+
const relative = path.relative(realRepoRoot, candidate);
|
|
135
|
+
if (relative === "" ||
|
|
136
|
+
relative === ".." ||
|
|
137
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
138
|
+
path.isAbsolute(relative)) {
|
|
139
|
+
throw new Error("Path escapes repo root");
|
|
140
|
+
}
|
|
141
|
+
if (!existsSync(candidate)) {
|
|
142
|
+
throw new Error("File not found");
|
|
143
|
+
}
|
|
144
|
+
let realCandidate;
|
|
145
|
+
try {
|
|
146
|
+
realCandidate = realpathSync(candidate);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new Error("File not found");
|
|
150
|
+
}
|
|
151
|
+
if (!isPathInside(realRepoRoot, realCandidate)) {
|
|
152
|
+
throw new Error("Symlink escapes repo root");
|
|
153
|
+
}
|
|
154
|
+
const fileStat = await stat(realCandidate);
|
|
155
|
+
if (!fileStat.isFile()) {
|
|
156
|
+
throw new Error("Path is not a regular file");
|
|
157
|
+
}
|
|
158
|
+
const sampleLength = Math.min(fileStat.size, 8 * 1024);
|
|
159
|
+
if (sampleLength > 0) {
|
|
160
|
+
const sample = Buffer.alloc(sampleLength);
|
|
161
|
+
const handle = await open(realCandidate, "r");
|
|
162
|
+
let bytesRead = 0;
|
|
163
|
+
try {
|
|
164
|
+
({ bytesRead } = await handle.read(sample, 0, sampleLength, 0));
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await handle.close();
|
|
168
|
+
}
|
|
169
|
+
if (sample.subarray(0, bytesRead).includes(0)) {
|
|
170
|
+
throw new Error("Binary file content is not allowed");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { absPath: realCandidate, size: fileStat.size };
|
|
174
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { open as openFile, readFile, stat } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
|
|
@@ -8,8 +9,9 @@ import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, }
|
|
|
8
9
|
import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
|
|
9
10
|
import { dagSourceBindingSchema } from "../../workflows/dag/types.js";
|
|
10
11
|
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
11
|
-
import { isAllowedArtifactTextPath, resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
|
|
12
|
-
import { extractSpecEvidence, } from "./spec-evidence.js";
|
|
12
|
+
import { isAllowedArtifactTextPath, resolveArtifactPath, resolveRepoFilePreview, toRepoRelativeArtifactPath, } from "./paths.js";
|
|
13
|
+
import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js";
|
|
14
|
+
import { buildObserveHealthV1 } from "./health.js";
|
|
13
15
|
const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
|
|
14
16
|
export function createObserveSnapshotCache() {
|
|
15
17
|
return { expiresAt: 0 };
|
|
@@ -76,6 +78,11 @@ const ROUTES = [
|
|
|
76
78
|
pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
|
|
77
79
|
handler: handleDagNodeSpecEvidence,
|
|
78
80
|
},
|
|
81
|
+
{
|
|
82
|
+
method: "GET",
|
|
83
|
+
pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence\/file$/,
|
|
84
|
+
handler: handleDagNodeSpecEvidenceFile,
|
|
85
|
+
},
|
|
79
86
|
{
|
|
80
87
|
method: "GET",
|
|
81
88
|
pattern: /^\/api\/dag-runs\/([^/]+)$/,
|
|
@@ -149,11 +156,10 @@ export async function serveStatic(req, res, staticDir) {
|
|
|
149
156
|
res.end(content);
|
|
150
157
|
}
|
|
151
158
|
async function handleHealth(_req, res, _match, ctx) {
|
|
152
|
-
sendJson(res, 200, {
|
|
153
|
-
ok: true,
|
|
159
|
+
sendJson(res, 200, buildObserveHealthV1({
|
|
154
160
|
repoRoot: ctx.repoRoot,
|
|
155
|
-
|
|
156
|
-
});
|
|
161
|
+
routes: ROUTES,
|
|
162
|
+
}));
|
|
157
163
|
}
|
|
158
164
|
async function getSnapshot(ctx) {
|
|
159
165
|
const now = Date.now();
|
|
@@ -706,35 +712,142 @@ function sendJson(res, status, body) {
|
|
|
706
712
|
});
|
|
707
713
|
res.end(payload);
|
|
708
714
|
}
|
|
709
|
-
async function
|
|
710
|
-
const
|
|
711
|
-
const nodeId = match.params.sub;
|
|
712
|
-
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
713
|
-
sendJson(res, 400, { error: "Invalid dag run or node identifier" });
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
// Extract skill injection info from the DAG run spec (run.json)
|
|
717
|
-
const skillInjection = { skills: [], references: [] };
|
|
718
|
-
let sourceBinding;
|
|
719
|
-
const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
|
|
715
|
+
async function loadDagRunSourceBinding(repoRoot, dagRunId) {
|
|
716
|
+
const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
|
|
720
717
|
for (const lifecycle of ["active", "completed", "paused"]) {
|
|
721
718
|
const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
|
|
722
719
|
try {
|
|
723
720
|
const runRaw = await readFile(runJsonPath, "utf-8");
|
|
724
721
|
const runSpec = JSON.parse(runRaw);
|
|
725
|
-
const
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
if (task?.skills && Array.isArray(task.skills)) {
|
|
730
|
-
skillInjection.skills = task.skills;
|
|
731
|
-
}
|
|
732
|
-
break;
|
|
722
|
+
const parsed = dagSourceBindingSchema.safeParse(runSpec.sourceBinding);
|
|
723
|
+
if (parsed.success)
|
|
724
|
+
return parsed.data;
|
|
725
|
+
return undefined;
|
|
733
726
|
}
|
|
734
727
|
catch {
|
|
735
728
|
// run.json may not exist; continue to next lifecycle
|
|
736
729
|
}
|
|
737
730
|
}
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Read-only spec-evidence file preview (AC-001..AC-005). Re-validates evidence
|
|
735
|
+
* membership server-side: binding paths must be in sourceBinding.sources, read
|
|
736
|
+
* paths must have a successful paired read in session events. Enforces AC-003
|
|
737
|
+
* path safety and AC-004 truncation + redaction.
|
|
738
|
+
*/
|
|
739
|
+
async function handleDagNodeSpecEvidenceFile(_req, res, match, ctx) {
|
|
740
|
+
const dagRunId = match.params.id;
|
|
741
|
+
const nodeId = match.params.sub;
|
|
742
|
+
if (!isSafeObservabilityIdentifier(dagRunId) ||
|
|
743
|
+
!isSafeObservabilityIdentifier(nodeId)) {
|
|
744
|
+
sendJson(res, 400, { error: "Invalid dag run or node identifier" });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const source = match.query.get("source");
|
|
748
|
+
const rawPath = match.query.get("path");
|
|
749
|
+
if (source !== "binding" && source !== "read") {
|
|
750
|
+
sendJson(res, 400, { error: "Invalid source" });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (!rawPath) {
|
|
754
|
+
sendJson(res, 400, { error: "path is required" });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const maxBytes = ARTIFACT_PREVIEW_MAX_BYTES;
|
|
758
|
+
if (source === "binding") {
|
|
759
|
+
const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
|
|
760
|
+
const member = sourceBinding?.sources?.find((s) => s.path === rawPath);
|
|
761
|
+
if (!sourceBinding || !member) {
|
|
762
|
+
sendJson(res, 404, { error: "Path is not a bound source" });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
let resolved;
|
|
766
|
+
try {
|
|
767
|
+
resolved = await resolveRepoFilePreview(ctx.repoRoot, rawPath);
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
sendJson(res, 400, { error: "Invalid or unsafe path" });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
let raw;
|
|
774
|
+
try {
|
|
775
|
+
raw = await readFile(resolved.absPath, "utf-8");
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
sendJson(res, 404, { error: "File not found" });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const redacted = redactSecrets(raw);
|
|
782
|
+
const content = truncateUtf8Preview(redacted, maxBytes);
|
|
783
|
+
const truncated = content !== redacted;
|
|
784
|
+
const currentSha256 = createHash("sha256")
|
|
785
|
+
.update(raw, "utf-8")
|
|
786
|
+
.digest("hex");
|
|
787
|
+
sendJson(res, 200, {
|
|
788
|
+
source: "binding",
|
|
789
|
+
path: rawPath,
|
|
790
|
+
sha256: member.sha256,
|
|
791
|
+
currentSha256,
|
|
792
|
+
hashMatch: currentSha256 === member.sha256,
|
|
793
|
+
content,
|
|
794
|
+
truncated,
|
|
795
|
+
contentBytes: Buffer.byteLength(content, "utf-8"),
|
|
796
|
+
maxBytes,
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// source === "read"
|
|
801
|
+
const record = await extractSpecReadContent(ctx.repoRoot, dagRunId, nodeId, rawPath);
|
|
802
|
+
if (!record) {
|
|
803
|
+
sendJson(res, 404, { error: "Path was not successfully read" });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const redacted = redactSecrets(record.content);
|
|
807
|
+
const content = truncateUtf8Preview(redacted, maxBytes);
|
|
808
|
+
const truncated = content !== redacted;
|
|
809
|
+
sendJson(res, 200, {
|
|
810
|
+
source: "read",
|
|
811
|
+
path: rawPath,
|
|
812
|
+
content,
|
|
813
|
+
truncated,
|
|
814
|
+
contentBytes: Buffer.byteLength(content, "utf-8"),
|
|
815
|
+
maxBytes,
|
|
816
|
+
readAt: record.timestamp ?? null,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
|
|
820
|
+
const dagRunId = match.params.id;
|
|
821
|
+
const nodeId = match.params.sub;
|
|
822
|
+
if (!isSafeObservabilityIdentifier(dagRunId) ||
|
|
823
|
+
!isSafeObservabilityIdentifier(nodeId)) {
|
|
824
|
+
sendJson(res, 400, { error: "Invalid dag run or node identifier" });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// Extract skill injection info from the DAG run spec (run.json)
|
|
828
|
+
const skillInjection = {
|
|
829
|
+
skills: [],
|
|
830
|
+
references: [],
|
|
831
|
+
};
|
|
832
|
+
const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
|
|
833
|
+
{
|
|
834
|
+
const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
|
|
835
|
+
for (const lifecycle of ["active", "completed", "paused"]) {
|
|
836
|
+
const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
|
|
837
|
+
try {
|
|
838
|
+
const runRaw = await readFile(runJsonPath, "utf-8");
|
|
839
|
+
const runSpec = JSON.parse(runRaw);
|
|
840
|
+
const task = runSpec.tasks?.find((t) => t.id === nodeId);
|
|
841
|
+
if (task?.skills && Array.isArray(task.skills)) {
|
|
842
|
+
skillInjection.skills = task.skills;
|
|
843
|
+
}
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
// run.json may not exist; continue to next lifecycle
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
738
851
|
const evidence = await extractSpecEvidence(ctx.repoRoot, dagRunId, nodeId);
|
|
739
852
|
if (!evidence) {
|
|
740
853
|
const hasSourceBinding = Boolean(sourceBinding);
|
|
@@ -756,7 +869,8 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
|
|
|
756
869
|
evidence.skillInjection = skillInjection;
|
|
757
870
|
evidence.sourceBinding = sourceBinding;
|
|
758
871
|
// Recompute status considering skill injection
|
|
759
|
-
if (evidence.specReads.length === 0 &&
|
|
872
|
+
if (evidence.specReads.length === 0 &&
|
|
873
|
+
evidence.knowledgeBaseQueries.length === 0) {
|
|
760
874
|
if (evidence.specSearches.length > 0) {
|
|
761
875
|
evidence.status = "search-only";
|
|
762
876
|
}
|
|
@@ -766,7 +880,8 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
|
|
|
766
880
|
}
|
|
767
881
|
else if (skillInjection.skills.length > 0) {
|
|
768
882
|
evidence.status = "spec-injected";
|
|
769
|
-
evidence.summary =
|
|
883
|
+
evidence.summary =
|
|
884
|
+
"规范 skill 已注入但未观察到规范文件读取或知识库查询。";
|
|
770
885
|
}
|
|
771
886
|
}
|
|
772
887
|
sendJson(res, 200, evidence);
|
|
@@ -132,6 +132,90 @@ async function readSessionEvents(filePath) {
|
|
|
132
132
|
return [];
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Coerce a `tool_execution_end` result body (toolResult.content or result.content)
|
|
137
|
+
* into a string. Accepts plain strings, `{type:"text", text}[]` arrays, and
|
|
138
|
+
* message.content shapes. Returns null when no usable body is present.
|
|
139
|
+
*/
|
|
140
|
+
function resultContentToString(content) {
|
|
141
|
+
if (content == null)
|
|
142
|
+
return null;
|
|
143
|
+
if (typeof content === "string")
|
|
144
|
+
return content;
|
|
145
|
+
if (Array.isArray(content)) {
|
|
146
|
+
const parts = [];
|
|
147
|
+
for (const part of content) {
|
|
148
|
+
if (typeof part === "string") {
|
|
149
|
+
parts.push(part);
|
|
150
|
+
}
|
|
151
|
+
else if (part && typeof part === "object" && typeof part.text === "string") {
|
|
152
|
+
parts.push(part.text);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Scan session events for successful paired `read` tool calls and return the
|
|
161
|
+
* most recent result body + timestamp whose repo-relative path matches
|
|
162
|
+
* `relPath` exactly. Used for AC-002: show the content the model actually
|
|
163
|
+
* received at read time, never the current workspace file.
|
|
164
|
+
*/
|
|
165
|
+
export async function extractSpecReadContent(repoRoot, dagRunId, nodeId, relPath) {
|
|
166
|
+
if (!relPath || !isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// The preview endpoint must expose exactly the same membership as the
|
|
170
|
+
// `specReads` list, not every file the node happened to read.
|
|
171
|
+
if (!isSpecFilePath(relPath))
|
|
172
|
+
return null;
|
|
173
|
+
const eventsPath = resolveSessionEventsPath(repoRoot, dagRunId, nodeId);
|
|
174
|
+
if (!eventsPath)
|
|
175
|
+
return null;
|
|
176
|
+
const events = await readSessionEvents(eventsPath);
|
|
177
|
+
const startMap = new Map();
|
|
178
|
+
let latest = null;
|
|
179
|
+
for (const event of events) {
|
|
180
|
+
const type = event.type;
|
|
181
|
+
const toolName = resolveToolName(event);
|
|
182
|
+
const toolCallId = event.toolCallId;
|
|
183
|
+
if (type === "tool_execution_start" && toolCallId) {
|
|
184
|
+
startMap.set(toolCallId, { toolName, input: eventToolArgs(event) });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (type !== "tool_execution_end" || toolName !== "read" || !toolCallId)
|
|
188
|
+
continue;
|
|
189
|
+
const start = startMap.get(toolCallId);
|
|
190
|
+
if (!start || start.toolName !== "read")
|
|
191
|
+
continue;
|
|
192
|
+
const isErrored = event.isError === true ||
|
|
193
|
+
event.toolResult?.error != null ||
|
|
194
|
+
event.toolResult?.ok === false ||
|
|
195
|
+
event.result?.error != null ||
|
|
196
|
+
event.result?.ok === false;
|
|
197
|
+
if (isErrored) {
|
|
198
|
+
startMap.delete(toolCallId);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const startInput = start.input ?? {};
|
|
202
|
+
const filePath = typeof startInput.path === "string" ? startInput.path : undefined;
|
|
203
|
+
if (!filePath) {
|
|
204
|
+
startMap.delete(toolCallId);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const rel = toRepoRelative(repoRoot, filePath);
|
|
208
|
+
startMap.delete(toolCallId);
|
|
209
|
+
if (!rel || rel !== relPath)
|
|
210
|
+
continue;
|
|
211
|
+
const body = resultContentToString(event.toolResult?.content) ??
|
|
212
|
+
resultContentToString(event.result?.content);
|
|
213
|
+
if (body == null)
|
|
214
|
+
continue;
|
|
215
|
+
latest = { content: body, timestamp: eventTimestamp(event) };
|
|
216
|
+
}
|
|
217
|
+
return latest;
|
|
218
|
+
}
|
|
135
219
|
export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
|
|
136
220
|
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
137
221
|
return null;
|
|
@@ -29,6 +29,29 @@ export async function fetchJsonResult(url) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Fetch a spec-evidence file preview (binding source or successful read).
|
|
34
|
+
* Resolves to the parsed JSON body, or throws an Error carrying the HTTP
|
|
35
|
+
* status and server message so the UI can render an accessible error state.
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchSpecEvidenceFile(runId, nodeId, source, relPath) {
|
|
38
|
+
const url = `/api/dag-runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence/file?source=${encodeURIComponent(source)}&path=${encodeURIComponent(relPath)}`;
|
|
39
|
+
const res = await fetch(url);
|
|
40
|
+
let body = null;
|
|
41
|
+
try {
|
|
42
|
+
body = await res.json();
|
|
43
|
+
} catch {
|
|
44
|
+
body = null;
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const message = body?.error || `加载失败:HTTP ${res.status}`;
|
|
48
|
+
const error = new Error(message);
|
|
49
|
+
error.status = res.status;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return body;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
export function artifactUrl(artifactPath) {
|
|
33
56
|
return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
|
|
34
57
|
}
|
|
@@ -46,6 +46,13 @@ export const uiState = {
|
|
|
46
46
|
dagTimelineViewportState: null,
|
|
47
47
|
dagNodeOutputViewportState: null,
|
|
48
48
|
runOutputViewportState: null,
|
|
49
|
+
/**
|
|
50
|
+
* Spec-evidence file preview drill-down. null = list view; otherwise
|
|
51
|
+
* { source, path, loading, error, data, triggerId } for the active preview.
|
|
52
|
+
*/
|
|
53
|
+
specEvidenceDetail: null,
|
|
54
|
+
/** Last-rendered spec-evidence list, used to restore the list synchronously. */
|
|
55
|
+
specEvidenceListEvidence: null,
|
|
49
56
|
pollingGeneration: 0,
|
|
50
57
|
};
|
|
51
58
|
|
|
@@ -271,3 +278,22 @@ export function bumpPollingGeneration() {
|
|
|
271
278
|
uiState.pollingGeneration += 1;
|
|
272
279
|
return uiState.pollingGeneration;
|
|
273
280
|
}
|
|
281
|
+
|
|
282
|
+
/** Open an in-inspector spec-evidence file preview (AC-001/002). */
|
|
283
|
+
export function openSpecEvidenceDetail(source, path, triggerEl) {
|
|
284
|
+
uiState.specEvidenceDetail = {
|
|
285
|
+
source,
|
|
286
|
+
path,
|
|
287
|
+
loading: true,
|
|
288
|
+
error: null,
|
|
289
|
+
data: null,
|
|
290
|
+
triggerId: triggerEl?.id ?? null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Close the preview; caller restores focus to the trigger element. */
|
|
295
|
+
export function closeSpecEvidenceDetail() {
|
|
296
|
+
const detail = uiState.specEvidenceDetail;
|
|
297
|
+
uiState.specEvidenceDetail = null;
|
|
298
|
+
return detail;
|
|
299
|
+
}
|
|
@@ -1863,6 +1863,16 @@ body.is-resizing-dag-graph {
|
|
|
1863
1863
|
.spec-evidence-time { margin-left: auto; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
1864
1864
|
.spec-evidence-warning { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--space-4); padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--hairline)); border-radius: var(--radius-sm); background: #fff8e7; color: var(--body); font-size: 12px; line-height: 1.6; }
|
|
1865
1865
|
.spec-evidence-warning > i { flex-shrink: 0; margin-top: 2px; color: var(--amber); }
|
|
1866
|
+
.spec-evidence-file-btn { display: flex; align-items: flex-start; gap: 6px; width: 100%; padding: 4px 0; background: none; border: none; text-align: left; cursor: pointer; color: inherit; font: inherit; line-height: 1.5; overflow-wrap: anywhere; border-radius: var(--radius-sm); }
|
|
1867
|
+
.spec-evidence-file-btn:hover, .spec-evidence-file-btn:focus-visible { background: color-mix(in srgb, var(--accent, #2563eb) 8%, transparent); outline: none; }
|
|
1868
|
+
.spec-evidence-file-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent, #2563eb); }
|
|
1869
|
+
.spec-evidence-detail { display: grid; gap: var(--space-3); }
|
|
1870
|
+
.spec-evidence-back { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--surface, #fff); color: var(--ink); font-size: 12px; cursor: pointer; }
|
|
1871
|
+
.spec-evidence-back:hover, .spec-evidence-back:focus-visible { background: color-mix(in srgb, var(--accent, #2563eb) 8%, var(--surface, #fff)); }
|
|
1872
|
+
.spec-evidence-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--body); }
|
|
1873
|
+
.spec-evidence-detail-content { min-height: 0; max-width: 100%; margin: 0; padding: var(--space-3); border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--surface, #fff); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; }
|
|
1874
|
+
.spec-evidence-detail-error { margin: 0; color: var(--red, #c00); font-size: 12px; }
|
|
1875
|
+
.spec-evidence-detail-truncated { margin: 0; color: var(--muted); font-size: 11px; }
|
|
1866
1876
|
|
|
1867
1877
|
.run-layer-process {
|
|
1868
1878
|
display: grid;
|