toolroll 0.9.4 → 0.9.6
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 +28 -0
- package/README.md +5 -4
- package/dist/assignment-presentation.d.ts +13 -2
- package/dist/assignment-presentation.js +26 -11
- package/dist/assignment-ui.d.ts +10 -15
- package/dist/assignment-ui.js +18 -18
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +21 -20
- package/dist/browser-workspace.d.ts +13 -10
- package/dist/chat-acceptance.js +2 -2
- package/dist/chat-quiet.js +20 -19
- package/dist/cli.js +6 -6
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +9 -0
- package/dist/demo-chat.d.ts +32 -0
- package/dist/demo-chat.js +158 -0
- package/dist/demo.d.ts +98 -0
- package/dist/demo.js +525 -21
- package/dist/flow-pull-request.js +26 -3
- package/dist/lead-status.d.ts +7 -0
- package/dist/lead-status.js +11 -2
- package/dist/operate.js +25 -9
- package/dist/proof.d.ts +9 -0
- package/dist/proof.js +25 -1
- package/dist/publish.d.ts +15 -0
- package/dist/publish.js +41 -1
- package/dist/pull-request-flow.js +3 -3
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +260 -81
- package/dist/store.d.ts +1 -0
- package/dist/store.js +2 -1
- package/dist/task-status.d.ts +179 -0
- package/dist/task-status.js +317 -0
- package/dist/telegram-progress.js +62 -100
- package/dist/telegram-status.js +19 -6
- package/dist/work-index.js +25 -9
- package/dist/workspace-ui.d.ts +7 -0
- package/dist/workspace-ui.js +31 -2
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import type { Store } from './store.js';
|
|
|
8
8
|
import type { WorkSummaryAccess } from './work-summary.js';
|
|
9
9
|
import type { StatusTone } from './workspace-ui.js';
|
|
10
10
|
import type { AssignmentCard } from './assignment-ui.js';
|
|
11
|
+
import type { TaskStatus } from './task-status.js';
|
|
11
12
|
import type { FirstRunStep, FirstTaskSuggestion } from './first-run.js';
|
|
12
13
|
export type BrowserProject = {
|
|
13
14
|
name: string;
|
|
@@ -365,12 +366,6 @@ export type BrowserProjectsView = {
|
|
|
365
366
|
};
|
|
366
367
|
};
|
|
367
368
|
export type BrowserResultTab = 'summary' | 'changes' | 'checks';
|
|
368
|
-
export type BrowserResultChip = {
|
|
369
|
-
tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral';
|
|
370
|
-
label: string;
|
|
371
|
-
icon: 'check' | 'x' | null;
|
|
372
|
-
title: string | null;
|
|
373
|
-
};
|
|
374
369
|
/** The shared result panel in parts. Each tab's content, the feedback
|
|
375
370
|
* section and the learning card are the server's own HTML; the page script
|
|
376
371
|
* binds to the same data attributes and ids (tabs, drafts, line notes). */
|
|
@@ -378,12 +373,20 @@ export type BrowserResultPanel = {
|
|
|
378
373
|
attributes: Record<string, string>;
|
|
379
374
|
heading: string;
|
|
380
375
|
outcome: string;
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
by: string | null;
|
|
384
|
-
} | null;
|
|
376
|
+
/** The shared task status (task-status.ts): headline, sentence and detail rows. */
|
|
377
|
+
status: TaskStatus | null;
|
|
385
378
|
reviewHistory: string | null;
|
|
386
379
|
attention: string[];
|
|
380
|
+
/** Requirements only a person can confirm, in plain words, and the one
|
|
381
|
+
* Accept that records the decision (null when it is not offered here). */
|
|
382
|
+
youCheck: {
|
|
383
|
+
lines: string[];
|
|
384
|
+
accept: {
|
|
385
|
+
action: string;
|
|
386
|
+
run: number;
|
|
387
|
+
returnTo: string;
|
|
388
|
+
} | null;
|
|
389
|
+
} | null;
|
|
387
390
|
/** Storage limits on saved output (shortened logs or diffs): shown on request. */
|
|
388
391
|
limits: string[];
|
|
389
392
|
tabs: {
|
package/dist/chat-acceptance.js
CHANGED
|
@@ -6,7 +6,7 @@ export function acceptanceStatus(proof, accepted) {
|
|
|
6
6
|
if (accepted)
|
|
7
7
|
return manualReviewOnly(proof) ? "Accepted by a person" : "Accepted with an exception";
|
|
8
8
|
if (manualReviewOnly(proof))
|
|
9
|
-
return "
|
|
9
|
+
return "You check the remaining requirements";
|
|
10
10
|
return proof === null ? "No checks recorded" : ({ verified: "Checks passed", attested: "Checks reported by the agent", short: "Some required material is missing", refuted: "Saved result conflicts with the approved scope" }[proof.verdict] ?? "Some required material is missing");
|
|
11
11
|
}
|
|
12
12
|
export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0) {
|
|
@@ -75,7 +75,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
|
|
|
75
75
|
accepted: acceptance !== null, acceptance: acceptance === null ? null : { at: acceptance.acceptedAt, note: brief(acceptance.note) },
|
|
76
76
|
reasons: (recorded?.reasons ?? []).map(one => brief(one)), gate,
|
|
77
77
|
criteriaTotal: matrix.length, nextCriterionOffset: offset + page.length < matrix.length ? offset + page.length : null,
|
|
78
|
-
criteria: page.map(row => ({ id: row.id, requirement: brief(row.statement), state: row.state === "manual-review" ? "
|
|
78
|
+
criteria: page.map(row => ({ id: row.id, requirement: brief(row.statement), state: row.state === "manual-review" ? "You check" : row.state,
|
|
79
79
|
details: row.detail.map(one => brief(one)), requiredEvidence: row.requiredEvidence,
|
|
80
80
|
reviewer: row.review == null ? null : { judgement: row.review.judgement, note: brief(row.review.note) },
|
|
81
81
|
evidence: row.answered.map(one => ({ kind: one.kind, reference: brief(one.ref) })),
|
package/dist/chat-quiet.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { assignmentOf } from "./assignment.js";
|
|
8
8
|
import { chatControlHref } from "./chat-controls.js";
|
|
9
9
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
10
|
+
import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
|
|
10
11
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
11
12
|
import { isLifecycleNotification } from "./store.js";
|
|
12
13
|
/** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
|
|
@@ -37,15 +38,16 @@ function cardRun(store, taskRef) {
|
|
|
37
38
|
return store.runsFor(taskRef).find(run => run.role === "builder" && run.contestant === null) ?? null;
|
|
38
39
|
}
|
|
39
40
|
/** Before any attempt starts, the card says where the task stands in the words of its last lifecycle fact. */
|
|
41
|
+
const before = (headline) => ({ icon: headlineEmoji(headline), status: headline });
|
|
40
42
|
const BEFORE_RUN = {
|
|
41
|
-
"task-filed":
|
|
42
|
-
"scope-approved":
|
|
43
|
-
"approval-withdrawn":
|
|
44
|
-
"task-held":
|
|
45
|
-
"task-released":
|
|
46
|
-
"task-queued":
|
|
47
|
-
"task-requeued":
|
|
48
|
-
"task-cancelled":
|
|
43
|
+
"task-filed": before("Queued"),
|
|
44
|
+
"scope-approved": before("Queued"),
|
|
45
|
+
"approval-withdrawn": before("Needs you"),
|
|
46
|
+
"task-held": before("Stopped"),
|
|
47
|
+
"task-released": before("Queued"),
|
|
48
|
+
"task-queued": before("Queued"),
|
|
49
|
+
"task-requeued": before("Queued"),
|
|
50
|
+
"task-cancelled": before("Stopped"),
|
|
49
51
|
};
|
|
50
52
|
function taskLine(store, taskRef, now, root) {
|
|
51
53
|
const ref = store.refById(taskRef);
|
|
@@ -85,10 +87,8 @@ export function quietCardView(store, taskRefs, now, root) {
|
|
|
85
87
|
export function readyPingView(store, run, taskId, project, now, root) {
|
|
86
88
|
const card = telegramProgressCard(store, run, taskId, project, now, root);
|
|
87
89
|
const [title = "", shown = ""] = card.entities.map(one => card.text.slice(one.offset, one.offset + one.length));
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const heading = saved ? "✅ Ready" : shown;
|
|
91
|
-
const next = card.next !== "" ? card.next : saved ? "Next: open the result, then mark it complete or request changes." : "";
|
|
90
|
+
const heading = shown;
|
|
91
|
+
const next = card.next;
|
|
92
92
|
const first = `${heading} · ${title}`;
|
|
93
93
|
return { text: next === "" ? first : `${first}\n${next}`, entities: [{ type: "bold", offset: 0, length: heading.length }], link: card.link };
|
|
94
94
|
}
|
|
@@ -120,18 +120,19 @@ export function eveningDigestText(store, account, now, root) {
|
|
|
120
120
|
failed.set(row.taskId, title(row.taskId));
|
|
121
121
|
}
|
|
122
122
|
for (const taskId of new Set(facts.map(row => row.taskId).filter((one) => one !== null))) {
|
|
123
|
-
let
|
|
123
|
+
let headline = null;
|
|
124
124
|
try {
|
|
125
125
|
const project = facts.find(row => row.taskId === taskId).project;
|
|
126
|
-
|
|
126
|
+
const assignment = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root);
|
|
127
|
+
headline = assignment === null ? null : taskStatusOf(assignmentStatusFacts(assignment)).headline;
|
|
127
128
|
}
|
|
128
129
|
catch {
|
|
129
|
-
|
|
130
|
+
headline = null;
|
|
130
131
|
}
|
|
131
|
-
if (
|
|
132
|
-
waits.set(taskId, `${title(taskId)} ·
|
|
133
|
-
else if (
|
|
134
|
-
|
|
132
|
+
if (headline === "Ready for review" || headline === "Needs you")
|
|
133
|
+
waits.set(taskId, `${title(taskId)} · ${headline}`);
|
|
134
|
+
else if (headline === "Failed")
|
|
135
|
+
failed.set(taskId, title(taskId));
|
|
135
136
|
}
|
|
136
137
|
if (finished.size + failed.size + waits.size === 0)
|
|
137
138
|
return null;
|
package/dist/cli.js
CHANGED
|
@@ -1225,7 +1225,7 @@ async function runDemoCommand(argv, write) {
|
|
|
1225
1225
|
const DEMO_USAGE = "`toolroll demo [--port <n>] [--host <addr>] [--allow-host name:port,…] [--keep]` — the port is a number under 65536; absent, a free one is picked";
|
|
1226
1226
|
// Asking how it works never starts a sandbox.
|
|
1227
1227
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1228
|
-
write(json ? envelopeJson({ ok: true, command: "demo", usage: DEMO_USAGE }) : `${DEMO_USAGE}\n\nA throwaway sandbox
|
|
1228
|
+
write(json ? envelopeJson({ ok: true, command: "demo", usage: DEMO_USAGE }) : `${DEMO_USAGE}\n\nA throwaway sandbox: a scripted lead in Chat, sample projects and flows mid-flight. It never calls a model, spends or reaches outside; Ctrl-C removes it unless --keep.`);
|
|
1229
1229
|
return 0;
|
|
1230
1230
|
}
|
|
1231
1231
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
@@ -1248,9 +1248,9 @@ async function runDemoCommand(argv, write) {
|
|
|
1248
1248
|
const { createDemoSandbox } = await import("./demo.js");
|
|
1249
1249
|
const { createDecisionServer } = await import("./serve.js");
|
|
1250
1250
|
const now = new Date();
|
|
1251
|
-
const { sandbox, store, seed, evidenceRoot, passwordFile } = createDemoSandbox(now);
|
|
1251
|
+
const { sandbox, store, seed, evidenceRoot, passwordFile, lead } = createDemoSandbox(now);
|
|
1252
1252
|
const server = createDecisionServer({
|
|
1253
|
-
store, evidenceRoot, clock: () => new Date(), repos: seed.repos,
|
|
1253
|
+
store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead,
|
|
1254
1254
|
...(allowedHosts.length === 0 ? {} : { allowedHosts }),
|
|
1255
1255
|
});
|
|
1256
1256
|
await new Promise((ready, failed) => {
|
|
@@ -1268,14 +1268,13 @@ async function runDemoCommand(argv, write) {
|
|
|
1268
1268
|
write(envelopeJson({ ok: true, command: "demo", url, sandbox, login: { name: seed.login.name, passwordFile }, keep }));
|
|
1269
1269
|
}
|
|
1270
1270
|
else {
|
|
1271
|
-
write("Demo sandbox is up
|
|
1271
|
+
write("Demo sandbox is up. A scripted lead answers Chat: no model, no key, no spend, nothing leaves this machine.");
|
|
1272
1272
|
write("");
|
|
1273
1273
|
write(` open ${url}`);
|
|
1274
1274
|
write(` login ${seed.login.name} / ${seed.login.password}`);
|
|
1275
1275
|
write(` sandbox ${sandbox}`);
|
|
1276
1276
|
write("");
|
|
1277
|
-
write("
|
|
1278
|
-
write("routines ask you to open one first — pick payments-api.");
|
|
1277
|
+
write("Open Chat and ask for a change, like \"fix the flaky refund test\".");
|
|
1279
1278
|
write("");
|
|
1280
1279
|
write(keep
|
|
1281
1280
|
? "Ctrl-C stops the server; the sandbox is KEPT. It stays a demo forever — every spending command refuses it."
|
|
@@ -1290,6 +1289,7 @@ async function runDemoCommand(argv, write) {
|
|
|
1290
1289
|
process.on("SIGINT", stop);
|
|
1291
1290
|
process.on("SIGTERM", stop);
|
|
1292
1291
|
});
|
|
1292
|
+
lead.stop();
|
|
1293
1293
|
await new Promise(closed => server.close(() => closed()));
|
|
1294
1294
|
store.close();
|
|
1295
1295
|
if (!keep) {
|
package/dist/containment.d.ts
CHANGED
|
@@ -103,6 +103,11 @@ export type EffectiveContainment = {
|
|
|
103
103
|
export declare function effectiveContainment(policy: ContainmentPolicy, capability: ContainmentCapability): EffectiveContainment;
|
|
104
104
|
/** One-line status words for `daemon status`, `up`, and the certificate. */
|
|
105
105
|
export declare function describeContainment(effective: EffectiveContainment): string;
|
|
106
|
+
/** The one sentence a person reads at start when agents on Linux run
|
|
107
|
+
* without native containment that the machine could offer once set up;
|
|
108
|
+
* null when there is nothing a person needs to read. The exact
|
|
109
|
+
* `describeContainment` line stays in --verbose and in a service log. */
|
|
110
|
+
export declare function containmentNotice(effective: EffectiveContainment): string | null;
|
|
106
111
|
/** The status envelope's shape: no paths a person did not already know, no secrets. */
|
|
107
112
|
export declare function containmentStatus(effective: EffectiveContainment): Record<string, unknown>;
|
|
108
113
|
/**
|
package/dist/containment.js
CHANGED
|
@@ -191,6 +191,15 @@ export function describeContainment(effective) {
|
|
|
191
191
|
return `containment: observed (preferred native, ${backend} unavailable) — ${effective.capability.detail}`;
|
|
192
192
|
return `containment: observed (${effective.policy}) — ${effective.capability.detail}`;
|
|
193
193
|
}
|
|
194
|
+
/** The one sentence a person reads at start when agents on Linux run
|
|
195
|
+
* without native containment that the machine could offer once set up;
|
|
196
|
+
* null when there is nothing a person needs to read. The exact
|
|
197
|
+
* `describeContainment` line stays in --verbose and in a service log. */
|
|
198
|
+
export function containmentNotice(effective) {
|
|
199
|
+
if (effective.refusal !== null || effective.mode !== "observed" || effective.capability.platform !== "linux" || effective.capability.available)
|
|
200
|
+
return null;
|
|
201
|
+
return "Agents run without Linux process containment on this machine (it needs a delegated cgroup). That's fine for trying Toolroll; see docs/guide/linux.md to turn it on.";
|
|
202
|
+
}
|
|
194
203
|
/** The status envelope's shape: no paths a person did not already know, no secrets. */
|
|
195
204
|
export function containmentStatus(effective) {
|
|
196
205
|
return {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DemoExchange } from "./demo.js";
|
|
2
|
+
/** What a finished exchange's stored result holds, read back from its evidence. */
|
|
3
|
+
export type DemoResultView = {
|
|
4
|
+
diff: string | null;
|
|
5
|
+
checkLog: string | null;
|
|
6
|
+
checks: {
|
|
7
|
+
status: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
};
|
|
10
|
+
screenshot: {
|
|
11
|
+
href: string;
|
|
12
|
+
caption: string;
|
|
13
|
+
} | null;
|
|
14
|
+
additions: number;
|
|
15
|
+
deletions: number;
|
|
16
|
+
files: number;
|
|
17
|
+
taskHref: string;
|
|
18
|
+
};
|
|
19
|
+
/** The conversation itself; the live script swaps this region in place. */
|
|
20
|
+
export declare function demoThreadHtml(exchanges: readonly DemoExchange[], csrf: string, resultOf: (exchange: DemoExchange) => DemoResultView | null): string;
|
|
21
|
+
export declare function demoChatHtml(input: {
|
|
22
|
+
exchanges: readonly DemoExchange[];
|
|
23
|
+
csrf: string;
|
|
24
|
+
version: number;
|
|
25
|
+
problem: string | null;
|
|
26
|
+
resultOf: (exchange: DemoExchange) => DemoResultView | null;
|
|
27
|
+
}): string;
|
|
28
|
+
/**
|
|
29
|
+
* Keeps a running build live: polls while one is working and swaps the
|
|
30
|
+
* thread in place, unless the visitor is typing inside it. Enter sends.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEMO_CHAT_SCRIPT = "(() => {\n const thread = () => document.getElementById(\"demo-thread\");\n let timer = 0;\n const editing = region => region.contains(document.activeElement) && document.activeElement.matches(\"textarea, input\") || region.querySelector(\"details[open] textarea:not(:placeholder-shown)\") !== null;\n const tick = async () => {\n const region = thread();\n if (!region) { timer = setTimeout(tick, 400); return; }\n if (region.dataset.working !== \"1\") return;\n try {\n const answer = await fetch(\"/chat/demo/live\", { headers: { accept: \"application/json\" }, credentials: \"same-origin\" });\n if (answer.ok) {\n const next = await answer.json();\n if (String(next.version) !== region.dataset.version && !editing(region)) {\n region.innerHTML = next.html;\n region.dataset.version = String(next.version);\n region.dataset.working = next.working ? \"1\" : \"0\";\n if (!next.working) {\n const last = region.querySelector(\".demo-turn:last-of-type .demo-result\");\n if (last) last.scrollIntoView({ block: \"nearest\", behavior: matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\" });\n }\n }\n }\n } catch { /* the next tick tries again */ }\n if (thread()?.dataset.working === \"1\") timer = setTimeout(tick, 700);\n };\n const start = () => { clearTimeout(timer); tick(); };\n document.addEventListener(\"keydown\", event => {\n const field = event.target;\n if (event.key !== \"Enter\" || event.shiftKey || event.isComposing || !(field instanceof HTMLTextAreaElement) || field.id !== \"demo-message\") return;\n if (field.value.trim() === \"\") return;\n event.preventDefault();\n field.form?.requestSubmit();\n });\n window.addEventListener(\"standing-orders:workspace-rendered\", start);\n if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start); else start();\n})();";
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat in `toolroll demo`: the scripted lead's conversation (src/demo.ts),
|
|
3
|
+
* rendered as plain server HTML with native forms. One small script keeps a
|
|
4
|
+
* running build's progress live; without it the page still works by reload.
|
|
5
|
+
*/
|
|
6
|
+
import { headlineOf } from "./task-status.js";
|
|
7
|
+
const SUGGESTIONS = ["Fix the flaky refund test", "Rewrite the empty Payouts page", "Put the new invoice view behind a flag"];
|
|
8
|
+
const esc = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
9
|
+
const hidden = (csrf) => `<input type="hidden" name="csrf" value="${esc(csrf)}">`;
|
|
10
|
+
function hint(csrf) {
|
|
11
|
+
return `<section class="demo-hint" aria-label="Get started">` +
|
|
12
|
+
`<p class="demo-hint-title">Ask for something, like “fix the flaky refund test”</p>` +
|
|
13
|
+
`<form method="post" action="/chat/demo/ask" class="demo-suggestions">${hidden(csrf)}` +
|
|
14
|
+
SUGGESTIONS.map(one => `<button type="submit" name="message" value="${esc(one)}">${esc(one)}</button>`).join("") +
|
|
15
|
+
`</form></section>`;
|
|
16
|
+
}
|
|
17
|
+
function planCard(exchange, csrf) {
|
|
18
|
+
const plan = exchange.plan;
|
|
19
|
+
const goal = exchange.note === null ? plan.goal : `${plan.goal} Also: ${exchange.note}`;
|
|
20
|
+
const actions = exchange.state === "proposed"
|
|
21
|
+
? `<div class="demo-actions">` +
|
|
22
|
+
`<form method="post" action="/chat/demo/${exchange.id}/approve" class="approve-form">${hidden(csrf)}<button type="submit">Approve</button></form>` +
|
|
23
|
+
`<details class="demo-more"><summary>Change it</summary>` +
|
|
24
|
+
`<form method="post" action="/chat/demo/${exchange.id}/change">${hidden(csrf)}` +
|
|
25
|
+
`<label for="demo-change-${exchange.id}">What should change?</label>` +
|
|
26
|
+
`<textarea id="demo-change-${exchange.id}" name="note" rows="2" maxlength="500" required></textarea>` +
|
|
27
|
+
`<button type="submit">Update plan</button></form></details></div>`
|
|
28
|
+
: exchange.state === "replaced"
|
|
29
|
+
? `<p class="meta">Replaced by the updated plan below.</p>`
|
|
30
|
+
: `<p class="meta">Approved.</p>`;
|
|
31
|
+
return `<section class="card demo-plan" aria-label="Plan">` +
|
|
32
|
+
`<p class="demo-kicker">Plan · ${esc(plan.project)}</p>` +
|
|
33
|
+
`<h2>${esc(plan.title)}</h2>` +
|
|
34
|
+
`<p>${esc(goal)}</p>` +
|
|
35
|
+
`<h3>Boundaries</h3><ul>${plan.boundaries.map(one => `<li>${esc(one)}</li>`).join("")}</ul>` +
|
|
36
|
+
`<h3>Checks</h3><ul>${plan.checks.map(one => `<li>${esc(one)}</li>`).join("")}</ul>` +
|
|
37
|
+
actions + `</section>`;
|
|
38
|
+
}
|
|
39
|
+
function buildCard(exchange) {
|
|
40
|
+
const stages = [["planning", "Planning"], ["building", "Building"], ["checking", "Running checks"]];
|
|
41
|
+
const at = stages.findIndex(([key]) => key === exchange.stage);
|
|
42
|
+
return `<section class="card demo-build" id="demo-${exchange.id}-work" aria-label="Build" aria-busy="true">` +
|
|
43
|
+
`<p class="demo-kicker">${esc(exchange.plan.project)}</p>` +
|
|
44
|
+
`<h2>${esc(stages[Math.max(0, at)][1])}…</h2>` +
|
|
45
|
+
`<ol class="demo-steps">${stages.map(([, label], index) => `<li class="${index < at ? "done" : index === at ? "now" : ""}"${index === at ? ` aria-current="step"` : ""}>${esc(label)}</li>`).join("")}</ol>` +
|
|
46
|
+
(exchange.progress === null ? "" : `<p class="meta demo-progress">${esc(exchange.progress)}</p>`) +
|
|
47
|
+
`</section>`;
|
|
48
|
+
}
|
|
49
|
+
function diffHtml(diff) {
|
|
50
|
+
return diff.replace(/\n$/, "").split("\n").map(line => {
|
|
51
|
+
const kind = line.startsWith("diff --git") || line.startsWith("new file") ? "file"
|
|
52
|
+
: line.startsWith("+++") || line.startsWith("---") ? "meta"
|
|
53
|
+
: line.startsWith("@@") ? "hunk"
|
|
54
|
+
: line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
|
|
55
|
+
return `<span class="demo-diff-${kind}">${esc(line) || " "}</span>`;
|
|
56
|
+
}).join("");
|
|
57
|
+
}
|
|
58
|
+
function resultCard(exchange, csrf, result) {
|
|
59
|
+
if (result === null)
|
|
60
|
+
return `<section class="card demo-result"><p class="meta">This result's saved evidence is unavailable.</p></section>`;
|
|
61
|
+
const passed = result.checks.status === "passed";
|
|
62
|
+
// The shared headline (task-status.ts): the same words as every real task.
|
|
63
|
+
const headline = headlineOf({ stage: exchange.state === "complete" ? "complete" : exchange.state === "sent-back" ? "building" : "finished",
|
|
64
|
+
checks: { status: passed ? "passed" : "failed", exitCode: null, head: null } });
|
|
65
|
+
const status = `<span class="badge${headline === "Complete" ? " demo-complete" : headline === "Ready for review" ? " demo-ready" : ""}" data-headline="${headline}">${headline}</span>`;
|
|
66
|
+
const summary = `${result.files} file${result.files === 1 ? "" : "s"} changed · +${result.additions} −${result.deletions} · ` +
|
|
67
|
+
`<span class="${passed ? "demo-pass" : "demo-fail"}">${esc(result.checks.detail)}</span>`;
|
|
68
|
+
const actions = exchange.state === "ready"
|
|
69
|
+
? `<div class="demo-actions">` +
|
|
70
|
+
`<form method="post" action="/chat/demo/${exchange.id}/complete">${hidden(csrf)}<button type="submit" class="demo-primary">Complete</button></form>` +
|
|
71
|
+
`<details class="demo-more"><summary>Request changes</summary>` +
|
|
72
|
+
`<form method="post" action="/chat/demo/${exchange.id}/revise">${hidden(csrf)}` +
|
|
73
|
+
`<label for="demo-revise-${exchange.id}">What should change?</label>` +
|
|
74
|
+
`<textarea id="demo-revise-${exchange.id}" name="note" rows="2" maxlength="500" required></textarea>` +
|
|
75
|
+
`<button type="submit">Send back</button></form></details></div>`
|
|
76
|
+
: `<p class="meta"><a href="${esc(result.taskHref)}">Open in Tasks</a></p>`;
|
|
77
|
+
return `<section class="card demo-result" id="demo-${exchange.id}-work" aria-label="Result">` +
|
|
78
|
+
`<p class="demo-state">${status}<span class="meta">${esc(exchange.plan.project)}</span></p>` +
|
|
79
|
+
`<h2>${esc(exchange.plan.title)}</h2>` +
|
|
80
|
+
`<p>${esc(exchange.plan.conclusion)}</p>` +
|
|
81
|
+
`<p class="meta">${summary}</p>` +
|
|
82
|
+
actions +
|
|
83
|
+
(result.diff === null ? "" : `<details class="demo-evidence"${exchange.state === "ready" ? " open" : ""}><summary>Changes</summary><pre class="demo-diff">${diffHtml(result.diff)}</pre></details>`) +
|
|
84
|
+
(result.checkLog === null ? "" : `<details class="demo-evidence"><summary>Check log</summary><pre class="demo-log">${esc(result.checkLog)}</pre></details>`) +
|
|
85
|
+
(result.screenshot === null ? "" : `<details class="demo-evidence"><summary>Screenshot</summary><figure><img src="${esc(result.screenshot.href)}" alt="${esc(result.screenshot.caption)}" width="960" height="600"><figcaption class="meta">${esc(result.screenshot.caption)}</figcaption></figure></details>`) +
|
|
86
|
+
`</section>`;
|
|
87
|
+
}
|
|
88
|
+
/** The conversation itself; the live script swaps this region in place. */
|
|
89
|
+
export function demoThreadHtml(exchanges, csrf, resultOf) {
|
|
90
|
+
if (exchanges.length === 0)
|
|
91
|
+
return hint(csrf);
|
|
92
|
+
return exchanges.map(exchange => {
|
|
93
|
+
const parts = [
|
|
94
|
+
`<div class="demo-said demo-you"><p>${esc(exchange.asked)}</p></div>`,
|
|
95
|
+
`<div class="demo-said demo-lead"><p class="demo-who">Lead</p><p>${esc(exchange.reply)}</p></div>`,
|
|
96
|
+
planCard(exchange, csrf),
|
|
97
|
+
];
|
|
98
|
+
if (exchange.state === "working")
|
|
99
|
+
parts.push(buildCard(exchange));
|
|
100
|
+
if (exchange.state === "ready" || exchange.state === "complete" || exchange.state === "sent-back")
|
|
101
|
+
parts.push(resultCard(exchange, csrf, resultOf(exchange)));
|
|
102
|
+
if (exchange.state === "complete") {
|
|
103
|
+
parts.push(`<div class="demo-said demo-lead"><p class="demo-who">Lead</p><p>Done. That's the whole loop: ask, approve, Ready, Complete. Ask for something else whenever you like.</p></div>`);
|
|
104
|
+
}
|
|
105
|
+
return `<article class="demo-turn" id="demo-${exchange.id}">${parts.join("")}</article>`;
|
|
106
|
+
}).join("");
|
|
107
|
+
}
|
|
108
|
+
export function demoChatHtml(input) {
|
|
109
|
+
const working = input.exchanges.some(one => one.state === "working");
|
|
110
|
+
return `<div class="demo-chat">` +
|
|
111
|
+
(input.problem === null ? "" : `<p class="problem" role="alert">${esc(input.problem)}</p>`) +
|
|
112
|
+
`<div id="demo-thread" class="demo-thread" aria-live="polite" data-version="${input.version}" data-working="${working ? "1" : "0"}">` +
|
|
113
|
+
demoThreadHtml(input.exchanges, input.csrf, input.resultOf) + `</div>` +
|
|
114
|
+
`<form method="post" action="/chat/demo/ask" class="demo-composer">${hidden(input.csrf)}` +
|
|
115
|
+
`<label for="demo-message" class="so-sr-only">Message the lead</label>` +
|
|
116
|
+
`<textarea id="demo-message" name="message" rows="2" maxlength="500" placeholder="Ask the lead for a change" required></textarea>` +
|
|
117
|
+
`<button type="submit" class="demo-primary">Send</button></form></div>`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Keeps a running build live: polls while one is working and swaps the
|
|
121
|
+
* thread in place, unless the visitor is typing inside it. Enter sends.
|
|
122
|
+
*/
|
|
123
|
+
export const DEMO_CHAT_SCRIPT = `(() => {
|
|
124
|
+
const thread = () => document.getElementById("demo-thread");
|
|
125
|
+
let timer = 0;
|
|
126
|
+
const editing = region => region.contains(document.activeElement) && document.activeElement.matches("textarea, input") || region.querySelector("details[open] textarea:not(:placeholder-shown)") !== null;
|
|
127
|
+
const tick = async () => {
|
|
128
|
+
const region = thread();
|
|
129
|
+
if (!region) { timer = setTimeout(tick, 400); return; }
|
|
130
|
+
if (region.dataset.working !== "1") return;
|
|
131
|
+
try {
|
|
132
|
+
const answer = await fetch("/chat/demo/live", { headers: { accept: "application/json" }, credentials: "same-origin" });
|
|
133
|
+
if (answer.ok) {
|
|
134
|
+
const next = await answer.json();
|
|
135
|
+
if (String(next.version) !== region.dataset.version && !editing(region)) {
|
|
136
|
+
region.innerHTML = next.html;
|
|
137
|
+
region.dataset.version = String(next.version);
|
|
138
|
+
region.dataset.working = next.working ? "1" : "0";
|
|
139
|
+
if (!next.working) {
|
|
140
|
+
const last = region.querySelector(".demo-turn:last-of-type .demo-result");
|
|
141
|
+
if (last) last.scrollIntoView({ block: "nearest", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch { /* the next tick tries again */ }
|
|
146
|
+
if (thread()?.dataset.working === "1") timer = setTimeout(tick, 700);
|
|
147
|
+
};
|
|
148
|
+
const start = () => { clearTimeout(timer); tick(); };
|
|
149
|
+
document.addEventListener("keydown", event => {
|
|
150
|
+
const field = event.target;
|
|
151
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing || !(field instanceof HTMLTextAreaElement) || field.id !== "demo-message") return;
|
|
152
|
+
if (field.value.trim() === "") return;
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
field.form?.requestSubmit();
|
|
155
|
+
});
|
|
156
|
+
window.addEventListener("standing-orders:workspace-rendered", start);
|
|
157
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
|
|
158
|
+
})();`;
|
package/dist/demo.d.ts
CHANGED
|
@@ -52,4 +52,102 @@ export declare function createDemoSandbox(now: Date): {
|
|
|
52
52
|
seed: DemoSeed;
|
|
53
53
|
evidenceRoot: string;
|
|
54
54
|
passwordFile: string;
|
|
55
|
+
lead: DemoLead;
|
|
55
56
|
};
|
|
57
|
+
type DemoProject = "payments-api" | "web-console";
|
|
58
|
+
type DemoFile = {
|
|
59
|
+
path: string;
|
|
60
|
+
before: string | null;
|
|
61
|
+
after: string;
|
|
62
|
+
};
|
|
63
|
+
export type DemoPlan = {
|
|
64
|
+
kind: "flaky-test" | "bug" | "copy" | "flag" | "default";
|
|
65
|
+
project: DemoProject;
|
|
66
|
+
title: string;
|
|
67
|
+
goal: string;
|
|
68
|
+
boundaries: string[];
|
|
69
|
+
checks: string[];
|
|
70
|
+
/** What the builder reports while it works, one line per beat. */
|
|
71
|
+
progress: string[];
|
|
72
|
+
conclusion: string;
|
|
73
|
+
checkOutput: string;
|
|
74
|
+
screenshot: {
|
|
75
|
+
caption: string;
|
|
76
|
+
accent: readonly [number, number, number];
|
|
77
|
+
};
|
|
78
|
+
files: DemoFile[];
|
|
79
|
+
};
|
|
80
|
+
/** The scripted lead's choice: a plan named by the request's words, else the default. */
|
|
81
|
+
export declare function pickDemoPlan(text: string): DemoPlan;
|
|
82
|
+
export type DemoExchange = {
|
|
83
|
+
id: number;
|
|
84
|
+
/** What the visitor typed, or the note that asked for this plan. */
|
|
85
|
+
asked: string;
|
|
86
|
+
reply: string;
|
|
87
|
+
plan: DemoPlan;
|
|
88
|
+
/** A visitor's extra instruction carried into the plan by Change it or Request changes. */
|
|
89
|
+
note: string | null;
|
|
90
|
+
state: "proposed" | "replaced" | "working" | "ready" | "complete" | "sent-back";
|
|
91
|
+
stage: "planning" | "building" | "checking" | null;
|
|
92
|
+
progress: string | null;
|
|
93
|
+
taskId: string | null;
|
|
94
|
+
runId: number | null;
|
|
95
|
+
approvedAt: number | null;
|
|
96
|
+
};
|
|
97
|
+
export type DemoLead = {
|
|
98
|
+
exchanges(): readonly DemoExchange[];
|
|
99
|
+
/** Changes whenever anything a reader would see changes. */
|
|
100
|
+
version(): number;
|
|
101
|
+
ask(text: string, now: Date): DemoExchange;
|
|
102
|
+
approve(id: number, now: Date): {
|
|
103
|
+
ok: true;
|
|
104
|
+
} | {
|
|
105
|
+
ok: false;
|
|
106
|
+
message: string;
|
|
107
|
+
};
|
|
108
|
+
change(id: number, note: string, now: Date): {
|
|
109
|
+
ok: true;
|
|
110
|
+
} | {
|
|
111
|
+
ok: false;
|
|
112
|
+
message: string;
|
|
113
|
+
};
|
|
114
|
+
requestChanges(id: number, note: string, now: Date): {
|
|
115
|
+
ok: true;
|
|
116
|
+
} | {
|
|
117
|
+
ok: false;
|
|
118
|
+
message: string;
|
|
119
|
+
};
|
|
120
|
+
/** Complete through the real completion act, then mark the exchange. */
|
|
121
|
+
complete(id: number, now: Date, act: (taskId: string, runId: number) => {
|
|
122
|
+
ok: true;
|
|
123
|
+
} | {
|
|
124
|
+
ok: false;
|
|
125
|
+
message: string;
|
|
126
|
+
}): {
|
|
127
|
+
ok: true;
|
|
128
|
+
} | {
|
|
129
|
+
ok: false;
|
|
130
|
+
message: string;
|
|
131
|
+
};
|
|
132
|
+
advance(now: Date): void;
|
|
133
|
+
stop(): void;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The scripted lead. `stepMs` is one beat: planning takes one, building two
|
|
137
|
+
* (one per progress line), checks one, so a build lands Ready in four beats.
|
|
138
|
+
* State advances on a timer AND whenever someone reads, so a page and the
|
|
139
|
+
* Tasks list agree without anyone polling.
|
|
140
|
+
*/
|
|
141
|
+
export declare function createDemoLead(input: {
|
|
142
|
+
store: Store;
|
|
143
|
+
repos: {
|
|
144
|
+
api: string;
|
|
145
|
+
web: string;
|
|
146
|
+
};
|
|
147
|
+
evidenceRoot: string;
|
|
148
|
+
approver: string;
|
|
149
|
+
token: string;
|
|
150
|
+
stepMs?: number;
|
|
151
|
+
clock?: () => Date;
|
|
152
|
+
}): DemoLead;
|
|
153
|
+
export {};
|