@kubitnodes/stellaris 0.3.27 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +448 -66
- package/dist/server/index.js +882 -156
- package/package.json +2 -2
- package/web/dist/assets/BackLink-fxPmFUg8.js +1 -0
- package/web/dist/assets/Button-DxGzriWQ.js +1 -0
- package/web/dist/assets/ChannelForms-C3m4IPnZ.js +1 -0
- package/web/dist/assets/ChannelView-DvtoxLi5.js +2 -0
- package/web/dist/assets/CitizenView-Dq64VsZi.js +2 -0
- package/web/dist/assets/{CitizensView-Bgw6PRSo.js → CitizensView-BOZHABbN.js} +1 -1
- package/web/dist/assets/{CliIcon-BB2NdLxN.js → CliIcon-D3GddnP4.js} +1 -1
- package/web/dist/assets/CronsView-CI947lZS.js +1 -0
- package/web/dist/assets/{FileTree-CHDmrOhY.js → FileTree-DSFN4sd8.js} +1 -1
- package/web/dist/assets/{FileView-DUMfKYRW.js → FileView-DyUYLXRC.js} +2 -2
- package/web/dist/assets/KnowledgeView-CMdXaTvS.js +1 -0
- package/web/dist/assets/{Markdown-C7RbUEZE.js → Markdown-BSeWHBBA.js} +5 -5
- package/web/dist/assets/{MessageItem-DgWh5pJF.js → MessageItem-qIxS96uJ.js} +1 -1
- package/web/dist/assets/MetricsView-BhjdwkX1.js +1 -0
- package/web/dist/assets/{NeedsYouView-CrmHXZAn.js → NeedsYouView-7uZW9dY8.js} +1 -1
- package/web/dist/assets/{Overview-xatORJK6.js → Overview-D6kiK8sE.js} +1 -1
- package/web/dist/assets/{Pane-BlIRwUtQ.js → Pane-pZUzNQIW.js} +1 -1
- package/web/dist/assets/{Picker-BSOB2JM_.js → Picker-CDf6os_x.js} +1 -1
- package/web/dist/assets/{ProjectView-CVt28SoI.js → ProjectView-B6OmxiT9.js} +1 -1
- package/web/dist/assets/ProposalView-B1X8Jlo0.js +1 -0
- package/web/dist/assets/{ProposalsView-C0r1ya8_.js → ProposalsView-DBGpcAPO.js} +1 -1
- package/web/dist/assets/{Quota-59QJZrPa.js → Quota-Brd4ddB4.js} +1 -1
- package/web/dist/assets/RunnersView-45OXtalD.js +1 -0
- package/web/dist/assets/{SkillFileList-Cs_f3RPo.js → SkillFileList-BUImALv9.js} +1 -1
- package/web/dist/assets/{SkillView-BvVx3LSL.js → SkillView-B1knYZV8.js} +1 -1
- package/web/dist/assets/SocietyView-F7bzrNv5.js +1 -0
- package/web/dist/assets/{StickyList-BbGq82Jj.js → StickyList-D_ED_-LW.js} +1 -1
- package/web/dist/assets/{SubjectThread-DdkyhVqK.js → SubjectThread-B5zvoRSd.js} +1 -1
- package/web/dist/assets/{TaskView-CyEWpCvS.js → TaskView-B5_QTI3p.js} +2 -2
- package/web/dist/assets/{TasksView-D10XP1uk.js → TasksView-DZLNqU2V.js} +1 -1
- package/web/dist/assets/{ThreadForms-D94Jj0ES.js → ThreadForms-CbFClyd0.js} +1 -1
- package/web/dist/assets/{ThreadItem-CYmdCPOf.js → ThreadItem-kFn5M3Cx.js} +1 -1
- package/web/dist/assets/{ThreadView-D79G2zgF.js → ThreadView-CHFs0zaS.js} +1 -1
- package/web/dist/assets/citizen-B8p56JRh.js +1 -0
- package/web/dist/assets/compose-CV4o6TMb.js +1 -0
- package/web/dist/assets/format-BYF63sQU.js +55 -0
- package/web/dist/assets/{governance-YialJVYK.js → governance-BjIg0eok.js} +1 -1
- package/web/dist/assets/index-CA_njyMe.css +2 -0
- package/web/dist/assets/index-CyROMh25.js +24 -0
- package/web/dist/assets/{mentions-Buc5vs-j.js → mentions-BH0RZXfc.js} +1 -1
- package/web/dist/assets/{tasks-DLTeaHys.js → tasks-D2Es55TQ.js} +1 -1
- package/web/dist/assets/useMatch-DeOpK57G.js +1 -0
- package/web/dist/index.html +9 -9
- package/web/dist/assets/BackLink-qSxOMeEG.js +0 -1
- package/web/dist/assets/Button-CxjWy598.js +0 -1
- package/web/dist/assets/ChannelForms-BVidWMId.js +0 -1
- package/web/dist/assets/ChannelView-rqfk1Tyu.js +0 -2
- package/web/dist/assets/CitizenView-xU99R8hS.js +0 -2
- package/web/dist/assets/CronsView-BrXrcfC6.js +0 -1
- package/web/dist/assets/KnowledgeView-Qog6c92V.js +0 -1
- package/web/dist/assets/MetricsView-DuOyISk1.js +0 -1
- package/web/dist/assets/ProposalView-DJ0aJm7J.js +0 -1
- package/web/dist/assets/RunnersView-CcAAfw6y.js +0 -1
- package/web/dist/assets/SocietyView-CJRrypjI.js +0 -1
- package/web/dist/assets/citizen-CgA4vJjy.js +0 -1
- package/web/dist/assets/compose-3ksQaHF6.js +0 -1
- package/web/dist/assets/format-DpQyO6r1.js +0 -55
- package/web/dist/assets/index-DIa8Dlav.css +0 -2
- package/web/dist/assets/index-DhuCp-VF.js +0 -23
- package/web/dist/assets/useMatch-DPEg8ohe.js +0 -1
package/dist/server/index.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// apps/server/dist/index.js
|
|
4
4
|
import { access as access2 } from "node:fs/promises";
|
|
5
5
|
import path11 from "node:path";
|
|
6
|
-
import { serve } from "@hono/node-server";
|
|
7
6
|
|
|
8
7
|
// packages/board-core/dist/board.js
|
|
9
8
|
import { chmod as chmod2, cp, mkdir as mkdir5, readdir as readdir4, readFile as readFile4, rename as rename3, rm as rm3, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
|
|
@@ -65,7 +64,8 @@ var VERB_NAMES = [
|
|
|
65
64
|
"update_cron",
|
|
66
65
|
"remove_cron",
|
|
67
66
|
"remove_skill",
|
|
68
|
-
"upload_image"
|
|
67
|
+
"upload_image",
|
|
68
|
+
"read_image"
|
|
69
69
|
];
|
|
70
70
|
var VerbNameSchema = z2.enum(VERB_NAMES);
|
|
71
71
|
var CharterTriggerSchema = z2.enum(["user_post", "ops_event", "heartbeat"]);
|
|
@@ -95,6 +95,7 @@ var USER_NAME = "user";
|
|
|
95
95
|
var MEMBER_VERBS = [
|
|
96
96
|
"post_message",
|
|
97
97
|
"upload_image",
|
|
98
|
+
"read_image",
|
|
98
99
|
"read_inbox",
|
|
99
100
|
"search",
|
|
100
101
|
"open_thread",
|
|
@@ -226,7 +227,12 @@ var CronScheduleSchema = z3.union([
|
|
|
226
227
|
var CronRunSchema = z3.object({
|
|
227
228
|
turnId: UlidSchema,
|
|
228
229
|
at: IsoDateTimeSchema,
|
|
229
|
-
outcome: z3.enum(["completed", "failed", "stopped"])
|
|
230
|
+
outcome: z3.enum(["completed", "failed", "stopped"]),
|
|
231
|
+
/**
|
|
232
|
+
* For a failed one, the first turn of the cron's citizen in its conversation that was shown the
|
|
233
|
+
* failure and completed, and when it ended.
|
|
234
|
+
*/
|
|
235
|
+
caughtUp: z3.object({ turnId: UlidSchema, at: IsoDateTimeSchema }).optional()
|
|
230
236
|
});
|
|
231
237
|
var CronFrontmatterSchema = z3.object({
|
|
232
238
|
id: UlidSchema,
|
|
@@ -273,6 +279,16 @@ function shortestGapMs(schedule, societyZone, after) {
|
|
|
273
279
|
}
|
|
274
280
|
return shortest;
|
|
275
281
|
}
|
|
282
|
+
function firesInTask(cron, task) {
|
|
283
|
+
return cron.scope === task.project && cron.thread === task.id;
|
|
284
|
+
}
|
|
285
|
+
function cronFailure(cron) {
|
|
286
|
+
return cron.lastRun?.outcome === "failed" && cron.lastRun.caughtUp === void 0 ? cron.lastRun : null;
|
|
287
|
+
}
|
|
288
|
+
function cronStoppedAt(cron) {
|
|
289
|
+
const times = [cron.paused?.at, cron.ended?.at, cronFailure(cron)?.at].filter((at) => at !== void 0);
|
|
290
|
+
return times.reduce((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null);
|
|
291
|
+
}
|
|
276
292
|
function nextFire(cron, societyZone, floorMs = CRON_FLOOR_MS) {
|
|
277
293
|
if (cron.ended !== void 0 || cron.paused !== void 0) {
|
|
278
294
|
return null;
|
|
@@ -382,6 +398,21 @@ function quotaLevel(quota, now) {
|
|
|
382
398
|
const high = quota.windows.some((window) => windowCurrent(window, now) && window.usedPercent !== null && window.usedPercent >= QUOTA_WARNING_PERCENT);
|
|
383
399
|
return quota.status === "warning" || high ? "warning" : "ok";
|
|
384
400
|
}
|
|
401
|
+
var UNKNOWN_LIMIT_HOLD_MS = 30 * 6e4;
|
|
402
|
+
function stopsTurns(quota) {
|
|
403
|
+
return quota.status === "limited" && !(quota.source === "read" && quota.extraUsage?.enabled === true);
|
|
404
|
+
}
|
|
405
|
+
function limitHold(quota, now) {
|
|
406
|
+
if (!stopsTurns(quota)) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const known = quota.limitedUntil !== null;
|
|
410
|
+
const until = quota.limitedUntil ?? new Date(Date.parse(quota.observedAt) + UNKNOWN_LIMIT_HOLD_MS).toISOString();
|
|
411
|
+
return Date.parse(until) > now.getTime() ? { until, known } : null;
|
|
412
|
+
}
|
|
413
|
+
function limitLapsed(quota, now) {
|
|
414
|
+
return stopsTurns(quota) && quota.limitedUntil === null && limitHold(quota, now) === null;
|
|
415
|
+
}
|
|
385
416
|
var WINDOW_NAMES = {
|
|
386
417
|
five_hour: "5h",
|
|
387
418
|
seven_day: "7d",
|
|
@@ -704,6 +735,7 @@ var MemberSchema = z5.object({
|
|
|
704
735
|
});
|
|
705
736
|
var RunnerOsSchema = z5.enum(["linux", "windows", "darwin"]);
|
|
706
737
|
var GitIdentityPartSchema = z5.enum(["name", "email"]);
|
|
738
|
+
var RunnerPauseSchema = z5.object({ at: IsoDateTimeSchema, by: NameSchema });
|
|
707
739
|
var RunnerSchema = z5.object({
|
|
708
740
|
name: NameSchema,
|
|
709
741
|
os: RunnerOsSchema,
|
|
@@ -722,8 +754,29 @@ var RunnerSchema = z5.object({
|
|
|
722
754
|
* Each CLI's plan quota as its login on this runner last reported it, from a turn's stream or a
|
|
723
755
|
* read; absent until one has.
|
|
724
756
|
*/
|
|
725
|
-
quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional()
|
|
757
|
+
quotas: z5.partialRecord(CliKindSchema, CliQuotaSchema).optional(),
|
|
758
|
+
/** The user's pause of the whole runner: no turn starts on it until the user resumes it. */
|
|
759
|
+
paused: RunnerPauseSchema.optional(),
|
|
760
|
+
/**
|
|
761
|
+
* The user's pauses of single CLIs on the runner, each apart from the runner's own: no turn of
|
|
762
|
+
* that CLI starts on it until the user resumes it.
|
|
763
|
+
*/
|
|
764
|
+
pausedClis: z5.partialRecord(CliKindSchema, RunnerPauseSchema).optional()
|
|
726
765
|
});
|
|
766
|
+
function runnerPause(runner, cli) {
|
|
767
|
+
return runner.paused ?? runner.pausedClis?.[cli];
|
|
768
|
+
}
|
|
769
|
+
function runnerLimitHold(runner, cli, now) {
|
|
770
|
+
const quota = runner.quotas?.[cli];
|
|
771
|
+
return quota === void 0 ? null : limitHold(quota, now);
|
|
772
|
+
}
|
|
773
|
+
function heldOn(runners2, runner, cli, now) {
|
|
774
|
+
if (runner === void 0 || cli === null) {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
const record = runners2.find((each) => each.name === runner);
|
|
778
|
+
return record !== void 0 && (runnerPause(record, cli) !== void 0 || runnerLimitHold(record, cli, now) !== null);
|
|
779
|
+
}
|
|
727
780
|
var ProposalKindSchema = z5.enum([
|
|
728
781
|
"role",
|
|
729
782
|
"member",
|
|
@@ -1073,7 +1126,21 @@ function imageTypeOf(bytes) {
|
|
|
1073
1126
|
return null;
|
|
1074
1127
|
}
|
|
1075
1128
|
var ImagePathSchema = z6.string().min(1).max(4096).refine((value) => !value.includes("\0"), { message: "a path with no NUL character" });
|
|
1129
|
+
var ImageNameSchema = z6.string().max(1024);
|
|
1130
|
+
function imageDescription(name) {
|
|
1131
|
+
return name.replaceAll(/[[\]\\\r\n`]/g, "");
|
|
1132
|
+
}
|
|
1076
1133
|
var IMAGE_SCHEME = "image:";
|
|
1134
|
+
var ImageRefSchema = z6.string().regex(/^(?:image:)?[0-9A-HJKMNP-TV-Z]{26}$/, "expected an image's id or image:<id>");
|
|
1135
|
+
function imageIdOf(ref) {
|
|
1136
|
+
return ref.startsWith(IMAGE_SCHEME) ? ref.slice(IMAGE_SCHEME.length) : ref;
|
|
1137
|
+
}
|
|
1138
|
+
var ImageFileSchema = z6.object({
|
|
1139
|
+
id: UlidSchema,
|
|
1140
|
+
type: ImageTypeSchema,
|
|
1141
|
+
size: z6.number().int().positive(),
|
|
1142
|
+
path: ImagePathSchema
|
|
1143
|
+
});
|
|
1077
1144
|
var UploadedImageSchema = z6.object({
|
|
1078
1145
|
id: UlidSchema,
|
|
1079
1146
|
type: ImageTypeSchema,
|
|
@@ -1238,6 +1305,10 @@ var VerbInputs = {
|
|
|
1238
1305
|
upload_image: z7.object({
|
|
1239
1306
|
/** The image file on the machine your turn runs on: absolute, or relative to your working directory. */
|
|
1240
1307
|
path: ImagePathSchema
|
|
1308
|
+
}),
|
|
1309
|
+
read_image: z7.object({
|
|
1310
|
+
/** The image's id, or its `image:<id>` address as a post shows it. */
|
|
1311
|
+
image_id: ImageRefSchema
|
|
1241
1312
|
})
|
|
1242
1313
|
};
|
|
1243
1314
|
var VERB_DESCRIPTIONS = {
|
|
@@ -1249,7 +1320,7 @@ var VERB_DESCRIPTIONS = {
|
|
|
1249
1320
|
create_task: "Create a task in a project with its plan: stages of {name, role or agent, gate}. Without stages it gets one stage, work, that anyone in the project may take. File it in a channel of the project, such as a release's, or general by default; its thread opens there with it, under the task's id, for everything said about the work.",
|
|
1250
1321
|
claim_task: "Hold the task's current stage. Claims are leases renewed by every turn that touches the task.",
|
|
1251
1322
|
release_task: "Let go of the stage you hold so someone else can take it.",
|
|
1252
|
-
update_task: "Move a task back to an earlier stage (the current stage's holder, the user, the steward, or the concierge; anyone else asks the concierge in the task's thread), abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
|
|
1323
|
+
update_task: "Move a task back to an earlier stage (the current stage's holder, the user, the steward, or the concierge; anyone else asks the concierge in the task's thread, with a mention), abandon it, set the tasks it is blocked by (blocked_by: its stage wakes nobody until they have all ended), or link its pull request (pull_request: url, with the merge commit's merge_subject and merge_body), which a ghpr task needs before its last stage completes. A note is posted to the task's thread.",
|
|
1253
1324
|
get_task: "Read a task with its plan, its brief, and the messages of its thread.",
|
|
1254
1325
|
subscribe: "Subscribe to a channel. Subscriptions feed your digest; they never wake you.",
|
|
1255
1326
|
unsubscribe: "Unsubscribe from a channel.",
|
|
@@ -1272,7 +1343,8 @@ var VERB_DESCRIPTIONS = {
|
|
|
1272
1343
|
update_cron: "Pause or resume a cron, or change its title, note, or schedule; resuming or rescheduling starts it afresh from now. Yours, or anyone's for the user, the steward, and the concierge.",
|
|
1273
1344
|
remove_cron: "End a cron for good, with a reason. Yours, or anyone's for the user, the steward, and the concierge. A cron also ends with its conversation, and a one-time cron once it fires.",
|
|
1274
1345
|
remove_skill: "Remove a skill from the society's skills, with a reason, when it is wrong, superseded, or no longer used: no skills index lists it from the next turn, and a notice in general says so with the reason. Its proposer's own copy stays in its home. Steward and user only; the board keeps its text aside for the user to restore.",
|
|
1275
|
-
upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, ``; write what it shows as the alt text."
|
|
1346
|
+
upload_image: "Upload an image file, such as a screenshot or a chart, from the machine your turn runs on: a PNG, JPEG, GIF, or WebP of at most 8 MB, in your worktree, your home, or the system temp folder. Returns its id and the markdown that shows it in a post, ``; write what it shows as the alt text.",
|
|
1347
|
+
read_image: "Open an image the board keeps, as a post shows it, ``, by its id: the board writes it to a file of this turn's own on the machine it runs on, removed when the turn ends, and returns its path, type, and size. Open the path with your file tools to see the image."
|
|
1276
1348
|
};
|
|
1277
1349
|
|
|
1278
1350
|
// packages/shared/dist/events.js
|
|
@@ -1323,6 +1395,8 @@ var BOARD_EVENT_TYPES = [
|
|
|
1323
1395
|
"user.token_rotated",
|
|
1324
1396
|
"runner.changed",
|
|
1325
1397
|
"runner.quota",
|
|
1398
|
+
"runner.paused",
|
|
1399
|
+
"runner.resumed",
|
|
1326
1400
|
"ops.signal",
|
|
1327
1401
|
"paused.changed",
|
|
1328
1402
|
"wake.requested",
|
|
@@ -1472,6 +1546,11 @@ var RunnerHelloSchema = z9.object({
|
|
|
1472
1546
|
quotaReads: z9.boolean().default(false),
|
|
1473
1547
|
/** Whether the runner reads an image a turn running there uploads; one from before never does. */
|
|
1474
1548
|
imageReads: z9.boolean().default(false),
|
|
1549
|
+
/**
|
|
1550
|
+
* Whether the runner writes an image the board sends for a turn running there to open, for
|
|
1551
|
+
* `read_image`; one from before never does.
|
|
1552
|
+
*/
|
|
1553
|
+
imageWrites: z9.boolean().default(false),
|
|
1475
1554
|
/**
|
|
1476
1555
|
* Whether the runner carries a project's repository out to another runner and in from one, and
|
|
1477
1556
|
* removes its copy once the project has moved; one from before never does.
|
|
@@ -1593,6 +1672,12 @@ var TurnJobSchema = z9.object({
|
|
|
1593
1672
|
usageSoFar: UsageSchema.optional(),
|
|
1594
1673
|
/** The rendered charter, norms, memory core, and skills index, with `PATH_TOKENS` for places. */
|
|
1595
1674
|
instructions: z9.string(),
|
|
1675
|
+
/**
|
|
1676
|
+
* The hash of `instructions` without the first turn's section (`instructionsHash` in
|
|
1677
|
+
* `turn-host`): a warm process started on other instructions starts afresh, so a compaction in
|
|
1678
|
+
* it puts back the current ones. Absent from a server from before, when the text is compared.
|
|
1679
|
+
*/
|
|
1680
|
+
instructionsHash: z9.string().optional(),
|
|
1596
1681
|
prompt: z9.string(),
|
|
1597
1682
|
mcp: z9.object({ url: z9.string().min(1), token: z9.string().min(1) }),
|
|
1598
1683
|
limits: z9.object({
|
|
@@ -1777,6 +1862,13 @@ var TurnImageSchema = z9.discriminatedUnion("kind", [
|
|
|
1777
1862
|
}),
|
|
1778
1863
|
z9.object({ kind: z9.literal("refused"), why: ImageRefusalSchema, detail: z9.string() })
|
|
1779
1864
|
]);
|
|
1865
|
+
var TurnImageWriteSchema = z9.object({
|
|
1866
|
+
turnId: UlidSchema,
|
|
1867
|
+
id: UlidSchema,
|
|
1868
|
+
type: ImageTypeSchema,
|
|
1869
|
+
content: z9.string()
|
|
1870
|
+
});
|
|
1871
|
+
var TurnImageFileSchema = z9.object({ path: ImagePathSchema });
|
|
1780
1872
|
var WorkspaceConversationSchema = z9.discriminatedUnion("kind", [
|
|
1781
1873
|
z9.object({ kind: z9.literal("thread"), id: UlidSchema }),
|
|
1782
1874
|
z9.object({ kind: z9.literal("channel"), scope: NameSchema, name: NameSchema }),
|
|
@@ -1799,6 +1891,10 @@ var HeldWorkspaceSchema = z9.object({
|
|
|
1799
1891
|
var HeldWorkspacesSchema = z9.object({ workspaces: z9.array(HeldWorkspaceSchema) });
|
|
1800
1892
|
var SweepResultSchema = z9.object({ kept: z9.array(HeldWorkspaceSchema) });
|
|
1801
1893
|
var MOVE_PACK_LIMIT_BYTES = 256 * 1024 * 1024;
|
|
1894
|
+
var MOVE_FLOOR_BYTES_PER_SECOND = 16 * 1024;
|
|
1895
|
+
function moveTransferMs(bytes) {
|
|
1896
|
+
return 10 * 6e4 + Math.ceil(bytes / MOVE_FLOOR_BYTES_PER_SECOND) * 1e3;
|
|
1897
|
+
}
|
|
1802
1898
|
var RepoRefsSchema = z9.record(z9.string().regex(/^refs\/(heads|tags)\/\S+$/), z9.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/));
|
|
1803
1899
|
var ProjectExportSchema = z9.object({ move: UlidSchema, repo: ProjectRepoSchema });
|
|
1804
1900
|
var HeldWorkSchema = z9.object({
|
|
@@ -1822,7 +1918,9 @@ var ProjectExportedSchema = z9.discriminatedUnion("ok", [
|
|
|
1822
1918
|
var ProjectImportSchema = z9.object({
|
|
1823
1919
|
move: UlidSchema,
|
|
1824
1920
|
repo: ProjectRepoSchema,
|
|
1825
|
-
pullRequests: z9.boolean()
|
|
1921
|
+
pullRequests: z9.boolean(),
|
|
1922
|
+
/** The packs' size the old runner measured, which the clone's clock is sized from; the cap if unsaid. */
|
|
1923
|
+
bytes: z9.number().int().nonnegative().optional()
|
|
1826
1924
|
});
|
|
1827
1925
|
var ProjectImportedSchema = z9.object({ refs: RepoRefsSchema });
|
|
1828
1926
|
var ProjectReleaseSchema = z9.object({ slug: NameSchema });
|
|
@@ -1862,6 +1960,15 @@ var RunnerMessageSchema = z9.discriminatedUnion("type", [
|
|
|
1862
1960
|
* whose hello says `imageReads`.
|
|
1863
1961
|
*/
|
|
1864
1962
|
z9.object({ type: z9.literal("image"), request: z9.string().min(1), read: TurnImageReadSchema }),
|
|
1963
|
+
/**
|
|
1964
|
+
* Answered with a `TurnImageFile`, or null when the turn is not running there. Sent only to
|
|
1965
|
+
* runners whose hello says `imageWrites`.
|
|
1966
|
+
*/
|
|
1967
|
+
z9.object({
|
|
1968
|
+
type: z9.literal("image_write"),
|
|
1969
|
+
request: z9.string().min(1),
|
|
1970
|
+
write: TurnImageWriteSchema
|
|
1971
|
+
}),
|
|
1865
1972
|
/** Answered with whether the CLI accepted it; that it took it comes as a `steered` event. */
|
|
1866
1973
|
z9.object({
|
|
1867
1974
|
type: z9.literal("steer"),
|
|
@@ -1927,6 +2034,7 @@ var TriggerKindSchema = z10.enum([
|
|
|
1927
2034
|
"closing",
|
|
1928
2035
|
"cron"
|
|
1929
2036
|
]);
|
|
2037
|
+
var HeartbeatCauseSchema = z10.enum(["unread", "held", "waiting"]);
|
|
1930
2038
|
var TriggerSchema = z10.object({
|
|
1931
2039
|
kind: TriggerKindSchema,
|
|
1932
2040
|
from: NameSchema.optional(),
|
|
@@ -1937,7 +2045,9 @@ var TriggerSchema = z10.object({
|
|
|
1937
2045
|
/** The channel the waking message was posted in. */
|
|
1938
2046
|
channel: ChannelRefSchema.optional(),
|
|
1939
2047
|
/** The cron that came due, for a `cron` wake. */
|
|
1940
|
-
cronId: UlidSchema.optional()
|
|
2048
|
+
cronId: UlidSchema.optional(),
|
|
2049
|
+
/** What a `heartbeat` woke for; absent on records from before the board kept it. */
|
|
2050
|
+
heartbeat: HeartbeatCauseSchema.optional()
|
|
1941
2051
|
});
|
|
1942
2052
|
var TurnThreadSchema = z10.object({ id: UlidSchema, task: z10.boolean() });
|
|
1943
2053
|
var TurnDispatchSchema = z10.object({
|
|
@@ -2019,6 +2129,12 @@ var TurnRecordSchema = z10.object({
|
|
|
2019
2129
|
* one. A resumed session's next turn measures its own usage from here.
|
|
2020
2130
|
*/
|
|
2021
2131
|
sessionUsage: UsageSchema.optional(),
|
|
2132
|
+
/**
|
|
2133
|
+
* The SHA-256 of the instructions the session holds after this turn, the first turn's section
|
|
2134
|
+
* left out (`instructionsHash` in `turn-host`): those it began with, or the latest a prompt
|
|
2135
|
+
* carried. Absent when the board does not know them, as for records from before it kept them.
|
|
2136
|
+
*/
|
|
2137
|
+
sessionInstructionsHash: z10.string().optional(),
|
|
2022
2138
|
/**
|
|
2023
2139
|
* The whole input of the turn's latest model call in the session's own thread, as the runner
|
|
2024
2140
|
* reported it: how large the session had grown. Absent for a turn that made no call.
|
|
@@ -2043,7 +2159,13 @@ var TurnRecordSchema = z10.object({
|
|
|
2043
2159
|
*/
|
|
2044
2160
|
sessionDropped: z10.boolean().optional(),
|
|
2045
2161
|
/** The crons whose fire started the turn, which record how it ended. */
|
|
2046
|
-
crons: z10.array(UlidSchema).optional()
|
|
2162
|
+
crons: z10.array(UlidSchema).optional(),
|
|
2163
|
+
/**
|
|
2164
|
+
* The failed fires its prompt named: each of its citizen's crons in its conversation whose latest
|
|
2165
|
+
* fire's turn failed with nothing catching it up (`cronFailure`), but those it carries, with that
|
|
2166
|
+
* turn. Completing it catches each up.
|
|
2167
|
+
*/
|
|
2168
|
+
cronFailures: z10.array(z10.object({ cron: UlidSchema, turnId: UlidSchema })).optional()
|
|
2047
2169
|
});
|
|
2048
2170
|
var RunningTurnSchema = z10.object({
|
|
2049
2171
|
turnId: UlidSchema,
|
|
@@ -2085,6 +2207,8 @@ var TurnHistoryEntrySchema = z10.object({
|
|
|
2085
2207
|
/** The project whose channels' conversation the turn was in, for a turn in the society scope. */
|
|
2086
2208
|
place: NameSchema.optional(),
|
|
2087
2209
|
trigger: z10.string(),
|
|
2210
|
+
/** What a `heartbeat` woke for; absent on turns logged before the board kept it. */
|
|
2211
|
+
heartbeat: HeartbeatCauseSchema.optional(),
|
|
2088
2212
|
exitReason: z10.string().nullable(),
|
|
2089
2213
|
costUsd: z10.number(),
|
|
2090
2214
|
/** The tokens the turn used; absent for turns logged before turns recorded them. */
|
|
@@ -2158,7 +2282,7 @@ var METRICS_WINDOW_MS = {
|
|
|
2158
2282
|
"7d": 7 * 24 * 36e5,
|
|
2159
2283
|
all: null
|
|
2160
2284
|
};
|
|
2161
|
-
var READ_VERBS = ["read_inbox", "search", "get_task"];
|
|
2285
|
+
var READ_VERBS = ["read_inbox", "search", "get_task", "read_image"];
|
|
2162
2286
|
function isBoardAction(tool) {
|
|
2163
2287
|
const verb = /^mcp__board__(.+)$/.exec(tool)?.[1];
|
|
2164
2288
|
return verb !== void 0 && !READ_VERBS.some((read2) => read2 === verb);
|
|
@@ -2172,12 +2296,19 @@ var MetricsSchema = z12.object({
|
|
|
2172
2296
|
/**
|
|
2173
2297
|
* Finished turns that changed nothing on the board. Reflections are left out, since they write
|
|
2174
2298
|
* memory, not the board; turns without a transcript are counted apart, their actions unknown.
|
|
2299
|
+
* Heartbeats are split by what each woke for, and those recorded before the board kept it are a
|
|
2300
|
+
* row without one.
|
|
2175
2301
|
*/
|
|
2176
2302
|
idle: z12.object({
|
|
2177
2303
|
turns: CountSchema,
|
|
2178
2304
|
idle: CountSchema,
|
|
2179
2305
|
unknown: CountSchema,
|
|
2180
|
-
byTrigger: z12.array(z12.object({
|
|
2306
|
+
byTrigger: z12.array(z12.object({
|
|
2307
|
+
trigger: z12.string(),
|
|
2308
|
+
heartbeat: HeartbeatCauseSchema.optional(),
|
|
2309
|
+
turns: CountSchema,
|
|
2310
|
+
idle: CountSchema
|
|
2311
|
+
})),
|
|
2181
2312
|
byAgent: z12.array(z12.object({
|
|
2182
2313
|
agent: NameSchema,
|
|
2183
2314
|
role: NameSchema.nullable(),
|
|
@@ -2355,7 +2486,7 @@ var RunnerConfigSchema = z13.object({
|
|
|
2355
2486
|
});
|
|
2356
2487
|
|
|
2357
2488
|
// packages/shared/dist/version.js
|
|
2358
|
-
var STELLARIS_VERSION = "0.
|
|
2489
|
+
var STELLARIS_VERSION = "0.4.1";
|
|
2359
2490
|
|
|
2360
2491
|
// packages/shared/dist/mentions.js
|
|
2361
2492
|
import { parse, postprocess, preprocess } from "micromark";
|
|
@@ -3119,7 +3250,8 @@ var TurnPayload = z14.object({
|
|
|
3119
3250
|
thread: z14.string().optional(),
|
|
3120
3251
|
channel: z14.string().optional(),
|
|
3121
3252
|
place: z14.string().optional(),
|
|
3122
|
-
trigger: z14.string()
|
|
3253
|
+
trigger: z14.string(),
|
|
3254
|
+
heartbeat: HeartbeatCauseSchema.optional().catch(void 0)
|
|
3123
3255
|
});
|
|
3124
3256
|
var TaskPayload = z14.object({
|
|
3125
3257
|
taskId: z14.string(),
|
|
@@ -3219,13 +3351,12 @@ function computeMetrics(input) {
|
|
|
3219
3351
|
const nothing = actions === 0 ? 1 : 0;
|
|
3220
3352
|
turns2 += 1;
|
|
3221
3353
|
idle += nothing;
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
}
|
|
3354
|
+
const { trigger, heartbeat } = turn.data;
|
|
3355
|
+
const key = heartbeat === void 0 ? trigger : `${trigger}\0${heartbeat}`;
|
|
3356
|
+
const row = turnRows.get(key) ?? { trigger, heartbeat, turns: 0, idle: 0 };
|
|
3357
|
+
turnRows.set(key, { ...row, turns: row.turns + 1, idle: row.idle + nothing });
|
|
3358
|
+
const own = agentRows.get(event.actor) ?? { turns: 0, idle: 0 };
|
|
3359
|
+
agentRows.set(event.actor, { turns: own.turns + 1, idle: own.idle + nothing });
|
|
3229
3360
|
break;
|
|
3230
3361
|
}
|
|
3231
3362
|
case "turn.started": {
|
|
@@ -3381,7 +3512,10 @@ function computeMetrics(input) {
|
|
|
3381
3512
|
turns: turns2,
|
|
3382
3513
|
idle,
|
|
3383
3514
|
unknown,
|
|
3384
|
-
byTrigger: [...turnRows].map((
|
|
3515
|
+
byTrigger: [...turnRows.values()].map(({ heartbeat, ...row }) => ({
|
|
3516
|
+
...row,
|
|
3517
|
+
...heartbeat === void 0 ? {} : { heartbeat }
|
|
3518
|
+
})).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns),
|
|
3385
3519
|
byAgent: [...agentRows].map(([agent, row]) => ({ agent, role: citizens.get(agent)?.role ?? null, ...row })).toSorted((a, b) => b.idle - a.idle || b.turns - a.turns)
|
|
3386
3520
|
},
|
|
3387
3521
|
sentBack: {
|
|
@@ -3441,6 +3575,8 @@ var TurnHistoryPayloadSchema = z15.object({
|
|
|
3441
3575
|
channel: NameSchema.optional(),
|
|
3442
3576
|
place: NameSchema.optional(),
|
|
3443
3577
|
trigger: z15.string().default("manual"),
|
|
3578
|
+
// One a later board added reads as none, so the turn stays in the history.
|
|
3579
|
+
heartbeat: HeartbeatCauseSchema.optional().catch(void 0),
|
|
3444
3580
|
exitReason: z15.string().nullable().default(null),
|
|
3445
3581
|
costUsd: z15.number().default(0),
|
|
3446
3582
|
usage: UsageSchema.optional(),
|
|
@@ -3866,7 +4002,10 @@ var SignInsSchema = z16.object({
|
|
|
3866
4002
|
expiresAt: IsoDateTimeSchema
|
|
3867
4003
|
})).default([])
|
|
3868
4004
|
});
|
|
3869
|
-
var TurnStartPayloadSchema = z16.object({
|
|
4005
|
+
var TurnStartPayloadSchema = z16.object({
|
|
4006
|
+
turnId: UlidSchema,
|
|
4007
|
+
heartbeat: TurnHistoryPayloadSchema.shape.heartbeat
|
|
4008
|
+
});
|
|
3870
4009
|
var CursorsSchema = z16.object({
|
|
3871
4010
|
digest: z16.string().nullable(),
|
|
3872
4011
|
scopes: z16.record(z16.string(), z16.string()).default({}),
|
|
@@ -3938,6 +4077,9 @@ function skillFolderOf(proposal) {
|
|
|
3938
4077
|
function proposedFrom(origin, where) {
|
|
3939
4078
|
return origin !== void 0 && origin.scope === where.scope && origin.thread === where.thread && origin.channel === where.channel && origin.place === where.place;
|
|
3940
4079
|
}
|
|
4080
|
+
function latestLease(a, b) {
|
|
4081
|
+
return a === null ? b : b === null || a >= b ? a : b;
|
|
4082
|
+
}
|
|
3941
4083
|
function megabytes(bytes) {
|
|
3942
4084
|
return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, "");
|
|
3943
4085
|
}
|
|
@@ -3963,8 +4105,10 @@ function plain(value) {
|
|
|
3963
4105
|
return "";
|
|
3964
4106
|
return JSON.stringify(value);
|
|
3965
4107
|
}
|
|
3966
|
-
|
|
3967
|
-
|
|
4108
|
+
var SENTENCE_END = /[.…?!]["'”’»)\]}*_]*$/u;
|
|
4109
|
+
function asSentence(text) {
|
|
4110
|
+
const trimmed = text.trim();
|
|
4111
|
+
return SENTENCE_END.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
3968
4112
|
}
|
|
3969
4113
|
function skillSummary(data, body) {
|
|
3970
4114
|
const description = data["description"];
|
|
@@ -4077,6 +4221,7 @@ var Board = class _Board {
|
|
|
4077
4221
|
/** The turns in flight, which the server hosting them supplies; none until it does. */
|
|
4078
4222
|
turnsInFlight = () => [];
|
|
4079
4223
|
imageSource = null;
|
|
4224
|
+
imageSink = null;
|
|
4080
4225
|
newId = monotonicFactory();
|
|
4081
4226
|
events;
|
|
4082
4227
|
actionCounts = /* @__PURE__ */ new Map();
|
|
@@ -4127,10 +4272,13 @@ var Board = class _Board {
|
|
|
4127
4272
|
/**
|
|
4128
4273
|
* Applies each one-time alignment an existing society has not had yet, and remembers it, so a
|
|
4129
4274
|
* member that later chooses otherwise keeps its choice. A new alignment is a name and a method in
|
|
4130
|
-
* this list; once every board has applied it, both are deleted.
|
|
4275
|
+
* this list; once every board has applied it, both are deleted.
|
|
4131
4276
|
*/
|
|
4132
4277
|
async applyAlignments() {
|
|
4133
|
-
const alignments = [
|
|
4278
|
+
const alignments = [
|
|
4279
|
+
// Whoever may show an image in a post may open one a post shows.
|
|
4280
|
+
{ name: "image-reads-granted", apply: () => this.grantBeside("upload_image", "read_image") }
|
|
4281
|
+
];
|
|
4134
4282
|
const file = path8.join(this.paths.state(), "alignments.json");
|
|
4135
4283
|
const { applied } = await exists(file) ? await readJson(file, AlignmentsSchema) : { applied: [] };
|
|
4136
4284
|
const pending = alignments.filter(({ name }) => !applied.includes(name));
|
|
@@ -4142,6 +4290,17 @@ var Board = class _Board {
|
|
|
4142
4290
|
}
|
|
4143
4291
|
await writeJson(file, { applied: [...applied, ...pending.map(({ name }) => name)] });
|
|
4144
4292
|
}
|
|
4293
|
+
/**
|
|
4294
|
+
* Grants `verb` once to every charter granting `beside`, the work roles' charters in practice;
|
|
4295
|
+
* seed roles have it from their seed.
|
|
4296
|
+
*/
|
|
4297
|
+
async grantBeside(beside, verb) {
|
|
4298
|
+
for (const charter of await this.listRoles()) {
|
|
4299
|
+
if (charter.verbs.includes(beside) && !charter.verbs.includes(verb)) {
|
|
4300
|
+
await this.writeRoleUnlocked(USER_NAME, { ...charter, verbs: [...charter.verbs, verb] }, { verbsAdded: [verb] });
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4145
4304
|
/** Writes every missing seed charter and aligns the existing ones with their seed. */
|
|
4146
4305
|
async ensureSeedRoles() {
|
|
4147
4306
|
await ensureDir(this.paths.roles());
|
|
@@ -4909,6 +5068,46 @@ ${DASHBOARD_LINE}
|
|
|
4909
5068
|
return next;
|
|
4910
5069
|
});
|
|
4911
5070
|
}
|
|
5071
|
+
/**
|
|
5072
|
+
* Pauses or resumes a runner, the user's alone: the whole runner, or with `cli` that CLI on it
|
|
5073
|
+
* alone, each apart from the other. While either holds, no turn it covers starts there; their
|
|
5074
|
+
* wakes wait in the scheduler's queue, and a turn under way finishes. Pausing what is paused, or
|
|
5075
|
+
* resuming what is not, changes nothing and logs nothing.
|
|
5076
|
+
*/
|
|
5077
|
+
async setRunnerPaused(actor, name, change) {
|
|
5078
|
+
this.assertUser(actor, "only the user may pause or resume a runner");
|
|
5079
|
+
const cli = change.cli === void 0 ? void 0 : CliKindSchema.parse(change.cli);
|
|
5080
|
+
return this.mutex.run(async () => {
|
|
5081
|
+
const file = this.paths.runner(name);
|
|
5082
|
+
if (!await exists(file)) {
|
|
5083
|
+
throw new BoardError("NOT_FOUND", `runner ${name} not found`);
|
|
5084
|
+
}
|
|
5085
|
+
const doc = await readMarkdown(file, RunnerSchema);
|
|
5086
|
+
const { paused, pausedClis, ...rest } = doc.data;
|
|
5087
|
+
if (cli !== void 0 && change.paused && !doc.data.clis.includes(cli)) {
|
|
5088
|
+
throw new BoardError("INVALID_STATE", `runner ${name} has no ${cli} to pause`);
|
|
5089
|
+
}
|
|
5090
|
+
const current = cli === void 0 ? paused : pausedClis?.[cli];
|
|
5091
|
+
if (current !== void 0 === change.paused) {
|
|
5092
|
+
return doc.data;
|
|
5093
|
+
}
|
|
5094
|
+
const pause = change.paused ? { at: this.now().toISOString(), by: actor.name } : void 0;
|
|
5095
|
+
const clis = cli === void 0 ? { ...pausedClis } : { ...pausedClis, [cli]: pause };
|
|
5096
|
+
const kept = Object.entries(clis).filter(([, each]) => each !== void 0);
|
|
5097
|
+
const whole = cli === void 0 ? pause : paused;
|
|
5098
|
+
const next = RunnerSchema.parse({
|
|
5099
|
+
...rest,
|
|
5100
|
+
...whole === void 0 ? {} : { paused: whole },
|
|
5101
|
+
...kept.length === 0 ? {} : { pausedClis: Object.fromEntries(kept) }
|
|
5102
|
+
});
|
|
5103
|
+
await writeMarkdown(file, next, doc.body);
|
|
5104
|
+
await this.events.append(change.paused ? "runner.paused" : "runner.resumed", actor.name, {
|
|
5105
|
+
name,
|
|
5106
|
+
...cli === void 0 ? {} : { cli }
|
|
5107
|
+
});
|
|
5108
|
+
return next;
|
|
5109
|
+
});
|
|
5110
|
+
}
|
|
4912
5111
|
/**
|
|
4913
5112
|
* Logs an operations signal as an `ops.signal` event. No channel carries it: the scheduler wakes
|
|
4914
5113
|
* its readers from the event, the runner lists it in their prompts, and the interface's log shows
|
|
@@ -4943,8 +5142,9 @@ ${DASHBOARD_LINE}
|
|
|
4943
5142
|
}
|
|
4944
5143
|
if (event.type === "turn.started") {
|
|
4945
5144
|
const start = TurnStartPayloadSchema.safeParse(event.payload);
|
|
4946
|
-
if (start.success)
|
|
4947
|
-
starts.set(start.data.turnId, event.ts);
|
|
5145
|
+
if (start.success) {
|
|
5146
|
+
starts.set(start.data.turnId, { ts: event.ts, heartbeat: start.data.heartbeat });
|
|
5147
|
+
}
|
|
4948
5148
|
continue;
|
|
4949
5149
|
}
|
|
4950
5150
|
if (event.type !== "turn.completed" && event.type !== "turn.failed") {
|
|
@@ -4952,17 +5152,19 @@ ${DASHBOARD_LINE}
|
|
|
4952
5152
|
}
|
|
4953
5153
|
const parsed = TurnHistoryPayloadSchema.safeParse(event.payload);
|
|
4954
5154
|
if (parsed.success) {
|
|
4955
|
-
const {
|
|
4956
|
-
const
|
|
4957
|
-
if (turnId !== void 0) {
|
|
4958
|
-
starts.delete(turnId);
|
|
5155
|
+
const { heartbeat: ended, ...turn } = parsed.data;
|
|
5156
|
+
const start = turn.turnId === void 0 ? void 0 : starts.get(turn.turnId);
|
|
5157
|
+
if (turn.turnId !== void 0) {
|
|
5158
|
+
starts.delete(turn.turnId);
|
|
4959
5159
|
}
|
|
5160
|
+
const heartbeat = ended ?? start?.heartbeat;
|
|
4960
5161
|
entries.push({
|
|
4961
5162
|
id: event.id,
|
|
4962
5163
|
ts: event.ts,
|
|
4963
5164
|
outcome: event.type === "turn.failed" ? "failed" : "completed",
|
|
4964
|
-
...
|
|
4965
|
-
...
|
|
5165
|
+
...turn,
|
|
5166
|
+
...heartbeat === void 0 ? {} : { heartbeat },
|
|
5167
|
+
...start === void 0 ? {} : { startedAt: start.ts }
|
|
4966
5168
|
});
|
|
4967
5169
|
}
|
|
4968
5170
|
}
|
|
@@ -4992,14 +5194,31 @@ ${DASHBOARD_LINE}
|
|
|
4992
5194
|
return society;
|
|
4993
5195
|
});
|
|
4994
5196
|
}
|
|
4995
|
-
/**
|
|
5197
|
+
/**
|
|
5198
|
+
* Releases every claim whose lease has expired, but for a holder's that its own cron in the
|
|
5199
|
+
* task's thread keeps (`keptLease`). Called by the scheduler on its tick.
|
|
5200
|
+
*/
|
|
4996
5201
|
async expireLeases() {
|
|
4997
5202
|
return this.mutex.run(async () => {
|
|
4998
5203
|
const expired = [];
|
|
4999
5204
|
const now = this.now();
|
|
5205
|
+
let crons;
|
|
5206
|
+
let runners2;
|
|
5000
5207
|
for (const project of await listDirs(this.paths.projects())) {
|
|
5208
|
+
let place;
|
|
5001
5209
|
for (const task of await this.listTasks(project)) {
|
|
5002
|
-
if (task.status
|
|
5210
|
+
if (task.status !== "claimed") {
|
|
5211
|
+
continue;
|
|
5212
|
+
}
|
|
5213
|
+
crons ??= await this.listCrons();
|
|
5214
|
+
runners2 ??= await this.listRunners();
|
|
5215
|
+
place ??= await this.readProject(project);
|
|
5216
|
+
const kept = latestLease(this.keptLease(task, crons, now), await this.pausedLease(task, runners2, place.runner, now));
|
|
5217
|
+
if (kept !== null) {
|
|
5218
|
+
await this.writeTask(project, { ...task, leaseExpiresAt: kept });
|
|
5219
|
+
continue;
|
|
5220
|
+
}
|
|
5221
|
+
if (this.leaseExpired(task, now)) {
|
|
5003
5222
|
const released = await this.writeTask(project, {
|
|
5004
5223
|
...task,
|
|
5005
5224
|
status: "open",
|
|
@@ -5068,20 +5287,24 @@ ${DASHBOARD_LINE}
|
|
|
5068
5287
|
* general channel, one of its other channels, one of its threads, or, in the society scope, a
|
|
5069
5288
|
* project's channels. The heartbeat asks, so that it wakes a member where its unread
|
|
5070
5289
|
* messages are and nowhere else. Society channels count only for a member woken in the society
|
|
5071
|
-
* scope; a project member reads them at its next turn.
|
|
5290
|
+
* scope; a project member reads them at its next turn. Some posts are in the digest but not
|
|
5072
5291
|
* counted, and are read when the member wakes for something else: a task step's, which wakes
|
|
5073
|
-
* whoever it hands work to
|
|
5074
|
-
*
|
|
5075
|
-
* nothing
|
|
5076
|
-
*
|
|
5292
|
+
* whoever it hands work to; the board's own, operations signals and landing announcements, whose
|
|
5293
|
+
* signals that call for action wake their readers directly; and a knowledge topic's notice, which
|
|
5294
|
+
* asks nothing of anyone. In a task's thread a post counts only for whoever the current stage is
|
|
5295
|
+
* for, its holder or, while nobody holds it, a member who may take it, and for the task's creator
|
|
5296
|
+
* when it mentions the user, so the creator can put a wait on the user in; a post that asks the
|
|
5297
|
+
* reader counts wherever it is. A closed thread counts for nothing: its conversation has ended,
|
|
5298
|
+
* and a turn there could only read its posts and be refused a reply, while a wake queued before
|
|
5299
|
+
* it closed, as for a mention, still runs there.
|
|
5077
5300
|
*/
|
|
5078
5301
|
async unreadByConversation(actor) {
|
|
5079
5302
|
const { cursors, upto } = await this.digestSnapshot(actor.name);
|
|
5080
5303
|
const counts = /* @__PURE__ */ new Map();
|
|
5081
|
-
const
|
|
5304
|
+
const threadCounts = /* @__PURE__ */ new Map();
|
|
5082
5305
|
const unscoped = { name: actor.name, role: actor.role };
|
|
5083
|
-
for (const { scope, part, message, news } of await this.digestSince(unscoped, cursors, upto)) {
|
|
5084
|
-
if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name) {
|
|
5306
|
+
for (const { scope, part, message, news, asked } of await this.digestSince(unscoped, cursors, upto)) {
|
|
5307
|
+
if (news === true || message.step !== void 0 || message.author === SYSTEM_ACTOR.name || message.notice?.kind === "knowledge") {
|
|
5085
5308
|
continue;
|
|
5086
5309
|
}
|
|
5087
5310
|
const posted = parseChannelRef(message.channel).project ?? SOCIETY_SCOPE;
|
|
@@ -5090,12 +5313,13 @@ ${DASHBOARD_LINE}
|
|
|
5090
5313
|
}
|
|
5091
5314
|
const thread = threadOfPart(part);
|
|
5092
5315
|
if (thread !== void 0) {
|
|
5093
|
-
let
|
|
5094
|
-
if (
|
|
5095
|
-
|
|
5096
|
-
|
|
5316
|
+
let counted = threadCounts.get(thread);
|
|
5317
|
+
if (counted === void 0) {
|
|
5318
|
+
counted = await this.threadCount(actor, thread);
|
|
5319
|
+
threadCounts.set(thread, counted);
|
|
5097
5320
|
}
|
|
5098
|
-
|
|
5321
|
+
const forReader = counted === "every" || counted !== "none" && asked === true || counted === "asks-user" && message.mentions.includes(USER_NAME);
|
|
5322
|
+
if (!forReader) {
|
|
5099
5323
|
continue;
|
|
5100
5324
|
}
|
|
5101
5325
|
}
|
|
@@ -5248,7 +5472,7 @@ ${DASHBOARD_LINE}
|
|
|
5248
5472
|
include = participates;
|
|
5249
5473
|
}
|
|
5250
5474
|
if (include && message.author !== actor.name) {
|
|
5251
|
-
collected.push({ scope: filedIn, part, message });
|
|
5475
|
+
collected.push({ scope: filedIn, part, message, ...asked ? { asked: true } : {} });
|
|
5252
5476
|
}
|
|
5253
5477
|
}
|
|
5254
5478
|
return collected.toSorted(byId);
|
|
@@ -5468,7 +5692,7 @@ ${current.body}`);
|
|
|
5468
5692
|
name: args.name,
|
|
5469
5693
|
reason: args.reason
|
|
5470
5694
|
});
|
|
5471
|
-
await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${
|
|
5695
|
+
await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${args.name} removed from the society by ${actor.name}: ${asSentence(withoutMentions(args.reason))} Every citizen's skills index stops listing it from its next turn.`);
|
|
5472
5696
|
return removed;
|
|
5473
5697
|
});
|
|
5474
5698
|
}
|
|
@@ -5525,12 +5749,30 @@ ${current.body}`);
|
|
|
5525
5749
|
throw new BoardError("NOT_FOUND", "this board has no runners to read an image from");
|
|
5526
5750
|
}
|
|
5527
5751
|
const bytes = await this.imageSource(actor, args.path);
|
|
5752
|
+
return this.keepImage(actor, bytes, args.path, path8.basename(args.path));
|
|
5753
|
+
}
|
|
5754
|
+
/**
|
|
5755
|
+
* Keeps an image the user posts from a composer, as its bytes, under the same checks as an image
|
|
5756
|
+
* a turn uploads. `name`, the file's, describes it in the markdown answered; without one the
|
|
5757
|
+
* description is empty, for the user to write.
|
|
5758
|
+
*/
|
|
5759
|
+
async postImage(actor, bytes, name) {
|
|
5760
|
+
this.assertUser(actor, "only the user posts an image's bytes; a turn uploads one with upload_image");
|
|
5761
|
+
const given = name === void 0 ? "" : ImageNameSchema.parse(name);
|
|
5762
|
+
return this.keepImage(actor, bytes, given === "" ? "the image" : given, given);
|
|
5763
|
+
}
|
|
5764
|
+
/**
|
|
5765
|
+
* Keeps `bytes` under a new id when they are a PNG, JPEG, GIF, or WebP of at most
|
|
5766
|
+
* `IMAGE_LIMIT_BYTES`, and answers the markdown that shows them, described by `name`. `what`
|
|
5767
|
+
* names the image in a refusal.
|
|
5768
|
+
*/
|
|
5769
|
+
async keepImage(actor, bytes, what, name) {
|
|
5528
5770
|
const type = imageTypeOf(bytes);
|
|
5529
5771
|
if (type === null) {
|
|
5530
|
-
throw new BoardError("VALIDATION", `${
|
|
5772
|
+
throw new BoardError("VALIDATION", `${what} is not a PNG, JPEG, GIF, or WebP image by its first bytes`);
|
|
5531
5773
|
}
|
|
5532
5774
|
if (bytes.byteLength > IMAGE_LIMIT_BYTES) {
|
|
5533
|
-
throw new BoardError("VALIDATION", `${
|
|
5775
|
+
throw new BoardError("VALIDATION", `${what} is ${bytes.byteLength} bytes, over the ${IMAGE_LIMIT_BYTES} an image may be`);
|
|
5534
5776
|
}
|
|
5535
5777
|
return this.mutex.run(async () => {
|
|
5536
5778
|
const id = this.newId();
|
|
@@ -5540,15 +5782,37 @@ ${current.body}`);
|
|
|
5540
5782
|
type,
|
|
5541
5783
|
size: bytes.byteLength
|
|
5542
5784
|
});
|
|
5543
|
-
const
|
|
5785
|
+
const description = imageDescription(name);
|
|
5544
5786
|
return {
|
|
5545
5787
|
id,
|
|
5546
5788
|
type,
|
|
5547
5789
|
size: bytes.byteLength,
|
|
5548
|
-
markdown: ``
|
|
5549
5791
|
};
|
|
5550
5792
|
});
|
|
5551
5793
|
}
|
|
5794
|
+
/**
|
|
5795
|
+
* Opens an image the board keeps for the calling turn, by its id or its `image:<id>` address:
|
|
5796
|
+
* the runner the turn runs on writes the bytes to a file in a folder of the turn's own in the
|
|
5797
|
+
* system temp folder, removed when the turn ends, and the board answers its path, for the
|
|
5798
|
+
* citizen to open with its own file tools, since a post reaches a turn as markdown and an image
|
|
5799
|
+
* in it as its address. Any image the board keeps may be opened: every post, brief, topic, and
|
|
5800
|
+
* dashboard that could show one is in the projection every turn reads.
|
|
5801
|
+
*/
|
|
5802
|
+
async openImage(actor, input) {
|
|
5803
|
+
const args = VerbInputs.read_image.parse(input);
|
|
5804
|
+
await this.authorize(actor, "read_image");
|
|
5805
|
+
if (actor.scope === void 0) {
|
|
5806
|
+
throw new BoardError("INVALID_STATE", "read_image writes the image on the machine a turn runs on, so only a turn can call it");
|
|
5807
|
+
}
|
|
5808
|
+
if (this.imageSink === null) {
|
|
5809
|
+
throw new BoardError("NOT_FOUND", "this board has no runners to write an image to");
|
|
5810
|
+
}
|
|
5811
|
+
const id = imageIdOf(args.image_id);
|
|
5812
|
+
const { type, bytes } = await this.readImage(id);
|
|
5813
|
+
const file = await this.imageSink(actor, { id, type, bytes });
|
|
5814
|
+
return { id, type, size: bytes.byteLength, path: file };
|
|
5815
|
+
}
|
|
5552
5816
|
/** An uploaded image's bytes and kind, which its first bytes tell. */
|
|
5553
5817
|
async readImage(id) {
|
|
5554
5818
|
const file = this.paths.image(UlidSchema.parse(id));
|
|
@@ -5894,6 +6158,13 @@ ${current.body}`);
|
|
|
5894
6158
|
readImagesWith(source) {
|
|
5895
6159
|
this.imageSource = source;
|
|
5896
6160
|
}
|
|
6161
|
+
/**
|
|
6162
|
+
* Where `read_image` writes an image to: the runner the calling turn runs on, through the server
|
|
6163
|
+
* that hosts the runners. Without one, as in the admin CLI, nothing can be opened.
|
|
6164
|
+
*/
|
|
6165
|
+
openImagesWith(sink) {
|
|
6166
|
+
this.imageSink = sink;
|
|
6167
|
+
}
|
|
5897
6168
|
/**
|
|
5898
6169
|
* Refuses to end conversations while another citizen's turn is in one of them: `cut` picks the
|
|
5899
6170
|
* turns that would lose theirs. The caller's own turn is exempt, since ending a conversation
|
|
@@ -6491,7 +6762,7 @@ ${args.summary}`);
|
|
|
6491
6762
|
});
|
|
6492
6763
|
const thread = await this.openThreadUnlocked(actor.name, this.proposalThreadOpening(proposal));
|
|
6493
6764
|
const rationale = args.rationale.trim();
|
|
6494
|
-
await this.appendThreadMessage(actor.name, thread.id, `Proposal ${proposal.id}: ${describeCharter(proposal.kind, proposal.charter)}
|
|
6765
|
+
await this.appendThreadMessage(actor.name, thread.id, `Proposal ${proposal.id}: ${asSentence(describeCharter(proposal.kind, proposal.charter))}${rationale.length === 0 ? "" : `
|
|
6495
6766
|
|
|
6496
6767
|
${rationale}`}`);
|
|
6497
6768
|
return proposal;
|
|
@@ -6729,6 +7000,8 @@ ${rationale}`}`);
|
|
|
6729
7000
|
return this.removeCron(actor, VerbInputs.remove_cron.parse(input));
|
|
6730
7001
|
case "upload_image":
|
|
6731
7002
|
return this.uploadImage(actor, VerbInputs.upload_image.parse(input));
|
|
7003
|
+
case "read_image":
|
|
7004
|
+
return this.openImage(actor, VerbInputs.read_image.parse(input));
|
|
6732
7005
|
default:
|
|
6733
7006
|
throw new BoardError("VALIDATION", `unknown verb ${String(verb)}`);
|
|
6734
7007
|
}
|
|
@@ -6964,6 +7237,7 @@ ${rationale}`}`);
|
|
|
6964
7237
|
...parsed.channel === void 0 ? {} : { channel: parsed.channel },
|
|
6965
7238
|
...parsed.place === void 0 ? {} : { place: parsed.place },
|
|
6966
7239
|
trigger: parsed.trigger.kind,
|
|
7240
|
+
...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
|
|
6967
7241
|
session: parsed.session,
|
|
6968
7242
|
runner: parsed.runner
|
|
6969
7243
|
});
|
|
@@ -6986,6 +7260,11 @@ ${rationale}`}`);
|
|
|
6986
7260
|
for (const id of parsed.crons ?? []) {
|
|
6987
7261
|
await this.recordCronRun(id, parsed, failed);
|
|
6988
7262
|
}
|
|
7263
|
+
if (parsed.exitReason === "completed" || parsed.exitReason === "blocked") {
|
|
7264
|
+
for (const shown of parsed.cronFailures ?? []) {
|
|
7265
|
+
await this.catchUpCron(shown, parsed);
|
|
7266
|
+
}
|
|
7267
|
+
}
|
|
6989
7268
|
if (parsed.exitReason === "stopped") {
|
|
6990
7269
|
await this.events.append("turn.stopped", parsed.stoppedBy ?? USER_NAME, {
|
|
6991
7270
|
...parsed.id === void 0 ? {} : { turnId: parsed.id },
|
|
@@ -7003,6 +7282,7 @@ ${rationale}`}`);
|
|
|
7003
7282
|
...parsed.channel === void 0 ? {} : { channel: parsed.channel },
|
|
7004
7283
|
...parsed.place === void 0 ? {} : { place: parsed.place },
|
|
7005
7284
|
trigger: parsed.trigger.kind,
|
|
7285
|
+
...parsed.trigger.heartbeat === void 0 ? {} : { heartbeat: parsed.trigger.heartbeat },
|
|
7006
7286
|
session: parsed.session,
|
|
7007
7287
|
exitReason: parsed.exitReason,
|
|
7008
7288
|
costUsd: parsed.costUsd,
|
|
@@ -7126,6 +7406,27 @@ ${rationale}`}`);
|
|
|
7126
7406
|
failures: outcome === "failed" ? cron.failures + 1 : outcome === "completed" ? 0 : cron.failures
|
|
7127
7407
|
});
|
|
7128
7408
|
}
|
|
7409
|
+
/**
|
|
7410
|
+
* Marks a cron's failed fire caught up by a completed turn whose prompt named it, while that
|
|
7411
|
+
* failure is still its latest run. Its schedule and its run of failures stay as they are.
|
|
7412
|
+
*/
|
|
7413
|
+
async catchUpCron(shown, record) {
|
|
7414
|
+
if (record.id === void 0 || !await exists(this.paths.cron(shown.cron))) {
|
|
7415
|
+
return;
|
|
7416
|
+
}
|
|
7417
|
+
const cron = await this.readCron(shown.cron);
|
|
7418
|
+
const failure = cronFailure(cron);
|
|
7419
|
+
if (failure === null || failure.turnId !== shown.turnId) {
|
|
7420
|
+
return;
|
|
7421
|
+
}
|
|
7422
|
+
await this.writeCron({
|
|
7423
|
+
...cron,
|
|
7424
|
+
lastRun: {
|
|
7425
|
+
...failure,
|
|
7426
|
+
caughtUp: { turnId: record.id, at: record.endedAt ?? this.now().toISOString() }
|
|
7427
|
+
}
|
|
7428
|
+
});
|
|
7429
|
+
}
|
|
7129
7430
|
/**
|
|
7130
7431
|
* Whether a conversation with a workspace of its own has ended: a thread closed, a task ended
|
|
7131
7432
|
* (for one from before tasks had threads), a channel archived, or a project archived, which ends
|
|
@@ -7165,7 +7466,7 @@ ${rationale}`}`);
|
|
|
7165
7466
|
const ts = this.now().toISOString();
|
|
7166
7467
|
const landed = current.onDone !== "none";
|
|
7167
7468
|
if (landed) {
|
|
7168
|
-
await this.postTaskNote(actor.name, current, input.ok ?
|
|
7469
|
+
await this.postTaskNote(actor.name, current, input.ok ? asSentence(input.detail) : `Landing failed: ${asSentence(input.detail)} The task waits at its last stage for its participants to reshape the plan.`, input.ok ? { action: "landed", stage: current.stage, to: null } : { action: "reopened", stage: current.stage, to: current.stage });
|
|
7169
7470
|
}
|
|
7170
7471
|
if (input.ok) {
|
|
7171
7472
|
const task2 = await this.writeEndedTask(actor.name, location.project, {
|
|
@@ -7185,7 +7486,7 @@ ${rationale}`}`);
|
|
|
7185
7486
|
await this.refreshMember(name);
|
|
7186
7487
|
}
|
|
7187
7488
|
if (landed) {
|
|
7188
|
-
await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${
|
|
7489
|
+
await this.appendMessage(actor.name, channelRef(location.project, task2.channel), `Task ${task2.id} "${task2.title}" is done: ${asSentence(input.detail)}`);
|
|
7189
7490
|
}
|
|
7190
7491
|
return task2;
|
|
7191
7492
|
}
|
|
@@ -7896,7 +8197,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
7896
8197
|
files: taken.files.length,
|
|
7897
8198
|
...meta
|
|
7898
8199
|
});
|
|
7899
|
-
await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${
|
|
8200
|
+
await this.appendMessage(SYSTEM_ACTOR.name, "general", `Skill ${skill.name} ${replaced ? "updated in" : "promoted to"} the society: ${asSentence(skill.summary)} Every citizen's skills index now lists it.`);
|
|
7900
8201
|
return replaced;
|
|
7901
8202
|
}
|
|
7902
8203
|
/**
|
|
@@ -8143,7 +8444,7 @@ Short, curated, loaded on every turn. Keep entries that change future behavior a
|
|
|
8143
8444
|
});
|
|
8144
8445
|
const purpose = withoutMentions(input.purpose);
|
|
8145
8446
|
await this.appendMessage(SYSTEM_ACTOR.name, ref, `Channel ${ref} opened: ${purpose}`);
|
|
8146
|
-
await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${purpose}
|
|
8447
|
+
await this.appendMessage(SYSTEM_ACTOR.name, channelRef(input.project, "general"), `Channel ${ref} opened by ${by}: ${asSentence(purpose)} Subscribe to it to follow its posts; its tasks and mentions reach whoever they concern either way.`);
|
|
8147
8448
|
return ref;
|
|
8148
8449
|
}
|
|
8149
8450
|
/**
|
|
@@ -8495,7 +8796,7 @@ ${charter.purpose}
|
|
|
8495
8796
|
}
|
|
8496
8797
|
assertMayGate(actor, what) {
|
|
8497
8798
|
if (!PLANNING_ROLES.includes(actor.role)) {
|
|
8498
|
-
throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread`);
|
|
8799
|
+
throw new BoardError("FORBIDDEN", `only the user, the steward, and the concierge may ${what}; ask one of them in the task's thread, with a mention`);
|
|
8499
8800
|
}
|
|
8500
8801
|
}
|
|
8501
8802
|
/** The user holds anything; everyone else must be a project member the current stage admits. */
|
|
@@ -8537,6 +8838,52 @@ ${charter.purpose}
|
|
|
8537
8838
|
leaseEnd(now) {
|
|
8538
8839
|
return new Date(now.getTime() + this.leaseMs).toISOString();
|
|
8539
8840
|
}
|
|
8841
|
+
/**
|
|
8842
|
+
* The lease a holder's own crons in the task's thread keep its claim to, when later than the one
|
|
8843
|
+
* it has, else null. While one of them is pending, the lease is renewed for a full lease once less
|
|
8844
|
+
* than half is left, so it never lapses between fires that may be hours apart; once every one is
|
|
8845
|
+
* paused, ended, or failed at its latest fire with nothing catching it up (`cronStoppedAt`), it
|
|
8846
|
+
* runs a full lease from when the last of them stopped.
|
|
8847
|
+
*/
|
|
8848
|
+
keptLease(task, crons, now) {
|
|
8849
|
+
if (task.leaseExpiresAt === void 0) {
|
|
8850
|
+
return null;
|
|
8851
|
+
}
|
|
8852
|
+
const lease = Date.parse(task.leaseExpiresAt);
|
|
8853
|
+
let pending = false;
|
|
8854
|
+
let stopped = null;
|
|
8855
|
+
for (const cron of crons) {
|
|
8856
|
+
if (cron.agent !== task.claimedBy || !firesInTask(cron, task)) {
|
|
8857
|
+
continue;
|
|
8858
|
+
}
|
|
8859
|
+
const at = cronStoppedAt(cron);
|
|
8860
|
+
if (at === null) {
|
|
8861
|
+
pending = true;
|
|
8862
|
+
} else {
|
|
8863
|
+
stopped = Math.max(stopped ?? 0, Date.parse(at));
|
|
8864
|
+
}
|
|
8865
|
+
}
|
|
8866
|
+
const kept = pending ? lease - now.getTime() < this.leaseMs / 2 ? now.getTime() + this.leaseMs : lease : stopped === null ? lease : stopped + this.leaseMs;
|
|
8867
|
+
return kept > lease ? new Date(kept).toISOString() : null;
|
|
8868
|
+
}
|
|
8869
|
+
/**
|
|
8870
|
+
* The lease a holder keeps while the user's pause, or its CLI's limit, holds its turns in the
|
|
8871
|
+
* task's project (`heldOn`), when later than the one it has, else null. It is renewed for a full
|
|
8872
|
+
* lease once less than half is left, as for a pending cron, so it never lapses while the holder
|
|
8873
|
+
* cannot take a turn there, and after the resume or the reset the holder has at least half a
|
|
8874
|
+
* lease to come back to it.
|
|
8875
|
+
*/
|
|
8876
|
+
async pausedLease(task, runners2, runner, now) {
|
|
8877
|
+
if (task.claimedBy === void 0 || task.leaseExpiresAt === void 0) {
|
|
8878
|
+
return null;
|
|
8879
|
+
}
|
|
8880
|
+
const holder = await this.readAgent(task.claimedBy).catch(() => null);
|
|
8881
|
+
if (holder === null || !heldOn(runners2, runner, holder.cli, now)) {
|
|
8882
|
+
return null;
|
|
8883
|
+
}
|
|
8884
|
+
const lease = Date.parse(task.leaseExpiresAt);
|
|
8885
|
+
return lease - now.getTime() < this.leaseMs / 2 ? this.leaseEnd(now) : null;
|
|
8886
|
+
}
|
|
8540
8887
|
leaseExpired(task, now) {
|
|
8541
8888
|
return task.leaseExpiresAt !== void 0 && new Date(task.leaseExpiresAt).getTime() <= now.getTime();
|
|
8542
8889
|
}
|
|
@@ -8622,9 +8969,9 @@ ${charter.purpose}
|
|
|
8622
8969
|
});
|
|
8623
8970
|
}
|
|
8624
8971
|
const verdict = outcome === "approved" ? "Approved" : "Rejected";
|
|
8625
|
-
const why = reason === void 0 ? "" : ` Reason: ${reason}
|
|
8972
|
+
const why = reason === void 0 ? "" : ` Reason: ${asSentence(reason)}`;
|
|
8626
8973
|
const result = provision === void 0 ? "" : ` Provisioned: ${Object.entries(provision).map(([key, value]) => `${key} ${plain(value)}`).join(", ")}.`;
|
|
8627
|
-
const post = `${verdict}: ${describeCharter(proposal.kind, proposal.charter)}
|
|
8974
|
+
const post = `${verdict}: ${asSentence(describeCharter(proposal.kind, proposal.charter))}${why}${result}`;
|
|
8628
8975
|
await this.appendThreadMessage(actor.name, thread.id, actor.name === SYSTEM_ACTOR.name ? withoutMentions(post) : post, { step: { action: outcome } });
|
|
8629
8976
|
await this.endThreadOf(proposalId, actor.name, outcome);
|
|
8630
8977
|
return decision;
|
|
@@ -8649,11 +8996,7 @@ ${charter.purpose}
|
|
|
8649
8996
|
if (task.claimedBy === actor.name || task.createdBy === actor.name || task.stages.some((stage) => stage.agent === actor.name || stage.holders.includes(actor.name))) {
|
|
8650
8997
|
return true;
|
|
8651
8998
|
}
|
|
8652
|
-
|
|
8653
|
-
return false;
|
|
8654
|
-
}
|
|
8655
|
-
const agent = await this.readAgent(actor.name);
|
|
8656
|
-
return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
|
|
8999
|
+
return await this.mayTakeStage(actor, task);
|
|
8657
9000
|
}
|
|
8658
9001
|
if (subject?.kind === "proposal") {
|
|
8659
9002
|
const proposal = await this.readProposal(subject.id);
|
|
@@ -8667,6 +9010,34 @@ ${charter.purpose}
|
|
|
8667
9010
|
}
|
|
8668
9011
|
return false;
|
|
8669
9012
|
}
|
|
9013
|
+
/** Whether a task's current stage waits for a holder and the member may take it. */
|
|
9014
|
+
async mayTakeStage(actor, task) {
|
|
9015
|
+
if (task.status !== "open" || task.completing) {
|
|
9016
|
+
return false;
|
|
9017
|
+
}
|
|
9018
|
+
const agent = await this.readAgent(actor.name);
|
|
9019
|
+
return agent.memberships.includes(task.project) && mayHoldStage(actor, task);
|
|
9020
|
+
}
|
|
9021
|
+
/**
|
|
9022
|
+
* Which of a thread's posts count toward a reader's heartbeat (see `unreadByConversation`). A
|
|
9023
|
+
* thread, or a task, the board cannot find counts as an open topic's would.
|
|
9024
|
+
*/
|
|
9025
|
+
async threadCount(actor, id) {
|
|
9026
|
+
const found = await this.tryFindThread(id);
|
|
9027
|
+
if (found?.thread.state === "closed") {
|
|
9028
|
+
return "none";
|
|
9029
|
+
}
|
|
9030
|
+
const subject = found?.thread.subject;
|
|
9031
|
+
const task = subject?.kind === "task" ? await this.findTask(subject.id).then((location) => location.task, () => null) : null;
|
|
9032
|
+
if (task === null) {
|
|
9033
|
+
return "every";
|
|
9034
|
+
}
|
|
9035
|
+
const stageIsTheirs = task.status === "claimed" ? task.claimedBy === actor.name : await this.mayTakeStage(actor, task);
|
|
9036
|
+
if (stageIsTheirs) {
|
|
9037
|
+
return "every";
|
|
9038
|
+
}
|
|
9039
|
+
return task.createdBy === actor.name ? "asks-user" : "asked";
|
|
9040
|
+
}
|
|
8670
9041
|
async threadScopes() {
|
|
8671
9042
|
const projects = await listDirs(this.paths.projects());
|
|
8672
9043
|
return [this.paths.societyThreads(), ...projects.map((slug) => this.paths.threads(slug))];
|
|
@@ -8771,6 +9142,15 @@ ${charter.purpose}
|
|
|
8771
9142
|
};
|
|
8772
9143
|
|
|
8773
9144
|
// packages/scheduler/dist/wake.js
|
|
9145
|
+
function heartbeatCause(input) {
|
|
9146
|
+
if (input.digestSize > 0) {
|
|
9147
|
+
return "unread";
|
|
9148
|
+
}
|
|
9149
|
+
if (input.claimsHeld > 0) {
|
|
9150
|
+
return "held";
|
|
9151
|
+
}
|
|
9152
|
+
return input.waitingStages > 0 ? "waiting" : null;
|
|
9153
|
+
}
|
|
8774
9154
|
function decideWake(input) {
|
|
8775
9155
|
if (input.paused) {
|
|
8776
9156
|
return { wake: false, reason: "society paused", priority: 0 };
|
|
@@ -8802,7 +9182,7 @@ function decideWake(input) {
|
|
|
8802
9182
|
case "heartbeat":
|
|
8803
9183
|
break;
|
|
8804
9184
|
}
|
|
8805
|
-
if (input
|
|
9185
|
+
if (heartbeatCause(input) === null) {
|
|
8806
9186
|
return { wake: false, reason: "nothing to read, hold, or take", priority: 0 };
|
|
8807
9187
|
}
|
|
8808
9188
|
return {
|
|
@@ -8897,6 +9277,18 @@ var StateSchema = z17.object({
|
|
|
8897
9277
|
lastHeartbeat: z17.record(z17.string(), z17.string()),
|
|
8898
9278
|
/** Waiting stages already signalled, as `task:stage`, so each is logged once while it waits. */
|
|
8899
9279
|
waitingSeen: z17.record(z17.string(), z17.string()).default({}),
|
|
9280
|
+
/**
|
|
9281
|
+
* The last pass at which each waiting stage was held by the user's pause of its runner, as
|
|
9282
|
+
* `task:stage`; its wait counts from there, so a stage that waited through a pause is not
|
|
9283
|
+
* signalled the moment the pause ends.
|
|
9284
|
+
*/
|
|
9285
|
+
waitingHeld: z17.record(z17.string(), z17.string()).default({}),
|
|
9286
|
+
/**
|
|
9287
|
+
* The last operations pass at which each citizen was held by the user's pause of a runner it
|
|
9288
|
+
* works on; its idle time counts from there, so a citizen held through a pause is not signalled
|
|
9289
|
+
* idle the moment the pause ends.
|
|
9290
|
+
*/
|
|
9291
|
+
idleHeld: z17.record(z17.string(), z17.string()).default({}),
|
|
8900
9292
|
/** Queued dispatches survive a restart; the cursor has already moved past the events that made them. */
|
|
8901
9293
|
pending: z17.record(z17.string(), PendingSchema).default({}),
|
|
8902
9294
|
opsReported: z17.record(z17.string(), z17.string()).default({}),
|
|
@@ -9835,10 +10227,14 @@ var Scheduler = class {
|
|
|
9835
10227
|
* A heartbeat per member and scope on the cadence, which then looks at every conversation of the
|
|
9836
10228
|
* scope: the unread messages of its home, of each other channel, and of each thread, a stage the
|
|
9837
10229
|
* member holds, and a stage of an unblocked task waiting that it may take, each in its task's
|
|
9838
|
-
* thread.
|
|
9839
|
-
*
|
|
10230
|
+
* thread. A stage counts for neither while the member has a cron pending in its task's thread
|
|
10231
|
+
* (`cronStoppedAt`: neither paused nor ended, and its latest fire's turn did not fail or was
|
|
10232
|
+
* caught up since), whose fires are its checks there. Each conversation with something wakes on
|
|
10233
|
+
* its own, so a heartbeat never brings one task's business into another's turn, and its trigger
|
|
10234
|
+
* says what it woke for (`heartbeatCause`).
|
|
9840
10235
|
*/
|
|
9841
10236
|
async checkHeartbeats(now) {
|
|
10237
|
+
let pendingCrons;
|
|
9842
10238
|
for (const agent of await this.board.listAgents()) {
|
|
9843
10239
|
if (agent.status !== "active" || agent.cli === null) {
|
|
9844
10240
|
continue;
|
|
@@ -9864,6 +10260,9 @@ var Scheduler = class {
|
|
|
9864
10260
|
this.state.lastHeartbeat[cadence] = iso(now);
|
|
9865
10261
|
unread ??= await this.board.unreadByConversation(actor);
|
|
9866
10262
|
held ??= await this.board.heldClaims(agent.name);
|
|
10263
|
+
pendingCrons ??= (await this.board.listCrons()).filter((cron) => cronStoppedAt(cron) === null);
|
|
10264
|
+
const crons = pendingCrons;
|
|
10265
|
+
const leftToCron = (task) => crons.some((cron) => cron.agent === agent.name && firesInTask(cron, task));
|
|
9867
10266
|
const reasons = /* @__PURE__ */ new Map();
|
|
9868
10267
|
const reasonsIn = (beside) => {
|
|
9869
10268
|
const key = conversationKey2(agent.name, project, beside);
|
|
@@ -9882,13 +10281,13 @@ var Scheduler = class {
|
|
|
9882
10281
|
}
|
|
9883
10282
|
}
|
|
9884
10283
|
for (const task of held) {
|
|
9885
|
-
if (task.project === project) {
|
|
10284
|
+
if (task.project === project && !leftToCron(task)) {
|
|
9886
10285
|
reasonsIn({ thread: { id: task.id, task: true } }).claimsHeld += 1;
|
|
9887
10286
|
}
|
|
9888
10287
|
}
|
|
9889
10288
|
if (project !== SOCIETY_SCOPE) {
|
|
9890
10289
|
for (const task of await this.board.openTasks(project)) {
|
|
9891
|
-
if (mayHoldStage(agent, task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
|
|
10290
|
+
if (mayHoldStage(agent, task) && !leftToCron(task) && (await this.blockersInPlay(task.blockedBy)).length === 0) {
|
|
9892
10291
|
reasonsIn({ thread: { id: task.id, task: true } }).waiting += 1;
|
|
9893
10292
|
}
|
|
9894
10293
|
}
|
|
@@ -9897,7 +10296,16 @@ var Scheduler = class {
|
|
|
9897
10296
|
if (this.pending.has(key) || this.running.has(key)) {
|
|
9898
10297
|
continue;
|
|
9899
10298
|
}
|
|
9900
|
-
const
|
|
10299
|
+
const cause = heartbeatCause({
|
|
10300
|
+
digestSize: reason.digestSize,
|
|
10301
|
+
claimsHeld: reason.claimsHeld,
|
|
10302
|
+
waitingStages: reason.waiting
|
|
10303
|
+
});
|
|
10304
|
+
const trigger = TriggerSchema.parse({
|
|
10305
|
+
kind: "heartbeat",
|
|
10306
|
+
reason: "heartbeat",
|
|
10307
|
+
...cause === null ? {} : { heartbeat: cause }
|
|
10308
|
+
});
|
|
9901
10309
|
const decision = decideWake({
|
|
9902
10310
|
trigger,
|
|
9903
10311
|
digestSize: reason.digestSize,
|
|
@@ -10100,7 +10508,9 @@ var Scheduler = class {
|
|
|
10100
10508
|
*/
|
|
10101
10509
|
async checkWaitingStages(now) {
|
|
10102
10510
|
const stillWaiting = /* @__PURE__ */ new Set();
|
|
10511
|
+
const runners2 = await this.board.listRunners();
|
|
10103
10512
|
for (const project of await this.board.listProjects()) {
|
|
10513
|
+
let members;
|
|
10104
10514
|
for (const task of await this.board.openTasks(project.slug)) {
|
|
10105
10515
|
const stage = currentStage(task);
|
|
10106
10516
|
if (stage === void 0) {
|
|
@@ -10111,7 +10521,14 @@ var Scheduler = class {
|
|
|
10111
10521
|
if (this.state.waitingSeen[key] !== void 0) {
|
|
10112
10522
|
continue;
|
|
10113
10523
|
}
|
|
10114
|
-
|
|
10524
|
+
members ??= await this.board.projectMembers(project.slug);
|
|
10525
|
+
const takers = members.filter((member) => member.cli !== null && mayHoldStage(member, task));
|
|
10526
|
+
if (takers.length > 0 && takers.every((taker) => heldOn(runners2, project.runner, taker.cli, new Date(now)))) {
|
|
10527
|
+
this.state.waitingHeld[key] = iso(now);
|
|
10528
|
+
continue;
|
|
10529
|
+
}
|
|
10530
|
+
const held = this.state.waitingHeld[key];
|
|
10531
|
+
const age = now - Math.max(Date.parse(task.stageSince), held === void 0 ? 0 : Date.parse(held));
|
|
10115
10532
|
if (age < this.timings.waitingStageMs) {
|
|
10116
10533
|
continue;
|
|
10117
10534
|
}
|
|
@@ -10132,6 +10549,11 @@ var Scheduler = class {
|
|
|
10132
10549
|
});
|
|
10133
10550
|
}
|
|
10134
10551
|
}
|
|
10552
|
+
for (const key of Object.keys(this.state.waitingHeld)) {
|
|
10553
|
+
if (!stillWaiting.has(key)) {
|
|
10554
|
+
delete this.state.waitingHeld[key];
|
|
10555
|
+
}
|
|
10556
|
+
}
|
|
10135
10557
|
for (const key of Object.keys(this.state.waitingSeen)) {
|
|
10136
10558
|
if (!stillWaiting.has(key)) {
|
|
10137
10559
|
delete this.state.waitingSeen[key];
|
|
@@ -10197,7 +10619,8 @@ var Scheduler = class {
|
|
|
10197
10619
|
const roles = (await this.board.listRoles()).filter((role) => role.name !== USER_ROLE);
|
|
10198
10620
|
const runners2 = await this.board.listRunners();
|
|
10199
10621
|
const anywhere = new Set(runners2.filter((runner) => runner.status === "connected").flatMap((runner) => runner.capabilities));
|
|
10200
|
-
|
|
10622
|
+
const projects = await this.board.listProjects();
|
|
10623
|
+
for (const project of projects) {
|
|
10201
10624
|
const slug = project.slug;
|
|
10202
10625
|
const tasks = await this.board.listTasks(slug);
|
|
10203
10626
|
const home = runners2.find((runner) => runner.name === project.runner);
|
|
@@ -10289,7 +10712,13 @@ var Scheduler = class {
|
|
|
10289
10712
|
}
|
|
10290
10713
|
}
|
|
10291
10714
|
const crons = (await this.board.listCrons()).filter((cron) => cron.ended === void 0);
|
|
10292
|
-
|
|
10715
|
+
const agents = await this.board.listAgents();
|
|
10716
|
+
for (const name of Object.keys(this.state.idleHeld)) {
|
|
10717
|
+
if (!agents.some((agent) => agent.name === name && agent.status === "active")) {
|
|
10718
|
+
delete this.state.idleHeld[name];
|
|
10719
|
+
}
|
|
10720
|
+
}
|
|
10721
|
+
for (const agent of agents) {
|
|
10293
10722
|
if (agent.status !== "active" || agent.cli === null) {
|
|
10294
10723
|
continue;
|
|
10295
10724
|
}
|
|
@@ -10301,7 +10730,13 @@ var Scheduler = class {
|
|
|
10301
10730
|
last = Math.max(last, Date.parse(ended));
|
|
10302
10731
|
}
|
|
10303
10732
|
}
|
|
10304
|
-
const
|
|
10733
|
+
const at = new Date(now);
|
|
10734
|
+
const held = heldOn(runners2, agent.homeRunner, agent.cli, at) || projects.some((project) => agent.memberships.includes(project.slug) && heldOn(runners2, project.runner, agent.cli, at));
|
|
10735
|
+
if (held) {
|
|
10736
|
+
this.state.idleHeld[agent.name] = iso(now);
|
|
10737
|
+
}
|
|
10738
|
+
const heldAt = this.state.idleHeld[agent.name];
|
|
10739
|
+
const idle = now - Math.max(last, heldAt === void 0 ? 0 : Date.parse(heldAt));
|
|
10305
10740
|
if (idle >= this.timings.idleMemberMs) {
|
|
10306
10741
|
signals.push({
|
|
10307
10742
|
kind: "idle_member",
|
|
@@ -10654,20 +11089,21 @@ var Scheduler = class {
|
|
|
10654
11089
|
import { randomUUID } from "node:crypto";
|
|
10655
11090
|
|
|
10656
11091
|
// packages/turn-host/dist/render.js
|
|
11092
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
10657
11093
|
var TURN_CONTRACT = [
|
|
10658
11094
|
"- Every turn belongs to one conversation, named at the top of your prompt: your home conversation in a project or the society, for its general channel and for work tied to no other channel and no thread; the conversation of one other channel of the place, a workstream such as a release; outside projects, the conversation of one project's channels, general and its workstreams together, for posts there that ask something of you while you are not a member; or the conversation of one thread you take part in, a task's, a proposal's, or a topic's. Each has its own session and its own turns, which may run while this one does. The prompt carries this conversation only: in a thread, its new messages, or the whole thread when the conversation is new, and in a task's thread the task and its stage; in a channel's, a project's, or at home, that channel's, that project's channels', or general's messages that concern you. Read anything else you need with get_task, search, and the board's files, and leave other channels', other threads', and other projects' work to their own turns.",
|
|
10659
|
-
"- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it.",
|
|
11095
|
+
"- Act through the board tools (the `board` MCP server). Holding a stage is a lease; every turn that touches the task renews it, and so does the board while a cron of yours in the task's thread is pending.",
|
|
10660
11096
|
"- Work only inside your worktree. In a task's conversation it is a worktree of its own on the task's branch `task/<id>`: commit your work there. The branch is checked out in one worktree at a time and kept for the turns of whoever holds the task's stage, so a turn of anyone else there, or one that starts while another turn has the branch, works detached at its tip, which Your workspace then says; its commits go on the branch by a fast-forward when a turn in the task ends with the branch free, and the task's thread is told of commits that no longer build on its tip. After each turn the runner commits what you left on the branch and lets it go for the next holder. Your home worktree is on your own branch, `agent/<name>`, for work tied to no task; nothing lands from it by itself. In a proposal's, a topic's, or a channel's conversation it is a worktree of its own, detached at the project's default branch, which goes when the thread closes or the channel is archived, so work that should last goes on a task's branch. Before each turn the runner fetches the project's remote and brings what holds no work of yours up to the default branch: your own branch when it has no commits of its own, a task's branch nobody has worked on, and a detached worktree with nothing uncommitted; what it leaves where it is, and why, is under Your workspace in the prompt. It never merges or commits for you.",
|
|
10661
11097
|
"- Posts that would wake this conversation may arrive while you work, delivered into the turn under a heading that says so and formatted like the digest. They are part of this turn: act on them now, after the step in hand, or not at all, as with the digest. The user may also stop a turn; the next turn is told.",
|
|
10662
11098
|
"- Silence is allowed. If the digest needs no reply, post nothing.",
|
|
10663
|
-
"- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by sleeping or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. While you check your own stage's work on a schedule, such as a running job, CI, or a deploy, keep holding the stage: the cron sets when you check, and
|
|
11099
|
+
"- To come back to something later, set a cron with create_cron: a note on what to do then, and a five-field cron expression or one time with at. It wakes you in this conversation unless you name another, at most every fifteen minutes, and every fire is a paid turn, so end a cron with remove_cron once it has done its job. Never wait by sleeping or leaving a process running: a turn's board token ends with the turn, so nothing it leaves behind can act on the board. While you check your own stage's work on a schedule, such as a running job, CI, or a deploy, keep holding the stage: the cron sets when you check, and while it is pending in the task's thread, neither ended nor paused, with its latest fire's turn not failed or a turn of yours there completed since, the board keeps your lease and the heartbeat leaves the stage to it, waking you there only for a post you have not read. Release a stage only to hand it on, or once its task's blocked_by names the tasks it waits on: the stage then wakes nobody until the last of them ends. Any other wait on someone else, such as the user or an outside party, is a stage of its own, not a held stage. The society's crons are under society/crons/ in the board projection.",
|
|
10664
11100
|
"- An @mention wakes the citizen named, and every wake costs a turn. Address someone with @ only when you need them to act; when you merely refer to citizens, write their names plainly. A name in a code span or a code block is no mention, so code keeps its @ as written. @user wakes nobody, since the user takes no turns; it is what puts a question before the user, so use it only when you need the user to answer or decide.",
|
|
10665
|
-
"- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
|
|
10666
|
-
"- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, ``, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt.",
|
|
11101
|
+
"- A thread is a conversation off a channel that reaches only its participants and whoever is mentioned in it. Every task has one, opened with it under the task's id on the channel of its project it was filed in, general unless it was filed in another: talk about a task there, with post_message and its thread_id, and the notes you give advance_task and update_task are posted there as you. Its participants are the task's creator, its stage holders and assignees, whoever may take the stage that waits, and anyone who posted; it closes when the task ends. In a task's thread, only the current stage's holder, or while nobody holds it whoever may take it, wakes for your post, and a post that asks the user also wakes the task's creator; mention anyone else you need, including the concierge for a plan change. Every proposal has one too, in governance, opened with its pitch and closed by its decision. Open other threads with open_thread on a channel with a title, for any other topic; close_thread posts your summary to its channel.",
|
|
11102
|
+
"- To show an image in a post, such as a screenshot or a chart, save it anywhere you can read it, such as a folder from `mktemp -d`, call upload_image with its path, and put the markdown it returns in the post, ``, with what the image shows as its alt text: the board keeps the image, so it shows in any conversation after the file is gone. It takes a PNG, JPEG, GIF, or WebP of at most 8 MB from your worktree, your home, or the system temp folder. An image committed on a task's branch, linked by its path in the task's worktree, still shows in place, and so does one at an https address. Never put an image or any other file into a message as base64 or a data: URL: the board does not show it, and every reader of the conversation gets it in full in their prompt. To see an image a post, a brief, or a topic shows as `image:<id>`, call read_image with the id: it returns the path of a file of this turn's own that holds the image, removed when the turn ends, which you open with the tool that shows you an image, Read in Claude Code or view_image in Codex.",
|
|
10667
11103
|
"- Route every lesson: about you, your craft, or the user, write it to memory/core.md in your home directory, and keep that file short by moving detail to memory/<topic>.md, your archive; how a codebase behaves (architecture, conventions, gotchas, how to build, test, and verify it) goes in its AGENTS.md or README, committed on a task's branch like any change to the code, or proposed in the task's thread or the project channel when it is outside the task at hand; a durable fact about a project as a place (runners, access, flow, where results live) goes through `write_knowledge` on that project; something the whole project should know now, post it to the project channel, and anything about one task, in the task's thread.",
|
|
10668
11104
|
'- A procedure you have followed twice is a candidate for a skill, which it is when it makes a citizen better at a kind of work on any project: write it to skills/<name>/SKILL.md in your home, frontmatter with `name` and a one-line `description` and then the steps, and your skills index lists it from the next turn. Files the steps use, such as scripts/ and references/, go in the same folder, named in SKILL.md by paths relative to it. Propose it with kind "skill" when more than one citizen would use it and nothing in it is tied to one place; a skill only you use stays yours, however many projects you use it on.',
|
|
10669
11105
|
"- To take a published skill for yourself, install it in a temporary folder and copy it into your home: in a folder from `mktemp -d`, `npx -y skills add <source> --skill <name> -a universal --copy -y` writes `.agents/skills/<name>/` and a lock file there, and you copy that skill folder to skills/<name>/ in your home, named in lowercase letters, digits, and hyphens. Never install one inside a worktree, where it would be committed into the project, or with -g, which writes to the runner's own home, shared by every citizen on it and listed in no skills index.",
|
|
10670
|
-
"- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill,
|
|
11106
|
+
"- Report memoryUpdated: true in the status object whenever you changed memory/core.md or a skill. A change to your instructions reaches each of your conversations at its next turn, whose prompt then carries them whole, replacing what that conversation had; a turn already running keeps what it started with.",
|
|
10671
11107
|
"- Other turns of yours may be running at the same time and share your home. Change a file there with your edit tool, which refuses a file changed since you read it, and never rewrite one whole from an earlier read, with a shell redirect or a script, or you erase what another of your turns wrote.",
|
|
10672
11108
|
"- To ask the user something, mention @user where the question belongs: the task's thread for a task, the proposal's thread for a proposal, and for anything else a thread you open on your scope's general channel, titled with the question. The user answers in the same thread, and the answer comes back to that thread's conversation. A proposal waiting on the user is already in front of them and needs no mention.",
|
|
10673
11109
|
"- End every turn with the status object: summary, claims held, what is blocked, and whether the user must decide. If you report that the user must decide, mentioned @user nowhere this turn, and proposed nothing for the user to decide, the runner asks in this turn's thread when it has one, and otherwise opens a thread on your scope's general channel with your summary as the question."
|
|
@@ -10676,9 +11112,9 @@ var PLANNING = [
|
|
|
10676
11112
|
"- A task is a plan of stages between open and done. Hold the current stage with claim_task and finish it with advance_task; the next stage becomes current, and past the last one the task is done.",
|
|
10677
11113
|
"- Name stages by what gets done. Plan the next few steps rather than everything, and reshape with plan_task as the work teaches you.",
|
|
10678
11114
|
"- Assign a stage to a role when anyone in it could do it, to a citizen only when it must be them, and to nobody when anyone in the project could. A citizen named on a stage must be a member of the task's project; add them first. A stage wakes everyone it is open to when it comes up, so the narrower the assignee, the fewer turns it costs.",
|
|
10679
|
-
"- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, the holder of the current stage sends the task back to an earlier stage with update_task, and so may the user, the steward, and the concierge; anyone else asks the concierge in the task's thread.",
|
|
11115
|
+
"- Reshape rather than force: another round is an inserted stage, and a wait on something outside the society is a stage named for what it waits on. When a check finds work unfinished, the holder of the current stage sends the task back to an earlier stage with update_task, and so may the user, the steward, and the concierge; anyone else asks the concierge in the task's thread, with a mention.",
|
|
10680
11116
|
"- A task that cannot start until other tasks finish names them with update_task blocked_by, and whoever holds its stage then releases it, since the heartbeat still wakes a holder. While any of them is in play, a stage nobody holds wakes nobody, neither when it comes up nor on a heartbeat; once the last has ended, done or abandoned, the stage wakes as if it had just come up, or wakes its holder. Nobody needs to clear the block or mention anyone when they end; change blocked_by only when the plan changes.",
|
|
10681
|
-
"- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread when a gate should change.",
|
|
11117
|
+
"- A gated stage is an independent check: nobody who held an earlier stage of the task may hold it, and only the user, the steward, and the concierge may add, remove, move, reassign, or ungate it. Ask them in the task's thread, with a mention, when a gate should change.",
|
|
10682
11118
|
"- Commit your work on the task's branch before you advance, and say what you did in the advance note; it is the handover the next holder reads in the task's thread."
|
|
10683
11119
|
].join("\n");
|
|
10684
11120
|
var GOVERNANCE = [
|
|
@@ -10737,6 +11173,10 @@ function renderInstructions(input) {
|
|
|
10737
11173
|
return `${sections.join("\n")}
|
|
10738
11174
|
`;
|
|
10739
11175
|
}
|
|
11176
|
+
function instructionsHash(input) {
|
|
11177
|
+
const { onboarding: _first, ...kept } = input;
|
|
11178
|
+
return createHash3("sha256").update(renderInstructions(kept)).digest("hex");
|
|
11179
|
+
}
|
|
10740
11180
|
|
|
10741
11181
|
// packages/turn-host/dist/prompt.js
|
|
10742
11182
|
var REFLECTION_INTRO = "This turn is for your memory; take no new work, and answer unread messages only where a reply is needed. Read memory/core.md and your recent turn records, then:";
|
|
@@ -10749,6 +11189,7 @@ var REFLECTION_STEPS = [
|
|
|
10749
11189
|
];
|
|
10750
11190
|
var SAVE_WHAT_IS_OPEN = "- This conversation's session ends with this turn, and the next turn here starts a new one that knows only your home's files and the board: write anything still open into memory/core.md, or the archive it points to, before you end.";
|
|
10751
11191
|
var REFLECTION_REPORT = "- Report memoryUpdated: true in the status object when core.md or a skill changed.";
|
|
11192
|
+
var INSTRUCTIONS_CHANGED = "Your instructions changed since this conversation last received them. Between the tags below are the whole of them now: they replace the instructions this conversation began with and any earlier text like this, so where the two differ, follow these, and a rule missing from them no longer applies.";
|
|
10752
11193
|
var MAX_BODY_CHARS = 1500;
|
|
10753
11194
|
var MAX_PROFILE_CHARS = 200;
|
|
10754
11195
|
function clip(text) {
|
|
@@ -10803,12 +11244,23 @@ function quotaPhrase(cli, quota, now) {
|
|
|
10803
11244
|
}
|
|
10804
11245
|
function runnerLine(runner, projects, now) {
|
|
10805
11246
|
const living = projects.filter((project) => project.runner === runner.name).map((project) => project.slug);
|
|
10806
|
-
const state =
|
|
11247
|
+
const state = [
|
|
11248
|
+
runner.status === "connected" ? "connected" : `away${runner.lastSeen === void 0 ? "" : ` since ${runner.lastSeen}`}`,
|
|
11249
|
+
...runner.paused === void 0 ? [] : [`paused by the user since ${runner.paused.at}`]
|
|
11250
|
+
].join(", ");
|
|
11251
|
+
const pauses = runner.clis.flatMap((cli) => {
|
|
11252
|
+
const pause = runner.pausedClis?.[cli];
|
|
11253
|
+
const hold = runnerLimitHold(runner, cli, now);
|
|
11254
|
+
return [
|
|
11255
|
+
...pause === void 0 ? [] : [`${cli} paused by the user since ${pause.at}`],
|
|
11256
|
+
...hold === null ? [] : [`${cli} held by its limit until ${hold.until}`]
|
|
11257
|
+
];
|
|
11258
|
+
});
|
|
10807
11259
|
const quotas = runner.clis.flatMap((cli) => {
|
|
10808
11260
|
const quota = runner.quotas?.[cli];
|
|
10809
11261
|
return quota === void 0 ? [] : [quotaPhrase(cli, quota, now)];
|
|
10810
11262
|
});
|
|
10811
|
-
return `- ${runner.name}: ${state}; ${runner.os}; CLIs ${runner.clis.length === 0 ? "none" : runner.clis.join(", ")}; offers ${runner.capabilities.length === 0 ? "nothing beyond its CLIs" : runner.capabilities.join(", ")}; ${living.length === 0 ? "no projects" : `projects ${living.join(", ")}`}${quotas.length === 0 ? "" : `; quota ${quotas.join("; ")}`}`;
|
|
11263
|
+
return `- ${runner.name}: ${state}; ${runner.os}; CLIs ${runner.clis.length === 0 ? "none" : runner.clis.join(", ")}; offers ${runner.capabilities.length === 0 ? "nothing beyond its CLIs" : runner.capabilities.join(", ")}; ${living.length === 0 ? "no projects" : `projects ${living.join(", ")}`}${pauses.map((pause) => `; ${pause}`).join("")}${quotas.length === 0 ? "" : `; quota ${quotas.join("; ")}`}`;
|
|
10812
11264
|
}
|
|
10813
11265
|
function profileLine(profile) {
|
|
10814
11266
|
const line = profile.split("\n").map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#"));
|
|
@@ -10845,7 +11297,7 @@ function messageLines(messages, threads) {
|
|
|
10845
11297
|
function cronLines(cron, timeZone) {
|
|
10846
11298
|
const previous = cron.lastRun === void 0 ? "" : ` The turn of its previous fire ${cron.lastRun.outcome === "stopped" ? "was stopped by the user" : cron.lastRun.outcome} at ${cron.lastRun.at}.`;
|
|
10847
11299
|
const next = nextFire(cron, timeZone);
|
|
10848
|
-
const state = isOneTime(cron.schedule) ? " It fired once, as set, and has ended." : cron.ended !== void 0 ? ` ${cron.ended.by} has ended it since it fired (${cron.ended.reason}), so do what it asks only if it still matters.` : next === null ? "" : ` Next it fires at ${next.toISOString()}.`;
|
|
11300
|
+
const state = isOneTime(cron.schedule) ? " It fired once, as set, and has ended." : cron.ended !== void 0 ? ` ${cron.ended.by} has ended it since it fired (${cron.ended.reason}), so do what it asks only if it still matters.` : cron.paused !== void 0 ? ` ${cron.paused.by} has paused it since it fired, so it fires no more until resumed.` : next === null ? "" : ` Next it fires at ${next.toISOString()}.`;
|
|
10849
11301
|
return [
|
|
10850
11302
|
`- "${cron.title}" (cron ${cron.id}), set by ${cron.createdBy} at ${cron.createdAt}, fires ${describeSchedule(cron.schedule, timeZone)}.${previous}${state}`,
|
|
10851
11303
|
"",
|
|
@@ -10899,6 +11351,10 @@ function buildTurnPrompt(input) {
|
|
|
10899
11351
|
if (dispatch.trigger.kind === "user_post") {
|
|
10900
11352
|
lines.push("The user posted. Route it: answer where it was posted, in its thread when it came in one, or create what it needs: a task with a plan whose stages name who does them, a thread, or a project, adding citizens to a project first when they are not members. Stay silent when the user already addressed a citizen and nothing else is needed.");
|
|
10901
11353
|
}
|
|
11354
|
+
const changed = input.changedInstructions ?? null;
|
|
11355
|
+
if (changed !== null) {
|
|
11356
|
+
lines.push("", "## Your instructions changed", "", INSTRUCTIONS_CHANGED, "", "<instructions>", changed.trimEnd(), "</instructions>");
|
|
11357
|
+
}
|
|
10902
11358
|
const conversation = input.conversation ?? null;
|
|
10903
11359
|
lines.push("", "## This conversation", "");
|
|
10904
11360
|
if (conversation === null && dispatch.channel !== void 0) {
|
|
@@ -10947,6 +11403,11 @@ function buildTurnPrompt(input) {
|
|
|
10947
11403
|
"Its CLI could not resume this conversation's session, so the session was dropped and this turn starts a new one: nothing of your earlier turns here carries over but what is on the board and in your home."
|
|
10948
11404
|
] : [], "The worktree may hold uncommitted changes. Run git status before doing anything else.");
|
|
10949
11405
|
}
|
|
11406
|
+
const failed = input.failedCrons ?? [];
|
|
11407
|
+
if (failed.length > 0) {
|
|
11408
|
+
const timeZone = input.clock?.timeZone ?? DEFAULT_TIME_ZONE;
|
|
11409
|
+
lines.push("", "## Cron fires that did not finish", "", failed.length === 1 ? "The turn of this cron's latest fire here failed, so what its note asks may not be done:" : "The turns of these crons' latest fires here failed, so what their notes ask may not be done:", "", ...failed.flatMap((cron) => cronLines(cron, timeZone)));
|
|
11410
|
+
}
|
|
10950
11411
|
if (last?.trigger.kind === "reflection" && last.exitReason === "completed" && last.sessionDropped === true) {
|
|
10951
11412
|
lines.push("", "## Your session started afresh", "", "Your last turn here was a reflection, and this conversation's session ended with it, so this turn starts a new one: what you kept is in memory/core.md and the archive it points to, and what your earlier turns did is on the board, in your posts and the threads and tasks you worked in, which search and the board's files reach.");
|
|
10952
11413
|
}
|
|
@@ -10966,7 +11427,7 @@ function buildTurnPrompt(input) {
|
|
|
10966
11427
|
}
|
|
10967
11428
|
const machines = input.runners;
|
|
10968
11429
|
if (machines !== void 0 && machines !== null) {
|
|
10969
|
-
lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window
|
|
11430
|
+
lines.push("", "## Runners", "", "The machines turns run on. A project lives on the runner that takes its first turn, one that offers every capability the project and that turn's task require, and moves only when the user moves it. A task's required_capabilities must name capabilities exactly as a runner below offers them; a task needing one its project's runner lacks waits. A runner's quota is what each CLI's login there last reported of its plan, in percent of each window. A runner, or a CLI on it, paused by the user starts none of the turns it would run until the user resumes it, and a CLI held by its limit starts none of its turns there until the limit lifts, at the reset the CLI gave or, with none given, half an hour after the report: they wait, and the stages their citizens hold keep their leases.", "");
|
|
10970
11431
|
if (machines.runners.length === 0) {
|
|
10971
11432
|
lines.push("None registered yet.");
|
|
10972
11433
|
}
|
|
@@ -11211,7 +11672,7 @@ var TurnHost = class {
|
|
|
11211
11672
|
worktree: PATH_TOKENS.worktree,
|
|
11212
11673
|
otherChannels: (project?.channels ?? []).map((name) => channelRef(dispatch.project, name)).filter((ref) => !agent.subscriptions.includes(ref))
|
|
11213
11674
|
} : null;
|
|
11214
|
-
const
|
|
11675
|
+
const rendered = {
|
|
11215
11676
|
agentName: agent.name,
|
|
11216
11677
|
roleCharter: await this.board.readAgentRoleBody(agent.name),
|
|
11217
11678
|
memoryCore: await this.board.readMemoryCore(agent.name),
|
|
@@ -11229,7 +11690,11 @@ var TurnHost = class {
|
|
|
11229
11690
|
}))
|
|
11230
11691
|
],
|
|
11231
11692
|
...onboarding === null ? {} : { onboarding }
|
|
11232
|
-
}
|
|
11693
|
+
};
|
|
11694
|
+
const instructions = renderInstructions(rendered);
|
|
11695
|
+
const instructionsNow = instructionsHash(rendered);
|
|
11696
|
+
const instructionsHeld = !newSession && lastTurn?.session === recorded ? lastTurn.sessionInstructionsHash : void 0;
|
|
11697
|
+
const changedInstructions = !newSession && instructionsHeld !== instructionsNow;
|
|
11233
11698
|
const societyView = charter.wakeTriggers.includes(USER_POST_TRIGGER2) ? {
|
|
11234
11699
|
projects: (await this.board.listProjects()).filter((each) => each.archived === void 0),
|
|
11235
11700
|
members: await this.board.listMembers()
|
|
@@ -11270,6 +11735,11 @@ var TurnHost = class {
|
|
|
11270
11735
|
crons.push(cron);
|
|
11271
11736
|
}
|
|
11272
11737
|
}
|
|
11738
|
+
const failedCrons = (await this.board.listCrons()).filter((cron) => cron.agent === agent.name && cron.scope === dispatch.project && conversationPart(cron) === part && cronFailure(cron) !== null && !(dispatch.crons ?? []).includes(cron.id));
|
|
11739
|
+
const cronFailures = failedCrons.flatMap((cron) => {
|
|
11740
|
+
const failure = cronFailure(cron);
|
|
11741
|
+
return failure === null ? [] : [{ cron: cron.id, turnId: failure.turnId }];
|
|
11742
|
+
});
|
|
11273
11743
|
const prompt = buildTurnPrompt({
|
|
11274
11744
|
dispatch,
|
|
11275
11745
|
clock: { now: this.now(), timeZone: society.timezone },
|
|
@@ -11282,6 +11752,8 @@ var TurnHost = class {
|
|
|
11282
11752
|
waitingStages: waiting,
|
|
11283
11753
|
project,
|
|
11284
11754
|
lastTurn,
|
|
11755
|
+
failedCrons,
|
|
11756
|
+
...changedInstructions ? { changedInstructions: instructions } : {},
|
|
11285
11757
|
onboarding,
|
|
11286
11758
|
afreshAfterReflection,
|
|
11287
11759
|
societyView,
|
|
@@ -11297,7 +11769,9 @@ var TurnHost = class {
|
|
|
11297
11769
|
...base,
|
|
11298
11770
|
session: recorded ?? null,
|
|
11299
11771
|
sessionCostUsd: costSoFarUsd,
|
|
11300
|
-
...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar }
|
|
11772
|
+
...usageSoFar === void 0 ? {} : { sessionUsage: usageSoFar },
|
|
11773
|
+
...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
|
|
11774
|
+
...cronFailures.length === 0 ? {} : { cronFailures }
|
|
11301
11775
|
});
|
|
11302
11776
|
if (record.id === void 0) {
|
|
11303
11777
|
throw new Error("the board gave the turn no id");
|
|
@@ -11317,6 +11791,7 @@ var TurnHost = class {
|
|
|
11317
11791
|
costSoFarUsd,
|
|
11318
11792
|
...usageSoFar === void 0 ? {} : { usageSoFar },
|
|
11319
11793
|
instructions,
|
|
11794
|
+
instructionsHash: instructionsNow,
|
|
11320
11795
|
prompt,
|
|
11321
11796
|
mcp: { url: this.mcpUrl, token },
|
|
11322
11797
|
limits: { timeoutMs: this.turnTimeoutMs, maxTurns: this.maxTurns },
|
|
@@ -11340,6 +11815,7 @@ var TurnHost = class {
|
|
|
11340
11815
|
stopRequestedBy: null,
|
|
11341
11816
|
held: held.map((each) => each.id),
|
|
11342
11817
|
costSoFarUsd,
|
|
11818
|
+
instructionsNow,
|
|
11343
11819
|
resumeRetries,
|
|
11344
11820
|
configuredModel: agent.model ?? null,
|
|
11345
11821
|
transcript: [],
|
|
@@ -11510,7 +11986,10 @@ var TurnHost = class {
|
|
|
11510
11986
|
const resumeRetries = outcome.resumeFailed === "retry" ? turn.resumeRetries + 1 : void 0;
|
|
11511
11987
|
const dropSession = job.session !== null && (outcome.resumeFailed === "lost" || (resumeRetries ?? 0) >= RESUME_RETRIES);
|
|
11512
11988
|
const reflected = turn.afreshAfterReflection && turn.record.trigger.kind === "reflection" && outcome.exitReason === "completed";
|
|
11513
|
-
const { sessionUsage: _started, ...begun } = turn.record;
|
|
11989
|
+
const { sessionUsage: _started, sessionInstructionsHash: heldBefore, ...begun } = turn.record;
|
|
11990
|
+
const promptDelivered = outcome.exitReason === "completed" || outcome.exitReason === "blocked" || outcome.exitReason === "stopped";
|
|
11991
|
+
const instructionsTaken = outcome.exitReason === "stopped" ? outcome.contextTokens !== void 0 : promptDelivered;
|
|
11992
|
+
const instructionsHeld = job.session === null ? outcome.session === null ? void 0 : turn.instructionsNow : instructionsTaken ? turn.instructionsNow : heldBefore;
|
|
11514
11993
|
const finished = {
|
|
11515
11994
|
...begun,
|
|
11516
11995
|
session: outcome.session,
|
|
@@ -11522,6 +12001,7 @@ var TurnHost = class {
|
|
|
11522
12001
|
costUsd: outcome.costUsd,
|
|
11523
12002
|
sessionCostUsd: outcome.sessionCostUsd ?? turn.costSoFarUsd,
|
|
11524
12003
|
...outcome.sessionUsage === void 0 ? {} : { sessionUsage: outcome.sessionUsage },
|
|
12004
|
+
...instructionsHeld === void 0 ? {} : { sessionInstructionsHash: instructionsHeld },
|
|
11525
12005
|
...outcome.contextTokens === void 0 ? {} : { contextTokens: outcome.contextTokens },
|
|
11526
12006
|
toolCalls: turn.transcript.filter((entry) => entry.event.type === "tool_call").length,
|
|
11527
12007
|
model: outcome.model ?? turn.configuredModel,
|
|
@@ -11556,7 +12036,7 @@ var TurnHost = class {
|
|
|
11556
12036
|
} else if (outcome.session !== null && outcome.session !== job.session) {
|
|
11557
12037
|
await this.board.writeSession(job.agent, job.scope, job.cli, outcome.session, turn.runner, part);
|
|
11558
12038
|
}
|
|
11559
|
-
if (
|
|
12039
|
+
if (promptDelivered) {
|
|
11560
12040
|
await this.board.setDigestCursor(job.agent, job.scope, turn.delivered, part);
|
|
11561
12041
|
await this.renewLeases(turn.actor, turn.held);
|
|
11562
12042
|
if (outcome.status?.needsUserDecision === true) {
|
|
@@ -11844,6 +12324,9 @@ var TurnHost = class {
|
|
|
11844
12324
|
|
|
11845
12325
|
// packages/turn-host/dist/hub.js
|
|
11846
12326
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
12327
|
+
function receiptOverdue(seat) {
|
|
12328
|
+
return [...seat.unacked.values()].some((each) => each.overdue);
|
|
12329
|
+
}
|
|
11847
12330
|
var RunnerProtocolError = class extends Error {
|
|
11848
12331
|
};
|
|
11849
12332
|
var RunnerAwayError = class extends Error {
|
|
@@ -11888,6 +12371,13 @@ var RunnerHub = class {
|
|
|
11888
12371
|
starting = /* @__PURE__ */ new Map();
|
|
11889
12372
|
/** Moves started since the hub did, so a placement that a move overtook gives its turn back. */
|
|
11890
12373
|
moveStarts = 0;
|
|
12374
|
+
/** When each runner was last asked to read a CLI's quota for a limit with no reset, by `runner/cli`. */
|
|
12375
|
+
limitReads = /* @__PURE__ */ new Map();
|
|
12376
|
+
/**
|
|
12377
|
+
* The turn let through once a limit with no reset had held its half hour, by `runner/cli`: the
|
|
12378
|
+
* report it tries, which `observedAt` names, and whether the turn has ended.
|
|
12379
|
+
*/
|
|
12380
|
+
limitTries = /* @__PURE__ */ new Map();
|
|
11891
12381
|
constructor(options) {
|
|
11892
12382
|
this.board = options.board;
|
|
11893
12383
|
this.host = options.host;
|
|
@@ -11924,9 +12414,9 @@ var RunnerHub = class {
|
|
|
11924
12414
|
if (hello.protocol !== RUNNER_PROTOCOL) {
|
|
11925
12415
|
throw new RunnerProtocolError(`runner protocol ${hello.protocol} is not ${RUNNER_PROTOCOL}; upgrade the runner or the server`);
|
|
11926
12416
|
}
|
|
11927
|
-
|
|
12417
|
+
let seat = this.seats.get(name);
|
|
11928
12418
|
if (seat === void 0) {
|
|
11929
|
-
|
|
12419
|
+
seat = {
|
|
11930
12420
|
name,
|
|
11931
12421
|
hello,
|
|
11932
12422
|
send: null,
|
|
@@ -11934,19 +12424,18 @@ var RunnerHub = class {
|
|
|
11934
12424
|
warm: /* @__PURE__ */ new Set(),
|
|
11935
12425
|
disconnectedAt: null,
|
|
11936
12426
|
grace: null,
|
|
11937
|
-
unacked: /* @__PURE__ */ new Map()
|
|
11938
|
-
|
|
11939
|
-
|
|
12427
|
+
unacked: /* @__PURE__ */ new Map()
|
|
12428
|
+
};
|
|
12429
|
+
this.seats.set(name, seat);
|
|
11940
12430
|
} else {
|
|
11941
12431
|
seat.hello = hello;
|
|
11942
12432
|
seat.warm = /* @__PURE__ */ new Set();
|
|
11943
12433
|
}
|
|
11944
12434
|
const still = new Set(hello.turns);
|
|
11945
|
-
const unacked = this.seats.get(name)?.unacked ?? /* @__PURE__ */ new Map();
|
|
11946
12435
|
for (const turnId of this.host.turnsOn(name)) {
|
|
11947
12436
|
if (still.has(turnId)) {
|
|
11948
|
-
this.settle(
|
|
11949
|
-
} else if (!unacked.has(turnId)) {
|
|
12437
|
+
this.settle(seat, turnId);
|
|
12438
|
+
} else if (!seat.unacked.has(turnId)) {
|
|
11950
12439
|
await this.host.abortTurn(turnId, `runner ${name} restarted during the turn`);
|
|
11951
12440
|
}
|
|
11952
12441
|
}
|
|
@@ -11969,7 +12458,6 @@ var RunnerHub = class {
|
|
|
11969
12458
|
}
|
|
11970
12459
|
seat.send = send;
|
|
11971
12460
|
seat.disconnectedAt = null;
|
|
11972
|
-
seat.unconfirmed = false;
|
|
11973
12461
|
if (seat.grace !== null) {
|
|
11974
12462
|
clearTimeout(seat.grace);
|
|
11975
12463
|
seat.grace = null;
|
|
@@ -12010,24 +12498,23 @@ var RunnerHub = class {
|
|
|
12010
12498
|
}
|
|
12011
12499
|
await Promise.allSettled(this.detaching);
|
|
12012
12500
|
}
|
|
12501
|
+
/** A turn's steps, which show its job arrived as its receipt would. */
|
|
12013
12502
|
async turnEvents(runner, turnId, entries) {
|
|
12014
12503
|
this.assertOwns(runner, turnId);
|
|
12504
|
+
this.received(runner, turnId);
|
|
12015
12505
|
await this.host.addEvents(turnId, entries);
|
|
12016
12506
|
}
|
|
12017
12507
|
/** A runner's word that a turn's job arrived, which also shows its stream delivers again. */
|
|
12018
12508
|
received(runner, turnId) {
|
|
12019
12509
|
const seat = this.seats.get(runner);
|
|
12020
|
-
if (seat
|
|
12021
|
-
|
|
12022
|
-
}
|
|
12023
|
-
this.settle(seat.unacked, turnId);
|
|
12024
|
-
if (seat.unconfirmed) {
|
|
12025
|
-
seat.unconfirmed = false;
|
|
12026
|
-
this.log.info({ runner }, "runner is taking jobs again");
|
|
12510
|
+
if (seat !== void 0) {
|
|
12511
|
+
this.settle(seat, turnId);
|
|
12027
12512
|
}
|
|
12028
12513
|
}
|
|
12514
|
+
/** A turn's outcome, which shows its job arrived as its receipt would. */
|
|
12029
12515
|
async turnOutcome(runner, turnId, outcome) {
|
|
12030
12516
|
this.assertOwns(runner, turnId);
|
|
12517
|
+
this.received(runner, turnId);
|
|
12031
12518
|
const ack = await this.host.closeTurn(turnId, outcome);
|
|
12032
12519
|
return ack ?? { dropWorkspace: false };
|
|
12033
12520
|
}
|
|
@@ -12171,8 +12658,11 @@ var RunnerHub = class {
|
|
|
12171
12658
|
return { refused: `${agent.name} has no CLI binding` };
|
|
12172
12659
|
}
|
|
12173
12660
|
const cli = agent.cli;
|
|
12174
|
-
const
|
|
12661
|
+
const runners2 = await this.board.listRunners();
|
|
12662
|
+
const now = this.now();
|
|
12663
|
+
const free = (seat) => seat.send !== null && !receiptOverdue(seat) && seat.hello.clis.includes(cli) && (seat.hello.slots === null || seat.inUse < seat.hello.slots) && !heldOn(runners2, seat.name, cli, now) && !this.tryRunning(runners2, seat.name, cli, now);
|
|
12175
12664
|
let chosen;
|
|
12665
|
+
let waitsOn = agent.homeRunner;
|
|
12176
12666
|
if (dispatch.project === SOCIETY_SCOPE) {
|
|
12177
12667
|
chosen = await this.pinnedSeat(agent.name, agent.homeRunner, cli, free);
|
|
12178
12668
|
} else {
|
|
@@ -12201,8 +12691,12 @@ var RunnerHub = class {
|
|
|
12201
12691
|
}
|
|
12202
12692
|
const seat = this.seats.get(home);
|
|
12203
12693
|
chosen = seat !== void 0 && free(seat) ? seat : void 0;
|
|
12694
|
+
waitsOn = home;
|
|
12204
12695
|
}
|
|
12205
12696
|
if (chosen === void 0) {
|
|
12697
|
+
if (waitsOn !== void 0) {
|
|
12698
|
+
this.readAfterLimit(runners2, waitsOn, cli, now);
|
|
12699
|
+
}
|
|
12206
12700
|
return null;
|
|
12207
12701
|
}
|
|
12208
12702
|
if (dispatch.project !== SOCIETY_SCOPE && (this.moving.has(dispatch.project) || this.moveStarts !== starts)) {
|
|
@@ -12212,7 +12706,17 @@ var RunnerHub = class {
|
|
|
12212
12706
|
if (dispatch.project !== SOCIETY_SCOPE) {
|
|
12213
12707
|
this.starting.set(dispatch.project, (this.starting.get(dispatch.project) ?? 0) + 1);
|
|
12214
12708
|
}
|
|
12215
|
-
|
|
12709
|
+
const runner = chosen.name;
|
|
12710
|
+
const lapsed = runners2.find((each) => each.name === runner)?.quotas?.[cli];
|
|
12711
|
+
const tried = this.limitTries.get(`${runner}/${cli}`);
|
|
12712
|
+
if (lapsed !== void 0 && limitLapsed(lapsed, now) && tried?.observedAt !== lapsed.observedAt) {
|
|
12713
|
+
this.limitTries.set(`${runner}/${cli}`, {
|
|
12714
|
+
observedAt: lapsed.observedAt,
|
|
12715
|
+
dispatch,
|
|
12716
|
+
ended: false
|
|
12717
|
+
});
|
|
12718
|
+
}
|
|
12719
|
+
return { runner };
|
|
12216
12720
|
}
|
|
12217
12721
|
async runTurn(dispatch, assignment) {
|
|
12218
12722
|
if ("refused" in assignment) {
|
|
@@ -12249,10 +12753,15 @@ var RunnerHub = class {
|
|
|
12249
12753
|
return await opened.ended;
|
|
12250
12754
|
} finally {
|
|
12251
12755
|
opening();
|
|
12756
|
+
for (const tried of this.limitTries.values()) {
|
|
12757
|
+
if (tried.dispatch === dispatch) {
|
|
12758
|
+
tried.ended = true;
|
|
12759
|
+
}
|
|
12760
|
+
}
|
|
12252
12761
|
if (seat !== void 0) {
|
|
12253
12762
|
seat.inUse = Math.max(0, seat.inUse - 1);
|
|
12254
12763
|
if (turnId !== null) {
|
|
12255
|
-
this.settle(seat
|
|
12764
|
+
this.settle(seat, turnId);
|
|
12256
12765
|
}
|
|
12257
12766
|
}
|
|
12258
12767
|
}
|
|
@@ -12428,11 +12937,7 @@ var RunnerHub = class {
|
|
|
12428
12937
|
move.id = await this.board.openMoveRepo();
|
|
12429
12938
|
const id = move.id;
|
|
12430
12939
|
move.phase = "export";
|
|
12431
|
-
const exported = ProjectExportedSchema.parse(await this.request(source, (request) => ({
|
|
12432
|
-
type: "export",
|
|
12433
|
-
request,
|
|
12434
|
-
export: { move: id, repo }
|
|
12435
|
-
})).catch((error) => {
|
|
12940
|
+
const exported = ProjectExportedSchema.parse(await this.request(source, (request) => ({ type: "export", request, export: { move: id, repo } }), moveTransferMs(MOVE_PACK_LIMIT_BYTES) + this.requestTimeoutMs).catch((error) => {
|
|
12436
12941
|
throw new BoardError("INVALID_STATE", `${from} could not send ${slug}: ${describe(error)}`);
|
|
12437
12942
|
}));
|
|
12438
12943
|
if (!exported.ok) {
|
|
@@ -12442,8 +12947,13 @@ var RunnerHub = class {
|
|
|
12442
12947
|
const imported = ProjectImportedSchema.parse(await this.request(target, (request) => ({
|
|
12443
12948
|
type: "import",
|
|
12444
12949
|
request,
|
|
12445
|
-
import: {
|
|
12446
|
-
|
|
12950
|
+
import: {
|
|
12951
|
+
move: id,
|
|
12952
|
+
repo,
|
|
12953
|
+
pullRequests: project.onDone === "ghpr",
|
|
12954
|
+
bytes: exported.bytes
|
|
12955
|
+
}
|
|
12956
|
+
}), moveTransferMs(exported.bytes) + this.requestTimeoutMs).catch((error) => {
|
|
12447
12957
|
throw new BoardError("INVALID_STATE", `${to} could not take ${slug}: ${describe(error)}`);
|
|
12448
12958
|
}));
|
|
12449
12959
|
move.phase = "recording";
|
|
@@ -12570,18 +13080,8 @@ var RunnerHub = class {
|
|
|
12570
13080
|
* back as a `BoardError` in the runner's words.
|
|
12571
13081
|
*/
|
|
12572
13082
|
async readTurnImage(actor, file) {
|
|
12573
|
-
const
|
|
12574
|
-
|
|
12575
|
-
throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to read ${file} from`);
|
|
12576
|
-
}
|
|
12577
|
-
const seat = this.seats.get(turn.runner);
|
|
12578
|
-
if (seat === void 0 || seat.send === null) {
|
|
12579
|
-
throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
|
|
12580
|
-
}
|
|
12581
|
-
if (!seat.hello.imageReads) {
|
|
12582
|
-
throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot read images yet; upgrade it`);
|
|
12583
|
-
}
|
|
12584
|
-
const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId: turn.turnId, path: file } }), FILE_TIMEOUT_MS));
|
|
13083
|
+
const { turnId, seat } = this.turnSeat(actor, "imageReads", `read ${file} from`, "read images");
|
|
13084
|
+
const image = TurnImageSchema.nullable().parse(await this.request(seat, (request) => ({ type: "image", request, read: { turnId, path: file } }), FILE_TIMEOUT_MS));
|
|
12585
13085
|
if (image === null) {
|
|
12586
13086
|
throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
|
|
12587
13087
|
}
|
|
@@ -12590,6 +13090,28 @@ var RunnerHub = class {
|
|
|
12590
13090
|
}
|
|
12591
13091
|
return Buffer.from(image.content, "base64");
|
|
12592
13092
|
}
|
|
13093
|
+
/**
|
|
13094
|
+
* Hands an image the board keeps to the runner a citizen's turn runs on, for `read_image`, which
|
|
13095
|
+
* writes it to a folder of that turn's own in the system temp folder, removed when the turn
|
|
13096
|
+
* ends, and answers the file's path there, with the same refusals as `readTurnImage`.
|
|
13097
|
+
*/
|
|
13098
|
+
async writeTurnImage(actor, image) {
|
|
13099
|
+
const { turnId, seat } = this.turnSeat(actor, "imageWrites", "open an image in", "open images");
|
|
13100
|
+
const file = TurnImageFileSchema.nullable().parse(await this.request(seat, (request) => ({
|
|
13101
|
+
type: "image_write",
|
|
13102
|
+
request,
|
|
13103
|
+
write: {
|
|
13104
|
+
turnId,
|
|
13105
|
+
id: image.id,
|
|
13106
|
+
type: image.type,
|
|
13107
|
+
content: Buffer.from(image.bytes).toString("base64")
|
|
13108
|
+
}
|
|
13109
|
+
}), FILE_TIMEOUT_MS));
|
|
13110
|
+
if (file === null) {
|
|
13111
|
+
throw new BoardError("INVALID_STATE", `the turn is no longer running on runner ${seat.name}`);
|
|
13112
|
+
}
|
|
13113
|
+
return file.path;
|
|
13114
|
+
}
|
|
12593
13115
|
/** The models a CLI offers, asked of a connected runner that has it; none when no runner has it. */
|
|
12594
13116
|
/**
|
|
12595
13117
|
* The connected runner a model list for `cli` comes from: the first of `prefer` that has the CLI,
|
|
@@ -12629,6 +13151,40 @@ var RunnerHub = class {
|
|
|
12629
13151
|
await this.board.recordQuota(runner, cli, quota);
|
|
12630
13152
|
return quota;
|
|
12631
13153
|
}
|
|
13154
|
+
/**
|
|
13155
|
+
* Whether a turn let through to try a limit with no reset, once its hold ran out, is still running
|
|
13156
|
+
* on the runner, so the CLI's other turns there wait for what it reports: a refusal records a newer
|
|
13157
|
+
* limit, which holds them another half hour, and a turn that ends without one lets them go.
|
|
13158
|
+
*/
|
|
13159
|
+
tryRunning(runners2, name, cli, now) {
|
|
13160
|
+
const quota = runners2.find((each) => each.name === name)?.quotas?.[cli];
|
|
13161
|
+
const tried = this.limitTries.get(`${name}/${cli}`);
|
|
13162
|
+
return quota !== void 0 && tried !== void 0 && !tried.ended && tried.observedAt === quota.observedAt && limitLapsed(quota, now);
|
|
13163
|
+
}
|
|
13164
|
+
/**
|
|
13165
|
+
* Asks a runner to read a CLI's quota again while a limit it gave no reset for holds a turn
|
|
13166
|
+
* waiting for it there, at most once per `UNKNOWN_LIMIT_HOLD_MS` for each runner and CLI, so a
|
|
13167
|
+
* reset the read names becomes the hold and a clear read lifts it. Not while the user's pause
|
|
13168
|
+
* holds the CLI there too, nor on a runner from before quota reads; a read that fails changes
|
|
13169
|
+
* nothing, and the hold runs out as it would have.
|
|
13170
|
+
*/
|
|
13171
|
+
readAfterLimit(runners2, name, cli, now) {
|
|
13172
|
+
const record = runners2.find((each) => each.name === name);
|
|
13173
|
+
const seat = this.seats.get(name);
|
|
13174
|
+
if (record === void 0 || seat === void 0 || seat.send === null || !seat.hello.quotaReads || !seat.hello.clis.includes(cli) || runnerPause(record, cli) !== void 0 || runnerLimitHold(record, cli, now)?.known !== false) {
|
|
13175
|
+
return;
|
|
13176
|
+
}
|
|
13177
|
+
const key = `${name}/${cli}`;
|
|
13178
|
+
const last = this.limitReads.get(key);
|
|
13179
|
+
if (last !== void 0 && now.getTime() - last < UNKNOWN_LIMIT_HOLD_MS) {
|
|
13180
|
+
return;
|
|
13181
|
+
}
|
|
13182
|
+
this.limitReads.set(key, now.getTime());
|
|
13183
|
+
this.log.info({ runner: name, cli }, "reading a quota limited until an unknown time");
|
|
13184
|
+
this.quota(name, cli).catch((error) => {
|
|
13185
|
+
this.log.warn({ runner: name, cli, error: String(error) }, "could not read a limited quota");
|
|
13186
|
+
});
|
|
13187
|
+
}
|
|
12632
13188
|
/** Calls `listener` whenever a runner registers, connects, or goes away. */
|
|
12633
13189
|
onRunnerChange(listener) {
|
|
12634
13190
|
this.runnerListeners.add(listener);
|
|
@@ -12642,29 +13198,41 @@ var RunnerHub = class {
|
|
|
12642
13198
|
/**
|
|
12643
13199
|
* Waits for a runner to acknowledge a job. One that does not in time may be behind a stream that
|
|
12644
13200
|
* looks open but delivers nothing, as when its machine's network stops passing traffic, so no
|
|
12645
|
-
* more turns go to it until
|
|
12646
|
-
* they report by posts.
|
|
13201
|
+
* more turns go to it until the job is acknowledged, its turn ends, or the runner connects again;
|
|
13202
|
+
* the turns it has carry on, since they report by posts.
|
|
12647
13203
|
*/
|
|
12648
13204
|
expectAck(seat, job) {
|
|
12649
13205
|
const previous = seat.unacked.get(job.turnId);
|
|
12650
13206
|
if (previous !== void 0) {
|
|
12651
13207
|
clearTimeout(previous.timer);
|
|
12652
13208
|
}
|
|
12653
|
-
const
|
|
12654
|
-
|
|
12655
|
-
|
|
12656
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
12659
|
-
|
|
12660
|
-
|
|
12661
|
-
|
|
13209
|
+
const entry = {
|
|
13210
|
+
job,
|
|
13211
|
+
timer: setTimeout(() => {
|
|
13212
|
+
if (seat.unacked.get(job.turnId) !== entry || seat.send === null) {
|
|
13213
|
+
return;
|
|
13214
|
+
}
|
|
13215
|
+
const wasHeld = receiptOverdue(seat);
|
|
13216
|
+
entry.overdue = true;
|
|
13217
|
+
if (!wasHeld) {
|
|
13218
|
+
this.log.warn({ runner: seat.name, turnId: job.turnId, waitedMs: this.ackTimeoutMs }, "runner has not acknowledged a job; no more turns go to it until it does, the turn ends, or it reconnects");
|
|
13219
|
+
}
|
|
13220
|
+
}, this.ackTimeoutMs),
|
|
13221
|
+
overdue: false
|
|
13222
|
+
};
|
|
13223
|
+
entry.timer.unref?.();
|
|
13224
|
+
seat.unacked.set(job.turnId, entry);
|
|
12662
13225
|
}
|
|
12663
|
-
|
|
12664
|
-
|
|
12665
|
-
|
|
12666
|
-
|
|
12667
|
-
|
|
13226
|
+
/** A job acknowledged, or its turn over: the hold it put on its runner, if any, goes with it. */
|
|
13227
|
+
settle(seat, turnId) {
|
|
13228
|
+
const entry = seat.unacked.get(turnId);
|
|
13229
|
+
if (entry === void 0) {
|
|
13230
|
+
return;
|
|
13231
|
+
}
|
|
13232
|
+
clearTimeout(entry.timer);
|
|
13233
|
+
seat.unacked.delete(turnId);
|
|
13234
|
+
if (entry.overdue && !receiptOverdue(seat)) {
|
|
13235
|
+
this.log.info({ runner: seat.name }, "runner is taking jobs again");
|
|
12668
13236
|
}
|
|
12669
13237
|
}
|
|
12670
13238
|
async detach(seat) {
|
|
@@ -12767,6 +13335,25 @@ var RunnerHub = class {
|
|
|
12767
13335
|
repo: projectRepo(record)
|
|
12768
13336
|
};
|
|
12769
13337
|
}
|
|
13338
|
+
/**
|
|
13339
|
+
* The caller's turn in flight, found by its conversation, and the connected runner it runs on,
|
|
13340
|
+
* which must say `able` in its hello: an image's way to and from the machine a turn runs on.
|
|
13341
|
+
* `where` and `what` say what the caller asked for in a refusal.
|
|
13342
|
+
*/
|
|
13343
|
+
turnSeat(actor, able, where, what) {
|
|
13344
|
+
const turn = this.host.runningTurns().find((each) => each.agent === actor.name && each.scope === actor.scope && each.thread === actor.thread && each.channel === actor.channel && each.place === actor.place);
|
|
13345
|
+
if (turn === void 0) {
|
|
13346
|
+
throw new BoardError("INVALID_STATE", `${actor.name} has no turn running in this conversation to ${where}`);
|
|
13347
|
+
}
|
|
13348
|
+
const seat = this.seats.get(turn.runner);
|
|
13349
|
+
if (seat === void 0 || seat.send === null) {
|
|
13350
|
+
throw new RunnerAwayError(`runner ${turn.runner}, where the turn runs, is not connected`);
|
|
13351
|
+
}
|
|
13352
|
+
if (!seat.hello[able]) {
|
|
13353
|
+
throw new BoardError("INVALID_STATE", `runner ${seat.name}, where the turn runs, cannot ${what} yet; upgrade it`);
|
|
13354
|
+
}
|
|
13355
|
+
return { turnId: turn.turnId, seat };
|
|
13356
|
+
}
|
|
12770
13357
|
assertOwns(runner, turnId) {
|
|
12771
13358
|
const owner = this.host.runnerOf(turnId);
|
|
12772
13359
|
if (owner !== void 0 && owner !== runner) {
|
|
@@ -12899,6 +13486,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
12899
13486
|
var ENROLL_BODY_LIMIT_BYTES = 16 * 1024;
|
|
12900
13487
|
var APP_TOKEN_BODY_LIMIT_BYTES = 16 * 1024;
|
|
12901
13488
|
var API_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
13489
|
+
var IMAGE_BODY_LIMIT_BYTES = IMAGE_LIMIT_BYTES;
|
|
12902
13490
|
var RUNNER_BODY_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
12903
13491
|
var RUNNER_ANSWER_LIMIT_BYTES = 16 * 1024 * 1024;
|
|
12904
13492
|
function limitBody(maxSize) {
|
|
@@ -12915,7 +13503,7 @@ function limitBody(maxSize) {
|
|
|
12915
13503
|
}
|
|
12916
13504
|
|
|
12917
13505
|
// apps/server/dist/github-auth.js
|
|
12918
|
-
import { createHash as
|
|
13506
|
+
import { createHash as createHash4, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
|
|
12919
13507
|
import { Hono } from "hono";
|
|
12920
13508
|
import { z as z18 } from "zod";
|
|
12921
13509
|
var SIGN_IN_TTL_MS = 30 * 24 * 36e5;
|
|
@@ -12968,7 +13556,7 @@ function prune(map, now) {
|
|
|
12968
13556
|
}
|
|
12969
13557
|
}
|
|
12970
13558
|
function provesChallenge(verifier, challenge) {
|
|
12971
|
-
const answer = Buffer.from(
|
|
13559
|
+
const answer = Buffer.from(createHash4("sha256").update(verifier).digest("base64url"));
|
|
12972
13560
|
const expected = Buffer.from(challenge);
|
|
12973
13561
|
return answer.length === expected.length && timingSafeEqual(answer, expected);
|
|
12974
13562
|
}
|
|
@@ -13203,9 +13791,109 @@ import { Hono as Hono2 } from "hono";
|
|
|
13203
13791
|
import { streamSSE } from "hono/streaming";
|
|
13204
13792
|
|
|
13205
13793
|
// apps/server/dist/home-git.js
|
|
13794
|
+
import { once } from "node:events";
|
|
13206
13795
|
import { execa as execa3 } from "execa";
|
|
13796
|
+
|
|
13797
|
+
// apps/server/dist/serve.js
|
|
13798
|
+
import { serve } from "@hono/node-server";
|
|
13799
|
+
var REQUEST_TIMEOUT_MS = 3e5;
|
|
13800
|
+
var HEADERS_TIMEOUT_MS = 6e4;
|
|
13801
|
+
var deadlines = /* @__PURE__ */ new WeakMap();
|
|
13802
|
+
function serveBoard(options, listening) {
|
|
13803
|
+
const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
13804
|
+
return serve({
|
|
13805
|
+
fetch: (request, env) => {
|
|
13806
|
+
const { incoming, outgoing } = env;
|
|
13807
|
+
if (!incoming.complete) {
|
|
13808
|
+
const timer = setTimeout(() => {
|
|
13809
|
+
if (!incoming.complete) {
|
|
13810
|
+
incoming.socket.destroy();
|
|
13811
|
+
}
|
|
13812
|
+
}, timeoutMs);
|
|
13813
|
+
timer.unref();
|
|
13814
|
+
const lift = () => clearTimeout(timer);
|
|
13815
|
+
deadlines.set(request, lift);
|
|
13816
|
+
outgoing.once("close", lift);
|
|
13817
|
+
}
|
|
13818
|
+
return options.fetch(request, env);
|
|
13819
|
+
},
|
|
13820
|
+
port: options.port,
|
|
13821
|
+
hostname: options.hostname,
|
|
13822
|
+
serverOptions: {
|
|
13823
|
+
requestTimeout: 0,
|
|
13824
|
+
headersTimeout: options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS,
|
|
13825
|
+
...options.connectionsCheckingIntervalMs === void 0 ? {} : { connectionsCheckingInterval: options.connectionsCheckingIntervalMs }
|
|
13826
|
+
}
|
|
13827
|
+
}, listening);
|
|
13828
|
+
}
|
|
13829
|
+
function liftDeadline(request) {
|
|
13830
|
+
deadlines.get(request)?.();
|
|
13831
|
+
deadlines.delete(request);
|
|
13832
|
+
}
|
|
13833
|
+
|
|
13834
|
+
// apps/server/dist/home-git.js
|
|
13207
13835
|
var SMART_PATHS = /* @__PURE__ */ new Set(["/info/refs", "/git-upload-pack", "/git-receive-pack"]);
|
|
13208
13836
|
var MAX_BYTES = MOVE_PACK_LIMIT_BYTES;
|
|
13837
|
+
var IDLE_MS = 5 * 6e4;
|
|
13838
|
+
async function feed(body, stdin, limit2, idleMs) {
|
|
13839
|
+
if (body === null) {
|
|
13840
|
+
stdin.end();
|
|
13841
|
+
return "done";
|
|
13842
|
+
}
|
|
13843
|
+
const reader = body.getReader();
|
|
13844
|
+
const waits = new AbortController();
|
|
13845
|
+
let ended = null;
|
|
13846
|
+
const endedAs = () => ended;
|
|
13847
|
+
const end = (why) => {
|
|
13848
|
+
if (ended === null) {
|
|
13849
|
+
ended = why;
|
|
13850
|
+
waits.abort();
|
|
13851
|
+
if (why === "idle" || why === "cut") {
|
|
13852
|
+
reader.cancel().catch(() => void 0);
|
|
13853
|
+
} else {
|
|
13854
|
+
reader.releaseLock();
|
|
13855
|
+
}
|
|
13856
|
+
}
|
|
13857
|
+
return ended;
|
|
13858
|
+
};
|
|
13859
|
+
const timer = setTimeout(() => end("idle"), idleMs);
|
|
13860
|
+
const stopped = () => {
|
|
13861
|
+
end("stopped");
|
|
13862
|
+
};
|
|
13863
|
+
stdin.on("error", stopped);
|
|
13864
|
+
stdin.once("close", stopped);
|
|
13865
|
+
let total = 0;
|
|
13866
|
+
try {
|
|
13867
|
+
for (; ; ) {
|
|
13868
|
+
const next = await reader.read().catch(() => null);
|
|
13869
|
+
const early = endedAs();
|
|
13870
|
+
if (early !== null) {
|
|
13871
|
+
return early;
|
|
13872
|
+
}
|
|
13873
|
+
if (next === null) {
|
|
13874
|
+
return end("cut");
|
|
13875
|
+
}
|
|
13876
|
+
if (next.done) {
|
|
13877
|
+
stdin.end();
|
|
13878
|
+
return "done";
|
|
13879
|
+
}
|
|
13880
|
+
timer.refresh();
|
|
13881
|
+
total += next.value.byteLength;
|
|
13882
|
+
if (total > limit2) {
|
|
13883
|
+
return end("too large");
|
|
13884
|
+
}
|
|
13885
|
+
if (!stdin.write(next.value)) {
|
|
13886
|
+
const drained = await once(stdin, "drain", { signal: waits.signal }).then(() => true, () => false);
|
|
13887
|
+
if (!drained) {
|
|
13888
|
+
return end("stopped");
|
|
13889
|
+
}
|
|
13890
|
+
}
|
|
13891
|
+
}
|
|
13892
|
+
} finally {
|
|
13893
|
+
clearTimeout(timer);
|
|
13894
|
+
stdin.off("close", stopped);
|
|
13895
|
+
}
|
|
13896
|
+
}
|
|
13209
13897
|
function parseCgi(output) {
|
|
13210
13898
|
const crlf = output.indexOf("\r\n\r\n");
|
|
13211
13899
|
const lf = output.indexOf("\n\n");
|
|
@@ -13233,6 +13921,7 @@ function parseCgi(output) {
|
|
|
13233
13921
|
var HomeGit = class {
|
|
13234
13922
|
pushes = /* @__PURE__ */ new Map();
|
|
13235
13923
|
serve(input) {
|
|
13924
|
+
liftDeadline(input.request);
|
|
13236
13925
|
if (input.rest !== "/git-receive-pack") {
|
|
13237
13926
|
return serveHomeGit(input);
|
|
13238
13927
|
}
|
|
@@ -13247,10 +13936,13 @@ async function serveHomeGit(input) {
|
|
|
13247
13936
|
return new Response("not a smart HTTP path", { status: 404 });
|
|
13248
13937
|
}
|
|
13249
13938
|
const { request } = input;
|
|
13250
|
-
const
|
|
13251
|
-
|
|
13939
|
+
const limit2 = input.limitBytes ?? MAX_BYTES;
|
|
13940
|
+
const length = request.headers.get("content-length");
|
|
13941
|
+
const declared = length !== null && /^\d+$/.test(length) ? length : null;
|
|
13942
|
+
if (declared !== null && Number(declared) > limit2) {
|
|
13252
13943
|
return new Response("pack too large for the board to take", { status: 413 });
|
|
13253
13944
|
}
|
|
13945
|
+
liftDeadline(request);
|
|
13254
13946
|
const url = new URL(request.url);
|
|
13255
13947
|
const env = {
|
|
13256
13948
|
PATH: process.env["PATH"] ?? "/usr/bin:/bin",
|
|
@@ -13261,10 +13953,12 @@ async function serveHomeGit(input) {
|
|
|
13261
13953
|
PATH_INFO: `/${input.repository}${input.rest}`,
|
|
13262
13954
|
QUERY_STRING: url.search.replace(/^\?/, ""),
|
|
13263
13955
|
CONTENT_TYPE: request.headers.get("content-type") ?? "",
|
|
13264
|
-
CONTENT_LENGTH: String(body.length),
|
|
13265
13956
|
REMOTE_USER: input.runner,
|
|
13266
13957
|
REMOTE_ADDR: "127.0.0.1"
|
|
13267
13958
|
};
|
|
13959
|
+
if (declared !== null) {
|
|
13960
|
+
env["CONTENT_LENGTH"] = declared;
|
|
13961
|
+
}
|
|
13268
13962
|
const encoding = request.headers.get("content-encoding");
|
|
13269
13963
|
if (encoding !== null) {
|
|
13270
13964
|
env["HTTP_CONTENT_ENCODING"] = encoding;
|
|
@@ -13273,10 +13967,9 @@ async function serveHomeGit(input) {
|
|
|
13273
13967
|
if (protocol !== null) {
|
|
13274
13968
|
env["HTTP_GIT_PROTOCOL"] = protocol;
|
|
13275
13969
|
}
|
|
13276
|
-
const
|
|
13970
|
+
const backend = execa3("git", ["http-backend"], {
|
|
13277
13971
|
env,
|
|
13278
13972
|
extendEnv: false,
|
|
13279
|
-
input: body,
|
|
13280
13973
|
encoding: "buffer",
|
|
13281
13974
|
// The blank line that ends the headers ends the whole answer when the body is empty, as for
|
|
13282
13975
|
// the 4-byte probe git sends before a pack too large for one post; stripped, it reads as none.
|
|
@@ -13284,6 +13977,15 @@ async function serveHomeGit(input) {
|
|
|
13284
13977
|
reject: false,
|
|
13285
13978
|
maxBuffer: MAX_BYTES
|
|
13286
13979
|
});
|
|
13980
|
+
const fed = await feed(request.body, backend.stdin, limit2, input.idleMs ?? IDLE_MS);
|
|
13981
|
+
if (fed !== "done" && fed !== "stopped") {
|
|
13982
|
+
backend.kill();
|
|
13983
|
+
await backend;
|
|
13984
|
+
return fed === "too large" ? new Response("pack too large for the board to take", { status: 413 }) : new Response(fed === "idle" ? "the request stopped arriving" : "the request was cut", {
|
|
13985
|
+
status: fed === "idle" ? 408 : 400
|
|
13986
|
+
});
|
|
13987
|
+
}
|
|
13988
|
+
const result = await backend;
|
|
13287
13989
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
13288
13990
|
return new Response(`git http-backend failed: ${Buffer.from(result.stderr).toString("utf8")}`, {
|
|
13289
13991
|
status: 500
|
|
@@ -13566,6 +14268,14 @@ function createApp(deps) {
|
|
|
13566
14268
|
};
|
|
13567
14269
|
const api = new Hono3();
|
|
13568
14270
|
api.use("*", authenticate);
|
|
14271
|
+
api.post("/images", limitBody(IMAGE_BODY_LIMIT_BYTES), async (c) => {
|
|
14272
|
+
if (c.get("actor").role !== USER_ROLE) {
|
|
14273
|
+
throw new BoardError("FORBIDDEN", "only the user posts an image's bytes");
|
|
14274
|
+
}
|
|
14275
|
+
const name = ImageNameSchema.optional().parse(c.req.query("name"));
|
|
14276
|
+
const bytes = new Uint8Array(await c.req.arrayBuffer());
|
|
14277
|
+
return c.json(await board2.postImage(c.get("actor"), bytes, name));
|
|
14278
|
+
});
|
|
13569
14279
|
api.on(["POST", "PUT", "PATCH", "DELETE"], "*", limitBody(API_BODY_LIMIT_BYTES));
|
|
13570
14280
|
api.get("/me", (c) => {
|
|
13571
14281
|
const signIn = board2.signInOf(bearer2(c) ?? "");
|
|
@@ -13618,6 +14328,21 @@ function createApp(deps) {
|
|
|
13618
14328
|
}
|
|
13619
14329
|
return c.json(await quotas.read(runner, cli));
|
|
13620
14330
|
});
|
|
14331
|
+
for (const [verb, paused] of [
|
|
14332
|
+
["pause", true],
|
|
14333
|
+
["resume", false]
|
|
14334
|
+
]) {
|
|
14335
|
+
for (const path12 of [`/runners/:name/${verb}`, `/runners/:name/${verb}/:cli`]) {
|
|
14336
|
+
api.post(path12, async (c) => {
|
|
14337
|
+
const name = NameSchema.parse(c.req.param("name"));
|
|
14338
|
+
const cli = c.req.param("cli");
|
|
14339
|
+
return c.json(await board2.setRunnerPaused(c.get("actor"), name, {
|
|
14340
|
+
paused,
|
|
14341
|
+
...cli === void 0 ? {} : { cli: CliKindSchema.parse(cli) }
|
|
14342
|
+
}));
|
|
14343
|
+
});
|
|
14344
|
+
}
|
|
14345
|
+
}
|
|
13621
14346
|
api.post("/runners", async (c) => {
|
|
13622
14347
|
const body = RunnerBodySchema.parse(await c.req.json());
|
|
13623
14348
|
return c.json(await board2.addRunner(c.get("actor"), body.name));
|
|
@@ -14194,6 +14919,7 @@ var host = new TurnHost({
|
|
|
14194
14919
|
var runners = new RunnerHub({ board, host, version: VERSION, log });
|
|
14195
14920
|
board.watchTurns(() => host.inFlight());
|
|
14196
14921
|
board.readImagesWith((actor, file) => runners.readTurnImage(actor, file));
|
|
14922
|
+
board.openImagesWith((actor, image) => runners.writeTurnImage(actor, image));
|
|
14197
14923
|
var concurrency = config.concurrency ?? Number.POSITIVE_INFINITY;
|
|
14198
14924
|
var timings = parseTimings(JSON.parse(process.env["STELLARIS_TIMINGS"] ?? "{}"));
|
|
14199
14925
|
var scheduler = new Scheduler({ board, runner: runners, log, concurrency, timings });
|
|
@@ -14227,7 +14953,7 @@ var app = createApp({
|
|
|
14227
14953
|
trustedProxies: config.trustedProxies,
|
|
14228
14954
|
log
|
|
14229
14955
|
});
|
|
14230
|
-
var server =
|
|
14956
|
+
var server = serveBoard({ fetch: app.fetch, port: config.port, hostname: config.host }, (info) => {
|
|
14231
14957
|
log.info({
|
|
14232
14958
|
host: info.address,
|
|
14233
14959
|
port: info.port,
|