@akagilnc/pi-workflow-roles 0.1.3783 → 0.1.3795
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 -1
- package/README.zh-CN.md +3 -1
- package/dist/acp-host/production-host.js +681 -269
- package/dist/atomic-write.js +23 -0
- package/dist/collector-evidence.js +4 -1
- package/dist/collector-github.js +1 -0
- package/dist/collector-handbook.js +142 -0
- package/dist/collector-identity.js +11 -0
- package/dist/collector-ledger.js +109 -63
- package/dist/collector-role.js +182 -18
- package/dist/collector-tool-schemas.js +40 -3
- package/dist/headless-host/production-host.js +682 -270
- package/dist/pi/role-turn-host.js +1 -0
- package/dist/public-cli/invocation.js +15 -0
- package/dist/public-cli/main.js +45 -77
- package/dist/public-cli/option-definitions.js +16 -2
- package/dist/public-cli/run-lifecycle.js +8 -0
- package/dist/role-runtime.js +3 -0
- package/extensions/role-runtime.ts +2 -0
- package/package.json +1 -1
- package/resources/collector-bot-handbook.md +33 -0
- package/src/acp-host/production-host.ts +4 -0
- package/src/acp-host/role-envelope.ts +1 -0
- package/src/collector-evidence.ts +6 -1
- package/src/collector-github.ts +3 -0
- package/src/collector-handbook.ts +194 -0
- package/src/collector-identity.ts +14 -0
- package/src/collector-ledger.ts +132 -80
- package/src/collector-role.ts +198 -14
- package/src/collector-tool-schemas.ts +42 -3
- package/src/host-contracts.ts +2 -0
- package/src/pi/role-turn-host.ts +1 -0
- package/src/public-cli/collector-run.ts +2 -0
- package/src/public-cli/invocation.ts +21 -0
- package/src/public-cli/option-definitions.ts +16 -2
- package/src/public-cli/run-lifecycle.ts +10 -0
- package/src/role-runtime.ts +5 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collector bot handbook — opaque working memory under the book topology.
|
|
3
|
+
* Runtime stores and delivers UTF-8 text only; never parses free text into
|
|
4
|
+
* code state rules (parent #673 D3 / #677).
|
|
5
|
+
*
|
|
6
|
+
* Placement reuses the machine ledger home (ADR 0048) when the admitted
|
|
7
|
+
* session already sits under books/<bookKey>/ — no parallel persistence frame.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { join, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { writeFileAtomically } from "./atomic-write.ts";
|
|
13
|
+
import {
|
|
14
|
+
activationBookDirectory,
|
|
15
|
+
assertLedgerFileInsideHome,
|
|
16
|
+
ensureRealDirectoryTree,
|
|
17
|
+
resolveActivationLedgerHomeForPath,
|
|
18
|
+
} from "./activation-ledger-topology.ts";
|
|
19
|
+
import { COLLECTOR_HANDBOOK_MAX_BYTES } from "./collector-tool-schemas.ts";
|
|
20
|
+
|
|
21
|
+
export type CollectorHandbookScope = "general" | "repo";
|
|
22
|
+
|
|
23
|
+
export type CollectorHandbookRead = {
|
|
24
|
+
readonly general: string;
|
|
25
|
+
readonly repo: string;
|
|
26
|
+
readonly generalSource: "book" | "seed" | "empty";
|
|
27
|
+
readonly repoSource: "book" | "empty";
|
|
28
|
+
readonly generalPath: string;
|
|
29
|
+
readonly repoPath: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CollectorHandbookWriteResult = {
|
|
33
|
+
readonly scope: CollectorHandbookScope;
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly byteLength: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CollectorHandbookStore = {
|
|
39
|
+
readonly root: string;
|
|
40
|
+
readonly repositoryCanonical: string;
|
|
41
|
+
read(): Promise<CollectorHandbookRead>;
|
|
42
|
+
write(scope: CollectorHandbookScope, body: string): Promise<CollectorHandbookWriteResult>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CollectorHandbookPlacement = {
|
|
46
|
+
readonly ledgerHome: string;
|
|
47
|
+
readonly bookKey: string;
|
|
48
|
+
readonly root: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derive handbook root from an admitted session/run path under
|
|
53
|
+
* `.ak-roles/books/<bookKey>/...`. Fails closed when topology is absent.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveCollectorHandbookRoot(sessionPath: string): CollectorHandbookPlacement {
|
|
56
|
+
if (typeof sessionPath !== "string" || sessionPath.trim().length === 0) {
|
|
57
|
+
throw new Error("通进司手册要求非空 session 路径,且位于 books/<bookKey>/");
|
|
58
|
+
}
|
|
59
|
+
// Platform path segments only — never rewrite non-separator characters (POSIX `\\` is literal).
|
|
60
|
+
const segments = sessionPath.split(sep);
|
|
61
|
+
let bookKey: string | undefined;
|
|
62
|
+
for (let i = 0; i + 3 < segments.length; i += 1) {
|
|
63
|
+
if (segments[i] === ".ak-roles" && segments[i + 1] === "books") {
|
|
64
|
+
const candidate = segments[i + 2]!;
|
|
65
|
+
// Require a following segment so bookKey is a true path component under books/.
|
|
66
|
+
if (candidate.length > 0) {
|
|
67
|
+
bookKey = candidate;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (bookKey === undefined) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`通进司手册要求 session 位于 books/<bookKey>/;收到 ${sessionPath}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (bookKey === "." || bookKey === "..") {
|
|
78
|
+
throw new Error(`通进司手册拒绝不安全 bookKey ${JSON.stringify(bookKey)}`);
|
|
79
|
+
}
|
|
80
|
+
const ledgerHome = resolveActivationLedgerHomeForPath(sessionPath);
|
|
81
|
+
const root = join(activationBookDirectory(ledgerHome, bookKey), "collector-handbook");
|
|
82
|
+
return { ledgerHome, bookKey, root };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Flat repo file name under handbook/repos/ — avoids nested owner/repo dirs. */
|
|
86
|
+
function collectorHandbookRepoFileName(repositoryCanonical: string): string {
|
|
87
|
+
if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(repositoryCanonical)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`通进司手册仓库文件要求规范 owner/repo,收到 ${JSON.stringify(repositoryCanonical)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return `${repositoryCanonical.replaceAll("/", "__")}.md`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createCollectorHandbookStore(input: {
|
|
96
|
+
readonly ledgerHome: string;
|
|
97
|
+
readonly handbookRoot: string;
|
|
98
|
+
readonly repositoryCanonical: string;
|
|
99
|
+
readonly seedGeneral?: string;
|
|
100
|
+
}): CollectorHandbookStore {
|
|
101
|
+
const generalPath = join(input.handbookRoot, "general.md");
|
|
102
|
+
const repoDir = join(input.handbookRoot, "repos");
|
|
103
|
+
const repoPath = join(repoDir, collectorHandbookRepoFileName(input.repositoryCanonical));
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ADR 0038: confine at the real read seam. Parent dirs must be physical under
|
|
107
|
+
* ledger home; the leaf must not be a pre-existing symlink (even in-home).
|
|
108
|
+
*/
|
|
109
|
+
/** Sole UTF-8 budget seam — write and read share COLLECTOR_HANDBOOK_MAX_BYTES. */
|
|
110
|
+
const assertHandbookBudget = (body: string, label: string): number => {
|
|
111
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
112
|
+
if (byteLength > COLLECTOR_HANDBOOK_MAX_BYTES) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`通进司手册${label} UTF-8 至多 ${COLLECTOR_HANDBOOK_MAX_BYTES} 字节,收到 ${byteLength}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return byteLength;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const readOptional = async (path: string, parentDir: string): Promise<string | undefined> => {
|
|
121
|
+
ensureRealDirectoryTree(input.ledgerHome, parentDir);
|
|
122
|
+
assertLedgerFileInsideHome(path, input.ledgerHome);
|
|
123
|
+
try {
|
|
124
|
+
const body = await readFile(path, "utf8");
|
|
125
|
+
assertHandbookBudget(body, "正文");
|
|
126
|
+
return body;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (isNotFound(error)) return undefined;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
root: input.handbookRoot,
|
|
135
|
+
repositoryCanonical: input.repositoryCanonical,
|
|
136
|
+
async read() {
|
|
137
|
+
const bookGeneral = await readOptional(generalPath, input.handbookRoot);
|
|
138
|
+
const bookRepo = await readOptional(repoPath, repoDir);
|
|
139
|
+
if (bookGeneral !== undefined) {
|
|
140
|
+
return {
|
|
141
|
+
general: bookGeneral,
|
|
142
|
+
repo: bookRepo ?? "",
|
|
143
|
+
generalSource: "book",
|
|
144
|
+
repoSource: bookRepo === undefined ? "empty" : "book",
|
|
145
|
+
generalPath,
|
|
146
|
+
repoPath,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const seed = input.seedGeneral;
|
|
150
|
+
if (typeof seed === "string" && seed.length > 0) {
|
|
151
|
+
assertHandbookBudget(seed, "正文");
|
|
152
|
+
return {
|
|
153
|
+
general: seed,
|
|
154
|
+
repo: bookRepo ?? "",
|
|
155
|
+
generalSource: "seed",
|
|
156
|
+
repoSource: bookRepo === undefined ? "empty" : "book",
|
|
157
|
+
generalPath,
|
|
158
|
+
repoPath,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
general: "",
|
|
163
|
+
repo: bookRepo ?? "",
|
|
164
|
+
generalSource: "empty",
|
|
165
|
+
repoSource: bookRepo === undefined ? "empty" : "book",
|
|
166
|
+
generalPath,
|
|
167
|
+
repoPath,
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async write(scope, body) {
|
|
171
|
+
// body string shape = collectorHandbookWriteArgsSchema; byte budget is business (UTF-8).
|
|
172
|
+
const byteLength = assertHandbookBudget(body, "正文");
|
|
173
|
+
ensureRealDirectoryTree(input.ledgerHome, input.handbookRoot);
|
|
174
|
+
const path = scope === "general" ? generalPath : repoPath;
|
|
175
|
+
if (scope === "repo") {
|
|
176
|
+
ensureRealDirectoryTree(input.ledgerHome, repoDir);
|
|
177
|
+
}
|
|
178
|
+
assertLedgerFileInsideHome(path, input.ledgerHome);
|
|
179
|
+
await writeFileAtomically(path, body);
|
|
180
|
+
return {
|
|
181
|
+
scope,
|
|
182
|
+
path,
|
|
183
|
+
byteLength,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isNotFound(error: unknown): boolean {
|
|
190
|
+
return typeof error === "object"
|
|
191
|
+
&& error !== null
|
|
192
|
+
&& "code" in error
|
|
193
|
+
&& (error as { code?: unknown }).code === "ENOENT";
|
|
194
|
+
}
|
|
@@ -151,6 +151,20 @@ export class CollectorNonOpenRequestError extends CorrectableSubmissionError {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** #678: wait window not open or already ended — bounce without latching fatal so materials still seal. */
|
|
155
|
+
export class CollectorWaitWindowClosedError extends CorrectableSubmissionError {
|
|
156
|
+
constructor(action: "request" | "wait" | "wait-before-open") {
|
|
157
|
+
super(
|
|
158
|
+
action === "request"
|
|
159
|
+
? "通进司请求不在资格截止前"
|
|
160
|
+
: action === "wait-before-open"
|
|
161
|
+
? "通进司等待需要先在工作步骤开启等待窗(新建 PR 用创建成功时刻;已有 PR 在触发阶段结束后开启)"
|
|
162
|
+
: "通进司等待不在资格截止前",
|
|
163
|
+
);
|
|
164
|
+
this.name = "CollectorWaitWindowClosedError";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
function candidateRecord(candidate: unknown): Record<string, unknown> | undefined {
|
|
155
169
|
if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
156
170
|
return undefined;
|
package/src/collector-ledger.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import Value from "typebox/value";
|
|
2
|
-
|
|
3
1
|
import type { CollectorManifest, CollectorRepository } from "./collector-config.ts";
|
|
4
2
|
import {
|
|
5
3
|
applyEvidenceVersionHistory,
|
|
6
4
|
assignWindowRelations,
|
|
7
|
-
|
|
5
|
+
COLLECTOR_DEFAULT_WAIT_WINDOW_MS,
|
|
8
6
|
measureNormalizedBytes,
|
|
9
7
|
normalizeAuthenticatedUserEvidence,
|
|
10
8
|
normalizeIssueCommentEvidence,
|
|
@@ -25,22 +23,22 @@ import {
|
|
|
25
23
|
type GitHubPageDiagnostics,
|
|
26
24
|
type GitHubPullRequest,
|
|
27
25
|
} from "./collector-github.ts";
|
|
28
|
-
import { CollectorNonOpenRequestError } from "./collector-identity.ts";
|
|
29
26
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
collectorRequestArgsSchema,
|
|
34
|
-
collectorWaitArgsSchema,
|
|
35
|
-
} from "./collector-tool-schemas.ts";
|
|
27
|
+
CollectorNonOpenRequestError,
|
|
28
|
+
CollectorWaitWindowClosedError,
|
|
29
|
+
} from "./collector-identity.ts";
|
|
36
30
|
import { COLLECTOR_OUTPUT_TOOL } from "./package-contracts/collector-output.ts";
|
|
37
31
|
|
|
38
32
|
export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
|
|
39
33
|
export const COLLECTOR_READ_TOOL = "ak_collector_read";
|
|
40
34
|
export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
|
|
41
35
|
export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
|
|
36
|
+
/** #678 D4: open wait window at a work step — business tool, ledger-booked. */
|
|
37
|
+
export const COLLECTOR_OPEN_WAIT_WINDOW_TOOL = "ak_collector_open_wait_window";
|
|
42
38
|
/** #676 A: role-decided target bind — business tool, ledger-booked. */
|
|
43
39
|
export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
|
|
40
|
+
/** #677: opaque handbook write — business tool, ledger-booked. */
|
|
41
|
+
export const COLLECTOR_HANDBOOK_WRITE_TOOL = "ak_collector_handbook_write";
|
|
44
42
|
export { COLLECTOR_OUTPUT_TOOL };
|
|
45
43
|
|
|
46
44
|
export const COLLECTOR_OPERATIONAL_TOOLS = [
|
|
@@ -48,7 +46,9 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
|
|
|
48
46
|
COLLECTOR_OBSERVE_TOOL,
|
|
49
47
|
COLLECTOR_READ_TOOL,
|
|
50
48
|
COLLECTOR_REQUEST_TOOL,
|
|
49
|
+
COLLECTOR_OPEN_WAIT_WINDOW_TOOL,
|
|
51
50
|
COLLECTOR_WAIT_TOOL,
|
|
51
|
+
COLLECTOR_HANDBOOK_WRITE_TOOL,
|
|
52
52
|
] as const;
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -57,7 +57,7 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
|
|
|
57
57
|
* (evidenceId + htmlUrl) and is preserved in the volume, never unconditionally
|
|
58
58
|
* transcribed into model context or receipt.
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
|
|
61
61
|
|
|
62
62
|
function utf8SafePrefix(text: string, maxBytes: number): string {
|
|
63
63
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
@@ -89,6 +89,8 @@ export type ObserveModelView = {
|
|
|
89
89
|
completedAt: string;
|
|
90
90
|
prState: string;
|
|
91
91
|
headOid: string;
|
|
92
|
+
/** PR create success time when known — new-PR wait-window startedAt (#678 D4). */
|
|
93
|
+
prCreatedAt?: string;
|
|
92
94
|
complete: boolean;
|
|
93
95
|
evidence: ObserveEvidenceEntry[];
|
|
94
96
|
requestAttempts: Array<{
|
|
@@ -108,7 +110,7 @@ export type ObserveModelView = {
|
|
|
108
110
|
* (collector-side; provider-visible observe details carry only this bounded
|
|
109
111
|
* projection), never unconditionally transcribed into model context.
|
|
110
112
|
*/
|
|
111
|
-
|
|
113
|
+
function projectObserveContextView(modelView: ObserveModelView): ObserveModelView {
|
|
112
114
|
return {
|
|
113
115
|
...modelView,
|
|
114
116
|
evidence: modelView.evidence.map((entry) =>
|
|
@@ -121,9 +123,9 @@ export function projectObserveContextView(modelView: ObserveModelView): ObserveM
|
|
|
121
123
|
export type CollectorOperationalTool = (typeof COLLECTOR_OPERATIONAL_TOOLS)[number];
|
|
122
124
|
|
|
123
125
|
export const COLLECTOR_ACTIVATION_ENTRY_TYPE = "ak-collector-activation" as const;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
const COLLECTOR_SNAPSHOT_ENTRY_TYPE = "ak-collector-snapshot" as const;
|
|
127
|
+
const COLLECTOR_REQUEST_ENTRY_TYPE = "ak-collector-request" as const;
|
|
128
|
+
const COLLECTOR_WAIT_ENTRY_TYPE = "ak-collector-wait" as const;
|
|
127
129
|
|
|
128
130
|
export type CollectorRequestAttempt = {
|
|
129
131
|
attemptId: string;
|
|
@@ -164,6 +166,15 @@ export type CollectorConfigState = {
|
|
|
164
166
|
/** Bound PR target; undefined until explicit flag, admission bind, or role bind-target tool. */
|
|
165
167
|
prNumber: number | undefined;
|
|
166
168
|
manifest: CollectorManifest;
|
|
169
|
+
/**
|
|
170
|
+
* Wait window duration (#678 D4). Default 10 minutes; caller-configurable without code change.
|
|
171
|
+
* Window opens at a work step (PR create success / trigger-phase end), not session activation.
|
|
172
|
+
*/
|
|
173
|
+
waitWindowMs: number;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export type CollectorLedgerConfigInput = Omit<CollectorConfigState, "waitWindowMs"> & {
|
|
177
|
+
waitWindowMs?: number;
|
|
167
178
|
};
|
|
168
179
|
|
|
169
180
|
/** Durable session journal sink owned by the shared execution seam. */
|
|
@@ -183,8 +194,11 @@ export type CollectorLedger = {
|
|
|
183
194
|
readonly fatal: boolean;
|
|
184
195
|
readonly fatalReason: string | undefined;
|
|
185
196
|
readonly outputCandidate: boolean;
|
|
197
|
+
/** Session ready (before_agent_start); not the wait window. */
|
|
186
198
|
readonly activationRecorded: boolean;
|
|
199
|
+
/** Wait-window start (receipt activationTime); undefined until openWaitWindow. */
|
|
187
200
|
readonly activationTime: Date | undefined;
|
|
201
|
+
/** Wait-window end; undefined until openWaitWindow. */
|
|
188
202
|
readonly deadlineTime: Date | undefined;
|
|
189
203
|
readonly activationMono: number | undefined;
|
|
190
204
|
readonly deadlineMono: number | undefined;
|
|
@@ -198,7 +212,13 @@ export type CollectorLedger = {
|
|
|
198
212
|
|
|
199
213
|
latchFatal(reason: string, cause?: unknown): Error;
|
|
200
214
|
assertNotFatal(): void;
|
|
215
|
+
/** Mark session ready for prep observe/request; does not open the wait window (#678). */
|
|
201
216
|
recordActivation(clock: CollectorClock): void;
|
|
217
|
+
/**
|
|
218
|
+
* Open the wait window once at a work step. Optional startedAt covers new-PR create success;
|
|
219
|
+
* omit for existing-PR trigger-phase end (= now). Second call is a no-op (D6: updates do not reset).
|
|
220
|
+
*/
|
|
221
|
+
openWaitWindow(clock: CollectorClock, options?: { startedAt?: Date }): void;
|
|
202
222
|
recordOutputCandidate(): void;
|
|
203
223
|
/** #676 A: role-decided unique PR bind (business fact on the ledger). */
|
|
204
224
|
bindTarget(prNumber: number): void;
|
|
@@ -217,7 +237,7 @@ export type CollectorLedger = {
|
|
|
217
237
|
}>;
|
|
218
238
|
|
|
219
239
|
request(
|
|
220
|
-
input: { requestId: string; snapshotId: string },
|
|
240
|
+
input: { requestId: string; snapshotId: string; body?: string },
|
|
221
241
|
transport: CollectorGitHubTransport,
|
|
222
242
|
clock: CollectorClock,
|
|
223
243
|
signal?: AbortSignal,
|
|
@@ -243,39 +263,31 @@ function isOperationalTool(name: string): name is CollectorOperationalTool {
|
|
|
243
263
|
return (COLLECTOR_OPERATIONAL_TOOLS as readonly string[]).includes(name);
|
|
244
264
|
}
|
|
245
265
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
): boolean {
|
|
251
|
-
// Residual envelope: reject missing/null args before schema check.
|
|
252
|
-
if (args === undefined || args === null) return false;
|
|
253
|
-
switch (name) {
|
|
254
|
-
case COLLECTOR_OBSERVE_TOOL:
|
|
255
|
-
return Value.Check(collectorObserveArgsSchema, args);
|
|
256
|
-
case COLLECTOR_READ_TOOL:
|
|
257
|
-
return Value.Check(collectorReadArgsSchema, args);
|
|
258
|
-
case COLLECTOR_REQUEST_TOOL:
|
|
259
|
-
return Value.Check(collectorRequestArgsSchema, args);
|
|
260
|
-
case COLLECTOR_WAIT_TOOL:
|
|
261
|
-
return Value.Check(collectorWaitArgsSchema, args);
|
|
262
|
-
case COLLECTOR_OUTPUT_TOOL:
|
|
263
|
-
return Value.Check(collectorOutputArgsSchema, args);
|
|
264
|
-
default:
|
|
265
|
-
return false;
|
|
266
|
+
function resolveWaitWindowMs(raw: number | undefined): number {
|
|
267
|
+
if (raw === undefined) return COLLECTOR_DEFAULT_WAIT_WINDOW_MS;
|
|
268
|
+
if (!Number.isSafeInteger(raw) || raw < 1) {
|
|
269
|
+
throw new Error("通进司 waitWindowMs 须为正安全整数毫秒");
|
|
266
270
|
}
|
|
271
|
+
return raw;
|
|
267
272
|
}
|
|
268
273
|
|
|
269
274
|
export function createCollectorLedger(
|
|
270
|
-
|
|
275
|
+
input: CollectorLedgerConfigInput,
|
|
271
276
|
options?: CollectorLedgerOptions,
|
|
272
277
|
): CollectorLedger {
|
|
278
|
+
const config: CollectorConfigState = {
|
|
279
|
+
repository: input.repository,
|
|
280
|
+
prNumber: input.prNumber,
|
|
281
|
+
manifest: input.manifest,
|
|
282
|
+
waitWindowMs: resolveWaitWindowMs(input.waitWindowMs),
|
|
283
|
+
};
|
|
273
284
|
const clock = options?.clock;
|
|
274
285
|
const journal = options?.journal;
|
|
275
286
|
let fatal = false;
|
|
276
287
|
let fatalReason: string | undefined;
|
|
277
288
|
let outputCandidate = false;
|
|
278
289
|
let pendingOutputCallId: string | undefined;
|
|
290
|
+
let sessionReady = false;
|
|
279
291
|
let activationTime: Date | undefined;
|
|
280
292
|
let deadlineTime: Date | undefined;
|
|
281
293
|
let activationMono: number | undefined;
|
|
@@ -326,7 +338,7 @@ export function createCollectorLedger(
|
|
|
326
338
|
};
|
|
327
339
|
|
|
328
340
|
const remainingMs = (clock: CollectorClock): number => {
|
|
329
|
-
if (deadlineMono === undefined) return
|
|
341
|
+
if (deadlineMono === undefined) return config.waitWindowMs;
|
|
330
342
|
return Math.max(0, deadlineMono - monoNowOrThrow(clock));
|
|
331
343
|
};
|
|
332
344
|
|
|
@@ -335,9 +347,7 @@ export function createCollectorLedger(
|
|
|
335
347
|
|
|
336
348
|
const requireBoundPr = (): number => {
|
|
337
349
|
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
|
-
);
|
|
350
|
+
throw new Error("通进司 PR 目标未绑定");
|
|
341
351
|
}
|
|
342
352
|
return config.prNumber;
|
|
343
353
|
};
|
|
@@ -411,7 +421,9 @@ export function createCollectorLedger(
|
|
|
411
421
|
};
|
|
412
422
|
|
|
413
423
|
const commitActivationWindow = (nextActivation: Date, nextDeadline: Date): void => {
|
|
424
|
+
// D6: once open, PR updates / second open must not reset the window.
|
|
414
425
|
if (activationTime !== undefined) return;
|
|
426
|
+
sessionReady = true;
|
|
415
427
|
activationTime = nextActivation;
|
|
416
428
|
deadlineTime = nextDeadline;
|
|
417
429
|
bindDeadlineMonoFromWall();
|
|
@@ -551,6 +563,8 @@ export function createCollectorLedger(
|
|
|
551
563
|
if (entry.customType === COLLECTOR_ACTIVATION_ENTRY_TYPE) {
|
|
552
564
|
if (typeof data.activationTime === "string" && typeof data.deadlineTime === "string") {
|
|
553
565
|
commitActivationWindow(new Date(data.activationTime), new Date(data.deadlineTime));
|
|
566
|
+
} else if (data.sessionReady === true) {
|
|
567
|
+
sessionReady = true;
|
|
554
568
|
}
|
|
555
569
|
continue;
|
|
556
570
|
}
|
|
@@ -612,7 +626,7 @@ export function createCollectorLedger(
|
|
|
612
626
|
return outputCandidate || pendingOutputCallId !== undefined;
|
|
613
627
|
},
|
|
614
628
|
get activationRecorded() {
|
|
615
|
-
return
|
|
629
|
+
return sessionReady;
|
|
616
630
|
},
|
|
617
631
|
get activationTime() {
|
|
618
632
|
return activationTime;
|
|
@@ -651,16 +665,38 @@ export function createCollectorLedger(
|
|
|
651
665
|
latchFatal,
|
|
652
666
|
assertNotFatal,
|
|
653
667
|
|
|
654
|
-
recordActivation(
|
|
668
|
+
recordActivation(_clock) {
|
|
669
|
+
assertNotFatal();
|
|
670
|
+
if (sessionReady) return;
|
|
671
|
+
sessionReady = true;
|
|
672
|
+
// Session ready only — wait window opens later at a work step (#678 D4).
|
|
673
|
+
appendJournal(COLLECTOR_ACTIVATION_ENTRY_TYPE, {
|
|
674
|
+
sessionReady: true,
|
|
675
|
+
});
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
openWaitWindow(clock, options) {
|
|
655
679
|
assertNotFatal();
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
680
|
+
if (!sessionReady) {
|
|
681
|
+
throw latchFatal("通进司开启等待窗需要先激活会话");
|
|
682
|
+
}
|
|
683
|
+
if (activationTime !== undefined) return; // D6: do not reset
|
|
684
|
+
const startedAt = options?.startedAt ?? clock.wallNow();
|
|
685
|
+
if (!(startedAt instanceof Date) || Number.isNaN(startedAt.getTime())) {
|
|
686
|
+
throw new Error("通进司等待窗 startedAt 须为有效时间");
|
|
687
|
+
}
|
|
688
|
+
const nextDeadline = new Date(startedAt.getTime() + config.waitWindowMs);
|
|
689
|
+
// Bind mono relative to current clock so a past startedAt shortens remaining correctly.
|
|
690
|
+
activationTime = startedAt;
|
|
691
|
+
deadlineTime = nextDeadline;
|
|
692
|
+
const wallNow = clock.wallNow().getTime();
|
|
693
|
+
const monoNow = clock.monoNow();
|
|
694
|
+
activationMono = monoNow - (wallNow - startedAt.getTime());
|
|
695
|
+
deadlineMono = monoNow + (nextDeadline.getTime() - wallNow);
|
|
661
696
|
appendJournal(COLLECTOR_ACTIVATION_ENTRY_TYPE, {
|
|
662
697
|
activationTime: activationTime.toISOString(),
|
|
663
698
|
deadlineTime: deadlineTime.toISOString(),
|
|
699
|
+
waitWindowMs: config.waitWindowMs,
|
|
664
700
|
});
|
|
665
701
|
},
|
|
666
702
|
|
|
@@ -675,11 +711,11 @@ export function createCollectorLedger(
|
|
|
675
711
|
throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
|
|
676
712
|
}
|
|
677
713
|
if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
|
|
678
|
-
throw new Error("
|
|
714
|
+
throw new Error("通进司绑定目标要求正安全整数 PR 号");
|
|
679
715
|
}
|
|
680
716
|
if (config.prNumber !== undefined && config.prNumber !== prNumber) {
|
|
681
717
|
throw new Error(
|
|
682
|
-
|
|
718
|
+
`通进司目标已绑定 PR ${config.prNumber},不可改绑为 ${prNumber}`,
|
|
683
719
|
);
|
|
684
720
|
}
|
|
685
721
|
config.prNumber = prNumber;
|
|
@@ -764,7 +800,7 @@ export function createCollectorLedger(
|
|
|
764
800
|
|
|
765
801
|
async observe(transport, clock, signal) {
|
|
766
802
|
assertNotFatal();
|
|
767
|
-
if (
|
|
803
|
+
if (!sessionReady) {
|
|
768
804
|
throw latchFatal("通进司观察需要激活");
|
|
769
805
|
}
|
|
770
806
|
if (signal?.aborted) {
|
|
@@ -872,6 +908,7 @@ export function createCollectorLedger(
|
|
|
872
908
|
prNumber: requireBoundPr(),
|
|
873
909
|
prState: pr.state,
|
|
874
910
|
headOid: pr.headOid,
|
|
911
|
+
...(pr.createdAt === undefined ? {} : { prCreatedAt: pr.createdAt }),
|
|
875
912
|
complete: true,
|
|
876
913
|
evidenceIds: storedIds,
|
|
877
914
|
pageDiagnostics,
|
|
@@ -908,16 +945,32 @@ export function createCollectorLedger(
|
|
|
908
945
|
|
|
909
946
|
async request(input, transport, clock, signal) {
|
|
910
947
|
assertNotFatal();
|
|
911
|
-
if (
|
|
948
|
+
if (!sessionReady) {
|
|
912
949
|
throw latchFatal("通进司请求需要激活");
|
|
913
950
|
}
|
|
914
951
|
if (ledger.unresolvedTransportFailure) {
|
|
915
952
|
throw latchFatal("通进司请求时存在未恢复的传输失败");
|
|
916
953
|
}
|
|
917
954
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
955
|
+
// Sole request identity seam: trim once, then use everywhere (lookup/marker/attemptKey).
|
|
956
|
+
// Schema pattern rejects leading/trailing whitespace at host; trim still collapses
|
|
957
|
+
// direct ledger callers and any residual padding into one stable id (#677 D2).
|
|
958
|
+
if (typeof input.requestId !== "string") {
|
|
959
|
+
throw new Error("通进司 requestId 须为字符串");
|
|
960
|
+
}
|
|
961
|
+
const requestId = input.requestId.trim();
|
|
962
|
+
if (requestId.length === 0) {
|
|
963
|
+
throw new Error("通进司 requestId 须为非空(首尾空白不构成身份)");
|
|
964
|
+
}
|
|
965
|
+
const configured = config.manifest.requests.find((item) => item.id === requestId);
|
|
966
|
+
const roleBody = typeof input.body === "string" ? input.body : undefined;
|
|
967
|
+
if (configured === undefined) {
|
|
968
|
+
// #677: role-decided trigger from handbook/field activity — body required when not in manifest.
|
|
969
|
+
if (roleBody === undefined || roleBody.trim().length === 0) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
`未知通进司 requestId "${requestId}" 且未提供 body;请提交角色判定的请求正文或使用 request-manifest`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
921
974
|
}
|
|
922
975
|
|
|
923
976
|
const snapshot = snapshots.find((item) => item.snapshotId === input.snapshotId);
|
|
@@ -933,13 +986,16 @@ export function createCollectorLedger(
|
|
|
933
986
|
}
|
|
934
987
|
if (pastCutoff(clock)) {
|
|
935
988
|
finalObservationRequired = true;
|
|
936
|
-
|
|
989
|
+
// #678: do not latch fatal — materials must still seal after the window ends.
|
|
990
|
+
throw new CollectorWaitWindowClosedError("request");
|
|
937
991
|
}
|
|
938
992
|
|
|
993
|
+
// Caller manifest body wins when requestId is configured; otherwise role body.
|
|
994
|
+
const configuredBody = configured?.requestBody ?? roleBody!;
|
|
939
995
|
const { body, marker } = buildCollectorRequestBody({
|
|
940
|
-
configuredBody
|
|
996
|
+
configuredBody,
|
|
941
997
|
manifestDigest: config.manifest.digest,
|
|
942
|
-
requestId
|
|
998
|
+
requestId,
|
|
943
999
|
headOid: snapshot.headOid,
|
|
944
1000
|
});
|
|
945
1001
|
const existingMarker = snapshot.evidenceIds.some((id) => {
|
|
@@ -951,7 +1007,7 @@ export function createCollectorLedger(
|
|
|
951
1007
|
});
|
|
952
1008
|
if (existingMarker) {
|
|
953
1009
|
throw new Error(
|
|
954
|
-
`通进司在此 HEAD 已有同 marker 的已认证请求 "${
|
|
1010
|
+
`通进司在此 HEAD 已有同 marker 的已认证请求 "${requestId}"`,
|
|
955
1011
|
);
|
|
956
1012
|
}
|
|
957
1013
|
|
|
@@ -960,11 +1016,11 @@ export function createCollectorLedger(
|
|
|
960
1016
|
config.repository.canonical,
|
|
961
1017
|
String(boundPr),
|
|
962
1018
|
snapshot.headOid,
|
|
963
|
-
|
|
1019
|
+
requestId,
|
|
964
1020
|
].join("|");
|
|
965
1021
|
if (attemptKeys.has(attemptKey)) {
|
|
966
1022
|
throw new Error(
|
|
967
|
-
`通进司进程内请求 "${
|
|
1023
|
+
`通进司进程内请求 "${requestId}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`,
|
|
968
1024
|
);
|
|
969
1025
|
}
|
|
970
1026
|
|
|
@@ -972,7 +1028,7 @@ export function createCollectorLedger(
|
|
|
972
1028
|
const attemptId = sha256Text(`${attemptKey}:${startedAt}`).slice(0, 16);
|
|
973
1029
|
const attempt: CollectorRequestAttempt = {
|
|
974
1030
|
attemptId,
|
|
975
|
-
requestId
|
|
1031
|
+
requestId,
|
|
976
1032
|
observedHead: snapshot.headOid,
|
|
977
1033
|
snapshotId: snapshot.snapshotId,
|
|
978
1034
|
marker,
|
|
@@ -1017,7 +1073,7 @@ export function createCollectorLedger(
|
|
|
1017
1073
|
return {
|
|
1018
1074
|
status: "succeeded",
|
|
1019
1075
|
attemptId,
|
|
1020
|
-
requestId
|
|
1076
|
+
requestId,
|
|
1021
1077
|
observedHead: snapshot.headOid,
|
|
1022
1078
|
marker,
|
|
1023
1079
|
commentEvidenceId: record.evidenceId,
|
|
@@ -1032,7 +1088,7 @@ export function createCollectorLedger(
|
|
|
1032
1088
|
return {
|
|
1033
1089
|
status: "ambiguous_loss",
|
|
1034
1090
|
attemptId,
|
|
1035
|
-
requestId
|
|
1091
|
+
requestId,
|
|
1036
1092
|
observedHead: snapshot.headOid,
|
|
1037
1093
|
marker,
|
|
1038
1094
|
diagnostics: result.diagnostics,
|
|
@@ -1048,30 +1104,23 @@ export function createCollectorLedger(
|
|
|
1048
1104
|
|
|
1049
1105
|
async wait(input, clock, signal) {
|
|
1050
1106
|
assertNotFatal();
|
|
1051
|
-
if (
|
|
1107
|
+
if (!sessionReady) {
|
|
1052
1108
|
throw latchFatal("通进司等待需要激活");
|
|
1053
1109
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
throw new Error(
|
|
1059
|
-
`通进司等待 durationMs 至多为 ${COLLECTOR_ELIGIBILITY_MS}`,
|
|
1060
|
-
);
|
|
1110
|
+
// #678: wait never invents a window start. Work-step open (create-success / trigger-end)
|
|
1111
|
+
// must be explicit — silent "now" would break the new-PR create-success clock.
|
|
1112
|
+
if (activationTime === undefined || deadlineTime === undefined) {
|
|
1113
|
+
throw new CollectorWaitWindowClosedError("wait-before-open");
|
|
1061
1114
|
}
|
|
1115
|
+
// durationMs shape authority = collectorWaitArgsSchema (host parameters).
|
|
1062
1116
|
if (pastCutoff(clock)) {
|
|
1063
1117
|
finalObservationRequired = true;
|
|
1064
|
-
throw
|
|
1118
|
+
throw new CollectorWaitWindowClosedError("wait");
|
|
1065
1119
|
}
|
|
1066
1120
|
|
|
1067
1121
|
const remaining = remainingMs(clock);
|
|
1068
|
-
//
|
|
1069
|
-
const
|
|
1070
|
-
const effectiveMs = Math.min(
|
|
1071
|
-
input.durationMs,
|
|
1072
|
-
remaining,
|
|
1073
|
-
COLLECTOR_SINGLE_WAIT_MAX_MS,
|
|
1074
|
-
);
|
|
1122
|
+
// Cap only by remaining window — no package-local single-wait ceiling (ADR 0035 / #678).
|
|
1123
|
+
const effectiveMs = Math.min(input.durationMs, remaining);
|
|
1075
1124
|
const startedAt = clock.wallNow().toISOString();
|
|
1076
1125
|
const waitId = sha256Text(`wait:${startedAt}:${effectiveMs}`).slice(0, 16);
|
|
1077
1126
|
await clock.sleep(effectiveMs, signal);
|
|
@@ -1163,6 +1212,9 @@ function buildObserveModelView(input: {
|
|
|
1163
1212
|
completedAt: input.snapshot.completedAt,
|
|
1164
1213
|
prState: input.snapshot.prState,
|
|
1165
1214
|
headOid: input.snapshot.headOid,
|
|
1215
|
+
...(input.snapshot.prCreatedAt === undefined
|
|
1216
|
+
? {}
|
|
1217
|
+
: { prCreatedAt: input.snapshot.prCreatedAt }),
|
|
1166
1218
|
complete: input.snapshot.complete,
|
|
1167
1219
|
evidence: relevant.map((record): ObserveEvidenceEntry => projectEvidenceEntryView(record)),
|
|
1168
1220
|
requestAttempts: input.attempts.map((attempt) => ({
|