@bud.sh/app 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -31
- package/dist/client/assets/bud/design.js +1 -1
- package/dist/client/assets/canvas-Bih9EdKJ.js +1 -0
- package/dist/client/assets/difference-worker-Bzg-rizy.js +8 -0
- package/dist/client/assets/dist-CdE5wBkg.js +5 -0
- package/dist/client/assets/files-1dLuoM2f.js +1 -0
- package/dist/client/assets/i18n-CkO6VFI0.js +1 -0
- package/dist/client/assets/index-Cn2mxroU.css +2 -0
- package/dist/client/assets/{index-Dn2pfmXf.js → index-DtISWZp9.js} +2 -2
- package/dist/client/assets/pages-BJ-tNJ4J.js +1 -0
- package/dist/client/assets/{project-branches-CMGRmrqr.js → project-branches-Ce3rXrkc.js} +1 -1
- package/dist/client/assets/project-comments-CPUONfs8.js +1 -0
- package/dist/client/assets/project-thread-links-C-TGkq59.js +1 -0
- package/dist/client/assets/{project-threads-ZbKDh78d.js → project-threads-Bv7IDBgi.js} +105 -105
- package/dist/client/assets/project-work-DAE9-olg.js +1 -0
- package/dist/client/assets/{task-design-BPpc4Gd7.js → task-design-CwySIAOl.js} +3 -3
- package/dist/client/assets/tasks-KWsCLi_h.js +3 -0
- package/dist/client/assets/wasm-inline-DSUyFn1L.js +1 -0
- package/dist/client/assets/workspace-CVdcfaaB.js +2 -0
- package/dist/client/index.html +3 -3
- package/dist/hosted/assets/{canvas-BYTOp1p7.js → canvas-AII16n9i.js} +97 -6
- package/dist/hosted/assets/{comment-data-Cn5y2VxV.js → comment-data-BXfMElfx.js} +1998 -381
- package/dist/hosted/assets/dist-BCSTeNf4.js +4785 -0
- package/dist/hosted/assets/{files-B1P2_24q.js → files-D_1yeAVO.js} +10 -5
- package/dist/hosted/assets/{pages-B0ZlVdci.js → pages-CN_SbKWA.js} +3 -3
- package/dist/hosted/assets/{project-branches-DlCWmfRo.js → project-branches-Bxp0DCwg.js} +1 -1
- package/dist/hosted/assets/{project-thread-links-BZXQ200n.js → project-thread-links-KqQoKBLE.js} +76 -4
- package/dist/{node-host/assets/screen-DapOVrga.js → hosted/assets/screen-DpAcDUKR.js} +25 -1
- package/dist/hosted/assets/tasks-D66LlwxM.js +1423 -0
- package/dist/hosted/assets/wasm-inline-Dl9tqabx.js +4 -0
- package/dist/hosted/hosted.js +7885 -6219
- package/dist/node/assets/Stream-CAm1eJ2n.js +16604 -0
- package/dist/{server/assets/bridge-B_Blt55E.js → node/assets/bridge-DQSBYHmw.js} +124 -290
- package/dist/node/assets/config-9_jWwNBh.js +1067 -0
- package/dist/node/assets/load-config.server-CMVtRVmO.js +261 -0
- package/dist/node/assets/module-3GgtT4Xs.js +308 -0
- package/dist/node/assets/server-DTQFuoX2.js +846 -0
- package/dist/node/assets/src-D-kG1GxB.js +2 -0
- package/dist/node/assets/{src-CerrKjID.js → src-Dwe9APUx.js} +93 -3
- package/dist/node/assets/src-DysjfbM8.js +8592 -0
- package/dist/node/bridge.js +1 -845
- package/dist/node/cli.js +206 -0
- package/dist/node/design.js +811 -8
- package/dist/node/index.js +2 -739
- package/dist/node/plugins.js +5711 -0
- package/dist/node/server.js +1 -6746
- package/dist/node/vite.js +121 -0
- package/dist/{server/assets/canvas-CkMK4hoN.js → node-host/assets/canvas-BeDoufy1.js} +97 -7
- package/dist/node-host/assets/{comment-data-yfWEdDQL.js → comment-data-DE2T9yTj.js} +1974 -355
- package/dist/node-host/assets/dist-BCSTeNf4.js +4785 -0
- package/dist/node-host/assets/{files-anHi-apm.js → files-Dl-PTS09.js} +11 -6
- package/dist/node-host/assets/{pages-CW4aF6dK.js → pages-DL2sk6_U.js} +3 -3
- package/dist/node-host/assets/{project-branches-D7rVCxQX.js → project-branches-DGp5ELct.js} +1 -1
- package/dist/{server/assets/project-thread-links-lLS_rq7c.js → node-host/assets/project-thread-links-CgBV3Jcm.js} +76 -5
- package/dist/{hosted/assets/screen-CfdC3NbR.js → node-host/assets/screen-B55yyVfb.js} +25 -1
- package/dist/node-host/assets/tasks-DZRNkoxe.js +1425 -0
- package/dist/node-host/assets/wasm-inline-Dl9tqabx.js +4 -0
- package/dist/node-host/node-host.js +6127 -4438
- package/dist/server/assets/{branch-previews-ClkoJ0lM.js → branch-previews-DedrpnVR.js} +1555 -673
- package/dist/{node-host/assets/canvas-DxmcFZCh.js → server/assets/canvas-CJWQ-jjv.js} +98 -6
- package/dist/server/assets/{comment-data-NLeHG89b.js → comment-data-Rg42ToTW.js} +1660 -26
- package/dist/server/assets/dist-BCSTeNf4.js +4785 -0
- package/dist/server/assets/{events-iHpLGr3k.js → events-BH_rAu7O.js} +155 -20
- package/dist/server/assets/{files-CRdl489E.js → files-CPJqKeMo.js} +12 -7
- package/dist/server/assets/{listener-BAYn9Q-u.js → listener-D61O6PzF.js} +708 -12
- package/dist/server/assets/{pages-DXVlJlBC.js → pages-KJY_Ba92.js} +4 -4
- package/dist/server/assets/{project-branches-CwTsf6WQ.js → project-branches-qG9ACZvo.js} +2 -2
- package/dist/{node-host/assets/project-thread-links-m-OLpsD9.js → server/assets/project-thread-links-CqK0lqCF.js} +77 -4
- package/dist/{node-host/assets/tasks-request-B0aeNocI.js → server/assets/project-work-DoIwxWMm.js} +87 -2
- package/dist/server/assets/run-BJ8r5jSh.js +572 -0
- package/dist/server/assets/{screen-B1zH_1O3.js → screen-CBrHPFug.js} +26 -2
- package/dist/server/assets/{server-DxK-UB_Z.js → server-Dkbp5stG.js} +1521 -793
- package/dist/server/assets/{services-BRrgXYlT.js → services-BTfig8A8.js} +234 -997
- package/dist/server/assets/tasks-Bgu5Lg9Y.js +1427 -0
- package/dist/server/assets/wasm-inline-Dl9tqabx.js +4 -0
- package/dist/server/capture.js +1 -2
- package/dist/server/migration.js +569 -12
- package/dist/server/runtime.js +7 -5
- package/dist/server/server.js +3 -3
- package/dist/types/assets/{config-DIC2cf_S.d.ts → config-CvcYmE2s.d.ts} +188 -50
- package/dist/types/assets/{contract-CAT-XFNq.d.ts → contract-B7yuuqSn.d.ts} +44 -2
- package/dist/types/assets/{items-CaVkByHk.d.ts → items-BQdaVksf.d.ts} +12 -1
- package/dist/types/assets/memory-state-BBW-FAVA.d.ts +236 -0
- package/dist/types/assets/picker-Be5Gx4WR.d.ts +9 -0
- package/dist/types/assets/{task-design-xJOhRNWh.d.ts → task-design-BDHKFHpp.d.ts} +3 -1
- package/dist/types/bridge.d.ts +1 -1
- package/dist/types/companion.d.ts +3 -3
- package/dist/types/design.d.ts +2 -2
- package/dist/types/hosted.d.ts +3 -3
- package/dist/types/index.d.ts +2 -2
- package/dist/types/node-host.d.ts +2 -2
- package/dist/types/plugins.d.ts +445 -0
- package/dist/types/screen.d.ts +2 -2
- package/dist/types/server.d.ts +2 -228
- package/dist/types/vite.d.ts +16 -0
- package/package.json +19 -3
- package/dist/client/assets/canvas-8GXJIuDK.js +0 -1
- package/dist/client/assets/difference-worker-C99ychYw.js +0 -8
- package/dist/client/assets/files-DkWt1l9H.js +0 -1
- package/dist/client/assets/i18n-hH_Ba5QO.js +0 -1
- package/dist/client/assets/index-tpHYVXma.css +0 -2
- package/dist/client/assets/pages-BVd6MQmu.js +0 -1
- package/dist/client/assets/project-comments-D1IIQwsW.js +0 -1
- package/dist/client/assets/project-thread-links-DKwe_SYb.js +0 -1
- package/dist/client/assets/project-work-D4DZqBCZ.js +0 -1
- package/dist/client/assets/tasks-Cn0VOZ9Z.js +0 -1
- package/dist/client/assets/tasks-request-BLyOzokZ.js +0 -1
- package/dist/client/assets/workspace-DGErsSg6.js +0 -2
- package/dist/hosted/assets/tasks-_yKh2Z6q.js +0 -649
- package/dist/hosted/assets/tasks-request-CFPLvLc7.js +0 -113
- package/dist/node-host/assets/tasks-BBK_CybR.js +0 -651
- package/dist/server/assets/project-work-CSZlCwH-.js +0 -88
- package/dist/server/assets/tasks-CGdz-uox.js +0 -652
- package/dist/server/assets/tasks-request-D2NBjCTl.js +0 -115
- package/dist/types/assets/picker-D1Bw3ndx.d.ts +0 -14
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { $ as
|
|
2
|
-
import { $t as
|
|
3
|
-
import {
|
|
4
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
1
|
+
import { $ as unwrap$1, $n as provide$1, $t as map, An as tap, At as andThen, B as ensuring$1, Bn as withFiber, Cn as scoped, Cr as TaggedError, Dn as succeedNone, En as succeed, Et as acquireRelease, Fn as try_, G as ignore$1, Gn as effect, Gt as flatMap, H as filter, Hn as currentTimeMillis, Ht as ensuring, In as undefined_$1, It as catchIf, Jn as empty, Jt as forever, K as make$1, Kt as flatten, L as debounce, Ln as uninterruptible, Lt as catchTag, M as Path, Mn as timeoutOption, Mt as asSome, N as FileSystem, Nt as asVoid, On as suspend, Ot as addFinalizer, P as as, Pn as tryPromise, Q as tap$1, Qn as mergeAll, Qt as ignore, Rn as uninterruptibleMask, Rt as catch_, Sn as scope, Sr as some, Tn as sleep, Tt as bytes$1, U as fromAsyncIterable, Un as build, Ut as fail$1, V as fail$2, Vn as yieldNow, Vt as die, W as fromPubSub, Wn as buildWithMemoMap, Wt as filterOrFail, X as runForEach, Xn as makeMemoMapUnsafe, Xt as forkScoped, Y as merge, Yn as isLayer, Yt as forkIn, Z as suspend$1, Zt as gen, _n as runPromiseWith, an as promise, ar as close, at as take, bn as runSyncExitWith, br as isNone, cn as provideService, cr as _await, ct as shutdown, dn as runCallbackWith, dr as succeed$2, dt as await_, en as mapError, et as make$2, f as PICKER_MODULE_URL, fn as runFork, fr as void_, ft as interrupt, gn as runPromiseExitWith, gr as getOption, hn as runPromiseExit, in as orElseSucceed, ir as unwrap, it as sliding$1, j as RuntimePolicy, jn as tapError, jt as as$1, k as writeTextAtomic, kn as sync, kt as all, ln as result, lr as doneUnsafe, lt as sliding, m as Git, mn as runPromise, mr as Service, nn as option, nt as offer, or as forkUnsafe, ot as publish, p as pickerModule, pn as runForkWith, pr as scopeRemoveFinalizerUnsafe, pt as runIn, qn as effectDiscard, qt as forEach, rn as orDie, rr as sync$1, rt as shutdown$1, sn as provideContext, sr as makeUnsafe, st as publishAll, t as BranchPreviews, tr as succeed$1, tt as makeUnsafe$1, u as bridgeScript, un as runCallback, ur as make$3, ut as unbounded, v as projectModuleFile, vn as runSync, vr as getOrNull, wn as serviceOption, xn as runSyncWith, xr as none, y as configPlugins, yn as runSyncExit, yr as getOrUndefined, z as empty$1, zn as void_$1, zt as clockWith } from "./branch-previews-DedrpnVR.js";
|
|
2
|
+
import { $t as parseContract, An as fieldByRole, At as compileSearchPattern, Bn as iconNames, En as parseMessage, Fn as nonempty, Gt as parseFileIndexQuery, In as parseWork, It as classifyText, Jt as parentPath, Kt as isProtectedPath, Ln as record$2, Lt as filterFileEntries, Nn as WorkValidationError, Nt as searchPathMatches, Pn as identifier, Pt as parseFileSearchQuery, Qt as list, R as parseThreadSnapshot, Rn as strict, Rt as orderFileEntries, Sn as valueByRole, St as parseReviewDiffQuery, W as applyReviewSelection, Xt as pathHierarchy, Yt as parseFilePath, Zt as integer, _n as parseWorkQuery, _t as pathCollisionKey, bt as parseReviewComparison, ct as parseFileSaveRequest, dt as parseMutationApply, en as revision, fn as parseWorkDetail, ft as parseMutationIntent, gt as parseRecovery, i as routes, in as parseWorkWorkspace, j as parseThread, jn as parseWorkSchema, jt as matchFileText, kt as parseReviewSourceQuery, nt as parseReviewActionApply, ot as parseReviewActionRecovery, qt as isWithinDirectory, r as parseLiveId, rt as parseReviewActionIntent, t as RESYNC, tn as strict$1, un as parseWorkConnection, vt as validateMutationSelection, wt as parseReviewHistoryQuery } from "./events-BH_rAu7O.js";
|
|
3
|
+
import { a as parseRunRecord, c as parseTaskContext, i as advanceRun, n as RUN_WAITING_STATES, o as runActive, r as RunTransitionError, s as startRun, t as NEEDS_CONTEXT_MARKER } from "./run-BJ8r5jSh.js";
|
|
4
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeSync } from "node:fs";
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { any, boolean, enum_ } from "remix/data-schema";
|
|
8
|
-
import { open, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import { mkdir, open, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
//#region ../../node_modules/.pnpm/effect@4.0.5/node_modules/effect/dist/ManagedRuntime.js
|
|
10
10
|
var TypeId = "~effect/ManagedRuntime";
|
|
11
11
|
/**
|
|
@@ -323,7 +323,7 @@ var ThreadImportSchema = strict({
|
|
|
323
323
|
thread: any()
|
|
324
324
|
});
|
|
325
325
|
function history(value) {
|
|
326
|
-
let input = parseWork(record$
|
|
326
|
+
let input = parseWork(record$2, value, "import.thread");
|
|
327
327
|
if (!Array.isArray(input.messages) || input.messages.length === 0 || input.messages.length > 67108864 || input.messagesLoaded !== true || input.messagesTruncated !== false || input.messageCount !== input.messages.length) throw new WorkValidationError("import.thread.messages");
|
|
328
328
|
let encoder = new TextEncoder();
|
|
329
329
|
let bytes = 0;
|
|
@@ -374,755 +374,12 @@ function parseThreadImport(value) {
|
|
|
374
374
|
thread: history(input.thread)
|
|
375
375
|
};
|
|
376
376
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
var projectRoot = nonempty(4096);
|
|
381
|
-
/**
|
|
382
|
-
* Runs a parser over caller input, turning its failure into an `input` work error.
|
|
383
|
-
* @param parse - Parses the input.
|
|
384
|
-
* @returns The parsed input.
|
|
385
|
-
*/
|
|
386
|
-
function input$3(parse) {
|
|
387
|
-
try {
|
|
388
|
-
return parse();
|
|
389
|
-
} catch (cause) {
|
|
390
|
-
if (cause instanceof WorkError) throw cause;
|
|
391
|
-
throw new WorkError({
|
|
392
|
-
reason: "input",
|
|
393
|
-
message: "Check the supplied values.",
|
|
394
|
-
cause
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Runs a parser over stored data, turning its failure into a `storage` work error.
|
|
400
|
-
* @param parse - Parses the stored data.
|
|
401
|
-
* @returns The parsed data.
|
|
402
|
-
*/
|
|
403
|
-
function output(parse) {
|
|
404
|
-
try {
|
|
405
|
-
return parse();
|
|
406
|
-
} catch (cause) {
|
|
407
|
-
if (cause instanceof WorkError) throw cause;
|
|
408
|
-
throw new WorkError({
|
|
409
|
-
reason: "storage",
|
|
410
|
-
message: "The adapter returned invalid data.",
|
|
411
|
-
cause
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
/**
|
|
416
|
-
* Throws a `refused` work error.
|
|
417
|
-
* @param message - The reason, safe to show.
|
|
418
|
-
*/
|
|
419
|
-
function refused(message) {
|
|
420
|
-
throw new WorkError({
|
|
421
|
-
reason: "refused",
|
|
422
|
-
message
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Throws a `conflict` work error unless a record is still at the revision the caller saw.
|
|
427
|
-
* @param current - The stored record.
|
|
428
|
-
* @param revision - The caller's expected revision.
|
|
429
|
-
*/
|
|
430
|
-
function expected(current, revision) {
|
|
431
|
-
input$3(() => parseWork(expectedRevision, revision, "expectedRevision"));
|
|
432
|
-
if (current.revision !== revision) throw new WorkError({
|
|
433
|
-
reason: "conflict",
|
|
434
|
-
message: "This record changed. Refresh and review your changes before saving.",
|
|
435
|
-
currentRevision: current.revision
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* A loaded section, cut to its limit.
|
|
440
|
-
* @param items - The section's entries.
|
|
441
|
-
* @param max - The most entries to return.
|
|
442
|
-
* @param count - How many entries exist in all.
|
|
443
|
-
* @returns The available section, truncated when more exist than it holds.
|
|
444
|
-
*/
|
|
445
|
-
function available(items, max, count = items.length) {
|
|
446
|
-
return {
|
|
447
|
-
state: "available",
|
|
448
|
-
items: items.slice(0, max),
|
|
449
|
-
truncated: count > max
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* A section the store does not provide.
|
|
454
|
-
* @returns The unsupported section.
|
|
455
|
-
*/
|
|
456
|
-
function unsupported$1() {
|
|
457
|
-
return {
|
|
458
|
-
state: "unsupported",
|
|
459
|
-
items: [],
|
|
460
|
-
truncated: false
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
/** The items, discussions and watchers behind the in-memory work and thread adapters. */
|
|
464
|
-
var MemoryStore = class {
|
|
465
|
-
identity = randomUUID();
|
|
466
|
-
schema;
|
|
467
|
-
author;
|
|
468
|
-
defaults;
|
|
469
|
-
capabilities;
|
|
470
|
-
options;
|
|
471
|
-
workspaces = /* @__PURE__ */ new Map();
|
|
472
|
-
#watchers = /* @__PURE__ */ new Map();
|
|
473
|
-
seed;
|
|
474
|
-
constructor(options) {
|
|
475
|
-
this.schema = parseWorkSchema(options.schema);
|
|
476
|
-
this.author = parseMessageAuthor(options.author ?? {
|
|
477
|
-
id: "memory",
|
|
478
|
-
name: "Memory adapter",
|
|
479
|
-
email: ""
|
|
480
|
-
});
|
|
481
|
-
this.defaults = Object.fromEntries(Object.entries(options.defaults ?? {}).map(([key, value]) => {
|
|
482
|
-
let field = this.schema.fields.find((field) => field.id === key);
|
|
483
|
-
if (!field) refused("A default refers to an undeclared field.");
|
|
484
|
-
return [key, parseWorkValue(value, field, this.schema)];
|
|
485
|
-
}));
|
|
486
|
-
this.options = {
|
|
487
|
-
...options,
|
|
488
|
-
actions: { ...options.actions },
|
|
489
|
-
sections: { ...options.sections }
|
|
490
|
-
};
|
|
491
|
-
this.capabilities = parseThreadCapabilities({
|
|
492
|
-
create: this.schema.capabilities.threads,
|
|
493
|
-
reply: this.schema.capabilities.threads,
|
|
494
|
-
status: this.schema.capabilities.threads,
|
|
495
|
-
link: this.schema.capabilities.threads,
|
|
496
|
-
delete: this.schema.capabilities.threads,
|
|
497
|
-
...options.threadCapabilities,
|
|
498
|
-
sync: false,
|
|
499
|
-
edit: false
|
|
500
|
-
});
|
|
501
|
-
if (this.schema.capabilities.sync) refused("Memory storage has no remote synchronization.");
|
|
502
|
-
for (let action of this.schema.actions) if (action.kind === "custom" && !this.options.actions?.[action.id]) refused("Declare an implementation for each custom memory action.");
|
|
503
|
-
for (let id of Object.keys(this.options.actions ?? {})) if (!this.schema.actions.some((action) => action.id === id)) refused("An action implementation has no declaration.");
|
|
504
|
-
for (let id of Object.keys(this.options.sections ?? {})) if (!this.schema.sections.some((section) => section.id === id)) refused("A section implementation has no declaration.");
|
|
505
|
-
this.seed = {
|
|
506
|
-
sequence: 0,
|
|
507
|
-
items: /* @__PURE__ */ new Map(),
|
|
508
|
-
threads: /* @__PURE__ */ new Map()
|
|
509
|
-
};
|
|
510
|
-
for (let value of options.items ?? []) {
|
|
511
|
-
let item = parseWorkItem(value, this.schema);
|
|
512
|
-
if (!item.contentLoaded || !item.revision || this.seed.items.has(item.id)) refused("Seed work items need complete content and unique identities with revisions.");
|
|
513
|
-
this.seed.items.set(item.id, {
|
|
514
|
-
item,
|
|
515
|
-
activity: [],
|
|
516
|
-
activityCount: 0,
|
|
517
|
-
history: [],
|
|
518
|
-
historyCount: 0
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
for (let value of options.threads ?? []) {
|
|
522
|
-
let thread = parseThread(value);
|
|
523
|
-
if (!thread.messagesLoaded || thread.messagesTruncated || !thread.revision || this.seed.threads.has(thread.id)) refused("Seed threads need complete messages and unique identities with revisions.");
|
|
524
|
-
for (let id of thread.itemIds) this.item(this.seed, id);
|
|
525
|
-
this.seed.threads.set(thread.id, thread);
|
|
526
|
-
}
|
|
527
|
-
for (let row of this.seed.items.values()) for (let relation of row.item.relations) this.item(this.seed, relation.item.id);
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* Signals every write to one workspace while the stream runs.
|
|
531
|
-
* @param root - The project directory.
|
|
532
|
-
* @returns One element per write.
|
|
533
|
-
*/
|
|
534
|
-
watch(root) {
|
|
535
|
-
return callback((queue) => acquireRelease(try_({
|
|
536
|
-
try: () => {
|
|
537
|
-
let key = input$3(() => resolve(root));
|
|
538
|
-
let notify = () => void offerUnsafe(queue, void 0);
|
|
539
|
-
let watchers = this.#watchers.get(key) ?? /* @__PURE__ */ new Set();
|
|
540
|
-
this.#watchers.set(key, watchers.add(notify));
|
|
541
|
-
return () => watchers.delete(notify);
|
|
542
|
-
},
|
|
543
|
-
catch: (cause) => cause instanceof WorkError ? cause : new WorkError({
|
|
544
|
-
reason: "input",
|
|
545
|
-
message: "Invalid project directory.",
|
|
546
|
-
cause
|
|
547
|
-
})
|
|
548
|
-
}), (stop) => sync(stop)));
|
|
549
|
-
}
|
|
550
|
-
run(root, write, operation) {
|
|
551
|
-
return gen({ self: this }, function* () {
|
|
552
|
-
yield* yieldNow;
|
|
553
|
-
let now = yield* currentTimeMillis;
|
|
554
|
-
return yield* try_({
|
|
555
|
-
try: () => {
|
|
556
|
-
let key = input$3(() => {
|
|
557
|
-
parseWork(projectRoot, root, "root");
|
|
558
|
-
if (!isAbsolute(root)) throw new TypeError("The project directory must be absolute.");
|
|
559
|
-
return resolve(root);
|
|
560
|
-
});
|
|
561
|
-
let current = this.workspaces.get(key) ?? structuredClone(this.seed);
|
|
562
|
-
let data = write ? structuredClone(current) : current;
|
|
563
|
-
let result = structuredClone(operation(data, new Date(now).toISOString()));
|
|
564
|
-
if (write) {
|
|
565
|
-
this.workspaces.set(key, data);
|
|
566
|
-
for (let notify of [...this.#watchers.get(key) ?? []]) notify();
|
|
567
|
-
}
|
|
568
|
-
return result;
|
|
569
|
-
},
|
|
570
|
-
catch: (cause) => cause instanceof WorkError ? cause : new WorkError({
|
|
571
|
-
reason: "storage",
|
|
572
|
-
message: "Memory storage failed.",
|
|
573
|
-
cause
|
|
574
|
-
})
|
|
575
|
-
});
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
next(data, prefix) {
|
|
579
|
-
return `${prefix}-${this.identity}-${++data.sequence}`;
|
|
580
|
-
}
|
|
581
|
-
item(data, id) {
|
|
582
|
-
input$3(() => parseWork(identifier, id, "id"));
|
|
583
|
-
let item = data.items.get(id);
|
|
584
|
-
if (!item) throw new WorkError({
|
|
585
|
-
reason: "not-found",
|
|
586
|
-
message: "This work item does not exist."
|
|
587
|
-
});
|
|
588
|
-
return item;
|
|
589
|
-
}
|
|
590
|
-
thread(data, id) {
|
|
591
|
-
input$3(() => parseWork(identifier, id, "id"));
|
|
592
|
-
let thread = data.threads.get(id);
|
|
593
|
-
if (!thread) throw new WorkError({
|
|
594
|
-
reason: "not-found",
|
|
595
|
-
message: "This discussion does not exist."
|
|
596
|
-
});
|
|
597
|
-
return thread;
|
|
598
|
-
}
|
|
599
|
-
done(item) {
|
|
600
|
-
let status = valueByRole(item, this.schema, "status");
|
|
601
|
-
return typeof status === "string" && ["done", "cancelled"].includes(statusCategory(this.schema, status) ?? "");
|
|
602
|
-
}
|
|
603
|
-
view(data, id) {
|
|
604
|
-
let item = this.item(data, id).item;
|
|
605
|
-
let children = [...data.items.values()].filter((row) => row.item.relations.some((relation) => relation.direction === "out" && relation.item.id === id && this.schema.relations.find((kind) => kind.type === relation.type)?.kind === "parent"));
|
|
606
|
-
let discussion = [...data.threads.values()].filter((thread) => thread.itemIds.includes(id)).reduce((count, thread) => count + thread.messageCount, 0);
|
|
607
|
-
let blocked = item.relations.some((relation) => relation.direction === "out" && this.schema.relations.find((kind) => kind.type === relation.type)?.kind === "blocks" && !this.done(this.item(data, relation.item.id).item));
|
|
608
|
-
return output(() => parseWorkItem({
|
|
609
|
-
...item,
|
|
610
|
-
counts: {
|
|
611
|
-
...this.schema.capabilities.discussion ? { discussion } : {},
|
|
612
|
-
children: children.length,
|
|
613
|
-
childrenDone: children.filter((row) => this.done(row.item)).length
|
|
614
|
-
},
|
|
615
|
-
...this.schema.capabilities.ready ? { ready: !this.done(item) && !blocked } : {}
|
|
616
|
-
}, this.schema));
|
|
617
|
-
}
|
|
618
|
-
save(data, value, at, summary) {
|
|
619
|
-
let prior = data.items.get(value.id);
|
|
620
|
-
let revision = this.next(data, "revision");
|
|
621
|
-
let values = { ...value.values };
|
|
622
|
-
for (let [role, entry] of [
|
|
623
|
-
["identifier", value.id],
|
|
624
|
-
["revision", revision],
|
|
625
|
-
["updated", at],
|
|
626
|
-
...!prior ? [["created", at]] : []
|
|
627
|
-
]) {
|
|
628
|
-
let field = this.schema.fields.find((field) => field.role === role);
|
|
629
|
-
if (field) values[field.id] = entry;
|
|
630
|
-
}
|
|
631
|
-
let item = output(() => parseWorkItem({
|
|
632
|
-
...value,
|
|
633
|
-
revision,
|
|
634
|
-
values
|
|
635
|
-
}, this.schema));
|
|
636
|
-
let row = prior ?? {
|
|
637
|
-
item,
|
|
638
|
-
history: [],
|
|
639
|
-
historyCount: 0,
|
|
640
|
-
activity: [],
|
|
641
|
-
activityCount: 0
|
|
642
|
-
};
|
|
643
|
-
row.item = item;
|
|
644
|
-
data.items.set(item.id, row);
|
|
645
|
-
item = this.view(data, item.id);
|
|
646
|
-
row.item = item;
|
|
647
|
-
row.history.unshift({
|
|
648
|
-
revision,
|
|
649
|
-
author: this.author.name,
|
|
650
|
-
at,
|
|
651
|
-
item: structuredClone(item)
|
|
652
|
-
});
|
|
653
|
-
row.history = row.history.slice(0, 50);
|
|
654
|
-
row.historyCount++;
|
|
655
|
-
row.activity.unshift({
|
|
656
|
-
id: this.next(data, "activity"),
|
|
657
|
-
actor: this.author.name,
|
|
658
|
-
at,
|
|
659
|
-
verb: prior ? "changed" : "created",
|
|
660
|
-
summary
|
|
661
|
-
});
|
|
662
|
-
row.activity = row.activity.slice(0, 200);
|
|
663
|
-
row.activityCount++;
|
|
664
|
-
return item;
|
|
665
|
-
}
|
|
666
|
-
touchThreads(data, ids, at) {
|
|
667
|
-
for (let id of new Set(ids)) this.save(data, this.item(data, id).item, at, "Discussion changed");
|
|
668
|
-
}
|
|
669
|
-
initialValues(id, at, supplied) {
|
|
670
|
-
let values = {
|
|
671
|
-
...this.defaults,
|
|
672
|
-
...supplied
|
|
673
|
-
};
|
|
674
|
-
let status = fieldByRole(this.schema, "status");
|
|
675
|
-
if (status && values[status.id] === void 0 && this.schema.statuses.length) values[status.id] = this.schema.statuses[0].value;
|
|
676
|
-
for (let role of [
|
|
677
|
-
"identifier",
|
|
678
|
-
"created",
|
|
679
|
-
"updated"
|
|
680
|
-
]) {
|
|
681
|
-
let field = fieldByRole(this.schema, role);
|
|
682
|
-
if (field) values[field.id] = role === "identifier" ? id : at;
|
|
683
|
-
}
|
|
684
|
-
return values;
|
|
685
|
-
}
|
|
686
|
-
};
|
|
687
|
-
//#endregion
|
|
688
|
-
//#region ../work/src/server/memory-threads.ts
|
|
689
|
-
/**
|
|
690
|
-
* The thread adapter over an in-memory store.
|
|
691
|
-
* @param store - The store shared with the work adapter.
|
|
692
|
-
* @returns The `ThreadAdapter` implementation.
|
|
693
|
-
*/
|
|
694
|
-
function memoryThreads(store) {
|
|
695
|
-
function view(thread, brief = false) {
|
|
696
|
-
let limit = brief ? 1 : 200;
|
|
697
|
-
return output(() => parseThread({
|
|
698
|
-
...thread,
|
|
699
|
-
messages: thread.messages.slice(0, limit),
|
|
700
|
-
messagesLoaded: !brief,
|
|
701
|
-
messagesTruncated: !brief && thread.messages.length > limit
|
|
702
|
-
}));
|
|
703
|
-
}
|
|
704
|
-
function save(data, thread, at, oldIds = []) {
|
|
705
|
-
let changed = {
|
|
706
|
-
...thread,
|
|
707
|
-
revision: store.next(data, "thread-revision"),
|
|
708
|
-
updatedAt: new Date(Math.max(Date.parse(at), Date.parse(thread.updatedAt))).toISOString(),
|
|
709
|
-
messageCount: thread.messages.length
|
|
710
|
-
};
|
|
711
|
-
let result = view(changed);
|
|
712
|
-
data.threads.set(changed.id, changed);
|
|
713
|
-
store.touchThreads(data, [...oldIds, ...changed.itemIds], at);
|
|
714
|
-
return result;
|
|
715
|
-
}
|
|
716
|
-
function snapshot(data, filter) {
|
|
717
|
-
let threads = [...data.threads.values()].filter((thread) => {
|
|
718
|
-
if (filter.itemId && !thread.itemIds.includes(filter.itemId)) return false;
|
|
719
|
-
let kind = thread.anchor ? "type" in thread.anchor ? "code" : "canvas" : null;
|
|
720
|
-
if (filter.anchorKind && filter.anchorKind !== kind) return false;
|
|
721
|
-
return !filter.pagePath || thread.anchor && !("type" in thread.anchor) && thread.anchor.pagePath === filter.pagePath;
|
|
722
|
-
});
|
|
723
|
-
return output(() => parseThreadSnapshot({
|
|
724
|
-
connection: {
|
|
725
|
-
state: "ready",
|
|
726
|
-
adapter: store.schema.adapter
|
|
727
|
-
},
|
|
728
|
-
capabilities: store.capabilities,
|
|
729
|
-
threads: threads.slice(0, MAX_THREADS).map((thread) => view(thread, true)),
|
|
730
|
-
truncated: threads.length > MAX_THREADS
|
|
731
|
-
}));
|
|
732
|
-
}
|
|
733
|
-
function link(root, id, ids, expectedRevision, remove) {
|
|
734
|
-
return store.run(root, true, (data, at) => {
|
|
735
|
-
if (!store.capabilities.link) refused("This adapter does not allow linking discussions.");
|
|
736
|
-
let request = input$3(() => parseThreadLinkInput({
|
|
737
|
-
itemIds: ids,
|
|
738
|
-
expectedRevision
|
|
739
|
-
}));
|
|
740
|
-
let thread = store.thread(data, id);
|
|
741
|
-
expected(thread, request.expectedRevision);
|
|
742
|
-
for (let id of request.itemIds) store.item(data, id);
|
|
743
|
-
let itemIds = remove ? thread.itemIds.filter((id) => !request.itemIds.includes(id)) : [.../* @__PURE__ */ new Set([...thread.itemIds, ...request.itemIds])];
|
|
744
|
-
if (itemIds.length > 100) refused("This discussion has too many linked work items.");
|
|
745
|
-
if (!thread.anchor && !itemIds.length) refused("An unanchored discussion needs a linked work item.");
|
|
746
|
-
return save(data, {
|
|
747
|
-
...thread,
|
|
748
|
-
itemIds
|
|
749
|
-
}, at, thread.itemIds);
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
|
-
return {
|
|
753
|
-
watch: (root) => store.watch(root),
|
|
754
|
-
list: (root, value) => store.run(root, false, (data) => snapshot(data, input$3(() => parseThreadFilter(value)))),
|
|
755
|
-
get: (root, id) => store.run(root, false, (data) => view(store.thread(data, id))),
|
|
756
|
-
create: (root, value, author) => store.run(root, true, (data, at) => {
|
|
757
|
-
if (!store.capabilities.create) refused("This adapter does not allow creating discussions.");
|
|
758
|
-
let request = input$3(() => parseThreadInput(value));
|
|
759
|
-
let trusted = input$3(() => parseMessageAuthor(author));
|
|
760
|
-
for (let id of request.itemIds) store.item(data, id);
|
|
761
|
-
return save(data, {
|
|
762
|
-
id: store.next(data, "thread"),
|
|
763
|
-
revision: null,
|
|
764
|
-
status: "open",
|
|
765
|
-
anchor: request.anchor,
|
|
766
|
-
itemIds: request.itemIds,
|
|
767
|
-
messages: [{
|
|
768
|
-
id: store.next(data, "message"),
|
|
769
|
-
body: request.body,
|
|
770
|
-
author: trusted,
|
|
771
|
-
createdAt: at,
|
|
772
|
-
parentId: null
|
|
773
|
-
}],
|
|
774
|
-
messageCount: 1,
|
|
775
|
-
messagesLoaded: true,
|
|
776
|
-
messagesTruncated: false,
|
|
777
|
-
createdAt: at,
|
|
778
|
-
updatedAt: at
|
|
779
|
-
}, at);
|
|
780
|
-
}),
|
|
781
|
-
reply: (root, id, value, author) => store.run(root, true, (data, at) => {
|
|
782
|
-
if (!store.capabilities.reply) refused("This adapter does not allow replying to discussions.");
|
|
783
|
-
let request = input$3(() => parseThreadReplyInput(value));
|
|
784
|
-
let trusted = input$3(() => parseMessageAuthor(author));
|
|
785
|
-
let thread = store.thread(data, id);
|
|
786
|
-
expected(thread, request.expectedRevision);
|
|
787
|
-
if (request.parentId && !thread.messages.some((message) => message.id === request.parentId)) refused("The reply parent must belong to this discussion.");
|
|
788
|
-
return save(data, {
|
|
789
|
-
...thread,
|
|
790
|
-
messages: [...thread.messages, {
|
|
791
|
-
id: store.next(data, "message"),
|
|
792
|
-
body: request.body,
|
|
793
|
-
author: trusted,
|
|
794
|
-
createdAt: at,
|
|
795
|
-
parentId: request.parentId
|
|
796
|
-
}]
|
|
797
|
-
}, at);
|
|
798
|
-
}),
|
|
799
|
-
setStatus: (root, id, status, expectedRevision) => store.run(root, true, (data, at) => {
|
|
800
|
-
if (!store.capabilities.status) refused("This adapter does not allow changing discussion status.");
|
|
801
|
-
let request = input$3(() => parseThreadStatusInput({
|
|
802
|
-
status,
|
|
803
|
-
expectedRevision
|
|
804
|
-
}));
|
|
805
|
-
let thread = store.thread(data, id);
|
|
806
|
-
expected(thread, request.expectedRevision);
|
|
807
|
-
return save(data, {
|
|
808
|
-
...thread,
|
|
809
|
-
status: request.status
|
|
810
|
-
}, at);
|
|
811
|
-
}),
|
|
812
|
-
deleteMessage: (root, id, value, author) => store.run(root, true, (data, at) => {
|
|
813
|
-
if (!store.capabilities.delete) refused("This adapter does not allow deleting messages.");
|
|
814
|
-
let request = input$3(() => parseThreadDeleteInput(value));
|
|
815
|
-
let trusted = input$3(() => parseMessageAuthor(author));
|
|
816
|
-
let thread = store.thread(data, id);
|
|
817
|
-
expected(thread, request.expectedRevision);
|
|
818
|
-
let refusal = messageDeletionRefusal(thread.messages, request.messageId, trusted.id);
|
|
819
|
-
if (refusal === "missing") throw new WorkError({
|
|
820
|
-
reason: "not-found",
|
|
821
|
-
message: messageDeletionRefusals.missing
|
|
822
|
-
});
|
|
823
|
-
if (refusal) refused(messageDeletionRefusals[refusal]);
|
|
824
|
-
if (thread.messages[0].id === request.messageId) {
|
|
825
|
-
data.threads.delete(id);
|
|
826
|
-
store.touchThreads(data, thread.itemIds, at);
|
|
827
|
-
return null;
|
|
828
|
-
}
|
|
829
|
-
return save(data, {
|
|
830
|
-
...thread,
|
|
831
|
-
messages: withoutMessages(thread.messages, [request.messageId])
|
|
832
|
-
}, at);
|
|
833
|
-
}),
|
|
834
|
-
link: (root, id, ids, revision) => link(root, id, ids, revision, false),
|
|
835
|
-
unlink: (root, id, ids, revision) => link(root, id, ids, revision, true)
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
//#endregion
|
|
839
|
-
//#region ../work/src/server/memory-actions.ts
|
|
840
|
-
/**
|
|
841
|
-
* Computes what a declared action changes on an item, using its custom implementation or the
|
|
842
|
-
* built-in status, claim and relation behaviour.
|
|
843
|
-
* @param store - The memory store, with its schema, author and action implementations.
|
|
844
|
-
* @param item - The item the action runs on.
|
|
845
|
-
* @param action - The action's declaration.
|
|
846
|
-
* @param values - The action's validated input.
|
|
847
|
-
* @param at - When the action runs, for close timestamps.
|
|
848
|
-
* @returns The new values or outgoing relations.
|
|
849
|
-
*/
|
|
850
|
-
function actionChange(store, item, action, values, at) {
|
|
851
|
-
let custom = store.options.actions?.[action.id];
|
|
852
|
-
if (custom) return custom(structuredClone({
|
|
853
|
-
item,
|
|
854
|
-
input: values,
|
|
855
|
-
schema: store.schema,
|
|
856
|
-
author: store.author
|
|
857
|
-
}));
|
|
858
|
-
let patch = {};
|
|
859
|
-
let status = fieldByRole(store.schema, "status");
|
|
860
|
-
if (action.kind === "close" || action.kind === "reopen" || action.kind === "transition") {
|
|
861
|
-
if (!status) refused("This schema has no status field.");
|
|
862
|
-
let destination = action.kind === "transition" ? values[action.input?.find((field) => field.role === "status" || field.kind === "status")?.id ?? ""] : store.schema.statuses.find((status) => action.kind === "close" ? status.category === "done" : ["unstarted", "backlog"].includes(status.category))?.value;
|
|
863
|
-
if (typeof destination !== "string" || !store.schema.statuses.some((status) => status.value === destination)) refused("This action has no valid destination status.");
|
|
864
|
-
patch[status.id] = destination;
|
|
865
|
-
let closed = fieldByRole(store.schema, "closed");
|
|
866
|
-
if (closed && action.kind === "close") patch[closed.id] = at;
|
|
867
|
-
if (closed && !closed.required && action.kind === "reopen") patch[closed.id] = null;
|
|
868
|
-
let reason = fieldByRole(store.schema, "closeReason");
|
|
869
|
-
let reasonInput = action.input?.find((field) => field.role === "closeReason");
|
|
870
|
-
if (reason && reasonInput && values[reasonInput.id] !== void 0) patch[reason.id] = values[reasonInput.id];
|
|
871
|
-
return { values: patch };
|
|
872
|
-
}
|
|
873
|
-
if (action.kind === "claim" || action.kind === "unclaim") {
|
|
874
|
-
let assignee = fieldByRole(store.schema, "assignee");
|
|
875
|
-
if (!assignee) refused("This schema has no assignee field.");
|
|
876
|
-
let current = item.values[assignee.id];
|
|
877
|
-
if (action.kind === "claim" && current !== null && current !== void 0 && current !== "" && !(Array.isArray(current) && !current.length) && !(typeof current === "string" && current === store.author.name) && !(typeof current === "object" && !Array.isArray(current) && current.id === store.author.id)) refused("This work item is already assigned.");
|
|
878
|
-
let person = {
|
|
879
|
-
id: store.author.id,
|
|
880
|
-
name: store.author.name,
|
|
881
|
-
email: store.author.email
|
|
882
|
-
};
|
|
883
|
-
let claimed = action.kind === "claim";
|
|
884
|
-
if (assignee.kind === "person") patch[assignee.id] = claimed ? person : null;
|
|
885
|
-
else if (assignee.kind === "people") patch[assignee.id] = claimed ? [person] : [];
|
|
886
|
-
else if (assignee.kind === "text") patch[assignee.id] = claimed ? person.name : "";
|
|
887
|
-
else refused("This assignee field cannot represent a claim.");
|
|
888
|
-
return { values: patch };
|
|
889
|
-
}
|
|
890
|
-
if (action.kind === "relate" || action.kind === "unrelate") {
|
|
891
|
-
let targetField = action.input?.find((field) => field.kind === "item");
|
|
892
|
-
if (!targetField) refused("A relation action requires an item input.");
|
|
893
|
-
let target = input$3(() => parseWorkItemRef(values[targetField.id]));
|
|
894
|
-
let editable = store.schema.relations.filter((relation) => relation.editable);
|
|
895
|
-
let relationField = action.input?.find((field) => field.kind === "enum");
|
|
896
|
-
let type = relationField ? values[relationField.id] : editable.length === 1 ? editable[0].type : void 0;
|
|
897
|
-
if (typeof type !== "string" || !editable.some((relation) => relation.type === type)) refused("Select an editable relation type.");
|
|
898
|
-
let remaining = item.relations.filter((relation) => !(relation.direction === "out" && relation.type === type && relation.item.id === target.id));
|
|
899
|
-
return { relations: action.kind === "unrelate" ? remaining : [...remaining, {
|
|
900
|
-
type,
|
|
901
|
-
direction: "out",
|
|
902
|
-
item: target
|
|
903
|
-
}] };
|
|
904
|
-
}
|
|
905
|
-
refused("This action needs an implementation.");
|
|
906
|
-
}
|
|
907
|
-
function reference(store, item) {
|
|
908
|
-
let title = valueByRole(item, store.schema, "title");
|
|
909
|
-
let status = valueByRole(item, store.schema, "status");
|
|
910
|
-
return {
|
|
911
|
-
id: item.id,
|
|
912
|
-
...typeof title === "string" ? { title: title.slice(0, 1e3) } : {},
|
|
913
|
-
...typeof status === "string" ? { status } : {}
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
/**
|
|
917
|
-
* Rebuilds every item's incoming relations and link summaries from the outgoing ones, refusing
|
|
918
|
-
* self-links and parent or blocking cycles, and saves the items that changed.
|
|
919
|
-
* @param store - The memory store.
|
|
920
|
-
* @param data - The project's items.
|
|
921
|
-
* @param at - When the change happens.
|
|
922
|
-
*/
|
|
923
|
-
function reconcileRelations(store, data, at) {
|
|
924
|
-
let incoming = /* @__PURE__ */ new Map();
|
|
925
|
-
for (let row of data.items.values()) for (let relation of row.item.relations.filter((relation) => relation.direction === "out")) {
|
|
926
|
-
store.item(data, relation.item.id);
|
|
927
|
-
if (relation.item.id === row.item.id) refused("A work item cannot relate to itself.");
|
|
928
|
-
let relations = incoming.get(relation.item.id) ?? [];
|
|
929
|
-
relations.push({
|
|
930
|
-
type: relation.type,
|
|
931
|
-
direction: "in",
|
|
932
|
-
item: reference(store, row.item)
|
|
933
|
-
});
|
|
934
|
-
incoming.set(relation.item.id, relations);
|
|
935
|
-
let kind = store.schema.relations.find((kind) => kind.type === relation.type)?.kind;
|
|
936
|
-
if (kind !== "parent" && kind !== "blocks") continue;
|
|
937
|
-
let pending = [relation.item.id];
|
|
938
|
-
let seen = /* @__PURE__ */ new Set();
|
|
939
|
-
while (pending.length) {
|
|
940
|
-
let id = pending.pop();
|
|
941
|
-
if (id === row.item.id) refused("This relationship would create a cycle.");
|
|
942
|
-
if (seen.has(id)) continue;
|
|
943
|
-
seen.add(id);
|
|
944
|
-
for (let edge of store.item(data, id).item.relations) if (edge.direction === "out" && edge.type === relation.type) pending.push(edge.item.id);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
for (let row of data.items.values()) {
|
|
948
|
-
let relations = [...row.item.relations.filter((relation) => relation.direction === "out").map((relation) => ({
|
|
949
|
-
...relation,
|
|
950
|
-
item: reference(store, store.item(data, relation.item.id).item)
|
|
951
|
-
})), ...incoming.get(row.item.id) ?? []];
|
|
952
|
-
if (JSON.stringify(row.item.relations) !== JSON.stringify(relations)) store.save(data, {
|
|
953
|
-
...row.item,
|
|
954
|
-
relations
|
|
955
|
-
}, at, "Relationships changed");
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
//#endregion
|
|
959
|
-
//#region ../work/src/server/memory-work.ts
|
|
960
|
-
var actionResult = strict({
|
|
377
|
+
nonempty(256);
|
|
378
|
+
nonempty(4096);
|
|
379
|
+
strict({
|
|
961
380
|
values: any(),
|
|
962
381
|
relations: any()
|
|
963
382
|
});
|
|
964
|
-
/**
|
|
965
|
-
* The work adapter over an in-memory store.
|
|
966
|
-
* @param store - The store shared with the thread adapter.
|
|
967
|
-
* @returns The `WorkAdapter` implementation.
|
|
968
|
-
*/
|
|
969
|
-
function memoryWork(store) {
|
|
970
|
-
let schema = store.schema;
|
|
971
|
-
let connection = {
|
|
972
|
-
state: "ready",
|
|
973
|
-
adapter: schema.adapter
|
|
974
|
-
};
|
|
975
|
-
function detail(data, id) {
|
|
976
|
-
let row = store.item(data, id);
|
|
977
|
-
let messages = [...data.threads.values()].filter((thread) => thread.itemIds.includes(id)).flatMap((thread) => thread.messages);
|
|
978
|
-
return output(() => parseWorkDetail({
|
|
979
|
-
connection,
|
|
980
|
-
item: store.view(data, id),
|
|
981
|
-
discussion: schema.capabilities.discussion ? available(messages, 200) : unsupported$1(),
|
|
982
|
-
history: schema.capabilities.history ? available(row.history, 50, row.historyCount) : unsupported$1(),
|
|
983
|
-
activity: schema.capabilities.activity ? available(row.activity, 200, row.activityCount) : unsupported$1()
|
|
984
|
-
}, schema));
|
|
985
|
-
}
|
|
986
|
-
function result(data, id) {
|
|
987
|
-
return output(() => parseWorkMutationResult({ item: store.view(data, id) }, schema));
|
|
988
|
-
}
|
|
989
|
-
return {
|
|
990
|
-
watch: (root) => store.watch(root),
|
|
991
|
-
describe: (root) => store.run(root, false, () => connection),
|
|
992
|
-
schema: (root) => store.run(root, false, () => schema),
|
|
993
|
-
list: (root, value) => store.run(root, false, (data) => {
|
|
994
|
-
let query = input$3(() => parseWorkQuery(value));
|
|
995
|
-
let search = query.search?.trim().toLowerCase();
|
|
996
|
-
let rows = [...data.items.values()].map((row) => row.item).filter((item) => {
|
|
997
|
-
if (query.status && valueByRole(item, schema, "status") !== query.status) return false;
|
|
998
|
-
return !search || [item.id, ...Object.values(item.values).filter((value) => typeof value === "string")].some((value) => value.toLowerCase().includes(search));
|
|
999
|
-
});
|
|
1000
|
-
let items = rows.slice(0, query.limit).map((row) => {
|
|
1001
|
-
let item = store.view(data, row.id);
|
|
1002
|
-
return {
|
|
1003
|
-
...item,
|
|
1004
|
-
values: Object.fromEntries(Object.entries(item.values).filter(([key]) => schema.fields.find((field) => field.id === key)?.kind !== "markdown")),
|
|
1005
|
-
contentLoaded: false
|
|
1006
|
-
};
|
|
1007
|
-
});
|
|
1008
|
-
return output(() => parseWorkSnapshot({
|
|
1009
|
-
connection,
|
|
1010
|
-
items,
|
|
1011
|
-
truncated: rows.length > query.limit,
|
|
1012
|
-
schemaVersion: schema.adapter.version
|
|
1013
|
-
}, schema));
|
|
1014
|
-
}),
|
|
1015
|
-
get: (root, id) => store.run(root, false, (data) => detail(data, id)),
|
|
1016
|
-
section: (root, id, sectionId) => store.run(root, false, (data) => {
|
|
1017
|
-
input$3(() => parseWork(identifier, sectionId, "sectionId"));
|
|
1018
|
-
let item = store.view(data, id);
|
|
1019
|
-
let declared = schema.sections.find((section) => section.id === sectionId);
|
|
1020
|
-
if (!declared) return {
|
|
1021
|
-
kind: "custom",
|
|
1022
|
-
data: unsupported$1()
|
|
1023
|
-
};
|
|
1024
|
-
let custom = store.options.sections?.[sectionId];
|
|
1025
|
-
let result;
|
|
1026
|
-
if (custom) result = custom(structuredClone(item));
|
|
1027
|
-
else if (declared.kind === "activity") result = {
|
|
1028
|
-
kind: "activity",
|
|
1029
|
-
data: schema.capabilities.activity ? available(store.item(data, id).activity, 200, store.item(data, id).activityCount) : unsupported$1()
|
|
1030
|
-
};
|
|
1031
|
-
else if (declared.kind === "history") result = {
|
|
1032
|
-
kind: "history",
|
|
1033
|
-
data: schema.capabilities.history ? available(store.item(data, id).history, 50, store.item(data, id).historyCount) : unsupported$1()
|
|
1034
|
-
};
|
|
1035
|
-
else if (declared.kind === "metadata") result = {
|
|
1036
|
-
kind: "metadata",
|
|
1037
|
-
data: available([item.extra], 1)
|
|
1038
|
-
};
|
|
1039
|
-
else result = {
|
|
1040
|
-
kind: declared.kind,
|
|
1041
|
-
data: unsupported$1()
|
|
1042
|
-
};
|
|
1043
|
-
return output(() => {
|
|
1044
|
-
if (result.kind !== declared.kind) throw new TypeError("The section renderer changed.");
|
|
1045
|
-
return parseSectionData(result, schema);
|
|
1046
|
-
});
|
|
1047
|
-
}),
|
|
1048
|
-
create: (root, value) => store.run(root, true, (data, at) => {
|
|
1049
|
-
if (!schema.capabilities.create) refused("This adapter does not allow creating work items.");
|
|
1050
|
-
let request = input$3(() => parseWorkCreate(value, schema));
|
|
1051
|
-
let id = store.next(data, "work");
|
|
1052
|
-
store.save(data, {
|
|
1053
|
-
id,
|
|
1054
|
-
revision: null,
|
|
1055
|
-
values: store.initialValues(id, at, request.values),
|
|
1056
|
-
relations: [],
|
|
1057
|
-
counts: {},
|
|
1058
|
-
contentLoaded: true,
|
|
1059
|
-
extra: null
|
|
1060
|
-
}, at, "Work item created");
|
|
1061
|
-
return result(data, id);
|
|
1062
|
-
}),
|
|
1063
|
-
update: (root, id, patch, expectedRevision) => store.run(root, true, (data, at) => {
|
|
1064
|
-
if (!schema.capabilities.update) refused("This adapter does not allow editing work items.");
|
|
1065
|
-
let request = input$3(() => parseWorkUpdate({
|
|
1066
|
-
values: patch,
|
|
1067
|
-
expectedRevision
|
|
1068
|
-
}, schema));
|
|
1069
|
-
let item = store.item(data, id).item;
|
|
1070
|
-
expected(item, request.expectedRevision);
|
|
1071
|
-
store.save(data, {
|
|
1072
|
-
...item,
|
|
1073
|
-
values: {
|
|
1074
|
-
...item.values,
|
|
1075
|
-
...request.values
|
|
1076
|
-
}
|
|
1077
|
-
}, at, "Work item updated");
|
|
1078
|
-
reconcileRelations(store, data, at);
|
|
1079
|
-
return result(data, id);
|
|
1080
|
-
}),
|
|
1081
|
-
act: (root, id, actionId, values, expectedRevision) => store.run(root, true, (data, at) => {
|
|
1082
|
-
let request = input$3(() => parseWorkActionInput({
|
|
1083
|
-
action: actionId,
|
|
1084
|
-
input: values,
|
|
1085
|
-
expectedRevision
|
|
1086
|
-
}, schema));
|
|
1087
|
-
let item = store.item(data, id).item;
|
|
1088
|
-
expected(item, request.expectedRevision);
|
|
1089
|
-
let action = schema.actions.find((action) => action.id === request.action);
|
|
1090
|
-
let changed = actionChange(store, item, action, request.input, at);
|
|
1091
|
-
output(() => parseWork(actionResult, changed, "actionResult"));
|
|
1092
|
-
let next = {
|
|
1093
|
-
...item,
|
|
1094
|
-
values: {
|
|
1095
|
-
...item.values,
|
|
1096
|
-
...changed.values
|
|
1097
|
-
},
|
|
1098
|
-
relations: changed.relations ?? item.relations
|
|
1099
|
-
};
|
|
1100
|
-
store.save(data, next, at, action.label);
|
|
1101
|
-
reconcileRelations(store, data, at);
|
|
1102
|
-
return result(data, id);
|
|
1103
|
-
})
|
|
1104
|
-
};
|
|
1105
|
-
}
|
|
1106
|
-
//#endregion
|
|
1107
|
-
//#region ../work/src/server/memory.ts
|
|
1108
|
-
/**
|
|
1109
|
-
* Creates isolated work and discussion services for each layer and project root.
|
|
1110
|
-
* @param options - The schema, complete seed records and trusted behavior for this adapter.
|
|
1111
|
-
* @returns A layer with atomic in-memory writes and no filesystem or process requirements.
|
|
1112
|
-
*/
|
|
1113
|
-
function memoryAdapterLayer(options) {
|
|
1114
|
-
return effectContext(try_({
|
|
1115
|
-
try: () => {
|
|
1116
|
-
let store = new MemoryStore(options);
|
|
1117
|
-
return make$1(WorkAdapter, memoryWork(store)).pipe(add(ThreadAdapter, memoryThreads(store)));
|
|
1118
|
-
},
|
|
1119
|
-
catch: (cause) => new WorkError({
|
|
1120
|
-
reason: "input",
|
|
1121
|
-
message: "The memory adapter configuration is invalid.",
|
|
1122
|
-
cause
|
|
1123
|
-
})
|
|
1124
|
-
}));
|
|
1125
|
-
}
|
|
1126
383
|
//#endregion
|
|
1127
384
|
//#region app/server/adapter-definition.ts
|
|
1128
385
|
/**
|
|
@@ -1146,24 +403,8 @@ function defineAdapter(definition) {
|
|
|
1146
403
|
}
|
|
1147
404
|
//#endregion
|
|
1148
405
|
//#region app/server/adapters.ts
|
|
1149
|
-
/**
|
|
406
|
+
/** A store or design that fails before the companion serves; causes stay on the server. */
|
|
1150
407
|
var AdapterStartupError = class extends TaggedError("AdapterStartupError") {};
|
|
1151
|
-
/** Module evaluation is replaceable in tests and cached by the registry, not globally. */
|
|
1152
|
-
var AdapterModuleLoader = class extends Service()("bud/AdapterModuleLoader") {};
|
|
1153
|
-
var adapterModuleLoaderLayer = succeed$1(AdapterModuleLoader, { load: (filename) => tryPromise({
|
|
1154
|
-
try: async () => {
|
|
1155
|
-
let url = pathToFileURL(filename);
|
|
1156
|
-
url.searchParams.set("bud", randomUUID());
|
|
1157
|
-
return (await import(
|
|
1158
|
-
/* @vite-ignore */
|
|
1159
|
-
url.href
|
|
1160
|
-
)).default;
|
|
1161
|
-
},
|
|
1162
|
-
catch: (cause) => new AdapterStartupError({
|
|
1163
|
-
message: `Could not evaluate adapter module ${filename}.`,
|
|
1164
|
-
cause
|
|
1165
|
-
})
|
|
1166
|
-
}) });
|
|
1167
408
|
/** The project's selected work and thread stores, its storage settings and its task design. */
|
|
1168
409
|
var AdapterRegistry = class extends Service()("bud/AdapterRegistry") {};
|
|
1169
410
|
function failure$3(message) {
|
|
@@ -1172,10 +413,10 @@ function failure$3(message) {
|
|
|
1172
413
|
cause
|
|
1173
414
|
});
|
|
1174
415
|
}
|
|
1175
|
-
function
|
|
416
|
+
function designFile(root, module) {
|
|
1176
417
|
return projectModuleFile(root, module, {
|
|
1177
|
-
setting,
|
|
1178
|
-
kind:
|
|
418
|
+
setting: "taskDesign",
|
|
419
|
+
kind: "browser"
|
|
1179
420
|
}).pipe(mapError((cause) => new AdapterStartupError({
|
|
1180
421
|
message: cause.message,
|
|
1181
422
|
cause
|
|
@@ -1204,35 +445,34 @@ function publicConnection(definition, connection) {
|
|
|
1204
445
|
* @param project - The trusted project context, updated with public connection state before serving
|
|
1205
446
|
* it.
|
|
1206
447
|
* @param builtIns - Explicit built-in implementations supplied by application composition.
|
|
1207
|
-
* @returns The registry layer;
|
|
448
|
+
* @returns The registry layer; all acquired resources share its lifetime.
|
|
1208
449
|
*/
|
|
1209
450
|
function adapterRegistryLayer(project, builtIns) {
|
|
1210
451
|
return unwrap(gen(function* () {
|
|
1211
452
|
let root = project.root;
|
|
1212
|
-
let loader = yield* AdapterModuleLoader;
|
|
1213
453
|
let loaded = /* @__PURE__ */ new Map();
|
|
1214
454
|
let selected = {
|
|
1215
455
|
work: null,
|
|
1216
456
|
threads: null
|
|
1217
457
|
};
|
|
1218
458
|
for (let kind of ["work", "threads"]) {
|
|
1219
|
-
let
|
|
1220
|
-
if (
|
|
1221
|
-
let
|
|
1222
|
-
let definition = loaded.get(key);
|
|
459
|
+
let name = project.config.storage[kind];
|
|
460
|
+
if (name === false) continue;
|
|
461
|
+
let definition = loaded.get(name);
|
|
1223
462
|
if (!definition) {
|
|
1224
|
-
let value =
|
|
463
|
+
let value = builtIns[name];
|
|
464
|
+
if (!value) return yield* new AdapterStartupError({ message: `No plugin provides the ${kind === "work" ? "task" : "discussion"} store ${name}.` });
|
|
1225
465
|
definition = yield* try_({
|
|
1226
466
|
try: () => defineAdapter(value),
|
|
1227
|
-
catch: failure$3(`
|
|
467
|
+
catch: failure$3(`The ${kind} store ${name} is not a valid adapter definition.`)
|
|
1228
468
|
});
|
|
1229
|
-
loaded.set(
|
|
469
|
+
loaded.set(name, definition);
|
|
1230
470
|
}
|
|
1231
|
-
if (!definition[kind]) return yield* new AdapterStartupError({ message: `
|
|
471
|
+
if (!definition[kind]) return yield* new AdapterStartupError({ message: `The ${kind === "work" ? "task" : "discussion"} store ${definition.id} does not provide ${kind}.` });
|
|
1232
472
|
selected[kind] = definition;
|
|
1233
473
|
}
|
|
1234
474
|
let design = project.config.designs.tasks;
|
|
1235
|
-
let taskDesignFile = design === "bud:beads" ? null : yield*
|
|
475
|
+
let taskDesignFile = design === "bud:beads" ? null : yield* designFile(root, design);
|
|
1236
476
|
let workLayer = selected.work?.work;
|
|
1237
477
|
let threadLayer = selected.threads?.threads;
|
|
1238
478
|
let layers = mergeAll(workLayer ? provide$1(effect(WorkAdapter, gen(function* () {
|
|
@@ -1265,7 +505,13 @@ function adapterRegistryLayer(project, builtIns) {
|
|
|
1265
505
|
"create",
|
|
1266
506
|
"update",
|
|
1267
507
|
"act"
|
|
1268
|
-
], [
|
|
508
|
+
], [
|
|
509
|
+
"sync",
|
|
510
|
+
"read",
|
|
511
|
+
"addStatuses",
|
|
512
|
+
"readItemData",
|
|
513
|
+
"writeItemData"
|
|
514
|
+
]);
|
|
1269
515
|
let connection = yield* work.describe(root).pipe(flatMap((value) => try_({
|
|
1270
516
|
try: () => parseWorkConnection(value),
|
|
1271
517
|
catch: failure$3(`Adapter ${selected.work.id} returned an invalid work connection.`)
|
|
@@ -1315,6 +561,75 @@ function adapterRegistryLayer(project, builtIns) {
|
|
|
1315
561
|
}));
|
|
1316
562
|
}
|
|
1317
563
|
//#endregion
|
|
564
|
+
//#region app/server/attachments.ts
|
|
565
|
+
/** Images that belong to tasks, such as a comment's screenshot, kept under their content's hash. */
|
|
566
|
+
var Attachments = class extends Service()("bud/Attachments") {};
|
|
567
|
+
var hashPattern = /^[a-f0-9]{64}$/;
|
|
568
|
+
/**
|
|
569
|
+
* Whether bytes are a JPEG of a size a task keeps.
|
|
570
|
+
* @param bytes - The upload.
|
|
571
|
+
* @returns Whether it is acceptable.
|
|
572
|
+
*/
|
|
573
|
+
function acceptableAttachment(bytes) {
|
|
574
|
+
return bytes.length > 3 && bytes.length <= 2097152 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
575
|
+
}
|
|
576
|
+
async function sha256(bytes) {
|
|
577
|
+
let digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
|
|
578
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Attachments in a store, under `attachments/<hash>.jpg`.
|
|
582
|
+
* @param store - The directory or host store.
|
|
583
|
+
* @returns The attachments layer.
|
|
584
|
+
*/
|
|
585
|
+
function attachmentsLayer(store) {
|
|
586
|
+
let failed = (message) => (cause) => new WorkError({
|
|
587
|
+
reason: "storage",
|
|
588
|
+
message,
|
|
589
|
+
cause
|
|
590
|
+
});
|
|
591
|
+
return succeed$1(Attachments, {
|
|
592
|
+
put: (bytes) => gen(function* () {
|
|
593
|
+
if (!acceptableAttachment(bytes)) return yield* new WorkError({
|
|
594
|
+
reason: "input",
|
|
595
|
+
message: "Attachments are JPEG images of at most 2 MB."
|
|
596
|
+
});
|
|
597
|
+
let hash = yield* tryPromise({
|
|
598
|
+
try: () => sha256(bytes),
|
|
599
|
+
catch: failed("Could not read the image.")
|
|
600
|
+
});
|
|
601
|
+
yield* tryPromise({
|
|
602
|
+
try: () => store.write(`attachments/${hash}.jpg`, bytes),
|
|
603
|
+
catch: failed("Could not keep the image.")
|
|
604
|
+
});
|
|
605
|
+
return hash;
|
|
606
|
+
}),
|
|
607
|
+
get: (hash) => hashPattern.test(hash) ? tryPromise({
|
|
608
|
+
try: async () => await store.read(`attachments/${hash}.jpg`) ?? null,
|
|
609
|
+
catch: failed("Could not read the image.")
|
|
610
|
+
}) : succeed(null)
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* A directory as an attachment store, such as the project's cache.
|
|
615
|
+
* @param directory - Where the files go.
|
|
616
|
+
* @returns The store.
|
|
617
|
+
*/
|
|
618
|
+
function directoryAttachmentStore(directory) {
|
|
619
|
+
return {
|
|
620
|
+
async read(key) {
|
|
621
|
+
return readFile(join(directory, key)).then((bytes) => new Uint8Array(bytes), () => void 0);
|
|
622
|
+
},
|
|
623
|
+
async write(key, bytes) {
|
|
624
|
+
let path = join(directory, key);
|
|
625
|
+
await mkdir(dirname(path), { recursive: true });
|
|
626
|
+
let staged = `${path}.${process.pid}.tmp`;
|
|
627
|
+
await writeFile(staged, bytes);
|
|
628
|
+
await rename(staged, path);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
1318
633
|
//#region ../live/src/server/hub.ts
|
|
1319
634
|
/**
|
|
1320
635
|
* Orders every published change into one stream per companion. Clients resume from their
|
|
@@ -1646,7 +961,7 @@ function publishDiffs(store, read, diff, external, quiet) {
|
|
|
1646
961
|
pending = true;
|
|
1647
962
|
changes++;
|
|
1648
963
|
})));
|
|
1649
|
-
yield* merge(make$
|
|
964
|
+
yield* merge(make$1(void 0), merge(changed, activity.requests)).pipe(debounce(quiet), runForEach(() => pending ? andThen(sync(() => {
|
|
1650
965
|
pending = false;
|
|
1651
966
|
}), check) : void_$1), forkScoped);
|
|
1652
967
|
});
|
|
@@ -1661,13 +976,1426 @@ function publishDiffs(store, read, diff, external, quiet) {
|
|
|
1661
976
|
function storeLiveLayer(root) {
|
|
1662
977
|
return effectDiscard(gen(function* () {
|
|
1663
978
|
let registry = yield* AdapterRegistry;
|
|
1664
|
-
let reads = yield* make$
|
|
979
|
+
let reads = yield* make$2(1);
|
|
1665
980
|
yield* publishDiffs("work", reads.withPermit(readWorkspace(root, parseWorkQuery({}))), diffWorkspaces, registry.work?.watch?.(root) ?? empty$1, "250 millis");
|
|
1666
981
|
let threads = registry.threads;
|
|
1667
982
|
if (threads) yield* publishDiffs("threads", reads.withPermit(threads.list(root, {}).pipe(map(parseThreadSnapshot))), diffThreads, threads.watch?.(root) ?? empty$1, "400 millis");
|
|
1668
983
|
}));
|
|
1669
984
|
}
|
|
1670
985
|
//#endregion
|
|
986
|
+
//#region ../factory/src/server/errors.ts
|
|
987
|
+
var RunErrorBase = TaggedError("RunError");
|
|
988
|
+
/** A sandbox or agent failure; `retryable` when trying the same step again may work. */
|
|
989
|
+
var RunError = class extends RunErrorBase {};
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region ../factory/src/server/executor.ts
|
|
992
|
+
/** The project's run executor, or null when it has no sandbox to run in. */
|
|
993
|
+
var RunExecutor = class extends Service()("@bud.sh/factory/RunExecutor") {};
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region ../factory/src/server/agent-token.ts
|
|
996
|
+
var PURPOSE = "bud-tasks-read";
|
|
997
|
+
function sign(secret, body) {
|
|
998
|
+
return createHmac("sha256", secret).update(`${PURPOSE}:${body}`).digest("base64url");
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* The token a run's agent reads the task database with: its task and run, signed.
|
|
1002
|
+
* @param secret - The server's agent secret.
|
|
1003
|
+
* @param run - The run's id and its task's.
|
|
1004
|
+
* @returns The token.
|
|
1005
|
+
*/
|
|
1006
|
+
function agentToken(secret, run) {
|
|
1007
|
+
let body = Buffer.from(JSON.stringify([run.taskId, run.id])).toString("base64url");
|
|
1008
|
+
return `${body}.${sign(secret, body)}`;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Reads a token an agent sent, if this server signed it.
|
|
1012
|
+
* @param secret - The server's agent secret.
|
|
1013
|
+
* @param token - What the agent sent.
|
|
1014
|
+
* @returns Its task and run, or null for a token this server didn't sign.
|
|
1015
|
+
*/
|
|
1016
|
+
function readAgentToken(secret, token) {
|
|
1017
|
+
let [body, signature, ...rest] = token.split(".");
|
|
1018
|
+
if (!body || !signature || rest.length || token.length > 2e3) return null;
|
|
1019
|
+
let expected = Buffer.from(sign(secret, body));
|
|
1020
|
+
let given = Buffer.from(signature);
|
|
1021
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
|
|
1022
|
+
try {
|
|
1023
|
+
let value = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
1024
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string") return null;
|
|
1025
|
+
return {
|
|
1026
|
+
taskId: value[0],
|
|
1027
|
+
runId: value[1]
|
|
1028
|
+
};
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region ../factory/src/server/runs.ts
|
|
1035
|
+
var MAX_ATTEMPTS = 3;
|
|
1036
|
+
/**
|
|
1037
|
+
* Runs that can't start, for a project whose factory is off or whose store can't keep runs.
|
|
1038
|
+
* @param message - Why, as Start says it.
|
|
1039
|
+
* @returns Runs that refuse every action.
|
|
1040
|
+
*/
|
|
1041
|
+
function noRuns(message) {
|
|
1042
|
+
let no = fail$1(refused(message));
|
|
1043
|
+
return {
|
|
1044
|
+
get: () => succeed(null),
|
|
1045
|
+
start: () => no,
|
|
1046
|
+
changes: () => no,
|
|
1047
|
+
answer: () => no,
|
|
1048
|
+
approve: () => no,
|
|
1049
|
+
discard: () => no,
|
|
1050
|
+
retry: () => no,
|
|
1051
|
+
preview: () => succeed(null),
|
|
1052
|
+
terminal: () => succeed(null),
|
|
1053
|
+
touch: () => void_$1,
|
|
1054
|
+
agentTask: () => succeed(null),
|
|
1055
|
+
wake: () => succeed(null),
|
|
1056
|
+
checkMerged: () => void_$1,
|
|
1057
|
+
resume: void_$1
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function refused(message) {
|
|
1061
|
+
return new WorkError({
|
|
1062
|
+
reason: "refused",
|
|
1063
|
+
message
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* A project's runs, with background work tied to the surrounding scope.
|
|
1068
|
+
* @param options - The project, its task store, the executor and how to tell views about changes.
|
|
1069
|
+
* @returns The runs; their background fibers stop with the scope, the sandboxes don't.
|
|
1070
|
+
*/
|
|
1071
|
+
function makeRuns(options) {
|
|
1072
|
+
return gen(function* () {
|
|
1073
|
+
let scope$2 = yield* scope;
|
|
1074
|
+
let { root, factory, work, executor } = options;
|
|
1075
|
+
let now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
1076
|
+
let newId = options.id ?? (() => crypto.randomUUID());
|
|
1077
|
+
let locks = /* @__PURE__ */ new Map();
|
|
1078
|
+
let following = /* @__PURE__ */ new Set();
|
|
1079
|
+
let sleepAfter = options.sleepAfterMs === void 0 ? 3e5 : options.sleepAfterMs;
|
|
1080
|
+
let dozes = /* @__PURE__ */ new Map();
|
|
1081
|
+
let wakeLocks = /* @__PURE__ */ new Map();
|
|
1082
|
+
let underway = /* @__PURE__ */ new Set();
|
|
1083
|
+
let owned = (runId, step) => suspend(() => {
|
|
1084
|
+
if (underway.has(runId)) return succeed(null);
|
|
1085
|
+
underway.add(runId);
|
|
1086
|
+
return step.pipe(ensuring(sync(() => underway.delete(runId))));
|
|
1087
|
+
});
|
|
1088
|
+
let readData = work.readItemData;
|
|
1089
|
+
let writeData = work.writeItemData;
|
|
1090
|
+
if (!readData || !writeData) return noRuns("The task store can’t keep runs; it needs item data support.");
|
|
1091
|
+
let readItem = readData;
|
|
1092
|
+
let writeItem = writeData;
|
|
1093
|
+
function lock(taskId) {
|
|
1094
|
+
let found = locks.get(taskId);
|
|
1095
|
+
if (!found) locks.set(taskId, found = makeUnsafe$1(1));
|
|
1096
|
+
return found;
|
|
1097
|
+
}
|
|
1098
|
+
function background(effect) {
|
|
1099
|
+
return asVoid(forkIn(ignore(effect), scope$2));
|
|
1100
|
+
}
|
|
1101
|
+
let read = (taskId) => readItem(root, taskId, "budRun").pipe(flatMap(({ value, revision }) => try_({
|
|
1102
|
+
try: () => ({
|
|
1103
|
+
run: value === null ? null : parseRunRecord(value),
|
|
1104
|
+
revision
|
|
1105
|
+
}),
|
|
1106
|
+
catch: (cause) => new WorkError({
|
|
1107
|
+
reason: "storage",
|
|
1108
|
+
message: "The task’s run is unreadable.",
|
|
1109
|
+
cause
|
|
1110
|
+
})
|
|
1111
|
+
})));
|
|
1112
|
+
function schema() {
|
|
1113
|
+
return flatMap(work.schema(root), (value) => try_({
|
|
1114
|
+
try: () => parseWorkSchema(value),
|
|
1115
|
+
catch: (cause) => new WorkError({
|
|
1116
|
+
reason: "storage",
|
|
1117
|
+
message: "Invalid task schema.",
|
|
1118
|
+
cause
|
|
1119
|
+
})
|
|
1120
|
+
}));
|
|
1121
|
+
}
|
|
1122
|
+
function setStatus(taskId, status, revision) {
|
|
1123
|
+
return gen(function* () {
|
|
1124
|
+
let current = yield* schema();
|
|
1125
|
+
let field = fieldByRole(current, "status");
|
|
1126
|
+
if (!field || !revision) return;
|
|
1127
|
+
let values = { [field.id]: factory.statuses[status] };
|
|
1128
|
+
yield* work.update(root, taskId, values, revision).pipe(catchIf((error) => error.reason === "conflict", () => flatMap(read(taskId), (fresh) => fresh.revision ? work.update(root, taskId, values, fresh.revision) : void_$1)));
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Reads the run, decides the step and saves it, again if the task changed meanwhile.
|
|
1133
|
+
* @param taskId - The task.
|
|
1134
|
+
* @param decide - The step from the stored run.
|
|
1135
|
+
* @returns The saved step.
|
|
1136
|
+
*/
|
|
1137
|
+
function transact(taskId, decide) {
|
|
1138
|
+
return lock(taskId).withPermit(gen(function* () {
|
|
1139
|
+
for (let attempt = 1;; attempt++) {
|
|
1140
|
+
let { run, revision } = yield* read(taskId);
|
|
1141
|
+
if (!revision) return yield* new WorkError({
|
|
1142
|
+
reason: "storage",
|
|
1143
|
+
message: "The task has no revision."
|
|
1144
|
+
});
|
|
1145
|
+
let step = yield* try_({
|
|
1146
|
+
try: () => decide(run),
|
|
1147
|
+
catch: (cause) => cause instanceof RunTransitionError ? refused(cause.message) : new WorkError({
|
|
1148
|
+
reason: "input",
|
|
1149
|
+
message: "Invalid run step.",
|
|
1150
|
+
cause
|
|
1151
|
+
})
|
|
1152
|
+
});
|
|
1153
|
+
let saved = yield* writeItem(root, taskId, "budRun", step.run, revision).pipe(map((result) => result.revision), catchIf((error) => error.reason === "conflict" && attempt < MAX_ATTEMPTS, () => void_$1));
|
|
1154
|
+
if (saved === void 0) continue;
|
|
1155
|
+
if (step.status) yield* setStatus(taskId, step.status, saved);
|
|
1156
|
+
yield* options.changed;
|
|
1157
|
+
return step;
|
|
1158
|
+
}
|
|
1159
|
+
}));
|
|
1160
|
+
}
|
|
1161
|
+
function report(taskId, event) {
|
|
1162
|
+
return transact(taskId, (run) => {
|
|
1163
|
+
if (!run) throw new RunTransitionError(null, event.type);
|
|
1164
|
+
return advanceRun(run, event, now());
|
|
1165
|
+
}).pipe(flatMap((step) => perform(taskId, step.effects)));
|
|
1166
|
+
}
|
|
1167
|
+
function fail(taskId, error) {
|
|
1168
|
+
return report(taskId, {
|
|
1169
|
+
type: "fail",
|
|
1170
|
+
message: error.message,
|
|
1171
|
+
retryable: error.retryable
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
function task(taskId) {
|
|
1175
|
+
return gen(function* () {
|
|
1176
|
+
let current = yield* schema();
|
|
1177
|
+
let detail = yield* flatMap(work.get(root, taskId), (value) => try_({
|
|
1178
|
+
try: () => parseWorkDetail(value, current),
|
|
1179
|
+
catch: (cause) => new WorkError({
|
|
1180
|
+
reason: "storage",
|
|
1181
|
+
message: "Invalid task.",
|
|
1182
|
+
cause
|
|
1183
|
+
})
|
|
1184
|
+
}));
|
|
1185
|
+
let text = (role) => {
|
|
1186
|
+
let value = valueByRole(detail.item, current, role);
|
|
1187
|
+
return typeof value === "string" ? value : "";
|
|
1188
|
+
};
|
|
1189
|
+
let images = options.images ? yield* options.images(taskId) : [];
|
|
1190
|
+
return {
|
|
1191
|
+
id: taskId,
|
|
1192
|
+
title: text("title") || taskId,
|
|
1193
|
+
description: text("description"),
|
|
1194
|
+
...images.length ? { images } : {}
|
|
1195
|
+
};
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
function follow(taskId, run) {
|
|
1199
|
+
if (!executor || following.has(run.id)) return void_$1;
|
|
1200
|
+
following.add(run.id);
|
|
1201
|
+
return background(executor.follow(run).pipe(runForEach((event) => report(taskId, event)), catchTag("RunError", (error) => fail(taskId, error)), ensuring(sync(() => following.delete(run.id)))));
|
|
1202
|
+
}
|
|
1203
|
+
function provision(taskId, runId) {
|
|
1204
|
+
if (underway.has(runId)) return void_$1;
|
|
1205
|
+
underway.add(runId);
|
|
1206
|
+
return gen(function* () {
|
|
1207
|
+
if (!executor) return;
|
|
1208
|
+
let { run } = yield* read(taskId);
|
|
1209
|
+
if (!run || run.id !== runId || run.state !== "provisioning") return;
|
|
1210
|
+
let started = run;
|
|
1211
|
+
yield* report(taskId, {
|
|
1212
|
+
type: "ready",
|
|
1213
|
+
...yield* executor.provision(started, yield* task(taskId), (sandbox) => ignore(report(taskId, {
|
|
1214
|
+
type: "created",
|
|
1215
|
+
sandbox
|
|
1216
|
+
})))
|
|
1217
|
+
});
|
|
1218
|
+
let { run: working } = yield* read(taskId);
|
|
1219
|
+
if (working?.state === "working") yield* follow(taskId, working);
|
|
1220
|
+
}).pipe(catchTag("RunError", (error) => fail(taskId, error)), ensuring(sync(() => underway.delete(runId))));
|
|
1221
|
+
}
|
|
1222
|
+
function push(taskId) {
|
|
1223
|
+
return gen(function* () {
|
|
1224
|
+
if (!executor) return;
|
|
1225
|
+
let { run } = yield* read(taskId);
|
|
1226
|
+
if (!run || run.state !== "pushing") return;
|
|
1227
|
+
if (run.asleep) {
|
|
1228
|
+
yield* wake(taskId).pipe(mapError((cause) => new RunError({
|
|
1229
|
+
message: cause.message,
|
|
1230
|
+
retryable: true
|
|
1231
|
+
})));
|
|
1232
|
+
run = (yield* read(taskId)).run ?? run;
|
|
1233
|
+
}
|
|
1234
|
+
let commit = yield* owned(run.id, executor.push(run));
|
|
1235
|
+
if (!commit) return;
|
|
1236
|
+
yield* report(taskId, {
|
|
1237
|
+
type: "pushed",
|
|
1238
|
+
commit
|
|
1239
|
+
});
|
|
1240
|
+
let { run: pushed } = yield* read(taskId);
|
|
1241
|
+
if (pushed?.state === "pushed" && options.onPushed) yield* ignore(options.onPushed(pushed));
|
|
1242
|
+
}).pipe(catchTag("RunError", (error) => fail(taskId, error)));
|
|
1243
|
+
}
|
|
1244
|
+
function doze(taskId) {
|
|
1245
|
+
if (!executor || sleepAfter === null) return void_$1;
|
|
1246
|
+
let generation = (dozes.get(taskId) ?? 0) + 1;
|
|
1247
|
+
dozes.set(taskId, generation);
|
|
1248
|
+
return background(gen(function* () {
|
|
1249
|
+
yield* sleep(`${sleepAfter} millis`);
|
|
1250
|
+
if (dozes.get(taskId) !== generation) return;
|
|
1251
|
+
dozes.delete(taskId);
|
|
1252
|
+
yield* perform(taskId, (yield* transact(taskId, existing({ type: "sleep" }))).effects);
|
|
1253
|
+
}));
|
|
1254
|
+
}
|
|
1255
|
+
function wake(taskId) {
|
|
1256
|
+
let found = wakeLocks.get(taskId);
|
|
1257
|
+
if (!found) wakeLocks.set(taskId, found = makeUnsafe$1(1));
|
|
1258
|
+
return found.withPermit(gen(function* () {
|
|
1259
|
+
dozes.set(taskId, (dozes.get(taskId) ?? 0) + 1);
|
|
1260
|
+
let { run } = yield* read(taskId);
|
|
1261
|
+
if (!run || !run.asleep || !executor) return run;
|
|
1262
|
+
if (executor.resume) yield* executor.resume(run).pipe(mapError((cause) => new WorkError({
|
|
1263
|
+
reason: cause.retryable ? "unavailable" : "refused",
|
|
1264
|
+
message: cause.message,
|
|
1265
|
+
cause
|
|
1266
|
+
})));
|
|
1267
|
+
let step = yield* transact(taskId, existing({ type: "wake" }));
|
|
1268
|
+
if (RUN_WAITING_STATES.includes(step.run.state)) yield* doze(taskId);
|
|
1269
|
+
return step.run;
|
|
1270
|
+
}));
|
|
1271
|
+
}
|
|
1272
|
+
function perform(taskId, effects) {
|
|
1273
|
+
return gen(function* () {
|
|
1274
|
+
for (let effect of effects) {
|
|
1275
|
+
let stored = (yield* read(taskId)).run;
|
|
1276
|
+
if (!stored || !executor) return;
|
|
1277
|
+
let run = stored;
|
|
1278
|
+
let awake = map(orElseSucceed(wake(taskId), () => run), (woken) => woken ?? run);
|
|
1279
|
+
switch (effect.type) {
|
|
1280
|
+
case "provision":
|
|
1281
|
+
yield* background(provision(taskId, run.id));
|
|
1282
|
+
break;
|
|
1283
|
+
case "send":
|
|
1284
|
+
run = yield* awake;
|
|
1285
|
+
yield* executor.send(run, effect.message).pipe(flatMap(() => follow(taskId, run)), catchTag("RunError", (error) => fail(taskId, error)));
|
|
1286
|
+
break;
|
|
1287
|
+
case "push":
|
|
1288
|
+
yield* background(push(taskId));
|
|
1289
|
+
break;
|
|
1290
|
+
case "stop":
|
|
1291
|
+
yield* ignore(executor.stop(run));
|
|
1292
|
+
break;
|
|
1293
|
+
case "destroy":
|
|
1294
|
+
yield* ignore(executor.destroy(run));
|
|
1295
|
+
break;
|
|
1296
|
+
case "doze":
|
|
1297
|
+
yield* doze(taskId);
|
|
1298
|
+
break;
|
|
1299
|
+
case "resume":
|
|
1300
|
+
if (effect.state !== "provisioning") run = yield* awake;
|
|
1301
|
+
if (effect.state === "provisioning") yield* background(provision(taskId, run.id));
|
|
1302
|
+
else if (effect.state === "pushing") yield* background(push(taskId));
|
|
1303
|
+
else if (effect.state === "working" || effect.state === "changes") yield* follow(taskId, run);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
function act(taskId, decide) {
|
|
1309
|
+
return gen(function* () {
|
|
1310
|
+
let step = yield* transact(taskId, decide);
|
|
1311
|
+
yield* perform(taskId, step.effects);
|
|
1312
|
+
return step.run;
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
function existing(event) {
|
|
1316
|
+
return (run) => {
|
|
1317
|
+
if (!run) throw new RunTransitionError(null, event.type);
|
|
1318
|
+
return advanceRun(run, event, now());
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
get: (taskId) => map(read(taskId), (result) => result.run),
|
|
1323
|
+
start: (taskId, by) => gen(function* () {
|
|
1324
|
+
if (!factory.sandbox) return yield* refused("Choose a sandbox in factory.sandbox before starting tasks.");
|
|
1325
|
+
if (!executor) return yield* refused(options.unavailable ?? `The sandbox ${factory.sandbox} isn’t available here.`);
|
|
1326
|
+
let { title } = yield* task(taskId);
|
|
1327
|
+
return yield* act(taskId, (previous) => startRun(previous, {
|
|
1328
|
+
id: newId(),
|
|
1329
|
+
taskId,
|
|
1330
|
+
title,
|
|
1331
|
+
branchPrefix: factory.branchPrefix,
|
|
1332
|
+
base: "main",
|
|
1333
|
+
provider: factory.sandbox,
|
|
1334
|
+
harness: factory.agent.harness,
|
|
1335
|
+
model: factory.agent.model,
|
|
1336
|
+
by
|
|
1337
|
+
}, now()));
|
|
1338
|
+
}),
|
|
1339
|
+
changes: (taskId, message, by) => act(taskId, existing({
|
|
1340
|
+
type: "changes",
|
|
1341
|
+
message,
|
|
1342
|
+
by
|
|
1343
|
+
})),
|
|
1344
|
+
answer: (taskId, message, by) => act(taskId, existing({
|
|
1345
|
+
type: "answer",
|
|
1346
|
+
message,
|
|
1347
|
+
by
|
|
1348
|
+
})),
|
|
1349
|
+
approve: (taskId, by) => act(taskId, existing({
|
|
1350
|
+
type: "approve",
|
|
1351
|
+
by
|
|
1352
|
+
})),
|
|
1353
|
+
discard: (taskId) => act(taskId, existing({ type: "discard" })),
|
|
1354
|
+
retry: (taskId) => act(taskId, existing({ type: "retry" })),
|
|
1355
|
+
wake,
|
|
1356
|
+
preview: (taskId) => gen(function* () {
|
|
1357
|
+
let { run } = yield* read(taskId);
|
|
1358
|
+
let preview = executor?.preview;
|
|
1359
|
+
if (!run || !preview || ![
|
|
1360
|
+
"working",
|
|
1361
|
+
"changes",
|
|
1362
|
+
"review",
|
|
1363
|
+
"needs_context",
|
|
1364
|
+
"pushing"
|
|
1365
|
+
].includes(run.state)) return null;
|
|
1366
|
+
if (run.asleep) run = (yield* wake(taskId)) ?? run;
|
|
1367
|
+
return yield* preview(run).pipe(mapError((cause) => new WorkError({
|
|
1368
|
+
reason: "unavailable",
|
|
1369
|
+
message: cause.message,
|
|
1370
|
+
cause
|
|
1371
|
+
})));
|
|
1372
|
+
}),
|
|
1373
|
+
terminal: (taskId) => gen(function* () {
|
|
1374
|
+
let { run } = yield* read(taskId);
|
|
1375
|
+
let terminal = executor?.terminal;
|
|
1376
|
+
if (!run || !terminal || !run.sandbox || !runActive(run)) return null;
|
|
1377
|
+
if (run.asleep) run = (yield* wake(taskId)) ?? run;
|
|
1378
|
+
return yield* terminal(run).pipe(mapError((cause) => new WorkError({
|
|
1379
|
+
reason: "unavailable",
|
|
1380
|
+
message: cause.message,
|
|
1381
|
+
cause
|
|
1382
|
+
})));
|
|
1383
|
+
}),
|
|
1384
|
+
agentTask: (token) => gen(function* () {
|
|
1385
|
+
let claim = executor?.readToken?.(token);
|
|
1386
|
+
if (!claim) return null;
|
|
1387
|
+
let { run } = yield* read(claim.taskId);
|
|
1388
|
+
return run && run.id === claim.runId && runActive(run) ? claim.taskId : null;
|
|
1389
|
+
}),
|
|
1390
|
+
touch: (taskId) => gen(function* () {
|
|
1391
|
+
let { run } = yield* read(taskId);
|
|
1392
|
+
if (run && RUN_WAITING_STATES.includes(run.state) && !run.asleep && run.sandbox) yield* doze(taskId);
|
|
1393
|
+
}),
|
|
1394
|
+
checkMerged: (merged) => gen(function* () {
|
|
1395
|
+
let snapshot = yield* work.list(root, { limit: 1e3 });
|
|
1396
|
+
for (let item of snapshot.items) {
|
|
1397
|
+
if (item.run?.state !== "pushed") continue;
|
|
1398
|
+
let { run } = yield* read(item.id);
|
|
1399
|
+
if (run?.state !== "pushed" || !run.pushed || !(yield* merged(run.pushed))) continue;
|
|
1400
|
+
let step = yield* transact(item.id, existing({ type: "merged" })).pipe(catchIf((error) => error.reason === "refused", () => succeed(null)));
|
|
1401
|
+
if (!step) continue;
|
|
1402
|
+
yield* perform(item.id, step.effects);
|
|
1403
|
+
if (options.onMerged) yield* ignore(options.onMerged(step.run));
|
|
1404
|
+
}
|
|
1405
|
+
}),
|
|
1406
|
+
resume: gen(function* () {
|
|
1407
|
+
if (!executor) return;
|
|
1408
|
+
let current = yield* schema();
|
|
1409
|
+
let field = fieldByRole(current, "status");
|
|
1410
|
+
let candidates = (yield* work.list(root, { limit: 1e3 })).items.filter((item) => {
|
|
1411
|
+
let status = field ? item.values[field.id] : void 0;
|
|
1412
|
+
return [factory.statuses.inProgress, factory.statuses.needsApproval].includes(String(status));
|
|
1413
|
+
});
|
|
1414
|
+
for (let item of candidates) {
|
|
1415
|
+
let { run } = yield* read(item.id);
|
|
1416
|
+
if (!runActive(run) || underway.has(run.id)) continue;
|
|
1417
|
+
if (RUN_WAITING_STATES.includes(run.state) && !run.asleep && run.sandbox) {
|
|
1418
|
+
if (!dozes.has(item.id)) yield* doze(item.id);
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
if (run.state === "provisioning") yield* background(report(item.id, {
|
|
1422
|
+
type: "fail",
|
|
1423
|
+
message: "Bud restarted while setting up the sandbox. Retry to set it up again.",
|
|
1424
|
+
retryable: true
|
|
1425
|
+
}));
|
|
1426
|
+
else if (run.state === "working" || run.state === "changes") yield* follow(item.id, run);
|
|
1427
|
+
else if (run.state === "pushing") yield* background(push(item.id));
|
|
1428
|
+
}
|
|
1429
|
+
})
|
|
1430
|
+
};
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
//#endregion
|
|
1434
|
+
//#region ../factory/src/server/sandbox.ts
|
|
1435
|
+
/**
|
|
1436
|
+
* A clone URL Git can use with a token: SSH remotes become their HTTPS form.
|
|
1437
|
+
* @param url - The project's remote URL.
|
|
1438
|
+
* @returns The HTTPS URL, without credentials.
|
|
1439
|
+
*/
|
|
1440
|
+
function httpsRemote(url) {
|
|
1441
|
+
let ssh = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?\/?$/.exec(url.trim());
|
|
1442
|
+
if (ssh) return `https://${ssh[1]}/${ssh[2]}.git`;
|
|
1443
|
+
let parsed = new URL(url.trim());
|
|
1444
|
+
parsed.username = "";
|
|
1445
|
+
parsed.password = "";
|
|
1446
|
+
return parsed.href;
|
|
1447
|
+
}
|
|
1448
|
+
//#endregion
|
|
1449
|
+
//#region ../factory/src/server/sandbox-executor.ts
|
|
1450
|
+
var DEFAULT_TERMINAL_PORT = 4097;
|
|
1451
|
+
var DEFAULT_PREVIEW_PORT = 4098;
|
|
1452
|
+
var AGENT_START = 6e4;
|
|
1453
|
+
function agentGit(remote) {
|
|
1454
|
+
return {
|
|
1455
|
+
GIT_CONFIG_COUNT: "1",
|
|
1456
|
+
GIT_CONFIG_KEY_0: `remote.${remote}.pushurl`,
|
|
1457
|
+
GIT_CONFIG_VALUE_0: "bud-agents-do-not-push://"
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* The prompt a run starts with: the task, the project's instructions and the rules every run keeps.
|
|
1462
|
+
* @param task - The task.
|
|
1463
|
+
* @param run - The run, for its branch.
|
|
1464
|
+
* @param instructions - The project's instructions, if any.
|
|
1465
|
+
* @returns The prompt text.
|
|
1466
|
+
*/
|
|
1467
|
+
function runPrompt(task, run, instructions) {
|
|
1468
|
+
return [
|
|
1469
|
+
`Finish this task in the project checked out here, on the branch ${run.branch}.`,
|
|
1470
|
+
"",
|
|
1471
|
+
`# ${task.title}`,
|
|
1472
|
+
"",
|
|
1473
|
+
task.description.trim() || "(No description.)",
|
|
1474
|
+
...task.images?.length ? ["", `Attached: ${task.images.map((image) => image.name).join(", ")}, the page as the comment saw it, with its point marked.`] : [],
|
|
1475
|
+
...instructions?.trim() ? [
|
|
1476
|
+
"",
|
|
1477
|
+
"# Project instructions",
|
|
1478
|
+
"",
|
|
1479
|
+
instructions.trim()
|
|
1480
|
+
] : [],
|
|
1481
|
+
"",
|
|
1482
|
+
"# How to work",
|
|
1483
|
+
"",
|
|
1484
|
+
"- Stay on the current branch; commit your work with clear messages when it is done.",
|
|
1485
|
+
"- Run the project’s quick checks if there are any, and fix what you broke.",
|
|
1486
|
+
"- Never push, never change git remotes, credentials or the git configuration.",
|
|
1487
|
+
"- End with a short summary: what changed, and how a reviewer can see it.",
|
|
1488
|
+
`- If you can't finish without something only a person knows, such as a decision, a missing text or an account, don't guess: stop, and end your reply with a last line \`${NEEDS_CONTEXT_MARKER} <your question>\`. You'll get the answer in this session.`
|
|
1489
|
+
].join("\n");
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Git options and environment that give one command a push token: a credential helper answering
|
|
1493
|
+
* from the command's own environment, so the token is never written anywhere or seen by the agent.
|
|
1494
|
+
* @param token - The token, or null to use Git's own credentials.
|
|
1495
|
+
* @param username - The user name the Git host expects with a token.
|
|
1496
|
+
* @returns The options to put after `git`, and the command's environment.
|
|
1497
|
+
*/
|
|
1498
|
+
function pushCredential(token, username) {
|
|
1499
|
+
if (!token) return {
|
|
1500
|
+
auth: "",
|
|
1501
|
+
env: {}
|
|
1502
|
+
};
|
|
1503
|
+
return {
|
|
1504
|
+
auth: `-c credential.helper= -c 'credential.helper=!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$BUD_PUSH_USER" "$BUD_PUSH_TOKEN"; }; f' `,
|
|
1505
|
+
env: {
|
|
1506
|
+
BUD_PUSH_USER: username,
|
|
1507
|
+
BUD_PUSH_TOKEN: token
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Runs in sandboxes from a provider with a harness's agent, pushing the branch on approval.
|
|
1513
|
+
* @param options - The provider, the harness, the project, secrets and the factory config.
|
|
1514
|
+
* @returns The run executor, with the live preview of a run.
|
|
1515
|
+
*/
|
|
1516
|
+
function sandboxExecutor(options) {
|
|
1517
|
+
let { sandbox, factory, harness } = options;
|
|
1518
|
+
let fail = (message, retryable = true, cause) => new RunError({
|
|
1519
|
+
message,
|
|
1520
|
+
retryable,
|
|
1521
|
+
cause
|
|
1522
|
+
});
|
|
1523
|
+
let derive = (purpose, run) => createHmac("sha256", options.secret).update(`${purpose}:${run.id}`).digest("hex").slice(0, 32);
|
|
1524
|
+
let password = (run) => derive("bud-agent", run);
|
|
1525
|
+
let terminalPassword = (run) => derive("bud-terminal", run);
|
|
1526
|
+
let promise = (operation, message) => tryPromise({
|
|
1527
|
+
try: operation,
|
|
1528
|
+
catch: (cause) => cause instanceof RunError ? cause : fail(`${message}: ${cause instanceof Error ? cause.message : String(cause)}`, true, cause)
|
|
1529
|
+
});
|
|
1530
|
+
let agent = (run) => gen(function* () {
|
|
1531
|
+
if (!run.sandbox || !run.ports.agent) return yield* fail("The run has no agent yet.", false);
|
|
1532
|
+
let port = yield* sandbox.expose(run.sandbox, run.ports.agent);
|
|
1533
|
+
let { url, headers } = port.server ?? {
|
|
1534
|
+
url: port.url,
|
|
1535
|
+
headers: {}
|
|
1536
|
+
};
|
|
1537
|
+
return harness.connect({
|
|
1538
|
+
url,
|
|
1539
|
+
headers,
|
|
1540
|
+
password: password(run)
|
|
1541
|
+
}, options.fetch);
|
|
1542
|
+
});
|
|
1543
|
+
let head = (id) => map(sandbox.exec(id, "git rev-parse HEAD"), (result) => result.exitCode === 0 ? result.output.trim() : null);
|
|
1544
|
+
let live = (client, session, checkout) => suspend$1(() => {
|
|
1545
|
+
let controller = new AbortController();
|
|
1546
|
+
return fromAsyncIterable(client.follow(session, () => runPromise(orElseSucceed(head(checkout), () => null)), controller.signal), (cause) => fail(`Lost the agent: ${cause instanceof Error ? cause.message : String(cause)}`, true, cause)).pipe(ensuring$1(sync(() => controller.abort())));
|
|
1547
|
+
});
|
|
1548
|
+
let port = (id, preferred) => sandbox.freePort ? sandbox.freePort(id) : succeed(preferred);
|
|
1549
|
+
return {
|
|
1550
|
+
provision: (run, task, created) => gen(function* () {
|
|
1551
|
+
let id = yield* sandbox.create({
|
|
1552
|
+
repository: options.repository,
|
|
1553
|
+
ref: run.base.branch,
|
|
1554
|
+
branch: run.branch,
|
|
1555
|
+
setup: factory.setup,
|
|
1556
|
+
env: {
|
|
1557
|
+
...factory.dev?.env,
|
|
1558
|
+
...options.env
|
|
1559
|
+
},
|
|
1560
|
+
secrets: options.secrets ?? [],
|
|
1561
|
+
labels: {
|
|
1562
|
+
run: run.id,
|
|
1563
|
+
task: task.id
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
if (created) yield* created(id);
|
|
1567
|
+
let dev = null;
|
|
1568
|
+
if (factory.dev) {
|
|
1569
|
+
dev = yield* port(id, factory.dev.port);
|
|
1570
|
+
yield* sandbox.spawn(id, "dev", factory.dev.command.replaceAll("$PORT", String(dev)), { env: {
|
|
1571
|
+
PORT: String(dev),
|
|
1572
|
+
BUD_SANDBOX: "1",
|
|
1573
|
+
FORCE_COLOR: "1"
|
|
1574
|
+
} });
|
|
1575
|
+
}
|
|
1576
|
+
let preview = dev && options.page ? yield* gen(function* () {
|
|
1577
|
+
let proxy = yield* port(id, DEFAULT_PREVIEW_PORT);
|
|
1578
|
+
let directory = `"$(git rev-parse --absolute-git-dir)/bud-preview"`;
|
|
1579
|
+
let file = (name, content) => `cat > ${directory}/${name} <<'BUD_FILE_END'\n${content}\nBUD_FILE_END`;
|
|
1580
|
+
if ((yield* sandbox.exec(id, [
|
|
1581
|
+
`mkdir -p ${directory}`,
|
|
1582
|
+
file("proxy.py", "import http.client, os, socket, sys, threading\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\n\nUPSTREAM = int(sys.argv[2])\nFILES = os.environ.get('BUD_PREVIEW_FILES', '')\nPICKER = os.environ.get('BUD_PICKER_PATH', '')\nHOP = {'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade'}\n\n\ndef read(name):\n try:\n with open(os.path.join(FILES, name), 'rb') as file:\n return file.read()\n except OSError:\n return b''\n\n\nclass Handler(BaseHTTPRequestHandler):\n protocol_version = 'HTTP/1.1'\n\n def log_message(self, *args):\n pass\n\n def headers_for_upstream(self):\n headers = {}\n for key, value in self.headers.items():\n # The page's own address stays, as the dev server would see it without the proxy.\n if key.lower() in HOP or key.lower() == 'accept-encoding':\n continue\n headers[key] = value\n return headers\n\n def tunnel(self):\n upstream = socket.create_connection(('127.0.0.1', UPSTREAM))\n lines = ['%s %s HTTP/1.1' % (self.command, self.path)]\n for key, value in self.headers.items():\n lines.append('%s: %s' % (key, value))\n upstream.sendall(('\\r\\n'.join(lines) + '\\r\\n\\r\\n').encode('latin-1'))\n client = self.connection\n\n def pipe(source, target):\n try:\n while True:\n data = source.recv(65536)\n if not data:\n break\n target.sendall(data)\n except OSError:\n pass\n finally:\n for end in (source, target):\n try:\n end.shutdown(socket.SHUT_RDWR)\n except OSError:\n pass\n\n threading.Thread(target=pipe, args=(upstream, client), daemon=True).start()\n pipe(client, upstream)\n self.close_connection = True\n\n def forward(self):\n if self.headers.get('Upgrade'):\n return self.tunnel()\n length = int(self.headers.get('Content-Length') or 0)\n body = self.rfile.read(length) if length else None\n connection = http.client.HTTPConnection('127.0.0.1', UPSTREAM, timeout=300)\n try:\n connection.request(self.command, self.path, body=body, headers=self.headers_for_upstream())\n response = connection.getresponse()\n except OSError:\n self.send_response(502)\n self.send_header('Content-Length', '0')\n self.end_headers()\n return\n path = self.path.split('?')[0]\n # The picker Bud's page bridge imports, when the project serves none of its own; a dev\n # server may answer an unknown path with its page instead of a 404.\n script = 'javascript' in (response.getheader('Content-Type') or '')\n if PICKER and path == PICKER and (response.status != 200 or not script):\n response.read()\n data = read('picker.js')\n self.send_response(200)\n self.send_header('Content-Type', 'text/javascript')\n self.send_header('Content-Length', str(len(data)))\n self.end_headers()\n self.wfile.write(data)\n return\n html = (response.getheader('Content-Type') or '').startswith('text/html')\n self.send_response(response.status, response.reason)\n for key, value in response.getheaders():\n if key.lower() in HOP or key.lower() in ('content-length', 'server', 'date'):\n continue\n # A redirect to the dev server's own address stays on the page's.\n if key.lower() == 'location':\n for local in ('http://127.0.0.1:%d' % UPSTREAM, 'http://localhost:%d' % UPSTREAM):\n if value.startswith(local):\n value = value[len(local):] or '/'\n self.send_header(key, value)\n if html or self.command == 'HEAD':\n data = response.read()\n if html:\n script = b'<script type=\"module\">' + read('bridge.js') + b'<\/script>'\n head = data.lower().find(b'<head>')\n data = data[: head + 6] + script + data[head + 6 :] if head >= 0 else script + data\n self.send_header('Content-Length', str(len(data)))\n self.end_headers()\n if self.command != 'HEAD':\n self.wfile.write(data)\n return\n if response.status in (204, 304) or response.status < 200:\n self.send_header('Content-Length', '0')\n self.end_headers()\n return\n self.send_header('Transfer-Encoding', 'chunked')\n self.end_headers()\n try:\n while True:\n chunk = response.read1(65536) if hasattr(response, 'read1') else response.read(65536)\n if not chunk:\n break\n self.wfile.write(b'%x\\r\\n%s\\r\\n' % (len(chunk), chunk))\n self.wfile.flush()\n self.wfile.write(b'0\\r\\n\\r\\n')\n except OSError:\n self.close_connection = True\n\n do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_HEAD = forward\n\n\nif __name__ == '__main__':\n ThreadingHTTPServer.daemon_threads = True\n ThreadingHTTPServer(('127.0.0.1', int(sys.argv[1])), Handler).serve_forever()\n"),
|
|
1583
|
+
file("bridge.js", options.page.bridge),
|
|
1584
|
+
file("picker.js", options.page.picker)
|
|
1585
|
+
].join("\n"))).exitCode !== 0) return null;
|
|
1586
|
+
yield* sandbox.spawn(id, "preview", `BUD_PREVIEW_FILES=${directory} exec python3 ${directory}/proxy.py ${proxy} ${dev}`, { env: { BUD_PICKER_PATH: options.page.pickerPath } });
|
|
1587
|
+
return proxy;
|
|
1588
|
+
}).pipe(orElseSucceed(() => null)) : null;
|
|
1589
|
+
let agentPort = yield* port(id, harness.port);
|
|
1590
|
+
let started = {
|
|
1591
|
+
...run,
|
|
1592
|
+
sandbox: id,
|
|
1593
|
+
ports: {
|
|
1594
|
+
dev,
|
|
1595
|
+
agent: agentPort,
|
|
1596
|
+
...preview ? { preview } : {}
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
let tools = options.tools?.();
|
|
1600
|
+
let server = harness.start({
|
|
1601
|
+
port: agentPort,
|
|
1602
|
+
password: password(started),
|
|
1603
|
+
...tools ? { tools: {
|
|
1604
|
+
url: tools,
|
|
1605
|
+
token: agentToken(options.secret, {
|
|
1606
|
+
id: run.id,
|
|
1607
|
+
taskId: task.id
|
|
1608
|
+
})
|
|
1609
|
+
} } : {}
|
|
1610
|
+
});
|
|
1611
|
+
yield* sandbox.spawn(id, "agent", server.command, { env: {
|
|
1612
|
+
...server.env,
|
|
1613
|
+
...agentGit(factory.push.remote)
|
|
1614
|
+
} });
|
|
1615
|
+
let terminal = yield* gen(function* () {
|
|
1616
|
+
let bridgePort = yield* port(id, DEFAULT_TERMINAL_PORT);
|
|
1617
|
+
let file = `"$(git rev-parse --absolute-git-dir)/bud-terminal.py"`;
|
|
1618
|
+
if ((yield* sandbox.exec(id, `cat > ${file} <<'BUD_TERMINAL'\nimport base64, fcntl, json, os, pty, select, signal, struct, sys, termios, threading
|
|
1619
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
1620
|
+
|
|
1621
|
+
PASSWORD = os.environ.pop('BUD_TERMINAL_PASSWORD', '')
|
|
1622
|
+
LIMIT = 512 * 1024
|
|
1623
|
+
sessions = {}
|
|
1624
|
+
lock = threading.Lock()
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def clamp(value):
|
|
1628
|
+
return max(2, min(int(value), 1000))
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
class Session:
|
|
1632
|
+
def __init__(self, command, cols, rows, env):
|
|
1633
|
+
self.buffer = bytearray()
|
|
1634
|
+
self.start = 0
|
|
1635
|
+
self.done = False
|
|
1636
|
+
self.changed = threading.Condition()
|
|
1637
|
+
pid, fd = pty.fork()
|
|
1638
|
+
if pid == 0:
|
|
1639
|
+
# The size is set before the command starts, so it never reads an empty one.
|
|
1640
|
+
fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack('HHHH', clamp(rows), clamp(cols), 0, 0))
|
|
1641
|
+
os.environ.update(env)
|
|
1642
|
+
os.environ['TERM'] = 'xterm-256color'
|
|
1643
|
+
os.environ['COLORTERM'] = 'truecolor'
|
|
1644
|
+
os.execvp('sh', ['sh', '-c', command])
|
|
1645
|
+
self.pid = pid
|
|
1646
|
+
self.fd = fd
|
|
1647
|
+
self.resize(cols, rows)
|
|
1648
|
+
threading.Thread(target=self.read, daemon=True).start()
|
|
1649
|
+
|
|
1650
|
+
def read(self):
|
|
1651
|
+
while True:
|
|
1652
|
+
try:
|
|
1653
|
+
ready, _, _ = select.select([self.fd], [], [], 1)
|
|
1654
|
+
if not ready:
|
|
1655
|
+
continue
|
|
1656
|
+
data = os.read(self.fd, 65536)
|
|
1657
|
+
except OSError:
|
|
1658
|
+
data = b''
|
|
1659
|
+
with self.changed:
|
|
1660
|
+
if not data:
|
|
1661
|
+
self.done = True
|
|
1662
|
+
self.changed.notify_all()
|
|
1663
|
+
return
|
|
1664
|
+
self.buffer += data
|
|
1665
|
+
if len(self.buffer) > LIMIT:
|
|
1666
|
+
drop = len(self.buffer) - LIMIT
|
|
1667
|
+
del self.buffer[:drop]
|
|
1668
|
+
self.start += drop
|
|
1669
|
+
self.changed.notify_all()
|
|
1670
|
+
|
|
1671
|
+
def resize(self, cols, rows):
|
|
1672
|
+
fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack('HHHH', clamp(rows), clamp(cols), 0, 0))
|
|
1673
|
+
|
|
1674
|
+
def write(self, data):
|
|
1675
|
+
view = memoryview(data)
|
|
1676
|
+
while view:
|
|
1677
|
+
written = os.write(self.fd, view)
|
|
1678
|
+
view = view[written:]
|
|
1679
|
+
|
|
1680
|
+
def close(self):
|
|
1681
|
+
try:
|
|
1682
|
+
os.killpg(os.getpgid(self.pid), signal.SIGHUP)
|
|
1683
|
+
except OSError:
|
|
1684
|
+
pass
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1688
|
+
protocol_version = 'HTTP/1.1'
|
|
1689
|
+
|
|
1690
|
+
def log_message(self, *args):
|
|
1691
|
+
pass
|
|
1692
|
+
|
|
1693
|
+
def authorized(self):
|
|
1694
|
+
expected = 'Basic ' + base64.b64encode(('bud:' + PASSWORD).encode()).decode()
|
|
1695
|
+
if PASSWORD and self.headers.get('Authorization') == expected:
|
|
1696
|
+
return True
|
|
1697
|
+
self.send_response(401)
|
|
1698
|
+
self.send_header('Content-Length', '0')
|
|
1699
|
+
self.end_headers()
|
|
1700
|
+
return False
|
|
1701
|
+
|
|
1702
|
+
def reply(self, status, body=None):
|
|
1703
|
+
data = json.dumps(body).encode() if body is not None else b''
|
|
1704
|
+
self.send_response(status)
|
|
1705
|
+
self.send_header('Content-Type', 'application/json')
|
|
1706
|
+
self.send_header('Content-Length', str(len(data)))
|
|
1707
|
+
self.end_headers()
|
|
1708
|
+
self.wfile.write(data)
|
|
1709
|
+
|
|
1710
|
+
def body(self):
|
|
1711
|
+
length = int(self.headers.get('Content-Length') or 0)
|
|
1712
|
+
return self.rfile.read(length) if length else b''
|
|
1713
|
+
|
|
1714
|
+
def session(self, query):
|
|
1715
|
+
name = query.get('id', '')
|
|
1716
|
+
with lock:
|
|
1717
|
+
return sessions.get(name)
|
|
1718
|
+
|
|
1719
|
+
def query(self):
|
|
1720
|
+
from urllib.parse import parse_qs, urlparse
|
|
1721
|
+
url = urlparse(self.path)
|
|
1722
|
+
return url.path, {key: values[0] for key, values in parse_qs(url.query).items()}
|
|
1723
|
+
|
|
1724
|
+
def do_GET(self):
|
|
1725
|
+
if not self.authorized():
|
|
1726
|
+
return
|
|
1727
|
+
path, query = self.query()
|
|
1728
|
+
if path == '/health':
|
|
1729
|
+
return self.reply(200, {'ok': True})
|
|
1730
|
+
if path != '/stream':
|
|
1731
|
+
return self.reply(404)
|
|
1732
|
+
session = self.session(query)
|
|
1733
|
+
if not session:
|
|
1734
|
+
return self.reply(404)
|
|
1735
|
+
offset = int(query.get('from') or 0)
|
|
1736
|
+
self.send_response(200)
|
|
1737
|
+
self.send_header('Content-Type', 'text/event-stream')
|
|
1738
|
+
self.send_header('Cache-Control', 'no-store')
|
|
1739
|
+
self.send_header('Transfer-Encoding', 'chunked')
|
|
1740
|
+
self.end_headers()
|
|
1741
|
+
|
|
1742
|
+
def send(text):
|
|
1743
|
+
data = text.encode()
|
|
1744
|
+
self.wfile.write(b'%x\\r\\n%s\\r\\n' % (len(data), data))
|
|
1745
|
+
self.wfile.flush()
|
|
1746
|
+
|
|
1747
|
+
try:
|
|
1748
|
+
send(': connected\\n\\n')
|
|
1749
|
+
while True:
|
|
1750
|
+
with session.changed:
|
|
1751
|
+
end = session.start + len(session.buffer)
|
|
1752
|
+
while offset >= end and not session.done:
|
|
1753
|
+
if not session.changed.wait(15):
|
|
1754
|
+
break
|
|
1755
|
+
end = session.start + len(session.buffer)
|
|
1756
|
+
begin = max(offset, session.start)
|
|
1757
|
+
chunk = bytes(session.buffer[begin - session.start:end - session.start])
|
|
1758
|
+
done = session.done and begin >= end
|
|
1759
|
+
if chunk:
|
|
1760
|
+
send('id: %d\\ndata: %s\\n\\n' % (end, base64.b64encode(chunk).decode()))
|
|
1761
|
+
offset = end
|
|
1762
|
+
elif done:
|
|
1763
|
+
send('event: exit\\ndata: {}\\n\\n')
|
|
1764
|
+
break
|
|
1765
|
+
else:
|
|
1766
|
+
send(': keepalive\\n\\n')
|
|
1767
|
+
self.wfile.write(b'0\\r\\n\\r\\n')
|
|
1768
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1769
|
+
pass
|
|
1770
|
+
|
|
1771
|
+
def do_POST(self):
|
|
1772
|
+
if not self.authorized():
|
|
1773
|
+
return
|
|
1774
|
+
path, query = self.query()
|
|
1775
|
+
body = self.body()
|
|
1776
|
+
if path == '/open':
|
|
1777
|
+
spec = json.loads(body or b'{}')
|
|
1778
|
+
name = str(spec.get('id', ''))
|
|
1779
|
+
with lock:
|
|
1780
|
+
existing = sessions.get(name)
|
|
1781
|
+
if existing and not existing.done:
|
|
1782
|
+
return self.reply(200, {'opened': False})
|
|
1783
|
+
env = spec.get('env') or {}
|
|
1784
|
+
sessions[name] = Session(
|
|
1785
|
+
str(spec['command']),
|
|
1786
|
+
spec.get('cols', 80),
|
|
1787
|
+
spec.get('rows', 24),
|
|
1788
|
+
{str(key): str(value) for key, value in env.items()},
|
|
1789
|
+
)
|
|
1790
|
+
return self.reply(200, {'opened': True})
|
|
1791
|
+
session = self.session(query)
|
|
1792
|
+
if not session:
|
|
1793
|
+
return self.reply(404)
|
|
1794
|
+
if path == '/input':
|
|
1795
|
+
session.write(body)
|
|
1796
|
+
return self.reply(204)
|
|
1797
|
+
if path == '/resize':
|
|
1798
|
+
size = json.loads(body or b'{}')
|
|
1799
|
+
session.resize(size.get('cols', 80), size.get('rows', 24))
|
|
1800
|
+
return self.reply(204)
|
|
1801
|
+
if path == '/close':
|
|
1802
|
+
session.close()
|
|
1803
|
+
return self.reply(204)
|
|
1804
|
+
return self.reply(404)
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
if __name__ == '__main__':
|
|
1808
|
+
ThreadingHTTPServer.daemon_threads = True
|
|
1809
|
+
ThreadingHTTPServer(('127.0.0.1', int(sys.argv[1])), Handler).serve_forever()
|
|
1810
|
+
\nBUD_TERMINAL`)).exitCode !== 0) return null;
|
|
1811
|
+
yield* sandbox.spawn(id, "terminal", `exec python3 ${file} ${bridgePort}`, { env: { BUD_TERMINAL_PASSWORD: terminalPassword(started) } });
|
|
1812
|
+
return bridgePort;
|
|
1813
|
+
}).pipe(orElseSucceed(() => null));
|
|
1814
|
+
started = {
|
|
1815
|
+
...started,
|
|
1816
|
+
ports: {
|
|
1817
|
+
...started.ports,
|
|
1818
|
+
...terminal ? { terminal } : {}
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
let client = yield* agent(started);
|
|
1822
|
+
let instructions = options.instructions ? yield* options.instructions : null;
|
|
1823
|
+
return {
|
|
1824
|
+
sandbox: id,
|
|
1825
|
+
session: yield* promise(async () => {
|
|
1826
|
+
let until = Date.now() + AGENT_START;
|
|
1827
|
+
while (!await client.healthy()) {
|
|
1828
|
+
if (Date.now() > until) throw fail("The agent didn’t start within a minute.");
|
|
1829
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1830
|
+
}
|
|
1831
|
+
let session = await client.session(task.title);
|
|
1832
|
+
await client.prompt(session, runPrompt(task, run, instructions), task.images);
|
|
1833
|
+
return session;
|
|
1834
|
+
}, "Could not start the agent"),
|
|
1835
|
+
commit: yield* head(id),
|
|
1836
|
+
ports: started.ports
|
|
1837
|
+
};
|
|
1838
|
+
}),
|
|
1839
|
+
follow: (run) => unwrap$1(gen(function* () {
|
|
1840
|
+
let client = yield* agent(run);
|
|
1841
|
+
let session = run.session;
|
|
1842
|
+
if (!session) return fail$2(fail("The run has no agent session.", false));
|
|
1843
|
+
let checkout = run.sandbox;
|
|
1844
|
+
return live(client, session, checkout);
|
|
1845
|
+
})),
|
|
1846
|
+
send: (run, message) => gen(function* () {
|
|
1847
|
+
let client = yield* agent(run);
|
|
1848
|
+
if (!run.session) return yield* fail("The run has no agent session.", false);
|
|
1849
|
+
let session = run.session;
|
|
1850
|
+
yield* promise(() => client.prompt(session, `${message}\n\nKeep working on the same branch, commit, and end with a short summary.`), "Could not reach the agent");
|
|
1851
|
+
}),
|
|
1852
|
+
push: (run) => gen(function* () {
|
|
1853
|
+
if (!run.sandbox) return yield* fail("The run has no sandbox.", false);
|
|
1854
|
+
let id = run.sandbox;
|
|
1855
|
+
let commit = yield* head(id);
|
|
1856
|
+
if (!commit) return yield* fail("The branch has no commit to push.", false);
|
|
1857
|
+
let { remote } = factory.push;
|
|
1858
|
+
let ref = `refs/heads/${run.branch}`;
|
|
1859
|
+
let token = options.pushToken ?? null;
|
|
1860
|
+
let { auth, env } = pushCredential(token, factory.push.username);
|
|
1861
|
+
let hide = (text) => token ? text.split(token).join("•••") : text;
|
|
1862
|
+
let git = (command) => sandbox.exec(id, `git ${auth}${command}`, {
|
|
1863
|
+
env,
|
|
1864
|
+
timeoutMs: 12e4
|
|
1865
|
+
});
|
|
1866
|
+
let remoteHead = () => map(git(`ls-remote '${remote}' '${ref}'`), (result) => result.exitCode === 0 ? result.output.trim().split(/\s+/)[0] ?? "" : null);
|
|
1867
|
+
if ((yield* remoteHead()) === commit) return commit;
|
|
1868
|
+
let pushed = yield* git(`push '${remote}' 'HEAD:${ref}'`);
|
|
1869
|
+
if (pushed.exitCode === 0) return commit;
|
|
1870
|
+
if ((yield* remoteHead()) === commit) return commit;
|
|
1871
|
+
return yield* fail(`The push was refused: ${hide(pushed.output.trim().slice(-400))}`, true);
|
|
1872
|
+
}),
|
|
1873
|
+
stop: (run) => run.sandbox ? sandbox.stop(run.sandbox) : void_$1,
|
|
1874
|
+
resume: (run) => gen(function* () {
|
|
1875
|
+
if (!run.sandbox) return yield* fail("The run has no sandbox.", false);
|
|
1876
|
+
yield* sandbox.resume(run.sandbox);
|
|
1877
|
+
let client = yield* agent(run);
|
|
1878
|
+
yield* promise(async () => {
|
|
1879
|
+
let until = Date.now() + AGENT_START;
|
|
1880
|
+
while (!await client.healthy()) {
|
|
1881
|
+
if (Date.now() > until) throw fail("The agent didn’t start again within a minute.");
|
|
1882
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1883
|
+
}
|
|
1884
|
+
}, "Could not wake the agent");
|
|
1885
|
+
}),
|
|
1886
|
+
destroy: (run) => run.sandbox ? sandbox.destroy(run.sandbox) : void_$1,
|
|
1887
|
+
readToken: (token) => readAgentToken(options.secret, token),
|
|
1888
|
+
terminal: (run) => gen(function* () {
|
|
1889
|
+
if (!run.sandbox || !run.ports.terminal) return null;
|
|
1890
|
+
let id = run.sandbox;
|
|
1891
|
+
let port = yield* sandbox.expose(id, run.ports.terminal);
|
|
1892
|
+
let { url, headers } = port.server ?? {
|
|
1893
|
+
url: port.url,
|
|
1894
|
+
headers: {}
|
|
1895
|
+
};
|
|
1896
|
+
let attach = run.session && run.ports.agent && harness.attach ? harness.attach({
|
|
1897
|
+
port: run.ports.agent,
|
|
1898
|
+
password: password(run),
|
|
1899
|
+
session: run.session
|
|
1900
|
+
}) : null;
|
|
1901
|
+
let log = factory.dev ? sandbox.logFile?.(id, "dev") : void 0;
|
|
1902
|
+
return {
|
|
1903
|
+
url,
|
|
1904
|
+
headers,
|
|
1905
|
+
password: terminalPassword(run),
|
|
1906
|
+
sessions: {
|
|
1907
|
+
agent: attach,
|
|
1908
|
+
dev: log ? {
|
|
1909
|
+
command: `exec tail -n 400 -F "${log}"`,
|
|
1910
|
+
env: {}
|
|
1911
|
+
} : null,
|
|
1912
|
+
shell: {
|
|
1913
|
+
command: "exec \"${SHELL:-/bin/sh}\" -i",
|
|
1914
|
+
env: {}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
}),
|
|
1919
|
+
preview: (run) => run.sandbox && run.ports.dev ? map(sandbox.expose(run.sandbox, run.ports.preview ?? run.ports.dev, { public: true }), ({ url }) => {
|
|
1920
|
+
let page = new URL(url);
|
|
1921
|
+
page.pathname = factory.dev?.path ?? "/";
|
|
1922
|
+
return { url: page.href };
|
|
1923
|
+
}) : succeed(null)
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
//#endregion
|
|
1927
|
+
//#region ../harness-opencode/src/opencode.server.ts
|
|
1928
|
+
/**
|
|
1929
|
+
* Splits a configured model into OpenCode's provider and model.
|
|
1930
|
+
* @param model - Such as `deepseek/deepseek-v4.1-flash`.
|
|
1931
|
+
* @returns The provider and model ids.
|
|
1932
|
+
*/
|
|
1933
|
+
function opencodeModel(model) {
|
|
1934
|
+
let slash = model.indexOf("/");
|
|
1935
|
+
if (slash < 1 || slash === model.length - 1) throw new TypeError(`The model ${model} must be provider/model.`);
|
|
1936
|
+
return {
|
|
1937
|
+
providerID: model.slice(0, slash),
|
|
1938
|
+
modelID: model.slice(slash + 1)
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* OpenCode's config for an agent working in a sandbox: it may edit and run commands without asking,
|
|
1943
|
+
* since nothing leaves the sandbox until a person approves.
|
|
1944
|
+
* @param model - The configured model.
|
|
1945
|
+
* @param tools - Bud's task database as an MCP server, when the sandbox reaches it.
|
|
1946
|
+
* @returns The config as `OPENCODE_CONFIG_CONTENT` takes it.
|
|
1947
|
+
*/
|
|
1948
|
+
function opencodeConfig(model, tools) {
|
|
1949
|
+
return JSON.stringify({
|
|
1950
|
+
$schema: "https://opencode.ai/config.json",
|
|
1951
|
+
model,
|
|
1952
|
+
permission: {
|
|
1953
|
+
edit: "allow",
|
|
1954
|
+
bash: "allow",
|
|
1955
|
+
webfetch: "allow"
|
|
1956
|
+
},
|
|
1957
|
+
autoupdate: false,
|
|
1958
|
+
share: "disabled",
|
|
1959
|
+
...tools ? { mcp: { bud: {
|
|
1960
|
+
type: "remote",
|
|
1961
|
+
url: tools.url,
|
|
1962
|
+
headers: { Authorization: `Bearer ${tools.token}` },
|
|
1963
|
+
enabled: true
|
|
1964
|
+
} } } : {}
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* A client for one `opencode serve`.
|
|
1969
|
+
* @param options - Its URL and password.
|
|
1970
|
+
* @returns Health, sessions, prompts and its event stream.
|
|
1971
|
+
*/
|
|
1972
|
+
function opencodeClient(options) {
|
|
1973
|
+
let request = options.fetch ?? fetch;
|
|
1974
|
+
let base = options.url.replace(/\/$/, "");
|
|
1975
|
+
let headers = {
|
|
1976
|
+
...options.headers,
|
|
1977
|
+
Authorization: `Basic ${btoa(`opencode:${options.password}`)}`,
|
|
1978
|
+
"Content-Type": "application/json"
|
|
1979
|
+
};
|
|
1980
|
+
let address = (path) => {
|
|
1981
|
+
let url = new URL(base);
|
|
1982
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}${path}`;
|
|
1983
|
+
return url.href;
|
|
1984
|
+
};
|
|
1985
|
+
async function call(path, init = {}) {
|
|
1986
|
+
let response = await request(address(path), {
|
|
1987
|
+
...init,
|
|
1988
|
+
headers: {
|
|
1989
|
+
...headers,
|
|
1990
|
+
...init.headers
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
if (!response.ok) throw new Error(`OpenCode answered ${response.status} for ${path}: ${(await response.text()).slice(0, 300)}`);
|
|
1994
|
+
return response;
|
|
1995
|
+
}
|
|
1996
|
+
return {
|
|
1997
|
+
async healthy() {
|
|
1998
|
+
return request(address("/global/health"), { headers }).then((response) => response.ok, () => false);
|
|
1999
|
+
},
|
|
2000
|
+
async session(title) {
|
|
2001
|
+
let session = await (await call("/session", {
|
|
2002
|
+
method: "POST",
|
|
2003
|
+
body: JSON.stringify({ title })
|
|
2004
|
+
})).json();
|
|
2005
|
+
if (typeof session.id !== "string") throw new Error("OpenCode returned no session.");
|
|
2006
|
+
return session.id;
|
|
2007
|
+
},
|
|
2008
|
+
async prompt(session, model, text, files = []) {
|
|
2009
|
+
let path = `/session/${encodeURIComponent(session)}`;
|
|
2010
|
+
let count = async () => {
|
|
2011
|
+
let messages = await (await call(`${path}/message`)).json();
|
|
2012
|
+
return Array.isArray(messages) ? messages.length : 0;
|
|
2013
|
+
};
|
|
2014
|
+
let before = await count();
|
|
2015
|
+
await call(`${path}/prompt_async`, {
|
|
2016
|
+
method: "POST",
|
|
2017
|
+
body: JSON.stringify({
|
|
2018
|
+
model,
|
|
2019
|
+
parts: [{
|
|
2020
|
+
type: "text",
|
|
2021
|
+
text
|
|
2022
|
+
}, ...files.map((file) => ({
|
|
2023
|
+
type: "file",
|
|
2024
|
+
mime: file.mime,
|
|
2025
|
+
filename: file.name,
|
|
2026
|
+
url: `data:${file.mime};base64,${file.data}`
|
|
2027
|
+
}))]
|
|
2028
|
+
})
|
|
2029
|
+
});
|
|
2030
|
+
for (let tries = 0; tries < 60; tries++) {
|
|
2031
|
+
if (await count() > before) return;
|
|
2032
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
2033
|
+
}
|
|
2034
|
+
},
|
|
2035
|
+
async turn(session) {
|
|
2036
|
+
if (record$1(record$1(await (await call("/session/status")).json())[session]).type === "busy") return { state: "working" };
|
|
2037
|
+
let messages = await (await call(`/session/${encodeURIComponent(session)}/message`)).json();
|
|
2038
|
+
let list = Array.isArray(messages) ? messages.map((message) => record$1(message)) : [];
|
|
2039
|
+
let info = record$1(list.at(-1)?.info);
|
|
2040
|
+
if (info.role === "user" || info.role === "assistant" && !record$1(info.time).completed) return { state: "working" };
|
|
2041
|
+
let error = record$1(info.error);
|
|
2042
|
+
if (Object.keys(error).length) {
|
|
2043
|
+
let data = record$1(error.data);
|
|
2044
|
+
return {
|
|
2045
|
+
state: "failed",
|
|
2046
|
+
message: typeof data.message === "string" ? data.message : String(error.name ?? ""),
|
|
2047
|
+
retryable: data.isRetryable === true
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
for (let message of list.toReversed()) {
|
|
2051
|
+
if (record$1(message.info).role !== "assistant") continue;
|
|
2052
|
+
let text = replyText(message.parts);
|
|
2053
|
+
if (text) return {
|
|
2054
|
+
state: "done",
|
|
2055
|
+
summary: text
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
return {
|
|
2059
|
+
state: "done",
|
|
2060
|
+
summary: ""
|
|
2061
|
+
};
|
|
2062
|
+
},
|
|
2063
|
+
async *events(signal) {
|
|
2064
|
+
let response = await call("/event", {
|
|
2065
|
+
headers: { Accept: "text/event-stream" },
|
|
2066
|
+
signal
|
|
2067
|
+
});
|
|
2068
|
+
yield {
|
|
2069
|
+
type: "bud.subscribed",
|
|
2070
|
+
properties: {}
|
|
2071
|
+
};
|
|
2072
|
+
let reader = response.body.getReader();
|
|
2073
|
+
let decoder = new TextDecoder();
|
|
2074
|
+
let buffer = "";
|
|
2075
|
+
try {
|
|
2076
|
+
while (true) {
|
|
2077
|
+
let { done, value } = await reader.read();
|
|
2078
|
+
if (done) return;
|
|
2079
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2080
|
+
let blocks = buffer.split("\n\n");
|
|
2081
|
+
buffer = blocks.pop() ?? "";
|
|
2082
|
+
for (let block of blocks) {
|
|
2083
|
+
let data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
2084
|
+
if (!data) continue;
|
|
2085
|
+
try {
|
|
2086
|
+
let event = JSON.parse(data);
|
|
2087
|
+
if (event && typeof event.type === "string") yield event;
|
|
2088
|
+
} catch {}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
} finally {
|
|
2092
|
+
await reader.cancel().catch(() => {});
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
function replyText(parts) {
|
|
2098
|
+
return Array.isArray(parts) ? parts.map((part) => record$1(part)).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim() : "";
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Reads an untrusted JSON value as an object.
|
|
2102
|
+
* @param value - Anything.
|
|
2103
|
+
* @returns The object, or an empty one.
|
|
2104
|
+
*/
|
|
2105
|
+
function record$1(value) {
|
|
2106
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Turns OpenCode's events into a run's: completed tool calls as activity, the agent's last reply as
|
|
2110
|
+
* its summary when the session is idle, and session errors as a failure.
|
|
2111
|
+
* @param events - OpenCode's event stream.
|
|
2112
|
+
* @param session - The run's session; other sessions' events are skipped.
|
|
2113
|
+
* @param head - Reads the checkout's commit once the agent is done.
|
|
2114
|
+
* @param turn - Reads the session's last turn once subscribed, for a turn that ended before.
|
|
2115
|
+
* @returns The run's events, ending with `idle` or `fail`.
|
|
2116
|
+
*/
|
|
2117
|
+
async function* followSession(events, session, head, turn) {
|
|
2118
|
+
let assistant = /* @__PURE__ */ new Set();
|
|
2119
|
+
let replies = /* @__PURE__ */ new Map();
|
|
2120
|
+
let order = [];
|
|
2121
|
+
let busy = Boolean(turn);
|
|
2122
|
+
let idle = async (summary) => ({
|
|
2123
|
+
type: "idle",
|
|
2124
|
+
summary: summary.trim() || "The agent finished without a summary.",
|
|
2125
|
+
head: await head()
|
|
2126
|
+
});
|
|
2127
|
+
for await (let event of events) {
|
|
2128
|
+
if (event.type === "bud.subscribed") {
|
|
2129
|
+
let last = await turn?.();
|
|
2130
|
+
if (last?.state === "done") return yield await idle(last.summary);
|
|
2131
|
+
if (last?.state === "failed") return yield {
|
|
2132
|
+
type: "fail",
|
|
2133
|
+
message: `The agent failed: ${last.message}`,
|
|
2134
|
+
retryable: last.retryable
|
|
2135
|
+
};
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
let properties = record$1(event.properties);
|
|
2139
|
+
if (properties.sessionID !== session) continue;
|
|
2140
|
+
if (event.type === "message.updated") {
|
|
2141
|
+
let info = record$1(properties.info);
|
|
2142
|
+
if (info.role === "assistant" && typeof info.id === "string") assistant.add(info.id);
|
|
2143
|
+
} else if (event.type === "message.part.updated") {
|
|
2144
|
+
let part = record$1(properties.part);
|
|
2145
|
+
let state = record$1(part.state);
|
|
2146
|
+
if (part.type === "tool" && state.status === "completed") yield {
|
|
2147
|
+
type: "activity",
|
|
2148
|
+
entry: {
|
|
2149
|
+
kind: "tool",
|
|
2150
|
+
text: `${String(part.tool)}${typeof state.title === "string" && state.title ? `: ${state.title}` : ""}`
|
|
2151
|
+
}
|
|
2152
|
+
};
|
|
2153
|
+
else if (part.type === "text" && typeof part.text === "string" && typeof part.messageID === "string") {
|
|
2154
|
+
if (!replies.has(part.messageID)) order.push(part.messageID);
|
|
2155
|
+
replies.set(part.messageID, part.text);
|
|
2156
|
+
}
|
|
2157
|
+
} else if (event.type === "session.status") busy = record$1(properties.status).type === "busy" || busy;
|
|
2158
|
+
else if (event.type === "session.error") {
|
|
2159
|
+
let error = record$1(properties.error);
|
|
2160
|
+
let data = record$1(error.data);
|
|
2161
|
+
yield {
|
|
2162
|
+
type: "fail",
|
|
2163
|
+
message: `The agent failed: ${typeof data.message === "string" ? data.message : String(error.name ?? "The agent failed.")}`,
|
|
2164
|
+
retryable: data.isRetryable === true
|
|
2165
|
+
};
|
|
2166
|
+
return;
|
|
2167
|
+
} else if (event.type === "session.idle" && busy) {
|
|
2168
|
+
let last = order.findLast((id) => assistant.has(id));
|
|
2169
|
+
yield await idle(last ? replies.get(last) ?? "" : "");
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* OpenCode as the factory's agent: `opencode serve` in the sandbox, with a password Bud derives
|
|
2176
|
+
* per run, and OpenCode's events followed to the agent's summary.
|
|
2177
|
+
* @param options - The model as OpenCode names it, `provider/model`, and the server variables
|
|
2178
|
+
* its key is in.
|
|
2179
|
+
* @returns The harness.
|
|
2180
|
+
*/
|
|
2181
|
+
function opencodeHarness(options) {
|
|
2182
|
+
let model = opencodeModel(options.model);
|
|
2183
|
+
return {
|
|
2184
|
+
id: "opencode",
|
|
2185
|
+
model: options.model,
|
|
2186
|
+
keys: options.keys ?? [],
|
|
2187
|
+
port: 4096,
|
|
2188
|
+
start: ({ port, password, tools }) => ({
|
|
2189
|
+
command: `opencode serve --port ${port} --hostname 127.0.0.1`,
|
|
2190
|
+
env: {
|
|
2191
|
+
OPENCODE_SERVER_PASSWORD: password,
|
|
2192
|
+
OPENCODE_CONFIG_CONTENT: opencodeConfig(options.model, tools)
|
|
2193
|
+
}
|
|
2194
|
+
}),
|
|
2195
|
+
attach: ({ port, password, session }) => ({
|
|
2196
|
+
command: `opencode attach http://127.0.0.1:${port} --session '${session.replaceAll("'", "")}'`,
|
|
2197
|
+
env: { OPENCODE_SERVER_PASSWORD: password }
|
|
2198
|
+
}),
|
|
2199
|
+
connect: (address, request) => {
|
|
2200
|
+
let client = opencodeClient({
|
|
2201
|
+
...address,
|
|
2202
|
+
fetch: request
|
|
2203
|
+
});
|
|
2204
|
+
return {
|
|
2205
|
+
healthy: () => client.healthy(),
|
|
2206
|
+
session: (title) => client.session(title),
|
|
2207
|
+
prompt: (session, text, images) => client.prompt(session, model, text, images),
|
|
2208
|
+
follow: (session, head, signal) => followSession(client.events(signal), session, head, () => client.turn(session))
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region app/server/factory-runs.ts
|
|
2215
|
+
/** The project's factory runs: what Start task, Approve and the rest do. */
|
|
2216
|
+
var FactoryRuns = class extends Service()("@bud.sh/app/FactoryRuns") {};
|
|
2217
|
+
succeed$1(RunExecutor, null);
|
|
2218
|
+
var unavailable = /* @__PURE__ */ new WeakMap();
|
|
2219
|
+
/**
|
|
2220
|
+
* What the preview proxy in each sandbox adds to the project's pages: Bud's page bridge, and its
|
|
2221
|
+
* DOM picker where the project serves none, so Agent view follows and picks in any project.
|
|
2222
|
+
*/
|
|
2223
|
+
var previewPage = {
|
|
2224
|
+
bridge: bridgeScript,
|
|
2225
|
+
picker: pickerModule("bud:dom", null),
|
|
2226
|
+
pickerPath: PICKER_MODULE_URL
|
|
2227
|
+
};
|
|
2228
|
+
var origins = /* @__PURE__ */ new WeakMap();
|
|
2229
|
+
/**
|
|
2230
|
+
* Remembers where this Bud listens, so agents in sandboxes on this machine reach its task
|
|
2231
|
+
* database.
|
|
2232
|
+
* @param project - The connected project.
|
|
2233
|
+
* @param origin - Such as `http://127.0.0.1:4321`.
|
|
2234
|
+
*/
|
|
2235
|
+
function rememberOrigin(project, origin) {
|
|
2236
|
+
origins.set(project, origin);
|
|
2237
|
+
}
|
|
2238
|
+
function agentSecret(root) {
|
|
2239
|
+
return tryPromise({
|
|
2240
|
+
try: async () => {
|
|
2241
|
+
let directory = join(root, "node_modules/.cache/bud");
|
|
2242
|
+
let file = join(directory, "factory-secret");
|
|
2243
|
+
let existing = (await readFile(file, "utf8").catch(() => "")).trim();
|
|
2244
|
+
if (/^[0-9a-f]{64}$/.test(existing)) return existing;
|
|
2245
|
+
let secret = randomBytes(32).toString("hex");
|
|
2246
|
+
await mkdir(directory, { recursive: true });
|
|
2247
|
+
await writeFile(file, `${secret}\n`, { mode: 384 });
|
|
2248
|
+
return secret;
|
|
2249
|
+
},
|
|
2250
|
+
catch: (cause) => new AdapterStartupError({
|
|
2251
|
+
message: "Could not keep the factory’s agent secret.",
|
|
2252
|
+
cause
|
|
2253
|
+
})
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Carries out runs in the sandboxes the project's sandbox plugin makes, with its harness's agent.
|
|
2258
|
+
* @param project - The connected project and its factory config.
|
|
2259
|
+
* @returns The executor; null when the factory or its sandbox is off, or can't run here.
|
|
2260
|
+
*/
|
|
2261
|
+
function runExecutorLayer(project) {
|
|
2262
|
+
return effect(RunExecutor, gen(function* () {
|
|
2263
|
+
let factory = project.config.factory;
|
|
2264
|
+
let selected = factory?.sandbox;
|
|
2265
|
+
if (!factory || !selected) return null;
|
|
2266
|
+
let credential = factory.push.credential;
|
|
2267
|
+
let pushToken = credential ? process.env[credential] ?? null : null;
|
|
2268
|
+
let keys = Object.fromEntries(factory.agent.keys.flatMap((name) => {
|
|
2269
|
+
let value = process.env[name];
|
|
2270
|
+
return value ? [[name, value]] : [];
|
|
2271
|
+
}));
|
|
2272
|
+
let plugins = configPlugins(project.config);
|
|
2273
|
+
let plugin = plugins.find((candidate) => candidate.name === selected);
|
|
2274
|
+
if (!plugin?.sandbox) return null;
|
|
2275
|
+
let harness = plugins.find((candidate) => candidate.name === factory.agent.harness)?.harness ?? opencodeHarness(factory.agent);
|
|
2276
|
+
let remote = yield* gen(function* () {
|
|
2277
|
+
return yield* (yield* (yield* Git).discover(project.root)).remoteUrl(factory.push.remote);
|
|
2278
|
+
}).pipe(orElseSucceed(() => null));
|
|
2279
|
+
let https = remote ? yield* try_(() => httpsRemote(remote)).pipe(orElseSucceed(() => null)) : null;
|
|
2280
|
+
let sandbox = yield* try_(() => {
|
|
2281
|
+
let made = plugin.sandbox({
|
|
2282
|
+
root: project.root,
|
|
2283
|
+
env: process.env,
|
|
2284
|
+
repository: https,
|
|
2285
|
+
clone: pushToken ? {
|
|
2286
|
+
username: factory.push.username,
|
|
2287
|
+
token: pushToken
|
|
2288
|
+
} : null,
|
|
2289
|
+
withhold: credential ? [credential] : []
|
|
2290
|
+
});
|
|
2291
|
+
if (!made || typeof made !== "object" || typeof made.id !== "string" || [
|
|
2292
|
+
"create",
|
|
2293
|
+
"status",
|
|
2294
|
+
"exec",
|
|
2295
|
+
"spawn",
|
|
2296
|
+
"expose",
|
|
2297
|
+
"stop",
|
|
2298
|
+
"resume",
|
|
2299
|
+
"destroy"
|
|
2300
|
+
].some((name) => typeof made[name] !== "function")) throw new Error(`The plugin ${plugin.name} didn’t make a sandbox adapter.`);
|
|
2301
|
+
return made;
|
|
2302
|
+
}).pipe(tapError(({ cause }) => sync(() => unavailable.set(project, cause instanceof Error ? cause.message : String(cause)))), orElseSucceed(() => null));
|
|
2303
|
+
if (!sandbox) return null;
|
|
2304
|
+
let repository = https ?? project.root;
|
|
2305
|
+
let file = factory.instructions ? join(project.root, factory.instructions) : null;
|
|
2306
|
+
return sandboxExecutor({
|
|
2307
|
+
sandbox,
|
|
2308
|
+
harness,
|
|
2309
|
+
page: previewPage,
|
|
2310
|
+
tools: () => {
|
|
2311
|
+
let origin = sandbox.capabilities.localhost ? origins.get(project) : void 0;
|
|
2312
|
+
return origin ? `${origin}${routes.agentApi.tasks.href()}` : null;
|
|
2313
|
+
},
|
|
2314
|
+
repository,
|
|
2315
|
+
factory,
|
|
2316
|
+
env: keys,
|
|
2317
|
+
secrets: Object.keys(keys),
|
|
2318
|
+
secret: yield* agentSecret(project.root),
|
|
2319
|
+
pushToken,
|
|
2320
|
+
instructions: file ? promise(() => readFile(file, "utf8").catch(() => null)) : void 0
|
|
2321
|
+
});
|
|
2322
|
+
}));
|
|
2323
|
+
}
|
|
2324
|
+
var MAIN_TRACKING = "refs/bud/factory/main";
|
|
2325
|
+
var MERGE_CHECK = 6e4;
|
|
2326
|
+
/**
|
|
2327
|
+
* The project's runs on its task store, picking up runs left going before a restart. Pushed
|
|
2328
|
+
* branches are pictured on the canvas, and a run counts as merged once main contains it.
|
|
2329
|
+
* @param project - The connected project and its factory config.
|
|
2330
|
+
* @param options - How to tell a merge, and how often to check for one.
|
|
2331
|
+
* @returns The runs service; its background work stops with the companion.
|
|
2332
|
+
*/
|
|
2333
|
+
function factoryRunsLayer(project, options = {}) {
|
|
2334
|
+
return effect(FactoryRuns, gen(function* () {
|
|
2335
|
+
let factory = project.config.factory;
|
|
2336
|
+
let { work } = yield* AdapterRegistry;
|
|
2337
|
+
if (!factory) return noRuns("The factory is off; turn it on with factory in bud.config.ts.");
|
|
2338
|
+
if (!work) return noRuns("Task storage is off, so the factory has nowhere to keep runs.");
|
|
2339
|
+
let activity = yield* StoreActivity;
|
|
2340
|
+
let executor = yield* RunExecutor;
|
|
2341
|
+
let branches = getOrNull(yield* serviceOption(BranchPreviews));
|
|
2342
|
+
let git = getOrNull(yield* serviceOption(Git));
|
|
2343
|
+
let attachments = getOrNull(yield* serviceOption(Attachments));
|
|
2344
|
+
let images = (taskId) => gen(function* () {
|
|
2345
|
+
if (!attachments || !work.readItemData) return [];
|
|
2346
|
+
let { value } = yield* work.readItemData(project.root, taskId, "budContext");
|
|
2347
|
+
let context = parseTaskContext(value);
|
|
2348
|
+
if (!context?.image) return [];
|
|
2349
|
+
let bytes = yield* attachments.get(context.image);
|
|
2350
|
+
if (!bytes) return [];
|
|
2351
|
+
return [{
|
|
2352
|
+
mime: "image/jpeg",
|
|
2353
|
+
name: "comment.jpg",
|
|
2354
|
+
data: Buffer.from(bytes).toString("base64")
|
|
2355
|
+
}];
|
|
2356
|
+
}).pipe(orElseSucceed(() => []));
|
|
2357
|
+
let picture = (change) => (run) => branches ? asVoid(branches[change](run.branch)) : void_$1;
|
|
2358
|
+
let runs = yield* makeRuns({
|
|
2359
|
+
root: project.root,
|
|
2360
|
+
factory,
|
|
2361
|
+
work,
|
|
2362
|
+
executor,
|
|
2363
|
+
unavailable: unavailable.get(project),
|
|
2364
|
+
images,
|
|
2365
|
+
sleepAfterMs: factory.sleepAfter ? factory.sleepAfter * 6e4 : null,
|
|
2366
|
+
changed: activity.notify("work"),
|
|
2367
|
+
onPushed: picture("activate"),
|
|
2368
|
+
onMerged: picture("deactivate")
|
|
2369
|
+
});
|
|
2370
|
+
yield* forkScoped(ignore(runs.resume));
|
|
2371
|
+
let merged = options.merged;
|
|
2372
|
+
if (!merged && git) {
|
|
2373
|
+
let main = project.config.branches.main ?? "main";
|
|
2374
|
+
let { remote } = factory.push;
|
|
2375
|
+
let local = git;
|
|
2376
|
+
let fetched = 0;
|
|
2377
|
+
merged = (commit) => gen(function* () {
|
|
2378
|
+
let repository = yield* local.discover(project.root);
|
|
2379
|
+
if (Date.now() - fetched > 3e4) {
|
|
2380
|
+
fetched = Date.now();
|
|
2381
|
+
yield* ignore(repository.fetchRef(remote, `refs/heads/${main}`, MAIN_TRACKING));
|
|
2382
|
+
}
|
|
2383
|
+
for (let ref of [MAIN_TRACKING, `refs/heads/${main}`]) {
|
|
2384
|
+
let head = yield* orElseSucceed(repository.resolve(ref), () => null);
|
|
2385
|
+
if (!head) continue;
|
|
2386
|
+
if ((yield* orElseSucceed(repository.mergeBase(commit, head), () => null)) === commit) return true;
|
|
2387
|
+
}
|
|
2388
|
+
return false;
|
|
2389
|
+
}).pipe(orElseSucceed(() => false));
|
|
2390
|
+
}
|
|
2391
|
+
if (executor && merged) {
|
|
2392
|
+
let check = merged;
|
|
2393
|
+
yield* runs.checkMerged(check).pipe(ignore, andThen(sleep(`${options.mergeCheckMs ?? MERGE_CHECK} millis`)), forever, forkScoped);
|
|
2394
|
+
}
|
|
2395
|
+
return runs;
|
|
2396
|
+
}));
|
|
2397
|
+
}
|
|
2398
|
+
//#endregion
|
|
1671
2399
|
//#region ../files/src/server/errors.ts
|
|
1672
2400
|
/** A request named an invalid path, or the service was given invalid options. */
|
|
1673
2401
|
var FilesInputError = class extends TaggedError("FilesInputError") {};
|
|
@@ -2352,8 +3080,8 @@ function makeFilesLayer(options, openReader) {
|
|
|
2352
3080
|
let confinement = yield* makeConfinement(config.root);
|
|
2353
3081
|
let scanner = yield* makeScanner(confinement, config.limits);
|
|
2354
3082
|
let scope$1 = yield* scope;
|
|
2355
|
-
let mutex = yield* make$
|
|
2356
|
-
let readers = yield* make$
|
|
3083
|
+
let mutex = yield* make$2(1);
|
|
3084
|
+
let readers = yield* make$2(config.limits.maxConcurrentReads);
|
|
2357
3085
|
let publications = yield* sliding({
|
|
2358
3086
|
capacity: 1,
|
|
2359
3087
|
replay: 1
|
|
@@ -2420,7 +3148,7 @@ function makeFilesLayer(options, openReader) {
|
|
|
2420
3148
|
watcherFault = false;
|
|
2421
3149
|
for (let [relative, directory] of wanted) {
|
|
2422
3150
|
if (watchers.has(relative)) continue;
|
|
2423
|
-
let ready = yield* make$
|
|
3151
|
+
let ready = yield* make$3();
|
|
2424
3152
|
let state = { active: false };
|
|
2425
3153
|
let stream = confinement.fs.watch(directory.absolute, {
|
|
2426
3154
|
recursive: config.recursive,
|
|
@@ -2626,7 +3354,7 @@ var FileSearch = class extends Service()("@bud.sh/files/FileSearch") {};
|
|
|
2626
3354
|
*/
|
|
2627
3355
|
function fileSearchLayer(options = {}) {
|
|
2628
3356
|
return effect(FileSearch, gen(function* () {
|
|
2629
|
-
let files = yield* Files, mutex = yield* make$
|
|
3357
|
+
let files = yield* Files, mutex = yield* make$2(1);
|
|
2630
3358
|
let maxFiles = options.maxFiles ?? 500, maxBytes = options.maxBytes ?? 16777216, timeoutMs = options.timeoutMs ?? 5e3;
|
|
2631
3359
|
if (![
|
|
2632
3360
|
maxFiles,
|
|
@@ -3906,7 +4634,7 @@ function makeMutationSnapshot(confinement, io, openReader, limits) {
|
|
|
3906
4634
|
//#region ../files/src/server/write-lock.ts
|
|
3907
4635
|
/** One companion's explicit workspace writes share this gate; reads and watchers do not. */
|
|
3908
4636
|
var FileWriteLock = class extends Service()("@bud.sh/files/FileWriteLock") {};
|
|
3909
|
-
var fileWriteLockLayer = effect(FileWriteLock, make$
|
|
4637
|
+
var fileWriteLockLayer = effect(FileWriteLock, make$2(1));
|
|
3910
4638
|
//#endregion
|
|
3911
4639
|
//#region ../files/src/server/mutations.ts
|
|
3912
4640
|
/** Plans and applies file actions, and lists the recoveries they kept. */
|
|
@@ -4028,7 +4756,7 @@ function makeFileMutationsLayer(options, io, openReader) {
|
|
|
4028
4756
|
let rootInfo = yield* io.inspect(confinement.root, "");
|
|
4029
4757
|
let project = hash(`${confinement.root}\0${rootInfo.identity}`);
|
|
4030
4758
|
let snapshot = makeMutationSnapshot(confinement, io, openReader, config.limits);
|
|
4031
|
-
let mutex = yield* make$
|
|
4759
|
+
let mutex = yield* make$2(1);
|
|
4032
4760
|
let plans = /* @__PURE__ */ new Map();
|
|
4033
4761
|
let store;
|
|
4034
4762
|
function recoveryStore() {
|
|
@@ -5477,4 +6205,4 @@ function fileReviewActionsLayer(options) {
|
|
|
5477
6205
|
}).pipe(mapError(reviewActionFailure)));
|
|
5478
6206
|
}
|
|
5479
6207
|
//#endregion
|
|
5480
|
-
export {
|
|
6208
|
+
export { liveChannelLayer as A, ThreadAdapter as B, inspect$1 as C, selected as D, ready as E, AdapterRegistry as F, sourceScreensLayer as G, WorkError as H, AdapterStartupError as I, make as J, sharedModules as K, adapterRegistryLayer as L, Attachments as M, attachmentsLayer as N, validate as O, directoryAttachmentStore as P, defineAdapter as R, storeLiveLayer as S, readWorkspace as T, ScreenModules as U, WorkAdapter as V, bundledScreensLayer as W, factoryRunsLayer as _, FileMutations as a, StoreActivity as b, FileReview as c, fileSearchLayer as d, Files as f, FactoryRuns as g, ReviewActionError as h, fileSavesLayer as i, publishChanges as j, LiveChannel as k, fileReviewLayer as l, FilesInputError as m, fileReviewActionsLayer as n, fileMutationsLayer as o, filesLayer as p, taskDesignModuleId as q, FileSaves as r, fileWriteLockLayer as s, FileReviewActions as t, FileSearch as u, rememberOrigin as v, read as w, storeActivityLayer as x, runExecutorLayer as y, parseThreadImport as z };
|