@akagilnc/pi-workflow-roles 0.1.3758 → 0.1.3783
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/dist/acp-host/production-host.js +1735 -948
- package/dist/collector-config.js +0 -1
- package/dist/collector-github.js +199 -2
- package/dist/collector-identity.js +128 -41
- package/dist/collector-ledger.js +38 -9
- package/dist/collector-receipt.js +19 -7
- package/dist/collector-role.js +330 -369
- package/dist/collector-target.js +169 -0
- package/dist/collector-tool-schemas.js +51 -14
- package/dist/headless-host/description.js +77 -0
- package/dist/headless-host/mcp-relay.mjs +119 -0
- package/dist/headless-host/production-host.js +26590 -0
- package/dist/host-descriptions.js +44 -3
- package/dist/package-contracts/collector-output.js +32 -0
- package/dist/package-contracts/terminating-infrastructure.js +13 -12
- package/dist/pi/role-turn-host.js +1 -2
- package/dist/public-cli/github-remote.js +45 -0
- package/dist/public-cli/invocation.js +53 -54
- package/dist/public-cli/load-production-external-host.js +19 -0
- package/dist/public-cli/load-production-headless-host.js +36 -0
- package/dist/public-cli/main.js +857 -310
- package/dist/public-cli/option-definitions.js +6 -4
- package/dist/public-cli/run-lifecycle.js +3 -3
- package/dist/public-cli/settlement.js +83 -6
- package/dist/public-role-summons.js +40 -5
- package/dist/role-runtime.js +137 -7
- package/dist/submission-correctable-error.js +24 -0
- package/extensions/role-runtime.ts +0 -1
- package/package.json +1 -1
- package/scripts/build-package.mjs +28 -0
- package/src/acp-host/role-envelope.ts +146 -103
- package/src/acp-host/role-turn-host.ts +12 -0
- package/src/collector-config.ts +0 -1
- package/src/collector-github.ts +236 -2
- package/src/collector-identity.ts +148 -40
- package/src/collector-ledger.ts +48 -10
- package/src/collector-receipt.ts +33 -14
- package/src/collector-role.ts +376 -450
- package/src/collector-target.ts +207 -0
- package/src/collector-tool-schemas.ts +62 -15
- package/src/headless-host/description.ts +123 -0
- package/src/headless-host/production-host.ts +75 -0
- package/src/headless-host/role-turn-host.ts +424 -0
- package/src/host-contracts.ts +2 -1
- package/src/host-descriptions.ts +53 -6
- package/src/package-contracts/collector-output.ts +72 -0
- package/src/package-contracts/terminating-infrastructure.ts +24 -13
- package/src/pi/role-turn-host.ts +1 -2
- package/src/public-cli/cli.ts +12 -3
- package/src/public-cli/collector-run.ts +3 -2
- package/src/public-cli/github-remote.ts +45 -0
- package/src/public-cli/invocation.ts +60 -59
- package/src/public-cli/load-production-external-host.ts +29 -0
- package/src/public-cli/load-production-headless-host.ts +48 -0
- package/src/public-cli/main.ts +5 -0
- package/src/public-cli/option-definitions.ts +6 -4
- package/src/public-cli/run-lifecycle.ts +2 -2
- package/src/public-cli/settlement.ts +82 -6
- package/src/public-role-summons.ts +62 -9
- package/src/role-runtime.ts +166 -13
- package/src/submission-correctable-error.ts +38 -0
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
RoleTurnKnownFailure,
|
|
16
16
|
RoleTurnRequest,
|
|
17
17
|
} from "../host-contracts.ts";
|
|
18
|
-
import { packagedRoleInputFlag, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
|
|
18
|
+
import { packagedRoleInputFlag, packagedRoleOutputTool, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
|
|
19
19
|
import { stripSkillFrontmatter } from "../package-resources/method-skill.ts";
|
|
20
20
|
import {
|
|
21
21
|
createRoleRuntimeExtension,
|
|
@@ -27,12 +27,9 @@ import {
|
|
|
27
27
|
type AcpRoleTurnHostConfig,
|
|
28
28
|
} from "./role-turn-host.ts";
|
|
29
29
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
WorkerUnfinishedReasonReminderError,
|
|
34
|
-
} from "../submission-errors.ts";
|
|
35
|
-
import { isCorrectableExecuteError } from "../submission-correctable-error.ts";
|
|
30
|
+
isCorrectableExecuteError,
|
|
31
|
+
projectCorrectableExecuteRejection,
|
|
32
|
+
} from "../submission-correctable-error.ts";
|
|
36
33
|
import {
|
|
37
34
|
buildNavigatorInfrastructureFailureFact,
|
|
38
35
|
extractInfrastructureFailureEvidence,
|
|
@@ -98,7 +95,8 @@ export function projectAcpActivationFlags(request: RoleTurnRequest): Map<string,
|
|
|
98
95
|
}
|
|
99
96
|
if (activation.role === "collector") {
|
|
100
97
|
flags.set("ak-collector-repo", activation.repo);
|
|
101
|
-
|
|
98
|
+
// #676 D1: pr optional at admission; omit flag when role binds from materials.
|
|
99
|
+
if (activation.pr !== undefined) flags.set("ak-collector-pr", activation.pr);
|
|
102
100
|
if (activation.requestManifestPath !== undefined) flags.set("ak-collector-request-manifest", activation.requestManifestPath);
|
|
103
101
|
}
|
|
104
102
|
return flags;
|
|
@@ -133,10 +131,33 @@ export function createComposedAcpRoleTurnHost(
|
|
|
133
131
|
});
|
|
134
132
|
}
|
|
135
133
|
|
|
134
|
+
/** JSON Schema draft-07 document for host-native `--json-schema` (headless). */
|
|
135
|
+
export function terminatingToolJsonSchema(parameters: unknown): Readonly<Record<string, unknown>> {
|
|
136
|
+
const cloned = JSON.parse(JSON.stringify(parameters)) as Record<string, unknown>;
|
|
137
|
+
// $schema last so a newer declaration on the tool parameters cannot override draft-07.
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
...cloned,
|
|
140
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
export async function prepareAcpRoleEnvelope(options: {
|
|
137
145
|
readonly request: RoleTurnRequest;
|
|
138
146
|
readonly dependencies: RoleRuntimeDependencies;
|
|
147
|
+
/**
|
|
148
|
+
* MCP unix socket path for the protocol-relay child. Always required — AK tools
|
|
149
|
+
* ride this single MCP path; headless structured_output reuses the same
|
|
150
|
+
* terminating-tool ledger path without listing the terminating tool on MCP.
|
|
151
|
+
*/
|
|
139
152
|
readonly socketPath: string;
|
|
153
|
+
/**
|
|
154
|
+
* Whether MCP `tools/list` advertises the role terminating tool.
|
|
155
|
+
* ACP keeps it listed (session-tool receipt). Headless hides it so the host
|
|
156
|
+
* native `--json-schema` / structured_output is the sole schema channel
|
|
157
|
+
* (#750 submission-tool-is-schema-channel) and empty MCP probes cannot
|
|
158
|
+
* pre-empt a later structured_output.
|
|
159
|
+
*/
|
|
160
|
+
readonly listTerminatingToolOnMcp?: boolean;
|
|
140
161
|
/**
|
|
141
162
|
* Durable principal session path (header layout only).
|
|
142
163
|
* Production passes DurablePrincipalAuthority.decode(principal).sessionFile so
|
|
@@ -145,6 +166,14 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
145
166
|
readonly sessionFile?: string;
|
|
146
167
|
}): Promise<AcpPreparedTurn> {
|
|
147
168
|
const { request } = options;
|
|
169
|
+
if (options.socketPath === "") {
|
|
170
|
+
throw new Error("prepareAcpRoleEnvelope requires socketPath");
|
|
171
|
+
}
|
|
172
|
+
const listTerminatingToolOnMcp = options.listTerminatingToolOnMcp !== false;
|
|
173
|
+
const earlyTerminatingTool = packagedRoleOutputTool(request.activation.role);
|
|
174
|
+
if (earlyTerminatingTool === undefined) {
|
|
175
|
+
throw new Error(`role has no terminating tool: ${request.activation.role}`);
|
|
176
|
+
}
|
|
148
177
|
const flags = projectAcpActivationFlags(request);
|
|
149
178
|
const tools = new Map<string, HostToolDefinition>();
|
|
150
179
|
const handlers = new Map<string, Handler[]>();
|
|
@@ -405,6 +434,87 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
405
434
|
await emit("tool_execution_end", { toolCallId, toolName, isError: projected.isError });
|
|
406
435
|
return projected;
|
|
407
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Shared terminating/support tool path for MCP relay and headless structured_output.
|
|
439
|
+
* Books the call, runs execute, projects tool_result; does not emit turn_end (closeRound).
|
|
440
|
+
*/
|
|
441
|
+
async function invokeAkTool(name: string, args: unknown): Promise<{
|
|
442
|
+
content: ContentPart[];
|
|
443
|
+
isError: boolean;
|
|
444
|
+
blocked?: true;
|
|
445
|
+
}> {
|
|
446
|
+
const tool = tools.get(name);
|
|
447
|
+
if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
|
|
448
|
+
const toolCallId = randomUUID();
|
|
449
|
+
calls.push({ toolCallId, toolName: name });
|
|
450
|
+
// First-record-then-audit: book the tool-call leaf in memory before execute so
|
|
451
|
+
// judge/doctor subject gates see the candidate on parent session books.
|
|
452
|
+
sessionEntries.push({
|
|
453
|
+
type: "message",
|
|
454
|
+
message: {
|
|
455
|
+
role: "assistant" as const,
|
|
456
|
+
content: [{
|
|
457
|
+
type: "toolCall",
|
|
458
|
+
id: toolCallId,
|
|
459
|
+
name,
|
|
460
|
+
arguments: args ?? {},
|
|
461
|
+
}],
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
await emit("tool_execution_start", { toolCallId, toolName: name });
|
|
466
|
+
const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: (args ?? {}) as Record<string, unknown> }))
|
|
467
|
+
.some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
|
|
468
|
+
if (blocked) {
|
|
469
|
+
// Lawful seatbelt/block stays bare rejection — not infrastructure (#593 r3).
|
|
470
|
+
return { content: [{ type: "text", text: `AK tool blocked: ${name}` }], isError: true, blocked: true };
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
// Pre-execution emit failure shares the non-correctable infra pathway (#593 r3).
|
|
474
|
+
const declared = declareRoundInfrastructureFailure(error);
|
|
475
|
+
try {
|
|
476
|
+
const projected = await projectToolResult(toolCallId, name, {
|
|
477
|
+
content: declared.content,
|
|
478
|
+
details: declared.details,
|
|
479
|
+
isError: true,
|
|
480
|
+
});
|
|
481
|
+
return { content: projected.content, isError: true };
|
|
482
|
+
} catch {
|
|
483
|
+
return { content: declared.content, isError: true };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const result = await tool.execute(toolCallId, (args ?? {}) as never, undefined, undefined, context);
|
|
488
|
+
const projected = await projectToolResult(toolCallId, name, {
|
|
489
|
+
content: result.content,
|
|
490
|
+
details: result.details,
|
|
491
|
+
isError: false,
|
|
492
|
+
});
|
|
493
|
+
// Candidate only: seal waits for closeRound after the host round boundary.
|
|
494
|
+
return { content: projected.content, isError: projected.isError };
|
|
495
|
+
} catch (error) {
|
|
496
|
+
let content: ContentPart[];
|
|
497
|
+
let details: Record<string, unknown>;
|
|
498
|
+
if (isCorrectableExecuteError(error)) {
|
|
499
|
+
const projected = projectCorrectableExecuteRejection(error);
|
|
500
|
+
content = [{ type: "text", text: projected.diagnostic }];
|
|
501
|
+
details = projected.details;
|
|
502
|
+
} else {
|
|
503
|
+
// Slot-before-abort; projectToolResult may still project durable details.
|
|
504
|
+
({ content, details } = declareRoundInfrastructureFailure(error));
|
|
505
|
+
}
|
|
506
|
+
// The shared envelope's tool_result handler is the sole classifier:
|
|
507
|
+
// it projects either the structured submission non-pass (correctable
|
|
508
|
+
// rejection) or the typed infrastructure fact onto the reply.
|
|
509
|
+
const projected = await projectToolResult(toolCallId, name, {
|
|
510
|
+
content,
|
|
511
|
+
details,
|
|
512
|
+
isError: true,
|
|
513
|
+
});
|
|
514
|
+
return { content: projected.content, isError: projected.isError };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
408
518
|
function reply(socket: Socket, id: number, result?: unknown, error?: unknown): void {
|
|
409
519
|
const rpcError = error instanceof Error
|
|
410
520
|
? { code: "ak-relay-failure", name: error.name, message: error.message }
|
|
@@ -426,7 +536,9 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
426
536
|
if (rpc.token !== token) { reply(socket, rpc.id, undefined, "unauthorized relay"); return; }
|
|
427
537
|
try {
|
|
428
538
|
if (rpc.method === "tools/list") {
|
|
429
|
-
|
|
539
|
+
const listed = [...tools.values()].filter((tool) =>
|
|
540
|
+
listTerminatingToolOnMcp || tool.name !== earlyTerminatingTool);
|
|
541
|
+
reply(socket, rpc.id, { tools: listed.map((tool) => {
|
|
430
542
|
return { name: tool.name, description: tool.description, inputSchema: tool.parameters };
|
|
431
543
|
}) });
|
|
432
544
|
return;
|
|
@@ -435,107 +547,24 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
435
547
|
const params = rpc.params as ToolCallParams | undefined;
|
|
436
548
|
const name = params?.name;
|
|
437
549
|
if (typeof name !== "string") throw new Error("MCP tool name is missing");
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
// First-record-then-audit: book the tool-call leaf in memory before execute so
|
|
443
|
-
// judge/doctor subject gates see the candidate on parent session books.
|
|
444
|
-
{
|
|
445
|
-
const message = {
|
|
446
|
-
role: "assistant" as const,
|
|
447
|
-
content: [{
|
|
448
|
-
type: "toolCall",
|
|
449
|
-
id: toolCallId,
|
|
450
|
-
name,
|
|
451
|
-
arguments: params?.arguments ?? {},
|
|
452
|
-
}],
|
|
453
|
-
};
|
|
454
|
-
sessionEntries.push({ type: "message", message });
|
|
550
|
+
// Headless schema channel owns the terminating receipt — refuse MCP
|
|
551
|
+
// terminating calls so an empty probe cannot book a non-sealable candidate.
|
|
552
|
+
if (!listTerminatingToolOnMcp && name === earlyTerminatingTool) {
|
|
553
|
+
throw new Error(`terminating tool ${name} is schema-channel only on this host`);
|
|
455
554
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
.some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
|
|
460
|
-
if (blocked) {
|
|
461
|
-
// Lawful seatbelt/block stays bare RPC rejection — not infrastructure (#593 r3).
|
|
462
|
-
reply(socket, rpc.id, undefined, new Error(`AK tool blocked: ${name}`));
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
} catch (error) {
|
|
466
|
-
// Pre-execution emit failure (e.g. observation writer) shares the same
|
|
467
|
-
// non-correctable infra pathway as execute throws (#593 r3).
|
|
468
|
-
const declared = declareRoundInfrastructureFailure(error);
|
|
469
|
-
try {
|
|
470
|
-
const projected = await projectToolResult(toolCallId, name, {
|
|
471
|
-
content: declared.content,
|
|
472
|
-
details: declared.details,
|
|
473
|
-
isError: true,
|
|
474
|
-
});
|
|
475
|
-
reply(socket, rpc.id, {
|
|
476
|
-
content: projected.content,
|
|
477
|
-
isError: true,
|
|
478
|
-
});
|
|
479
|
-
} catch {
|
|
480
|
-
// Slot already filled; durable projection may have partially failed.
|
|
481
|
-
reply(socket, rpc.id, {
|
|
482
|
-
content: declared.content,
|
|
483
|
-
isError: true,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
555
|
+
const outcome = await invokeAkTool(name, params?.arguments ?? {});
|
|
556
|
+
if (outcome.blocked === true) {
|
|
557
|
+
reply(socket, rpc.id, undefined, new Error(outcome.content.map((p) => p.type === "text" ? p.text : "").join("")));
|
|
486
558
|
return;
|
|
487
559
|
}
|
|
488
|
-
|
|
489
|
-
const result = await tool.execute(toolCallId, (params?.arguments ?? {}) as never, undefined, undefined, context);
|
|
490
|
-
const projected = await projectToolResult(toolCallId, name, {
|
|
491
|
-
content: result.content,
|
|
492
|
-
details: result.details,
|
|
493
|
-
isError: false,
|
|
494
|
-
});
|
|
495
|
-
// Candidate only: do not emit turn_end here. Seal waits for the typed ACP
|
|
496
|
-
// round boundary (closeRound after session/prompt), so delayed siblings stay
|
|
497
|
-
// in the same round instead of becoming silent post-seal anomalies.
|
|
498
|
-
reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
|
|
499
|
-
} catch (error) {
|
|
500
|
-
let content: ContentPart[];
|
|
501
|
-
let details: Record<string, unknown>;
|
|
502
|
-
if (isCorrectableExecuteError(error)) {
|
|
503
|
-
const diagnostic = error instanceof Error ? error.message : String(error);
|
|
504
|
-
content = [{ type: "text", text: diagnostic }];
|
|
505
|
-
if (error instanceof GatekeeperDecisionError) {
|
|
506
|
-
details = { ...error.result };
|
|
507
|
-
} else if (
|
|
508
|
-
error instanceof WorkerCommitReminderError
|
|
509
|
-
|| error instanceof WorkerPrefixReminderError
|
|
510
|
-
|| error instanceof WorkerUnfinishedReasonReminderError
|
|
511
|
-
) {
|
|
512
|
-
details = { code: error.code };
|
|
513
|
-
} else if (typeof (error as unknown as { code?: unknown }).code === "string") {
|
|
514
|
-
details = { code: (error as unknown as { code: string }).code };
|
|
515
|
-
} else {
|
|
516
|
-
details = { code: error instanceof Error && error.name ? error.name : "correctable-submission-error" };
|
|
517
|
-
}
|
|
518
|
-
} else {
|
|
519
|
-
// Slot-before-abort; projectToolResult may still project durable details.
|
|
520
|
-
({ content, details } = declareRoundInfrastructureFailure(error));
|
|
521
|
-
}
|
|
522
|
-
// The shared envelope's tool_result handler is the sole classifier:
|
|
523
|
-
// it projects either the structured submission non-pass (correctable
|
|
524
|
-
// rejection) or the typed infrastructure fact onto the reply.
|
|
525
|
-
const projected = await projectToolResult(toolCallId, name, {
|
|
526
|
-
content,
|
|
527
|
-
details,
|
|
528
|
-
isError: true,
|
|
529
|
-
});
|
|
530
|
-
reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
|
|
531
|
-
}
|
|
560
|
+
reply(socket, rpc.id, { content: outcome.content, ...(outcome.isError ? { isError: true } : {}) });
|
|
532
561
|
} catch (error) { reply(socket, rpc.id, undefined, error); }
|
|
533
562
|
})();
|
|
534
563
|
}
|
|
535
564
|
});
|
|
536
565
|
}
|
|
537
|
-
await listen(server, options.socketPath);
|
|
538
566
|
const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
|
|
567
|
+
await listen(server, options.socketPath);
|
|
539
568
|
let disposed = false;
|
|
540
569
|
// Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
|
|
541
570
|
// AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
|
|
@@ -566,8 +595,6 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
566
595
|
} catch (error) {
|
|
567
596
|
cleanupFailures.push(error);
|
|
568
597
|
}
|
|
569
|
-
// Server closure is unconditional: a shutdown-handler failure must not leave
|
|
570
|
-
// the listening MCP server keeping the completed invocation alive.
|
|
571
598
|
try {
|
|
572
599
|
const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
|
|
573
600
|
if (typeof closeAll === "function") closeAll.call(server);
|
|
@@ -585,6 +612,14 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
585
612
|
}
|
|
586
613
|
};
|
|
587
614
|
|
|
615
|
+
const terminatingToolName: string = earlyTerminatingTool;
|
|
616
|
+
async function ingestStructuredOutput(params: unknown): Promise<void> {
|
|
617
|
+
// MCP path may already have invoked the terminating tool this round; skip the
|
|
618
|
+
// duplicate so structured_output + tool-call does not arm non-sole.
|
|
619
|
+
if (calls.some((call) => call.toolName === terminatingToolName)) return;
|
|
620
|
+
await invokeAkTool(terminatingToolName, params ?? {});
|
|
621
|
+
}
|
|
622
|
+
|
|
588
623
|
const closeRound: AcpPreparedTurn["closeRound"] = async () => {
|
|
589
624
|
// Typed round boundary: hand the complete call list to the shared ledger once.
|
|
590
625
|
if (calls.length > 0) {
|
|
@@ -668,6 +703,11 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
668
703
|
else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
|
|
669
704
|
runDirInjected = true;
|
|
670
705
|
|
|
706
|
+
const terminating = tools.get(terminatingToolName);
|
|
707
|
+
if (terminating === undefined) {
|
|
708
|
+
throw new Error(`terminating tool not registered after activation: ${terminatingToolName}`);
|
|
709
|
+
}
|
|
710
|
+
const jsonSchema = terminatingToolJsonSchema(terminating.parameters);
|
|
671
711
|
return {
|
|
672
712
|
mcpServers: [{
|
|
673
713
|
name: `ak-${request.activation.role}`,
|
|
@@ -683,6 +723,9 @@ export async function prepareAcpRoleEnvelope(options: {
|
|
|
683
723
|
abortSignal: hostAbort.signal,
|
|
684
724
|
closeRound,
|
|
685
725
|
dispose,
|
|
726
|
+
jsonSchema,
|
|
727
|
+
terminatingToolName,
|
|
728
|
+
ingestStructuredOutput,
|
|
686
729
|
};
|
|
687
730
|
} catch (error) {
|
|
688
731
|
// listen already succeeded; dispose is not yet caller-owned. Release the
|
|
@@ -43,6 +43,18 @@ export type AcpPreparedTurn = Readonly<{
|
|
|
43
43
|
| { readonly accepted: false; readonly failure: RoleTurnKnownFailure }
|
|
44
44
|
>;
|
|
45
45
|
dispose?(): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Headless CLI family (#645): role terminating-tool schema for host-native
|
|
48
|
+
* `--json-schema`. Present for every prepared turn; ACP ignores it.
|
|
49
|
+
*/
|
|
50
|
+
jsonSchema: Readonly<Record<string, unknown>>;
|
|
51
|
+
/** Terminating tool name whose schema is `jsonSchema`. */
|
|
52
|
+
terminatingToolName: string;
|
|
53
|
+
/**
|
|
54
|
+
* Headless CLI family: feed host-native `structured_output` through the same
|
|
55
|
+
* terminating-tool path the MCP relay uses (ledger + gates). ACP ignores it.
|
|
56
|
+
*/
|
|
57
|
+
ingestStructuredOutput(params: unknown): Promise<void>;
|
|
46
58
|
}>;
|
|
47
59
|
|
|
48
60
|
/** Fold structured system-prompt authority into the provider-visible ACP override. */
|
package/src/collector-config.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
export const COLLECTOR_HOST = "github.com" as const;
|
|
5
5
|
export const COLLECTOR_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
|
|
6
6
|
export const COLLECTOR_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
|
|
7
|
-
export const COLLECTOR_FIXED_KICKOFF = "采集目标已受理,本局开始。";
|
|
8
7
|
|
|
9
8
|
export type CollectorRepository = {
|
|
10
9
|
display: string;
|
package/src/collector-github.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
|
|
4
|
+
import { parseCollectorPrNumber } from "./collector-config.ts";
|
|
5
|
+
|
|
4
6
|
export type GitHubPullRequest = {
|
|
5
7
|
number: number;
|
|
6
8
|
state: string;
|
|
@@ -199,6 +201,232 @@ function parseJson(text: string, label: string): unknown {
|
|
|
199
201
|
}
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Shared PR-list payload parse for admission target lookup (#676 D1/D7).
|
|
206
|
+
* Every entry must carry a positive safe-integer number — malformed items fail
|
|
207
|
+
* the response (do not skip then claim uniqueness).
|
|
208
|
+
*/
|
|
209
|
+
export function parsePullRequestNumberList(raw: unknown, label: string): number[] {
|
|
210
|
+
if (!Array.isArray(raw)) {
|
|
211
|
+
throw new Error(`GitHub ${label} payload is not a list`);
|
|
212
|
+
}
|
|
213
|
+
const numbers: number[] = [];
|
|
214
|
+
for (const item of raw) {
|
|
215
|
+
if (!isRecord(item)) {
|
|
216
|
+
throw new Error(`GitHub ${label} payload contains a non-object pull request entry`);
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
numbers.push(parseCollectorPrNumber(item["number"]));
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw new Error(`GitHub ${label} payload contains an invalid pull request number`, {
|
|
222
|
+
cause: error,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return numbers;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Online PR association by head owner:ref (state=all). Caller supplies the real
|
|
231
|
+
* head owner from branch context — never assume base repository owner is the fork head.
|
|
232
|
+
* Transport/HTTP/JSON failures throw with true cause (not target-ambiguity wash).
|
|
233
|
+
*/
|
|
234
|
+
export async function listPullRequestNumbersByHead(
|
|
235
|
+
runner: GhApiRunner,
|
|
236
|
+
input: {
|
|
237
|
+
readonly owner: string;
|
|
238
|
+
readonly repo: string;
|
|
239
|
+
readonly headOwner: string;
|
|
240
|
+
readonly headRef: string;
|
|
241
|
+
readonly signal?: AbortSignal;
|
|
242
|
+
},
|
|
243
|
+
): Promise<number[]> {
|
|
244
|
+
const head = `${input.headOwner}:${input.headRef}`;
|
|
245
|
+
const path =
|
|
246
|
+
`/repos/${input.owner}/${input.repo}/pulls?head=${encodeURIComponent(head)}&state=all&per_page=100`;
|
|
247
|
+
const response = await runner(
|
|
248
|
+
["api", "--hostname", "github.com", "--include", "-X", "GET", path],
|
|
249
|
+
input.signal === undefined ? {} : { signal: input.signal },
|
|
250
|
+
);
|
|
251
|
+
if (response.status < 200 || response.status >= 300) {
|
|
252
|
+
throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
|
|
253
|
+
cause: {
|
|
254
|
+
endpoint: path,
|
|
255
|
+
status: response.status,
|
|
256
|
+
headers: response.headers,
|
|
257
|
+
body: response.bodyText,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Online PR association for a commit SHA (fork-safe; works when the commit is on the PR).
|
|
266
|
+
* Transport/HTTP/JSON failures throw with true cause.
|
|
267
|
+
*/
|
|
268
|
+
export async function listPullRequestNumbersByCommit(
|
|
269
|
+
runner: GhApiRunner,
|
|
270
|
+
input: {
|
|
271
|
+
readonly owner: string;
|
|
272
|
+
readonly repo: string;
|
|
273
|
+
readonly commitSha: string;
|
|
274
|
+
readonly signal?: AbortSignal;
|
|
275
|
+
},
|
|
276
|
+
): Promise<number[]> {
|
|
277
|
+
const path = `/repos/${input.owner}/${input.repo}/commits/${encodeURIComponent(input.commitSha)}/pulls`;
|
|
278
|
+
const response = await runner(
|
|
279
|
+
["api", "--hostname", "github.com", "--include", "-X", "GET", path],
|
|
280
|
+
input.signal === undefined ? {} : { signal: input.signal },
|
|
281
|
+
);
|
|
282
|
+
if (response.status < 200 || response.status >= 300) {
|
|
283
|
+
throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
|
|
284
|
+
cause: {
|
|
285
|
+
endpoint: path,
|
|
286
|
+
status: response.status,
|
|
287
|
+
headers: response.headers,
|
|
288
|
+
body: response.bodyText,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Online association for a structured ticket number (#676 D1):
|
|
297
|
+
* - Number is itself a pull request → that PR.
|
|
298
|
+
* - Number is an issue → PRs linked via timeline cross-reference / closed-by.
|
|
299
|
+
* Transport/HTTP/JSON failures throw with true cause.
|
|
300
|
+
* 404 / empty association → [].
|
|
301
|
+
*/
|
|
302
|
+
export async function listPullRequestNumbersByTicket(
|
|
303
|
+
runner: GhApiRunner,
|
|
304
|
+
input: {
|
|
305
|
+
readonly owner: string;
|
|
306
|
+
readonly repo: string;
|
|
307
|
+
readonly ticketNumber: number;
|
|
308
|
+
readonly signal?: AbortSignal;
|
|
309
|
+
},
|
|
310
|
+
): Promise<number[]> {
|
|
311
|
+
const issuePath = `/repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
|
|
312
|
+
const issueResponse = await runner(
|
|
313
|
+
["api", "--hostname", "github.com", "--include", "-X", "GET", issuePath],
|
|
314
|
+
input.signal === undefined ? {} : { signal: input.signal },
|
|
315
|
+
);
|
|
316
|
+
if (issueResponse.status === 404) return [];
|
|
317
|
+
if (issueResponse.status < 200 || issueResponse.status >= 300) {
|
|
318
|
+
throw new Error(`GitHub ${issuePath} failed with HTTP ${issueResponse.status}`, {
|
|
319
|
+
cause: {
|
|
320
|
+
endpoint: issuePath,
|
|
321
|
+
status: issueResponse.status,
|
|
322
|
+
headers: issueResponse.headers,
|
|
323
|
+
body: issueResponse.bodyText,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const issueRaw = parseJson(issueResponse.bodyText, issuePath);
|
|
328
|
+
if (!isRecord(issueRaw)) {
|
|
329
|
+
throw new Error(`GitHub ${issuePath} payload is not an object`);
|
|
330
|
+
}
|
|
331
|
+
// Issues endpoint returns PRs too — own-key pull_request means the number is the PR.
|
|
332
|
+
if (Object.hasOwn(issueRaw, "pull_request")) {
|
|
333
|
+
return [parseCollectorPrNumber(issueRaw["number"] ?? input.ticketNumber)];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Linked PRs: GraphQL closed-by + cross-referenced PR sources (existing gh runner seam).
|
|
337
|
+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
338
|
+
repository(owner: $owner, name: $repo) {
|
|
339
|
+
issue(number: $number) {
|
|
340
|
+
closedByPullRequestsReferences(first: 50) { nodes { number } }
|
|
341
|
+
timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
|
342
|
+
nodes {
|
|
343
|
+
__typename
|
|
344
|
+
... on CrossReferencedEvent {
|
|
345
|
+
source { ... on PullRequest { number } }
|
|
346
|
+
}
|
|
347
|
+
... on ConnectedEvent {
|
|
348
|
+
subject { ... on PullRequest { number } }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}`;
|
|
355
|
+
const args = [
|
|
356
|
+
"api",
|
|
357
|
+
"graphql",
|
|
358
|
+
"--hostname",
|
|
359
|
+
"github.com",
|
|
360
|
+
"--include",
|
|
361
|
+
"-f",
|
|
362
|
+
`query=${query}`,
|
|
363
|
+
"-f",
|
|
364
|
+
`owner=${input.owner}`,
|
|
365
|
+
"-f",
|
|
366
|
+
`repo=${input.repo}`,
|
|
367
|
+
"-F",
|
|
368
|
+
`number=${input.ticketNumber}`,
|
|
369
|
+
];
|
|
370
|
+
const gqlResponse = await runner(
|
|
371
|
+
args,
|
|
372
|
+
input.signal === undefined ? {} : { signal: input.signal },
|
|
373
|
+
);
|
|
374
|
+
if (gqlResponse.status < 200 || gqlResponse.status >= 300) {
|
|
375
|
+
throw new Error(`GitHub GraphQL issue→PR failed with HTTP ${gqlResponse.status}`, {
|
|
376
|
+
cause: {
|
|
377
|
+
endpoint: "graphql",
|
|
378
|
+
status: gqlResponse.status,
|
|
379
|
+
headers: gqlResponse.headers,
|
|
380
|
+
body: gqlResponse.bodyText,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
let payload: unknown;
|
|
385
|
+
try {
|
|
386
|
+
payload = JSON.parse(gqlResponse.bodyText);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
throw new Error("GitHub GraphQL issue→PR returned malformed JSON", { cause: error });
|
|
389
|
+
}
|
|
390
|
+
if (!isRecord(payload)) {
|
|
391
|
+
throw new Error("GitHub GraphQL issue→PR payload is not an object");
|
|
392
|
+
}
|
|
393
|
+
if (payload.errors !== undefined) {
|
|
394
|
+
throw new Error(`GitHub GraphQL issue→PR errors: ${JSON.stringify(payload.errors).slice(0, 600)}`, {
|
|
395
|
+
cause: { body: gqlResponse.bodyText, errors: payload.errors },
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const data = payload.data;
|
|
399
|
+
if (!isRecord(data)) return [];
|
|
400
|
+
const repository = data["repository"];
|
|
401
|
+
if (!isRecord(repository)) return [];
|
|
402
|
+
const issue = repository["issue"];
|
|
403
|
+
if (!isRecord(issue)) return [];
|
|
404
|
+
const numbers: number[] = [];
|
|
405
|
+
const closedBy = issue["closedByPullRequestsReferences"];
|
|
406
|
+
if (isRecord(closedBy) && Array.isArray(closedBy["nodes"])) {
|
|
407
|
+
for (const node of closedBy["nodes"]) {
|
|
408
|
+
if (isRecord(node) && typeof node["number"] === "number") {
|
|
409
|
+
numbers.push(parseCollectorPrNumber(node["number"]));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const timeline = issue["timelineItems"];
|
|
414
|
+
if (isRecord(timeline) && Array.isArray(timeline["nodes"])) {
|
|
415
|
+
for (const node of timeline["nodes"]) {
|
|
416
|
+
if (!isRecord(node)) continue;
|
|
417
|
+
const source = node["source"];
|
|
418
|
+
if (isRecord(source) && typeof source["number"] === "number") {
|
|
419
|
+
numbers.push(parseCollectorPrNumber(source["number"]));
|
|
420
|
+
}
|
|
421
|
+
const subject = node["subject"];
|
|
422
|
+
if (isRecord(subject) && typeof subject["number"] === "number") {
|
|
423
|
+
numbers.push(parseCollectorPrNumber(subject["number"]));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return [...new Set(numbers)];
|
|
428
|
+
}
|
|
429
|
+
|
|
202
430
|
let commentFailureEvidence = 0;
|
|
203
431
|
function commentFailureCause(error: unknown) {
|
|
204
432
|
return {
|
|
@@ -249,13 +477,19 @@ export function normalizePullRequest(raw: unknown): GitHubPullRequest {
|
|
|
249
477
|
throw new Error("GitHub pull request payload missing head.sha");
|
|
250
478
|
}
|
|
251
479
|
const number = requireNumber(raw["number"], "number");
|
|
252
|
-
|
|
480
|
+
// GitHub REST: merged PRs keep state="closed" and set merged/merged_at. Project the
|
|
481
|
+
// real merge fact so receipt/settlement can distinguish merged from merely closed (#676 D6).
|
|
482
|
+
const mergedFlag = raw["merged"] === true
|
|
483
|
+
|| (typeof raw["merged_at"] === "string" && raw["merged_at"].length > 0);
|
|
484
|
+
const rawState = requireString(raw["state"], "state").toUpperCase();
|
|
485
|
+
// After toUpperCase the only OPEN spelling is "OPEN"; keep MERGED vs CLOSED distinction.
|
|
486
|
+
const state = mergedFlag ? "MERGED" : rawState;
|
|
253
487
|
const htmlUrl = typeof raw["html_url"] === "string"
|
|
254
488
|
? raw["html_url"]
|
|
255
489
|
: `https://github.com/unknown/unknown/pull/${number}`;
|
|
256
490
|
return {
|
|
257
491
|
number,
|
|
258
|
-
state
|
|
492
|
+
state,
|
|
259
493
|
headOid: head["sha"],
|
|
260
494
|
...(typeof raw["updated_at"] === "string" ? { updatedAt: raw["updated_at"] } : {}),
|
|
261
495
|
url: htmlUrl,
|