@yagni-app/code-staging 1.2.3-staging.1570.1 → 1.2.3-staging.1580.1
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/extension/askAdvisorTool.d.ts +6 -1
- package/dist/extension/askAdvisorTool.js +6 -1
- package/dist/extension/index.d.ts +2 -0
- package/dist/extension/index.js +24 -0
- package/dist/extension/permission/gate.js +3 -3
- package/dist/extension/pipeline/budget.d.ts +32 -0
- package/dist/extension/pipeline/budget.js +29 -0
- package/dist/extension/pipeline/goCommand.d.ts +3 -0
- package/dist/extension/pipeline/goCommand.js +30 -0
- package/dist/extension/pipeline/orchestrator.d.ts +22 -1
- package/dist/extension/pipeline/orchestrator.js +42 -9
- package/dist/extension/pipeline/resilience.d.ts +18 -0
- package/dist/extension/pipeline/resilience.js +6 -2
- package/dist/extension/pipeline/stages.js +5 -5
- package/dist/extension/pipeline/types.d.ts +9 -6
- package/dist/extension/pipeline/types.js +8 -5
- package/dist/extension/prototypeTool.d.ts +151 -0
- package/dist/extension/prototypeTool.js +330 -0
- package/dist/extension/prototypeVerify.d.ts +77 -0
- package/dist/extension/prototypeVerify.js +219 -0
- package/dist/extension/subagents.d.ts +3 -2
- package/dist/extension/subagents.js +3 -2
- package/package.json +2 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
5
|
+
import { logEvent } from "./errorSink.js";
|
|
6
|
+
async function launchProjectBrowser(cwd) {
|
|
7
|
+
const require = createRequire(join(cwd, "package.json"));
|
|
8
|
+
let runtime;
|
|
9
|
+
try {
|
|
10
|
+
runtime = require("playwright");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
runtime = require("@playwright/test");
|
|
14
|
+
}
|
|
15
|
+
const browser = await runtime.chromium.launch({ headless: true, timeout: 10_000 });
|
|
16
|
+
return {
|
|
17
|
+
async open(viewport, allow) {
|
|
18
|
+
const context = await browser.newContext({ viewport, serviceWorkers: "block", permissions: [], acceptDownloads: false });
|
|
19
|
+
await context.route("**/*", async (route) => {
|
|
20
|
+
if (allow(route.request().url()))
|
|
21
|
+
await route.continue();
|
|
22
|
+
else
|
|
23
|
+
await route.abort();
|
|
24
|
+
});
|
|
25
|
+
// Block sockets too; request routing alone does not cover WebSockets.
|
|
26
|
+
await context.routeWebSocket("**/*", socket => socket.close());
|
|
27
|
+
const page = await context.newPage();
|
|
28
|
+
return {
|
|
29
|
+
async navigate(url) {
|
|
30
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10_000 });
|
|
31
|
+
if (!allow(page.url()))
|
|
32
|
+
throw new Error("Navigation left the verification origin");
|
|
33
|
+
},
|
|
34
|
+
async act(action) {
|
|
35
|
+
const target = page.getByRole(action.role, { name: action.name, exact: true });
|
|
36
|
+
if (action.action === "click")
|
|
37
|
+
await target.click({ timeout: 3_000 });
|
|
38
|
+
else
|
|
39
|
+
await target.fill(action.value ?? "", { timeout: 3_000 });
|
|
40
|
+
if (!allow(page.url()))
|
|
41
|
+
throw new Error("Navigation left the verification origin");
|
|
42
|
+
},
|
|
43
|
+
async hasText(text) {
|
|
44
|
+
try {
|
|
45
|
+
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: 3_000 });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
screenshot: () => page.screenshot({ type: "png", fullPage: false, timeout: 3_000 }),
|
|
53
|
+
close: () => context.close(),
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
close: () => browser.close(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function loopbackOrigin(value) {
|
|
60
|
+
const url = new URL(value);
|
|
61
|
+
if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password) {
|
|
62
|
+
throw new Error("Prototype verification requires a credential-free loopback http(s) URL");
|
|
63
|
+
}
|
|
64
|
+
return url.origin;
|
|
65
|
+
}
|
|
66
|
+
/** Race an operation against the run's deadline, so a hung browser call cannot outlive it. */
|
|
67
|
+
function boundedBy(deadline) {
|
|
68
|
+
return async (operation) => {
|
|
69
|
+
if (deadline.aborted) {
|
|
70
|
+
void operation.catch(() => { });
|
|
71
|
+
deadline.throwIfAborted();
|
|
72
|
+
}
|
|
73
|
+
let rejectAbort;
|
|
74
|
+
try {
|
|
75
|
+
return await Promise.race([operation, new Promise((_resolve, reject) => {
|
|
76
|
+
rejectAbort = () => reject(deadline.reason);
|
|
77
|
+
deadline.addEventListener("abort", rejectAbort, { once: true });
|
|
78
|
+
})]);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (rejectAbort)
|
|
82
|
+
deadline.removeEventListener("abort", rejectAbort);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** One viewport: navigate, run each check's actions, then the screenshot the reviewer compares. */
|
|
87
|
+
async function verifyViewport(page, input, label, bounded, checkCancelled) {
|
|
88
|
+
const checks = [];
|
|
89
|
+
await bounded(page.navigate(input.url));
|
|
90
|
+
for (const check of input.checks) {
|
|
91
|
+
checkCancelled();
|
|
92
|
+
let passed = false;
|
|
93
|
+
try {
|
|
94
|
+
for (const action of check.actions ?? []) {
|
|
95
|
+
checkCancelled();
|
|
96
|
+
await bounded(page.act(action));
|
|
97
|
+
}
|
|
98
|
+
passed = await bounded(page.hasText(check.expectText));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
checkCancelled();
|
|
102
|
+
}
|
|
103
|
+
checks.push({ name: `${label}: ${check.name}`, status: passed ? "passed" : "failed", detail: passed ? "Expected text observed after the requested interactions." : "Requested interaction or visible text did not match." });
|
|
104
|
+
}
|
|
105
|
+
checkCancelled();
|
|
106
|
+
const png = await bounded(page.screenshot());
|
|
107
|
+
if (png.length > 2 * 1024 * 1024)
|
|
108
|
+
throw new Error("Screenshot exceeds the upload limit");
|
|
109
|
+
return { checks, png };
|
|
110
|
+
}
|
|
111
|
+
function errorReason(error) {
|
|
112
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").slice(0, 200);
|
|
113
|
+
}
|
|
114
|
+
export async function verifyPrototype(opts, input, cwd, signal) {
|
|
115
|
+
const origin = loopbackOrigin(input.url);
|
|
116
|
+
if (!Number.isInteger(input.revision) || input.revision < 1 || input.checks.length < 1 || input.checks.length > 8 || input.checks.some(check => (check.actions?.length ?? 0) > 8))
|
|
117
|
+
throw new Error("Verification requires a revision and 1–8 bounded checks");
|
|
118
|
+
const viewports = input.viewports ?? [{ width: 390, height: 844 }, { width: 1280, height: 800 }];
|
|
119
|
+
if (viewports.length < 1 || viewports.length > 3 || viewports.some(v => !Number.isInteger(v.width) || !Number.isInteger(v.height) || v.width < 320 || v.width > 1920 || v.height < 320 || v.height > 1200))
|
|
120
|
+
throw new Error("Invalid verification viewports");
|
|
121
|
+
const result = { revision: input.revision, status: "unverified", summary: "Browser verification unavailable.", checks: [], screenshots: [] };
|
|
122
|
+
const allow = (url) => { try {
|
|
123
|
+
return new URL(url).origin === origin;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
} };
|
|
128
|
+
const deadline = new AbortController();
|
|
129
|
+
const post = async (suffix, body) => {
|
|
130
|
+
const response = await resilientFetch(`${opts.baseUrl}/api/yagni-code/prototypes/${encodeURIComponent(input.prototypeId)}/${suffix}`, {
|
|
131
|
+
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${opts.getToken() ?? ""}` }, body: JSON.stringify(body),
|
|
132
|
+
}, { fetchImpl: opts.fetchImpl, signal: suffix === "verification" ? signal : deadline.signal, policy: METERED_POST_FETCH_POLICY });
|
|
133
|
+
if (!response.ok)
|
|
134
|
+
throw new Error(await friendlyFetchError("prototype_verify", response));
|
|
135
|
+
return await response.json();
|
|
136
|
+
};
|
|
137
|
+
let browser;
|
|
138
|
+
let timedOut = false;
|
|
139
|
+
let closing;
|
|
140
|
+
const close = () => { if (browser && !closing)
|
|
141
|
+
closing = browser.close().catch(() => { }); };
|
|
142
|
+
const abort = () => { deadline.abort(signal?.reason); close(); };
|
|
143
|
+
const timer = setTimeout(() => { timedOut = true; deadline.abort(new Error("Verification timed out")); close(); }, Math.min(opts.timeoutMs ?? 45_000, 45_000));
|
|
144
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
145
|
+
const bounded = boundedBy(deadline.signal);
|
|
146
|
+
const checkCancelled = () => {
|
|
147
|
+
signal?.throwIfAborted();
|
|
148
|
+
if (timedOut)
|
|
149
|
+
throw new Error("Verification timed out");
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
checkCancelled();
|
|
153
|
+
const activeBrowser = await bounded((opts.launchBrowser ?? launchProjectBrowser)(cwd).then(launched => {
|
|
154
|
+
if (deadline.signal.aborted)
|
|
155
|
+
void launched.close().catch(() => { });
|
|
156
|
+
return launched;
|
|
157
|
+
}));
|
|
158
|
+
browser = activeBrowser;
|
|
159
|
+
checkCancelled();
|
|
160
|
+
for (const viewport of viewports) {
|
|
161
|
+
checkCancelled();
|
|
162
|
+
const page = await bounded(activeBrowser.open(viewport, allow));
|
|
163
|
+
try {
|
|
164
|
+
const label = `${viewport.width}x${viewport.height}`;
|
|
165
|
+
const { checks, png } = await verifyViewport(page, input, label, bounded, checkCancelled);
|
|
166
|
+
result.checks.push(...checks);
|
|
167
|
+
const uploaded = await post("screenshots", { revision: input.revision, label, pngBase64: png.toString("base64") });
|
|
168
|
+
if (!uploaded.url)
|
|
169
|
+
throw new Error("Screenshot upload returned no evidence URL");
|
|
170
|
+
result.screenshots.push({ label, url: uploaded.url });
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await bounded(page.close()).catch(() => { });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
checkCancelled();
|
|
177
|
+
result.status = result.checks.every(check => check.status === "passed") && result.screenshots.length === viewports.length ? "passed" : "failed";
|
|
178
|
+
result.summary = result.status === "passed" ? "Requested interactions and visible text passed at every viewport; screenshots captured for visual review." : "One or more requested browser checks failed; review the captured screenshots.";
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
signal?.throwIfAborted();
|
|
182
|
+
// Every failure records the same honest status, but keeps its cause: a
|
|
183
|
+
// missing Playwright, a crashed browser and a failed upload need
|
|
184
|
+
// different fixes.
|
|
185
|
+
const reason = errorReason(error);
|
|
186
|
+
logEvent({ source: "tool", level: "warn", event: "prototype_verify.unavailable", fields: { timedOut, reason } });
|
|
187
|
+
result.status = "unverified";
|
|
188
|
+
result.summary = timedOut
|
|
189
|
+
? "Browser verification exceeded its time limit; incomplete checks remain unverified."
|
|
190
|
+
: `Browser, local app, or screenshot evidence unavailable (${reason}); verification is incomplete.`;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
signal?.removeEventListener("abort", abort);
|
|
195
|
+
close();
|
|
196
|
+
if (closing)
|
|
197
|
+
await Promise.race([closing, new Promise(resolve => { const cleanupTimer = setTimeout(resolve, 2_000); cleanupTimer.unref(); })]);
|
|
198
|
+
}
|
|
199
|
+
signal?.throwIfAborted();
|
|
200
|
+
await post("verification", result);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
const parameters = Type.Object({
|
|
204
|
+
prototypeId: Type.String(), revision: Type.Integer({ minimum: 1 }), url: Type.String(),
|
|
205
|
+
checks: Type.Array(Type.Object({ name: Type.String(), expectText: Type.String(), actions: Type.Optional(Type.Array(Type.Object({ action: Type.Union([Type.Literal("click"), Type.Literal("fill")]), role: Type.String(), name: Type.String(), value: Type.Optional(Type.String()) }), { maxItems: 8 })) }), { minItems: 1, maxItems: 8 }),
|
|
206
|
+
viewports: Type.Optional(Type.Array(Type.Object({ width: Type.Integer({ minimum: 320, maximum: 1920 }), height: Type.Integer({ minimum: 320, maximum: 1200 }) }), { minItems: 1, maxItems: 3 })),
|
|
207
|
+
});
|
|
208
|
+
export function makePrototypeVerifyTool(opts) {
|
|
209
|
+
return {
|
|
210
|
+
name: "prototype_verify", label: "Verify prototype implementation",
|
|
211
|
+
description: "Run real browser interaction checks on the implemented local app against an exact prototype revision and capture desktop/mobile screenshots for visual review. Supply agreed state checks from prototype_read and synthetic fill values only. Uses existing project Playwright without installing anything, in a fresh credential-free context restricted to the local app origin. Records passed/failed observed checks or explicitly unverified when the browser/app/evidence is unavailable. Screenshots support human visual comparison; passing text checks does not prove visual equivalence.",
|
|
212
|
+
parameters,
|
|
213
|
+
async execute(_id, input, signal, _update, context) {
|
|
214
|
+
const result = await verifyPrototype(opts, input, context.cwd, signal);
|
|
215
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=prototypeVerify.js.map
|
|
@@ -134,8 +134,9 @@ export interface MakeSubagentToolDeps {
|
|
|
134
134
|
/**
|
|
135
135
|
* The subagent lane's wall-clock ceiling: two hours per child.
|
|
136
136
|
*
|
|
137
|
-
* The /go stage default (20 minutes
|
|
138
|
-
*
|
|
137
|
+
* The /go stage default (20 minutes when this was set, an hour since) is
|
|
138
|
+
* sized for one pipeline step; a chat subagent is routinely handed a whole
|
|
139
|
+
* review, and at 20 minutes the runner
|
|
139
140
|
* killed three parallel reviewers mid-report and threw their findings away
|
|
140
141
|
* as "PARTIAL output". The wall clock is not the stall guard — the idle timer
|
|
141
142
|
* (4 minutes, deferred while a tool is in flight) is what catches a hung
|
|
@@ -465,8 +465,9 @@ const parameters = Type.Object({
|
|
|
465
465
|
/**
|
|
466
466
|
* The subagent lane's wall-clock ceiling: two hours per child.
|
|
467
467
|
*
|
|
468
|
-
* The /go stage default (20 minutes
|
|
469
|
-
*
|
|
468
|
+
* The /go stage default (20 minutes when this was set, an hour since) is
|
|
469
|
+
* sized for one pipeline step; a chat subagent is routinely handed a whole
|
|
470
|
+
* review, and at 20 minutes the runner
|
|
470
471
|
* killed three parallel reviewers mid-report and threw their findings away
|
|
471
472
|
* as "PARTIAL output". The wall clock is not the stall guard — the idle timer
|
|
472
473
|
* (4 minutes, deferred while a tool is in flight) is what catches a hung
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.3-staging.
|
|
3
|
+
"version": "1.2.3-staging.1580.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "8592117c432a85b840ac26dd74f48e84d6bb3a1a"
|
|
62
62
|
}
|