@sjawhar/pi-envoy 10.4.0 → 10.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dispatch.js
CHANGED
|
@@ -14033,7 +14033,8 @@ var dispatchToolSpecs = [
|
|
|
14033
14033
|
labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
|
|
14034
14034
|
priority: z2.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
|
|
14035
14035
|
assignee: z2.string().describe("Email of the person who answers this issue's asks (someone who has signed in to Dispatch); defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional(),
|
|
14036
|
-
components: componentsArgument(z2).optional()
|
|
14036
|
+
components: componentsArgument(z2).optional(),
|
|
14037
|
+
repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; a child inherits its parent's when unset, and an issue with none on its parent chain takes the project's default.").optional()
|
|
14037
14038
|
})
|
|
14038
14039
|
},
|
|
14039
14040
|
{
|
|
@@ -14043,7 +14044,7 @@ var dispatchToolSpecs = [
|
|
|
14043
14044
|
status: "done",
|
|
14044
14045
|
reason: "Shipped in owner/repo#7; verified on the production dashboard."
|
|
14045
14046
|
},
|
|
14046
|
-
description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by,
|
|
14047
|
+
description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, or set the repository it targets. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
|
|
14047
14048
|
arguments: (z2) => ({
|
|
14048
14049
|
issue: z2.string().describe(ISSUE_REFERENCE),
|
|
14049
14050
|
status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
|
|
@@ -14055,15 +14056,16 @@ var dispatchToolSpecs = [
|
|
|
14055
14056
|
route: z2.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional(),
|
|
14056
14057
|
parent: z2.string().describe("Parent issue key in the same project; an empty string clears the parent.").optional(),
|
|
14057
14058
|
blocked_by: z2.array(z2.string({ min: 1 }), { max: MAX_ISSUE_BLOCKERS }).describe("Replacement list of issue keys in the same project this issue waits on; [] clears it. Dispatch refuses a dependency cycle.").optional(),
|
|
14058
|
-
components: componentsArgument(z2).optional()
|
|
14059
|
+
components: componentsArgument(z2).optional(),
|
|
14060
|
+
repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional()
|
|
14059
14061
|
}),
|
|
14060
14062
|
validation: {
|
|
14061
14063
|
check: (value) => {
|
|
14062
14064
|
const input = value;
|
|
14063
14065
|
const reasonFits = input.status === "done" ? typeof input.reason === "string" && input.reason.trim() !== "" : input.reason === undefined;
|
|
14064
|
-
return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null);
|
|
14066
|
+
return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string");
|
|
14065
14067
|
},
|
|
14066
|
-
message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, or
|
|
14068
|
+
message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, or repo. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
|
|
14067
14069
|
},
|
|
14068
14070
|
strict: true
|
|
14069
14071
|
},
|
|
@@ -16577,14 +16579,14 @@ function toolSchema(tool) {
|
|
|
16577
16579
|
toolSchemas.set(tool, schema);
|
|
16578
16580
|
return schema;
|
|
16579
16581
|
}
|
|
16580
|
-
async function resolveOwnerArguments(tool, input, cwd, env, exec,
|
|
16582
|
+
async function resolveOwnerArguments(tool, input, cwd, env, exec, dashboardUrls, problems) {
|
|
16581
16583
|
if (issueFreeTools[tool] === true)
|
|
16582
16584
|
return { args: input, ref: null, owner: null };
|
|
16583
16585
|
const refArgument = input.ref;
|
|
16584
16586
|
let ref = null;
|
|
16585
16587
|
let args = input;
|
|
16586
16588
|
if (typeof refArgument === "string") {
|
|
16587
|
-
const refText = dispatchRefFromUrl(refArgument,
|
|
16589
|
+
const refText = dashboardUrls.map((dashboard) => dispatchRefFromUrl(refArgument, dashboard)).find((ref2) => ref2 !== undefined) ?? refArgument;
|
|
16588
16590
|
if (refText !== refArgument)
|
|
16589
16591
|
args = { ...input, ref: refText };
|
|
16590
16592
|
const agentArtifact = parseAgentArtifactRef(refText);
|
|
@@ -16846,7 +16848,32 @@ function progressText(progress, sep) {
|
|
|
16846
16848
|
}
|
|
16847
16849
|
return parts.join(sep);
|
|
16848
16850
|
}
|
|
16849
|
-
function
|
|
16851
|
+
function repositoryLine(issue2) {
|
|
16852
|
+
if (issue2.resolved_repo === undefined) {
|
|
16853
|
+
return "Repository: unknown (this Dispatch server predates repository fields; upgrade it)";
|
|
16854
|
+
}
|
|
16855
|
+
if (issue2.resolved_repo === null)
|
|
16856
|
+
return "Repository: none";
|
|
16857
|
+
if (issue2.repo !== null && issue2.repo !== undefined)
|
|
16858
|
+
return `Repository: ${issue2.repo} (own)`;
|
|
16859
|
+
return `Repository: ${issue2.resolved_repo} (inherited or project default)`;
|
|
16860
|
+
}
|
|
16861
|
+
function repositoryFieldsWarnings(issue2) {
|
|
16862
|
+
if (issue2.resolved_repo !== undefined)
|
|
16863
|
+
return {};
|
|
16864
|
+
return {
|
|
16865
|
+
warnings: [
|
|
16866
|
+
`dispatch: warning: Dispatch issue is missing resolved_repo: this Dispatch server predates repository fields, so ${issue2.key}'s repository is unknown; upgrade the server`
|
|
16867
|
+
]
|
|
16868
|
+
};
|
|
16869
|
+
}
|
|
16870
|
+
function refuseUnsavedRepository(verb, issue2, repo2) {
|
|
16871
|
+
if (repo2 === undefined || issue2.repo !== undefined)
|
|
16872
|
+
return;
|
|
16873
|
+
const lost = repo2 === "" ? "the repository was not cleared" : `${repo2} was not saved`;
|
|
16874
|
+
throw new Error(`Dispatch ${verb} ${issue2.key} without its repository: this Dispatch server predates repository fields, so ${lost}; upgrade the server`);
|
|
16875
|
+
}
|
|
16876
|
+
function issueSummary(issue2, events, references, graph, linkBase, titles) {
|
|
16850
16877
|
const asks = issue2.open_asks;
|
|
16851
16878
|
const spec = issue2.artifacts?.find((artifact) => artifact.primary);
|
|
16852
16879
|
const specApproval = spec === undefined ? undefined : approvalLine(spec);
|
|
@@ -16864,6 +16891,7 @@ function issueSummary(issue2, events, references, graph, titles) {
|
|
|
16864
16891
|
return [
|
|
16865
16892
|
`Title: ${issue2.title}`,
|
|
16866
16893
|
`Key: ${issue2.key}`,
|
|
16894
|
+
`Link: ${new URL(`/issues/${encodeURIComponent(issue2.key)}`, linkBase).toString()}`,
|
|
16867
16895
|
`Status: ${issue2.status}`,
|
|
16868
16896
|
`Assignee: ${issue2.assignee ?? "unassigned"}`,
|
|
16869
16897
|
`Claimed by: ${issue2.claim === null ? "nobody" : claimText(issue2.claim, titles)}`,
|
|
@@ -16871,6 +16899,7 @@ function issueSummary(issue2, events, references, graph, titles) {
|
|
|
16871
16899
|
`Labels: ${issue2.labels.length === 0 ? "none" : issue2.labels.join(", ")}`,
|
|
16872
16900
|
componentsLine(issue2.components),
|
|
16873
16901
|
`Route: ${routeText(issue2, titles)}`,
|
|
16902
|
+
repositoryLine(issue2),
|
|
16874
16903
|
`Progress: ${progressText(issue2.progress, ", ") || "none"}`,
|
|
16875
16904
|
...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
|
|
16876
16905
|
"External links:",
|
|
@@ -17328,6 +17357,7 @@ function withAccount(error48, account) {
|
|
|
17328
17357
|
}
|
|
17329
17358
|
async function executeDispatchTool(input) {
|
|
17330
17359
|
const { url: configUrl, token: configToken } = input.config;
|
|
17360
|
+
const linkBase = input.config.publicUrl ?? configUrl;
|
|
17331
17361
|
const baseFetch = input.fetchImpl ?? fetch;
|
|
17332
17362
|
const fetchImpl = Object.assign(async (...args2) => {
|
|
17333
17363
|
try {
|
|
@@ -17342,7 +17372,7 @@ async function executeDispatchTool(input) {
|
|
|
17342
17372
|
const env = input.env ?? process.env;
|
|
17343
17373
|
const exec = input.exec ?? defaultExec;
|
|
17344
17374
|
const problems = [];
|
|
17345
|
-
const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
|
|
17375
|
+
const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, input.config.publicUrl === undefined ? [configUrl] : [configUrl, input.config.publicUrl], problems);
|
|
17346
17376
|
const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
|
|
17347
17377
|
const schema = toolSchema(input.tool);
|
|
17348
17378
|
const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
|
|
@@ -17360,13 +17390,13 @@ async function executeDispatchTool(input) {
|
|
|
17360
17390
|
const project = optionalString(ownerArguments.args, "project");
|
|
17361
17391
|
if (project !== undefined) {
|
|
17362
17392
|
const response2 = await client2.openAsksForProject(project);
|
|
17363
|
-
return { text: formatOpenAsksSummary(response2,
|
|
17393
|
+
return { text: formatOpenAsksSummary(response2, linkBase), details: { ...response2 } };
|
|
17364
17394
|
}
|
|
17365
17395
|
const sessionId = input.sessionId?.trim();
|
|
17366
17396
|
if (!sessionId)
|
|
17367
17397
|
throw new Error("host session id is required for dispatch open-asks");
|
|
17368
17398
|
const response = await client2.openAsks(sessionId);
|
|
17369
|
-
return { text: formatOpenAsksSummary(response,
|
|
17399
|
+
return { text: formatOpenAsksSummary(response, linkBase), details: { ...response } };
|
|
17370
17400
|
}
|
|
17371
17401
|
if (input.tool === "dispatch_whoami") {
|
|
17372
17402
|
const sessionId = input.sessionId?.trim();
|
|
@@ -17425,6 +17455,7 @@ async function executeDispatchTool(input) {
|
|
|
17425
17455
|
const priority = optionalPriority(args, "priority");
|
|
17426
17456
|
const assignee = optionalString(args, "assignee");
|
|
17427
17457
|
const components = optionalComponents(args, "components");
|
|
17458
|
+
const repo2 = optionalString(args, "repo");
|
|
17428
17459
|
const labels = args.labels;
|
|
17429
17460
|
const blockedBy = args.blocked_by;
|
|
17430
17461
|
try {
|
|
@@ -17439,20 +17470,24 @@ async function executeDispatchTool(input) {
|
|
|
17439
17470
|
...priority === undefined ? {} : { priority },
|
|
17440
17471
|
...assignee === undefined ? {} : { assignee },
|
|
17441
17472
|
...components === undefined ? {} : { components },
|
|
17473
|
+
...repo2 === undefined ? {} : { repo: repo2 },
|
|
17442
17474
|
...Array.isArray(labels) ? { labels } : {},
|
|
17443
17475
|
actor
|
|
17444
17476
|
});
|
|
17477
|
+
refuseUnsavedRepository("created", created, repo2);
|
|
17445
17478
|
const componentGuidance = await architectureGuidance(client, created.project, created.components);
|
|
17446
17479
|
const adviceLines = [
|
|
17447
17480
|
...renderAdvice(input.tool, created.key, created.advice, {
|
|
17448
17481
|
isPrimarySpec: spec !== undefined
|
|
17449
17482
|
}),
|
|
17450
17483
|
...componentGuidance === undefined ? [] : [componentGuidance],
|
|
17451
|
-
...renderSuggestions(created.advice?.suggestions,
|
|
17484
|
+
...renderSuggestions(created.advice?.suggestions, linkBase)
|
|
17452
17485
|
];
|
|
17486
|
+
const href = new URL(`/issues/${encodeURIComponent(created.key)}`, linkBase).toString();
|
|
17487
|
+
const assigned = created.assignee === undefined ? "" : created.assignee === null ? "; unassigned" : `; assigned to ${created.assignee}`;
|
|
17453
17488
|
return {
|
|
17454
17489
|
text: [
|
|
17455
|
-
`Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
|
|
17490
|
+
`Created ${created.key}: ${created.title} \u2192 ${href}${assigned} ${notSubscribed(issueTopic(created.key))}`,
|
|
17456
17491
|
...adviceLines
|
|
17457
17492
|
].join(`
|
|
17458
17493
|
`),
|
|
@@ -17470,7 +17505,7 @@ async function executeDispatchTool(input) {
|
|
|
17470
17505
|
text: [
|
|
17471
17506
|
`Not created: "${title}" looks like a duplicate.`,
|
|
17472
17507
|
...candidates.map((candidate) => {
|
|
17473
|
-
const href = new URL(candidate.href,
|
|
17508
|
+
const href = new URL(candidate.href, linkBase).toString();
|
|
17474
17509
|
return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
|
|
17475
17510
|
}),
|
|
17476
17511
|
"Reference the existing issue, or run dispatch issue again with --force after reading it."
|
|
@@ -17488,6 +17523,7 @@ async function executeDispatchTool(input) {
|
|
|
17488
17523
|
const route = optionalString(args, "route");
|
|
17489
17524
|
const parent = optionalString(args, "parent");
|
|
17490
17525
|
const components = optionalComponents(args, "components");
|
|
17526
|
+
const repo2 = optionalString(args, "repo");
|
|
17491
17527
|
const priority = optionalPriority(args, "priority");
|
|
17492
17528
|
const labels = Array.isArray(args.labels) ? args.labels : undefined;
|
|
17493
17529
|
const blockedBy = Array.isArray(args.blocked_by) ? args.blocked_by : undefined;
|
|
@@ -17527,6 +17563,7 @@ async function executeDispatchTool(input) {
|
|
|
17527
17563
|
...parent === undefined ? {} : { parent: parent === "" ? null : parent },
|
|
17528
17564
|
...blockedBy === undefined ? {} : { blocked_by: blockedBy },
|
|
17529
17565
|
...components === undefined ? {} : { components },
|
|
17566
|
+
...repo2 === undefined ? {} : { repo: repo2 === "" ? null : repo2 },
|
|
17530
17567
|
...sentLinks === undefined ? {} : { external_links: sentLinks },
|
|
17531
17568
|
actor
|
|
17532
17569
|
});
|
|
@@ -17541,6 +17578,7 @@ async function executeDispatchTool(input) {
|
|
|
17541
17578
|
throw refusalWithCode(error48, taken, landed);
|
|
17542
17579
|
throw withAccount(error48, landed);
|
|
17543
17580
|
}
|
|
17581
|
+
refuseUnsavedRepository("updated", after, repo2);
|
|
17544
17582
|
const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
|
|
17545
17583
|
const changes = [
|
|
17546
17584
|
...closingNote === undefined ? [] : [`reason posted as message ${closingNote.id} (${closingNote.ref})`],
|
|
@@ -17556,7 +17594,10 @@ async function executeDispatchTool(input) {
|
|
|
17556
17594
|
...blockedBy === undefined ? [] : [
|
|
17557
17595
|
after.blocked_by.length === 0 ? "blocked_by cleared" : `blocked by ${after.blocked_by.join(", ")}`
|
|
17558
17596
|
],
|
|
17559
|
-
...components === undefined ? [] : [componentsChange(components, after.components)]
|
|
17597
|
+
...components === undefined ? [] : [componentsChange(components, after.components)],
|
|
17598
|
+
...repo2 === undefined ? [] : [
|
|
17599
|
+
after.repo === null ? `repo cleared (resolves to ${after.resolved_repo ?? "none"})` : `repo -> ${after.repo}`
|
|
17600
|
+
]
|
|
17560
17601
|
];
|
|
17561
17602
|
const adviceLines = renderAdvice(input.tool, after.key, after.advice, {
|
|
17562
17603
|
setsStatus: status !== undefined
|
|
@@ -17611,7 +17652,7 @@ async function executeDispatchTool(input) {
|
|
|
17611
17652
|
...limit === undefined ? {} : { limit },
|
|
17612
17653
|
...offset === undefined ? {} : { offset }
|
|
17613
17654
|
});
|
|
17614
|
-
return searchAnswer(search, query, offset,
|
|
17655
|
+
return searchAnswer(search, query, offset, linkBase);
|
|
17615
17656
|
}
|
|
17616
17657
|
case "dispatch_issues": {
|
|
17617
17658
|
const project = stringArg(args, "project");
|
|
@@ -17675,7 +17716,7 @@ async function executeDispatchTool(input) {
|
|
|
17675
17716
|
} catch (error48) {
|
|
17676
17717
|
throw refusalWithCode(error48);
|
|
17677
17718
|
}
|
|
17678
|
-
const href = new URL(`/projects/${encodeURIComponent(project.key)}`,
|
|
17719
|
+
const href = new URL(`/projects/${encodeURIComponent(project.key)}`, linkBase).toString();
|
|
17679
17720
|
return {
|
|
17680
17721
|
text: `Created project ${project.key} (${project.name}) at ${project.created_at}: ${href}`,
|
|
17681
17722
|
details: { project: project.key, name: project.name, created_at: project.created_at }
|
|
@@ -17768,7 +17809,7 @@ async function executeDispatchTool(input) {
|
|
|
17768
17809
|
const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue2()) : documentTopic(resolved.artifact);
|
|
17769
17810
|
const adviceLines = [
|
|
17770
17811
|
...renderAdvice(input.tool, askOwner.label, ask.advice, {}),
|
|
17771
|
-
...renderSuggestions(ask.advice?.suggestions,
|
|
17812
|
+
...renderSuggestions(ask.advice?.suggestions, linkBase)
|
|
17772
17813
|
];
|
|
17773
17814
|
return {
|
|
17774
17815
|
text: [
|
|
@@ -18249,9 +18290,10 @@ ${trailer.join(`
|
|
|
18249
18290
|
])
|
|
18250
18291
|
]);
|
|
18251
18292
|
return {
|
|
18252
|
-
text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], titles),
|
|
18293
|
+
text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], linkBase, titles),
|
|
18253
18294
|
details: { issue: read.issue.key },
|
|
18254
|
-
...showImages(pictures2)
|
|
18295
|
+
...showImages(pictures2),
|
|
18296
|
+
...repositoryFieldsWarnings(read.issue)
|
|
18255
18297
|
};
|
|
18256
18298
|
}
|
|
18257
18299
|
default:
|
|
@@ -18987,7 +19029,17 @@ function dispatchSettings(env, config2) {
|
|
|
18987
19029
|
}
|
|
18988
19030
|
const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
18989
19031
|
const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
|
|
18990
|
-
|
|
19032
|
+
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
19033
|
+
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
19034
|
+
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
19035
|
+
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
19036
|
+
}
|
|
19037
|
+
return {
|
|
19038
|
+
enabled: true,
|
|
19039
|
+
url: url2,
|
|
19040
|
+
token,
|
|
19041
|
+
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
19042
|
+
};
|
|
18991
19043
|
} catch (error48) {
|
|
18992
19044
|
return offWithError(messageFor(error48));
|
|
18993
19045
|
}
|
|
@@ -19156,6 +19208,10 @@ async function runDispatchCli(argv, rawEnv, io) {
|
|
|
19156
19208
|
...io.fetchImpl === undefined ? {} : { fetchImpl: io.fetchImpl }
|
|
19157
19209
|
});
|
|
19158
19210
|
text = result.text;
|
|
19211
|
+
for (const warning of result.warnings ?? []) {
|
|
19212
|
+
io.stderr(`${warning}
|
|
19213
|
+
`);
|
|
19214
|
+
}
|
|
19159
19215
|
try {
|
|
19160
19216
|
for (const image of result.images ?? []) {
|
|
19161
19217
|
const picture = writePicture(dir, image);
|
|
@@ -19197,6 +19253,7 @@ async function runDispatchCli(argv, rawEnv, io) {
|
|
|
19197
19253
|
var readText = (path2) => readFileSync4(path2 === "-" ? 0 : path2, "utf-8");
|
|
19198
19254
|
process.exit(await runDispatchCli(Bun.argv.slice(2), process.env, {
|
|
19199
19255
|
stdout: (text) => process.stdout.write(text),
|
|
19256
|
+
stderr: (text) => process.stderr.write(text),
|
|
19200
19257
|
readText,
|
|
19201
19258
|
cwd: process.cwd()
|
|
19202
19259
|
}));
|
package/dist/envoy.js
CHANGED
|
@@ -30020,7 +30020,8 @@ var dispatchToolSpecs = [
|
|
|
30020
30020
|
labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
|
|
30021
30021
|
priority: z2.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
|
|
30022
30022
|
assignee: z2.string().describe("Email of the person who answers this issue's asks (someone who has signed in to Dispatch); defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional(),
|
|
30023
|
-
components: componentsArgument(z2).optional()
|
|
30023
|
+
components: componentsArgument(z2).optional(),
|
|
30024
|
+
repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; a child inherits its parent's when unset, and an issue with none on its parent chain takes the project's default.").optional()
|
|
30024
30025
|
})
|
|
30025
30026
|
},
|
|
30026
30027
|
{
|
|
@@ -30030,7 +30031,7 @@ var dispatchToolSpecs = [
|
|
|
30030
30031
|
status: "done",
|
|
30031
30032
|
reason: "Shipped in owner/repo#7; verified on the production dashboard."
|
|
30032
30033
|
},
|
|
30033
|
-
description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by,
|
|
30034
|
+
description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, replace the issues it is blocked by, attach it to " + "architecture components, or set the repository it targets. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. A non-empty external_links is " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link; an empty external_links removes every link instead. components " + "replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "a reopening status (any status but done), and an external_links write that adds no new " + "link (so a link can move to the issue that replaced it); everything else, priority " + "included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
|
|
30034
30035
|
arguments: (z2) => ({
|
|
30035
30036
|
issue: z2.string().describe(ISSUE_REFERENCE),
|
|
30036
30037
|
status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
|
|
@@ -30042,15 +30043,16 @@ var dispatchToolSpecs = [
|
|
|
30042
30043
|
route: z2.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional(),
|
|
30043
30044
|
parent: z2.string().describe("Parent issue key in the same project; an empty string clears the parent.").optional(),
|
|
30044
30045
|
blocked_by: z2.array(z2.string({ min: 1 }), { max: MAX_ISSUE_BLOCKERS }).describe("Replacement list of issue keys in the same project this issue waits on; [] clears it. Dispatch refuses a dependency cycle.").optional(),
|
|
30045
|
-
components: componentsArgument(z2).optional()
|
|
30046
|
+
components: componentsArgument(z2).optional(),
|
|
30047
|
+
repo: z2.string().describe("The repository the issue targets, owner/name, mapped to the issue's project in Settings; an empty string clears it, so the issue inherits its parent's or the project's default again.").optional()
|
|
30046
30048
|
}),
|
|
30047
30049
|
validation: {
|
|
30048
30050
|
check: (value) => {
|
|
30049
30051
|
const input = value;
|
|
30050
30052
|
const reasonFits = input.status === "done" ? typeof input.reason === "string" && input.reason.trim() !== "" : input.reason === undefined;
|
|
30051
|
-
return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null);
|
|
30053
|
+
return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || Array.isArray(input.blocked_by) || typeof input.components === "object" && input.components !== null || typeof input.repo === "string");
|
|
30052
30054
|
},
|
|
30053
|
-
message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, or
|
|
30055
|
+
message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, blocked_by, components, or repo. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
|
|
30054
30056
|
},
|
|
30055
30057
|
strict: true
|
|
30056
30058
|
},
|
|
@@ -30463,6 +30465,12 @@ var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
|
|
|
30463
30465
|
function agentSubject(session) {
|
|
30464
30466
|
return `${AGENT_TOPIC_PREFIX}${session}`;
|
|
30465
30467
|
}
|
|
30468
|
+
// ../contracts/src/slack.ts
|
|
30469
|
+
var SLACK_POST_TEXT_MAX = 4000;
|
|
30470
|
+
function slackTopicChannel(topic) {
|
|
30471
|
+
const [scope, source, , channel] = topic.split(".");
|
|
30472
|
+
return scope === "notifications" && source === "slack" ? channel ?? "" : undefined;
|
|
30473
|
+
}
|
|
30466
30474
|
// ../contracts/src/tool-schema.ts
|
|
30467
30475
|
function overCapMessage(length, max) {
|
|
30468
30476
|
return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
|
|
@@ -32577,7 +32585,17 @@ function dispatchSettings(env, config2) {
|
|
|
32577
32585
|
}
|
|
32578
32586
|
const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
32579
32587
|
const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
|
|
32580
|
-
|
|
32588
|
+
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
32589
|
+
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
32590
|
+
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
32591
|
+
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
32592
|
+
}
|
|
32593
|
+
return {
|
|
32594
|
+
enabled: true,
|
|
32595
|
+
url: url2,
|
|
32596
|
+
token,
|
|
32597
|
+
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
32598
|
+
};
|
|
32581
32599
|
} catch (error48) {
|
|
32582
32600
|
return offWithError(messageFor(error48));
|
|
32583
32601
|
}
|
|
@@ -33698,6 +33716,293 @@ function sessionDispatchSettings(file2, env, options) {
|
|
|
33698
33716
|
};
|
|
33699
33717
|
}
|
|
33700
33718
|
|
|
33719
|
+
// ../envoy-client/src/slack-post.ts
|
|
33720
|
+
import { BlockList, isIP } from "net";
|
|
33721
|
+
var SLACK_BOT_TOKEN_SECRET = "LEGION_SLACK_BOT_TOKEN";
|
|
33722
|
+
var SLACK_POST_API_URL_VARIABLE = "SLACK_POST_API_URL";
|
|
33723
|
+
var POST_TIMEOUT_MS = 30000;
|
|
33724
|
+
var SLACK_ERROR_CODES = [
|
|
33725
|
+
"access_denied",
|
|
33726
|
+
"accesslimited",
|
|
33727
|
+
"account_inactive",
|
|
33728
|
+
"agent_prompt_display_not_allowed",
|
|
33729
|
+
"app_access_restricted",
|
|
33730
|
+
"as_user_not_supported",
|
|
33731
|
+
"attachment_payload_limit_exceeded",
|
|
33732
|
+
"cannot_reply_to_message",
|
|
33733
|
+
"channel_not_found",
|
|
33734
|
+
"deprecated_endpoint",
|
|
33735
|
+
"draft_already_deleted",
|
|
33736
|
+
"draft_already_sent",
|
|
33737
|
+
"draft_has_conflict",
|
|
33738
|
+
"draft_not_found",
|
|
33739
|
+
"duplicate_channel_not_found",
|
|
33740
|
+
"duplicate_message_not_found",
|
|
33741
|
+
"ekm_access_denied",
|
|
33742
|
+
"enterprise_is_restricted",
|
|
33743
|
+
"fatal_error",
|
|
33744
|
+
"internal_error",
|
|
33745
|
+
"invalid_arg_name",
|
|
33746
|
+
"invalid_arguments",
|
|
33747
|
+
"invalid_array_arg",
|
|
33748
|
+
"invalid_auth",
|
|
33749
|
+
"invalid_blocks",
|
|
33750
|
+
"invalid_blocks_format",
|
|
33751
|
+
"invalid_charset",
|
|
33752
|
+
"invalid_form_data",
|
|
33753
|
+
"invalid_metadata_format",
|
|
33754
|
+
"invalid_metadata_schema",
|
|
33755
|
+
"invalid_post_type",
|
|
33756
|
+
"is_archived",
|
|
33757
|
+
"markdown_text_conflict",
|
|
33758
|
+
"message_limit_exceeded",
|
|
33759
|
+
"messages_tab_disabled",
|
|
33760
|
+
"metadata_must_be_sent_from_app",
|
|
33761
|
+
"metadata_too_large",
|
|
33762
|
+
"method_deprecated",
|
|
33763
|
+
"missing_file_data",
|
|
33764
|
+
"missing_post_type",
|
|
33765
|
+
"missing_scope",
|
|
33766
|
+
"msg_blocks_too_long",
|
|
33767
|
+
"no_permission",
|
|
33768
|
+
"no_text",
|
|
33769
|
+
"not_allowed_token_type",
|
|
33770
|
+
"not_authed",
|
|
33771
|
+
"not_in_channel",
|
|
33772
|
+
"org_login_required",
|
|
33773
|
+
"rate_limited",
|
|
33774
|
+
"ratelimited",
|
|
33775
|
+
"request_timeout",
|
|
33776
|
+
"restricted_action",
|
|
33777
|
+
"restricted_action_non_threadable_channel",
|
|
33778
|
+
"restricted_action_read_only_channel",
|
|
33779
|
+
"restricted_action_thread_locked",
|
|
33780
|
+
"restricted_action_thread_only_channel",
|
|
33781
|
+
"send_on_behalf_not_allowed",
|
|
33782
|
+
"service_unavailable",
|
|
33783
|
+
"slack_connect_canvas_sharing_blocked",
|
|
33784
|
+
"slack_connect_file_link_sharing_blocked",
|
|
33785
|
+
"slack_connect_lists_sharing_blocked",
|
|
33786
|
+
"team_access_not_granted",
|
|
33787
|
+
"team_added_to_org",
|
|
33788
|
+
"team_not_found",
|
|
33789
|
+
"token_expired",
|
|
33790
|
+
"token_revoked",
|
|
33791
|
+
"too_many_attachments",
|
|
33792
|
+
"too_many_contact_cards",
|
|
33793
|
+
"two_factor_setup_required"
|
|
33794
|
+
];
|
|
33795
|
+
var POSTER = `
|
|
33796
|
+
const codes = ${JSON.stringify(Object.fromEntries(SLACK_ERROR_CODES.map((code) => [code, true])))};
|
|
33797
|
+
const token = process.env.LEGION_SLACK_BOT_TOKEN || "";
|
|
33798
|
+
const out = (value) => {
|
|
33799
|
+
let line = JSON.stringify(value);
|
|
33800
|
+
if (token !== "") line = line.split(token).join("[redacted]");
|
|
33801
|
+
process.stdout.write(line + "\\n");
|
|
33802
|
+
};
|
|
33803
|
+
let sent = false;
|
|
33804
|
+
try {
|
|
33805
|
+
const input = JSON.parse(await Bun.stdin.text());
|
|
33806
|
+
if (token === "") {
|
|
33807
|
+
out({ invalid: "the poster's environment carries no LEGION_SLACK_BOT_TOKEN" });
|
|
33808
|
+
process.exit(0);
|
|
33809
|
+
}
|
|
33810
|
+
const base = (process.env.SLACK_POST_API_URL || "https://slack.com/api").replace(/\\/+$/, "");
|
|
33811
|
+
let status = 0;
|
|
33812
|
+
let raw = "";
|
|
33813
|
+
try {
|
|
33814
|
+
sent = true;
|
|
33815
|
+
const response = await fetch(base + "/chat.postMessage", {
|
|
33816
|
+
method: "POST",
|
|
33817
|
+
headers: { Authorization: "Bearer " + token, "Content-Type": "application/json; charset=utf-8" },
|
|
33818
|
+
body: JSON.stringify({ channel: input.channel, thread_ts: input.thread_ts, text: input.text }),
|
|
33819
|
+
signal: AbortSignal.timeout(10000),
|
|
33820
|
+
});
|
|
33821
|
+
status = response.status;
|
|
33822
|
+
raw = await response.text();
|
|
33823
|
+
} catch {
|
|
33824
|
+
out({ unanswered: true });
|
|
33825
|
+
process.exit(0);
|
|
33826
|
+
}
|
|
33827
|
+
let answer;
|
|
33828
|
+
try {
|
|
33829
|
+
answer = JSON.parse(raw);
|
|
33830
|
+
} catch {}
|
|
33831
|
+
if (!answer || typeof answer !== "object" || typeof answer.ok !== "boolean") {
|
|
33832
|
+
out({ invalid: "HTTP " + Number(status) + " with a body that is not Slack's JSON" });
|
|
33833
|
+
} else if (!answer.ok) {
|
|
33834
|
+
out({ refused: typeof answer.error === "string" && Object.hasOwn(codes, answer.error) ? answer.error : null });
|
|
33835
|
+
} else if (
|
|
33836
|
+
typeof answer.ts !== "string" || !/^[0-9]{1,12}\\.[0-9]{1,8}$/.test(answer.ts) ||
|
|
33837
|
+
(answer.channel !== undefined && (typeof answer.channel !== "string" || !/^[CDG][A-Z0-9]{1,30}$/.test(answer.channel)))
|
|
33838
|
+
) {
|
|
33839
|
+
out({ invalid: "ok with a ts or channel that is not Slack's" });
|
|
33840
|
+
} else {
|
|
33841
|
+
out({ ts: answer.ts, channel: answer.channel === undefined ? input.channel : answer.channel });
|
|
33842
|
+
}
|
|
33843
|
+
} catch {
|
|
33844
|
+
out(sent ? { unanswered: true } : { invalid: "a reply the poster could not read" });
|
|
33845
|
+
}
|
|
33846
|
+
`;
|
|
33847
|
+
var MAY_HAVE_POSTED = "the reply may have been posted, and posting it again may put it in the thread twice";
|
|
33848
|
+
var URL_CANDIDATE = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s<>|"'`]+/gi;
|
|
33849
|
+
var WEB_SCHEME = /^https?:\/\//i;
|
|
33850
|
+
var BARE_HOST = /(?<![\w@./:%-])((?:[a-z0-9-]{1,63}\.)*[a-z0-9-]{1,63})\.?(:\d{1,5})?(?![\w-])/gi;
|
|
33851
|
+
var BARE_IPV6 = /(?<![\w@./:%-])\[([0-9a-f:.]{2,45})(?:%[\w.-]{1,32})?\](?::\d{1,5})?/gi;
|
|
33852
|
+
var TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/;
|
|
33853
|
+
var CLUSTER_ADDRESSES = new BlockList;
|
|
33854
|
+
for (const [network, prefix] of [
|
|
33855
|
+
["0.0.0.0", 8],
|
|
33856
|
+
["10.0.0.0", 8],
|
|
33857
|
+
["100.64.0.0", 10],
|
|
33858
|
+
["127.0.0.0", 8],
|
|
33859
|
+
["169.254.0.0", 16],
|
|
33860
|
+
["172.16.0.0", 12],
|
|
33861
|
+
["192.168.0.0", 16]
|
|
33862
|
+
]) {
|
|
33863
|
+
CLUSTER_ADDRESSES.addSubnet(network, prefix, "ipv4");
|
|
33864
|
+
}
|
|
33865
|
+
for (const [network, prefix] of [
|
|
33866
|
+
["::", 96],
|
|
33867
|
+
["fc00::", 7],
|
|
33868
|
+
["fe80::", 10]
|
|
33869
|
+
]) {
|
|
33870
|
+
CLUSTER_ADDRESSES.addSubnet(network, prefix, "ipv6");
|
|
33871
|
+
}
|
|
33872
|
+
function linkInsideCluster(text, env) {
|
|
33873
|
+
const { DISPATCH_URL: apiUrl, DISPATCH_PUBLIC_URL: publicUrl } = env;
|
|
33874
|
+
const apiHost = hostOf(apiUrl);
|
|
33875
|
+
const publicHost = hostOf(publicUrl);
|
|
33876
|
+
const dispatchHost = apiHost !== undefined && publicHost !== undefined && apiHost !== publicHost ? apiHost : undefined;
|
|
33877
|
+
const decoded = text.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
33878
|
+
for (const match of decoded.matchAll(URL_CANDIDATE)) {
|
|
33879
|
+
if (!WEB_SCHEME.test(match[0]))
|
|
33880
|
+
continue;
|
|
33881
|
+
for (const written of new Set([match[0], match[0].replace(TRAILING_PUNCTUATION, "")])) {
|
|
33882
|
+
const host = hostOf(written);
|
|
33883
|
+
if (host !== undefined && insideCluster(host, dispatchHost, true))
|
|
33884
|
+
return written;
|
|
33885
|
+
}
|
|
33886
|
+
}
|
|
33887
|
+
const prose = decoded.replace(URL_CANDIDATE, (url2) => " ".repeat(url2.length));
|
|
33888
|
+
for (const match of prose.matchAll(BARE_IPV6)) {
|
|
33889
|
+
if (insideCluster(`[${match[1]}]`, dispatchHost, false))
|
|
33890
|
+
return match[0];
|
|
33891
|
+
}
|
|
33892
|
+
for (const match of prose.matchAll(BARE_HOST)) {
|
|
33893
|
+
const host = (match[1] ?? "").toLowerCase();
|
|
33894
|
+
if (!host.includes(".") && match[2] === undefined)
|
|
33895
|
+
continue;
|
|
33896
|
+
if (insideCluster(host, dispatchHost, false))
|
|
33897
|
+
return match[0];
|
|
33898
|
+
}
|
|
33899
|
+
return;
|
|
33900
|
+
}
|
|
33901
|
+
function hostOf(value) {
|
|
33902
|
+
if (value === undefined || value === "")
|
|
33903
|
+
return;
|
|
33904
|
+
try {
|
|
33905
|
+
return new URL(value).hostname.toLowerCase().replace(/\.$/, "") || undefined;
|
|
33906
|
+
} catch {
|
|
33907
|
+
return;
|
|
33908
|
+
}
|
|
33909
|
+
}
|
|
33910
|
+
function insideCluster(host, dispatchHost, fromUrl) {
|
|
33911
|
+
if (host === dispatchHost)
|
|
33912
|
+
return true;
|
|
33913
|
+
if (host.endsWith(".svc") || host.includes(".svc.") || host.endsWith(".cluster.local"))
|
|
33914
|
+
return true;
|
|
33915
|
+
const address = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
33916
|
+
const family = isIP(address);
|
|
33917
|
+
if (family !== 0)
|
|
33918
|
+
return CLUSTER_ADDRESSES.check(address, family === 4 ? "ipv4" : "ipv6");
|
|
33919
|
+
return fromUrl && !host.includes(".");
|
|
33920
|
+
}
|
|
33921
|
+
async function postSlackReply(reply, options = {}) {
|
|
33922
|
+
const env = options.env ?? process.env;
|
|
33923
|
+
const internal = linkInsideCluster(reply.text, env);
|
|
33924
|
+
if (internal !== undefined) {
|
|
33925
|
+
throw new Error(`the reply links ${internal}, an address inside the cluster nobody in Slack can open; link Dispatch's dashboard instead (dispatch prints it when DISPATCH_PUBLIC_URL is set); nothing was posted`);
|
|
33926
|
+
}
|
|
33927
|
+
const override = env[SLACK_POST_API_URL_VARIABLE];
|
|
33928
|
+
if (override !== undefined && override !== "" && !allowedAPIURL(override)) {
|
|
33929
|
+
throw new Error(`${SLACK_POST_API_URL_VARIABLE} must be an https URL, or http on a loopback host (got ${override}); nothing was posted`);
|
|
33930
|
+
}
|
|
33931
|
+
const { PATH: searchPath = "" } = env;
|
|
33932
|
+
const client = Bun.which("agent-secrets", { PATH: searchPath });
|
|
33933
|
+
if (client === null) {
|
|
33934
|
+
throw new Error("agent-secrets is not on PATH: slack_post takes the bot token from the secrets broker, which this session is not enrolled with; nothing was posted");
|
|
33935
|
+
}
|
|
33936
|
+
const reason = `slack_post: reply in Slack channel ${reply.channel}, thread ${reply.threadTs}`;
|
|
33937
|
+
const child = Bun.spawn([client, SLACK_BOT_TOKEN_SECRET, "--reason", reason, "--wait", "0s", "--", "bun", "-e", POSTER], {
|
|
33938
|
+
env,
|
|
33939
|
+
stdin: new Blob([
|
|
33940
|
+
JSON.stringify({ channel: reply.channel, thread_ts: reply.threadTs, text: reply.text })
|
|
33941
|
+
]),
|
|
33942
|
+
stdout: "pipe",
|
|
33943
|
+
stderr: "pipe"
|
|
33944
|
+
});
|
|
33945
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), POST_TIMEOUT_MS);
|
|
33946
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
33947
|
+
new Response(child.stdout).text(),
|
|
33948
|
+
new Response(child.stderr).text(),
|
|
33949
|
+
child.exited
|
|
33950
|
+
]).finally(() => clearTimeout(timer));
|
|
33951
|
+
const said = stderr.trim().split(`
|
|
33952
|
+
`).filter(Boolean).join(" ");
|
|
33953
|
+
const line = posterLine(stdout);
|
|
33954
|
+
if (line === undefined) {
|
|
33955
|
+
if (child.signalCode === "SIGKILL") {
|
|
33956
|
+
throw new Error(`slack_post got no answer within ${POST_TIMEOUT_MS / 1000} s: ${MAY_HAVE_POSTED}`);
|
|
33957
|
+
}
|
|
33958
|
+
if (said.startsWith("agent-secrets:")) {
|
|
33959
|
+
if (code === 75) {
|
|
33960
|
+
throw new Error(`the secrets broker has not granted ${SLACK_BOT_TOKEN_SECRET}: its request is waiting for approval (${said}); nothing was posted`);
|
|
33961
|
+
}
|
|
33962
|
+
if (code === 77) {
|
|
33963
|
+
throw new Error(`the secrets broker denied ${SLACK_BOT_TOKEN_SECRET} (${said}); nothing was posted`);
|
|
33964
|
+
}
|
|
33965
|
+
throw new Error(`the secrets broker did not release ${SLACK_BOT_TOKEN_SECRET} (${said}); nothing was posted`);
|
|
33966
|
+
}
|
|
33967
|
+
throw new Error(`slack_post's poster exited ${code} without an answer: ${MAY_HAVE_POSTED}`);
|
|
33968
|
+
}
|
|
33969
|
+
if (line.refused === null) {
|
|
33970
|
+
throw new Error("Slack refused chat.postMessage with an error code slack_post does not pass on");
|
|
33971
|
+
}
|
|
33972
|
+
if (line.refused !== undefined)
|
|
33973
|
+
throw new Error(`Slack refused chat.postMessage: ${line.refused}`);
|
|
33974
|
+
if (line.unanswered !== undefined) {
|
|
33975
|
+
throw new Error(`Slack did not answer chat.postMessage: ${MAY_HAVE_POSTED}`);
|
|
33976
|
+
}
|
|
33977
|
+
if (line.ts === undefined) {
|
|
33978
|
+
throw new Error(`chat.postMessage answered ${line.invalid ?? "nothing"}: ${MAY_HAVE_POSTED}`);
|
|
33979
|
+
}
|
|
33980
|
+
return { channel: line.channel ?? reply.channel, threadTs: reply.threadTs, ts: line.ts };
|
|
33981
|
+
}
|
|
33982
|
+
function posterLine(stdout) {
|
|
33983
|
+
const last = stdout.trim().split(`
|
|
33984
|
+
`).at(-1);
|
|
33985
|
+
if (last === undefined || last === "")
|
|
33986
|
+
return;
|
|
33987
|
+
try {
|
|
33988
|
+
const parsed = JSON.parse(last);
|
|
33989
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
33990
|
+
} catch {
|
|
33991
|
+
return;
|
|
33992
|
+
}
|
|
33993
|
+
}
|
|
33994
|
+
function allowedAPIURL(value) {
|
|
33995
|
+
let url2;
|
|
33996
|
+
try {
|
|
33997
|
+
url2 = new URL(value);
|
|
33998
|
+
} catch {
|
|
33999
|
+
return false;
|
|
34000
|
+
}
|
|
34001
|
+
if (url2.protocol === "https:")
|
|
34002
|
+
return true;
|
|
34003
|
+
return url2.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url2.hostname);
|
|
34004
|
+
}
|
|
34005
|
+
|
|
33701
34006
|
// ../envoy-client/src/tool-contract.ts
|
|
33702
34007
|
var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across topics; use id for dedupe and at for freshness.";
|
|
33703
34008
|
var TOPIC_GUIDE = "Topic guide (all are under notifications.): agent.<session_id> (subscribe: own inbox); role.<role> " + "(publish-to; holders claim via envoy_role_set). > matches one or more trailing tokens and does not " + "match the base subject. Envoy registers the concrete base when you subscribe to <subject>.>, so " + "the recommended default remains github.<owner>.<repo>.pr.<n>.>. <owner> and <repo> are one token " + "each, with every dot in the name written _ (sjawhar/.github is github.sjawhar._github; " + "acme/site.io is github.acme.site_io), and a topic spelled with the dot receives nothing. " + "Default PR subscription: " + "github.<owner>.<repo>.pr.<n>.> (it receives the quiet PR family): pr.<n> (lifecycle: " + "opened/synchronize/closed; closed carries merged, merge_commit_sha, merged_by, head_sha), " + "pr.<n>.comment, pr.<n>.review, pr.<n>.mention, pr.<n>.checks (one settlement event per commit whose checks settle, the head or not, naming its sha: " + 'passed/failed/cancelled/skipped with failing names and URLs; re-fires with superseded_settlement: "true" ' + "when new runs appear for the same commit). Other GitHub: issue.<n>, issue.<n>.comment, " + "issue.<n>.mention, mention, push.branch.<name>, push.tag.<name>, workflow.<file>.<action> (only " + "runs without an associated PR); slack.<team>.<channel>.message|mention and " + "slack.<team>.<channel>.thread.<ts>.message|mention; ghostwispr.<session>.<kind>; " + "whatsapp.<phone>.<jid>.<kind>; envoy.exceptions.<original-topic>.";
|
|
@@ -33735,7 +34040,8 @@ var EnvoyToolOperation = {
|
|
|
33735
34040
|
setRole: "setRole",
|
|
33736
34041
|
getRole: "getRole",
|
|
33737
34042
|
whoami: "whoami",
|
|
33738
|
-
listSessions: "listSessions"
|
|
34043
|
+
listSessions: "listSessions",
|
|
34044
|
+
slackPost: "slackPost"
|
|
33739
34045
|
};
|
|
33740
34046
|
var envoyToolSpecs = [
|
|
33741
34047
|
{
|
|
@@ -33806,6 +34112,16 @@ var envoyToolSpecs = [
|
|
|
33806
34112
|
title: schema.string().optional()
|
|
33807
34113
|
}),
|
|
33808
34114
|
operation: EnvoyToolOperation.listSessions
|
|
34115
|
+
},
|
|
34116
|
+
{
|
|
34117
|
+
name: "slack_post",
|
|
34118
|
+
description: "Reply in a Slack thread as the Legion Slack app, with the bot token the secrets broker grants this session (LEGION_SLACK_BOT_TOKEN); the session never holds it. channel is the Slack channel id (a mention's channel_id); thread_ts is the thread's root ts: the mention's thread_ts, or its own ts when it has none. Answers the new message's ts. A refusal carries Slack's own error code (not_in_channel: the app is not in that channel) or the broker's state (waiting for approval, denied). Sent once and never retried: after 'Slack did not answer' the reply may already be in the thread, so do not post it again.",
|
|
34119
|
+
arguments: (schema) => ({
|
|
34120
|
+
channel: schema.string({ min: 1 }).describe("Slack channel id, such as C0123ABCD."),
|
|
34121
|
+
thread_ts: schema.string({ min: 1 }).describe("The thread's root ts, which keeps the reply in the mention's thread."),
|
|
34122
|
+
text: schema.string({ min: 1, max: SLACK_POST_TEXT_MAX }).describe(`The reply, in Slack mrkdwn, at most ${SLACK_POST_TEXT_MAX} characters.`)
|
|
34123
|
+
}),
|
|
34124
|
+
operation: EnvoyToolOperation.slackPost
|
|
33809
34125
|
}
|
|
33810
34126
|
];
|
|
33811
34127
|
function sendConfirmationText(result) {
|
|
@@ -34070,7 +34386,7 @@ function sourceSession(sessionID) {
|
|
|
34070
34386
|
|
|
34071
34387
|
// ../pi-shared/src/interface.ts
|
|
34072
34388
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
34073
|
-
var ENVOY_PLUGIN_INTERFACE_VERSION =
|
|
34389
|
+
var ENVOY_PLUGIN_INTERFACE_VERSION = 3;
|
|
34074
34390
|
var ENVOY_PLUGIN_INTERFACE_KEY = Symbol.for("legion.pi-shared.envoy-plugin-interface");
|
|
34075
34391
|
var LEGION_PLUGIN_LOADED_KEY = Symbol.for("legion.pi-legion.loaded");
|
|
34076
34392
|
var LEGACY_LEGION_LOADED_KEY = Symbol.for("legion.pi-envoy.legion-loaded");
|
|
@@ -34084,7 +34400,12 @@ function envoyPluginInterface() {
|
|
|
34084
34400
|
const created = {
|
|
34085
34401
|
version: ENVOY_PLUGIN_INTERFACE_VERSION,
|
|
34086
34402
|
publishers: [],
|
|
34087
|
-
roleClaim: {
|
|
34403
|
+
roleClaim: {
|
|
34404
|
+
instances: [],
|
|
34405
|
+
managedSessions: new Set,
|
|
34406
|
+
regained: undefined,
|
|
34407
|
+
slackRoleOwned: false
|
|
34408
|
+
},
|
|
34088
34409
|
injectedUserTurns: new Map,
|
|
34089
34410
|
bootstrappedSession: { file: undefined }
|
|
34090
34411
|
};
|
|
@@ -35717,9 +36038,21 @@ function envoyExtension(pi) {
|
|
|
35717
36038
|
});
|
|
35718
36039
|
if (registry2 === undefined)
|
|
35719
36040
|
return;
|
|
36041
|
+
const retired = publishedInterface?.roleClaim.slackRoleOwned === true ? registry2.topics.filter((topic) => slackTopicChannel(topic) !== undefined) : [];
|
|
36042
|
+
if (retired.length > 0) {
|
|
36043
|
+
await client.unsubscribe({ sessionID, topics: retired }).catch((error48) => {
|
|
36044
|
+
logger3.warn("envoy: could not unregister a resumed controller's retired Slack topics", {
|
|
36045
|
+
sessionID,
|
|
36046
|
+
topics: retired,
|
|
36047
|
+
error: messageFor(error48)
|
|
36048
|
+
});
|
|
36049
|
+
});
|
|
36050
|
+
}
|
|
35720
36051
|
for (const topic of registry2.topics) {
|
|
35721
36052
|
if (topic === agentSubject(sessionID) || topic.startsWith(ROLE_TOPIC_PREFIX))
|
|
35722
36053
|
continue;
|
|
36054
|
+
if (retired.includes(topic))
|
|
36055
|
+
continue;
|
|
35723
36056
|
await subscribe(topic);
|
|
35724
36057
|
}
|
|
35725
36058
|
};
|
|
@@ -36287,6 +36620,14 @@ Not delivered: that holder is the session this subagent sends as, and the listen
|
|
|
36287
36620
|
title
|
|
36288
36621
|
});
|
|
36289
36622
|
}
|
|
36623
|
+
case EnvoyToolOperation.slackPost: {
|
|
36624
|
+
const posted = await postSlackReply({
|
|
36625
|
+
channel: stringFor(parameters, "channel"),
|
|
36626
|
+
threadTs: stringFor(parameters, "thread_ts"),
|
|
36627
|
+
text: stringFor(parameters, "text")
|
|
36628
|
+
});
|
|
36629
|
+
return toolSuccess(`posted ${posted.ts} in ${posted.channel}, thread ${posted.threadTs}`, { channel: posted.channel, thread_ts: posted.threadTs, ts: posted.ts });
|
|
36630
|
+
}
|
|
36290
36631
|
}
|
|
36291
36632
|
} catch (error48) {
|
|
36292
36633
|
return toolFailure(error48);
|
|
@@ -153,7 +153,7 @@ that GitHub issue or pull request. Only `dispatch issue` with `--external` creat
|
|
|
153
153
|
`dispatch issue --external owner/repo#n --project <project> --title '<title>'` before addressing it.
|
|
154
154
|
|
|
155
155
|
Create an issue for newly tracked work with `dispatch issue --project <KEY> --title '<title>'` (`dispatch issue --help` lists `--parent`, `--spec-file`, `--label`, `--priority` and the rest).
|
|
156
|
-
Labels are optional: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. `--priority` is yours on creation too — see [Priority is yours to set](skill://dispatch/references/issues.md). Set `--assignee` (the email of a person who has signed in to Dispatch) only when the human said who owns the work; otherwise the default above applies, so a child inherits its parent's assignee. It prints
|
|
156
|
+
Labels are optional: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. `--priority` is yours on creation too — see [Priority is yours to set](skill://dispatch/references/issues.md). Set `--assignee` (the email of a person who has signed in to Dispatch) only when the human said who owns the work; otherwise the default above applies, so a child inherits its parent's assignee. Set `--repo <owner/name>` when the issue targets a repository other than the one it would inherit: its parent's, else the project's default; the repository must be mapped to the project in Settings, and `dispatch read` shows the one the issue resolves to on its `Repository:` line. It prints
|
|
157
157
|
the new key; creating an issue does not subscribe you to it (see [Following](#following)). Use `dispatch issue` only to create an issue; never use it to park a question. When you pass a spec,
|
|
158
158
|
follow [Writing a spec](#writing-a-spec).
|
|
159
159
|
|
|
@@ -85,12 +85,13 @@ issues you own as well as the root. An issue left at `triage` while work is unde
|
|
|
85
85
|
Waiting for the deploy lane is not a status and is never announced.
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
# PATCH /api/v1/issues/{key} — status, title, labels, priority, external links (merged by URL, or cleared by an empty list), route, parent, blocked_by
|
|
88
|
+
# PATCH /api/v1/issues/{key} — status, title, labels, priority, external links (merged by URL, or cleared by an empty list), route, parent, blocked_by, repo
|
|
89
89
|
dispatch issue-update --issue PROJ-175 --status testing
|
|
90
90
|
dispatch issue-update --issue PROJ-175 --status done --reason 'Shipped in owner/repo#7; verified on the production dashboard.'
|
|
91
91
|
dispatch issue-update --issue PROJ-175 --priority 1 # 0–3; see Priority is yours to set
|
|
92
92
|
dispatch issue-update --issue PROJ-175 --external-link https://github.com/owner/repo/pull/7
|
|
93
93
|
dispatch issue-update --issue PROJ-175 --parent PROJ-170 # same-project key; --parent '' clears it
|
|
94
|
+
dispatch issue-update --issue PROJ-175 --repo acme/widgets # mapped to the project; --repo '' clears it
|
|
94
95
|
```
|
|
95
96
|
|
|
96
97
|
Closing takes a `--reason`, and the command refuses `--status done` without one: it posts the reason on
|
|
@@ -100,9 +101,16 @@ error names the posted message; after a timeout or a server error it also says t
|
|
|
100
101
|
landed, so read the issue's status first. A retry points its reason at the posted message rather
|
|
101
102
|
than repeating it.
|
|
102
103
|
|
|
103
|
-
The three clears differ: `--clear-priority` sends null, `--parent ''
|
|
104
|
-
empty string, and `--clear-external-links` / `--clear-blocked-by` send an
|
|
105
|
-
wrong shape is a refusal either way.
|
|
104
|
+
The three clears differ: `--clear-priority` sends null, `--parent ''`, `--route ''` and
|
|
105
|
+
`--repo ''` clear with an empty string, and `--clear-external-links` / `--clear-blocked-by` send an
|
|
106
|
+
empty list. Guessing the wrong shape is a refusal either way.
|
|
107
|
+
|
|
108
|
+
An issue targets one repository: its own `--repo`, else its nearest ancestor's, else the project's
|
|
109
|
+
default (set on the Settings page), else none. A repository not mapped to the issue's project is
|
|
110
|
+
refused `REPO_NOT_MAPPED`, naming it. `dispatch read` prints the result as
|
|
111
|
+
`Repository: owner/name (own)`, `Repository: owner/name (inherited or project default)`, or
|
|
112
|
+
`Repository: none`. `Repository: unknown (…)` means the Dispatch server is older than repository
|
|
113
|
+
fields: the read still works, and a `--repo` write there fails rather than drop the repository.
|
|
106
114
|
|
|
107
115
|
Link the pull request that delivers the issue with `--external-link` when you open it; the issue page
|
|
108
116
|
renders its state and checks from that link, and `dispatch read` lists it under `External links:`.
|
|
@@ -57,8 +57,9 @@ dispatch://PROJECT/artifact/<document-ref>/comment/<id>
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records. `dispatch read` also
|
|
60
|
-
accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server
|
|
61
|
-
`dispatch://` form above), and an
|
|
60
|
+
accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server, or on its public
|
|
61
|
+
dashboard when `DISPATCH_PUBLIC_URL` names one (it maps to the `dispatch://` form above), and an issue's read names its page
|
|
62
|
+
(`Link:`, on that public dashboard when there is one), the link to hand a person. An ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
|
|
62
63
|
full uuid. A non-uuid id on `GET /asks/{id}`, `/comments/{id}`, or `/issues/{key}/messages/{id}` is a 400 `ASK_ID_INPUT` /
|
|
63
64
|
`COMMENT_ID_INPUT` / `MESSAGE_ID_INPUT`, never a 500.
|
|
64
65
|
|
|
@@ -237,6 +237,12 @@ envoy_subscribe([
|
|
|
237
237
|
This follows every message and mention in one thread. Channel-level topics end in `.message` or
|
|
238
238
|
`.mention`; thread timestamps replace dots with underscores.
|
|
239
239
|
|
|
240
|
+
Reply in a thread with `slack_post(channel=<channel_id>, thread_ts=<the message's thread_ts, or
|
|
241
|
+
its ts>, text=…)`, which posts as the Legion Slack app with the bot token the secrets broker grants
|
|
242
|
+
the session (`LEGION_SLACK_BOT_TOKEN`). A refusal carries Slack's own error code, or the broker's
|
|
243
|
+
state when it has not granted the token. The post is sent once; when the answer is that Slack did
|
|
244
|
+
not answer, the reply may already be there, so do not post it again.
|
|
245
|
+
|
|
240
246
|
## Ghost Wispr
|
|
241
247
|
|
|
242
248
|
Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
|