@tablation/crew 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1406 -1332
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1448,6 +1448,7 @@ var HOOK_NAMES = [
|
|
|
1448
1448
|
"ports",
|
|
1449
1449
|
"isolate",
|
|
1450
1450
|
"handoff",
|
|
1451
|
+
"preview",
|
|
1451
1452
|
"ci_status",
|
|
1452
1453
|
"version",
|
|
1453
1454
|
"bump",
|
|
@@ -2316,6 +2317,7 @@ function parseResolved(raw, m, where) {
|
|
|
2316
2317
|
areaId: raw.areaId,
|
|
2317
2318
|
shipsModelId: raw.shipsModelId,
|
|
2318
2319
|
epicsModelId: raw.epicsModelId,
|
|
2320
|
+
periscopesModelId: raw.periscopesModelId,
|
|
2319
2321
|
locksModelId: raw.locksModelId,
|
|
2320
2322
|
recordLinkViewId: raw.recordLinkViewId,
|
|
2321
2323
|
reposModelId: raw.reposModelId,
|
|
@@ -4215,6 +4217,15 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
|
|
|
4215
4217
|
if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
|
|
4216
4218
|
return null;
|
|
4217
4219
|
}
|
|
4220
|
+
var agentLogRefusedKey = (route) => `agent_log_refused:${route}`;
|
|
4221
|
+
function agentLogRefusedItem(route, role, status2, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
4222
|
+
return {
|
|
4223
|
+
kind: "agent_log_refused",
|
|
4224
|
+
key: agentLogRefusedKey(route),
|
|
4225
|
+
since: nowIso,
|
|
4226
|
+
message: `${route}: the tracker refused the ${role} seat's run report (${status2}) \u2014 runs and ticket spend are not being recorded; check the seat key's role`
|
|
4227
|
+
};
|
|
4228
|
+
}
|
|
4218
4229
|
var diskLowKey = (repo) => `disk_low:${repo}`;
|
|
4219
4230
|
var LOW_DISK_BYTES = 2 * 1024 ** 3;
|
|
4220
4231
|
var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
|
|
@@ -4587,1431 +4598,1455 @@ import { finished } from "stream/promises";
|
|
|
4587
4598
|
import { join as join19, dirname as dirname7, basename as basename7 } from "path";
|
|
4588
4599
|
import { createHash as createHash3 } from "crypto";
|
|
4589
4600
|
|
|
4590
|
-
// src/
|
|
4591
|
-
import {
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
];
|
|
4600
|
-
}
|
|
4601
|
-
var HOOK_PURPOSE = [
|
|
4602
|
-
["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
|
|
4603
|
-
["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
|
|
4604
|
-
["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
|
|
4605
|
-
["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
|
|
4606
|
-
["test", "run the suite"],
|
|
4607
|
-
["build", "build it"],
|
|
4608
|
-
["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
|
|
4609
|
-
];
|
|
4610
|
-
function hookTable(repo) {
|
|
4611
|
-
const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
|
|
4612
|
-
if (declared.length === 0) {
|
|
4613
|
-
return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
|
|
4601
|
+
// src/state.ts
|
|
4602
|
+
import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, existsSync as existsSync11, mkdirSync as mkdirSync6, rmSync as rmSync6, statSync as statSync4, readdirSync as readdirSync4 } from "fs";
|
|
4603
|
+
import { join as join12 } from "path";
|
|
4604
|
+
var EPOCH = "1970-01-01T00:00:00.000Z";
|
|
4605
|
+
var State = class _State {
|
|
4606
|
+
dir;
|
|
4607
|
+
constructor(dir) {
|
|
4608
|
+
this.dir = dir;
|
|
4609
|
+
mkdirSync6(dir, { recursive: true });
|
|
4614
4610
|
}
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
|
|
4618
|
-
return [
|
|
4619
|
-
"| hook | what it is for | what it runs |",
|
|
4620
|
-
"| --- | --- | --- |",
|
|
4621
|
-
...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
|
|
4622
|
-
"",
|
|
4623
|
-
"Run these **verbatim**, from the worktree. They come from the repository's own",
|
|
4624
|
-
"configuration, which is the only thing that knows them. A command you invented",
|
|
4625
|
-
"yourself is wrong even when it works, because the next repository will differ.",
|
|
4626
|
-
"",
|
|
4627
|
-
...blocks,
|
|
4628
|
-
...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
|
|
4629
|
-
].join("\n");
|
|
4630
|
-
}
|
|
4631
|
-
function repoPrefix(repo) {
|
|
4632
|
-
return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
|
|
4633
|
-
}
|
|
4634
|
-
function branchExample(repo, key) {
|
|
4635
|
-
const prefix = repoPrefix(repo);
|
|
4636
|
-
const template = effectiveBranchTemplate(repo, prefix);
|
|
4637
|
-
return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
|
|
4638
|
-
}
|
|
4639
|
-
function repoSection(r, key, heading) {
|
|
4640
|
-
const repo = r.config;
|
|
4641
|
-
const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
|
|
4642
|
-
const branchName = branchExample(repo, key);
|
|
4643
|
-
const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
|
|
4644
|
-
const worktreeName = effectiveWorktreeDirName(
|
|
4645
|
-
repo,
|
|
4646
|
-
r.dir,
|
|
4647
|
-
branchName,
|
|
4648
|
-
key.replace(/^\D+/, ""),
|
|
4649
|
-
// The example ticket has no tag of its own, so `{issue}` is the key.
|
|
4650
|
-
(template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
|
|
4651
|
-
);
|
|
4652
|
-
const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
|
|
4653
|
-
const lines = [
|
|
4654
|
-
"",
|
|
4655
|
-
heading,
|
|
4656
|
-
"",
|
|
4657
|
-
`- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
|
|
4658
|
-
` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
|
|
4659
|
-
`- Name the branch the way this repository names branches: \`${branchTemplate}\``,
|
|
4660
|
-
` \u2014 for ${key} that is \`${branchName}\`.`,
|
|
4661
|
-
"- Never work in the main checkout. It is the operator's, and the release phase",
|
|
4662
|
-
" uses it.",
|
|
4663
|
-
"",
|
|
4664
|
-
`${key} above is a placeholder, not a real ticket: this section is rendered`,
|
|
4665
|
-
"before any ticket is chosen, so it cannot know a specific ticket's own",
|
|
4666
|
-
"project prefix. Once you have a ticket, its digest row's `branch` and",
|
|
4667
|
-
"`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
|
|
4668
|
-
"over this section's worked example if the two ever disagree."
|
|
4669
|
-
];
|
|
4670
|
-
if (repo.worktrees.copy.length) {
|
|
4671
|
-
lines.push(
|
|
4672
|
-
"",
|
|
4673
|
-
"A worktree is a clean checkout, so it is missing exactly the files git ignores.",
|
|
4674
|
-
"Copy these across from the main checkout before running anything:",
|
|
4675
|
-
"",
|
|
4676
|
-
...repo.worktrees.copy.map((c) => `- \`${c}\``)
|
|
4677
|
-
);
|
|
4611
|
+
path(...parts) {
|
|
4612
|
+
return join12(this.dir, ...parts);
|
|
4678
4613
|
}
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4614
|
+
/**
|
|
4615
|
+
* The timestamp every "new since last poll" check is measured against.
|
|
4616
|
+
*
|
|
4617
|
+
* Read once per cycle and advanced only AFTER every role has been
|
|
4618
|
+
* evaluated: bumping it inside the first role's check would hide that
|
|
4619
|
+
* role's new comments from the second.
|
|
4620
|
+
*/
|
|
4621
|
+
watermark() {
|
|
4622
|
+
try {
|
|
4623
|
+
const v = readFileSync11(this.path(".poll-watermark"), "utf8").trim();
|
|
4624
|
+
return v || EPOCH;
|
|
4625
|
+
} catch {
|
|
4626
|
+
return EPOCH;
|
|
4627
|
+
}
|
|
4688
4628
|
}
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
"## This project's triage policy",
|
|
4693
|
-
"",
|
|
4694
|
-
`\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
|
|
4695
|
-
"disagree about what a status means or when a ticket may be accepted, the",
|
|
4696
|
-
"policy document wins: it is the project's own decision, and the brief is",
|
|
4697
|
-
"shared with projects that decided differently."
|
|
4698
|
-
);
|
|
4629
|
+
setWatermark(at) {
|
|
4630
|
+
writeFileSync7(this.path(".poll-watermark"), `${at}
|
|
4631
|
+
`);
|
|
4699
4632
|
}
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
"",
|
|
4713
|
-
"Supplied by the crew for THIS run. The brief below is shared by every project",
|
|
4714
|
-
"the crew works on and deliberately names nothing specific; this section is where",
|
|
4715
|
-
"those names come from. Where the two appear to disagree, this section wins.",
|
|
4716
|
-
"",
|
|
4717
|
-
"## The board",
|
|
4718
|
-
"",
|
|
4719
|
-
`- Base URL: \`${route.baseUrl}\``,
|
|
4720
|
-
`- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
|
|
4721
|
-
` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
|
|
4722
|
-
" and never write it into a comment, a commit or a log line.",
|
|
4723
|
-
`- Send \`User-Agent: ${i.userAgent}\` on every request.`
|
|
4724
|
-
];
|
|
4725
|
-
if (models) {
|
|
4726
|
-
lines.push(
|
|
4727
|
-
"",
|
|
4728
|
-
"Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
|
|
4729
|
-
"",
|
|
4730
|
-
`- Issues: \`${models.issues}\``,
|
|
4731
|
-
`- Comments: \`${models.comments}\``,
|
|
4732
|
-
`- Crew: \`${models.crew}\``,
|
|
4733
|
-
...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
|
|
4734
|
-
...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
|
|
4735
|
-
);
|
|
4633
|
+
/**
|
|
4634
|
+
* Advance to the newest thing this cycle actually saw, never to "now".
|
|
4635
|
+
*
|
|
4636
|
+
* Using the clock would silently skip any comment written between the fetch
|
|
4637
|
+
* and the write — a race that loses work rather than repeating it, which is
|
|
4638
|
+
* the wrong way round. Repeating a cycle is free; missing one is not.
|
|
4639
|
+
*/
|
|
4640
|
+
advanceWatermark(seen) {
|
|
4641
|
+
const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
|
|
4642
|
+
const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
|
|
4643
|
+
this.setWatermark(next);
|
|
4644
|
+
return next;
|
|
4736
4645
|
}
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
lines.push(
|
|
4741
|
-
"",
|
|
4742
|
-
`- Workspace id: \`${workspaceId}\``,
|
|
4743
|
-
"",
|
|
4744
|
-
"## Attaching a file to a comment",
|
|
4745
|
-
"",
|
|
4746
|
-
"A screenshot or mockup goes on a comment's `attachments` field, which holds",
|
|
4747
|
-
"Media Library record ids, not URLs. Upload the file first, then put the",
|
|
4748
|
-
"returned `id` in the comment's `attachments` array:",
|
|
4749
|
-
"",
|
|
4750
|
-
"```sh",
|
|
4751
|
-
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
4752
|
-
` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
|
|
4753
|
-
' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
|
|
4754
|
-
"```",
|
|
4755
|
-
"",
|
|
4756
|
-
"The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
|
|
4757
|
-
"means no field type id has to be looked up. If the upload fails, post the HTTP",
|
|
4758
|
-
"status and response body in your comment \u2014 do not write that uploads are",
|
|
4759
|
-
"impossible."
|
|
4760
|
-
);
|
|
4761
|
-
if (mediaLibraryModelId) {
|
|
4762
|
-
lines.push(
|
|
4763
|
-
"",
|
|
4764
|
-
"## Opening a ticket's attachments",
|
|
4765
|
-
"",
|
|
4766
|
-
`A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
|
|
4767
|
-
"**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
|
|
4768
|
-
"`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
|
|
4769
|
-
"read the record, then fetch the file by its storage key:",
|
|
4770
|
-
"",
|
|
4771
|
-
"```sh",
|
|
4772
|
-
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
4773
|
-
` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
|
|
4774
|
-
'# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
|
|
4775
|
-
`curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
4776
|
-
` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
|
|
4777
|
-
"```",
|
|
4778
|
-
"",
|
|
4779
|
-
"The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
|
|
4780
|
-
"id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
|
|
4781
|
-
"of the storage key). Save the file outside the repository and open it; where it",
|
|
4782
|
-
"disagrees with the description, the picture is the reproduction."
|
|
4783
|
-
);
|
|
4784
|
-
}
|
|
4646
|
+
/** Whole-crew pause, and the per-role sentinels one level down. */
|
|
4647
|
+
isPaused() {
|
|
4648
|
+
return existsSync11(this.path(".crew-paused"));
|
|
4785
4649
|
}
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
"",
|
|
4789
|
-
"## Ships",
|
|
4790
|
-
"",
|
|
4791
|
-
`This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
|
|
4792
|
-
"another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
|
|
4793
|
-
);
|
|
4650
|
+
isRolePaused(role) {
|
|
4651
|
+
return existsSync11(this.path(`.role-paused-${role}`));
|
|
4794
4652
|
}
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
lines.push(
|
|
4798
|
-
"",
|
|
4799
|
-
"## Seats on this ship",
|
|
4800
|
-
"",
|
|
4801
|
-
"The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
|
|
4802
|
-
"assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
|
|
4803
|
-
"",
|
|
4804
|
-
...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
|
|
4805
|
-
);
|
|
4653
|
+
pausedRoles(roles) {
|
|
4654
|
+
return new Set(roles.filter((r) => this.isRolePaused(r)));
|
|
4806
4655
|
}
|
|
4807
|
-
|
|
4808
|
-
"",
|
|
4809
|
-
"## What the columns are called here",
|
|
4810
|
-
"",
|
|
4811
|
-
"Every workspace names these itself. Use these names, not the ones you would",
|
|
4812
|
-
"guess, and not the ones another project used.",
|
|
4813
|
-
"",
|
|
4814
|
-
"| meaning | column |",
|
|
4815
|
-
"| --- | --- |",
|
|
4816
|
-
`| ticket key | \`${contract.columns.key}\` |`,
|
|
4817
|
-
`| status | \`${contract.columns.status}\` |`,
|
|
4818
|
-
`| assignee | \`${contract.columns.assignee}\` |`,
|
|
4819
|
-
`| severity | \`${contract.columns.severity}\` |`,
|
|
4820
|
-
`| priority | \`${contract.columns.priority}\` |`,
|
|
4821
|
-
`| blocked by | \`${contract.columns.blockedBy}\` |`,
|
|
4822
|
-
`| needs design | \`${contract.columns.needsDesign}\` |`,
|
|
4823
|
-
`| project | \`${contract.columns.slice}\` |`,
|
|
4824
|
-
`| repo | \`${contract.columns.repo}\` |`,
|
|
4825
|
-
...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
|
|
4826
|
-
"",
|
|
4827
|
-
...epicsModelId ? [
|
|
4828
|
-
"An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
|
|
4829
|
-
"fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
|
|
4830
|
-
"to a person rather than inventing one yourself.",
|
|
4831
|
-
"",
|
|
4832
|
-
"An epic's own status is kept by the crew runner, from its tickets: done once",
|
|
4833
|
-
"every one of them is closed, in progress while any is being worked. Do not",
|
|
4834
|
-
"set it yourself.",
|
|
4835
|
-
""
|
|
4836
|
-
] : [],
|
|
4837
|
-
"Statuses that matter to you:",
|
|
4838
|
-
"",
|
|
4839
|
-
`- Work you may pick up: \`${contract.statuses.approved}\``,
|
|
4840
|
-
`- While you are working it: \`${contract.statuses.building}\``,
|
|
4841
|
-
`- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
|
|
4842
|
-
`- Being checked: \`${contract.statuses.verifying}\``,
|
|
4843
|
-
`- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
|
|
4844
|
-
`- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
|
|
4845
|
-
"",
|
|
4846
|
-
`\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
|
|
4847
|
-
);
|
|
4848
|
-
const areaId = route.resolved?.areaId;
|
|
4849
|
-
if (areaId || repos.some((r) => r.id)) {
|
|
4850
|
-
lines.push(
|
|
4851
|
-
"",
|
|
4852
|
-
"## Filing a new ticket",
|
|
4853
|
-
"",
|
|
4854
|
-
"Something broken that is out of scope for the ticket you are working is not",
|
|
4855
|
-
"yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
|
|
4856
|
-
"the one you were on. A ticket filed without its project and repo sits unrouted",
|
|
4857
|
-
"until a person notices and fixes it by hand, so always set:",
|
|
4858
|
-
"",
|
|
4859
|
-
...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
|
|
4860
|
-
...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
|
|
4861
|
-
(r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
|
|
4862
|
-
),
|
|
4863
|
-
...authorshipBullets(i.authorship, contract),
|
|
4864
|
-
"",
|
|
4865
|
-
...i.sourceTicket ? [
|
|
4866
|
-
`Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
|
|
4867
|
-
"ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
|
|
4868
|
-
"genuinely cannot proceed without the new one being fixed first; merely having noticed it",
|
|
4869
|
-
"nearby is not a dependency.",
|
|
4870
|
-
""
|
|
4871
|
-
] : []
|
|
4872
|
-
);
|
|
4873
|
-
}
|
|
4874
|
-
if (only) {
|
|
4875
|
-
lines.push(...repoSection(only, key, "## Your worktree"));
|
|
4876
|
-
} else {
|
|
4877
|
-
lines.push(
|
|
4878
|
-
"",
|
|
4879
|
-
"## The repositories this board covers",
|
|
4880
|
-
"",
|
|
4881
|
-
"This board spans several repositories, and they do not share a branch name,",
|
|
4882
|
-
"a worktree location or a test command. **The `repo` column of your queue",
|
|
4883
|
-
"says which one your ticket belongs to** \u2014 read the section for THAT one and",
|
|
4884
|
-
"ignore the others. A ticket whose repo is not listed here has no checkout on",
|
|
4885
|
-
"this machine and is not yours to work."
|
|
4886
|
-
);
|
|
4887
|
-
for (const r of repos) {
|
|
4888
|
-
lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
|
|
4889
|
-
lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
|
|
4890
|
-
}
|
|
4891
|
-
}
|
|
4892
|
-
if (i.repoDir) {
|
|
4893
|
-
lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
|
|
4894
|
-
}
|
|
4895
|
-
if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
|
|
4896
|
-
return lines.join("\n");
|
|
4897
|
-
}
|
|
4898
|
-
|
|
4899
|
-
// src/contract.ts
|
|
4900
|
-
var DEFAULT_CONTRACT = {
|
|
4901
|
-
columns: {
|
|
4902
|
-
key: "issue_id",
|
|
4903
|
-
tag: "issue_tag",
|
|
4904
|
-
title: "title",
|
|
4905
|
-
status: "status",
|
|
4906
|
-
assignee: "assignee_id",
|
|
4907
|
-
priority: "priority",
|
|
4908
|
-
severity: "severity",
|
|
4909
|
-
slice: "project_id",
|
|
4910
|
-
repo: "repo_id",
|
|
4911
|
-
parent: "parent_id",
|
|
4912
|
-
blockedBy: "blocked_by",
|
|
4913
|
-
updatedAt: "updated_at",
|
|
4914
|
-
needsDesign: "needs_design",
|
|
4915
|
-
attachments: "attachments",
|
|
4916
|
-
workMode: "work_mode",
|
|
4917
|
-
reportType: "report_type",
|
|
4918
|
-
epic: "epic_id",
|
|
4919
|
-
author: "filed_by_id",
|
|
4920
|
-
ship: "ship_id",
|
|
4921
|
-
heldBy: "held_by_ship_id",
|
|
4922
|
-
resolutionNote: "resolution_note",
|
|
4923
|
-
verifiedSha: "verified_sha",
|
|
4924
|
-
prRef: "pr_ref",
|
|
4925
|
-
externalId: "external_id",
|
|
4926
|
-
position: "position",
|
|
4927
|
-
agentRuns: "agent_runs",
|
|
4928
|
-
tokensIn: "tokens_in",
|
|
4929
|
-
tokensOut: "tokens_out",
|
|
4930
|
-
cacheReadTokens: "cache_read_tokens",
|
|
4931
|
-
cacheWriteTokens: "cache_write_tokens",
|
|
4932
|
-
costUsd: "cost_usd"
|
|
4933
|
-
},
|
|
4934
|
-
comments: {
|
|
4935
|
-
parent: "ticket_id",
|
|
4936
|
-
body: "body",
|
|
4937
|
-
author: "team_member_id",
|
|
4938
|
-
createdAt: "created_at",
|
|
4939
|
-
ship: "ship_id",
|
|
4940
|
-
publicReply: "is_public_reply",
|
|
4941
|
-
authorName: "author_name"
|
|
4942
|
-
},
|
|
4943
|
-
statuses: {
|
|
4944
|
-
open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
|
|
4945
|
-
resolved: [
|
|
4946
|
-
"verified",
|
|
4947
|
-
"closed_deployed",
|
|
4948
|
-
"closed_wont_fix",
|
|
4949
|
-
"closed_duplicate",
|
|
4950
|
-
"closed_completed",
|
|
4951
|
-
"closed_cant_reproduce"
|
|
4952
|
-
],
|
|
4953
|
-
approved: "accepted",
|
|
4954
|
-
parked: "blocked",
|
|
4955
|
-
building: "in_progress",
|
|
4956
|
-
handoff: "fixed",
|
|
4957
|
-
verifying: "qa",
|
|
4958
|
-
verified: "verified",
|
|
4959
|
-
deployed: "closed_deployed",
|
|
4960
|
-
needsHuman: "needs_info",
|
|
4961
|
-
reviewing: null,
|
|
4962
|
-
wontFix: "closed_wont_fix",
|
|
4963
|
-
draft: "draft"
|
|
4964
|
-
},
|
|
4965
|
-
epics: {
|
|
4966
|
-
statusColumn: "status",
|
|
4967
|
-
keyColumn: "epic_id",
|
|
4968
|
-
statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
|
|
4969
|
-
},
|
|
4970
|
-
priorityOrder: ["p0", "p1", "p2", "p3"],
|
|
4971
|
-
severityOrder: ["s1", "s2", "s3", "s4"],
|
|
4972
|
-
unknownPriorityRank: 2,
|
|
4973
|
-
coordinatingValue: "coordinating",
|
|
4974
|
-
manualWorkMode: "manual",
|
|
4975
|
-
hybridWorkMode: "hybrid",
|
|
4976
|
-
repoExemptReportTypes: ["coordinating", "question", "investigation"]
|
|
4977
|
-
};
|
|
4978
|
-
function closedStatuses(c) {
|
|
4979
|
-
return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
|
|
4980
|
-
}
|
|
4981
|
-
function resolveContract(override) {
|
|
4982
|
-
if (!override) return DEFAULT_CONTRACT;
|
|
4983
|
-
return {
|
|
4984
|
-
columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
|
|
4985
|
-
comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
|
|
4986
|
-
statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
|
|
4987
|
-
epics: {
|
|
4988
|
-
...DEFAULT_CONTRACT.epics,
|
|
4989
|
-
...override.epics,
|
|
4990
|
-
statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
|
|
4991
|
-
},
|
|
4992
|
-
priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
|
|
4993
|
-
severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
|
|
4994
|
-
unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
|
|
4995
|
-
coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
|
|
4996
|
-
manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
|
|
4997
|
-
hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
|
|
4998
|
-
// A workspace overriding `coordinatingValue` alone (renaming the rollup
|
|
4999
|
-
// marker) still gets that renamed value exempted here by default,
|
|
5000
|
-
// without also having to repeat it — only an explicit
|
|
5001
|
-
// `repoExemptReportTypes` overrides this derived default.
|
|
5002
|
-
repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
|
|
5003
|
-
};
|
|
5004
|
-
}
|
|
5005
|
-
function validateContract(c) {
|
|
5006
|
-
const problems = [];
|
|
5007
|
-
const open = new Set(c.statuses.open);
|
|
5008
|
-
const resolved = new Set(c.statuses.resolved);
|
|
5009
|
-
for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
|
|
5010
|
-
const v = c.statuses[role];
|
|
5011
|
-
if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
|
|
4656
|
+
pause(role) {
|
|
4657
|
+
writeFileSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
|
|
5012
4658
|
}
|
|
5013
|
-
|
|
5014
|
-
|
|
4659
|
+
resume(role) {
|
|
4660
|
+
rmSync6(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
|
|
5015
4661
|
}
|
|
5016
|
-
|
|
5017
|
-
|
|
4662
|
+
/**
|
|
4663
|
+
* The release phase (merge, deploy, release) has its own sentinel, one
|
|
4664
|
+
* level below the whole-crew pause: `crew pause release` stops shipping
|
|
4665
|
+
* while agents keep working.
|
|
4666
|
+
*/
|
|
4667
|
+
isReleasePaused() {
|
|
4668
|
+
return existsSync11(this.path(".release-paused"));
|
|
5018
4669
|
}
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
);
|
|
4670
|
+
setReleasePaused(paused) {
|
|
4671
|
+
if (paused) writeFileSync7(this.path(".release-paused"), "");
|
|
4672
|
+
else rmSync6(this.path(".release-paused"), { force: true });
|
|
5023
4673
|
}
|
|
5024
|
-
|
|
5025
|
-
|
|
4674
|
+
/**
|
|
4675
|
+
* Why a release cycle must not run right now, or null when it may. Every
|
|
4676
|
+
* release entry point checks this first — the release timer, `crew release`,
|
|
4677
|
+
* `crew merge`, `crew deploy` and the inline release a `run` cycle used to
|
|
4678
|
+
* do — so a paused ship cannot merge, deploy, or file release alerts.
|
|
4679
|
+
* A release already in flight when the pause lands finishes; only new
|
|
4680
|
+
* cycles are refused.
|
|
4681
|
+
*/
|
|
4682
|
+
releaseSkipReason() {
|
|
4683
|
+
if (this.isPaused()) return "the crew is paused";
|
|
4684
|
+
if (this.isReleasePaused()) return "releases are paused";
|
|
4685
|
+
return null;
|
|
5026
4686
|
}
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
4687
|
+
/**
|
|
4688
|
+
* Take an exclusive lock, or report who holds it.
|
|
4689
|
+
*
|
|
4690
|
+
* A timer fires every couple of minutes and an agent run takes minutes, so
|
|
4691
|
+
* overlapping fires are certain rather than hypothetical — and two agents
|
|
4692
|
+
* on one checkout would fight over the same worktree and the same tickets.
|
|
4693
|
+
*
|
|
4694
|
+
* A lock naming a DEAD pid is stale and taken over: a run killed mid-cycle
|
|
4695
|
+
* (a reboot, a Ctrl-C) must not wedge the crew until someone notices.
|
|
4696
|
+
*/
|
|
4697
|
+
/**
|
|
4698
|
+
* Lock names become filenames, so anything that could be read as a path has
|
|
4699
|
+
* to go. A scope like `route/repo` otherwise produced
|
|
4700
|
+
* `.release-route/repo.lock` — a write into a directory that does not exist.
|
|
4701
|
+
*/
|
|
4702
|
+
static safe(name) {
|
|
4703
|
+
return name.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
5031
4704
|
}
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
if (c.statuses.parked === c.statuses.approved) {
|
|
5036
|
-
problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
|
|
5037
|
-
}
|
|
5038
|
-
const epicValues = Object.values(c.epics.statuses);
|
|
5039
|
-
if (new Set(epicValues).size !== epicValues.length) {
|
|
5040
|
-
problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
|
|
5041
|
-
}
|
|
5042
|
-
if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
|
|
5043
|
-
if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
|
|
5044
|
-
for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
|
|
5045
|
-
if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
|
|
5046
|
-
}
|
|
5047
|
-
return problems;
|
|
5048
|
-
}
|
|
5049
|
-
var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
|
|
5050
|
-
|
|
5051
|
-
// src/agent-log.ts
|
|
5052
|
-
import { existsSync as existsSync11, mkdirSync as mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync11, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
5053
|
-
import { join as join12 } from "path";
|
|
5054
|
-
|
|
5055
|
-
// src/pending-backoff.ts
|
|
5056
|
-
var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
|
|
5057
|
-
|
|
5058
|
-
// src/agent-log.ts
|
|
5059
|
-
var AgentLogUnreachableError = class extends Error {
|
|
5060
|
-
};
|
|
5061
|
-
var AgentLogHttpError = class extends Error {
|
|
5062
|
-
status;
|
|
5063
|
-
constructor(message, status2) {
|
|
5064
|
-
super(message);
|
|
5065
|
-
this.status = status2;
|
|
5066
|
-
}
|
|
5067
|
-
};
|
|
5068
|
-
async function request(target, method, path, body) {
|
|
5069
|
-
const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
5070
|
-
const res = await fetch(url, {
|
|
5071
|
-
method,
|
|
5072
|
-
headers: {
|
|
5073
|
-
Authorization: `Bearer ${target.apiKey}`,
|
|
5074
|
-
"User-Agent": target.userAgent,
|
|
5075
|
-
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
5076
|
-
},
|
|
5077
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
5078
|
-
});
|
|
5079
|
-
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5080
|
-
return await res.json();
|
|
5081
|
-
}
|
|
5082
|
-
var issueColumns = /* @__PURE__ */ new Map();
|
|
5083
|
-
function columnsOf(target, modelId) {
|
|
5084
|
-
const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
|
|
5085
|
-
let p = issueColumns.get(key);
|
|
5086
|
-
if (!p) {
|
|
5087
|
-
p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
|
|
5088
|
-
p.catch(() => issueColumns.delete(key));
|
|
5089
|
-
issueColumns.set(key, p);
|
|
5090
|
-
}
|
|
5091
|
-
return p;
|
|
5092
|
-
}
|
|
5093
|
-
async function findTicketRecord(target, t, ref) {
|
|
5094
|
-
const filters = {
|
|
5095
|
-
combinator: "OR",
|
|
5096
|
-
conditions: [
|
|
5097
|
-
{ columnName: t.keyColumn, operator: "EQ", value: ref },
|
|
5098
|
-
{ columnName: t.tagColumn, operator: "EQ", value: ref }
|
|
5099
|
-
]
|
|
5100
|
-
};
|
|
5101
|
-
const rows = await request(
|
|
5102
|
-
target,
|
|
5103
|
-
"GET",
|
|
5104
|
-
`/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
|
|
5105
|
-
);
|
|
5106
|
-
return rows.length === 1 ? rows[0].id : void 0;
|
|
5107
|
-
}
|
|
5108
|
-
function counterIncrements(run2, cols, have) {
|
|
5109
|
-
const figures = [
|
|
5110
|
-
[cols.agentRuns, 1],
|
|
5111
|
-
[cols.tokensIn, run2.tokensIn],
|
|
5112
|
-
[cols.tokensOut, run2.tokensOut],
|
|
5113
|
-
[cols.cacheReadTokens, run2.cacheReadTokens],
|
|
5114
|
-
[cols.cacheWriteTokens, run2.cacheWriteTokens],
|
|
5115
|
-
[cols.costUsd, run2.costUsd]
|
|
5116
|
-
];
|
|
5117
|
-
const out = {};
|
|
5118
|
-
for (const [column, value2] of figures) {
|
|
5119
|
-
if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
|
|
5120
|
-
}
|
|
5121
|
-
return out;
|
|
5122
|
-
}
|
|
5123
|
-
async function post(target, path, body) {
|
|
5124
|
-
const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
|
|
5125
|
-
const res = await fetch(url, {
|
|
5126
|
-
method: "POST",
|
|
5127
|
-
headers: {
|
|
5128
|
-
Authorization: `Bearer ${target.apiKey}`,
|
|
5129
|
-
// Cloudflare 403s default agents on this host — same header connect.ts sends.
|
|
5130
|
-
"User-Agent": target.userAgent,
|
|
5131
|
-
"Content-Type": "application/json"
|
|
5132
|
-
},
|
|
5133
|
-
body: JSON.stringify(body)
|
|
5134
|
-
});
|
|
5135
|
-
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5136
|
-
return await res.json();
|
|
5137
|
-
}
|
|
5138
|
-
async function reportAgentRun(target, run2) {
|
|
5139
|
-
let ticketRecordId;
|
|
5140
|
-
if (target.tickets && run2.ticketReference) {
|
|
4705
|
+
acquire(rawName) {
|
|
4706
|
+
const name = _State.safe(rawName);
|
|
4707
|
+
const file = this.path(`.${name}.lock`);
|
|
5141
4708
|
try {
|
|
5142
|
-
|
|
4709
|
+
const existing = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
|
|
4710
|
+
if (Number.isFinite(existing) && existing > 0) {
|
|
4711
|
+
try {
|
|
4712
|
+
process.kill(existing, 0);
|
|
4713
|
+
return { ok: false, heldBy: existing };
|
|
4714
|
+
} catch {
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
5143
4717
|
} catch {
|
|
5144
4718
|
}
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
agentId: target.agentId,
|
|
5148
|
-
ticketReference: run2.ticketReference,
|
|
5149
|
-
outcome: run2.outcome,
|
|
5150
|
-
startedAt: run2.startedAt,
|
|
5151
|
-
finishedAt: run2.finishedAt,
|
|
5152
|
-
source: "client",
|
|
5153
|
-
client: "crew",
|
|
5154
|
-
model: run2.model,
|
|
5155
|
-
tokensIn: run2.tokensIn,
|
|
5156
|
-
tokensOut: run2.tokensOut,
|
|
5157
|
-
cacheReadTokens: run2.cacheReadTokens,
|
|
5158
|
-
cacheWriteTokens: run2.cacheWriteTokens,
|
|
5159
|
-
costUsd: run2.costUsd,
|
|
5160
|
-
numTurns: run2.numTurns,
|
|
5161
|
-
prefixTokens: run2.prefixTokens,
|
|
5162
|
-
promptVersion: run2.promptVersion,
|
|
5163
|
-
promptSha: run2.promptSha
|
|
5164
|
-
};
|
|
5165
|
-
const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
|
|
5166
|
-
let entry;
|
|
5167
|
-
try {
|
|
5168
|
-
entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
|
|
5169
|
-
} catch (e) {
|
|
5170
|
-
if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
|
|
5171
|
-
if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
|
|
5172
|
-
if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) throw e;
|
|
5173
|
-
entry = await post(target, "/agents/log", body);
|
|
5174
|
-
}
|
|
5175
|
-
for (const cycle of run2.cycles) {
|
|
5176
|
-
await post(target, `/agents/log/${entry.id}/cycles`, {
|
|
5177
|
-
cycleIndex: cycle.cycleIndex,
|
|
5178
|
-
occurredAt: cycle.occurredAt,
|
|
5179
|
-
thinking: cycle.thinking,
|
|
5180
|
-
...cycle.role ? { role: cycle.role } : {}
|
|
5181
|
-
});
|
|
5182
|
-
}
|
|
5183
|
-
const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
|
|
5184
|
-
return {
|
|
5185
|
-
agentLogId: entry.id,
|
|
5186
|
-
...counters?.warning ? { counterWarning: counters.warning } : {},
|
|
5187
|
-
...counters?.retry ? { counterRetry: counters.retry } : {}
|
|
5188
|
-
};
|
|
5189
|
-
}
|
|
5190
|
-
var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
|
|
5191
|
-
async function writeCounters(target, w) {
|
|
5192
|
-
const have = await columnsOf(target, w.issuesModelId);
|
|
5193
|
-
const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
|
|
5194
|
-
if (Object.keys(body).length === 0) return;
|
|
5195
|
-
await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
|
|
5196
|
-
}
|
|
5197
|
-
async function addRunToTicket(target, t, recordId, run2) {
|
|
5198
|
-
const write = {
|
|
5199
|
-
issuesModelId: t.issuesModelId,
|
|
5200
|
-
recordId,
|
|
5201
|
-
ticketReference: run2.ticketReference,
|
|
5202
|
-
counters: t.counters,
|
|
5203
|
-
body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
|
|
5204
|
-
};
|
|
5205
|
-
try {
|
|
5206
|
-
await writeCounters(target, write);
|
|
5207
|
-
return {};
|
|
5208
|
-
} catch (e) {
|
|
4719
|
+
writeFileSync7(file, `${process.pid}
|
|
4720
|
+
`);
|
|
5209
4721
|
return {
|
|
5210
|
-
|
|
5211
|
-
|
|
4722
|
+
ok: true,
|
|
4723
|
+
release: () => {
|
|
4724
|
+
try {
|
|
4725
|
+
rmSync6(file, { force: true });
|
|
4726
|
+
} catch {
|
|
4727
|
+
}
|
|
4728
|
+
}
|
|
5212
4729
|
};
|
|
5213
4730
|
}
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
const dir = queueDir(stateDir);
|
|
5222
|
-
if (!existsSync11(dir)) return 0;
|
|
5223
|
-
let sent = 0;
|
|
5224
|
-
for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5225
|
-
const path = join12(dir, f);
|
|
5226
|
-
let q;
|
|
4731
|
+
/**
|
|
4732
|
+
* Whether a release is running right now: any `.release-<scope>.lock` in the
|
|
4733
|
+
* state directory naming a live process (the locks `releasePhase` takes per
|
|
4734
|
+
* scope). A lock naming a dead pid is ignored, as `acquire` ignores it.
|
|
4735
|
+
*/
|
|
4736
|
+
releaseInProgress() {
|
|
4737
|
+
let names;
|
|
5227
4738
|
try {
|
|
5228
|
-
|
|
4739
|
+
names = readdirSync4(this.dir);
|
|
5229
4740
|
} catch {
|
|
5230
|
-
|
|
4741
|
+
return false;
|
|
5231
4742
|
}
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
|
|
5239
|
-
version: 1,
|
|
5240
|
-
route: q.route,
|
|
5241
|
-
role: q.role,
|
|
5242
|
-
target: q.target,
|
|
5243
|
-
write: sentRun.counterRetry,
|
|
5244
|
-
attempts: 0,
|
|
5245
|
-
nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5246
|
-
});
|
|
5247
|
-
rmSync6(path, { force: true });
|
|
5248
|
-
sent += 1;
|
|
5249
|
-
log.emit(`sent a queued Agent Log report (${f})`);
|
|
5250
|
-
} catch (e) {
|
|
5251
|
-
if (!(e instanceof AgentLogUnreachableError)) {
|
|
5252
|
-
rmSync6(path, { force: true });
|
|
5253
|
-
log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
|
|
4743
|
+
for (const n of names) {
|
|
4744
|
+
if (!/^\.release-.+\.lock$/.test(n)) continue;
|
|
4745
|
+
let pid = 0;
|
|
4746
|
+
try {
|
|
4747
|
+
pid = Number.parseInt(readFileSync11(this.path(n), "utf8").trim(), 10);
|
|
4748
|
+
} catch {
|
|
5254
4749
|
continue;
|
|
5255
4750
|
}
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
}));
|
|
5263
|
-
if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
|
|
4751
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
4752
|
+
try {
|
|
4753
|
+
process.kill(pid, 0);
|
|
4754
|
+
return true;
|
|
4755
|
+
} catch {
|
|
4756
|
+
}
|
|
5264
4757
|
}
|
|
5265
|
-
|
|
5266
|
-
return sent;
|
|
5267
|
-
}
|
|
5268
|
-
var countersDir = (stateDir) => join12(stateDir, "pending-ticket-counters");
|
|
5269
|
-
function queueTicketCounters(stateDir, name, q) {
|
|
5270
|
-
mkdirSync6(countersDir(stateDir), { recursive: true });
|
|
5271
|
-
writeFileSync7(join12(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
|
|
5272
|
-
}
|
|
5273
|
-
async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
5274
|
-
const dir = countersDir(stateDir);
|
|
5275
|
-
if (!existsSync11(dir)) return 0;
|
|
5276
|
-
let sent = 0;
|
|
5277
|
-
for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5278
|
-
const path = join12(dir, f);
|
|
5279
|
-
let q;
|
|
5280
|
-
try {
|
|
5281
|
-
q = JSON.parse(readFileSync11(path, "utf8"));
|
|
5282
|
-
} catch {
|
|
5283
|
-
continue;
|
|
5284
|
-
}
|
|
5285
|
-
if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
|
|
5286
|
-
try {
|
|
5287
|
-
const apiKey = await keyFor(q.role);
|
|
5288
|
-
if (!apiKey) continue;
|
|
5289
|
-
await writeCounters({ ...q.target, apiKey }, q.write);
|
|
5290
|
-
rmSync6(path, { force: true });
|
|
5291
|
-
sent += 1;
|
|
5292
|
-
log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
|
|
5293
|
-
} catch (e) {
|
|
5294
|
-
if (!isRetryable(e)) {
|
|
5295
|
-
rmSync6(path, { force: true });
|
|
5296
|
-
log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
|
|
5297
|
-
continue;
|
|
5298
|
-
}
|
|
5299
|
-
const attempts = q.attempts + 1;
|
|
5300
|
-
writeFileSync7(path, JSON.stringify({
|
|
5301
|
-
...q,
|
|
5302
|
-
attempts,
|
|
5303
|
-
warned: true,
|
|
5304
|
-
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
5305
|
-
}));
|
|
5306
|
-
if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
|
|
5307
|
-
}
|
|
5308
|
-
}
|
|
5309
|
-
return sent;
|
|
5310
|
-
}
|
|
5311
|
-
|
|
5312
|
-
// src/ticket-attribution.ts
|
|
5313
|
-
var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
|
|
5314
|
-
function ticketMentions(text) {
|
|
5315
|
-
return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
|
|
5316
|
-
}
|
|
5317
|
-
var TicketAttributionTracker = class {
|
|
5318
|
-
#tokensByTicket = /* @__PURE__ */ new Map();
|
|
5319
|
-
#turns = 0;
|
|
5320
|
-
#current;
|
|
5321
|
-
/** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
|
|
5322
|
-
#keyByRecord = /* @__PURE__ */ new Map();
|
|
5323
|
-
#lastBy = {};
|
|
5324
|
-
/**
|
|
5325
|
-
* The ticket key most recently named in the transcript, or undefined before any.
|
|
5326
|
-
* Only the FIRST key in a turn counts: a closing summary names its own
|
|
5327
|
-
* ticket first and then lists others ("Other tickets", "Blocked by", "See
|
|
5328
|
-
* also"), and those trailing keys are not what the run worked (CREW-2598).
|
|
5329
|
-
*/
|
|
5330
|
-
get current() {
|
|
5331
|
-
return this.#current;
|
|
5332
|
-
}
|
|
5333
|
-
/** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
|
|
5334
|
-
note(text) {
|
|
5335
|
-
const mentions = ticketMentions(text);
|
|
5336
|
-
if (mentions.length > 0) this.#current = mentions[0];
|
|
5337
|
-
}
|
|
5338
|
-
/** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
|
|
5339
|
-
add(text, tokens) {
|
|
5340
|
-
const mentions = ticketMentions(text);
|
|
5341
|
-
if (mentions.length > 0) this.#current = mentions[0];
|
|
5342
|
-
if (this.#current === void 0 || tokens <= 0) return;
|
|
5343
|
-
this.#turns++;
|
|
5344
|
-
this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
|
|
4758
|
+
return false;
|
|
5345
4759
|
}
|
|
5346
4760
|
/**
|
|
5347
|
-
*
|
|
5348
|
-
*
|
|
5349
|
-
*
|
|
5350
|
-
*
|
|
5351
|
-
* seen before is ignored: its uuid cannot be resolved to a key.
|
|
4761
|
+
* Like `acquire`, but for a resource with `max` interchangeable slots
|
|
4762
|
+
* rather than one exclusive holder — this machine's overall concurrency
|
|
4763
|
+
* capacity (ISSUE-381), which nothing needs to name a particular slot to
|
|
4764
|
+
* use. Takes whichever numbered slot is free first.
|
|
5352
4765
|
*/
|
|
5353
|
-
|
|
5354
|
-
const
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
4766
|
+
acquireN(rawPrefix, max) {
|
|
4767
|
+
const heldBy = [];
|
|
4768
|
+
for (let i = 1; i <= max; i++) {
|
|
4769
|
+
const got = this.acquire(`${rawPrefix}-${i}`);
|
|
4770
|
+
if (got.ok) return got;
|
|
4771
|
+
heldBy.push(got.heldBy);
|
|
4772
|
+
}
|
|
4773
|
+
return { ok: false, heldBy };
|
|
5361
4774
|
}
|
|
5362
4775
|
/**
|
|
5363
|
-
*
|
|
5364
|
-
*
|
|
5365
|
-
*
|
|
5366
|
-
*
|
|
5367
|
-
*
|
|
5368
|
-
*
|
|
5369
|
-
*
|
|
5370
|
-
*
|
|
5371
|
-
*
|
|
5372
|
-
*
|
|
5373
|
-
*
|
|
4776
|
+
* The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
|
|
4777
|
+
* checked together.
|
|
4778
|
+
*
|
|
4779
|
+
* Two sessions of one role must never take the same ticket. Before
|
|
4780
|
+
* CREW-2821 that was guaranteed by an exclusive per-role lock; now the
|
|
4781
|
+
* guarantee is the per-ticket lock (`acquireTicket`), so the role lock is a
|
|
4782
|
+
* counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
|
|
4783
|
+
* historical name `crew-role-<role>` (crew-macos reads it for the live pid),
|
|
4784
|
+
* further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
|
|
4785
|
+
* limit on how many runs of ANY role may overlap; a role that clears its own
|
|
4786
|
+
* lock but finds every slot taken must give its role lock back, or a
|
|
4787
|
+
* locked-but-not-running role would wedge itself out for the rest of the
|
|
4788
|
+
* machine's uptime.
|
|
5374
4789
|
*/
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
4790
|
+
acquireRun(role, max, roleMax = 1) {
|
|
4791
|
+
let roleLock;
|
|
4792
|
+
const roleHolders = [];
|
|
4793
|
+
for (let i = 1; i <= roleMax; i++) {
|
|
4794
|
+
const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
|
|
4795
|
+
if (got.ok) {
|
|
4796
|
+
roleLock = got;
|
|
4797
|
+
break;
|
|
4798
|
+
}
|
|
4799
|
+
roleHolders.push(got.heldBy);
|
|
5379
4800
|
}
|
|
5380
|
-
if (
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
let total = 0;
|
|
5386
|
-
for (const n of this.#tokensByTicket.values()) total += n;
|
|
5387
|
-
const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
|
|
5388
|
-
return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
|
|
4801
|
+
if (!roleLock) {
|
|
4802
|
+
return {
|
|
4803
|
+
ok: false,
|
|
4804
|
+
reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
|
|
4805
|
+
};
|
|
5389
4806
|
}
|
|
5390
|
-
|
|
4807
|
+
const slot = this.acquireN("crew-slot", max);
|
|
4808
|
+
if (!slot.ok) {
|
|
4809
|
+
roleLock.release();
|
|
4810
|
+
return {
|
|
4811
|
+
ok: false,
|
|
4812
|
+
reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
|
|
4813
|
+
};
|
|
4814
|
+
}
|
|
4815
|
+
return { ok: true, release: () => {
|
|
4816
|
+
slot.release();
|
|
4817
|
+
roleLock.release();
|
|
4818
|
+
} };
|
|
5391
4819
|
}
|
|
5392
4820
|
/**
|
|
5393
|
-
* The ticket
|
|
5394
|
-
*
|
|
5395
|
-
*
|
|
5396
|
-
*
|
|
4821
|
+
* The per-ticket run lock (CREW-2821): held for the life of a run that works
|
|
4822
|
+
* the ticket (`crew-ticket-<record id>`), so a second run of the same role
|
|
4823
|
+
* on this ship walks past it instead of being handed the first run's
|
|
4824
|
+
* ticket. Same stale-pid rule as every lock here.
|
|
5397
4825
|
*/
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
let bestTokens = -1;
|
|
5401
|
-
for (const [ticket, tokens] of this.#tokensByTicket) {
|
|
5402
|
-
if (tokens > bestTokens) {
|
|
5403
|
-
best = ticket;
|
|
5404
|
-
bestTokens = tokens;
|
|
5405
|
-
}
|
|
5406
|
-
}
|
|
5407
|
-
return best;
|
|
5408
|
-
}
|
|
5409
|
-
};
|
|
5410
|
-
function isClaimWrite(tool, input) {
|
|
5411
|
-
if (!tool || !/records_controller_update$/.test(tool)) return false;
|
|
5412
|
-
const body = input?.body;
|
|
5413
|
-
return typeof body === "object" && body !== null && body.status === "in_progress";
|
|
5414
|
-
}
|
|
5415
|
-
var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
|
|
5416
|
-
function classifyWrite(tool, input) {
|
|
5417
|
-
if (!tool) return void 0;
|
|
5418
|
-
const body = input?.body;
|
|
5419
|
-
if (typeof body !== "object" || body === null) return void 0;
|
|
5420
|
-
const b = body;
|
|
5421
|
-
if (/records_controller_update$/.test(tool)) {
|
|
5422
|
-
const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
|
|
5423
|
-
if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
|
|
5424
|
-
if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
|
|
4826
|
+
acquireTicket(recordId) {
|
|
4827
|
+
return this.acquire(`crew-ticket-${recordId}`);
|
|
5425
4828
|
}
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
}
|
|
5429
|
-
|
|
5430
|
-
// src/handoff-transcript.ts
|
|
5431
|
-
var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
|
|
5432
|
-
function resultText(content) {
|
|
5433
|
-
if (typeof content === "string") return content;
|
|
5434
|
-
if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
|
|
5435
|
-
return "";
|
|
5436
|
-
}
|
|
5437
|
-
function isTransportFailure(isError, text) {
|
|
5438
|
-
if (!isError) {
|
|
4829
|
+
/** Whether a live run on this ship holds `recordId`'s ticket lock. */
|
|
4830
|
+
ticketInFlight(recordId) {
|
|
4831
|
+
const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
|
|
5439
4832
|
try {
|
|
5440
|
-
const
|
|
5441
|
-
if (
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
}
|
|
4833
|
+
const pid = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
|
|
4834
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
4835
|
+
process.kill(pid, 0);
|
|
4836
|
+
return true;
|
|
5445
4837
|
} catch {
|
|
4838
|
+
return false;
|
|
5446
4839
|
}
|
|
5447
4840
|
}
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
}
|
|
5484
|
-
|
|
4841
|
+
/**
|
|
4842
|
+
* How many consecutive cycles the release phase has refused to deploy.
|
|
4843
|
+
*
|
|
4844
|
+
* Refusing is correct — a dirty tree or a non-main checkout must never be
|
|
4845
|
+
* released — but it used to be SILENT, and eight commits once sat unreleased
|
|
4846
|
+
* behind a single untracked file with nothing saying so (ISSUE-174). One
|
|
4847
|
+
* blocked cycle is normal; the count is what makes a persistent one loud.
|
|
4848
|
+
*/
|
|
4849
|
+
/**
|
|
4850
|
+
* Release bookkeeping, scoped to one route.
|
|
4851
|
+
*
|
|
4852
|
+
* Scoped because a ship serves several boards and releases each repo
|
|
4853
|
+
* independently (ISSUE-338). These were single global files, so a deploy
|
|
4854
|
+
* failure on one route suppressed releases on every other one, and a
|
|
4855
|
+
* release blocked in one repo counted cycles for all of them — a dirty tree
|
|
4856
|
+
* in one checkout would have silently held up an unrelated project.
|
|
4857
|
+
*/
|
|
4858
|
+
release(route) {
|
|
4859
|
+
const suffix = _State.safe(route);
|
|
4860
|
+
const blocked = `.release-blocked-${suffix}`;
|
|
4861
|
+
const failed = `.deploy-failed-sha-${suffix}`;
|
|
4862
|
+
const gateFailed = `.test-gate-failed-${suffix}`;
|
|
4863
|
+
const gateReported = `.test-gate-reported-${suffix}`;
|
|
4864
|
+
const heldReported = `.release-held-${suffix}`;
|
|
4865
|
+
const read = (f) => {
|
|
4866
|
+
try {
|
|
4867
|
+
return readFileSync11(this.path(f), "utf8").trim() || null;
|
|
4868
|
+
} catch {
|
|
4869
|
+
return null;
|
|
4870
|
+
}
|
|
4871
|
+
};
|
|
4872
|
+
return {
|
|
4873
|
+
blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
|
|
4874
|
+
noteBlocked: () => {
|
|
4875
|
+
const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
|
|
4876
|
+
writeFileSync7(this.path(blocked), `${n}
|
|
4877
|
+
`);
|
|
4878
|
+
return n;
|
|
4879
|
+
},
|
|
4880
|
+
clearBlocked: () => {
|
|
4881
|
+
rmSync6(this.path(blocked), { force: true });
|
|
4882
|
+
},
|
|
4883
|
+
/**
|
|
4884
|
+
* The commit a deploy last failed on.
|
|
4885
|
+
*
|
|
4886
|
+
* Without this the crew retries a broken deploy on every cycle — every
|
|
4887
|
+
* two minutes, indefinitely — hammering the target and burying the
|
|
4888
|
+
* original failure under identical ones. A failed commit is retried
|
|
4889
|
+
* only when a NEW commit lands, or when an operator forces it by hand.
|
|
4890
|
+
*/
|
|
4891
|
+
deployFailedSha: () => read(failed),
|
|
4892
|
+
noteDeployFailed: (sha) => {
|
|
4893
|
+
writeFileSync7(this.path(failed), `${sha}
|
|
4894
|
+
`);
|
|
4895
|
+
},
|
|
4896
|
+
clearDeployFailed: () => {
|
|
4897
|
+
rmSync6(this.path(failed), { force: true });
|
|
4898
|
+
},
|
|
4899
|
+
/**
|
|
4900
|
+
* Consecutive test-gate failures on the same head (CREW-1368). A failure
|
|
4901
|
+
* on a new head restarts the count. Held on disk because one process is
|
|
4902
|
+
* one cycle, and "failed three cycles running" only exists across them.
|
|
4903
|
+
*/
|
|
4904
|
+
noteTestGateFailed: (sha) => {
|
|
4905
|
+
const [prev, n] = (read(gateFailed) ?? "").split(" ");
|
|
4906
|
+
const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
|
|
4907
|
+
writeFileSync7(this.path(gateFailed), `${sha} ${next}
|
|
4908
|
+
`);
|
|
4909
|
+
return next;
|
|
4910
|
+
},
|
|
4911
|
+
clearTestGateFailed: () => {
|
|
4912
|
+
rmSync6(this.path(gateFailed), { force: true });
|
|
4913
|
+
},
|
|
4914
|
+
/** Whether the board was already told about this head's red gate. */
|
|
4915
|
+
heldReported: () => {
|
|
4916
|
+
try {
|
|
4917
|
+
return readFileSync11(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
|
|
4918
|
+
} catch {
|
|
4919
|
+
return null;
|
|
4920
|
+
}
|
|
4921
|
+
},
|
|
4922
|
+
noteHeldReported: (signature) => {
|
|
4923
|
+
writeFileSync7(this.path(heldReported), `${signature}
|
|
4924
|
+
`);
|
|
4925
|
+
},
|
|
4926
|
+
clearHeldReported: () => {
|
|
4927
|
+
rmSync6(this.path(heldReported), { force: true });
|
|
4928
|
+
},
|
|
4929
|
+
testGateReported: (sha) => read(gateReported) === sha,
|
|
4930
|
+
noteTestGateReported: (sha) => {
|
|
4931
|
+
writeFileSync7(this.path(gateReported), `${sha}
|
|
4932
|
+
`);
|
|
4933
|
+
}
|
|
4934
|
+
};
|
|
4935
|
+
}
|
|
4936
|
+
/**
|
|
4937
|
+
* Starvation bookkeeping, scoped to one route (ISSUE-382).
|
|
4938
|
+
*
|
|
4939
|
+
* `decideFleet` already renders the winner and each role's rank; what it
|
|
4940
|
+
* throws away is the other side — a route that had work and did not
|
|
4941
|
+
* win, and the same ticket sitting at the front of that route's queue
|
|
4942
|
+
* cycle after cycle. This is state, not memory: a single process is one
|
|
4943
|
+
* cycle, so "how long has this been waiting" only exists if it survives
|
|
4944
|
+
* between them.
|
|
4945
|
+
*/
|
|
4946
|
+
fairness(route) {
|
|
4947
|
+
const suffix = _State.safe(route);
|
|
4948
|
+
const streakFile = `.fairness-streak-${suffix}`;
|
|
4949
|
+
const ticketFile = `.fairness-ticket-${suffix}`;
|
|
4950
|
+
const sinceFile = `.fairness-since-${suffix}`;
|
|
4951
|
+
const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
|
|
4952
|
+
const read = (f) => {
|
|
4953
|
+
try {
|
|
4954
|
+
return readFileSync11(this.path(f), "utf8").trim() || null;
|
|
4955
|
+
} catch {
|
|
4956
|
+
return null;
|
|
4957
|
+
}
|
|
4958
|
+
};
|
|
4959
|
+
const clear2 = () => {
|
|
4960
|
+
for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync6(this.path(f), { force: true });
|
|
4961
|
+
};
|
|
4962
|
+
return {
|
|
4963
|
+
/** Consecutive cycles this route had actionable work and did not win. */
|
|
4964
|
+
streak: () => Number.parseInt(read(streakFile) ?? "", 10) || 0,
|
|
4965
|
+
/**
|
|
4966
|
+
* The ticket sitting at the front of this route's queue, how long
|
|
4967
|
+
* it has been there and its own consecutive-passed-over count — or
|
|
4968
|
+
* undefined if nothing is waiting.
|
|
4969
|
+
*/
|
|
4970
|
+
waiting: () => {
|
|
4971
|
+
const ticket = read(ticketFile);
|
|
4972
|
+
const since2 = read(sinceFile);
|
|
4973
|
+
if (!ticket || !since2) return void 0;
|
|
4974
|
+
return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
|
|
4975
|
+
},
|
|
4976
|
+
/**
|
|
4977
|
+
* Record what one cycle saw for this route: how many tickets were
|
|
4978
|
+
* actionable, which one was most urgent, and whether this route
|
|
4979
|
+
* won. Winning or running dry both reset the streak — there is nothing
|
|
4980
|
+
* left being passed over.
|
|
4981
|
+
*/
|
|
4982
|
+
record: (actionable, topTicket, won) => {
|
|
4983
|
+
if (won || actionable === 0) {
|
|
4984
|
+
clear2();
|
|
4985
|
+
return;
|
|
4986
|
+
}
|
|
4987
|
+
writeFileSync7(this.path(streakFile), `${this.readInt(streakFile) + 1}
|
|
4988
|
+
`);
|
|
4989
|
+
const prior = read(ticketFile);
|
|
4990
|
+
if (topTicket && topTicket === prior) {
|
|
4991
|
+
writeFileSync7(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
|
|
4992
|
+
`);
|
|
4993
|
+
} else if (topTicket) {
|
|
4994
|
+
writeFileSync7(this.path(ticketFile), `${topTicket}
|
|
4995
|
+
`);
|
|
4996
|
+
writeFileSync7(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
4997
|
+
`);
|
|
4998
|
+
writeFileSync7(this.path(ticketStreakFile), "1\n");
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
};
|
|
5002
|
+
}
|
|
5003
|
+
/**
|
|
5004
|
+
* The previous cycle's "needs a person" set, scoped to one route
|
|
5005
|
+
* (ISSUE-928) — `{ ticketId: [reason, ...] }`. `poll.ts` reads it once
|
|
5006
|
+
* per cycle to compute transitions and the caller (`cli.ts`'s run path,
|
|
5007
|
+
* `fleet.ts`'s per-route loop) persists the new set afterward, beside the
|
|
5008
|
+
* existing `fairness(route).record(...)` call — never inside `decideCycle`
|
|
5009
|
+
* itself, which does no writes. A corrupt or missing file reads as empty,
|
|
5010
|
+
* which is also deliberately what a fresh install sees: everything
|
|
5011
|
+
* currently needing a person emits once, rather than being silently
|
|
5012
|
+
* skipped as though already notified.
|
|
5013
|
+
*/
|
|
5014
|
+
attention(route) {
|
|
5015
|
+
const file = `.attention-${_State.safe(route)}.json`;
|
|
5016
|
+
return {
|
|
5017
|
+
previous: () => {
|
|
5018
|
+
try {
|
|
5019
|
+
const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
|
|
5020
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
5021
|
+
} catch {
|
|
5022
|
+
return {};
|
|
5023
|
+
}
|
|
5024
|
+
},
|
|
5025
|
+
persist: (next) => {
|
|
5026
|
+
writeFileSync7(this.path(file), JSON.stringify(next));
|
|
5027
|
+
}
|
|
5028
|
+
};
|
|
5029
|
+
}
|
|
5030
|
+
/**
|
|
5031
|
+
* The previous cycle's unroutable ticket ids for one route (CREW-2548): open
|
|
5032
|
+
* tickets with no repository that are not exempt. `poll.ts` diffs against it
|
|
5033
|
+
* so each ticket warns once when it becomes unroutable, not every cycle; the
|
|
5034
|
+
* caller persists the new set beside `attention(route).persist(...)`. A
|
|
5035
|
+
* missing or corrupt file reads as empty, so a fresh install warns once.
|
|
5036
|
+
*/
|
|
5037
|
+
unroutable(route) {
|
|
5038
|
+
const file = `.unroutable-${_State.safe(route)}.json`;
|
|
5039
|
+
return {
|
|
5040
|
+
previous: () => {
|
|
5041
|
+
try {
|
|
5042
|
+
const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
|
|
5043
|
+
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
|
|
5044
|
+
} catch {
|
|
5045
|
+
return [];
|
|
5046
|
+
}
|
|
5047
|
+
},
|
|
5048
|
+
persist: (next) => {
|
|
5049
|
+
writeFileSync7(this.path(file), JSON.stringify(next));
|
|
5050
|
+
}
|
|
5051
|
+
};
|
|
5052
|
+
}
|
|
5053
|
+
/**
|
|
5054
|
+
* Open ship-level attention items (CREW-1373), ship-wide rather than
|
|
5055
|
+
* per-route: a parked role or a missing hook command is about this
|
|
5056
|
+
* machine. A corrupt or missing file reads as empty.
|
|
5057
|
+
*/
|
|
5058
|
+
shipAttention() {
|
|
5059
|
+
const file = ".ship-attention.json";
|
|
5060
|
+
return {
|
|
5061
|
+
previous: () => {
|
|
5062
|
+
try {
|
|
5063
|
+
const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
|
|
5064
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5065
|
+
} catch {
|
|
5066
|
+
return [];
|
|
5067
|
+
}
|
|
5068
|
+
},
|
|
5069
|
+
persist: (next) => {
|
|
5070
|
+
writeFileSync7(this.path(file), JSON.stringify(next));
|
|
5071
|
+
}
|
|
5072
|
+
};
|
|
5073
|
+
}
|
|
5074
|
+
/**
|
|
5075
|
+
* When the release phase last ran a cycle (CREW-1373), as the mtime of a
|
|
5076
|
+
* marker file. `touch()` is called at the start and the end of every cycle
|
|
5077
|
+
* so a long deploy does not read as a stopped timer.
|
|
5078
|
+
*/
|
|
5079
|
+
releaseHeartbeat() {
|
|
5080
|
+
const file = ".release-last-run";
|
|
5081
|
+
return {
|
|
5082
|
+
touch: () => {
|
|
5083
|
+
writeFileSync7(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
|
|
5084
|
+
},
|
|
5085
|
+
at: () => {
|
|
5086
|
+
try {
|
|
5087
|
+
return statSync4(this.path(file)).mtimeMs;
|
|
5088
|
+
} catch {
|
|
5089
|
+
return null;
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
};
|
|
5093
|
+
}
|
|
5094
|
+
readInt(file) {
|
|
5095
|
+
try {
|
|
5096
|
+
return Number.parseInt(readFileSync11(this.path(file), "utf8").trim(), 10) || 0;
|
|
5097
|
+
} catch {
|
|
5098
|
+
return 0;
|
|
5099
|
+
}
|
|
5100
|
+
}
|
|
5101
|
+
};
|
|
5102
|
+
|
|
5103
|
+
// src/environment.ts
|
|
5104
|
+
import { basename as basename5 } from "path";
|
|
5105
|
+
var API_KEY_VAR = "CREW_API_KEY";
|
|
5106
|
+
function authorshipBullets(a, contract) {
|
|
5107
|
+
if (!a) return [];
|
|
5108
|
+
return [
|
|
5109
|
+
...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
|
|
5110
|
+
...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
|
|
5111
|
+
...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
|
|
5112
|
+
];
|
|
5113
|
+
}
|
|
5114
|
+
var HOOK_PURPOSE = [
|
|
5115
|
+
["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
|
|
5116
|
+
["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
|
|
5117
|
+
["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
|
|
5118
|
+
["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
|
|
5119
|
+
["preview", "start this worktree's dev server for a viewer, bound to CREW_PREVIEW_HOST, detached; its last stdout line is the URL"],
|
|
5120
|
+
["test", "run the suite"],
|
|
5121
|
+
["build", "build it"],
|
|
5122
|
+
["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
|
|
5123
|
+
];
|
|
5124
|
+
function hookTable(repo) {
|
|
5125
|
+
const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
|
|
5126
|
+
if (declared.length === 0) {
|
|
5127
|
+
return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
|
|
5128
|
+
}
|
|
5129
|
+
const inline = (cmd) => cmd.includes("\n") ? "_(below)_" : `\`${cmd}\``;
|
|
5130
|
+
const blocks = declared.filter(([name]) => repo.hooks[name].includes("\n")).map(([name]) => [`### \`${name}\``, "", "```sh", repo.hooks[name].trimEnd(), "```", ""].join("\n"));
|
|
5131
|
+
const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
|
|
5132
|
+
return [
|
|
5133
|
+
"| hook | what it is for | what it runs |",
|
|
5134
|
+
"| --- | --- | --- |",
|
|
5135
|
+
...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
|
|
5136
|
+
"",
|
|
5137
|
+
"Run these **verbatim**, from the worktree. They come from the repository's own",
|
|
5138
|
+
"configuration, which is the only thing that knows them. A command you invented",
|
|
5139
|
+
"yourself is wrong even when it works, because the next repository will differ.",
|
|
5140
|
+
"",
|
|
5141
|
+
...blocks,
|
|
5142
|
+
...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
|
|
5143
|
+
].join("\n");
|
|
5144
|
+
}
|
|
5145
|
+
function repoPrefix(repo) {
|
|
5146
|
+
return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
|
|
5147
|
+
}
|
|
5148
|
+
function branchExample(repo, key) {
|
|
5149
|
+
const prefix = repoPrefix(repo);
|
|
5150
|
+
const template = effectiveBranchTemplate(repo, prefix);
|
|
5151
|
+
return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
|
|
5152
|
+
}
|
|
5153
|
+
function repoSection(r, key, heading) {
|
|
5154
|
+
const repo = r.config;
|
|
5155
|
+
const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
|
|
5156
|
+
const branchName = branchExample(repo, key);
|
|
5157
|
+
const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
|
|
5158
|
+
const worktreeName = effectiveWorktreeDirName(
|
|
5159
|
+
repo,
|
|
5160
|
+
r.dir,
|
|
5161
|
+
branchName,
|
|
5162
|
+
key.replace(/^\D+/, ""),
|
|
5163
|
+
// The example ticket has no tag of its own, so `{issue}` is the key.
|
|
5164
|
+
(template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
|
|
5165
|
+
);
|
|
5166
|
+
const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
|
|
5167
|
+
const lines = [
|
|
5168
|
+
"",
|
|
5169
|
+
heading,
|
|
5170
|
+
"",
|
|
5171
|
+
`- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
|
|
5172
|
+
` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
|
|
5173
|
+
`- Name the branch the way this repository names branches: \`${branchTemplate}\``,
|
|
5174
|
+
` \u2014 for ${key} that is \`${branchName}\`.`,
|
|
5175
|
+
"- Never work in the main checkout. It is the operator's, and the release phase",
|
|
5176
|
+
" uses it.",
|
|
5177
|
+
"",
|
|
5178
|
+
`${key} above is a placeholder, not a real ticket: this section is rendered`,
|
|
5179
|
+
"before any ticket is chosen, so it cannot know a specific ticket's own",
|
|
5180
|
+
"project prefix. Once you have a ticket, its digest row's `branch` and",
|
|
5181
|
+
"`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
|
|
5182
|
+
"over this section's worked example if the two ever disagree."
|
|
5183
|
+
];
|
|
5184
|
+
if (repo.worktrees.copy.length) {
|
|
5185
|
+
lines.push(
|
|
5186
|
+
"",
|
|
5187
|
+
"A worktree is a clean checkout, so it is missing exactly the files git ignores.",
|
|
5188
|
+
"Copy these across from the main checkout before running anything:",
|
|
5189
|
+
"",
|
|
5190
|
+
...repo.worktrees.copy.map((c) => `- \`${c}\``)
|
|
5191
|
+
);
|
|
5192
|
+
}
|
|
5193
|
+
if (repo.docs.designGuide) {
|
|
5194
|
+
lines.push(
|
|
5195
|
+
"",
|
|
5196
|
+
"## This project's design brief",
|
|
5197
|
+
"",
|
|
5198
|
+
`\`${repo.docs.designGuide}\`, in the repository. Read it from the primary`,
|
|
5199
|
+
"checkout before you `cd` into any worktree \u2014 a worktree cut from the base",
|
|
5200
|
+
"branch only has it once it has been committed there."
|
|
5201
|
+
);
|
|
5202
|
+
}
|
|
5203
|
+
if (repo.docs.triagePolicy) {
|
|
5204
|
+
lines.push(
|
|
5205
|
+
"",
|
|
5206
|
+
"## This project's triage policy",
|
|
5207
|
+
"",
|
|
5208
|
+
`\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
|
|
5209
|
+
"disagree about what a status means or when a ticket may be accepted, the",
|
|
5210
|
+
"policy document wins: it is the project's own decision, and the brief is",
|
|
5211
|
+
"shared with projects that decided differently."
|
|
5212
|
+
);
|
|
5213
|
+
}
|
|
5214
|
+
return lines;
|
|
5215
|
+
}
|
|
5216
|
+
function renderEnvironment(i) {
|
|
5217
|
+
const { route, contract } = i;
|
|
5218
|
+
const repos = i.repos;
|
|
5219
|
+
const only = repos.length === 1 ? repos[0] : void 0;
|
|
5220
|
+
const models = route.resolved?.models;
|
|
5221
|
+
const epicsModelId = route.resolved?.epicsModelId;
|
|
5222
|
+
const mediaLibraryModelId = i.attachments?.mediaLibraryModelId ?? null;
|
|
5223
|
+
const key = "ISSUE-000";
|
|
5224
|
+
const lines = [
|
|
5225
|
+
"# Your environment",
|
|
5226
|
+
"",
|
|
5227
|
+
"Supplied by the crew for THIS run. The brief below is shared by every project",
|
|
5228
|
+
"the crew works on and deliberately names nothing specific; this section is where",
|
|
5229
|
+
"those names come from. Where the two appear to disagree, this section wins.",
|
|
5230
|
+
"",
|
|
5231
|
+
"## The board",
|
|
5232
|
+
"",
|
|
5233
|
+
`- Base URL: \`${route.baseUrl}\``,
|
|
5234
|
+
`- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
|
|
5235
|
+
` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
|
|
5236
|
+
" and never write it into a comment, a commit or a log line.",
|
|
5237
|
+
`- Send \`User-Agent: ${i.userAgent}\` on every request.`
|
|
5238
|
+
];
|
|
5239
|
+
if (models) {
|
|
5240
|
+
lines.push(
|
|
5241
|
+
"",
|
|
5242
|
+
"Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
|
|
5243
|
+
"",
|
|
5244
|
+
`- Issues: \`${models.issues}\``,
|
|
5245
|
+
`- Comments: \`${models.comments}\``,
|
|
5246
|
+
`- Crew: \`${models.crew}\``,
|
|
5247
|
+
...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
|
|
5248
|
+
...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
|
|
5249
|
+
);
|
|
5250
|
+
}
|
|
5251
|
+
const workspaceId = route.resolved?.workspaceId;
|
|
5252
|
+
if (workspaceId) {
|
|
5253
|
+
const base = route.baseUrl.replace(/\/+$/, "");
|
|
5254
|
+
lines.push(
|
|
5255
|
+
"",
|
|
5256
|
+
`- Workspace id: \`${workspaceId}\``,
|
|
5257
|
+
"",
|
|
5258
|
+
"## Attaching a file to a comment",
|
|
5259
|
+
"",
|
|
5260
|
+
"A screenshot or mockup goes on a comment's `attachments` field, which holds",
|
|
5261
|
+
"Media Library record ids, not URLs. Upload the file first, then put the",
|
|
5262
|
+
"returned `id` in the comment's `attachments` array:",
|
|
5263
|
+
"",
|
|
5264
|
+
"```sh",
|
|
5265
|
+
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
5266
|
+
` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
|
|
5267
|
+
' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
|
|
5268
|
+
"```",
|
|
5269
|
+
"",
|
|
5270
|
+
"The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
|
|
5271
|
+
"means no field type id has to be looked up. If the upload fails, post the HTTP",
|
|
5272
|
+
"status and response body in your comment \u2014 do not write that uploads are",
|
|
5273
|
+
"impossible."
|
|
5274
|
+
);
|
|
5275
|
+
if (mediaLibraryModelId) {
|
|
5276
|
+
lines.push(
|
|
5277
|
+
"",
|
|
5278
|
+
"## Opening a ticket's attachments",
|
|
5279
|
+
"",
|
|
5280
|
+
`A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
|
|
5281
|
+
"**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
|
|
5282
|
+
"`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
|
|
5283
|
+
"read the record, then fetch the file by its storage key:",
|
|
5284
|
+
"",
|
|
5285
|
+
"```sh",
|
|
5286
|
+
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
5287
|
+
` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
|
|
5288
|
+
'# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
|
|
5289
|
+
`curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
5290
|
+
` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
|
|
5291
|
+
"```",
|
|
5292
|
+
"",
|
|
5293
|
+
"The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
|
|
5294
|
+
"id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
|
|
5295
|
+
"of the storage key). Save the file outside the repository and open it; where it",
|
|
5296
|
+
"disagrees with the description, the picture is the reproduction."
|
|
5297
|
+
);
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
if (i.shipName) {
|
|
5301
|
+
lines.push(
|
|
5302
|
+
"",
|
|
5303
|
+
"## Ships",
|
|
5304
|
+
"",
|
|
5305
|
+
`This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
|
|
5306
|
+
"another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
|
|
5307
|
+
);
|
|
5308
|
+
}
|
|
5309
|
+
const seatIds = Object.entries(route.resolved?.seats ?? {}).filter(([, id]) => !!id);
|
|
5310
|
+
if (seatIds.length > 0) {
|
|
5311
|
+
lines.push(
|
|
5312
|
+
"",
|
|
5313
|
+
"## Seats on this ship",
|
|
5314
|
+
"",
|
|
5315
|
+
"The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
|
|
5316
|
+
"assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
|
|
5317
|
+
"",
|
|
5318
|
+
...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
|
|
5319
|
+
);
|
|
5320
|
+
}
|
|
5321
|
+
lines.push(
|
|
5322
|
+
"",
|
|
5323
|
+
"## What the columns are called here",
|
|
5324
|
+
"",
|
|
5325
|
+
"Every workspace names these itself. Use these names, not the ones you would",
|
|
5326
|
+
"guess, and not the ones another project used.",
|
|
5327
|
+
"",
|
|
5328
|
+
"| meaning | column |",
|
|
5329
|
+
"| --- | --- |",
|
|
5330
|
+
`| ticket key | \`${contract.columns.key}\` |`,
|
|
5331
|
+
`| status | \`${contract.columns.status}\` |`,
|
|
5332
|
+
`| assignee | \`${contract.columns.assignee}\` |`,
|
|
5333
|
+
`| severity | \`${contract.columns.severity}\` |`,
|
|
5334
|
+
`| priority | \`${contract.columns.priority}\` |`,
|
|
5335
|
+
`| blocked by | \`${contract.columns.blockedBy}\` |`,
|
|
5336
|
+
`| needs design | \`${contract.columns.needsDesign}\` |`,
|
|
5337
|
+
`| project | \`${contract.columns.slice}\` |`,
|
|
5338
|
+
`| repo | \`${contract.columns.repo}\` |`,
|
|
5339
|
+
...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
|
|
5340
|
+
"",
|
|
5341
|
+
...epicsModelId ? [
|
|
5342
|
+
"An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
|
|
5343
|
+
"fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
|
|
5344
|
+
"to a person rather than inventing one yourself.",
|
|
5345
|
+
"",
|
|
5346
|
+
"An epic's own status is kept by the crew runner, from its tickets: done once",
|
|
5347
|
+
"every one of them is closed, in progress while any is being worked. Do not",
|
|
5348
|
+
"set it yourself.",
|
|
5349
|
+
""
|
|
5350
|
+
] : [],
|
|
5351
|
+
"Statuses that matter to you:",
|
|
5352
|
+
"",
|
|
5353
|
+
`- Work you may pick up: \`${contract.statuses.approved}\``,
|
|
5354
|
+
`- While you are working it: \`${contract.statuses.building}\``,
|
|
5355
|
+
`- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
|
|
5356
|
+
`- Being checked: \`${contract.statuses.verifying}\``,
|
|
5357
|
+
`- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
|
|
5358
|
+
`- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
|
|
5359
|
+
"",
|
|
5360
|
+
`\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
|
|
5361
|
+
);
|
|
5362
|
+
const areaId = route.resolved?.areaId;
|
|
5363
|
+
if (areaId || repos.some((r) => r.id)) {
|
|
5364
|
+
lines.push(
|
|
5365
|
+
"",
|
|
5366
|
+
"## Filing a new ticket",
|
|
5367
|
+
"",
|
|
5368
|
+
"Something broken that is out of scope for the ticket you are working is not",
|
|
5369
|
+
"yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
|
|
5370
|
+
"the one you were on. A ticket filed without its project and repo sits unrouted",
|
|
5371
|
+
"until a person notices and fixes it by hand, so always set:",
|
|
5372
|
+
"",
|
|
5373
|
+
...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
|
|
5374
|
+
...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
|
|
5375
|
+
(r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
|
|
5376
|
+
),
|
|
5377
|
+
...authorshipBullets(i.authorship, contract),
|
|
5378
|
+
"",
|
|
5379
|
+
...i.sourceTicket ? [
|
|
5380
|
+
`Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
|
|
5381
|
+
"ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
|
|
5382
|
+
"genuinely cannot proceed without the new one being fixed first; merely having noticed it",
|
|
5383
|
+
"nearby is not a dependency.",
|
|
5384
|
+
""
|
|
5385
|
+
] : []
|
|
5386
|
+
);
|
|
5387
|
+
}
|
|
5388
|
+
if (only) {
|
|
5389
|
+
lines.push(...repoSection(only, key, "## Your worktree"));
|
|
5390
|
+
} else {
|
|
5391
|
+
lines.push(
|
|
5392
|
+
"",
|
|
5393
|
+
"## The repositories this board covers",
|
|
5394
|
+
"",
|
|
5395
|
+
"This board spans several repositories, and they do not share a branch name,",
|
|
5396
|
+
"a worktree location or a test command. **The `repo` column of your queue",
|
|
5397
|
+
"says which one your ticket belongs to** \u2014 read the section for THAT one and",
|
|
5398
|
+
"ignore the others. A ticket whose repo is not listed here has no checkout on",
|
|
5399
|
+
"this machine and is not yours to work."
|
|
5400
|
+
);
|
|
5401
|
+
for (const r of repos) {
|
|
5402
|
+
lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
|
|
5403
|
+
lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
|
|
5485
5404
|
}
|
|
5486
5405
|
}
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
|
|
5490
|
-
if (!failed) return null;
|
|
5491
|
-
const [ticketRecordId, s] = failed;
|
|
5492
|
-
return {
|
|
5493
|
-
ticketRecordId,
|
|
5494
|
-
patch: s.patch,
|
|
5495
|
-
...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
|
|
5496
|
-
...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
|
|
5497
|
-
finalText: this.lastText || this.resultLine
|
|
5498
|
-
};
|
|
5406
|
+
if (i.repoDir) {
|
|
5407
|
+
lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
|
|
5499
5408
|
}
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
|
|
5504
|
-
import { join as join16 } from "path";
|
|
5505
|
-
|
|
5506
|
-
// src/ticket-branch.ts
|
|
5507
|
-
import { basename as basename6 } from "path";
|
|
5508
|
-
|
|
5509
|
-
// src/tracker.ts
|
|
5510
|
-
import { hostname } from "os";
|
|
5511
|
-
import { TablationClient } from "@tablation/client";
|
|
5409
|
+
if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
|
|
5410
|
+
return lines.join("\n");
|
|
5411
|
+
}
|
|
5512
5412
|
|
|
5513
|
-
// src/
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5413
|
+
// src/contract.ts
|
|
5414
|
+
var DEFAULT_CONTRACT = {
|
|
5415
|
+
columns: {
|
|
5416
|
+
key: "issue_id",
|
|
5417
|
+
tag: "issue_tag",
|
|
5418
|
+
title: "title",
|
|
5419
|
+
status: "status",
|
|
5420
|
+
assignee: "assignee_id",
|
|
5421
|
+
priority: "priority",
|
|
5422
|
+
severity: "severity",
|
|
5423
|
+
slice: "project_id",
|
|
5424
|
+
repo: "repo_id",
|
|
5425
|
+
parent: "parent_id",
|
|
5426
|
+
blockedBy: "blocked_by",
|
|
5427
|
+
updatedAt: "updated_at",
|
|
5428
|
+
needsDesign: "needs_design",
|
|
5429
|
+
attachments: "attachments",
|
|
5430
|
+
workMode: "work_mode",
|
|
5431
|
+
reportType: "report_type",
|
|
5432
|
+
epic: "epic_id",
|
|
5433
|
+
author: "filed_by_id",
|
|
5434
|
+
ship: "ship_id",
|
|
5435
|
+
heldBy: "held_by_ship_id",
|
|
5436
|
+
resolutionNote: "resolution_note",
|
|
5437
|
+
verifiedSha: "verified_sha",
|
|
5438
|
+
prRef: "pr_ref",
|
|
5439
|
+
externalId: "external_id",
|
|
5440
|
+
position: "position",
|
|
5441
|
+
agentRuns: "agent_runs",
|
|
5442
|
+
tokensIn: "tokens_in",
|
|
5443
|
+
tokensOut: "tokens_out",
|
|
5444
|
+
cacheReadTokens: "cache_read_tokens",
|
|
5445
|
+
cacheWriteTokens: "cache_write_tokens",
|
|
5446
|
+
costUsd: "cost_usd"
|
|
5447
|
+
},
|
|
5448
|
+
comments: {
|
|
5449
|
+
parent: "ticket_id",
|
|
5450
|
+
body: "body",
|
|
5451
|
+
author: "team_member_id",
|
|
5452
|
+
createdAt: "created_at",
|
|
5453
|
+
ship: "ship_id",
|
|
5454
|
+
publicReply: "is_public_reply",
|
|
5455
|
+
authorName: "author_name"
|
|
5456
|
+
},
|
|
5457
|
+
statuses: {
|
|
5458
|
+
open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
|
|
5459
|
+
resolved: [
|
|
5460
|
+
"verified",
|
|
5461
|
+
"closed_deployed",
|
|
5462
|
+
"closed_wont_fix",
|
|
5463
|
+
"closed_duplicate",
|
|
5464
|
+
"closed_completed",
|
|
5465
|
+
"closed_cant_reproduce"
|
|
5466
|
+
],
|
|
5467
|
+
approved: "accepted",
|
|
5468
|
+
parked: "blocked",
|
|
5469
|
+
building: "in_progress",
|
|
5470
|
+
handoff: "fixed",
|
|
5471
|
+
verifying: "qa",
|
|
5472
|
+
verified: "verified",
|
|
5473
|
+
deployed: "closed_deployed",
|
|
5474
|
+
needsHuman: "needs_info",
|
|
5475
|
+
reviewing: null,
|
|
5476
|
+
wontFix: "closed_wont_fix",
|
|
5477
|
+
draft: "draft"
|
|
5478
|
+
},
|
|
5479
|
+
epics: {
|
|
5480
|
+
statusColumn: "status",
|
|
5481
|
+
keyColumn: "epic_id",
|
|
5482
|
+
statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
|
|
5483
|
+
},
|
|
5484
|
+
priorityOrder: ["p0", "p1", "p2", "p3"],
|
|
5485
|
+
severityOrder: ["s1", "s2", "s3", "s4"],
|
|
5486
|
+
unknownPriorityRank: 2,
|
|
5487
|
+
coordinatingValue: "coordinating",
|
|
5488
|
+
manualWorkMode: "manual",
|
|
5489
|
+
hybridWorkMode: "hybrid",
|
|
5490
|
+
repoExemptReportTypes: ["coordinating", "question", "investigation"]
|
|
5491
|
+
};
|
|
5492
|
+
function closedStatuses(c) {
|
|
5493
|
+
return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
|
|
5494
|
+
}
|
|
5495
|
+
function resolveContract(override) {
|
|
5496
|
+
if (!override) return DEFAULT_CONTRACT;
|
|
5497
|
+
return {
|
|
5498
|
+
columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
|
|
5499
|
+
comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
|
|
5500
|
+
statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
|
|
5501
|
+
epics: {
|
|
5502
|
+
...DEFAULT_CONTRACT.epics,
|
|
5503
|
+
...override.epics,
|
|
5504
|
+
statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
|
|
5505
|
+
},
|
|
5506
|
+
priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
|
|
5507
|
+
severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
|
|
5508
|
+
unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
|
|
5509
|
+
coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
|
|
5510
|
+
manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
|
|
5511
|
+
hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
|
|
5512
|
+
// A workspace overriding `coordinatingValue` alone (renaming the rollup
|
|
5513
|
+
// marker) still gets that renamed value exempted here by default,
|
|
5514
|
+
// without also having to repeat it — only an explicit
|
|
5515
|
+
// `repoExemptReportTypes` overrides this derived default.
|
|
5516
|
+
repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
|
|
5517
|
+
};
|
|
5518
|
+
}
|
|
5519
|
+
function validateContract(c) {
|
|
5520
|
+
const problems = [];
|
|
5521
|
+
const open = new Set(c.statuses.open);
|
|
5522
|
+
const resolved = new Set(c.statuses.resolved);
|
|
5523
|
+
for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
|
|
5524
|
+
const v = c.statuses[role];
|
|
5525
|
+
if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
|
|
5525
5526
|
}
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
*
|
|
5529
|
-
* Read once per cycle and advanced only AFTER every role has been
|
|
5530
|
-
* evaluated: bumping it inside the first role's check would hide that
|
|
5531
|
-
* role's new comments from the second.
|
|
5532
|
-
*/
|
|
5533
|
-
watermark() {
|
|
5534
|
-
try {
|
|
5535
|
-
const v = readFileSync12(this.path(".poll-watermark"), "utf8").trim();
|
|
5536
|
-
return v || EPOCH;
|
|
5537
|
-
} catch {
|
|
5538
|
-
return EPOCH;
|
|
5539
|
-
}
|
|
5527
|
+
if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
|
|
5528
|
+
problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
|
|
5540
5529
|
}
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
`);
|
|
5530
|
+
if (!resolved.has(c.statuses.deployed)) {
|
|
5531
|
+
problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
|
|
5544
5532
|
}
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
* and the write — a race that loses work rather than repeating it, which is
|
|
5550
|
-
* the wrong way round. Repeating a cycle is free; missing one is not.
|
|
5551
|
-
*/
|
|
5552
|
-
advanceWatermark(seen) {
|
|
5553
|
-
const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
|
|
5554
|
-
const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
|
|
5555
|
-
this.setWatermark(next);
|
|
5556
|
-
return next;
|
|
5533
|
+
if (resolved.has(c.statuses.handoff)) {
|
|
5534
|
+
problems.push(
|
|
5535
|
+
`statuses.handoff ("${c.statuses.handoff}") is listed as resolved \u2014 it means built-but-unchecked, so a ticket blocked by it is still blocked; calling it resolved would build on unverified work`
|
|
5536
|
+
);
|
|
5557
5537
|
}
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
return existsSync12(this.path(".crew-paused"));
|
|
5538
|
+
if (!resolved.has(c.statuses.wontFix)) {
|
|
5539
|
+
problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
|
|
5561
5540
|
}
|
|
5562
|
-
|
|
5563
|
-
|
|
5541
|
+
if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
|
|
5542
|
+
problems.push(
|
|
5543
|
+
`statuses.reviewing ("${c.statuses.reviewing}") is listed as resolved \u2014 it is unmerged work under review, so a ticket blocked by it is still blocked`
|
|
5544
|
+
);
|
|
5564
5545
|
}
|
|
5565
|
-
|
|
5566
|
-
|
|
5546
|
+
if (resolved.has(c.statuses.approved)) {
|
|
5547
|
+
problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
|
|
5567
5548
|
}
|
|
5568
|
-
|
|
5569
|
-
|
|
5549
|
+
if (c.statuses.parked === c.statuses.approved) {
|
|
5550
|
+
problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
|
|
5570
5551
|
}
|
|
5571
|
-
|
|
5572
|
-
|
|
5552
|
+
const epicValues = Object.values(c.epics.statuses);
|
|
5553
|
+
if (new Set(epicValues).size !== epicValues.length) {
|
|
5554
|
+
problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
|
|
5573
5555
|
}
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
*/
|
|
5579
|
-
isReleasePaused() {
|
|
5580
|
-
return existsSync12(this.path(".release-paused"));
|
|
5556
|
+
if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
|
|
5557
|
+
if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
|
|
5558
|
+
for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
|
|
5559
|
+
if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
|
|
5581
5560
|
}
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5561
|
+
return problems;
|
|
5562
|
+
}
|
|
5563
|
+
var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
|
|
5564
|
+
var PERISCOPE_COLUMNS = [
|
|
5565
|
+
"ticket_id",
|
|
5566
|
+
"ship_id",
|
|
5567
|
+
"url",
|
|
5568
|
+
"host",
|
|
5569
|
+
"worktree",
|
|
5570
|
+
"branch",
|
|
5571
|
+
"state",
|
|
5572
|
+
"isolated",
|
|
5573
|
+
"started_at",
|
|
5574
|
+
"last_activity_at",
|
|
5575
|
+
"expires_at"
|
|
5576
|
+
];
|
|
5577
|
+
|
|
5578
|
+
// src/agent-log.ts
|
|
5579
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync7, readdirSync as readdirSync5, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
|
|
5580
|
+
import { join as join13 } from "path";
|
|
5581
|
+
|
|
5582
|
+
// src/pending-backoff.ts
|
|
5583
|
+
var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
|
|
5584
|
+
|
|
5585
|
+
// src/agent-log.ts
|
|
5586
|
+
var AgentLogUnreachableError = class extends Error {
|
|
5587
|
+
};
|
|
5588
|
+
var AgentLogHttpError = class extends Error {
|
|
5589
|
+
status;
|
|
5590
|
+
/** True when the refusal was of the Agent Log row itself (not a later cycle post): the tracker is not taking this seat's reports (CREW-2832). */
|
|
5591
|
+
row = false;
|
|
5592
|
+
constructor(message, status2) {
|
|
5593
|
+
super(message);
|
|
5594
|
+
this.status = status2;
|
|
5585
5595
|
}
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5596
|
+
};
|
|
5597
|
+
async function request(target, method, path, body) {
|
|
5598
|
+
const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
5599
|
+
const res = await fetch(url, {
|
|
5600
|
+
method,
|
|
5601
|
+
headers: {
|
|
5602
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
5603
|
+
"User-Agent": target.userAgent,
|
|
5604
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
5605
|
+
},
|
|
5606
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
5607
|
+
});
|
|
5608
|
+
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5609
|
+
return await res.json();
|
|
5610
|
+
}
|
|
5611
|
+
var issueColumns = /* @__PURE__ */ new Map();
|
|
5612
|
+
function columnsOf(target, modelId) {
|
|
5613
|
+
const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
|
|
5614
|
+
let p = issueColumns.get(key);
|
|
5615
|
+
if (!p) {
|
|
5616
|
+
p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
|
|
5617
|
+
p.catch(() => issueColumns.delete(key));
|
|
5618
|
+
issueColumns.set(key, p);
|
|
5598
5619
|
}
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5620
|
+
return p;
|
|
5621
|
+
}
|
|
5622
|
+
async function findTicketRecord(target, t, ref) {
|
|
5623
|
+
const filters = {
|
|
5624
|
+
combinator: "OR",
|
|
5625
|
+
conditions: [
|
|
5626
|
+
{ columnName: t.keyColumn, operator: "EQ", value: ref },
|
|
5627
|
+
{ columnName: t.tagColumn, operator: "EQ", value: ref }
|
|
5628
|
+
]
|
|
5629
|
+
};
|
|
5630
|
+
const rows = await request(
|
|
5631
|
+
target,
|
|
5632
|
+
"GET",
|
|
5633
|
+
`/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
|
|
5634
|
+
);
|
|
5635
|
+
return rows.length === 1 ? rows[0].id : void 0;
|
|
5636
|
+
}
|
|
5637
|
+
function counterIncrements(run2, cols, have) {
|
|
5638
|
+
const figures = [
|
|
5639
|
+
[cols.agentRuns, 1],
|
|
5640
|
+
[cols.tokensIn, run2.tokensIn],
|
|
5641
|
+
[cols.tokensOut, run2.tokensOut],
|
|
5642
|
+
[cols.cacheReadTokens, run2.cacheReadTokens],
|
|
5643
|
+
[cols.cacheWriteTokens, run2.cacheWriteTokens],
|
|
5644
|
+
[cols.costUsd, run2.costUsd]
|
|
5645
|
+
];
|
|
5646
|
+
const out = {};
|
|
5647
|
+
for (const [column, value2] of figures) {
|
|
5648
|
+
if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
|
|
5616
5649
|
}
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5650
|
+
return out;
|
|
5651
|
+
}
|
|
5652
|
+
async function post(target, path, body) {
|
|
5653
|
+
const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
|
|
5654
|
+
const res = await fetch(url, {
|
|
5655
|
+
method: "POST",
|
|
5656
|
+
headers: {
|
|
5657
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
5658
|
+
// Cloudflare 403s default agents on this host — same header connect.ts sends.
|
|
5659
|
+
"User-Agent": target.userAgent,
|
|
5660
|
+
"Content-Type": "application/json"
|
|
5661
|
+
},
|
|
5662
|
+
body: JSON.stringify(body)
|
|
5663
|
+
});
|
|
5664
|
+
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5665
|
+
return await res.json();
|
|
5666
|
+
}
|
|
5667
|
+
async function reportAgentRun(target, run2) {
|
|
5668
|
+
let ticketRecordId;
|
|
5669
|
+
if (target.tickets && run2.ticketReference) {
|
|
5620
5670
|
try {
|
|
5621
|
-
|
|
5622
|
-
if (Number.isFinite(existing) && existing > 0) {
|
|
5623
|
-
try {
|
|
5624
|
-
process.kill(existing, 0);
|
|
5625
|
-
return { ok: false, heldBy: existing };
|
|
5626
|
-
} catch {
|
|
5627
|
-
}
|
|
5628
|
-
}
|
|
5671
|
+
ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
|
|
5629
5672
|
} catch {
|
|
5630
5673
|
}
|
|
5631
|
-
writeFileSync8(file, `${process.pid}
|
|
5632
|
-
`);
|
|
5633
|
-
return {
|
|
5634
|
-
ok: true,
|
|
5635
|
-
release: () => {
|
|
5636
|
-
try {
|
|
5637
|
-
rmSync7(file, { force: true });
|
|
5638
|
-
} catch {
|
|
5639
|
-
}
|
|
5640
|
-
}
|
|
5641
|
-
};
|
|
5642
5674
|
}
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5675
|
+
const body = {
|
|
5676
|
+
agentId: target.agentId,
|
|
5677
|
+
ticketReference: run2.ticketReference,
|
|
5678
|
+
outcome: run2.outcome,
|
|
5679
|
+
startedAt: run2.startedAt,
|
|
5680
|
+
finishedAt: run2.finishedAt,
|
|
5681
|
+
source: "client",
|
|
5682
|
+
client: "crew",
|
|
5683
|
+
model: run2.model,
|
|
5684
|
+
tokensIn: run2.tokensIn,
|
|
5685
|
+
tokensOut: run2.tokensOut,
|
|
5686
|
+
cacheReadTokens: run2.cacheReadTokens,
|
|
5687
|
+
cacheWriteTokens: run2.cacheWriteTokens,
|
|
5688
|
+
costUsd: run2.costUsd,
|
|
5689
|
+
numTurns: run2.numTurns,
|
|
5690
|
+
prefixTokens: run2.prefixTokens,
|
|
5691
|
+
promptVersion: run2.promptVersion,
|
|
5692
|
+
promptSha: run2.promptSha
|
|
5693
|
+
};
|
|
5694
|
+
const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
|
|
5695
|
+
let entry;
|
|
5696
|
+
try {
|
|
5697
|
+
entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
|
|
5698
|
+
} catch (e) {
|
|
5699
|
+
if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
|
|
5700
|
+
if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
|
|
5701
|
+
if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) {
|
|
5702
|
+
e.row = true;
|
|
5703
|
+
throw e;
|
|
5669
5704
|
}
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
* capacity (ISSUE-381), which nothing needs to name a particular slot to
|
|
5676
|
-
* use. Takes whichever numbered slot is free first.
|
|
5677
|
-
*/
|
|
5678
|
-
acquireN(rawPrefix, max) {
|
|
5679
|
-
const heldBy = [];
|
|
5680
|
-
for (let i = 1; i <= max; i++) {
|
|
5681
|
-
const got = this.acquire(`${rawPrefix}-${i}`);
|
|
5682
|
-
if (got.ok) return got;
|
|
5683
|
-
heldBy.push(got.heldBy);
|
|
5705
|
+
try {
|
|
5706
|
+
entry = await post(target, "/agents/log", body);
|
|
5707
|
+
} catch (e2) {
|
|
5708
|
+
if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
|
|
5709
|
+
throw e2;
|
|
5684
5710
|
}
|
|
5685
|
-
return { ok: false, heldBy };
|
|
5686
5711
|
}
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
* counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
|
|
5695
|
-
* historical name `crew-role-<role>` (crew-macos reads it for the live pid),
|
|
5696
|
-
* further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
|
|
5697
|
-
* limit on how many runs of ANY role may overlap; a role that clears its own
|
|
5698
|
-
* lock but finds every slot taken must give its role lock back, or a
|
|
5699
|
-
* locked-but-not-running role would wedge itself out for the rest of the
|
|
5700
|
-
* machine's uptime.
|
|
5701
|
-
*/
|
|
5702
|
-
acquireRun(role, max, roleMax = 1) {
|
|
5703
|
-
let roleLock;
|
|
5704
|
-
const roleHolders = [];
|
|
5705
|
-
for (let i = 1; i <= roleMax; i++) {
|
|
5706
|
-
const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
|
|
5707
|
-
if (got.ok) {
|
|
5708
|
-
roleLock = got;
|
|
5709
|
-
break;
|
|
5710
|
-
}
|
|
5711
|
-
roleHolders.push(got.heldBy);
|
|
5712
|
-
}
|
|
5713
|
-
if (!roleLock) {
|
|
5714
|
-
return {
|
|
5715
|
-
ok: false,
|
|
5716
|
-
reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
|
|
5717
|
-
};
|
|
5718
|
-
}
|
|
5719
|
-
const slot = this.acquireN("crew-slot", max);
|
|
5720
|
-
if (!slot.ok) {
|
|
5721
|
-
roleLock.release();
|
|
5722
|
-
return {
|
|
5723
|
-
ok: false,
|
|
5724
|
-
reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
|
|
5725
|
-
};
|
|
5726
|
-
}
|
|
5727
|
-
return { ok: true, release: () => {
|
|
5728
|
-
slot.release();
|
|
5729
|
-
roleLock.release();
|
|
5730
|
-
} };
|
|
5712
|
+
for (const cycle of run2.cycles) {
|
|
5713
|
+
await post(target, `/agents/log/${entry.id}/cycles`, {
|
|
5714
|
+
cycleIndex: cycle.cycleIndex,
|
|
5715
|
+
occurredAt: cycle.occurredAt,
|
|
5716
|
+
thinking: cycle.thinking,
|
|
5717
|
+
...cycle.role ? { role: cycle.role } : {}
|
|
5718
|
+
});
|
|
5731
5719
|
}
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5720
|
+
const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
|
|
5721
|
+
return {
|
|
5722
|
+
agentLogId: entry.id,
|
|
5723
|
+
...counters?.warning ? { counterWarning: counters.warning } : {},
|
|
5724
|
+
...counters?.retry ? { counterRetry: counters.retry } : {}
|
|
5725
|
+
};
|
|
5726
|
+
}
|
|
5727
|
+
var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
|
|
5728
|
+
async function writeCounters(target, w) {
|
|
5729
|
+
const have = await columnsOf(target, w.issuesModelId);
|
|
5730
|
+
const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
|
|
5731
|
+
if (Object.keys(body).length === 0) return;
|
|
5732
|
+
await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
|
|
5733
|
+
}
|
|
5734
|
+
async function addRunToTicket(target, t, recordId, run2) {
|
|
5735
|
+
const write = {
|
|
5736
|
+
issuesModelId: t.issuesModelId,
|
|
5737
|
+
recordId,
|
|
5738
|
+
ticketReference: run2.ticketReference,
|
|
5739
|
+
counters: t.counters,
|
|
5740
|
+
body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
|
|
5741
|
+
};
|
|
5742
|
+
try {
|
|
5743
|
+
await writeCounters(target, write);
|
|
5744
|
+
return {};
|
|
5745
|
+
} catch (e) {
|
|
5746
|
+
return {
|
|
5747
|
+
warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
|
|
5748
|
+
...isRetryable(e) ? { retry: write } : {}
|
|
5749
|
+
};
|
|
5740
5750
|
}
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5751
|
+
}
|
|
5752
|
+
var queueDir = (stateDir) => join13(stateDir, "pending-agent-logs");
|
|
5753
|
+
function queueAgentRun(stateDir, name, q) {
|
|
5754
|
+
mkdirSync7(queueDir(stateDir), { recursive: true });
|
|
5755
|
+
writeFileSync8(join13(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
|
|
5756
|
+
}
|
|
5757
|
+
async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
5758
|
+
const dir = queueDir(stateDir);
|
|
5759
|
+
if (!existsSync12(dir)) return 0;
|
|
5760
|
+
let sent = 0;
|
|
5761
|
+
for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5762
|
+
const path = join13(dir, f);
|
|
5763
|
+
let q;
|
|
5744
5764
|
try {
|
|
5745
|
-
|
|
5746
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
5747
|
-
process.kill(pid, 0);
|
|
5748
|
-
return true;
|
|
5765
|
+
q = JSON.parse(readFileSync12(path, "utf8"));
|
|
5749
5766
|
} catch {
|
|
5750
|
-
|
|
5767
|
+
continue;
|
|
5751
5768
|
}
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
const gateReported = `.test-gate-reported-${suffix}`;
|
|
5776
|
-
const heldReported = `.release-held-${suffix}`;
|
|
5777
|
-
const read = (f) => {
|
|
5778
|
-
try {
|
|
5779
|
-
return readFileSync12(this.path(f), "utf8").trim() || null;
|
|
5780
|
-
} catch {
|
|
5781
|
-
return null;
|
|
5782
|
-
}
|
|
5783
|
-
};
|
|
5784
|
-
return {
|
|
5785
|
-
blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
|
|
5786
|
-
noteBlocked: () => {
|
|
5787
|
-
const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
|
|
5788
|
-
writeFileSync8(this.path(blocked), `${n}
|
|
5789
|
-
`);
|
|
5790
|
-
return n;
|
|
5791
|
-
},
|
|
5792
|
-
clearBlocked: () => {
|
|
5793
|
-
rmSync7(this.path(blocked), { force: true });
|
|
5794
|
-
},
|
|
5795
|
-
/**
|
|
5796
|
-
* The commit a deploy last failed on.
|
|
5797
|
-
*
|
|
5798
|
-
* Without this the crew retries a broken deploy on every cycle — every
|
|
5799
|
-
* two minutes, indefinitely — hammering the target and burying the
|
|
5800
|
-
* original failure under identical ones. A failed commit is retried
|
|
5801
|
-
* only when a NEW commit lands, or when an operator forces it by hand.
|
|
5802
|
-
*/
|
|
5803
|
-
deployFailedSha: () => read(failed),
|
|
5804
|
-
noteDeployFailed: (sha) => {
|
|
5805
|
-
writeFileSync8(this.path(failed), `${sha}
|
|
5806
|
-
`);
|
|
5807
|
-
},
|
|
5808
|
-
clearDeployFailed: () => {
|
|
5809
|
-
rmSync7(this.path(failed), { force: true });
|
|
5810
|
-
},
|
|
5811
|
-
/**
|
|
5812
|
-
* Consecutive test-gate failures on the same head (CREW-1368). A failure
|
|
5813
|
-
* on a new head restarts the count. Held on disk because one process is
|
|
5814
|
-
* one cycle, and "failed three cycles running" only exists across them.
|
|
5815
|
-
*/
|
|
5816
|
-
noteTestGateFailed: (sha) => {
|
|
5817
|
-
const [prev, n] = (read(gateFailed) ?? "").split(" ");
|
|
5818
|
-
const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
|
|
5819
|
-
writeFileSync8(this.path(gateFailed), `${sha} ${next}
|
|
5820
|
-
`);
|
|
5821
|
-
return next;
|
|
5822
|
-
},
|
|
5823
|
-
clearTestGateFailed: () => {
|
|
5824
|
-
rmSync7(this.path(gateFailed), { force: true });
|
|
5825
|
-
},
|
|
5826
|
-
/** Whether the board was already told about this head's red gate. */
|
|
5827
|
-
heldReported: () => {
|
|
5828
|
-
try {
|
|
5829
|
-
return readFileSync12(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
|
|
5830
|
-
} catch {
|
|
5831
|
-
return null;
|
|
5832
|
-
}
|
|
5833
|
-
},
|
|
5834
|
-
noteHeldReported: (signature) => {
|
|
5835
|
-
writeFileSync8(this.path(heldReported), `${signature}
|
|
5836
|
-
`);
|
|
5837
|
-
},
|
|
5838
|
-
clearHeldReported: () => {
|
|
5839
|
-
rmSync7(this.path(heldReported), { force: true });
|
|
5840
|
-
},
|
|
5841
|
-
testGateReported: (sha) => read(gateReported) === sha,
|
|
5842
|
-
noteTestGateReported: (sha) => {
|
|
5843
|
-
writeFileSync8(this.path(gateReported), `${sha}
|
|
5844
|
-
`);
|
|
5769
|
+
if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
|
|
5770
|
+
try {
|
|
5771
|
+
const apiKey = await keyFor(q.role);
|
|
5772
|
+
if (!apiKey) continue;
|
|
5773
|
+
const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
|
|
5774
|
+
if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
|
|
5775
|
+
if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
|
|
5776
|
+
version: 1,
|
|
5777
|
+
route: q.route,
|
|
5778
|
+
role: q.role,
|
|
5779
|
+
target: q.target,
|
|
5780
|
+
write: sentRun.counterRetry,
|
|
5781
|
+
attempts: 0,
|
|
5782
|
+
nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5783
|
+
});
|
|
5784
|
+
rmSync7(path, { force: true });
|
|
5785
|
+
sent += 1;
|
|
5786
|
+
log.emit(`sent a queued Agent Log report (${f})`);
|
|
5787
|
+
} catch (e) {
|
|
5788
|
+
if (!(e instanceof AgentLogUnreachableError)) {
|
|
5789
|
+
rmSync7(path, { force: true });
|
|
5790
|
+
log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
|
|
5791
|
+
continue;
|
|
5845
5792
|
}
|
|
5846
|
-
|
|
5793
|
+
const attempts = q.attempts + 1;
|
|
5794
|
+
writeFileSync8(path, JSON.stringify({
|
|
5795
|
+
...q,
|
|
5796
|
+
attempts,
|
|
5797
|
+
warned: true,
|
|
5798
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
5799
|
+
}));
|
|
5800
|
+
if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
|
|
5801
|
+
}
|
|
5847
5802
|
}
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
const
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
* undefined if nothing is waiting.
|
|
5881
|
-
*/
|
|
5882
|
-
waiting: () => {
|
|
5883
|
-
const ticket = read(ticketFile);
|
|
5884
|
-
const since2 = read(sinceFile);
|
|
5885
|
-
if (!ticket || !since2) return void 0;
|
|
5886
|
-
return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
|
|
5887
|
-
},
|
|
5888
|
-
/**
|
|
5889
|
-
* Record what one cycle saw for this route: how many tickets were
|
|
5890
|
-
* actionable, which one was most urgent, and whether this route
|
|
5891
|
-
* won. Winning or running dry both reset the streak — there is nothing
|
|
5892
|
-
* left being passed over.
|
|
5893
|
-
*/
|
|
5894
|
-
record: (actionable, topTicket, won) => {
|
|
5895
|
-
if (won || actionable === 0) {
|
|
5896
|
-
clear2();
|
|
5897
|
-
return;
|
|
5898
|
-
}
|
|
5899
|
-
writeFileSync8(this.path(streakFile), `${this.readInt(streakFile) + 1}
|
|
5900
|
-
`);
|
|
5901
|
-
const prior = read(ticketFile);
|
|
5902
|
-
if (topTicket && topTicket === prior) {
|
|
5903
|
-
writeFileSync8(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
|
|
5904
|
-
`);
|
|
5905
|
-
} else if (topTicket) {
|
|
5906
|
-
writeFileSync8(this.path(ticketFile), `${topTicket}
|
|
5907
|
-
`);
|
|
5908
|
-
writeFileSync8(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
5909
|
-
`);
|
|
5910
|
-
writeFileSync8(this.path(ticketStreakFile), "1\n");
|
|
5911
|
-
}
|
|
5803
|
+
return sent;
|
|
5804
|
+
}
|
|
5805
|
+
var countersDir = (stateDir) => join13(stateDir, "pending-ticket-counters");
|
|
5806
|
+
function queueTicketCounters(stateDir, name, q) {
|
|
5807
|
+
mkdirSync7(countersDir(stateDir), { recursive: true });
|
|
5808
|
+
writeFileSync8(join13(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
|
|
5809
|
+
}
|
|
5810
|
+
async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
5811
|
+
const dir = countersDir(stateDir);
|
|
5812
|
+
if (!existsSync12(dir)) return 0;
|
|
5813
|
+
let sent = 0;
|
|
5814
|
+
for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5815
|
+
const path = join13(dir, f);
|
|
5816
|
+
let q;
|
|
5817
|
+
try {
|
|
5818
|
+
q = JSON.parse(readFileSync12(path, "utf8"));
|
|
5819
|
+
} catch {
|
|
5820
|
+
continue;
|
|
5821
|
+
}
|
|
5822
|
+
if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
|
|
5823
|
+
try {
|
|
5824
|
+
const apiKey = await keyFor(q.role);
|
|
5825
|
+
if (!apiKey) continue;
|
|
5826
|
+
await writeCounters({ ...q.target, apiKey }, q.write);
|
|
5827
|
+
rmSync7(path, { force: true });
|
|
5828
|
+
sent += 1;
|
|
5829
|
+
log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
|
|
5830
|
+
} catch (e) {
|
|
5831
|
+
if (!isRetryable(e)) {
|
|
5832
|
+
rmSync7(path, { force: true });
|
|
5833
|
+
log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
|
|
5834
|
+
continue;
|
|
5912
5835
|
}
|
|
5913
|
-
|
|
5836
|
+
const attempts = q.attempts + 1;
|
|
5837
|
+
writeFileSync8(path, JSON.stringify({
|
|
5838
|
+
...q,
|
|
5839
|
+
attempts,
|
|
5840
|
+
warned: true,
|
|
5841
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
5842
|
+
}));
|
|
5843
|
+
if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
|
|
5844
|
+
}
|
|
5914
5845
|
}
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5846
|
+
return sent;
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
// src/ticket-attribution.ts
|
|
5850
|
+
var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
|
|
5851
|
+
function ticketMentions(text) {
|
|
5852
|
+
return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
|
|
5853
|
+
}
|
|
5854
|
+
var TicketAttributionTracker = class {
|
|
5855
|
+
#tokensByTicket = /* @__PURE__ */ new Map();
|
|
5856
|
+
#turns = 0;
|
|
5857
|
+
#current;
|
|
5858
|
+
/** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
|
|
5859
|
+
#keyByRecord = /* @__PURE__ */ new Map();
|
|
5860
|
+
#lastBy = {};
|
|
5861
|
+
/**
|
|
5862
|
+
* The ticket key most recently named in the transcript, or undefined before any.
|
|
5863
|
+
* Only the FIRST key in a turn counts: a closing summary names its own
|
|
5864
|
+
* ticket first and then lists others ("Other tickets", "Blocked by", "See
|
|
5865
|
+
* also"), and those trailing keys are not what the run worked (CREW-2598).
|
|
5925
5866
|
*/
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5867
|
+
get current() {
|
|
5868
|
+
return this.#current;
|
|
5869
|
+
}
|
|
5870
|
+
/** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
|
|
5871
|
+
note(text) {
|
|
5872
|
+
const mentions = ticketMentions(text);
|
|
5873
|
+
if (mentions.length > 0) this.#current = mentions[0];
|
|
5874
|
+
}
|
|
5875
|
+
/** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
|
|
5876
|
+
add(text, tokens) {
|
|
5877
|
+
const mentions = ticketMentions(text);
|
|
5878
|
+
if (mentions.length > 0) this.#current = mentions[0];
|
|
5879
|
+
if (this.#current === void 0 || tokens <= 0) return;
|
|
5880
|
+
this.#turns++;
|
|
5881
|
+
this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
|
|
5941
5882
|
}
|
|
5942
5883
|
/**
|
|
5943
|
-
*
|
|
5944
|
-
*
|
|
5945
|
-
*
|
|
5946
|
-
*
|
|
5947
|
-
*
|
|
5884
|
+
* Record a tool call if it is a ticket-targeted tracker write (claim,
|
|
5885
|
+
* hand-off or comment). The ticket key comes from a record uuid seen on an
|
|
5886
|
+
* earlier write when known, else from the key last named in the transcript
|
|
5887
|
+
* (a claim or hand-off announces its ticket). A comment on a record never
|
|
5888
|
+
* seen before is ignored: its uuid cannot be resolved to a key.
|
|
5948
5889
|
*/
|
|
5949
|
-
|
|
5950
|
-
const
|
|
5951
|
-
return
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
return [];
|
|
5958
|
-
}
|
|
5959
|
-
},
|
|
5960
|
-
persist: (next) => {
|
|
5961
|
-
writeFileSync8(this.path(file), JSON.stringify(next));
|
|
5962
|
-
}
|
|
5963
|
-
};
|
|
5890
|
+
noteWrite(tool, input) {
|
|
5891
|
+
const w = classifyWrite(tool, input);
|
|
5892
|
+
if (!w) return;
|
|
5893
|
+
let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
|
|
5894
|
+
if (w.kind !== "comment") key ??= this.#current;
|
|
5895
|
+
if (!key) return;
|
|
5896
|
+
if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
|
|
5897
|
+
this.#lastBy[w.kind] = key;
|
|
5964
5898
|
}
|
|
5965
5899
|
/**
|
|
5966
|
-
*
|
|
5967
|
-
*
|
|
5968
|
-
*
|
|
5900
|
+
* How the attribution is decided: a hand-off write beats a claim, a claim
|
|
5901
|
+
* beats a comment (so a follow-up ticket filed or commented on while
|
|
5902
|
+
* working never displaces the claimed one), and any write beats everything
|
|
5903
|
+
* below. Next, `hintMoved` — the post-run read-back showed the hinted
|
|
5904
|
+
* ticket changed status or assignee during the run — is a hand-off signal
|
|
5905
|
+
* for a run that wrote through MCP tools crew cannot see. Then the
|
|
5906
|
+
* mention-token heuristic, which with a hint may override it only when a
|
|
5907
|
+
* different ticket holds a strict majority of the credited tokens (a single
|
|
5908
|
+
* credited turn, like a lone end-of-run summary naming another ticket,
|
|
5909
|
+
* must not). A run with no hint
|
|
5910
|
+
* keeps the plain winner. The pre-run hint is last.
|
|
5969
5911
|
*/
|
|
5970
|
-
|
|
5971
|
-
const
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
}
|
|
5984
|
-
}
|
|
5912
|
+
decide(hint, opts = {}) {
|
|
5913
|
+
for (const by of ["handoff", "claim", "comment"]) {
|
|
5914
|
+
const ticket = this.#lastBy[by];
|
|
5915
|
+
if (ticket) return { ticket, by };
|
|
5916
|
+
}
|
|
5917
|
+
if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
|
|
5918
|
+
if (this.#tokensByTicket.size > 0) {
|
|
5919
|
+
if (!hint) return { ticket: this.winner(), by: "mentions" };
|
|
5920
|
+
const winner = this.winner(hint);
|
|
5921
|
+
if (winner === hint) return { ticket: hint, by: "mentions" };
|
|
5922
|
+
let total = 0;
|
|
5923
|
+
for (const n of this.#tokensByTicket.values()) total += n;
|
|
5924
|
+
const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
|
|
5925
|
+
return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
|
|
5926
|
+
}
|
|
5927
|
+
return { ticket: hint, by: "hint" };
|
|
5985
5928
|
}
|
|
5986
5929
|
/**
|
|
5987
|
-
*
|
|
5988
|
-
*
|
|
5989
|
-
*
|
|
5930
|
+
* The ticket key the run spent the most tokens on, or `fallback` (the
|
|
5931
|
+
* pre-run poll hint) when the transcript never named one — an
|
|
5932
|
+
* administrative run that touches the tracker without discussing a
|
|
5933
|
+
* specific ticket in its visible text still has to cite something.
|
|
5990
5934
|
*/
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
try {
|
|
5999
|
-
return statSync4(this.path(file)).mtimeMs;
|
|
6000
|
-
} catch {
|
|
6001
|
-
return null;
|
|
6002
|
-
}
|
|
5935
|
+
winner(fallback) {
|
|
5936
|
+
let best = fallback;
|
|
5937
|
+
let bestTokens = -1;
|
|
5938
|
+
for (const [ticket, tokens] of this.#tokensByTicket) {
|
|
5939
|
+
if (tokens > bestTokens) {
|
|
5940
|
+
best = ticket;
|
|
5941
|
+
bestTokens = tokens;
|
|
6003
5942
|
}
|
|
6004
|
-
}
|
|
5943
|
+
}
|
|
5944
|
+
return best;
|
|
6005
5945
|
}
|
|
6006
|
-
|
|
5946
|
+
};
|
|
5947
|
+
function isClaimWrite(tool, input) {
|
|
5948
|
+
if (!tool || !/records_controller_update$/.test(tool)) return false;
|
|
5949
|
+
const body = input?.body;
|
|
5950
|
+
return typeof body === "object" && body !== null && body.status === "in_progress";
|
|
5951
|
+
}
|
|
5952
|
+
var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
|
|
5953
|
+
function classifyWrite(tool, input) {
|
|
5954
|
+
if (!tool) return void 0;
|
|
5955
|
+
const body = input?.body;
|
|
5956
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
5957
|
+
const b = body;
|
|
5958
|
+
if (/records_controller_update$/.test(tool)) {
|
|
5959
|
+
const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
|
|
5960
|
+
if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
|
|
5961
|
+
if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
|
|
5962
|
+
}
|
|
5963
|
+
if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
|
|
5964
|
+
return void 0;
|
|
5965
|
+
}
|
|
5966
|
+
|
|
5967
|
+
// src/handoff-transcript.ts
|
|
5968
|
+
var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
|
|
5969
|
+
function resultText(content) {
|
|
5970
|
+
if (typeof content === "string") return content;
|
|
5971
|
+
if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
|
|
5972
|
+
return "";
|
|
5973
|
+
}
|
|
5974
|
+
function isTransportFailure(isError, text) {
|
|
5975
|
+
if (!isError) {
|
|
6007
5976
|
try {
|
|
6008
|
-
|
|
5977
|
+
const doc = JSON.parse(text);
|
|
5978
|
+
if (doc && typeof doc === "object") {
|
|
5979
|
+
const code = doc.statusCode;
|
|
5980
|
+
return typeof code === "number" && code >= 500;
|
|
5981
|
+
}
|
|
6009
5982
|
} catch {
|
|
6010
|
-
return 0;
|
|
6011
5983
|
}
|
|
6012
5984
|
}
|
|
5985
|
+
return TRANSPORT_FAILURE.test(text);
|
|
5986
|
+
}
|
|
5987
|
+
var HandoffTranscriptTracker = class {
|
|
5988
|
+
open = /* @__PURE__ */ new Map();
|
|
5989
|
+
/** Per ticket record: the last hand-off status write's outcome. */
|
|
5990
|
+
status = /* @__PURE__ */ new Map();
|
|
5991
|
+
/** Per ticket record: the last hand-off comment that failed after the status write's turn. */
|
|
5992
|
+
comment = /* @__PURE__ */ new Map();
|
|
5993
|
+
landed = /* @__PURE__ */ new Set();
|
|
5994
|
+
lastText = "";
|
|
5995
|
+
resultLine = "";
|
|
5996
|
+
feed(parsed) {
|
|
5997
|
+
const line = parsed;
|
|
5998
|
+
if (!line || typeof line !== "object") return;
|
|
5999
|
+
if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
|
|
6000
|
+
const content = line.message?.content;
|
|
6001
|
+
if (!Array.isArray(content)) return;
|
|
6002
|
+
for (const b of content) {
|
|
6003
|
+
if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
|
|
6004
|
+
else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
|
|
6005
|
+
const w = classifyWrite(b.name, b.input);
|
|
6006
|
+
const body = b.input?.body;
|
|
6007
|
+
if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
|
|
6008
|
+
this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
|
|
6009
|
+
}
|
|
6010
|
+
} else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
|
|
6011
|
+
const w = this.open.get(b.tool_use_id);
|
|
6012
|
+
if (!w) continue;
|
|
6013
|
+
this.open.delete(b.tool_use_id);
|
|
6014
|
+
const failed = isTransportFailure(b.is_error === true, resultText(b.content));
|
|
6015
|
+
if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
|
|
6016
|
+
else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
|
|
6017
|
+
else if (!failed) {
|
|
6018
|
+
this.comment.delete(w.recordId);
|
|
6019
|
+
this.landed.add(w.recordId);
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
}
|
|
6023
|
+
}
|
|
6024
|
+
/** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
|
|
6025
|
+
pending() {
|
|
6026
|
+
const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
|
|
6027
|
+
if (!failed) return null;
|
|
6028
|
+
const [ticketRecordId, s] = failed;
|
|
6029
|
+
return {
|
|
6030
|
+
ticketRecordId,
|
|
6031
|
+
patch: s.patch,
|
|
6032
|
+
...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
|
|
6033
|
+
...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
|
|
6034
|
+
finalText: this.lastText || this.resultLine
|
|
6035
|
+
};
|
|
6036
|
+
}
|
|
6013
6037
|
};
|
|
6014
6038
|
|
|
6039
|
+
// src/pending-handoff.ts
|
|
6040
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
|
|
6041
|
+
import { join as join16 } from "path";
|
|
6042
|
+
|
|
6043
|
+
// src/ticket-branch.ts
|
|
6044
|
+
import { basename as basename6 } from "path";
|
|
6045
|
+
|
|
6046
|
+
// src/tracker.ts
|
|
6047
|
+
import { hostname } from "os";
|
|
6048
|
+
import { TablationClient } from "@tablation/client";
|
|
6049
|
+
|
|
6015
6050
|
// src/work-mode.ts
|
|
6016
6051
|
function projectWorkModes(rows, contract) {
|
|
6017
6052
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -6787,6 +6822,19 @@ var Tracker = class {
|
|
|
6787
6822
|
async hasPublicReplyColumn() {
|
|
6788
6823
|
return (await this.authorshipProbe()).commentPublicReply;
|
|
6789
6824
|
}
|
|
6825
|
+
/**
|
|
6826
|
+
* Whether this workspace has the optional Periscopes table (EPIC-077), and
|
|
6827
|
+
* which of the columns the crew writes (`PERISCOPE_COLUMNS`) it lacks.
|
|
6828
|
+
* `present: false` is a legitimate shape, never a fault. A table whose
|
|
6829
|
+
* metadata cannot be read reports every column missing, as the authorship
|
|
6830
|
+
* probe reads an unreadable table as "column absent".
|
|
6831
|
+
*/
|
|
6832
|
+
async periscopesStatus() {
|
|
6833
|
+
const modelId = this.route.resolved?.periscopesModelId;
|
|
6834
|
+
if (!modelId) return { present: false, missing: [] };
|
|
6835
|
+
const have = await this.columnNames(modelId);
|
|
6836
|
+
return { present: true, modelId, missing: PERISCOPE_COLUMNS.filter((c) => !have.has(c)) };
|
|
6837
|
+
}
|
|
6790
6838
|
/**
|
|
6791
6839
|
* The authorship columns this workspace is missing, as `table.column` names
|
|
6792
6840
|
* for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
|
|
@@ -8907,6 +8955,10 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
8907
8955
|
};
|
|
8908
8956
|
try {
|
|
8909
8957
|
const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
|
|
8958
|
+
try {
|
|
8959
|
+
clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
|
|
8960
|
+
} catch {
|
|
8961
|
+
}
|
|
8910
8962
|
if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
|
|
8911
8963
|
if (sent.counterRetry) {
|
|
8912
8964
|
try {
|
|
@@ -8939,6 +8991,15 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
8939
8991
|
} catch {
|
|
8940
8992
|
}
|
|
8941
8993
|
}
|
|
8994
|
+
if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
|
|
8995
|
+
try {
|
|
8996
|
+
await raiseShipAttention(
|
|
8997
|
+
{ state: new State(stateDirOfStream(id.streamPath)), emit },
|
|
8998
|
+
agentLogRefusedItem(id.route, id.role, e.status)
|
|
8999
|
+
);
|
|
9000
|
+
} catch {
|
|
9001
|
+
}
|
|
9002
|
+
}
|
|
8942
9003
|
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
8943
9004
|
}
|
|
8944
9005
|
}
|
|
@@ -13687,6 +13748,7 @@ function forbiddenDetail(e, route) {
|
|
|
13687
13748
|
[r?.shipsModelId, "Ships"],
|
|
13688
13749
|
[r?.locksModelId, "Locks"],
|
|
13689
13750
|
[r?.epicsModelId, "Epics"],
|
|
13751
|
+
[r?.periscopesModelId, "Periscopes"],
|
|
13690
13752
|
[r?.reposModelId, "Repos"]
|
|
13691
13753
|
];
|
|
13692
13754
|
const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
|
|
@@ -14531,6 +14593,7 @@ async function discover(o) {
|
|
|
14531
14593
|
out.reposModelId = find("Repos");
|
|
14532
14594
|
out.shipsModelId = find("Ships");
|
|
14533
14595
|
out.epicsModelId = find("Epics");
|
|
14596
|
+
out.periscopesModelId = find("Periscopes");
|
|
14534
14597
|
out.locksModelId = find("Locks");
|
|
14535
14598
|
try {
|
|
14536
14599
|
const views = await get(o, `/views?workspaceId=${ws.id}`);
|
|
@@ -14582,6 +14645,7 @@ async function discover(o) {
|
|
|
14582
14645
|
shipColumns = new Set((shipsModel.fields ?? []).map((f) => f.columnName));
|
|
14583
14646
|
} catch {
|
|
14584
14647
|
}
|
|
14648
|
+
const hostPeriscopes = shipColumns.has("host_periscopes") ? o.ship.hostPeriscopes : void 0;
|
|
14585
14649
|
let ownerId;
|
|
14586
14650
|
if (out.meId) {
|
|
14587
14651
|
if (shipColumns.has("owner_id")) ownerId = out.meId;
|
|
@@ -14624,6 +14688,7 @@ async function discover(o) {
|
|
|
14624
14688
|
}
|
|
14625
14689
|
if (writeMachineId) patchBody.machine_id = machineId;
|
|
14626
14690
|
if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
|
|
14691
|
+
if (hostPeriscopes !== void 0 && (mine[0].host_periscopes ?? false) !== hostPeriscopes) patchBody.host_periscopes = hostPeriscopes;
|
|
14627
14692
|
if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
|
|
14628
14693
|
patchBody.ssh_public_key = sshPublicKey;
|
|
14629
14694
|
}
|
|
@@ -14631,6 +14696,7 @@ async function discover(o) {
|
|
|
14631
14696
|
if (Object.keys(patchBody).length > 0) {
|
|
14632
14697
|
await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
|
|
14633
14698
|
if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
|
|
14699
|
+
if ("host_periscopes" in patchBody) provisioning.push(`synced host_periscopes=${hostPeriscopes} onto Ships row "${o.ship.name}"`);
|
|
14634
14700
|
if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
|
|
14635
14701
|
if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
|
|
14636
14702
|
}
|
|
@@ -14639,6 +14705,7 @@ async function discover(o) {
|
|
|
14639
14705
|
name: o.ship.name,
|
|
14640
14706
|
...o.ship.platform ? { platform: o.ship.platform } : {},
|
|
14641
14707
|
host_passengers: hostPassengers,
|
|
14708
|
+
...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
|
|
14642
14709
|
...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
|
|
14643
14710
|
...ownerId !== void 0 ? { owner_id: ownerId } : {},
|
|
14644
14711
|
...writeMachineId ? { machine_id: machineId } : {}
|
|
@@ -20529,6 +20596,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
20529
20596
|
areaId: found.areaId,
|
|
20530
20597
|
shipsModelId: found.shipsModelId,
|
|
20531
20598
|
epicsModelId: found.epicsModelId,
|
|
20599
|
+
periscopesModelId: found.periscopesModelId,
|
|
20532
20600
|
locksModelId: found.locksModelId,
|
|
20533
20601
|
recordLinkViewId: found.recordLinkViewId,
|
|
20534
20602
|
reposModelId: found.reposModelId,
|
|
@@ -22011,6 +22079,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
22011
22079
|
process.stdout.write(
|
|
22012
22080
|
await tracker.hasPublicReplyColumn() ? `public replies: Comments.${tracker.contract.comments.publicReply} present \u2014 public reporters are labelled in the digest
|
|
22013
22081
|
` : `public replies: Comments.${tracker.contract.comments.publicReply} absent \u2014 no public intake form, nothing to guard
|
|
22082
|
+
`
|
|
22083
|
+
);
|
|
22084
|
+
const periscopes = await tracker.periscopesStatus();
|
|
22085
|
+
process.stdout.write(
|
|
22086
|
+
!periscopes.present ? "periscopes: Periscopes table absent \u2014 this workspace cannot host periscopes\n" : periscopes.missing.length === 0 ? `periscopes: Periscopes table present (${periscopes.modelId})
|
|
22087
|
+
` : `periscopes: Periscopes table present (${periscopes.modelId}) but missing ${periscopes.missing.join(", ")} \u2014 update the Issues template
|
|
22014
22088
|
`
|
|
22015
22089
|
);
|
|
22016
22090
|
process.stdout.write(
|