@xfey/tutti 0.1.41 → 0.1.43
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/dist/artifacts/candidate-validator.d.ts +39 -0
- package/dist/artifacts/candidate-validator.js +512 -0
- package/dist/artifacts/index.d.ts +3 -1
- package/dist/artifacts/index.js +3 -1
- package/dist/artifacts/manifest-contract.d.ts +30 -0
- package/dist/artifacts/manifest-contract.js +214 -0
- package/dist/artifacts/manifest.d.ts +3 -12
- package/dist/artifacts/manifest.js +29 -201
- package/dist/artifacts/preview-runtime.d.ts +29 -10
- package/dist/artifacts/preview-runtime.js +400 -155
- package/dist/artifacts/process-boundary.d.ts +33 -0
- package/dist/artifacts/process-boundary.js +134 -0
- package/dist/control-plane/types.d.ts +7 -2
- package/dist/prompt-templates/index.d.ts +1 -1
- package/dist/prompt-templates/index.js +1 -0
- package/dist/provider-usage/index.js +1 -0
- package/dist/run-pipeline/artifact-applicability.d.ts +49 -0
- package/dist/run-pipeline/artifact-applicability.js +281 -0
- package/dist/run-pipeline/openai.d.ts +6 -0
- package/dist/run-pipeline/openai.js +72 -2
- package/dist/run-pipeline/promotion-reconcile.d.ts +9 -1
- package/dist/run-pipeline/promotion-reconcile.js +65 -11
- package/dist/server-shell/http/host-tunnel.js +1 -1
- package/dist/server-shell/http/routes/project-api/artifacts-routes.js +63 -14
- package/dist/server-shell/http/routes/project-api/types.d.ts +2 -0
- package/dist/workspace-ops/index.d.ts +1 -1
- package/dist/workspace-ops/index.js +1 -1
- package/dist/workspace-ops/run-workspaces.d.ts +1 -0
- package/dist/workspace-ops/run-workspaces.js +27 -0
- package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +28 -8
- package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +34 -9
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +5 -1
- package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/utils/redaction/index.js +14 -0
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/artifacts/README.md +7 -0
- package/prompts/artifacts/applicability.md +41 -0
- package/prompts/prompt-flow-map.md +29 -26
- package/prompts/runs/README.md +3 -3
- package/prompts/runs/task-continuation.md +36 -4
- package/prompts/runs/task-retry.md +42 -6
- package/prompts/runs/task-run.md +36 -4
- package/web/assets/index-CT68KthI.js +29 -0
- package/web/assets/{index-g3gtJZjU.css → index-Sqw_t67u.css} +1 -1
- package/web/index.html +2 -2
- package/web/assets/index-D9zL_JFd.js +0 -29
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
export declare const MAX_ARTIFACT_DIAGNOSTIC_CHARS = 8000;
|
|
3
|
+
export type ArtifactPreviewProcessPlan = {
|
|
4
|
+
command: "npm";
|
|
5
|
+
args: ["run", "artifact:preview"];
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: NodeJS.ProcessEnv;
|
|
8
|
+
port: number;
|
|
9
|
+
};
|
|
10
|
+
export type ArtifactProcessRuntime = {
|
|
11
|
+
root: string;
|
|
12
|
+
home: string;
|
|
13
|
+
temp: string;
|
|
14
|
+
};
|
|
15
|
+
export declare class ArtifactDiagnosticBuffer {
|
|
16
|
+
private raw;
|
|
17
|
+
append(source: "stdout" | "stderr", chunk: Buffer | string): void;
|
|
18
|
+
read(sensitiveValues?: readonly string[]): {
|
|
19
|
+
text: string;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare function createArtifactProcessRuntime(): ArtifactProcessRuntime;
|
|
24
|
+
export declare function cleanupArtifactProcessRuntime(runtime: ArtifactProcessRuntime): void;
|
|
25
|
+
export declare function createArtifactPreviewEnv(options: {
|
|
26
|
+
sourceEnv: NodeJS.ProcessEnv;
|
|
27
|
+
port: number;
|
|
28
|
+
runtime: ArtifactProcessRuntime;
|
|
29
|
+
}): NodeJS.ProcessEnv;
|
|
30
|
+
export declare function spawnArtifactPreviewProcess(plan: ArtifactPreviewProcessPlan): ChildProcessWithoutNullStreams;
|
|
31
|
+
export declare function allocateArtifactPreviewPort(): Promise<number>;
|
|
32
|
+
export declare function terminateArtifactProcessTree(child: ChildProcessWithoutNullStreams): Promise<void>;
|
|
33
|
+
//# sourceMappingURL=process-boundary.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { redactAndTruncateText } from "@tutti/shared/utils";
|
|
7
|
+
export const MAX_ARTIFACT_DIAGNOSTIC_CHARS = 8_000;
|
|
8
|
+
const MAX_RAW_DIAGNOSTIC_CHARS = MAX_ARTIFACT_DIAGNOSTIC_CHARS * 2;
|
|
9
|
+
const SAFE_ENV_NAMES = new Set(["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TEMP", "TMP"]);
|
|
10
|
+
export class ArtifactDiagnosticBuffer {
|
|
11
|
+
raw = "";
|
|
12
|
+
append(source, chunk) {
|
|
13
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
14
|
+
this.raw = `${this.raw}[${source}]\n${text}`.slice(-MAX_RAW_DIAGNOSTIC_CHARS);
|
|
15
|
+
}
|
|
16
|
+
read(sensitiveValues = []) {
|
|
17
|
+
let redacted = this.raw;
|
|
18
|
+
for (const value of sensitiveValues) {
|
|
19
|
+
if (value.length >= 4) {
|
|
20
|
+
redacted = redacted.replaceAll(value, "[REDACTED:runtime_value]");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const truncated = redacted.length > MAX_ARTIFACT_DIAGNOSTIC_CHARS;
|
|
24
|
+
return {
|
|
25
|
+
text: redactAndTruncateText(redacted, MAX_ARTIFACT_DIAGNOSTIC_CHARS),
|
|
26
|
+
truncated,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function createArtifactProcessRuntime() {
|
|
31
|
+
const root = mkdtempSync(join(tmpdir(), "tutti-artifact-preview-"));
|
|
32
|
+
const home = join(root, "home");
|
|
33
|
+
const temp = join(root, "tmp");
|
|
34
|
+
mkdirSync(home, { recursive: true });
|
|
35
|
+
mkdirSync(temp, { recursive: true });
|
|
36
|
+
return { root, home, temp };
|
|
37
|
+
}
|
|
38
|
+
export function cleanupArtifactProcessRuntime(runtime) {
|
|
39
|
+
rmSync(runtime.root, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
export function createArtifactPreviewEnv(options) {
|
|
42
|
+
const env = {};
|
|
43
|
+
for (const name of SAFE_ENV_NAMES) {
|
|
44
|
+
const value = options.sourceEnv[name];
|
|
45
|
+
if (value !== undefined && value.trim() !== "") {
|
|
46
|
+
env[name] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
env.HOME = options.runtime.home;
|
|
50
|
+
env.TMPDIR = options.runtime.temp;
|
|
51
|
+
env.TEMP = options.runtime.temp;
|
|
52
|
+
env.TMP = options.runtime.temp;
|
|
53
|
+
env.HOST = "127.0.0.1";
|
|
54
|
+
env.PORT = String(options.port);
|
|
55
|
+
env.TUTTI_ARTIFACT_PREVIEW = "1";
|
|
56
|
+
env.npm_config_update_notifier = "false";
|
|
57
|
+
env.npm_config_fund = "false";
|
|
58
|
+
env.npm_config_audit = "false";
|
|
59
|
+
return env;
|
|
60
|
+
}
|
|
61
|
+
export function spawnArtifactPreviewProcess(plan) {
|
|
62
|
+
const child = spawn(plan.command, plan.args, {
|
|
63
|
+
cwd: plan.cwd,
|
|
64
|
+
env: plan.env,
|
|
65
|
+
shell: false,
|
|
66
|
+
detached: process.platform !== "win32",
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
child.stdin.end();
|
|
70
|
+
return child;
|
|
71
|
+
}
|
|
72
|
+
export async function allocateArtifactPreviewPort() {
|
|
73
|
+
return await new Promise((resolvePort, reject) => {
|
|
74
|
+
const server = createServer();
|
|
75
|
+
server.unref();
|
|
76
|
+
server.once("error", reject);
|
|
77
|
+
server.listen(0, "127.0.0.1", () => {
|
|
78
|
+
const address = server.address();
|
|
79
|
+
if (address === null || typeof address === "string") {
|
|
80
|
+
server.close();
|
|
81
|
+
reject(new Error("Could not allocate an Artifact preview port"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
server.close((error) => {
|
|
85
|
+
if (error === undefined) {
|
|
86
|
+
resolvePort(address.port);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
reject(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function sendProcessSignal(child, signal) {
|
|
96
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (process.platform !== "win32" && child.pid !== undefined) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-child.pid, signal);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Fall back to the direct child when a process group is already gone.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
child.kill(signal);
|
|
109
|
+
}
|
|
110
|
+
function waitForChildClose(child, timeoutMs) {
|
|
111
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
112
|
+
return Promise.resolve(true);
|
|
113
|
+
}
|
|
114
|
+
return new Promise((resolveClosed) => {
|
|
115
|
+
const timeout = setTimeout(() => {
|
|
116
|
+
child.off("close", onClose);
|
|
117
|
+
resolveClosed(false);
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
const onClose = () => {
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
resolveClosed(true);
|
|
122
|
+
};
|
|
123
|
+
child.once("close", onClose);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
export async function terminateArtifactProcessTree(child) {
|
|
127
|
+
sendProcessSignal(child, "SIGTERM");
|
|
128
|
+
if (await waitForChildClose(child, 1_000)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
sendProcessSignal(child, "SIGKILL");
|
|
132
|
+
await waitForChildClose(child, 500);
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=process-boundary.js.map
|
|
@@ -84,10 +84,15 @@ export type RunCorrectionContext = {
|
|
|
84
84
|
summary: string;
|
|
85
85
|
reason_code: string;
|
|
86
86
|
guidance: string;
|
|
87
|
-
|
|
87
|
+
diagnostic: {
|
|
88
|
+
source: string;
|
|
89
|
+
text: string;
|
|
90
|
+
truncated: boolean;
|
|
91
|
+
};
|
|
92
|
+
promotion_failure: {
|
|
88
93
|
reason_code: string;
|
|
89
94
|
summary: string;
|
|
90
|
-
};
|
|
95
|
+
} | null;
|
|
91
96
|
};
|
|
92
97
|
export type RunPipelineInput = {
|
|
93
98
|
activity_ref: ActivityRef;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type PromptTemplateKey = "procedures.project_context_bootstrap" | "procedures.context_sync" | "procedures.project_brief_refresh" | "procedures.scratchpad_refresh" | "procedures.task_compile" | "procedures.reference_file_summary" | "procedures.follow_up_check" | "runs.task_run" | "runs.task_continuation" | "runs.task_retry" | "chat.assistant" | "codex.no_write_smoke";
|
|
1
|
+
export type PromptTemplateKey = "procedures.project_context_bootstrap" | "procedures.context_sync" | "procedures.project_brief_refresh" | "procedures.scratchpad_refresh" | "procedures.task_compile" | "procedures.reference_file_summary" | "procedures.follow_up_check" | "runs.task_run" | "runs.task_continuation" | "runs.task_retry" | "artifacts.applicability" | "chat.assistant" | "codex.no_write_smoke";
|
|
2
2
|
export type RenderPromptTemplateOptions = {
|
|
3
3
|
templateKey: PromptTemplateKey;
|
|
4
4
|
variables?: Record<string, unknown>;
|
|
@@ -22,6 +22,7 @@ const TEMPLATE_REGISTRY = {
|
|
|
22
22
|
"runs.task_run": "runs/task-run.md",
|
|
23
23
|
"runs.task_continuation": "runs/task-continuation.md",
|
|
24
24
|
"runs.task_retry": "runs/task-retry.md",
|
|
25
|
+
"artifacts.applicability": "artifacts/applicability.md",
|
|
25
26
|
"chat.assistant": "chat-assistant.md",
|
|
26
27
|
"codex.no_write_smoke": "codex/no-write-smoke.md",
|
|
27
28
|
};
|
|
@@ -10,6 +10,7 @@ const SOURCE_LABELS = {
|
|
|
10
10
|
workspace_write_run: { label: "Implementation", category: "implementation" },
|
|
11
11
|
follow_up_run: { label: "Follow-up run", category: "implementation" },
|
|
12
12
|
pipeline_self_correction: { label: "Self-correction", category: "implementation" },
|
|
13
|
+
artifact_applicability: { label: "Artifact applicability", category: "planning" },
|
|
13
14
|
task_compile: { label: "Worklist planning", category: "planning" },
|
|
14
15
|
scratchpad_refresh: { label: "Scratchpad sync", category: "planning" },
|
|
15
16
|
project_brief_refresh: { label: "Project brief", category: "context" },
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ActivityRef, TaskId } from "@tutti/shared/ids";
|
|
2
|
+
import { type CandidateArtifactValidationFailure, type CandidateArtifactValidationResult, type ValidateCandidateArtifactOptions } from "../artifacts/index.js";
|
|
3
|
+
import type { ProviderUsageRecorder } from "../provider-usage/index.js";
|
|
4
|
+
import { type OpenAiStructuredOutputClient } from "../providers/openai/sdk-procedure-runner.js";
|
|
5
|
+
import type { OpenAiProviderConfig } from "../providers/openai/provider-config.js";
|
|
6
|
+
import type { RunDiffSummary } from "../workspace-ops/index.js";
|
|
7
|
+
export type ArtifactApplicabilityDecision = {
|
|
8
|
+
decision: "artifact_required" | "not_applicable";
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
export type ArtifactApplicabilityJudgeInput = {
|
|
12
|
+
task: {
|
|
13
|
+
title: string;
|
|
14
|
+
goal: string;
|
|
15
|
+
scope: string[];
|
|
16
|
+
};
|
|
17
|
+
candidate: {
|
|
18
|
+
changed_paths: string[];
|
|
19
|
+
diff_facts: string;
|
|
20
|
+
repo_evidence: string[];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type ArtifactApplicabilityJudge = (input: ArtifactApplicabilityJudgeInput) => Promise<ArtifactApplicabilityDecision>;
|
|
24
|
+
export type ArtifactCandidateGateResult = {
|
|
25
|
+
status: "passed";
|
|
26
|
+
disposition: "validated" | "not_applicable";
|
|
27
|
+
reason: string;
|
|
28
|
+
} | {
|
|
29
|
+
status: "failed";
|
|
30
|
+
correctable: boolean;
|
|
31
|
+
failure: CandidateArtifactValidationFailure;
|
|
32
|
+
};
|
|
33
|
+
export type RunArtifactCandidateGateOptions = {
|
|
34
|
+
repoRoot: string;
|
|
35
|
+
task: ArtifactApplicabilityJudgeInput["task"];
|
|
36
|
+
diff: RunDiffSummary;
|
|
37
|
+
config: OpenAiProviderConfig;
|
|
38
|
+
apiKey: string;
|
|
39
|
+
promptsRoot?: string;
|
|
40
|
+
baseEnv?: NodeJS.ProcessEnv;
|
|
41
|
+
client?: OpenAiStructuredOutputClient;
|
|
42
|
+
judge?: ArtifactApplicabilityJudge;
|
|
43
|
+
validateCandidate?: (options: ValidateCandidateArtifactOptions) => Promise<CandidateArtifactValidationResult>;
|
|
44
|
+
recordUsage?: ProviderUsageRecorder;
|
|
45
|
+
activityRef?: ActivityRef;
|
|
46
|
+
taskId?: TaskId;
|
|
47
|
+
};
|
|
48
|
+
export declare function runArtifactCandidateGate(options: RunArtifactCandidateGateOptions): Promise<ArtifactCandidateGateResult>;
|
|
49
|
+
//# sourceMappingURL=artifact-applicability.d.ts.map
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { redactAndTruncateText } from "@tutti/shared/utils";
|
|
4
|
+
import { ARTIFACT_MANIFEST_PATH, validateCandidateArtifact, } from "../artifacts/index.js";
|
|
5
|
+
import { renderPromptTemplate } from "../prompt-templates/index.js";
|
|
6
|
+
import { createOpenAiStructuredOutputClient, OpenAiStructuredOutputError, runOpenAiStructuredOutput, } from "../providers/openai/sdk-procedure-runner.js";
|
|
7
|
+
const ARTIFACT_APPLICABILITY_OUTPUT_SCHEMA = {
|
|
8
|
+
type: "object",
|
|
9
|
+
required: ["decision", "reason"],
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
decision: { type: "string", enum: ["artifact_required", "not_applicable"] },
|
|
13
|
+
reason: { type: "string", minLength: 1 },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const MAX_CHANGED_PATHS = 200;
|
|
17
|
+
const MAX_DIFF_FACT_CHARS = 4_000;
|
|
18
|
+
const MAX_EVIDENCE_ITEMS = 40;
|
|
19
|
+
const MAX_DIAGNOSTIC_CHARS = 8_000;
|
|
20
|
+
const VISUAL_TASK_PATTERN = /\b(page|website|web app|frontend|ui|dashboard|landing|preview|browser|spa|visuali[sz]ation|prototype)\b|页面|网站|网页|界面|预览|可视化|仪表盘|交互/iu;
|
|
21
|
+
const BROWSER_CHANGED_PATH_PATTERN = /(^|\/)(index\.html?|vite\.config\.[^/]+|next\.config\.[^/]+|astro\.config\.[^/]+|src\/(app|main|index)\.[jt]sx?|app\/.*page\.[jt]sx?|pages\/.*\.[jt]sx?|public\/.*|web\/.*)$/iu;
|
|
22
|
+
const DOC_EXTENSION_SET = new Set([".md", ".mdx"]);
|
|
23
|
+
const WEB_DEPENDENCIES = new Set([
|
|
24
|
+
"@angular/core",
|
|
25
|
+
"@sveltejs/kit",
|
|
26
|
+
"@vitejs/plugin-react",
|
|
27
|
+
"astro",
|
|
28
|
+
"next",
|
|
29
|
+
"nuxt",
|
|
30
|
+
"react",
|
|
31
|
+
"react-dom",
|
|
32
|
+
"solid-js",
|
|
33
|
+
"svelte",
|
|
34
|
+
"vite",
|
|
35
|
+
"vue",
|
|
36
|
+
]);
|
|
37
|
+
function isArtifactApplicabilityDecision(value) {
|
|
38
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const record = value;
|
|
42
|
+
return ((record.decision === "artifact_required" || record.decision === "not_applicable") &&
|
|
43
|
+
typeof record.reason === "string" &&
|
|
44
|
+
record.reason.trim().length > 0 &&
|
|
45
|
+
Object.keys(record).length === 2);
|
|
46
|
+
}
|
|
47
|
+
function boundedChangedPaths(changedPaths) {
|
|
48
|
+
return changedPaths.slice(0, MAX_CHANGED_PATHS).map((path) => redactAndTruncateText(path, 240));
|
|
49
|
+
}
|
|
50
|
+
function isDocsOnly(changedPaths) {
|
|
51
|
+
return (changedPaths.length > 0 &&
|
|
52
|
+
changedPaths.every((path) => {
|
|
53
|
+
const name = basename(path).toLowerCase();
|
|
54
|
+
return (name === "readme" || name.startsWith("readme.") || DOC_EXTENSION_SET.has(extname(name)));
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
function packageEvidence(repoRoot) {
|
|
58
|
+
const path = join(repoRoot, "package.json");
|
|
59
|
+
if (!existsSync(path)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const stat = lstatSync(path);
|
|
64
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) {
|
|
65
|
+
return ["root package.json exists but is not readable as bounded JSON"];
|
|
66
|
+
}
|
|
67
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
69
|
+
return ["root package.json is not a JSON object"];
|
|
70
|
+
}
|
|
71
|
+
const record = parsed;
|
|
72
|
+
const evidence = ["root package.json exists"];
|
|
73
|
+
if (record.bin !== undefined) {
|
|
74
|
+
evidence.push("root package.json declares a CLI bin entry");
|
|
75
|
+
}
|
|
76
|
+
const scriptNames = record.scripts !== null &&
|
|
77
|
+
typeof record.scripts === "object" &&
|
|
78
|
+
!Array.isArray(record.scripts)
|
|
79
|
+
? Object.keys(record.scripts).slice(0, 20)
|
|
80
|
+
: [];
|
|
81
|
+
if (scriptNames.length > 0) {
|
|
82
|
+
evidence.push(`root package scripts: ${scriptNames.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
const dependencyNames = [record.dependencies, record.devDependencies]
|
|
85
|
+
.flatMap((dependencies) => dependencies !== null && typeof dependencies === "object" && !Array.isArray(dependencies)
|
|
86
|
+
? Object.keys(dependencies)
|
|
87
|
+
: [])
|
|
88
|
+
.filter((name) => WEB_DEPENDENCIES.has(name));
|
|
89
|
+
if (dependencyNames.length > 0) {
|
|
90
|
+
evidence.push(`browser framework dependencies: ${[...new Set(dependencyNames)].join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
return evidence;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return ["root package.json exists but could not be parsed"];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function repoEvidence(repoRoot) {
|
|
99
|
+
const evidence = packageEvidence(repoRoot);
|
|
100
|
+
for (const path of [
|
|
101
|
+
"index.html",
|
|
102
|
+
"vite.config.ts",
|
|
103
|
+
"vite.config.js",
|
|
104
|
+
"next.config.js",
|
|
105
|
+
"next.config.mjs",
|
|
106
|
+
"astro.config.mjs",
|
|
107
|
+
"src/App.tsx",
|
|
108
|
+
"src/App.jsx",
|
|
109
|
+
"src/main.tsx",
|
|
110
|
+
"src/main.jsx",
|
|
111
|
+
]) {
|
|
112
|
+
if (existsSync(join(repoRoot, path))) {
|
|
113
|
+
evidence.push(`browser entry evidence: ${path}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return evidence.slice(0, MAX_EVIDENCE_ITEMS);
|
|
117
|
+
}
|
|
118
|
+
function deterministicApplicability(options) {
|
|
119
|
+
const taskText = [options.task.title, options.task.goal, ...options.task.scope].join("\n");
|
|
120
|
+
if (VISUAL_TASK_PATTERN.test(taskText) ||
|
|
121
|
+
options.changedPaths.some((path) => BROWSER_CHANGED_PATH_PATTERN.test(path))) {
|
|
122
|
+
return {
|
|
123
|
+
decision: "artifact_required",
|
|
124
|
+
reason: "The task or candidate paths contain explicit browser-viewable application evidence.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (isDocsOnly(options.changedPaths)) {
|
|
128
|
+
return {
|
|
129
|
+
decision: "not_applicable",
|
|
130
|
+
reason: "The candidate only changes documentation files.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function createDefaultJudge(options) {
|
|
136
|
+
const client = options.client ??
|
|
137
|
+
createOpenAiStructuredOutputClient({
|
|
138
|
+
apiKey: options.apiKey,
|
|
139
|
+
...(options.config.api_base_url === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: { apiBaseUrl: options.config.api_base_url }),
|
|
142
|
+
...(options.config.organization_id === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: { organizationId: options.config.organization_id }),
|
|
145
|
+
...(options.config.openai_project_id === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { openAiProjectId: options.config.openai_project_id }),
|
|
148
|
+
});
|
|
149
|
+
return async (input) => {
|
|
150
|
+
const instructions = renderPromptTemplate({
|
|
151
|
+
templateKey: "artifacts.applicability",
|
|
152
|
+
...(options.promptsRoot === undefined ? {} : { promptsRoot: options.promptsRoot }),
|
|
153
|
+
variables: { applicability_input_json: input },
|
|
154
|
+
});
|
|
155
|
+
const result = await runOpenAiStructuredOutput({
|
|
156
|
+
client,
|
|
157
|
+
model: options.config.default_model,
|
|
158
|
+
instructions,
|
|
159
|
+
input: "Decide Artifact applicability and return only structured JSON.",
|
|
160
|
+
schemaName: "artifact_applicability_output",
|
|
161
|
+
schemaDescription: "Transient Artifact applicability decision for a completed candidate.",
|
|
162
|
+
schema: ARTIFACT_APPLICABILITY_OUTPUT_SCHEMA,
|
|
163
|
+
maxAttempts: 1,
|
|
164
|
+
validate: isArtifactApplicabilityDecision,
|
|
165
|
+
});
|
|
166
|
+
if (result.usage !== undefined) {
|
|
167
|
+
options.recordUsage?.({
|
|
168
|
+
provider: "openai",
|
|
169
|
+
source: "artifact_applicability",
|
|
170
|
+
model: options.config.default_model,
|
|
171
|
+
usage: result.usage,
|
|
172
|
+
...(options.activityRef === undefined ? {} : { activity_ref: options.activityRef }),
|
|
173
|
+
...(options.taskId === undefined ? {} : { task_id: options.taskId }),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return result.output;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function retryableJudgeFailure(error) {
|
|
180
|
+
return ((error instanceof OpenAiStructuredOutputError && error.retryable) ||
|
|
181
|
+
(error !== null &&
|
|
182
|
+
typeof error === "object" &&
|
|
183
|
+
"retryable" in error &&
|
|
184
|
+
error.retryable === true));
|
|
185
|
+
}
|
|
186
|
+
async function judgeApplicability(judge, input) {
|
|
187
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
188
|
+
try {
|
|
189
|
+
const result = await judge(input);
|
|
190
|
+
if (!isArtifactApplicabilityDecision(result)) {
|
|
191
|
+
throw new Error("Artifact applicability judge returned an invalid result");
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (attempt < 2 && retryableJudgeFailure(error)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
throw new Error("Artifact applicability judge did not return a decision");
|
|
203
|
+
}
|
|
204
|
+
function gateFailure(options) {
|
|
205
|
+
const redacted = redactAndTruncateText(options.detail, MAX_DIAGNOSTIC_CHARS);
|
|
206
|
+
return {
|
|
207
|
+
status: "failed",
|
|
208
|
+
correctable: options.correctable,
|
|
209
|
+
failure: {
|
|
210
|
+
reason_code: options.reasonCode,
|
|
211
|
+
summary: options.summary,
|
|
212
|
+
guidance: options.guidance,
|
|
213
|
+
diagnostic: {
|
|
214
|
+
source: options.source,
|
|
215
|
+
text: redacted,
|
|
216
|
+
truncated: redacted.length >= MAX_DIAGNOSTIC_CHARS,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export async function runArtifactCandidateGate(options) {
|
|
222
|
+
const validation = await (options.validateCandidate ?? validateCandidateArtifact)({
|
|
223
|
+
repoRoot: options.repoRoot,
|
|
224
|
+
...(options.baseEnv === undefined ? {} : { baseEnv: options.baseEnv }),
|
|
225
|
+
});
|
|
226
|
+
if (validation.status === "valid") {
|
|
227
|
+
return {
|
|
228
|
+
status: "passed",
|
|
229
|
+
disposition: "validated",
|
|
230
|
+
reason: "The declared Artifact candidate passed validation.",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (validation.status === "invalid") {
|
|
234
|
+
return { status: "failed", correctable: true, failure: validation.failure };
|
|
235
|
+
}
|
|
236
|
+
const deterministic = deterministicApplicability({
|
|
237
|
+
task: options.task,
|
|
238
|
+
changedPaths: options.diff.changed_paths,
|
|
239
|
+
});
|
|
240
|
+
const evidence = repoEvidence(options.repoRoot);
|
|
241
|
+
let applicability = deterministic;
|
|
242
|
+
if (applicability === null) {
|
|
243
|
+
const input = {
|
|
244
|
+
task: options.task,
|
|
245
|
+
candidate: {
|
|
246
|
+
changed_paths: boundedChangedPaths(options.diff.changed_paths),
|
|
247
|
+
diff_facts: redactAndTruncateText(options.diff.summary, MAX_DIFF_FACT_CHARS),
|
|
248
|
+
repo_evidence: evidence,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
applicability = await judgeApplicability(options.judge ?? createDefaultJudge(options), input);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
return gateFailure({
|
|
256
|
+
reasonCode: "artifact_applicability_failed",
|
|
257
|
+
summary: "Tutti could not determine whether this candidate requires an Artifact.",
|
|
258
|
+
guidance: "Retry when the Artifact applicability provider is available.",
|
|
259
|
+
source: "applicability_judge",
|
|
260
|
+
detail: error instanceof Error ? error.message : "Artifact applicability judge failed",
|
|
261
|
+
correctable: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (applicability.decision === "not_applicable") {
|
|
266
|
+
return {
|
|
267
|
+
status: "passed",
|
|
268
|
+
disposition: "not_applicable",
|
|
269
|
+
reason: applicability.reason,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return gateFailure({
|
|
273
|
+
reasonCode: "artifact_manifest_missing",
|
|
274
|
+
summary: "This candidate requires a browser-viewable Artifact but does not declare one.",
|
|
275
|
+
guidance: "Create tutti.artifact.json using the strict static or server contract and make the preview usable.",
|
|
276
|
+
source: "manifest_parser",
|
|
277
|
+
detail: `Artifact applicability: ${applicability.reason}. ${ARTIFACT_MANIFEST_PATH} was not found in the candidate.`,
|
|
278
|
+
correctable: true,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=artifact-applicability.js.map
|
|
@@ -2,6 +2,9 @@ import type { ProjectId } from "@tutti/shared/ids";
|
|
|
2
2
|
import type { ApprovalRequestManager } from "../approvals/index.js";
|
|
3
3
|
import type { ControlPlaneLogger, RunPipelineResolution, RunPipelineRunner } from "../control-plane/index.js";
|
|
4
4
|
import type { ProviderUsageRecorder } from "../provider-usage/index.js";
|
|
5
|
+
import type { OpenAiStructuredOutputClient } from "../providers/openai/sdk-procedure-runner.js";
|
|
6
|
+
import type { CandidateArtifactValidationResult, ValidateCandidateArtifactOptions } from "../artifacts/index.js";
|
|
7
|
+
import { type ArtifactApplicabilityJudge } from "./artifact-applicability.js";
|
|
5
8
|
import { type OpenAiProviderConfig } from "../providers/openai/provider-config.js";
|
|
6
9
|
import type { CodexAppServerAgentContextRuntime } from "../providers/openai/app-server/skills.js";
|
|
7
10
|
export { TASK_RUN_OUTPUT_SCHEMA } from "./task-run-output.js";
|
|
@@ -24,6 +27,9 @@ export type OpenAiRunPipelineRunnerOptions = {
|
|
|
24
27
|
baseEnv?: NodeJS.ProcessEnv;
|
|
25
28
|
logger?: ControlPlaneLogger | undefined;
|
|
26
29
|
recordUsage?: ProviderUsageRecorder;
|
|
30
|
+
artifactApplicabilityClient?: OpenAiStructuredOutputClient;
|
|
31
|
+
artifactApplicabilityJudge?: ArtifactApplicabilityJudge;
|
|
32
|
+
validateArtifactCandidate?: (options: ValidateCandidateArtifactOptions) => Promise<CandidateArtifactValidationResult>;
|
|
27
33
|
};
|
|
28
34
|
export type ProjectOpenAiRunPipelineRunnerOptions = {
|
|
29
35
|
tuttiHome: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { createNodeNpmCheckPlan, executeCheckPlan } from "../checks/index.js";
|
|
3
3
|
import { candidateChangedAfterChecks } from "./candidate-diff.js";
|
|
4
|
+
import { runArtifactCandidateGate, } from "./artifact-applicability.js";
|
|
4
5
|
import { checkSkipFields, checkStatus, checksSummary, completedWithoutRepoChanges, docsStatusForChangedPaths, failedBeforeChecks, failedFinishedResult, promotionNotAttempted, promotionNotPromoted, successfulRunSummary, } from "./result-projections.js";
|
|
5
6
|
import { runPromotionReconcile } from "./promotion-reconcile.js";
|
|
6
7
|
import { runWorkspaceWriteTask } from "./task-run-invocation.js";
|
|
@@ -96,6 +97,8 @@ function stageProgressSummary(stage) {
|
|
|
96
97
|
return "retrying Codex task after a transient provider failure.";
|
|
97
98
|
case "candidate_diff_collect":
|
|
98
99
|
return "collecting candidate changes.";
|
|
100
|
+
case "artifact_candidate_validate":
|
|
101
|
+
return "validating the candidate Artifact preview.";
|
|
99
102
|
case "checks":
|
|
100
103
|
return "running project checks.";
|
|
101
104
|
case "post_checks_diff_collect":
|
|
@@ -208,6 +211,12 @@ function buildCorrectionContext(input) {
|
|
|
208
211
|
summary: input.result.summary,
|
|
209
212
|
reason_code: input.reasonCode,
|
|
210
213
|
guidance: input.guidance,
|
|
214
|
+
diagnostic: input.diagnostic ?? {
|
|
215
|
+
source: "pipeline",
|
|
216
|
+
text: input.result.summary,
|
|
217
|
+
truncated: false,
|
|
218
|
+
},
|
|
219
|
+
promotion_failure: null,
|
|
211
220
|
};
|
|
212
221
|
const promotion = runPipelineResultPromotion(input.result);
|
|
213
222
|
if (promotion?.status === "not_promoted") {
|
|
@@ -351,6 +360,54 @@ async function runTaskAttempt(options, input) {
|
|
|
351
360
|
guidance: "Produce a concrete candidate diff for the frozen task contract.",
|
|
352
361
|
});
|
|
353
362
|
}
|
|
363
|
+
const artifactGate = await loggedAsyncStage(options, input, "artifact_candidate_validate", () => runArtifactCandidateGate({
|
|
364
|
+
repoRoot: runHandle.repo_root,
|
|
365
|
+
task: {
|
|
366
|
+
title: input.task.title,
|
|
367
|
+
goal: input.task_detail.detail.contract.goal,
|
|
368
|
+
scope: input.task_detail.detail.contract.scope,
|
|
369
|
+
},
|
|
370
|
+
diff,
|
|
371
|
+
config: options.config,
|
|
372
|
+
apiKey: options.apiKey,
|
|
373
|
+
...(options.promptsRoot === undefined ? {} : { promptsRoot: options.promptsRoot }),
|
|
374
|
+
...(options.baseEnv === undefined ? {} : { baseEnv: options.baseEnv }),
|
|
375
|
+
...(options.artifactApplicabilityClient === undefined
|
|
376
|
+
? {}
|
|
377
|
+
: { client: options.artifactApplicabilityClient }),
|
|
378
|
+
...(options.artifactApplicabilityJudge === undefined
|
|
379
|
+
? {}
|
|
380
|
+
: { judge: options.artifactApplicabilityJudge }),
|
|
381
|
+
...(options.validateArtifactCandidate === undefined
|
|
382
|
+
? {}
|
|
383
|
+
: { validateCandidate: options.validateArtifactCandidate }),
|
|
384
|
+
...(options.recordUsage === undefined ? {} : { recordUsage: options.recordUsage }),
|
|
385
|
+
activityRef: input.activity_ref,
|
|
386
|
+
taskId: input.task.id,
|
|
387
|
+
}), (result) => ({
|
|
388
|
+
artifact_gate_status: result.status,
|
|
389
|
+
...(result.status === "passed"
|
|
390
|
+
? { artifact_gate_disposition: result.disposition }
|
|
391
|
+
: { artifact_reason_code: result.failure.reason_code }),
|
|
392
|
+
}));
|
|
393
|
+
if (artifactGate.status === "failed") {
|
|
394
|
+
const artifactResult = failedBeforeChecks({
|
|
395
|
+
summary: artifactGate.failure.summary,
|
|
396
|
+
errorCode: artifactGate.failure.reason_code,
|
|
397
|
+
retryable: false,
|
|
398
|
+
changedPaths,
|
|
399
|
+
promotion: promotionNotAttempted(artifactGate.failure.reason_code, "Artifact candidate validation failed, so promotion was not attempted."),
|
|
400
|
+
});
|
|
401
|
+
if (!artifactGate.correctable) {
|
|
402
|
+
return artifactResult;
|
|
403
|
+
}
|
|
404
|
+
return requestSelfCorrection({
|
|
405
|
+
result: artifactResult,
|
|
406
|
+
reasonCode: artifactGate.failure.reason_code,
|
|
407
|
+
guidance: artifactGate.failure.guidance,
|
|
408
|
+
diagnostic: artifactGate.failure.diagnostic,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
354
411
|
let docs = docsStatusForChangedPaths(changedPaths);
|
|
355
412
|
const checkPlan = createNodeNpmCheckPlan({ workspaceRoot: runHandle.repo_root });
|
|
356
413
|
const checks = await loggedAsyncStage(options, input, "checks", () => executeCheckPlan({
|
|
@@ -399,14 +456,27 @@ async function runTaskAttempt(options, input) {
|
|
|
399
456
|
}
|
|
400
457
|
catch (error) {
|
|
401
458
|
if (isMainlineMovedPromotionConflict(error)) {
|
|
402
|
-
const
|
|
459
|
+
const reconcileOutcome = await loggedAsyncStage(options, input, "promotion_reconcile", () => runPromotionReconcile({
|
|
403
460
|
options,
|
|
404
461
|
pipelineInput: input,
|
|
405
462
|
runHandle,
|
|
406
463
|
taskOutput,
|
|
407
464
|
sourceCommit: commit,
|
|
408
465
|
changedPaths: postChecksDiff.changed_paths,
|
|
409
|
-
}), runResultLogFields);
|
|
466
|
+
}), (outcome) => runResultLogFields(outcome.result));
|
|
467
|
+
const reconcileResult = reconcileOutcome.result;
|
|
468
|
+
if (reconcileOutcome.artifact_gate_failure !== undefined) {
|
|
469
|
+
if (!reconcileOutcome.artifact_gate_failure.correctable) {
|
|
470
|
+
return reconcileResult;
|
|
471
|
+
}
|
|
472
|
+
const artifactFailure = reconcileOutcome.artifact_gate_failure.failure;
|
|
473
|
+
return requestSelfCorrection({
|
|
474
|
+
result: reconcileResult,
|
|
475
|
+
reasonCode: artifactFailure.reason_code,
|
|
476
|
+
guidance: artifactFailure.guidance,
|
|
477
|
+
diagnostic: artifactFailure.diagnostic,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
410
480
|
const reasonCode = reconcileSelfCorrectionReason(reconcileResult);
|
|
411
481
|
if (reasonCode !== undefined) {
|
|
412
482
|
return requestSelfCorrection({
|