@akagilnc/pi-workflow-roles 0.1.3749 → 0.1.3771
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/CLAUDE.md +4 -0
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/dist/acp-host/production-host.js +1389 -751
- 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/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/main.js +615 -148
- 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/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/souls/coder.md +11 -6
- package/souls/fixer.md +10 -9
- package/src/acp-host/role-envelope.ts +8 -22
- 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/host-contracts.ts +2 -1
- 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 +10 -1
- 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/option-definitions.ts +6 -4
- package/src/public-cli/run-lifecycle.ts +2 -2
- package/src/public-cli/settlement.ts +82 -6
- package/src/role-runtime.ts +166 -13
- package/src/submission-correctable-error.ts +38 -0
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,
|
|
@@ -2,8 +2,11 @@ import type {
|
|
|
2
2
|
GitHubMachineIdentity,
|
|
3
3
|
} from "./collector-github.ts";
|
|
4
4
|
import type { CollectorEvidenceRecord, HeadRelation } from "./collector-evidence.ts";
|
|
5
|
+
import type { CollectorSubmissionProjection } from "./package-contracts/collector-output.ts";
|
|
5
6
|
import { CorrectableSubmissionError } from "./submission-correctable-error.ts";
|
|
6
7
|
|
|
8
|
+
export type { CollectorSubmissionProjection };
|
|
9
|
+
|
|
7
10
|
export type CollectorMaterialRef = {
|
|
8
11
|
kind: "review" | "issue_comment" | "review_comment" | "reaction";
|
|
9
12
|
id: number;
|
|
@@ -21,7 +24,10 @@ export type CollectorMaterialRef = {
|
|
|
21
24
|
export type CollectorFinding = {
|
|
22
25
|
identity: GitHubMachineIdentity | null;
|
|
23
26
|
source: CollectorMaterialRef;
|
|
27
|
+
/** Short classification label; not the finding summary. */
|
|
24
28
|
category?: string;
|
|
29
|
+
/** Finding summary for the caller; not a body transcription. */
|
|
30
|
+
summary?: string;
|
|
25
31
|
pointer: {
|
|
26
32
|
repository: string;
|
|
27
33
|
prNumber: number;
|
|
@@ -30,6 +36,8 @@ export type CollectorFinding = {
|
|
|
30
36
|
authorLogin?: string;
|
|
31
37
|
kind: CollectorMaterialRef["kind"];
|
|
32
38
|
authoritativeTime?: string | null;
|
|
39
|
+
/** Corresponding commit when the evidence carries one. */
|
|
40
|
+
commitOid?: string | null;
|
|
33
41
|
};
|
|
34
42
|
};
|
|
35
43
|
|
|
@@ -121,9 +129,9 @@ export class CollectorUnknownEvidenceError extends CorrectableSubmissionError {
|
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
132
|
+
* Evidence-binding failure for a finding pointer that resolves to the wrong kind
|
|
133
|
+
* of stored record, lacks a GitHub locator, or has no identity group — not a
|
|
134
|
+
* free-shape gate on the submission envelope.
|
|
127
135
|
*/
|
|
128
136
|
export class CollectorFindingsValidationError extends CorrectableSubmissionError {
|
|
129
137
|
constructor(message: string) {
|
|
@@ -133,11 +141,45 @@ export class CollectorFindingsValidationError extends CorrectableSubmissionError
|
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
/**
|
|
136
|
-
* #
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
* #676 D6: non-OPEN targets keep collected materials and must not fire new review
|
|
145
|
+
* requests. Bounce the request as correctable so the seat can still seal output.
|
|
146
|
+
*/
|
|
147
|
+
export class CollectorNonOpenRequestError extends CorrectableSubmissionError {
|
|
148
|
+
constructor(prState: string) {
|
|
149
|
+
super(`通进司请求要求 OPEN 状态的 PR 快照;当前为 ${prState},不再触发新评审,请直接交回已有材料`);
|
|
150
|
+
this.name = "CollectorNonOpenRequestError";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function candidateRecord(candidate: unknown): Record<string, unknown> | undefined {
|
|
155
|
+
if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return candidate as Record<string, unknown>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Canonical top-level keys the collector output projection understands. */
|
|
162
|
+
const COLLECTOR_OUTPUT_CANONICAL_KEYS = new Set([
|
|
163
|
+
"findings",
|
|
164
|
+
"unfinishedReasons",
|
|
165
|
+
"infrastructureFailure",
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
/** Non-canonical own keys mean content was present but not under a projectable key. */
|
|
169
|
+
function hasNonCanonicalOwnKeys(record: Record<string, unknown>): boolean {
|
|
170
|
+
for (const key of Object.keys(record)) {
|
|
171
|
+
if (!COLLECTOR_OUTPUT_CANONICAL_KEYS.has(key)) return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* #641 chain① / #676: turn model-submitted finding pointer refs into receipt findings.
|
|
178
|
+
* Binds resolvable evidence references only — no pure shape rejection of the
|
|
179
|
+
* candidate envelope. Unknown refs, wrong kinds, missing GitHub locator, and
|
|
180
|
+
* missing identity group stay as binding failures. Unreadable findings content
|
|
181
|
+
* is not washed into "zero findings": projection facts record the gap so
|
|
182
|
+
* downstream can open the session 正本 (第 0 条 / #676 C).
|
|
141
183
|
*/
|
|
142
184
|
export function enrichCollectorFindings(input: {
|
|
143
185
|
candidate: unknown;
|
|
@@ -146,63 +188,129 @@ export function enrichCollectorFindings(input: {
|
|
|
146
188
|
targetHead: string;
|
|
147
189
|
repository: string;
|
|
148
190
|
prNumber: number;
|
|
149
|
-
}):
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
191
|
+
}): {
|
|
192
|
+
findingsSource: CollectorSubmissionProjection["findingsSource"];
|
|
193
|
+
findingsProjectedCount: number;
|
|
194
|
+
findingsUnprojected: boolean;
|
|
195
|
+
} {
|
|
196
|
+
// Candidate present but not a record — content existed, none projected (#676 C).
|
|
197
|
+
if (input.candidate !== undefined && input.candidate !== null && candidateRecord(input.candidate) === undefined) {
|
|
198
|
+
return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
|
|
199
|
+
}
|
|
200
|
+
const record = candidateRecord(input.candidate);
|
|
201
|
+
if (record === undefined) {
|
|
202
|
+
return { findingsSource: "absent", findingsProjectedCount: 0, findingsUnprojected: false };
|
|
154
203
|
}
|
|
155
|
-
|
|
156
|
-
|
|
204
|
+
if (!Object.hasOwn(record, "findings")) {
|
|
205
|
+
// Non-canonical top-level content is not "absent" — record the projection gap (#676 C).
|
|
206
|
+
if (hasNonCanonicalOwnKeys(record)) {
|
|
207
|
+
return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
|
|
208
|
+
}
|
|
209
|
+
return { findingsSource: "absent", findingsProjectedCount: 0, findingsUnprojected: false };
|
|
210
|
+
}
|
|
211
|
+
const rawFindings = record["findings"];
|
|
157
212
|
if (!Array.isArray(rawFindings)) {
|
|
158
|
-
|
|
213
|
+
// Key present but not an array — content exists, none projected. No shape bounce.
|
|
214
|
+
return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
|
|
159
215
|
}
|
|
160
|
-
|
|
216
|
+
|
|
217
|
+
const byEvidenceId = new Map(input.records.map((evidence) => [evidence.evidenceId, evidence]));
|
|
218
|
+
let projected = 0;
|
|
219
|
+
let unprojected = false;
|
|
161
220
|
for (const raw of rawFindings) {
|
|
162
221
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
163
|
-
|
|
222
|
+
unprojected = true;
|
|
223
|
+
continue;
|
|
164
224
|
}
|
|
165
|
-
const
|
|
225
|
+
const item = raw as Record<string, unknown>;
|
|
226
|
+
const evidenceId = item.evidenceId;
|
|
166
227
|
if (typeof evidenceId !== "string" || evidenceId.length === 0) {
|
|
167
|
-
|
|
228
|
+
// Present item without a bindable pointer — keep gap fact, do not fabricate.
|
|
229
|
+
unprojected = true;
|
|
230
|
+
continue;
|
|
168
231
|
}
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
232
|
+
const evidence = byEvidenceId.get(evidenceId);
|
|
233
|
+
if (evidence === undefined) {
|
|
171
234
|
throw new CollectorUnknownEvidenceError(evidenceId);
|
|
172
235
|
}
|
|
173
|
-
if (
|
|
174
|
-
throw new CollectorFindingsValidationError(`通进司 finding 指针指向不可承 finding 的证据种类 ${
|
|
236
|
+
if (evidence.kind !== "review" && evidence.kind !== "issue_comment" && evidence.kind !== "review_comment") {
|
|
237
|
+
throw new CollectorFindingsValidationError(`通进司 finding 指针指向不可承 finding 的证据种类 ${evidence.kind}`);
|
|
175
238
|
}
|
|
176
|
-
if (
|
|
239
|
+
if (evidence.githubId === undefined) {
|
|
177
240
|
throw new CollectorFindingsValidationError(`通进司 finding 指针证据 ${evidenceId} 缺少 GitHub id`);
|
|
178
241
|
}
|
|
179
|
-
const
|
|
180
|
-
if (category !== undefined && (typeof category !== "string" || category.trim().length === 0)) {
|
|
181
|
-
throw new CollectorFindingsValidationError("通进司 finding category 必须为非空字符串");
|
|
182
|
-
}
|
|
183
|
-
const identity = record.machineIdentity ?? null;
|
|
242
|
+
const identity = evidence.machineIdentity ?? null;
|
|
184
243
|
const group = input.groups.find((candidateGroup) => identityKey(candidateGroup.identity) === identityKey(identity));
|
|
185
244
|
if (group === undefined) {
|
|
186
245
|
throw new CollectorFindingsValidationError(`通进司 finding 指针证据 ${evidenceId} 无归属身份组`);
|
|
187
246
|
}
|
|
247
|
+
// Bound finding keeps pointer; non-string category/summary are unprojected field gaps
|
|
248
|
+
// (pointer success ≠ full content projection) — #676 C / 3939511832.
|
|
249
|
+
const category = item.category;
|
|
250
|
+
const summary = item.summary;
|
|
251
|
+
if (Object.hasOwn(item, "category") && typeof category !== "string") unprojected = true;
|
|
252
|
+
if (Object.hasOwn(item, "summary") && typeof summary !== "string") unprojected = true;
|
|
188
253
|
group.findings.push({
|
|
189
254
|
identity,
|
|
190
255
|
source: {
|
|
191
|
-
kind:
|
|
192
|
-
id:
|
|
193
|
-
evidenceId:
|
|
194
|
-
headRelation: headRelationFor(
|
|
256
|
+
kind: evidence.kind,
|
|
257
|
+
id: evidence.githubId,
|
|
258
|
+
evidenceId: evidence.evidenceId,
|
|
259
|
+
headRelation: headRelationFor(evidence, input.targetHead),
|
|
195
260
|
},
|
|
196
|
-
...(category ===
|
|
261
|
+
...(typeof category === "string" ? { category } : {}),
|
|
262
|
+
...(typeof summary === "string" ? { summary } : {}),
|
|
197
263
|
pointer: {
|
|
198
264
|
repository: input.repository,
|
|
199
265
|
prNumber: input.prNumber,
|
|
200
|
-
commentId:
|
|
201
|
-
...(
|
|
202
|
-
...(
|
|
203
|
-
kind:
|
|
204
|
-
authoritativeTime:
|
|
266
|
+
commentId: evidence.githubId,
|
|
267
|
+
...(evidence.htmlUrl === undefined ? {} : { htmlUrl: evidence.htmlUrl }),
|
|
268
|
+
...(evidence.authorLogin === undefined ? {} : { authorLogin: evidence.authorLogin }),
|
|
269
|
+
kind: evidence.kind,
|
|
270
|
+
authoritativeTime: evidence.authoritativeTime ?? null,
|
|
271
|
+
...(evidence.commitOid === undefined ? {} : { commitOid: evidence.commitOid }),
|
|
205
272
|
},
|
|
206
273
|
});
|
|
274
|
+
projected += 1;
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
findingsSource: "array",
|
|
278
|
+
findingsProjectedCount: projected,
|
|
279
|
+
findingsUnprojected: unprojected,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* #676 D6/C: optional unfinished reasons from the model submission.
|
|
285
|
+
* Project readable strings; record when original content could not fully project
|
|
286
|
+
* (do not wash unreadable unfinishedReasons into "none").
|
|
287
|
+
*/
|
|
288
|
+
export function extractCollectorUnfinishedReasons(candidate: unknown): {
|
|
289
|
+
reasons: string[] | undefined;
|
|
290
|
+
source: CollectorSubmissionProjection["unfinishedReasonsSource"];
|
|
291
|
+
unprojected: boolean;
|
|
292
|
+
} {
|
|
293
|
+
if (candidate !== undefined && candidate !== null && candidateRecord(candidate) === undefined) {
|
|
294
|
+
return { reasons: undefined, source: "unreadable", unprojected: true };
|
|
295
|
+
}
|
|
296
|
+
const record = candidateRecord(candidate);
|
|
297
|
+
if (record === undefined) {
|
|
298
|
+
return { reasons: undefined, source: "absent", unprojected: false };
|
|
207
299
|
}
|
|
300
|
+
if (!Object.hasOwn(record, "unfinishedReasons")) {
|
|
301
|
+
// unfinishedReasons absent is fine when only findings (or nothing) present.
|
|
302
|
+
// Non-canonical keys alone are recorded on the findings projection path.
|
|
303
|
+
return { reasons: undefined, source: "absent", unprojected: false };
|
|
304
|
+
}
|
|
305
|
+
const raw = record["unfinishedReasons"];
|
|
306
|
+
if (!Array.isArray(raw)) {
|
|
307
|
+
return { reasons: undefined, source: "unreadable", unprojected: true };
|
|
308
|
+
}
|
|
309
|
+
const reasons = raw.filter((item): item is string => typeof item === "string");
|
|
310
|
+
const unprojected = reasons.length !== raw.length;
|
|
311
|
+
return {
|
|
312
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
313
|
+
source: "array",
|
|
314
|
+
unprojected,
|
|
315
|
+
};
|
|
208
316
|
}
|
package/src/collector-ledger.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type GitHubPageDiagnostics,
|
|
26
26
|
type GitHubPullRequest,
|
|
27
27
|
} from "./collector-github.ts";
|
|
28
|
+
import { CollectorNonOpenRequestError } from "./collector-identity.ts";
|
|
28
29
|
import {
|
|
29
30
|
collectorObserveArgsSchema,
|
|
30
31
|
collectorOutputArgsSchema,
|
|
@@ -38,9 +39,12 @@ export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
|
|
|
38
39
|
export const COLLECTOR_READ_TOOL = "ak_collector_read";
|
|
39
40
|
export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
|
|
40
41
|
export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
|
|
42
|
+
/** #676 A: role-decided target bind — business tool, ledger-booked. */
|
|
43
|
+
export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
|
|
41
44
|
export { COLLECTOR_OUTPUT_TOOL };
|
|
42
45
|
|
|
43
46
|
export const COLLECTOR_OPERATIONAL_TOOLS = [
|
|
47
|
+
COLLECTOR_BIND_TARGET_TOOL,
|
|
44
48
|
COLLECTOR_OBSERVE_TOOL,
|
|
45
49
|
COLLECTOR_READ_TOOL,
|
|
46
50
|
COLLECTOR_REQUEST_TOOL,
|
|
@@ -157,7 +161,8 @@ export type CollectorTransportFailure = {
|
|
|
157
161
|
|
|
158
162
|
export type CollectorConfigState = {
|
|
159
163
|
repository: CollectorRepository;
|
|
160
|
-
|
|
164
|
+
/** Bound PR target; undefined until explicit flag, admission bind, or role bind-target tool. */
|
|
165
|
+
prNumber: number | undefined;
|
|
161
166
|
manifest: CollectorManifest;
|
|
162
167
|
};
|
|
163
168
|
|
|
@@ -195,6 +200,8 @@ export type CollectorLedger = {
|
|
|
195
200
|
assertNotFatal(): void;
|
|
196
201
|
recordActivation(clock: CollectorClock): void;
|
|
197
202
|
recordOutputCandidate(): void;
|
|
203
|
+
/** #676 A: role-decided unique PR bind (business fact on the ledger). */
|
|
204
|
+
bindTarget(prNumber: number): void;
|
|
198
205
|
beginOperational(toolName: string, toolCallId: string): void;
|
|
199
206
|
completeOperational(toolCallId: string): void;
|
|
200
207
|
noteCutoffObserved(): void;
|
|
@@ -326,6 +333,15 @@ export function createCollectorLedger(
|
|
|
326
333
|
const prIdentity = (pr: GitHubPullRequest): string =>
|
|
327
334
|
`${pr.state}|${pr.headOid}|${pr.updatedAt ?? ""}`;
|
|
328
335
|
|
|
336
|
+
const requireBoundPr = (): number => {
|
|
337
|
+
if (config.prNumber === undefined) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
"Collector PR target is unbound; call ak_collector_bind_target with the role-decided issue/PR or pass --pr",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return config.prNumber;
|
|
343
|
+
};
|
|
344
|
+
|
|
329
345
|
const fetchObserveSurfaces = async (
|
|
330
346
|
transport: CollectorGitHubTransport,
|
|
331
347
|
observedAt: string,
|
|
@@ -333,7 +349,7 @@ export function createCollectorLedger(
|
|
|
333
349
|
) => {
|
|
334
350
|
const owner = config.repository.owner;
|
|
335
351
|
const repo = config.repository.repo;
|
|
336
|
-
const prNumber =
|
|
352
|
+
const prNumber = requireBoundPr();
|
|
337
353
|
const signalOpt = signal === undefined ? {} : { signal };
|
|
338
354
|
const user = await transport.getAuthenticatedUser(signalOpt);
|
|
339
355
|
const prInitial = await transport.getPullRequest({
|
|
@@ -653,6 +669,26 @@ export function createCollectorLedger(
|
|
|
653
669
|
outputCandidate = true;
|
|
654
670
|
},
|
|
655
671
|
|
|
672
|
+
bindTarget(prNumber) {
|
|
673
|
+
assertNotFatal();
|
|
674
|
+
if (outputCandidate || pendingOutputCallId !== undefined) {
|
|
675
|
+
throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
|
|
676
|
+
}
|
|
677
|
+
if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
|
|
678
|
+
throw new Error("Collector bind target requires a positive safe-integer PR number");
|
|
679
|
+
}
|
|
680
|
+
if (config.prNumber !== undefined && config.prNumber !== prNumber) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`Collector target already bound to PR ${config.prNumber}; cannot rebind to ${prNumber}`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
config.prNumber = prNumber;
|
|
686
|
+
appendJournal("ak-collector-target-bound", {
|
|
687
|
+
prNumber,
|
|
688
|
+
repository: config.repository.canonical,
|
|
689
|
+
});
|
|
690
|
+
},
|
|
691
|
+
|
|
656
692
|
beginOperational(toolName, toolCallId) {
|
|
657
693
|
assertNotFatal();
|
|
658
694
|
if (
|
|
@@ -833,7 +869,7 @@ export function createCollectorLedger(
|
|
|
833
869
|
completedMono,
|
|
834
870
|
host: "github.com",
|
|
835
871
|
repository: config.repository.canonical,
|
|
836
|
-
prNumber:
|
|
872
|
+
prNumber: requireBoundPr(),
|
|
837
873
|
prState: pr.state,
|
|
838
874
|
headOid: pr.headOid,
|
|
839
875
|
complete: true,
|
|
@@ -875,10 +911,6 @@ export function createCollectorLedger(
|
|
|
875
911
|
if (activationTime === undefined || deadlineTime === undefined) {
|
|
876
912
|
throw latchFatal("通进司请求需要激活");
|
|
877
913
|
}
|
|
878
|
-
if (pastCutoff(clock)) {
|
|
879
|
-
finalObservationRequired = true;
|
|
880
|
-
throw latchFatal("通进司请求不在资格截止前");
|
|
881
|
-
}
|
|
882
914
|
if (ledger.unresolvedTransportFailure) {
|
|
883
915
|
throw latchFatal("通进司请求时存在未恢复的传输失败");
|
|
884
916
|
}
|
|
@@ -896,7 +928,12 @@ export function createCollectorLedger(
|
|
|
896
928
|
throw new Error("通进司请求要求最新完整快照");
|
|
897
929
|
}
|
|
898
930
|
if (snapshot.prState !== "OPEN") {
|
|
899
|
-
|
|
931
|
+
// #676 D6: non-OPEN keeps materials; bounce before cutoff fatal so post-deadline materials still seal.
|
|
932
|
+
throw new CollectorNonOpenRequestError(snapshot.prState);
|
|
933
|
+
}
|
|
934
|
+
if (pastCutoff(clock)) {
|
|
935
|
+
finalObservationRequired = true;
|
|
936
|
+
throw latchFatal("通进司请求不在资格截止前");
|
|
900
937
|
}
|
|
901
938
|
|
|
902
939
|
const { body, marker } = buildCollectorRequestBody({
|
|
@@ -918,9 +955,10 @@ export function createCollectorLedger(
|
|
|
918
955
|
);
|
|
919
956
|
}
|
|
920
957
|
|
|
958
|
+
const boundPr = requireBoundPr();
|
|
921
959
|
const attemptKey = [
|
|
922
960
|
config.repository.canonical,
|
|
923
|
-
String(
|
|
961
|
+
String(boundPr),
|
|
924
962
|
snapshot.headOid,
|
|
925
963
|
request.id,
|
|
926
964
|
].join("|");
|
|
@@ -951,7 +989,7 @@ export function createCollectorLedger(
|
|
|
951
989
|
const result = await transport.createIssueComment({
|
|
952
990
|
owner: config.repository.owner,
|
|
953
991
|
repo: config.repository.repo,
|
|
954
|
-
prNumber:
|
|
992
|
+
prNumber: boundPr,
|
|
955
993
|
body,
|
|
956
994
|
...(signal === undefined ? {} : { signal }),
|
|
957
995
|
});
|