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
package/dist/demo.js
CHANGED
|
@@ -26,17 +26,18 @@
|
|
|
26
26
|
import { execFileSync } from "node:child_process";
|
|
27
27
|
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
|
-
import { join } from "node:path";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
31
|
import { openStore } from "./store.js";
|
|
32
32
|
import { addApprover, propose, approve } from "./scope.js";
|
|
33
33
|
import { legOf, routeDigestOf } from "./phase-routing.js";
|
|
34
|
-
import { acquire } from "./claim.js";
|
|
34
|
+
import { acquire, release } from "./claim.js";
|
|
35
35
|
import { register } from "./runner.js";
|
|
36
36
|
import { approveRoutine, fireRoutine } from "./routine.js";
|
|
37
37
|
import { fileTaskProposal, fileRoutineProposal } from "./proposal.js";
|
|
38
38
|
import { storeEvidence, budgetedStatJson, imageDimensions } from "./evidence.js";
|
|
39
39
|
import { parseProof, adjudicate } from "./proof.js";
|
|
40
|
+
import { sealVerificationReceipt } from "./verification-evidence.js";
|
|
40
41
|
import { parseExecutionPlanDocument, milestonesOf } from "./plan.js";
|
|
41
42
|
import { maybeTriggerRepair } from "./dispose.js";
|
|
42
43
|
import { deflateSync } from "node:zlib";
|
|
@@ -162,7 +163,7 @@ const DEMO_COPY_PROOF = {
|
|
|
162
163
|
criteria: [
|
|
163
164
|
{
|
|
164
165
|
id: "c1",
|
|
165
|
-
statement: "
|
|
166
|
+
statement: "The new empty-state copy reads clearly.",
|
|
166
167
|
verdict: "met",
|
|
167
168
|
how: "Opened the inbox pane with zero items and read the new copy aloud.",
|
|
168
169
|
evidence: [{ kind: "manual-review", ref: "read the new copy in src/inbox-copy.ts" }],
|
|
@@ -242,6 +243,25 @@ const DEMO_BUILDING_REVISION_EVIDENCE = "src/webhooks/queue.ts";
|
|
|
242
243
|
* shipping a binary asset for a run nobody actually captured.
|
|
243
244
|
*/
|
|
244
245
|
function encodeDemoPng(width, height, rgb) {
|
|
246
|
+
// A faint vertical gradient so the file is not one repeated byte —
|
|
247
|
+
// "meaningful", not merely large.
|
|
248
|
+
const stride = width * 3;
|
|
249
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
250
|
+
for (let y = 0; y < height; y++) {
|
|
251
|
+
const rowStart = y * (stride + 1);
|
|
252
|
+
raw[rowStart] = 0; // filter: none
|
|
253
|
+
const shade = Math.round((y / Math.max(1, height - 1)) * 40);
|
|
254
|
+
for (let x = 0; x < width; x++) {
|
|
255
|
+
const p = rowStart + 1 + x * 3;
|
|
256
|
+
raw[p] = Math.min(255, rgb[0] + shade);
|
|
257
|
+
raw[p + 1] = Math.min(255, rgb[1] + shade);
|
|
258
|
+
raw[p + 2] = Math.min(255, rgb[2] + shade);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return pngOf(width, height, raw);
|
|
262
|
+
}
|
|
263
|
+
/** IHDR + one zlib-deflated IDAT + IEND around filtered RGB scanlines. */
|
|
264
|
+
function pngOf(width, height, raw) {
|
|
245
265
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
246
266
|
let c = n;
|
|
247
267
|
for (let k = 0; k < 8; k++)
|
|
@@ -267,21 +287,6 @@ function encodeDemoPng(width, height, rgb) {
|
|
|
267
287
|
ihdr.writeUInt32BE(height, 4);
|
|
268
288
|
ihdr[8] = 8; // bit depth
|
|
269
289
|
ihdr[9] = 2; // color type: RGB
|
|
270
|
-
// A faint vertical gradient so the file is not one repeated byte —
|
|
271
|
-
// "meaningful", not merely large.
|
|
272
|
-
const stride = width * 3;
|
|
273
|
-
const raw = Buffer.alloc((stride + 1) * height);
|
|
274
|
-
for (let y = 0; y < height; y++) {
|
|
275
|
-
const rowStart = y * (stride + 1);
|
|
276
|
-
raw[rowStart] = 0; // filter: none
|
|
277
|
-
const shade = Math.round((y / Math.max(1, height - 1)) * 40);
|
|
278
|
-
for (let x = 0; x < width; x++) {
|
|
279
|
-
const p = rowStart + 1 + x * 3;
|
|
280
|
-
raw[p] = Math.min(255, rgb[0] + shade);
|
|
281
|
-
raw[p + 1] = Math.min(255, rgb[1] + shade);
|
|
282
|
-
raw[p + 2] = Math.min(255, rgb[2] + shade);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
290
|
const idat = deflateSync(raw);
|
|
286
291
|
return Buffer.concat([
|
|
287
292
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
@@ -618,7 +623,7 @@ export function seedDemo(store, repos, evidenceRoot, now) {
|
|
|
618
623
|
acceptance: [
|
|
619
624
|
{
|
|
620
625
|
id: "c1",
|
|
621
|
-
statement: "
|
|
626
|
+
statement: "The new empty-state copy reads clearly.",
|
|
622
627
|
how: "Open the inbox pane with zero items and read it.",
|
|
623
628
|
evidence: ["manual-review"],
|
|
624
629
|
},
|
|
@@ -893,6 +898,7 @@ export function makeDemoRepos(root) {
|
|
|
893
898
|
const dir = join(root, name);
|
|
894
899
|
mkdirSync(join(dir, "src"), { recursive: true });
|
|
895
900
|
for (const [file, content] of Object.entries(files)) {
|
|
901
|
+
mkdirSync(dirname(join(dir, file)), { recursive: true });
|
|
896
902
|
writeFileSync(join(dir, file), content);
|
|
897
903
|
}
|
|
898
904
|
try {
|
|
@@ -912,10 +918,12 @@ export function makeDemoRepos(root) {
|
|
|
912
918
|
api: make("payments-api", {
|
|
913
919
|
"package.json": JSON.stringify({ name: "payments-api", private: true }, null, 2),
|
|
914
920
|
"src/payout.ts": "export function settle(cents: number, rate: number): number {\n return Math.round(cents * rate * 100) / 100;\n}\n",
|
|
921
|
+
...demoLeadFiles("payments-api"),
|
|
915
922
|
}),
|
|
916
923
|
web: make("web-console", {
|
|
917
924
|
"package.json": JSON.stringify({ name: "web-console", private: true }, null, 2),
|
|
918
925
|
"src/app.ts": "export const app = () => 'hello';\n",
|
|
926
|
+
...demoLeadFiles("web-console"),
|
|
919
927
|
}),
|
|
920
928
|
};
|
|
921
929
|
}
|
|
@@ -970,7 +978,7 @@ function seedDemoFlows(store, folder, now) {
|
|
|
970
978
|
store.updateFlowCard(store.flowCards(replies, false).find(one => one.title === "Refund for order 42?").id, { waiting: "Waiting for demo to approve or send it back" }, at(19));
|
|
971
979
|
}
|
|
972
980
|
export function createDemoSandbox(now) {
|
|
973
|
-
const sandbox = mkdtempSync(join(tmpdir(), "
|
|
981
|
+
const sandbox = mkdtempSync(join(tmpdir(), "toolroll-demo-"));
|
|
974
982
|
const repos = makeDemoRepos(sandbox);
|
|
975
983
|
const store = openStore(join(sandbox, "orders.db"));
|
|
976
984
|
// The stamp precedes every row — a half-seeded sandbox is still fenced.
|
|
@@ -988,5 +996,501 @@ export function createDemoSandbox(now) {
|
|
|
988
996
|
store.setBudget({ scope: "project", key: repos.web, limitMicrousd: 25_000_000, hardStop: true }, seed.login.name, now);
|
|
989
997
|
const passwordFile = join(sandbox, "demo-login.txt");
|
|
990
998
|
writeFileSync(passwordFile, `name: ${seed.login.name}\npassword: ${seed.login.password}\n`, { mode: 0o600 });
|
|
991
|
-
|
|
999
|
+
const lead = createDemoLead({ store, repos, evidenceRoot, approver: seed.login.name, token: seed.login.password });
|
|
1000
|
+
return { sandbox, store, seed, evidenceRoot, passwordFile, lead };
|
|
1001
|
+
}
|
|
1002
|
+
const DEMO_CHECK_COMMAND = "npm test";
|
|
1003
|
+
const DEMO_PLANS = {
|
|
1004
|
+
"flaky-test": {
|
|
1005
|
+
kind: "flaky-test",
|
|
1006
|
+
project: "payments-api",
|
|
1007
|
+
title: "Fix the flaky refund test",
|
|
1008
|
+
goal: "Make the refund retry test pass every time. It fails about one run in twenty because it waits on a real timer.",
|
|
1009
|
+
boundaries: ["Only the test changes; refund logic stays as it is.", "No new dependencies."],
|
|
1010
|
+
checks: ["npm test passes, with the refund test run 50 times in a row.", "A screenshot of the passing test report."],
|
|
1011
|
+
progress: ["Reading test/refunds.test.ts", "Replacing the real timer with a fake clock", "Running the refund test 50 times"],
|
|
1012
|
+
conclusion: "The refund retry test now uses a fake clock instead of a real 200 ms wait, so it no longer depends on how busy the machine is. It passed 50 runs in a row.",
|
|
1013
|
+
checkOutput: " ✓ test/refunds.test.ts (1 test, repeated 50 times) 412ms\n ✓ test/payout.test.ts (14 tests) 38ms\n\n Test Files 9 passed (9)\n Tests 216 passed (216)\n",
|
|
1014
|
+
screenshot: { caption: "Test report: 216 passed, refund test 50/50.", accent: [33, 131, 88] },
|
|
1015
|
+
files: [{
|
|
1016
|
+
path: "test/refunds.test.ts",
|
|
1017
|
+
before: 'import { test, expect } from "vitest";\nimport { retryRefund } from "../src/refunds";\n\ntest("retries a declined refund", async () => {\n const started = Date.now();\n const result = await retryRefund("rf_1042", { delayMs: 200 });\n expect(result.status).toBe("refunded");\n expect(Date.now() - started).toBeLessThan(250);\n});\n',
|
|
1018
|
+
after: 'import { test, expect, vi } from "vitest";\nimport { retryRefund } from "../src/refunds";\n\ntest("retries a declined refund", async () => {\n vi.useFakeTimers();\n const pending = retryRefund("rf_1042", { delayMs: 200 });\n await vi.advanceTimersByTimeAsync(200);\n const result = await pending;\n expect(result.status).toBe("refunded");\n vi.useRealTimers();\n});\n',
|
|
1019
|
+
}],
|
|
1020
|
+
},
|
|
1021
|
+
bug: {
|
|
1022
|
+
kind: "bug",
|
|
1023
|
+
project: "payments-api",
|
|
1024
|
+
title: "Stop double refunds when a retry races",
|
|
1025
|
+
goal: "A refund retried while the first request is still in flight pays the customer twice. Make refunds idempotent per request.",
|
|
1026
|
+
boundaries: ["Only src/refunds.ts and a new test change.", "No database or bank API changes."],
|
|
1027
|
+
checks: ["npm test passes, including a new test that retries a refund mid-flight.", "A screenshot of the refunds log showing one refund per request."],
|
|
1028
|
+
progress: ["Reading src/refunds.ts", "Sharing one in-flight refund per request id", "Adding a test that retries mid-flight"],
|
|
1029
|
+
conclusion: "Refunds now share one in-flight request per request id and pass that id to the bank as the idempotency key, so a racing retry returns the first refund instead of paying twice. Added a regression test.",
|
|
1030
|
+
checkOutput: " ✓ test/refunds-race.test.ts (2 tests) 21ms\n ✓ test/refunds.test.ts (1 test) 205ms\n ✓ test/payout.test.ts (14 tests) 38ms\n\n Test Files 10 passed (10)\n Tests 218 passed (218)\n",
|
|
1031
|
+
screenshot: { caption: "Refunds log: one refund per request after two racing retries.", accent: [13, 116, 206] },
|
|
1032
|
+
files: [
|
|
1033
|
+
{
|
|
1034
|
+
path: "src/refunds.ts",
|
|
1035
|
+
before: 'import { bank } from "./bank";\n\nexport type Refund = { id: string; status: "refunded" | "declined" };\n\nexport async function refund(id: string, requestId: string): Promise<Refund> {\n return bank.refund(id);\n}\n',
|
|
1036
|
+
after: 'import { bank } from "./bank";\n\nexport type Refund = { id: string; status: "refunded" | "declined" };\n\nconst inFlight = new Map<string, Promise<Refund>>();\n\nexport async function refund(id: string, requestId: string): Promise<Refund> {\n const existing = inFlight.get(requestId);\n if (existing) return existing;\n const started = bank.refund(id, { idempotencyKey: requestId });\n inFlight.set(requestId, started);\n try {\n return await started;\n } finally {\n inFlight.delete(requestId);\n }\n}\n',
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
path: "test/refunds-race.test.ts",
|
|
1040
|
+
before: null,
|
|
1041
|
+
after: 'import { test, expect } from "vitest";\nimport { refund } from "../src/refunds";\nimport { bank } from "../src/bank";\n\ntest("a racing retry refunds once", async () => {\n const [first, second] = await Promise.all([refund("rf_7", "req_1"), refund("rf_7", "req_1")]);\n expect(second).toBe(first);\n expect(bank.refunds("rf_7")).toHaveLength(1);\n});\n',
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
},
|
|
1045
|
+
copy: {
|
|
1046
|
+
kind: "copy",
|
|
1047
|
+
project: "web-console",
|
|
1048
|
+
title: "Rewrite the empty Payouts page",
|
|
1049
|
+
goal: "The Payouts page says “No data” before the first payout. Say what will appear there and how to send the first one.",
|
|
1050
|
+
boundaries: ["Only the empty state's words and link change.", "No layout or style changes."],
|
|
1051
|
+
checks: ["npm test passes.", "A screenshot of the new empty Payouts page."],
|
|
1052
|
+
progress: ["Reading src/pages/payouts.tsx", "Writing the new empty-state copy", "Rendering the empty page"],
|
|
1053
|
+
conclusion: "The empty Payouts page now says “No payouts yet”, explains that sent payouts appear there with their status, and links to sending the first one.",
|
|
1054
|
+
checkOutput: " ✓ test/pages.test.tsx (6 tests) 64ms\n ✓ test/app.test.ts (3 tests) 9ms\n\n Test Files 4 passed (4)\n Tests 41 passed (41)\n",
|
|
1055
|
+
screenshot: { caption: "Payouts page with no payouts: the new heading, sentence and link.", accent: [23, 23, 23] },
|
|
1056
|
+
files: [{
|
|
1057
|
+
path: "src/pages/payouts.tsx",
|
|
1058
|
+
before: 'export function PayoutsEmpty() {\n return (\n <div className="empty">\n <p>No data</p>\n </div>\n );\n}\n',
|
|
1059
|
+
after: 'export function PayoutsEmpty() {\n return (\n <div className="empty">\n <h2>No payouts yet</h2>\n <p>Payouts you send will appear here with their status.</p>\n <a href="/payouts/new">Send your first payout</a>\n </div>\n );\n}\n',
|
|
1060
|
+
}],
|
|
1061
|
+
},
|
|
1062
|
+
flag: {
|
|
1063
|
+
kind: "flag",
|
|
1064
|
+
project: "web-console",
|
|
1065
|
+
title: "Put the new invoice view behind a flag",
|
|
1066
|
+
goal: "Ship the new invoice view switched off, behind an invoices.v2 flag that can be turned on per workspace.",
|
|
1067
|
+
boundaries: ["The current invoice list stays the default.", "No changes to invoice data or the API."],
|
|
1068
|
+
checks: ["npm test passes, with the flag both on and off.", "A screenshot of the invoices page with the flag on."],
|
|
1069
|
+
progress: ["Reading src/flags.ts", "Adding invoices.v2, off by default", "Rendering the invoices page both ways"],
|
|
1070
|
+
conclusion: "Added an invoices.v2 flag, off by default. With it on for a workspace, the invoices page shows the new view; otherwise the current list is unchanged.",
|
|
1071
|
+
checkOutput: " ✓ test/flags.test.ts (4 tests) 7ms\n ✓ test/pages.test.tsx (8 tests) 71ms\n\n Test Files 4 passed (4)\n Tests 45 passed (45)\n",
|
|
1072
|
+
screenshot: { caption: "Invoices page with invoices.v2 on for the Acme workspace.", accent: [171, 100, 0] },
|
|
1073
|
+
files: [
|
|
1074
|
+
{
|
|
1075
|
+
path: "src/flags.ts",
|
|
1076
|
+
before: 'export const flags = {\n "billing.export": true,\n} as const;\n\nexport type Flag = keyof typeof flags;\n\nexport function isOn(flag: Flag, workspace: { flags?: Partial<Record<Flag, boolean>> }): boolean {\n return workspace.flags?.[flag] ?? flags[flag];\n}\n',
|
|
1077
|
+
after: 'export const flags = {\n "billing.export": true,\n "invoices.v2": false,\n} as const;\n\nexport type Flag = keyof typeof flags;\n\nexport function isOn(flag: Flag, workspace: { flags?: Partial<Record<Flag, boolean>> }): boolean {\n return workspace.flags?.[flag] ?? flags[flag];\n}\n',
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
path: "src/pages/invoices.tsx",
|
|
1081
|
+
before: 'import { InvoiceList } from "../invoices/list";\n\nexport function InvoicesPage({ workspace }: { workspace: Workspace }) {\n return <InvoiceList workspace={workspace} />;\n}\n',
|
|
1082
|
+
after: 'import { InvoiceList } from "../invoices/list";\nimport { InvoiceView } from "../invoices/view-v2";\nimport { isOn } from "../flags";\n\nexport function InvoicesPage({ workspace }: { workspace: Workspace }) {\n if (isOn("invoices.v2", workspace)) return <InvoiceView workspace={workspace} />;\n return <InvoiceList workspace={workspace} />;\n}\n',
|
|
1083
|
+
},
|
|
1084
|
+
],
|
|
1085
|
+
},
|
|
1086
|
+
default: {
|
|
1087
|
+
kind: "default",
|
|
1088
|
+
project: "payments-api",
|
|
1089
|
+
title: "Explain declined payouts in the log",
|
|
1090
|
+
goal: "When a payout is declined, the log only says “payout failed”. Log the payout id and the bank's reason so support can answer customers.",
|
|
1091
|
+
boundaries: ["Only the declined-payout log line and its test change.", "No customer data beyond the payout id."],
|
|
1092
|
+
checks: ["npm test passes, including a test for the new log line.", "A screenshot of the log with a declined payout."],
|
|
1093
|
+
progress: ["Reading src/payout-log.ts", "Adding the payout id and reason", "Adding a test for the log line"],
|
|
1094
|
+
conclusion: "Declined payouts now log “payout declined” with the payout id and the bank's reason code. Added a test that checks the log line.",
|
|
1095
|
+
checkOutput: " ✓ test/payout-log.test.ts (1 test) 4ms\n ✓ test/payout.test.ts (14 tests) 38ms\n\n Test Files 9 passed (9)\n Tests 215 passed (215)\n",
|
|
1096
|
+
screenshot: { caption: "Log viewer: a declined payout with its id and reason.", accent: [196, 50, 10] },
|
|
1097
|
+
files: [
|
|
1098
|
+
{
|
|
1099
|
+
path: "src/payout-log.ts",
|
|
1100
|
+
before: 'import { log } from "./log";\n\nexport function payoutDeclined(payoutId: string, reason: string): void {\n log.warn("payout failed");\n}\n',
|
|
1101
|
+
after: 'import { log } from "./log";\n\nexport function payoutDeclined(payoutId: string, reason: string): void {\n log.warn("payout declined", { payoutId, reason });\n}\n',
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
path: "test/payout-log.test.ts",
|
|
1105
|
+
before: null,
|
|
1106
|
+
after: 'import { test, expect } from "vitest";\nimport { payoutDeclined } from "../src/payout-log";\nimport { log } from "../src/log";\n\ntest("a declined payout logs its id and reason", () => {\n payoutDeclined("po_311", "R01");\n expect(log.last()).toEqual({ level: "warn", message: "payout declined", payoutId: "po_311", reason: "R01" });\n});\n',
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
/** The files each plan changes, as they stand before the change. */
|
|
1112
|
+
function demoLeadFiles(project) {
|
|
1113
|
+
const files = {};
|
|
1114
|
+
for (const plan of Object.values(DEMO_PLANS)) {
|
|
1115
|
+
if (plan.project !== project)
|
|
1116
|
+
continue;
|
|
1117
|
+
for (const file of plan.files)
|
|
1118
|
+
if (file.before !== null)
|
|
1119
|
+
files[file.path] = file.before;
|
|
1120
|
+
}
|
|
1121
|
+
return files;
|
|
1122
|
+
}
|
|
1123
|
+
/** The scripted lead's choice: a plan named by the request's words, else the default. */
|
|
1124
|
+
export function pickDemoPlan(text) {
|
|
1125
|
+
const words = text.toLowerCase();
|
|
1126
|
+
if (/\b(tests?|flaky|flake|spec|ci)\b/.test(words))
|
|
1127
|
+
return DEMO_PLANS["flaky-test"];
|
|
1128
|
+
if (/\b(flags?|toggle|feature|rollout)\b/.test(words))
|
|
1129
|
+
return DEMO_PLANS.flag;
|
|
1130
|
+
if (/\b(copy|text|wording|words|label|typo|empty|page)\b/.test(words))
|
|
1131
|
+
return DEMO_PLANS.copy;
|
|
1132
|
+
if (/\b(bugs?|fix|broken|crash|errors?|fails?|wrong|double|refunds?)\b/.test(words))
|
|
1133
|
+
return DEMO_PLANS.bug;
|
|
1134
|
+
return DEMO_PLANS.default;
|
|
1135
|
+
}
|
|
1136
|
+
/** A unified diff of one small file, one hunk, from a longest-common-subsequence walk. */
|
|
1137
|
+
function unifiedDiff(file) {
|
|
1138
|
+
const split = (text) => (text === null ? [] : text.replace(/\n$/, "").split("\n"));
|
|
1139
|
+
const a = split(file.before);
|
|
1140
|
+
const b = split(file.after);
|
|
1141
|
+
const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
1142
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
1143
|
+
for (let j = b.length - 1; j >= 0; j--)
|
|
1144
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
1145
|
+
}
|
|
1146
|
+
const lines = [];
|
|
1147
|
+
let i = 0;
|
|
1148
|
+
let j = 0;
|
|
1149
|
+
let additions = 0;
|
|
1150
|
+
let deletions = 0;
|
|
1151
|
+
while (i < a.length || j < b.length) {
|
|
1152
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
1153
|
+
lines.push(` ${a[i]}`);
|
|
1154
|
+
i++;
|
|
1155
|
+
j++;
|
|
1156
|
+
}
|
|
1157
|
+
else if (i < a.length && (j === b.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
|
|
1158
|
+
lines.push(`-${a[i]}`);
|
|
1159
|
+
i++;
|
|
1160
|
+
deletions++;
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
1163
|
+
lines.push(`+${b[j]}`);
|
|
1164
|
+
j++;
|
|
1165
|
+
additions++;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const head = file.before === null
|
|
1169
|
+
? `diff --git a/${file.path} b/${file.path}\nnew file mode 100644\n--- /dev/null\n+++ b/${file.path}\n@@ -0,0 +1,${b.length} @@\n`
|
|
1170
|
+
: `diff --git a/${file.path} b/${file.path}\n--- a/${file.path}\n+++ b/${file.path}\n@@ -1,${a.length} +1,${b.length} @@\n`;
|
|
1171
|
+
return { patch: `${head}${lines.join("\n")}\n`, additions, deletions };
|
|
1172
|
+
}
|
|
1173
|
+
/** A plain mock of the screen the plan's screenshot shows: a header, rows and one coloured result mark. */
|
|
1174
|
+
function demoScreenshot(accent) {
|
|
1175
|
+
const width = 960;
|
|
1176
|
+
const height = 600;
|
|
1177
|
+
const pixels = Buffer.alloc(width * height * 3, 255);
|
|
1178
|
+
const fill = (x, y, w, h, rgb) => {
|
|
1179
|
+
for (let row = y; row < Math.min(height, y + h); row++) {
|
|
1180
|
+
for (let col = x; col < Math.min(width, x + w); col++) {
|
|
1181
|
+
const p = (row * width + col) * 3;
|
|
1182
|
+
pixels[p] = rgb[0];
|
|
1183
|
+
pixels[p + 1] = rgb[1];
|
|
1184
|
+
pixels[p + 2] = rgb[2];
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
fill(0, 0, width, 56, [245, 245, 245]);
|
|
1189
|
+
fill(24, 20, 140, 16, [23, 23, 23]);
|
|
1190
|
+
fill(0, 56, width, 1, [230, 230, 230]);
|
|
1191
|
+
for (let index = 0; index < 7; index++) {
|
|
1192
|
+
const y = 96 + index * 64;
|
|
1193
|
+
fill(48, y, 260 - (index % 3) * 40, 14, [64, 64, 64]);
|
|
1194
|
+
fill(48, y + 24, 420 - (index % 2) * 90, 10, [190, 190, 190]);
|
|
1195
|
+
fill(width - 168, y + 4, 112, 24, index === 0 ? accent : [232, 232, 232]);
|
|
1196
|
+
fill(24, y + 52, width - 48, 1, [236, 236, 236]);
|
|
1197
|
+
}
|
|
1198
|
+
return encodePngPixels(width, height, pixels);
|
|
1199
|
+
}
|
|
1200
|
+
function encodePngPixels(width, height, pixels) {
|
|
1201
|
+
const stride = width * 3;
|
|
1202
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
1203
|
+
for (let y = 0; y < height; y++)
|
|
1204
|
+
pixels.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
1205
|
+
return pngOf(width, height, raw);
|
|
1206
|
+
}
|
|
1207
|
+
const DEMO_STAGES = ["planning", "building", "checking"];
|
|
1208
|
+
const DEMO_MAX_EXCHANGES = 40;
|
|
1209
|
+
/**
|
|
1210
|
+
* The scripted lead. `stepMs` is one beat: planning takes one, building two
|
|
1211
|
+
* (one per progress line), checks one, so a build lands Ready in four beats.
|
|
1212
|
+
* State advances on a timer AND whenever someone reads, so a page and the
|
|
1213
|
+
* Tasks list agree without anyone polling.
|
|
1214
|
+
*/
|
|
1215
|
+
export function createDemoLead(input) {
|
|
1216
|
+
const { store, repos, evidenceRoot, approver, token } = input;
|
|
1217
|
+
if (!store.isDemo())
|
|
1218
|
+
throw new Error("the scripted lead only runs in a demo database");
|
|
1219
|
+
const stepMs = input.stepMs ?? 2_500;
|
|
1220
|
+
const clock = input.clock ?? (() => new Date());
|
|
1221
|
+
const list = [];
|
|
1222
|
+
const timers = new Set();
|
|
1223
|
+
let changes = 0;
|
|
1224
|
+
let nextId = 1;
|
|
1225
|
+
const touched = () => { changes++; };
|
|
1226
|
+
// The scripted crew is a registered builder, so Tasks reads a demo build
|
|
1227
|
+
// as a live claim on a connected builder — registered on first use only.
|
|
1228
|
+
const CREW = "demo-crew";
|
|
1229
|
+
let crewToken = null;
|
|
1230
|
+
const crewSeen = (at) => {
|
|
1231
|
+
if (crewToken === null)
|
|
1232
|
+
crewToken = register(store, { name: CREW, host: "demo", capacity: 4, repos: [repos.api, repos.web], now: at }).token;
|
|
1233
|
+
store.touchRunner(CREW, at);
|
|
1234
|
+
return crewToken;
|
|
1235
|
+
};
|
|
1236
|
+
const leases = new Map();
|
|
1237
|
+
const find = (id) => list.find(one => one.id === id);
|
|
1238
|
+
const repoOf = (plan) => (plan.project === "payments-api" ? repos.api : repos.web);
|
|
1239
|
+
const said = (text) => text.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
1240
|
+
const add = (asked, reply, plan, note) => {
|
|
1241
|
+
const exchange = { id: nextId++, asked, reply, plan, note, state: "proposed", stage: null, progress: null, taskId: null, runId: null, approvedAt: null };
|
|
1242
|
+
list.push(exchange);
|
|
1243
|
+
if (list.length > DEMO_MAX_EXCHANGES)
|
|
1244
|
+
list.splice(0, list.length - DEMO_MAX_EXCHANGES);
|
|
1245
|
+
touched();
|
|
1246
|
+
return exchange;
|
|
1247
|
+
};
|
|
1248
|
+
const goalOf = (exchange) => (exchange.note === null ? exchange.plan.goal : `${exchange.plan.goal} Also: ${exchange.note}`);
|
|
1249
|
+
const taskIdFor = (plan) => {
|
|
1250
|
+
const base = plan.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
|
|
1251
|
+
for (let n = 1;; n++) {
|
|
1252
|
+
const id = n === 1 ? base : `${base}-${n}`;
|
|
1253
|
+
if (store.lookupRef(id) === null)
|
|
1254
|
+
return id;
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
/** Each stage's store facts, written once, in order. */
|
|
1258
|
+
const enterStage = (exchange, stage, at) => {
|
|
1259
|
+
const plan = exchange.plan;
|
|
1260
|
+
const taskId = exchange.taskId;
|
|
1261
|
+
const ref = store.refFor("built-in", taskId).id;
|
|
1262
|
+
if (stage === 1) {
|
|
1263
|
+
const repo = repoOf(plan);
|
|
1264
|
+
// The project check the result reports, approved before the attempt starts — as `verify set` would.
|
|
1265
|
+
const command = store.liveVerifyCommand(store.refById(ref)?.repo ?? repo);
|
|
1266
|
+
if (command === null || command.command !== DEMO_CHECK_COMMAND) {
|
|
1267
|
+
store.setVerifyCommand({ repo: store.refById(ref)?.repo ?? repo, command: DEMO_CHECK_COMMAND, timeoutMs: 120_000, approvedBy: approver }, at);
|
|
1268
|
+
}
|
|
1269
|
+
const route = store.approvedRouteOf(taskId);
|
|
1270
|
+
const claimed = acquire(store, ref, CREW, { now: at, token: crewSeen(at), ttlMs: 10 * 60_000 });
|
|
1271
|
+
const leaseId = claimed.ok ? claimed.claim.leaseId : `demo-lease-${taskId}`;
|
|
1272
|
+
const run = store.startRun({
|
|
1273
|
+
taskRef: ref,
|
|
1274
|
+
leaseId,
|
|
1275
|
+
runner: CREW,
|
|
1276
|
+
branch: `toolroll/${taskId}`,
|
|
1277
|
+
worktree: join(repo, `.demo-worktree-${taskId}`),
|
|
1278
|
+
now: at,
|
|
1279
|
+
...(route === null ? {} : { route: { routeDigest: routeDigestOf(route), phase: "build", provider: legOf(route, "build").provider, model: legOf(route, "build").model, chosen: legOf(route, "build").chosen } }),
|
|
1280
|
+
});
|
|
1281
|
+
store.stampRun(run, { baseRevision: randomBytes(20).toString("hex"), scopeDigest: store.getScope(taskId)?.digest ?? "" });
|
|
1282
|
+
store.setRunPhase(run, "agent-running", at);
|
|
1283
|
+
store.setTaskState(taskId, "running", at);
|
|
1284
|
+
exchange.runId = run;
|
|
1285
|
+
if (claimed.ok)
|
|
1286
|
+
leases.set(exchange.id, leaseId);
|
|
1287
|
+
}
|
|
1288
|
+
if (stage === 2) {
|
|
1289
|
+
crewSeen(at);
|
|
1290
|
+
store.setRunPhase(exchange.runId, "verifying-proof", at);
|
|
1291
|
+
}
|
|
1292
|
+
if (stage === 3)
|
|
1293
|
+
finish(exchange, at);
|
|
1294
|
+
};
|
|
1295
|
+
const finish = (exchange, at) => {
|
|
1296
|
+
const plan = exchange.plan;
|
|
1297
|
+
const run = exchange.runId;
|
|
1298
|
+
const taskId = exchange.taskId;
|
|
1299
|
+
const head = randomBytes(20).toString("hex");
|
|
1300
|
+
const base = store.getRun(run)?.baseRevision ?? "";
|
|
1301
|
+
const diffs = plan.files.map(file => ({ path: file.path, ...unifiedDiff(file) }));
|
|
1302
|
+
const stat = {
|
|
1303
|
+
schema: 1, base, head, fileCount: diffs.length,
|
|
1304
|
+
additions: diffs.reduce((sum, one) => sum + one.additions, 0),
|
|
1305
|
+
deletions: diffs.reduce((sum, one) => sum + one.deletions, 0),
|
|
1306
|
+
binaryCount: 0,
|
|
1307
|
+
files: diffs.map(one => ({ path: one.path, additions: one.additions, deletions: one.deletions })),
|
|
1308
|
+
filesTruncated: false,
|
|
1309
|
+
};
|
|
1310
|
+
const shotPath = `evidence/${plan.kind}.png`;
|
|
1311
|
+
const png = demoScreenshot(plan.screenshot.accent);
|
|
1312
|
+
const handoff = { schema: 1, outcome: "built", committed: true, conclusion: plan.conclusion, changes: diffs.map(one => `Changed ${one.path}.`), verification: [plan.checks[0]], followUps: [], decisionsIncorporated: [] };
|
|
1313
|
+
const proof = {
|
|
1314
|
+
version: 1,
|
|
1315
|
+
criteria: [
|
|
1316
|
+
{ id: "c1", statement: plan.checks[0], verdict: "met", how: "Ran the project check.", evidence: [{ kind: "check", ref: DEMO_CHECK_COMMAND }] },
|
|
1317
|
+
{ id: "c2", statement: plan.checks[1], verdict: "met", how: "Captured the screen after the change.", evidence: [{ kind: "screenshot", ref: shotPath }] },
|
|
1318
|
+
],
|
|
1319
|
+
checks: [{ command: DEMO_CHECK_COMMAND, exitCode: 0, summary: plan.checkOutput.trim().split("\n").at(-1).trim() }],
|
|
1320
|
+
changed: diffs.map(one => one.path),
|
|
1321
|
+
caveats: [],
|
|
1322
|
+
screenshots: [{ path: shotPath, caption: plan.screenshot.caption }],
|
|
1323
|
+
};
|
|
1324
|
+
const synthetic = "[demo: scripted]";
|
|
1325
|
+
storeEvidence(store, evidenceRoot, run, "terminal-diff", "terminal-diff.patch", Buffer.from(diffs.map(one => one.patch).join(""), "utf8"), `git diff --no-ext-diff --no-textconv --no-color ${base.slice(0, 8)}..HEAD (exit 0) ${synthetic}`, at);
|
|
1326
|
+
storeEvidence(store, evidenceRoot, run, "diff-stat", "diff-stat.json", budgetedStatJson(stat), `parsed from git diff --numstat -z ${synthetic}`, at);
|
|
1327
|
+
storeEvidence(store, evidenceRoot, run, "handoff", "handoff.json", Buffer.from(JSON.stringify(handoff, null, 2), "utf8"), `composed at completion ${synthetic}`, at);
|
|
1328
|
+
storeEvidence(store, evidenceRoot, run, "proof", "proof.json", Buffer.from(JSON.stringify(proof, null, 2), "utf8"), `agent-authored proof (validated, re-serialized) ${synthetic}`, at);
|
|
1329
|
+
storeEvidence(store, evidenceRoot, run, "screenshot", `screenshot-${plan.kind}.png`, png, `agent-claimed screenshot at ${shotPath} (validated png) ${synthetic}`, at);
|
|
1330
|
+
const log = `=== Attempt summary ===\n- Project check · attempt 1: (exit 0)\n\n=== Project check · attempt 1 ===\n$ ${DEMO_CHECK_COMMAND}\n(exit 0)\n\n--- stdout ---\n${plan.checkOutput}\n--- stderr ---\n`;
|
|
1331
|
+
storeEvidence(store, evidenceRoot, run, "check-log", "check-log.txt", Buffer.from(log, "utf8"), `${DEMO_CHECK_COMMAND} (attempt recorded) ${synthetic}`, at, { captureStatus: "ok" });
|
|
1332
|
+
store.recordOutcomeFacts(run, { headRevision: head, handoff: plan.conclusion });
|
|
1333
|
+
const repo = store.refById(store.refFor("built-in", taskId).id)?.repo ?? repoOf(plan);
|
|
1334
|
+
const command = store.liveVerifyCommand(repo);
|
|
1335
|
+
if (command !== null)
|
|
1336
|
+
sealVerificationReceipt(store, evidenceRoot, run, head, command, { configured: true, ran: true, exitCode: 0 }, at);
|
|
1337
|
+
store.recordRunCheck(run, { status: "passed", exitCode: 0, suites: [] }, at);
|
|
1338
|
+
const adjudicated = adjudicate({
|
|
1339
|
+
proofArtifactPresent: true,
|
|
1340
|
+
proofParse: parseProof(JSON.stringify(proof)),
|
|
1341
|
+
handoffPresent: true,
|
|
1342
|
+
terminalDiffPresent: true,
|
|
1343
|
+
terminalDiffCaptureStatus: "ok",
|
|
1344
|
+
diffStat: { captured: true, truncated: false, paths: new Set(stat.files.map(one => one.path)) },
|
|
1345
|
+
verifyCommand: { configured: true, ran: true, exitCode: 0 },
|
|
1346
|
+
screenshots: [{ path: shotPath, ok: true, bytes: png.length, dims: imageDimensions(png, "png") }],
|
|
1347
|
+
approvedCriteria: store.getScope(taskId)?.acceptance ?? [],
|
|
1348
|
+
});
|
|
1349
|
+
store.saveProofVerdict(run, adjudicated.verdict, adjudicated.reasons, at, adjudicated.matrix);
|
|
1350
|
+
store.finishRun(run, { outcome: "built", committed: true, now: at });
|
|
1351
|
+
store.setTaskState(taskId, "done", at);
|
|
1352
|
+
crewSeen(at);
|
|
1353
|
+
const lease = leases.get(exchange.id);
|
|
1354
|
+
if (lease !== undefined) {
|
|
1355
|
+
release(store, lease, at);
|
|
1356
|
+
leases.delete(exchange.id);
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
const advance = (now) => {
|
|
1360
|
+
for (const exchange of list) {
|
|
1361
|
+
if (exchange.state !== "working" || exchange.approvedAt === null)
|
|
1362
|
+
continue;
|
|
1363
|
+
const beats = Math.floor((now.getTime() - exchange.approvedAt) / stepMs);
|
|
1364
|
+
// planning: beat 0; building: beats 1–2; checks: beat 3; Ready: beat 4.
|
|
1365
|
+
const target = beats >= 4 ? 3 : beats >= 3 ? 2 : beats >= 1 ? 1 : 0;
|
|
1366
|
+
const current = exchange.stage === null ? -1 : DEMO_STAGES.indexOf(exchange.stage);
|
|
1367
|
+
for (let stage = current + 1; stage <= target; stage++) {
|
|
1368
|
+
const at = new Date(exchange.approvedAt + [0, 1, 3, 4][stage] * stepMs);
|
|
1369
|
+
enterStage(exchange, stage, at);
|
|
1370
|
+
if (stage < 3)
|
|
1371
|
+
exchange.stage = DEMO_STAGES[stage];
|
|
1372
|
+
else {
|
|
1373
|
+
exchange.state = "ready";
|
|
1374
|
+
exchange.stage = null;
|
|
1375
|
+
exchange.progress = null;
|
|
1376
|
+
}
|
|
1377
|
+
touched();
|
|
1378
|
+
}
|
|
1379
|
+
if (exchange.state === "working") {
|
|
1380
|
+
const line = exchange.stage === "planning" ? "Reading the project and its guidance"
|
|
1381
|
+
: exchange.stage === "building" ? exchange.plan.progress[Math.min(1, Math.max(0, beats - 1))]
|
|
1382
|
+
: `Running ${DEMO_CHECK_COMMAND} — ${exchange.plan.progress[2].toLowerCase()}`;
|
|
1383
|
+
if (line !== exchange.progress) {
|
|
1384
|
+
exchange.progress = line;
|
|
1385
|
+
touched();
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
const schedule = (exchange) => {
|
|
1391
|
+
for (const beat of [1, 2, 3, 4]) {
|
|
1392
|
+
const timer = setTimeout(() => {
|
|
1393
|
+
timers.delete(timer);
|
|
1394
|
+
try {
|
|
1395
|
+
advance(clock());
|
|
1396
|
+
}
|
|
1397
|
+
catch { /* the next read advances it again */ }
|
|
1398
|
+
}, beat * stepMs + 20);
|
|
1399
|
+
timer.unref();
|
|
1400
|
+
timers.add(timer);
|
|
1401
|
+
}
|
|
1402
|
+
void exchange;
|
|
1403
|
+
};
|
|
1404
|
+
return {
|
|
1405
|
+
exchanges: () => { advance(clock()); return list; },
|
|
1406
|
+
version: () => { advance(clock()); return changes; },
|
|
1407
|
+
ask(text, now) {
|
|
1408
|
+
advance(now);
|
|
1409
|
+
const asked = said(text);
|
|
1410
|
+
const plan = pickDemoPlan(asked);
|
|
1411
|
+
const reply = plan.kind === "default"
|
|
1412
|
+
? `This demo lead is scripted, so it can't plan that exactly. Here's a realistic change in ${plan.project} instead.`
|
|
1413
|
+
: `Here's my plan for ${plan.project}. Approve it and the crew starts.`;
|
|
1414
|
+
return add(asked, reply, plan, null);
|
|
1415
|
+
},
|
|
1416
|
+
approve(id, now) {
|
|
1417
|
+
advance(now);
|
|
1418
|
+
const exchange = find(id);
|
|
1419
|
+
if (exchange === undefined)
|
|
1420
|
+
return { ok: false, message: "That plan is no longer here. Ask again." };
|
|
1421
|
+
if (exchange.state !== "proposed")
|
|
1422
|
+
return { ok: false, message: "That plan was already handled." };
|
|
1423
|
+
const repo = repoOf(exchange.plan);
|
|
1424
|
+
const taskId = taskIdFor(exchange.plan);
|
|
1425
|
+
const acceptance = [
|
|
1426
|
+
{ id: "c1", statement: exchange.plan.checks[0], how: null, evidence: ["check"] },
|
|
1427
|
+
{ id: "c2", statement: exchange.plan.checks[1], how: null, evidence: ["screenshot"] },
|
|
1428
|
+
];
|
|
1429
|
+
const made = fileTaskProposal(store, { id: taskId, title: exchange.plan.title, repo, goal: goalOf(exchange), acceptance, filedVia: "demo" }, now);
|
|
1430
|
+
if (!made.ok)
|
|
1431
|
+
return { ok: false, message: "The demo couldn't file that task." };
|
|
1432
|
+
const proposed = propose(store, {
|
|
1433
|
+
profile: DEMO_PROFILE,
|
|
1434
|
+
taskId,
|
|
1435
|
+
goal: goalOf(exchange),
|
|
1436
|
+
outOfScope: exchange.plan.boundaries.join(" "),
|
|
1437
|
+
touches: exchange.plan.files.map(file => file.path),
|
|
1438
|
+
acceptance,
|
|
1439
|
+
now,
|
|
1440
|
+
});
|
|
1441
|
+
const approved = approve(store, taskId, approver, now, proposed.digest, token);
|
|
1442
|
+
if (!approved.ok)
|
|
1443
|
+
return { ok: false, message: "The demo couldn't approve that plan." };
|
|
1444
|
+
exchange.taskId = taskId;
|
|
1445
|
+
exchange.state = "working";
|
|
1446
|
+
exchange.stage = "planning";
|
|
1447
|
+
exchange.progress = "Reading the project and its guidance";
|
|
1448
|
+
exchange.approvedAt = now.getTime();
|
|
1449
|
+
store.setTaskState(taskId, "queued", now);
|
|
1450
|
+
crewSeen(now);
|
|
1451
|
+
touched();
|
|
1452
|
+
schedule(exchange);
|
|
1453
|
+
return { ok: true };
|
|
1454
|
+
},
|
|
1455
|
+
change(id, note, now) {
|
|
1456
|
+
advance(now);
|
|
1457
|
+
const exchange = find(id);
|
|
1458
|
+
const words = said(note);
|
|
1459
|
+
if (exchange === undefined || exchange.state !== "proposed")
|
|
1460
|
+
return { ok: false, message: "That plan was already handled." };
|
|
1461
|
+
if (words === "")
|
|
1462
|
+
return { ok: false, message: "Say what should change." };
|
|
1463
|
+
exchange.state = "replaced";
|
|
1464
|
+
add(words, "Updated the plan with your note.", exchange.plan, exchange.note === null ? words : `${exchange.note} ${words}`);
|
|
1465
|
+
return { ok: true };
|
|
1466
|
+
},
|
|
1467
|
+
requestChanges(id, note, now) {
|
|
1468
|
+
advance(now);
|
|
1469
|
+
const exchange = find(id);
|
|
1470
|
+
const words = said(note);
|
|
1471
|
+
if (exchange === undefined || exchange.state !== "ready")
|
|
1472
|
+
return { ok: false, message: "That result was already handled." };
|
|
1473
|
+
if (words === "")
|
|
1474
|
+
return { ok: false, message: "Say what should change." };
|
|
1475
|
+
store.addRunNote(exchange.runId, approver, words, now);
|
|
1476
|
+
exchange.state = "sent-back";
|
|
1477
|
+
add(words, "Sent back with your note. Here's the revised plan.", exchange.plan, exchange.note === null ? words : `${exchange.note} ${words}`);
|
|
1478
|
+
return { ok: true };
|
|
1479
|
+
},
|
|
1480
|
+
complete(id, now, act) {
|
|
1481
|
+
advance(now);
|
|
1482
|
+
const exchange = find(id);
|
|
1483
|
+
if (exchange === undefined || exchange.state !== "ready")
|
|
1484
|
+
return { ok: false, message: "That result was already handled." };
|
|
1485
|
+
const done = act(exchange.taskId, exchange.runId);
|
|
1486
|
+
if (!done.ok)
|
|
1487
|
+
return done;
|
|
1488
|
+
exchange.state = "complete";
|
|
1489
|
+
touched();
|
|
1490
|
+
return { ok: true };
|
|
1491
|
+
},
|
|
1492
|
+
advance,
|
|
1493
|
+
stop() { for (const timer of timers)
|
|
1494
|
+
clearTimeout(timer); timers.clear(); },
|
|
1495
|
+
};
|
|
992
1496
|
}
|