@tea-agent/loop-agent 0.27.1-beta.2 → 0.28.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 +40 -1
- package/dist/application/task-lifecycle/observe.js +5 -0
- package/dist/application/task-lifecycle/plan-transitions.js +7 -2
- package/dist/cli/program.js +1 -1
- package/dist/commands/client-recovery.js +439 -20
- package/dist/commands/init.js +42 -6
- package/dist/executors/dag-pi-executor.js +165 -56
- package/dist/executors/pi-playwright-cli-tool.js +955 -0
- package/dist/executors/pi-sdk-executor.js +56 -0
- package/dist/executors/playwright-cli-launcher.js +63 -0
- package/dist/executors/shell-executor.js +128 -0
- package/dist/shared/playwright-cli-command-policy.js +41 -0
- package/dist/worker/observability/read-model.js +66 -8
- package/dist/worker/observe/static/dag-model.js +85 -13
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
- package/dist/workflows/dag/frontend-implementation-contract.js +6 -124
- package/dist/workflows/dag/frontend-prewrite-gate.js +5 -35
- package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
- package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
- package/dist/workflows/dag/init-hybrid.js +154 -95
- package/dist/workflows/dag/lifecycle.js +33 -2
- package/dist/workflows/dag/node-execution.js +11 -5
- package/dist/workflows/dag/output-protocol.js +25 -106
- package/dist/workflows/dag/report.js +9 -2
- package/dist/workflows/dag/rerun-run.js +62 -3
- package/dist/workflows/dag/run-store.js +6 -1
- package/dist/workflows/dag/runner.js +15 -3
- package/dist/workflows/dag/types.js +27 -0
- package/dist/workflows/dag/validate.js +121 -1
- package/docs/architecture/runtime-boundaries.md +13 -11
- package/docs/init-surface.manifest.json +6 -2
- package/docs/templates/README.md +9 -1
- package/docs/templates/frontend-implementation-contract.schema.json +2 -2
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
- package/docs/templates/frontend-test-dag.json +55 -15
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
- package/harness.json +4 -4
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +18 -6
- package/skills/playwright-cli/SKILL.md +69 -402
- package/skills/playwright-cli/references/tracing.md +3 -137
- package/skills/playwright-cli/references/video-recording.md +3 -141
- package/skills/playwright-cli-case-generator/SKILL.md +53 -46
|
@@ -77,6 +77,44 @@ export async function checkPiSdkAvailability(_repoRoot) {
|
|
|
77
77
|
return { ok: false, detail: `pi SDK not available: ${message}` };
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
/** Fail-closed probe for the SDK-only structured custom-tool surface. */
|
|
81
|
+
export async function checkPiSdkCustomToolCapability(_repoRoot) {
|
|
82
|
+
if (sdkSessionFactoryOverride) {
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
detail: "pi SDK custom-tool session factory override active",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const imported = sdkImportOverrideForTests
|
|
90
|
+
? await sdkImportOverrideForTests()
|
|
91
|
+
: await loadPiSdkModule();
|
|
92
|
+
const sdk = imported;
|
|
93
|
+
const { Type } = await import("typebox");
|
|
94
|
+
const ModelRuntime = sdk.ModelRuntime;
|
|
95
|
+
if (typeof sdk.createAgentSession !== "function" ||
|
|
96
|
+
typeof sdk.getAgentDir !== "function" ||
|
|
97
|
+
typeof ModelRuntime?.create !== "function" ||
|
|
98
|
+
typeof sdk.defineTool !== "function" ||
|
|
99
|
+
typeof Type?.Object !== "function") {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
detail: "pi SDK custom-tool capability incompatible: requires createAgentSession, getAgentDir, ModelRuntime.create, defineTool, and typebox Type.Object",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
detail: "pi SDK structured custom-tool capability available",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
detail: `pi SDK custom-tool capability unavailable: ${message}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
80
118
|
async function resolveModel(modelRuntime, provider, modelId) {
|
|
81
119
|
const fromRuntime = modelRuntime.getModel(provider, modelId);
|
|
82
120
|
if (fromRuntime)
|
|
@@ -159,6 +197,24 @@ async function createSdkSession(sdk, input, shared) {
|
|
|
159
197
|
? { customTools: input.customTools }
|
|
160
198
|
: {}),
|
|
161
199
|
});
|
|
200
|
+
const customToolNames = (input.customTools ?? [])
|
|
201
|
+
.map((tool) => isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined)
|
|
202
|
+
.filter((name) => Boolean(name));
|
|
203
|
+
if (customToolNames.length > 0) {
|
|
204
|
+
const activeTools = created.session.agent?.state?.tools;
|
|
205
|
+
if (!Array.isArray(activeTools)) {
|
|
206
|
+
throw new Error("pi SDK session does not expose agent.state.tools; refusing unverifiable custom-tool activation");
|
|
207
|
+
}
|
|
208
|
+
const activeNames = new Set(activeTools
|
|
209
|
+
.map((tool) => typeof tool?.name === "string" ? tool.name : undefined)
|
|
210
|
+
.filter((name) => Boolean(name)));
|
|
211
|
+
for (const customToolName of customToolNames) {
|
|
212
|
+
const expectedActive = input.toolNames.includes(customToolName);
|
|
213
|
+
if (activeNames.has(customToolName) !== expectedActive) {
|
|
214
|
+
throw new Error(`pi SDK custom-tool activation mismatch for ${customToolName}: expectedActive=${expectedActive}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
162
218
|
return created.session;
|
|
163
219
|
}
|
|
164
220
|
/**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
const PACKAGE_NAME = "@playwright/cli";
|
|
5
|
+
function launcherFromManifest(manifestPath) {
|
|
6
|
+
try {
|
|
7
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
8
|
+
if (manifest.name !== PACKAGE_NAME)
|
|
9
|
+
return undefined;
|
|
10
|
+
const bin = typeof manifest.bin === "string"
|
|
11
|
+
? manifest.bin
|
|
12
|
+
: manifest.bin && typeof manifest.bin === "object"
|
|
13
|
+
? manifest.bin["playwright-cli"]
|
|
14
|
+
: undefined;
|
|
15
|
+
if (typeof bin !== "string" || !bin || path.isAbsolute(bin) || bin.split(/[\\/]+/).includes(".."))
|
|
16
|
+
return undefined;
|
|
17
|
+
const packageRoot = path.dirname(manifestPath);
|
|
18
|
+
const entryPath = path.resolve(packageRoot, bin);
|
|
19
|
+
const relative = path.relative(packageRoot, entryPath);
|
|
20
|
+
if (relative.startsWith("..") || path.isAbsolute(relative) || !existsSync(entryPath))
|
|
21
|
+
return undefined;
|
|
22
|
+
return { executable: process.execPath, argvPrefix: [entryPath], entryPath };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function candidateManifestPaths(cwd) {
|
|
29
|
+
const paths = new Set();
|
|
30
|
+
for (const root of [cwd, process.cwd()]) {
|
|
31
|
+
try {
|
|
32
|
+
paths.add(createRequire(path.join(path.resolve(root), "package.json")).resolve(`${PACKAGE_NAME}/package.json`));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Keep searching controller-known module roots.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Npm's Windows/global shim is paired with node_modules in either the shim
|
|
39
|
+
// directory or its parent (.bin). We read package metadata directly; the
|
|
40
|
+
// .cmd file itself is never executed or parsed as a command string.
|
|
41
|
+
for (const binDir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
42
|
+
paths.add(path.join(binDir, "node_modules", ...PACKAGE_NAME.split("/"), "package.json"));
|
|
43
|
+
paths.add(path.join(binDir, "..", ...PACKAGE_NAME.split("/"), "package.json"));
|
|
44
|
+
}
|
|
45
|
+
return [...paths];
|
|
46
|
+
}
|
|
47
|
+
/** Resolve only a package-declared entry; throws instead of falling back to a PATH executable. */
|
|
48
|
+
export function resolvePlaywrightCliLauncher(cwd) {
|
|
49
|
+
for (const manifestPath of candidateManifestPaths(cwd)) {
|
|
50
|
+
const launcher = launcherFromManifest(manifestPath);
|
|
51
|
+
if (launcher)
|
|
52
|
+
return launcher;
|
|
53
|
+
}
|
|
54
|
+
throw new Error("playwright-cli-unavailable: verified @playwright/cli JavaScript entry was not found");
|
|
55
|
+
}
|
|
56
|
+
export function checkPlaywrightCliLauncher(cwd) {
|
|
57
|
+
try {
|
|
58
|
+
return { ok: true, launcher: resolvePlaywrightCliLauncher(cwd) };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -6,6 +7,10 @@ import { writeDagNodeJsonArtifact, writeDagNodeTextArtifact, } from "../infrastr
|
|
|
6
7
|
import { captureWorkspaceCheckpoint } from "../workflows/dag/workspace-checkpoint.js";
|
|
7
8
|
import { truncateOutput } from "../shared/output-truncation.js";
|
|
8
9
|
import { processTreeSpawnOptions, terminateProcessTree, } from "./process-tree.js";
|
|
10
|
+
import { createPlaywrightCliTool, defaultPlaywrightCliCommandRunner, } from "./pi-playwright-cli-tool.js";
|
|
11
|
+
import { resolvePiBackend } from "./pi-executor.js";
|
|
12
|
+
import { checkPiSdkCustomToolCapability } from "./pi-sdk-executor.js";
|
|
13
|
+
import { resolvePlaywrightCliLauncher } from "./playwright-cli-launcher.js";
|
|
9
14
|
import { buildRequirementCoverageGateShellCommand, expandShellPreset, buildVerdictGateShellCommand, } from "./shell-presets.js";
|
|
10
15
|
import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend-test-analysis-contract.js";
|
|
11
16
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
@@ -32,6 +37,7 @@ import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview,
|
|
|
32
37
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
33
38
|
import { buildShellProcessEnv } from "./shell-verification.js";
|
|
34
39
|
import { readRunState } from "../workflows/dag/run-store.js";
|
|
40
|
+
import { resolveDagTaskSourcePath } from "../task/dag-source-paths.js";
|
|
35
41
|
import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
|
|
36
42
|
import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
|
|
37
43
|
/** In-memory same-run success cache. Never shared across runIds. */
|
|
@@ -138,6 +144,56 @@ export function resolveShellCwd(root, requestedCwd) {
|
|
|
138
144
|
}
|
|
139
145
|
return resolved;
|
|
140
146
|
}
|
|
147
|
+
async function resolveControllerFrontendBaseUrlForPreflight(input) {
|
|
148
|
+
const binding = input.spec.sourceBinding;
|
|
149
|
+
const candidates = (binding?.sources ?? []).filter((source) => /(?:^|\/)config\.md$/i.test(source.path));
|
|
150
|
+
let rawBaseUrl = "http://localhost:5173";
|
|
151
|
+
let baseUrlSource = "default-localhost-5173";
|
|
152
|
+
let baseUrlSourceSha256;
|
|
153
|
+
for (const source of candidates) {
|
|
154
|
+
const absolutePath = resolveDagTaskSourcePath({
|
|
155
|
+
workspaceRoot: input.workspaceRoot,
|
|
156
|
+
taskId: binding.taskId,
|
|
157
|
+
sourcePath: source.path,
|
|
158
|
+
});
|
|
159
|
+
const markdown = await readFile(absolutePath, "utf8");
|
|
160
|
+
const actualSha256 = createHash("sha256").update(markdown).digest("hex");
|
|
161
|
+
if (actualSha256 !== source.sha256) {
|
|
162
|
+
throw new Error(`browser-command-capability-unavailable: stale frontend source ${source.path}`);
|
|
163
|
+
}
|
|
164
|
+
const match = markdown.match(/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i);
|
|
165
|
+
if (!match?.[1]) {
|
|
166
|
+
if (/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]/i.test(markdown)) {
|
|
167
|
+
throw new Error(`browser-command-capability-unavailable: invalid controller baseUrl from ${source.path}`);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
|
|
172
|
+
baseUrlSource = source.path;
|
|
173
|
+
baseUrlSourceSha256 = source.sha256;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = new URL(rawBaseUrl);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new Error(`browser-command-capability-unavailable: invalid controller baseUrl from ${baseUrlSource}`);
|
|
182
|
+
}
|
|
183
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
184
|
+
parsed.username ||
|
|
185
|
+
parsed.password ||
|
|
186
|
+
parsed.search ||
|
|
187
|
+
parsed.hash ||
|
|
188
|
+
/(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
|
|
189
|
+
throw new Error(`browser-command-capability-unavailable: unsafe controller baseUrl from ${baseUrlSource}`);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
baseUrl: parsed.toString(),
|
|
193
|
+
baseUrlSource,
|
|
194
|
+
...(baseUrlSourceSha256 ? { baseUrlSourceSha256 } : {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
141
197
|
async function resolveJsonArtifactSourceNodeId(input) {
|
|
142
198
|
const candidates = [
|
|
143
199
|
input.fromNodeId,
|
|
@@ -1671,6 +1727,78 @@ async function executeFrontendLintBaseline(input, meta) {
|
|
|
1671
1727
|
}
|
|
1672
1728
|
export async function executeDagShellNode(input, meta) {
|
|
1673
1729
|
const shell = input.task.shell;
|
|
1730
|
+
if (shell?.frontendBrowserToolPreflight) {
|
|
1731
|
+
const started = Date.now();
|
|
1732
|
+
try {
|
|
1733
|
+
if (resolvePiBackend() === "cli-only") {
|
|
1734
|
+
throw new Error("browser-command-capability-unavailable: CODE_AGENT_PI_BACKEND=cli-only rejects SDK-only playwright_cli");
|
|
1735
|
+
}
|
|
1736
|
+
const sdkCapability = await checkPiSdkCustomToolCapability(input.cwd);
|
|
1737
|
+
if (!sdkCapability.ok) {
|
|
1738
|
+
throw new Error(`browser-command-capability-unavailable: ${sdkCapability.detail}`);
|
|
1739
|
+
}
|
|
1740
|
+
const controllerOrigin = await resolveControllerFrontendBaseUrlForPreflight({
|
|
1741
|
+
workspaceRoot: input.cwd,
|
|
1742
|
+
spec: meta.spec,
|
|
1743
|
+
});
|
|
1744
|
+
const toolProbe = await createPlaywrightCliTool({
|
|
1745
|
+
repoRoot: input.cwd,
|
|
1746
|
+
runDir: meta.runDir,
|
|
1747
|
+
nodeId: input.task.id,
|
|
1748
|
+
caseId: "frontend-browser-preflight",
|
|
1749
|
+
evidenceDir: "testcase/frontend/evidence/frontend-browser-preflight",
|
|
1750
|
+
baseUrl: controllerOrigin.baseUrl,
|
|
1751
|
+
inputRoot: "testcase/frontend/fixtures",
|
|
1752
|
+
});
|
|
1753
|
+
if (toolProbe.name !== "playwright_cli") {
|
|
1754
|
+
throw new Error("browser-command-capability-unavailable: structured playwright_cli tool registration failed");
|
|
1755
|
+
}
|
|
1756
|
+
const launcher = resolvePlaywrightCliLauncher(input.cwd);
|
|
1757
|
+
const result = await defaultPlaywrightCliCommandRunner({
|
|
1758
|
+
executable: launcher.executable,
|
|
1759
|
+
argv: [...launcher.argvPrefix, "--help"],
|
|
1760
|
+
cwd: input.cwd,
|
|
1761
|
+
timeoutMs: 30_000,
|
|
1762
|
+
env: { ...process.env, CI: process.env.CI ?? "1" },
|
|
1763
|
+
});
|
|
1764
|
+
const help = `${result.stdout}\n${result.stderr}`;
|
|
1765
|
+
if (result.exitCode !== 0 ||
|
|
1766
|
+
result.timedOut ||
|
|
1767
|
+
!["open", "close", "snapshot", "click"].every((command) => new RegExp(`\\b${command}\\b`, "i").test(help))) {
|
|
1768
|
+
throw new Error("playwright-cli-contract-incompatible");
|
|
1769
|
+
}
|
|
1770
|
+
const capability = {
|
|
1771
|
+
schemaVersion: 1,
|
|
1772
|
+
status: "ready",
|
|
1773
|
+
tool: "playwright-cli",
|
|
1774
|
+
launcher: "verified-js-entry",
|
|
1775
|
+
sdkCustomToolCapability: true,
|
|
1776
|
+
structuredToolName: toolProbe.name,
|
|
1777
|
+
baseUrl: controllerOrigin.baseUrl,
|
|
1778
|
+
baseUrlSource: controllerOrigin.baseUrlSource,
|
|
1779
|
+
...(controllerOrigin.baseUrlSourceSha256
|
|
1780
|
+
? { baseUrlSourceSha256: controllerOrigin.baseUrlSourceSha256 }
|
|
1781
|
+
: {}),
|
|
1782
|
+
};
|
|
1783
|
+
await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "frontend-browser-capability.json", capability);
|
|
1784
|
+
return {
|
|
1785
|
+
ok: true,
|
|
1786
|
+
stdout: JSON.stringify(capability),
|
|
1787
|
+
stderr: "",
|
|
1788
|
+
failureCategory: "success",
|
|
1789
|
+
durationMs: Date.now() - started,
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
catch (error) {
|
|
1793
|
+
return {
|
|
1794
|
+
ok: false,
|
|
1795
|
+
stdout: "",
|
|
1796
|
+
stderr: `playwright-cli-unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
1797
|
+
failureCategory: "tool-policy",
|
|
1798
|
+
durationMs: Date.now() - started,
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1674
1802
|
if (shell?.frontendPrewriteGate) {
|
|
1675
1803
|
const started = Date.now();
|
|
1676
1804
|
try {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral command-policy source shared by the structured Pi runtime and
|
|
3
|
+
* pre-execution frontend case checklist. Keep this list capability-only: argv
|
|
4
|
+
* and browser-safety validation remain owned by the executor.
|
|
5
|
+
*/
|
|
6
|
+
export const PLAYWRIGHT_CLI_ALLOWED_COMMANDS = [
|
|
7
|
+
"open",
|
|
8
|
+
"close",
|
|
9
|
+
"goto",
|
|
10
|
+
"snapshot",
|
|
11
|
+
"find",
|
|
12
|
+
"click",
|
|
13
|
+
"dblclick",
|
|
14
|
+
"fill",
|
|
15
|
+
"type",
|
|
16
|
+
"press",
|
|
17
|
+
"keydown",
|
|
18
|
+
"keyup",
|
|
19
|
+
"hover",
|
|
20
|
+
"select",
|
|
21
|
+
"check",
|
|
22
|
+
"uncheck",
|
|
23
|
+
"drag",
|
|
24
|
+
"drop",
|
|
25
|
+
"upload",
|
|
26
|
+
"go-back",
|
|
27
|
+
"go-forward",
|
|
28
|
+
"reload",
|
|
29
|
+
"dialog-accept",
|
|
30
|
+
"dialog-dismiss",
|
|
31
|
+
"resize",
|
|
32
|
+
"screenshot",
|
|
33
|
+
"pdf",
|
|
34
|
+
"console",
|
|
35
|
+
"requests",
|
|
36
|
+
"request",
|
|
37
|
+
];
|
|
38
|
+
const allowedCommandSet = new Set(PLAYWRIGHT_CLI_ALLOWED_COMMANDS);
|
|
39
|
+
export function isPlaywrightCliCommand(value) {
|
|
40
|
+
return allowedCommandSet.has(value);
|
|
41
|
+
}
|
|
@@ -722,6 +722,7 @@ const ACTIVE_EFFECTIVE_STATUSES = new Set([
|
|
|
722
722
|
"needs-attention",
|
|
723
723
|
"remote-unknown",
|
|
724
724
|
"pending",
|
|
725
|
+
"paused",
|
|
725
726
|
]);
|
|
726
727
|
const TERMINAL_RUN_STATUSES = new Set([
|
|
727
728
|
"finished",
|
|
@@ -730,13 +731,51 @@ const TERMINAL_RUN_STATUSES = new Set([
|
|
|
730
731
|
"superseded",
|
|
731
732
|
"abandoned",
|
|
732
733
|
]);
|
|
734
|
+
const NON_EXECUTION_MODES = new Set(["dry-run", "init-only"]);
|
|
735
|
+
const SHELL_NODE_STATUSES = new Set(["pending", "queued"]);
|
|
736
|
+
function hasDagExecutionEvidence(dag) {
|
|
737
|
+
const status = (dag.status ?? "").toLowerCase();
|
|
738
|
+
if (status === "running" || status === "started" || status === "paused") {
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
return (dag.nodes ?? []).some((node) => {
|
|
742
|
+
const nodeStatus = (node.status ?? "").toLowerCase();
|
|
743
|
+
return nodeStatus.length > 0 && !SHELL_NODE_STATUSES.has(nodeStatus);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
733
746
|
/**
|
|
734
|
-
*
|
|
735
|
-
*
|
|
747
|
+
* Unified overview-active matrix (isOverviewActive). Shared contract with
|
|
748
|
+
* Observe UI `isDagRunActive` — default total view only shows real execution.
|
|
736
749
|
*/
|
|
737
750
|
function isDagRunActiveForHealth(dag) {
|
|
751
|
+
const executionMode = (dag.executionMode ?? "").toLowerCase();
|
|
752
|
+
if (NON_EXECUTION_MODES.has(executionMode))
|
|
753
|
+
return false;
|
|
754
|
+
const lifecycle = (dag.lifecycle ?? "").toLowerCase();
|
|
755
|
+
if (NON_EXECUTION_MODES.has(lifecycle))
|
|
756
|
+
return false;
|
|
757
|
+
if (lifecycle === "completed")
|
|
758
|
+
return false;
|
|
759
|
+
if (dag.effectiveStatus &&
|
|
760
|
+
TERMINAL_RUN_STATUSES.has(dag.effectiveStatus)) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
// Real execute pause remains overview-visible (AC-003).
|
|
764
|
+
if (lifecycle === "paused" || dag.effectiveStatus === "paused") {
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase())) {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
738
770
|
if (dag.effectiveStatus &&
|
|
739
771
|
ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)) {
|
|
772
|
+
// Legacy active+pending shells without execution evidence are not active.
|
|
773
|
+
if (!executionMode &&
|
|
774
|
+
dag.effectiveStatus === "pending" &&
|
|
775
|
+
!hasDagExecutionEvidence(dag)) {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
// execute-mode pending (or any non-legacy execute) stays visible.
|
|
740
779
|
return true;
|
|
741
780
|
}
|
|
742
781
|
// Only "unknown" (and absent) means liveness evidence is insufficient;
|
|
@@ -745,14 +784,20 @@ function isDagRunActiveForHealth(dag) {
|
|
|
745
784
|
if (dag.effectiveStatus && dag.effectiveStatus !== "unknown")
|
|
746
785
|
return false;
|
|
747
786
|
const status = (dag.status ?? "").toLowerCase();
|
|
748
|
-
if ((dag.lifecycle ?? "").toLowerCase() === "paused")
|
|
749
|
-
return false;
|
|
750
|
-
if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase()))
|
|
751
|
-
return false;
|
|
752
787
|
if (TERMINAL_RUN_STATUSES.has(status))
|
|
753
788
|
return false;
|
|
754
|
-
if (
|
|
789
|
+
if (executionMode === "execute") {
|
|
790
|
+
if (["running", "pending", "started", "paused"].includes(status)) {
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
return (dag.nodes ?? []).some((node) => isNodeStatusActive(node.status));
|
|
794
|
+
}
|
|
795
|
+
// Historical runs without executionMode: require real execution evidence.
|
|
796
|
+
if (status === "running" || status === "started")
|
|
755
797
|
return true;
|
|
798
|
+
if (status === "pending" || status === "") {
|
|
799
|
+
return hasDagExecutionEvidence(dag);
|
|
800
|
+
}
|
|
756
801
|
return (dag.nodes ?? []).some((node) => isNodeStatusActive(node.status));
|
|
757
802
|
}
|
|
758
803
|
function isNodeStatusActive(status) {
|
|
@@ -769,6 +814,9 @@ function isNodeStatusActive(status) {
|
|
|
769
814
|
].includes(normalized)) {
|
|
770
815
|
return false;
|
|
771
816
|
}
|
|
817
|
+
// pending/queued alone are not enough for overview active without mode/evidence.
|
|
818
|
+
if (SHELL_NODE_STATUSES.has(normalized))
|
|
819
|
+
return false;
|
|
772
820
|
return true;
|
|
773
821
|
}
|
|
774
822
|
function computeDagHealth(dagRuns) {
|
|
@@ -1276,6 +1324,7 @@ function mergeDagRun(existing, incoming) {
|
|
|
1276
1324
|
dagRunId: incoming.dagRunId,
|
|
1277
1325
|
status: incoming.status ?? existing.status,
|
|
1278
1326
|
lifecycle: incoming.lifecycle ?? existing.lifecycle,
|
|
1327
|
+
executionMode: incoming.executionMode ?? existing.executionMode,
|
|
1279
1328
|
liveness: incoming.liveness ?? existing.liveness,
|
|
1280
1329
|
effectiveStatus: incoming.effectiveStatus ?? existing.effectiveStatus,
|
|
1281
1330
|
stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
|
|
@@ -1563,11 +1612,19 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1563
1612
|
const lifecycleName = path.basename(path.dirname(runDir));
|
|
1564
1613
|
const lifecycle = lifecycleName === "active" ||
|
|
1565
1614
|
lifecycleName === "paused" ||
|
|
1566
|
-
lifecycleName === "completed"
|
|
1615
|
+
lifecycleName === "completed" ||
|
|
1616
|
+
lifecycleName === "dry-run" ||
|
|
1617
|
+
lifecycleName === "init-only"
|
|
1567
1618
|
? lifecycleName
|
|
1568
1619
|
: undefined;
|
|
1569
1620
|
const dagRunId = readString(parsed, "runId") ?? path.basename(runDir);
|
|
1570
1621
|
const status = readString(parsed, "status");
|
|
1622
|
+
const executionModeRaw = readString(parsed, "executionMode");
|
|
1623
|
+
const executionMode = executionModeRaw === "execute" ||
|
|
1624
|
+
executionModeRaw === "dry-run" ||
|
|
1625
|
+
executionModeRaw === "init-only"
|
|
1626
|
+
? executionModeRaw
|
|
1627
|
+
: undefined;
|
|
1571
1628
|
const title = readString(parsed, "title");
|
|
1572
1629
|
const startedAt = readString(parsed, "startedAt");
|
|
1573
1630
|
const finishedAt = readString(parsed, "finishedAt");
|
|
@@ -1649,6 +1706,7 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1649
1706
|
dagRunId,
|
|
1650
1707
|
status,
|
|
1651
1708
|
...(lifecycle ? { lifecycle } : {}),
|
|
1709
|
+
...(executionMode ? { executionMode } : {}),
|
|
1652
1710
|
liveness: liveness.status,
|
|
1653
1711
|
effectiveStatus,
|
|
1654
1712
|
stateConsistent,
|
|
@@ -30,29 +30,101 @@ export function dagMetaVisibility(dag) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const NON_EXECUTION_MODES = new Set(["dry-run", "init-only"]);
|
|
34
|
+
const SHELL_NODE_STATUSES = new Set(["pending", "queued"]);
|
|
35
|
+
const ACTIVE_EFFECTIVE_STATUSES = new Set([
|
|
36
|
+
"running",
|
|
37
|
+
"running-quiet",
|
|
38
|
+
"running-suspected-stall",
|
|
39
|
+
"needs-attention",
|
|
40
|
+
"remote-unknown",
|
|
41
|
+
"pending",
|
|
42
|
+
"paused",
|
|
43
|
+
]);
|
|
44
|
+
const TERMINAL_EFFECTIVE_STATUSES = new Set([
|
|
45
|
+
"finished",
|
|
46
|
+
"partial_failed",
|
|
47
|
+
"failed",
|
|
48
|
+
"superseded",
|
|
49
|
+
"abandoned",
|
|
50
|
+
]);
|
|
51
|
+
|
|
33
52
|
function isNodeActive(status) {
|
|
34
53
|
if (!status) return false;
|
|
35
54
|
const s = status.toLowerCase();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
55
|
+
// pending/queued alone do not prove overview activity without mode/evidence.
|
|
56
|
+
return s === "running" || s === "started";
|
|
39
57
|
}
|
|
40
58
|
|
|
59
|
+
function hasDagExecutionEvidence(dag) {
|
|
60
|
+
const status = (dag?.status ?? "").toLowerCase();
|
|
61
|
+
if (status === "running" || status === "started" || status === "paused") {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return (dag?.nodes ?? []).some((n) => {
|
|
65
|
+
const s = (n?.status ?? "").toLowerCase();
|
|
66
|
+
return s && !SHELL_NODE_STATUSES.has(s);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Unified overview-active matrix (isOverviewActive).
|
|
72
|
+
* Default dashboard only lists real execution (incl. paused execute).
|
|
73
|
+
*/
|
|
41
74
|
export function isDagRunActive(dag) {
|
|
75
|
+
const executionMode = (dag?.executionMode ?? "").toLowerCase();
|
|
76
|
+
if (NON_EXECUTION_MODES.has(executionMode)) return false;
|
|
77
|
+
|
|
78
|
+
const lifecycle = (dag?.lifecycle ?? "").toLowerCase();
|
|
79
|
+
if (NON_EXECUTION_MODES.has(lifecycle)) return false;
|
|
80
|
+
if (lifecycle === "completed") return false;
|
|
81
|
+
|
|
42
82
|
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
83
|
+
dag?.effectiveStatus &&
|
|
84
|
+
TERMINAL_EFFECTIVE_STATUSES.has(dag.effectiveStatus)
|
|
85
|
+
) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Real execute pause remains overview-visible (AC-003).
|
|
90
|
+
if (lifecycle === "paused" || dag?.effectiveStatus === "paused") {
|
|
47
91
|
return true;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if ((dag
|
|
51
|
-
if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase()))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (["orphaned", "stale"].includes((dag?.liveness ?? "").toLowerCase()))
|
|
52
95
|
return false;
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
dag?.effectiveStatus &&
|
|
99
|
+
ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)
|
|
100
|
+
) {
|
|
101
|
+
if (
|
|
102
|
+
!executionMode &&
|
|
103
|
+
dag.effectiveStatus === "pending" &&
|
|
104
|
+
!hasDagExecutionEvidence(dag)
|
|
105
|
+
) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (dag?.effectiveStatus && dag.effectiveStatus !== "unknown") return false;
|
|
112
|
+
|
|
113
|
+
const status = (dag?.status ?? "").toLowerCase();
|
|
53
114
|
if (TERMINAL_RUN_STATUSES.has(status)) return false;
|
|
54
|
-
|
|
55
|
-
|
|
115
|
+
|
|
116
|
+
if (executionMode === "execute") {
|
|
117
|
+
if (["running", "pending", "started", "paused"].includes(status))
|
|
118
|
+
return true;
|
|
119
|
+
return (dag?.nodes ?? []).some((n) => isNodeActive(n.status));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Historical runs without executionMode: require real execution evidence.
|
|
123
|
+
if (status === "running" || status === "started") return true;
|
|
124
|
+
if (status === "pending" || status === "") {
|
|
125
|
+
return hasDagExecutionEvidence(dag);
|
|
126
|
+
}
|
|
127
|
+
return (dag?.nodes ?? []).some((n) => isNodeActive(n.status));
|
|
56
128
|
}
|
|
57
129
|
|
|
58
130
|
const DASHBOARD_FEATURE_STATUSES = new Set(["running", "needs_action"]);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeDagRunJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
|
|
2
2
|
import { executeDagNode, } from "../node-execution.js";
|
|
3
3
|
import { writeRunSpec } from "../run-store.js";
|
|
4
|
+
import { assertValidMaterializedDagTask } from "../validate.js";
|
|
4
5
|
import { freshNodeRecord, lookupPath, parseConditionLiteral, parseJsonFromText, } from "./shared.js";
|
|
5
6
|
function evaluateLoopStopExpression(input) {
|
|
6
7
|
const equality = input.expression.match(/^\s*(\$\..+?)\s*==\s*(.+?)\s*$/);
|
|
@@ -38,6 +39,8 @@ export function buildLoopBodyChildTask(input) {
|
|
|
38
39
|
executor: bodyTask.executor,
|
|
39
40
|
role: bodyTask.role,
|
|
40
41
|
skills: bodyTask.skills,
|
|
42
|
+
toolProfile: bodyTask.toolProfile,
|
|
43
|
+
commandPolicy: bodyTask.commandPolicy,
|
|
41
44
|
writePolicy: bodyTask.writePolicy,
|
|
42
45
|
allowedPaths: bodyTask.allowedPaths,
|
|
43
46
|
forbiddenPaths: bodyTask.forbiddenPaths,
|
|
@@ -73,6 +76,7 @@ export async function executeDynamicLoopUntil(input) {
|
|
|
73
76
|
bodyIdMap,
|
|
74
77
|
}));
|
|
75
78
|
for (const child of children) {
|
|
79
|
+
assertValidMaterializedDagTask(child);
|
|
76
80
|
if (!input.tasksById.has(child.id)) {
|
|
77
81
|
input.tasksById.set(child.id, child);
|
|
78
82
|
input.spec.tasks.push(child);
|