@akagilnc/pi-workflow-roles 0.1.1751
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/LICENSE +202 -0
- package/README.md +104 -0
- package/README.zh-CN.md +133 -0
- package/THIRD_PARTY_NOTICES.md +60 -0
- package/dist/activation-ledger-git.js +68 -0
- package/dist/activation-ledger-session.js +120 -0
- package/dist/activation-ledger-topology.js +239 -0
- package/dist/activation-reconciliation.js +61 -0
- package/dist/audit-escalation.js +108 -0
- package/dist/auditor-dossier-tool.js +35 -0
- package/dist/canonical-json.js +78 -0
- package/dist/compliance-transport.js +77 -0
- package/dist/doctor-contracts.js +172 -0
- package/dist/dossier-resolution.js +103 -0
- package/dist/evidence-child-executor.js +661 -0
- package/dist/exact-utf8.js +12 -0
- package/dist/git-object-id.js +7 -0
- package/dist/in-process-session.js +50 -0
- package/dist/merger-contracts.js +76 -0
- package/dist/navigator-attendance.js +995 -0
- package/dist/navigator-invocation-identity.js +220 -0
- package/dist/open-tool-schema.js +39 -0
- package/dist/package-contracts/collector-output.js +50 -0
- package/dist/package-contracts/fixer-output.js +72 -0
- package/dist/package-contracts/fixer-packet.js +77 -0
- package/dist/package-contracts/judge-output.js +17 -0
- package/dist/package-contracts/reviewer-output.js +82 -0
- package/dist/package-contracts/terminating-tools.js +173 -0
- package/dist/package-contracts/worker-output.js +13 -0
- package/dist/package-owned-tool-idle.js +104 -0
- package/dist/packaged-role-registry.js +34 -0
- package/dist/public-cli/main.js +23867 -0
- package/dist/public-command-renderer.js +20 -0
- package/dist/reviewer-agent.js +93 -0
- package/dist/reviewer-child-executor.js +23 -0
- package/dist/reviewer-construction.js +95 -0
- package/dist/reviewer-dispatch.js +77 -0
- package/dist/reviewer-execution-ledger.js +160 -0
- package/dist/reviewer-failure-diagnostic.js +17 -0
- package/dist/reviewer-git-snapshot.js +38 -0
- package/dist/reviewer-pinned-git.js +146 -0
- package/dist/reviewer-preflight-error.js +15 -0
- package/dist/reviewer-prompt-identity.js +10 -0
- package/dist/reviewer-scope-prompt.js +21 -0
- package/dist/reviewer-workspace.js +151 -0
- package/dist/sha256.js +5 -0
- package/dist/sitian-record-entry.js +33 -0
- package/dist/stderr-jsonl.js +26 -0
- package/dist/stream-idle-guard.js +75 -0
- package/dist/tool-execution-observation.js +141 -0
- package/dist/uuidv7.js +21 -0
- package/dist/work-subject-identity.js +53 -0
- package/extensions/role-runtime.ts +303 -0
- package/package.json +69 -0
- package/packets/fixer-prerequisites.json +6 -0
- package/packets/fixer-repair.md +5 -0
- package/packets/judge-apply.md +77 -0
- package/packets/judge-authority.md +64 -0
- package/packets/judge-plan.md +55 -0
- package/packets/judge-review.md +49 -0
- package/packets/judge-submission.md +34 -0
- package/resources/methods/code-review/SKILL.md +92 -0
- package/resources/methods/code-review/agents/openai.yaml +3 -0
- package/resources/methods/code-review/provenance.json +26 -0
- package/resources/methods/diagnosing-bugs/SKILL.md +134 -0
- package/resources/methods/diagnosing-bugs/agents/openai.yaml +3 -0
- package/resources/methods/diagnosing-bugs/provenance.json +31 -0
- package/resources/methods/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
- package/resources/methods/resolving-merge-conflicts/SKILL.md +14 -0
- package/resources/methods/resolving-merge-conflicts/agents/openai.yaml +3 -0
- package/resources/methods/resolving-merge-conflicts/provenance.json +26 -0
- package/resources/methods/tdd/SKILL.md +38 -0
- package/resources/methods/tdd/agents/openai.yaml +3 -0
- package/resources/methods/tdd/mocking.md +59 -0
- package/resources/methods/tdd/provenance.json +36 -0
- package/resources/methods/tdd/tests.md +77 -0
- package/resources/navigator-route-playbook.md +32 -0
- package/schemas/tool-execution-observation.schema.json +107 -0
- package/scripts/build-package.mjs +65 -0
- package/scripts/generate-tool-execution-observation-schema.ts +7 -0
- package/souls/coder.md +10 -0
- package/souls/collector.md +11 -0
- package/souls/doctor-auditor.md +23 -0
- package/souls/doctor.md +8 -0
- package/souls/fixer-auditor.md +33 -0
- package/souls/fixer.md +13 -0
- package/souls/judge-auditor.md +33 -0
- package/souls/judge.md +74 -0
- package/souls/merger.md +5 -0
- package/souls/navigator.md +5 -0
- package/souls/reviewer-auditor.md +25 -0
- package/souls/reviewer.md +11 -0
- package/src/activation-ledger-git.ts +96 -0
- package/src/activation-ledger-session.ts +188 -0
- package/src/activation-ledger-topology.ts +301 -0
- package/src/activation-ledger.ts +240 -0
- package/src/activation-reconciliation.ts +163 -0
- package/src/activation-trace.ts +38 -0
- package/src/audit-escalation.ts +177 -0
- package/src/auditor-dossier-tool.ts +48 -0
- package/src/auditor-soul.ts +28 -0
- package/src/canonical-json.ts +74 -0
- package/src/canonical-skill-binding.ts +107 -0
- package/src/collector-config.ts +89 -0
- package/src/collector-evidence.ts +461 -0
- package/src/collector-github.ts +656 -0
- package/src/collector-identity.ts +161 -0
- package/src/collector-ledger.ts +827 -0
- package/src/collector-receipt.ts +87 -0
- package/src/collector-role.ts +592 -0
- package/src/collector-tool-schemas.ts +19 -0
- package/src/compliance-transport.ts +130 -0
- package/src/doctor-auditor.ts +53 -0
- package/src/doctor-contracts.ts +166 -0
- package/src/doctor-evidence.ts +47 -0
- package/src/doctor-role.ts +18 -0
- package/src/dossier-resolution.ts +137 -0
- package/src/evidence-child-executor.ts +775 -0
- package/src/exact-utf8.ts +9 -0
- package/src/factory-board.ts +1822 -0
- package/src/git-object-id.ts +11 -0
- package/src/human-format.ts +65 -0
- package/src/in-process-session.ts +78 -0
- package/src/judge-auditor.ts +55 -0
- package/src/judge-recording-anti-forge.ts +53 -0
- package/src/judge-role.ts +160 -0
- package/src/merger-contracts.ts +71 -0
- package/src/merger-git-state.ts +76 -0
- package/src/merger-role.ts +60 -0
- package/src/navigator-attendance.ts +1254 -0
- package/src/navigator-invocation-identity.ts +446 -0
- package/src/open-tool-schema.ts +46 -0
- package/src/package-contracts/collector-output.ts +109 -0
- package/src/package-contracts/fixer-output.ts +81 -0
- package/src/package-contracts/fixer-packet.ts +93 -0
- package/src/package-contracts/judge-output.ts +39 -0
- package/src/package-contracts/reviewer-output.ts +115 -0
- package/src/package-contracts/terminating-tools.ts +259 -0
- package/src/package-contracts/worker-output.ts +36 -0
- package/src/package-owned-tool-idle.ts +134 -0
- package/src/package-resources/method-skill-binding.ts +87 -0
- package/src/package-resources/method-skill.ts +358 -0
- package/src/packaged-role-registry.ts +36 -0
- package/src/public-cli/cli-errors.ts +11 -0
- package/src/public-cli/cli-io.ts +4 -0
- package/src/public-cli/cli.ts +912 -0
- package/src/public-cli/coder-run.ts +575 -0
- package/src/public-cli/collector-run.ts +375 -0
- package/src/public-cli/command-renderer.ts +8 -0
- package/src/public-cli/config.ts +346 -0
- package/src/public-cli/doctor-run.ts +355 -0
- package/src/public-cli/explicit-internal.ts +274 -0
- package/src/public-cli/fixer-run.ts +587 -0
- package/src/public-cli/host-pi-runtime.ts +112 -0
- package/src/public-cli/invocation.ts +1958 -0
- package/src/public-cli/judge-run.ts +507 -0
- package/src/public-cli/main.ts +15 -0
- package/src/public-cli/merger-run.ts +681 -0
- package/src/public-cli/public-run-credentials.ts +71 -0
- package/src/public-cli/registry.ts +153 -0
- package/src/public-cli/reviewer-run.ts +561 -0
- package/src/public-cli/run-lifecycle.ts +884 -0
- package/src/public-cli/settlement.ts +3765 -0
- package/src/public-cli/terminal.ts +325 -0
- package/src/public-command-renderer.ts +43 -0
- package/src/reviewer-agent.ts +94 -0
- package/src/reviewer-auditor.ts +53 -0
- package/src/reviewer-child-executor.ts +31 -0
- package/src/reviewer-construction.ts +137 -0
- package/src/reviewer-dispatch.ts +94 -0
- package/src/reviewer-execution-ledger.ts +206 -0
- package/src/reviewer-failure-diagnostic.ts +18 -0
- package/src/reviewer-git-snapshot.ts +53 -0
- package/src/reviewer-pinned-git.ts +144 -0
- package/src/reviewer-preflight-error.ts +14 -0
- package/src/reviewer-prompt-identity.ts +17 -0
- package/src/reviewer-role.ts +193 -0
- package/src/reviewer-scope-prompt.ts +24 -0
- package/src/reviewer-settlement.ts +63 -0
- package/src/reviewer-workspace.ts +111 -0
- package/src/role-runtime.ts +884 -0
- package/src/sha256.ts +6 -0
- package/src/sitian-record-entry.ts +57 -0
- package/src/stderr-jsonl.ts +28 -0
- package/src/stream-idle-guard.ts +98 -0
- package/src/ticket-snapshot.ts +662 -0
- package/src/ticket-trajectory.ts +1000 -0
- package/src/tool-execution-observation.ts +168 -0
- package/src/uuidv7.ts +1 -0
- package/src/work-subject-identity.ts +94 -0
- package/src/worker-role.ts +434 -0
- package/src/worker-submission-gates.ts +225 -0
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory board full view (S2+S3).
|
|
3
|
+
*
|
|
4
|
+
* Unique render seam: (books, view, now) → HTML.
|
|
5
|
+
* - books: explicit bookKey + ledgerDir (no git-remote guess)
|
|
6
|
+
* - view: BoardSnapshot success OR loud binding/api error
|
|
7
|
+
* - now: injected clock for unaccepted bands, leg age, wall-now rule
|
|
8
|
+
*
|
|
9
|
+
* Reuses S1 tracer (`loadTicketTrajectoryRuns` + station HTML) for each ticket.
|
|
10
|
+
* Owns per-book lanes, #162 yamen-column placement (ADR 0053 totality table),
|
|
11
|
+
* family aggregation + 已完成列 clusters, unknown badge, page filters, blocked
|
|
12
|
+
* badges, closed drill, and S3 latest-run state / wallclock / cost aggregation
|
|
13
|
+
* (no parallel receipt parser).
|
|
14
|
+
*
|
|
15
|
+
* Page lifecycle: startFactoryBoardPage owns refresh regeneration to an explicit
|
|
16
|
+
* output path outside every ledger and declares the bound; one-shot write does
|
|
17
|
+
* not advertise refresh. Snapshot bindings stay fixed; each tick re-reads ledgers.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
formatDurationZh,
|
|
25
|
+
formatLocalDateTime,
|
|
26
|
+
formatTokensCompact,
|
|
27
|
+
formatUsdPrecise,
|
|
28
|
+
} from "./human-format.ts";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_REFRESH_BOUNDARY_SECONDS,
|
|
31
|
+
loadTicketTrajectoryRuns,
|
|
32
|
+
renderTicketTrajectoryStationHtml,
|
|
33
|
+
type TicketTrajectoryRun,
|
|
34
|
+
type TrajectoryClock,
|
|
35
|
+
type TrajectoryScheduler,
|
|
36
|
+
} from "./ticket-trajectory.ts";
|
|
37
|
+
import type { BoardSnapshot, SnapshotTicket, TicketIssueState } from "./ticket-snapshot.ts";
|
|
38
|
+
|
|
39
|
+
/** Unaccepted latest-run mtime bands (page-visible thresholds). */
|
|
40
|
+
export const UNACCEPTED_FLYING_MS = 2 * 60 * 1000;
|
|
41
|
+
export const UNACCEPTED_WATCH_MS = 15 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/** Same declared refresh bound as the S1 trajectory surface (seconds). */
|
|
44
|
+
export { DEFAULT_REFRESH_BOUNDARY_SECONDS };
|
|
45
|
+
|
|
46
|
+
/** Injected clock + scheduler for the production board lifecycle. */
|
|
47
|
+
export type FactoryBoardClock = TrajectoryClock;
|
|
48
|
+
export type FactoryBoardScheduler = TrajectoryScheduler;
|
|
49
|
+
|
|
50
|
+
export type FactoryBoardPageHandle = {
|
|
51
|
+
readonly outputPath: string;
|
|
52
|
+
/** Settles when the first page write finishes (or rejects on first failure). */
|
|
53
|
+
readonly started: Promise<{ outputPath: string; html: string }>;
|
|
54
|
+
/**
|
|
55
|
+
* Settles when the lifecycle ends.
|
|
56
|
+
* Resolves on a clean stop with no regeneration fault; rejects with the
|
|
57
|
+
* original cause when a post-start regeneration fails (or the initial write fails).
|
|
58
|
+
*/
|
|
59
|
+
readonly closed: Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Stop further regeneration. In-flight write is awaited.
|
|
62
|
+
* Re-throws the original regeneration failure when the lifecycle faulted.
|
|
63
|
+
*/
|
|
64
|
+
stop: () => Promise<void>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Latest-run state bands (blocked is a badge, never a state; escalate is an awaiting overlay). */
|
|
68
|
+
export type TicketCurrentState =
|
|
69
|
+
| "closed"
|
|
70
|
+
| "pending"
|
|
71
|
+
| "unaccepted-flying"
|
|
72
|
+
| "unaccepted-watch"
|
|
73
|
+
| "unaccepted-suspect"
|
|
74
|
+
| "accepted-awaiting"
|
|
75
|
+
| "escalate-awaiting";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Yamen-column placement (#162 totality table, priority top-down):
|
|
79
|
+
* retained closed → done; open zero-run → pending; latest unknown → unknown set;
|
|
80
|
+
* latest judge → court (no identified non-judge run in full history) or marshal;
|
|
81
|
+
* latest coder → coder; fixer/reviewer → marshal; collector → collector;
|
|
82
|
+
* any other known station → non-resident `other:<station>` column.
|
|
83
|
+
* "unknown" never forms a column (badge + expand instead).
|
|
84
|
+
*/
|
|
85
|
+
export type BoardPlacement =
|
|
86
|
+
| "pending"
|
|
87
|
+
| "court"
|
|
88
|
+
| "coder"
|
|
89
|
+
| "marshal"
|
|
90
|
+
| "collector"
|
|
91
|
+
| "done"
|
|
92
|
+
| "unknown"
|
|
93
|
+
| `other:${string}`;
|
|
94
|
+
|
|
95
|
+
export type FactoryBoardBook = {
|
|
96
|
+
bookKey: string;
|
|
97
|
+
ledgerDir: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type FactoryBoardError = {
|
|
101
|
+
kind: "binding" | "api";
|
|
102
|
+
message: string;
|
|
103
|
+
bookKey?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type FactoryBoardView =
|
|
107
|
+
| { ok: true; snapshot: BoardSnapshot }
|
|
108
|
+
| { ok: false; error: FactoryBoardError };
|
|
109
|
+
|
|
110
|
+
type PreparedTicket = {
|
|
111
|
+
ticket: SnapshotTicket;
|
|
112
|
+
runs: TicketTrajectoryRun[];
|
|
113
|
+
/** Display runs with wallMs applying the latest-unaccepted→now rule. */
|
|
114
|
+
displayRuns: TicketTrajectoryRun[];
|
|
115
|
+
pending: boolean;
|
|
116
|
+
currentState: TicketCurrentState;
|
|
117
|
+
/** Incomplete blockers only (open blockers), for non-closed tickets. */
|
|
118
|
+
activeBlockedBy: number[];
|
|
119
|
+
costUsd: number;
|
|
120
|
+
totalTokens: number;
|
|
121
|
+
/** Cumulative construction wall (runs + axis legs). */
|
|
122
|
+
wallMs: number;
|
|
123
|
+
/** First run start → now (open) or issue closedAt (closed); never last ledger record. */
|
|
124
|
+
landingCycleMs: number;
|
|
125
|
+
/** Present only for unaccepted latest-run states. */
|
|
126
|
+
legAgeMs?: number;
|
|
127
|
+
lastActivityAt?: string;
|
|
128
|
+
lastActivityMtimeMs?: number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function formatUsd(value: number): string {
|
|
132
|
+
return Number.isFinite(value) ? String(value) : "0";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function wallMsBetween(startedAt: string | undefined, endedAtIso: string | undefined): number {
|
|
136
|
+
if (!startedAt || !endedAtIso) return 0;
|
|
137
|
+
const start = Date.parse(startedAt);
|
|
138
|
+
const end = Date.parse(endedAtIso);
|
|
139
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
|
|
140
|
+
return end - start;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sortRunsByStart(runs: readonly TicketTrajectoryRun[]): TicketTrajectoryRun[] {
|
|
144
|
+
return [...runs].sort((a, b) => {
|
|
145
|
+
const at = a.startedAt ?? "";
|
|
146
|
+
const bt = b.startedAt ?? "";
|
|
147
|
+
if (at !== bt) return at.localeCompare(bt);
|
|
148
|
+
return a.runId.localeCompare(b.runId);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function unacceptedBand(mtimeMs: number, nowMs: number): TicketCurrentState {
|
|
153
|
+
const age = Math.max(0, nowMs - mtimeMs);
|
|
154
|
+
// Authority: <2min flying / 2–15min watch / >15min suspect (exact 15 stays watch).
|
|
155
|
+
if (age < UNACCEPTED_FLYING_MS) return "unaccepted-flying";
|
|
156
|
+
if (age <= UNACCEPTED_WATCH_MS) return "unaccepted-watch";
|
|
157
|
+
return "unaccepted-suspect";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Current state is decided only by the latest run (session-start order).
|
|
162
|
+
* Historical unaccepted runs never flip the state; blocked is not a state.
|
|
163
|
+
*/
|
|
164
|
+
export function decideTicketCurrentState(input: {
|
|
165
|
+
ticketState: TicketIssueState;
|
|
166
|
+
runs: readonly TicketTrajectoryRun[];
|
|
167
|
+
now: Date;
|
|
168
|
+
}): TicketCurrentState {
|
|
169
|
+
if (input.ticketState === "closed") return "closed";
|
|
170
|
+
if (input.runs.length === 0) return "pending";
|
|
171
|
+
const latest = sortRunsByStart(input.runs).at(-1)!;
|
|
172
|
+
if (!latest.hasResult) return unacceptedBand(latest.mtimeMs, input.now.getTime());
|
|
173
|
+
// Escalate is an awaiting overlay: same placement and sort band, distinct state.
|
|
174
|
+
if (latest.resultStatus === "escalate") return "escalate-awaiting";
|
|
175
|
+
return "accepted-awaiting";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Yamen-column placement of one prepared ticket (mechanical, see BoardPlacement). */
|
|
179
|
+
function placeTicket(prepared: PreparedTicket): BoardPlacement {
|
|
180
|
+
if (prepared.ticket.state === "closed") return "done";
|
|
181
|
+
if (prepared.runs.length === 0) return "pending";
|
|
182
|
+
const sorted = sortRunsByStart(prepared.runs);
|
|
183
|
+
const latest = sorted.at(-1)!;
|
|
184
|
+
if (latest.station === "unknown") return "unknown";
|
|
185
|
+
if (latest.station === "judge") {
|
|
186
|
+
// Unknown history never counts as construction evidence.
|
|
187
|
+
const started = sorted.some((run) => run.station !== "unknown" && run.station !== "judge");
|
|
188
|
+
return started ? "marshal" : "court";
|
|
189
|
+
}
|
|
190
|
+
if (latest.station === "coder") return "coder";
|
|
191
|
+
if (latest.station === "fixer" || latest.station === "reviewer") return "marshal";
|
|
192
|
+
// ADR 0053: marshal-driven runs land in 刑部 once the seat ships (station maps now).
|
|
193
|
+
if (latest.station === "marshal") return "marshal";
|
|
194
|
+
if (latest.station === "collector") return "collector";
|
|
195
|
+
return `other:${latest.station}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Column-internal sort band: 在飞 → 观察 → 疑挂 → 已交卷(escalate 同档) → 待发 → 已完成. */
|
|
199
|
+
function stateSortBand(state: TicketCurrentState): number {
|
|
200
|
+
switch (state) {
|
|
201
|
+
case "unaccepted-flying":
|
|
202
|
+
return 0;
|
|
203
|
+
case "unaccepted-watch":
|
|
204
|
+
return 1;
|
|
205
|
+
case "unaccepted-suspect":
|
|
206
|
+
return 2;
|
|
207
|
+
case "accepted-awaiting":
|
|
208
|
+
case "escalate-awaiting":
|
|
209
|
+
return 3;
|
|
210
|
+
case "pending":
|
|
211
|
+
return 4;
|
|
212
|
+
case "closed":
|
|
213
|
+
return 5;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Last-activity sort key (desc): content activity first, then mtime; closed uses closedAt. */
|
|
218
|
+
function ticketSortActivityMs(prepared: PreparedTicket): number {
|
|
219
|
+
if (prepared.ticket.state === "closed") {
|
|
220
|
+
const parsed = prepared.ticket.closedAt ? Date.parse(prepared.ticket.closedAt) : NaN;
|
|
221
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
222
|
+
}
|
|
223
|
+
const latest = sortRunsByStart(prepared.runs).at(-1);
|
|
224
|
+
if (!latest) return 0;
|
|
225
|
+
const activity = latest.lastActivityAt ?? latest.endedAt;
|
|
226
|
+
const parsed = activity ? Date.parse(activity) : NaN;
|
|
227
|
+
return Number.isFinite(parsed) ? parsed : latest.mtimeMs;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function enrichRunsForBoard(
|
|
231
|
+
runs: readonly TicketTrajectoryRun[],
|
|
232
|
+
currentState: TicketCurrentState,
|
|
233
|
+
now: Date,
|
|
234
|
+
): TicketTrajectoryRun[] {
|
|
235
|
+
const sorted = sortRunsByStart(runs);
|
|
236
|
+
const latest = sorted.at(-1);
|
|
237
|
+
const extendLatest =
|
|
238
|
+
latest !== undefined &&
|
|
239
|
+
!latest.hasResult &&
|
|
240
|
+
(currentState === "unaccepted-flying" ||
|
|
241
|
+
currentState === "unaccepted-watch" ||
|
|
242
|
+
currentState === "unaccepted-suspect");
|
|
243
|
+
const nowIso = now.toISOString();
|
|
244
|
+
return sorted.map((run) => {
|
|
245
|
+
const isLatestUnaccepted = extendLatest && run.runId === latest!.runId;
|
|
246
|
+
const endIso = isLatestUnaccepted ? nowIso : run.endedAt;
|
|
247
|
+
const wallMs = wallMsBetween(run.startedAt, endIso);
|
|
248
|
+
return { ...run, wallMs };
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function aggregateTicketMetrics(
|
|
253
|
+
displayRuns: readonly TicketTrajectoryRun[],
|
|
254
|
+
ticket: Pick<SnapshotTicket, "state" | "closedAt">,
|
|
255
|
+
now: Date,
|
|
256
|
+
): { costUsd: number; totalTokens: number; wallMs: number; landingCycleMs: number } {
|
|
257
|
+
let costUsd = 0;
|
|
258
|
+
let totalTokens = 0;
|
|
259
|
+
let wallMs = 0;
|
|
260
|
+
let firstStart: string | undefined;
|
|
261
|
+
for (const run of displayRuns) {
|
|
262
|
+
costUsd += run.costUsd;
|
|
263
|
+
totalTokens += run.totalTokens;
|
|
264
|
+
wallMs += (run.wallMs ?? wallMsBetween(run.startedAt, run.endedAt)) + run.axisWallMs;
|
|
265
|
+
if (run.startedAt && (firstStart === undefined || run.startedAt < firstStart)) {
|
|
266
|
+
firstStart = run.startedAt;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let landingCycleMs = 0;
|
|
270
|
+
if (firstStart) {
|
|
271
|
+
if (ticket.state === "closed") {
|
|
272
|
+
// Authority (#136): 落地周期 = 首 run 起点至 now/关票 — closure, not last ledger record.
|
|
273
|
+
if (!ticket.closedAt) {
|
|
274
|
+
throw new Error("closed ticket landing cycle requires closedAt");
|
|
275
|
+
}
|
|
276
|
+
landingCycleMs = wallMsBetween(firstStart, ticket.closedAt);
|
|
277
|
+
} else {
|
|
278
|
+
landingCycleMs = wallMsBetween(firstStart, now.toISOString());
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { costUsd, totalTokens, wallMs, landingCycleMs };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function currentStateLabel(state: TicketCurrentState): string {
|
|
285
|
+
switch (state) {
|
|
286
|
+
case "closed":
|
|
287
|
+
return "已完成";
|
|
288
|
+
case "pending":
|
|
289
|
+
return "待发";
|
|
290
|
+
case "unaccepted-flying":
|
|
291
|
+
return "在飞";
|
|
292
|
+
case "unaccepted-watch":
|
|
293
|
+
return "观察";
|
|
294
|
+
case "unaccepted-suspect":
|
|
295
|
+
return "疑挂";
|
|
296
|
+
case "accepted-awaiting":
|
|
297
|
+
return "已交卷待派";
|
|
298
|
+
case "escalate-awaiting":
|
|
299
|
+
return "escalate 待裁";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
304
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isMissingPathError(error: unknown): boolean {
|
|
308
|
+
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function escapeHtml(text: string): string {
|
|
312
|
+
return text
|
|
313
|
+
.replaceAll("&", "&")
|
|
314
|
+
.replaceAll("<", "<")
|
|
315
|
+
.replaceAll(">", ">")
|
|
316
|
+
.replaceAll('"', """)
|
|
317
|
+
.replaceAll("'", "'");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function attr(value: string): string {
|
|
321
|
+
return escapeHtml(value);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isPathInside(parent: string, child: string): boolean {
|
|
325
|
+
const rel = relative(parent, child);
|
|
326
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(".."));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function activeBlockedBy(ticket: SnapshotTicket): number[] {
|
|
330
|
+
if (ticket.state === "closed") return [];
|
|
331
|
+
return ticket.blockedBy
|
|
332
|
+
.filter((edge) => edge.state === "open")
|
|
333
|
+
.map((edge) => edge.issueNumber)
|
|
334
|
+
.sort((a, b) => a - b);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** First duplicated bookKey in order, or null when all unique. */
|
|
338
|
+
function firstDuplicateBookKey(keys: readonly string[]): string | null {
|
|
339
|
+
const seen = new Set<string>();
|
|
340
|
+
for (const key of keys) {
|
|
341
|
+
if (seen.has(key)) return key;
|
|
342
|
+
seen.add(key);
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function renderErrorHtml(error: FactoryBoardError, generatedAt: string): string {
|
|
348
|
+
const bookAttr =
|
|
349
|
+
error.bookKey !== undefined && error.bookKey !== ""
|
|
350
|
+
? ` data-error-book="${attr(error.bookKey)}"`
|
|
351
|
+
: "";
|
|
352
|
+
return `<!DOCTYPE html>
|
|
353
|
+
<html lang="zh-CN" data-generated-at="${attr(generatedAt)}" data-board-error="${attr(error.kind)}"${bookAttr}>
|
|
354
|
+
<head>
|
|
355
|
+
<meta charset="utf-8"/>
|
|
356
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
357
|
+
<title>Factory board error</title>
|
|
358
|
+
<style>
|
|
359
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.45; }
|
|
360
|
+
body { margin: 0 auto; padding: 1.25rem; max-width: 40rem; }
|
|
361
|
+
.error {
|
|
362
|
+
border: 2px solid tomato;
|
|
363
|
+
background: color-mix(in srgb, tomato 12%, Canvas);
|
|
364
|
+
border-radius: 0.5rem;
|
|
365
|
+
padding: 1rem;
|
|
366
|
+
}
|
|
367
|
+
h1 { margin-top: 0; font-size: 1.25rem; }
|
|
368
|
+
pre { white-space: pre-wrap; word-break: break-word; }
|
|
369
|
+
</style>
|
|
370
|
+
</head>
|
|
371
|
+
<body>
|
|
372
|
+
<main class="error" data-board-error="${attr(error.kind)}"${bookAttr}>
|
|
373
|
+
<h1>工厂进度板无法加载</h1>
|
|
374
|
+
<p data-error-kind="${attr(error.kind)}">错误类型:${escapeHtml(error.kind)}</p>
|
|
375
|
+
${
|
|
376
|
+
error.bookKey
|
|
377
|
+
? `<p data-error-book="${attr(error.bookKey)}">册:${escapeHtml(error.bookKey)}</p>`
|
|
378
|
+
: ""
|
|
379
|
+
}
|
|
380
|
+
<pre data-error-message="${attr(error.message)}">${escapeHtml(error.message)}</pre>
|
|
381
|
+
<p class="generated">generated-at <time datetime="${attr(generatedAt)}">${escapeHtml(generatedAt)}</time></p>
|
|
382
|
+
</main>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|
|
385
|
+
`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Yamen display label per station (color = yamen, dot = state). */
|
|
389
|
+
const YAMEN_LABELS: Readonly<Record<string, string>> = {
|
|
390
|
+
judge: "大理寺",
|
|
391
|
+
coder: "将作监",
|
|
392
|
+
fixer: "修内司",
|
|
393
|
+
reviewer: "御史台",
|
|
394
|
+
collector: "门下省",
|
|
395
|
+
doctor: "太医署",
|
|
396
|
+
merger: "校书郎",
|
|
397
|
+
marshal: "刑部",
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
function latestKnownStation(runs: readonly TicketTrajectoryRun[]): string | undefined {
|
|
401
|
+
const latest = sortRunsByStart(runs).at(-1);
|
|
402
|
+
if (!latest || latest.station === "unknown") return undefined;
|
|
403
|
+
return latest.station;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function renderTicketArticle(input: {
|
|
407
|
+
bookKey: string;
|
|
408
|
+
prepared: PreparedTicket;
|
|
409
|
+
nested: boolean;
|
|
410
|
+
/** Column placement — carried by standalone cards and cluster members. */
|
|
411
|
+
placement?: BoardPlacement;
|
|
412
|
+
/** Family root issue when the ticket belongs to an in-snapshot family (族徽章). */
|
|
413
|
+
familyRoot?: number;
|
|
414
|
+
}): string {
|
|
415
|
+
const {
|
|
416
|
+
ticket,
|
|
417
|
+
runs,
|
|
418
|
+
displayRuns,
|
|
419
|
+
pending,
|
|
420
|
+
currentState,
|
|
421
|
+
activeBlockedBy: blockers,
|
|
422
|
+
costUsd,
|
|
423
|
+
totalTokens,
|
|
424
|
+
wallMs,
|
|
425
|
+
landingCycleMs,
|
|
426
|
+
legAgeMs,
|
|
427
|
+
lastActivityAt,
|
|
428
|
+
lastActivityMtimeMs,
|
|
429
|
+
} = input.prepared;
|
|
430
|
+
const state: TicketIssueState = ticket.state;
|
|
431
|
+
const milestone = ticket.milestone ?? "";
|
|
432
|
+
const blockedAttr = blockers.join(" ");
|
|
433
|
+
const badges = blockers
|
|
434
|
+
.map(
|
|
435
|
+
(n) =>
|
|
436
|
+
`<span class="blocked-badge" data-blocked-badge="${attr(String(n))}" data-book="${attr(input.bookKey)}" data-ticket="${attr(String(ticket.issueNumber))}">等 #${escapeHtml(String(n))}</span>`,
|
|
437
|
+
)
|
|
438
|
+
.join(" ");
|
|
439
|
+
const trajectory =
|
|
440
|
+
displayRuns.length > 0
|
|
441
|
+
? `<div class="trajectory" data-trajectory="true">${renderTicketTrajectoryStationHtml(displayRuns)}</div>`
|
|
442
|
+
: pending
|
|
443
|
+
? `<p class="pending-label" data-pending-label="true">待发(零卷)</p>`
|
|
444
|
+
: `<p class="pending-label" data-empty-trajectory="true">no runs</p>`;
|
|
445
|
+
|
|
446
|
+
const parentAttr =
|
|
447
|
+
ticket.parentIssueNumber !== null
|
|
448
|
+
? ` data-parent-issue="${attr(String(ticket.parentIssueNumber))}"`
|
|
449
|
+
: "";
|
|
450
|
+
const placementAttr =
|
|
451
|
+
input.placement !== undefined ? ` data-placement="${attr(input.placement)}"` : "";
|
|
452
|
+
|
|
453
|
+
const latestStation = latestKnownStation(runs);
|
|
454
|
+
const yamenTag =
|
|
455
|
+
latestStation !== undefined
|
|
456
|
+
? `<span class="yamen-tag yamen-${attr(latestStation)}" data-yamen="${attr(latestStation)}">${escapeHtml(YAMEN_LABELS[latestStation] ?? latestStation)}</span>`
|
|
457
|
+
: "";
|
|
458
|
+
const familyBadge =
|
|
459
|
+
input.familyRoot !== undefined
|
|
460
|
+
? `<span class="family-badge" data-family-badge="${attr(String(input.familyRoot))}" data-book="${attr(input.bookKey)}">族 #${escapeHtml(String(input.familyRoot))}</span>`
|
|
461
|
+
: "";
|
|
462
|
+
const breadcrumb = runs.length > 0 ? renderBreadcrumbHtml(runs) : "";
|
|
463
|
+
|
|
464
|
+
const ticketNo = String(ticket.issueNumber);
|
|
465
|
+
const unaccepted =
|
|
466
|
+
currentState === "unaccepted-flying" ||
|
|
467
|
+
currentState === "unaccepted-watch" ||
|
|
468
|
+
currentState === "unaccepted-suspect";
|
|
469
|
+
// Visible fact labels carry their own machine keys (data-*-label). Article-level
|
|
470
|
+
// data-leg-age-ms / data-last-activity-* are projection attributes only — tests that
|
|
471
|
+
// bite only the article cannot prove the human-visible spans exist (AC2 false-green).
|
|
472
|
+
const activityBits =
|
|
473
|
+
unaccepted && legAgeMs !== undefined
|
|
474
|
+
? [
|
|
475
|
+
`<span class="leg-age" data-leg-age-label="${attr(ticketNo)}" data-book="${attr(input.bookKey)}" data-leg-age-ms="${attr(String(legAgeMs))}">腿龄 ${escapeHtml(formatDurationZh(legAgeMs))}</span>`,
|
|
476
|
+
lastActivityAt
|
|
477
|
+
? `<span class="last-activity" data-last-activity-label="${attr(ticketNo)}" data-book="${attr(input.bookKey)}" data-last-activity-at="${attr(lastActivityAt)}"${lastActivityMtimeMs !== undefined ? ` data-last-activity-mtime-ms="${attr(String(lastActivityMtimeMs))}"` : ""}>末次活动 ${escapeHtml(formatLocalDateTime(lastActivityAt))}</span>`
|
|
478
|
+
: lastActivityMtimeMs !== undefined
|
|
479
|
+
? `<span class="last-activity" data-last-activity-label="${attr(ticketNo)}" data-book="${attr(input.bookKey)}" data-last-activity-mtime-ms="${attr(String(lastActivityMtimeMs))}">末次活动 ${escapeHtml(formatLocalDateTime(new Date(lastActivityMtimeMs).toISOString()))}</span>`
|
|
480
|
+
: "",
|
|
481
|
+
].join("")
|
|
482
|
+
: "";
|
|
483
|
+
|
|
484
|
+
return [
|
|
485
|
+
`<article class="ticket${input.nested ? " ticket-child" : ""} current-${attr(currentState)}"`,
|
|
486
|
+
` data-ticket="${attr(ticketNo)}"`,
|
|
487
|
+
` data-book="${attr(input.bookKey)}"`,
|
|
488
|
+
` data-title="${attr(ticket.title)}"`,
|
|
489
|
+
` data-milestone="${attr(milestone)}"`,
|
|
490
|
+
` data-ticket-state="${attr(state)}"`,
|
|
491
|
+
` data-current-state="${attr(currentState)}"`,
|
|
492
|
+
` data-state-strip="${attr(currentState)}"`,
|
|
493
|
+
` data-pending="${pending ? "true" : "false"}"`,
|
|
494
|
+
input.nested ? ` data-nested="true"` : "",
|
|
495
|
+
` data-blocked-by="${attr(blockedAttr)}"`,
|
|
496
|
+
` data-run-count="${attr(String(runs.length))}"`,
|
|
497
|
+
` data-cost-usd="${attr(formatUsd(costUsd))}"`,
|
|
498
|
+
` data-total-tokens="${attr(String(totalTokens))}"`,
|
|
499
|
+
` data-wall-ms="${attr(String(wallMs))}"`,
|
|
500
|
+
` data-landing-cycle-ms="${attr(String(landingCycleMs))}"`,
|
|
501
|
+
legAgeMs !== undefined ? ` data-leg-age-ms="${attr(String(legAgeMs))}"` : "",
|
|
502
|
+
lastActivityAt ? ` data-last-activity-at="${attr(lastActivityAt)}"` : "",
|
|
503
|
+
lastActivityMtimeMs !== undefined
|
|
504
|
+
? ` data-last-activity-mtime-ms="${attr(String(lastActivityMtimeMs))}"`
|
|
505
|
+
: "",
|
|
506
|
+
parentAttr,
|
|
507
|
+
placementAttr,
|
|
508
|
+
`>`,
|
|
509
|
+
`<header class="ticket-head">`,
|
|
510
|
+
`<h3 class="ticket-title">#${escapeHtml(ticketNo)} · ${escapeHtml(ticket.title)}</h3>`,
|
|
511
|
+
breadcrumb,
|
|
512
|
+
`<p class="ticket-meta">`,
|
|
513
|
+
`<span class="state" data-state-label="${attr(currentState)}" title="${attr(currentStateLabel(currentState))}"><span class="state-dot" aria-hidden="true" data-state-dot="true">●</span><span class="state-label"> ${escapeHtml(currentStateLabel(currentState))}</span></span>`,
|
|
514
|
+
yamenTag,
|
|
515
|
+
familyBadge,
|
|
516
|
+
milestone ? `<span class="milestone">milestone: ${escapeHtml(milestone)}</span>` : `<span class="milestone">milestone: —</span>`,
|
|
517
|
+
`<span class="cost" data-cost-label="${attr(ticketNo)}" data-book="${attr(input.bookKey)}" data-cost-usd="${attr(formatUsd(costUsd))}" data-total-tokens="${attr(String(totalTokens))}">$${escapeHtml(formatUsdPrecise(costUsd))} · ${escapeHtml(formatTokensCompact(totalTokens))} tok</span>`,
|
|
518
|
+
`<span class="wall" data-wall-label="${attr(ticketNo)}" data-book="${attr(input.bookKey)}" data-wall-ms="${attr(String(wallMs))}">施工墙钟 ${escapeHtml(formatDurationZh(wallMs))}</span>`,
|
|
519
|
+
`<span class="landing" data-landing-label="${attr(ticketNo)}" data-book="${attr(input.bookKey)}" data-landing-cycle-ms="${attr(String(landingCycleMs))}">落地周期 ${escapeHtml(formatDurationZh(landingCycleMs))}</span>`,
|
|
520
|
+
activityBits,
|
|
521
|
+
badges,
|
|
522
|
+
`</p>`,
|
|
523
|
+
`</header>`,
|
|
524
|
+
runs.length > 0
|
|
525
|
+
? `<details class="ticket-body" data-drill="${attr(String(ticket.issueNumber))}"><summary>明细 · ${runs.length} 腿</summary>${trajectory}</details>`
|
|
526
|
+
: trajectory,
|
|
527
|
+
`</article>`,
|
|
528
|
+
].join("");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function renderFamily(input: {
|
|
532
|
+
bookKey: string;
|
|
533
|
+
parent: PreparedTicket;
|
|
534
|
+
/** Whole in-snapshot descendant set (not only direct children). */
|
|
535
|
+
descendants: PreparedTicket[];
|
|
536
|
+
placement: BoardPlacement;
|
|
537
|
+
/**
|
|
538
|
+
* closed root → children nest inside the family section (父卡置顶、子卡缩进);
|
|
539
|
+
* open root → children place independently; the section carries an index only.
|
|
540
|
+
*/
|
|
541
|
+
nestChildren: boolean;
|
|
542
|
+
}): string {
|
|
543
|
+
const childCount = input.descendants.length;
|
|
544
|
+
const pendingCount = input.descendants.filter((c) => c.pending).length;
|
|
545
|
+
const closedCount = input.descendants.filter((c) => c.ticket.state === "closed").length;
|
|
546
|
+
// Family lane-entry burn = parent + every in-snapshot descendant (retains S2 nest;
|
|
547
|
+
// child burns such as #130 participate in the sortable family key).
|
|
548
|
+
const costUsd =
|
|
549
|
+
input.parent.costUsd + input.descendants.reduce((sum, d) => sum + d.costUsd, 0);
|
|
550
|
+
const totalTokens =
|
|
551
|
+
input.parent.totalTokens + input.descendants.reduce((sum, d) => sum + d.totalTokens, 0);
|
|
552
|
+
const parentBlock = renderTicketArticle({
|
|
553
|
+
bookKey: input.bookKey,
|
|
554
|
+
prepared: input.parent,
|
|
555
|
+
nested: false,
|
|
556
|
+
});
|
|
557
|
+
const childrenBlock = input.nestChildren
|
|
558
|
+
? input.descendants
|
|
559
|
+
.map((child) =>
|
|
560
|
+
renderTicketArticle({
|
|
561
|
+
bookKey: input.bookKey,
|
|
562
|
+
prepared: child,
|
|
563
|
+
nested: true,
|
|
564
|
+
placement: "done",
|
|
565
|
+
familyRoot: input.parent.ticket.issueNumber,
|
|
566
|
+
}),
|
|
567
|
+
)
|
|
568
|
+
.join("\n")
|
|
569
|
+
: input.descendants
|
|
570
|
+
.map(
|
|
571
|
+
(child) =>
|
|
572
|
+
`<li class="family-child-index" data-child-index="${attr(String(child.ticket.issueNumber))}" data-book="${attr(input.bookKey)}">#${escapeHtml(String(child.ticket.issueNumber))} · ${escapeHtml(child.ticket.title)}</li>`,
|
|
573
|
+
)
|
|
574
|
+
.join("\n");
|
|
575
|
+
|
|
576
|
+
return [
|
|
577
|
+
`<section class="family"`,
|
|
578
|
+
` data-family="true"`,
|
|
579
|
+
` data-book="${attr(input.bookKey)}"`,
|
|
580
|
+
` data-parent="${attr(String(input.parent.ticket.issueNumber))}"`,
|
|
581
|
+
` data-child-count="${attr(String(childCount))}"`,
|
|
582
|
+
` data-pending-count="${attr(String(pendingCount))}"`,
|
|
583
|
+
` data-closed-count="${attr(String(closedCount))}"`,
|
|
584
|
+
` data-cost-usd="${attr(formatUsd(costUsd))}"`,
|
|
585
|
+
` data-total-tokens="${attr(String(totalTokens))}"`,
|
|
586
|
+
` data-placement="${attr(input.placement)}"`,
|
|
587
|
+
`>`,
|
|
588
|
+
`<header class="family-head">`,
|
|
589
|
+
`<h2 class="family-title">族 #${escapeHtml(String(input.parent.ticket.issueNumber))} · ${escapeHtml(input.parent.ticket.title)}</h2>`,
|
|
590
|
+
`<p class="family-agg">`,
|
|
591
|
+
`<span>子轨迹 ${childCount}</span>`,
|
|
592
|
+
`<span>待发 ${pendingCount}</span>`,
|
|
593
|
+
`<span>收官 ${closedCount}</span>`,
|
|
594
|
+
`<span class="cost" data-family-cost-label="true">$${escapeHtml(formatUsdPrecise(costUsd))} · ${escapeHtml(formatTokensCompact(totalTokens))} tok</span>`,
|
|
595
|
+
`</p>`,
|
|
596
|
+
`</header>`,
|
|
597
|
+
`<div class="family-parent">${parentBlock}</div>`,
|
|
598
|
+
`<details class="family-children" data-family-expand="${attr(String(input.parent.ticket.issueNumber))}">`,
|
|
599
|
+
`<summary>子轨迹(${childCount})</summary>`,
|
|
600
|
+
input.nestChildren ? childrenBlock : `<ul class="family-child-index-list">${childrenBlock}</ul>`,
|
|
601
|
+
`</details>`,
|
|
602
|
+
`</section>`,
|
|
603
|
+
].join("");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** 已完成列 family cluster for closed members of an open-root family (族簇嵌套). */
|
|
607
|
+
function renderFamilyCluster(input: {
|
|
608
|
+
bookKey: string;
|
|
609
|
+
rootIssueNumber: number;
|
|
610
|
+
members: PreparedTicket[];
|
|
611
|
+
}): string {
|
|
612
|
+
const costUsd = input.members.reduce((sum, m) => sum + m.costUsd, 0);
|
|
613
|
+
const memberHtml = input.members
|
|
614
|
+
.map((member) =>
|
|
615
|
+
renderTicketArticle({
|
|
616
|
+
bookKey: input.bookKey,
|
|
617
|
+
prepared: member,
|
|
618
|
+
nested: true,
|
|
619
|
+
placement: "done",
|
|
620
|
+
familyRoot: input.rootIssueNumber,
|
|
621
|
+
}),
|
|
622
|
+
)
|
|
623
|
+
.join("\n");
|
|
624
|
+
return [
|
|
625
|
+
`<div class="family-cluster"`,
|
|
626
|
+
` data-family-cluster="${attr(String(input.rootIssueNumber))}"`,
|
|
627
|
+
` data-book="${attr(input.bookKey)}"`,
|
|
628
|
+
` data-placement="done"`,
|
|
629
|
+
` data-cost-usd="${attr(formatUsd(costUsd))}"`,
|
|
630
|
+
`>`,
|
|
631
|
+
`<p class="family-cluster-title">族 #${escapeHtml(String(input.rootIssueNumber))} · 已交卷簇(${input.members.length})</p>`,
|
|
632
|
+
memberHtml,
|
|
633
|
+
`</div>`,
|
|
634
|
+
].join("");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Direct in-snapshot children keyed by parent issue number. */
|
|
638
|
+
function buildDirectChildrenByParent(
|
|
639
|
+
prepared: ReadonlyMap<number, PreparedTicket>,
|
|
640
|
+
): Map<number, PreparedTicket[]> {
|
|
641
|
+
const childrenByParent = new Map<number, PreparedTicket[]>();
|
|
642
|
+
for (const item of prepared.values()) {
|
|
643
|
+
const parent = item.ticket.parentIssueNumber;
|
|
644
|
+
if (parent === null) continue;
|
|
645
|
+
if (!prepared.has(parent)) continue; // parent not in snapshot — child stays top-level
|
|
646
|
+
const list = childrenByParent.get(parent) ?? [];
|
|
647
|
+
list.push(item);
|
|
648
|
+
childrenByParent.set(parent, list);
|
|
649
|
+
}
|
|
650
|
+
for (const list of childrenByParent.values()) {
|
|
651
|
+
list.sort((a, b) => a.ticket.issueNumber - b.ticket.issueNumber);
|
|
652
|
+
}
|
|
653
|
+
return childrenByParent;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Walk native parent edges from a root and collect every in-snapshot descendant
|
|
658
|
+
* once (BFS). Intermediate parents are members of the root family, not new roots.
|
|
659
|
+
*/
|
|
660
|
+
function collectDescendants(
|
|
661
|
+
rootIssueNumber: number,
|
|
662
|
+
childrenByParent: ReadonlyMap<number, PreparedTicket[]>,
|
|
663
|
+
): PreparedTicket[] {
|
|
664
|
+
const out: PreparedTicket[] = [];
|
|
665
|
+
const seen = new Set<number>();
|
|
666
|
+
const queue = [...(childrenByParent.get(rootIssueNumber) ?? [])];
|
|
667
|
+
while (queue.length > 0) {
|
|
668
|
+
const item = queue.shift()!;
|
|
669
|
+
const n = item.ticket.issueNumber;
|
|
670
|
+
if (seen.has(n)) continue;
|
|
671
|
+
seen.add(n);
|
|
672
|
+
out.push(item);
|
|
673
|
+
const kids = childrenByParent.get(n);
|
|
674
|
+
if (kids) queue.push(...kids);
|
|
675
|
+
}
|
|
676
|
+
out.sort((a, b) => a.ticket.issueNumber - b.ticket.issueNumber);
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function prepareTicket(
|
|
681
|
+
ledgerDir: string,
|
|
682
|
+
ticket: SnapshotTicket,
|
|
683
|
+
now: Date,
|
|
684
|
+
): Promise<PreparedTicket> {
|
|
685
|
+
const runs = await loadTicketTrajectoryRuns(ledgerDir, ticket.issueNumber);
|
|
686
|
+
const pending = ticket.state !== "closed" && runs.length === 0;
|
|
687
|
+
const currentState = decideTicketCurrentState({
|
|
688
|
+
ticketState: ticket.state,
|
|
689
|
+
runs,
|
|
690
|
+
now,
|
|
691
|
+
});
|
|
692
|
+
const displayRuns = enrichRunsForBoard(runs, currentState, now);
|
|
693
|
+
const metrics = aggregateTicketMetrics(displayRuns, ticket, now);
|
|
694
|
+
|
|
695
|
+
let legAgeMs: number | undefined;
|
|
696
|
+
let lastActivityAt: string | undefined;
|
|
697
|
+
let lastActivityMtimeMs: number | undefined;
|
|
698
|
+
if (
|
|
699
|
+
currentState === "unaccepted-flying" ||
|
|
700
|
+
currentState === "unaccepted-watch" ||
|
|
701
|
+
currentState === "unaccepted-suspect"
|
|
702
|
+
) {
|
|
703
|
+
const latest = sortRunsByStart(runs).at(-1)!;
|
|
704
|
+
lastActivityMtimeMs = latest.mtimeMs;
|
|
705
|
+
// Newest parent/axis *content* activity (mtime stays on data-last-activity-mtime-ms).
|
|
706
|
+
lastActivityAt =
|
|
707
|
+
latest.lastActivityAt ??
|
|
708
|
+
latest.endedAt ??
|
|
709
|
+
(latest.mtimeMs > 0 ? new Date(latest.mtimeMs).toISOString() : undefined);
|
|
710
|
+
if (latest.startedAt) {
|
|
711
|
+
legAgeMs = wallMsBetween(latest.startedAt, now.toISOString());
|
|
712
|
+
} else if (latest.mtimeMs > 0) {
|
|
713
|
+
legAgeMs = Math.max(0, now.getTime() - latest.mtimeMs);
|
|
714
|
+
} else {
|
|
715
|
+
legAgeMs = 0;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
ticket,
|
|
721
|
+
runs,
|
|
722
|
+
displayRuns,
|
|
723
|
+
pending,
|
|
724
|
+
currentState,
|
|
725
|
+
activeBlockedBy: activeBlockedBy(ticket),
|
|
726
|
+
costUsd: metrics.costUsd,
|
|
727
|
+
totalTokens: metrics.totalTokens,
|
|
728
|
+
wallMs: metrics.wallMs,
|
|
729
|
+
landingCycleMs: metrics.landingCycleMs,
|
|
730
|
+
...(legAgeMs !== undefined ? { legAgeMs } : {}),
|
|
731
|
+
...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
|
|
732
|
+
...(lastActivityMtimeMs !== undefined ? { lastActivityMtimeMs } : {}),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
type LaneEntry = {
|
|
737
|
+
placement: BoardPlacement;
|
|
738
|
+
band: number;
|
|
739
|
+
activityMs: number;
|
|
740
|
+
/** Entry identity for sort tiebreak (family root / cluster root / ticket number). */
|
|
741
|
+
issueNumber: number;
|
|
742
|
+
html: string;
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
function entryCompare(a: LaneEntry, b: LaneEntry): number {
|
|
746
|
+
if (a.band !== b.band) return a.band - b.band;
|
|
747
|
+
if (a.activityMs !== b.activityMs) return b.activityMs - a.activityMs;
|
|
748
|
+
return a.issueNumber - b.issueNumber;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Six resident columns always render (empty ones carry data-column-count=0).
|
|
753
|
+
* Non-resident `other:*` columns slot before 已完成 when occupied.
|
|
754
|
+
*/
|
|
755
|
+
const RESIDENT_COLUMN_ORDER: readonly string[] = [
|
|
756
|
+
"pending",
|
|
757
|
+
"court",
|
|
758
|
+
"coder",
|
|
759
|
+
"marshal",
|
|
760
|
+
"collector",
|
|
761
|
+
"done",
|
|
762
|
+
];
|
|
763
|
+
|
|
764
|
+
const COLUMN_LABELS: Readonly<Record<string, string>> = {
|
|
765
|
+
pending: "待发",
|
|
766
|
+
court: "大理寺 · 审票",
|
|
767
|
+
coder: "将作监",
|
|
768
|
+
marshal: "刑部",
|
|
769
|
+
collector: "门下省",
|
|
770
|
+
done: "已完成",
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/** Terminal result statuses that mark a breadcrumb station step as 被拒 (strikethrough). */
|
|
774
|
+
const REJECTED_RESULT_STATUSES: ReadonlySet<string> = new Set(["continue", "refused", "rejected"]);
|
|
775
|
+
|
|
776
|
+
type BreadcrumbStep = {
|
|
777
|
+
station: string;
|
|
778
|
+
count: number;
|
|
779
|
+
isReturn: boolean;
|
|
780
|
+
isRejected: boolean;
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Compact ledger-order breadcrumb: consecutive same-station collapse (×N),
|
|
785
|
+
* ↩ when a station reappears after another, 被拒 when any run in the step was rejected.
|
|
786
|
+
*/
|
|
787
|
+
function buildBreadcrumbSteps(runs: readonly TicketTrajectoryRun[]): BreadcrumbStep[] {
|
|
788
|
+
const sorted = sortRunsByStart(runs);
|
|
789
|
+
if (sorted.length === 0) return [];
|
|
790
|
+
type Group = { station: string; runs: TicketTrajectoryRun[] };
|
|
791
|
+
const groups: Group[] = [];
|
|
792
|
+
for (const run of sorted) {
|
|
793
|
+
const last = groups.at(-1);
|
|
794
|
+
if (last && last.station === run.station) {
|
|
795
|
+
last.runs.push(run);
|
|
796
|
+
} else {
|
|
797
|
+
groups.push({ station: run.station, runs: [run] });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const seen = new Set<string>();
|
|
801
|
+
const steps: BreadcrumbStep[] = [];
|
|
802
|
+
for (const group of groups) {
|
|
803
|
+
const isReturn = seen.has(group.station);
|
|
804
|
+
seen.add(group.station);
|
|
805
|
+
const isRejected = group.runs.some(
|
|
806
|
+
(run) => run.hasResult && REJECTED_RESULT_STATUSES.has(run.resultStatus),
|
|
807
|
+
);
|
|
808
|
+
steps.push({
|
|
809
|
+
station: group.station,
|
|
810
|
+
count: group.runs.length,
|
|
811
|
+
isReturn,
|
|
812
|
+
isRejected,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
return steps;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function renderBreadcrumbHtml(runs: readonly TicketTrajectoryRun[]): string {
|
|
819
|
+
const steps = buildBreadcrumbSteps(runs);
|
|
820
|
+
if (steps.length === 0) return "";
|
|
821
|
+
const parts = steps.map((step, index) => {
|
|
822
|
+
const label =
|
|
823
|
+
step.station === "unknown" ? "未知站" : (YAMEN_LABELS[step.station] ?? step.station);
|
|
824
|
+
return [
|
|
825
|
+
`<span class="breadcrumb-step${step.isRejected ? " breadcrumb-rejected" : ""}"`,
|
|
826
|
+
` data-breadcrumb-step="${attr(String(index))}"`,
|
|
827
|
+
` data-station="${attr(step.station)}"`,
|
|
828
|
+
` data-step-count="${attr(String(step.count))}"`,
|
|
829
|
+
` data-return="${step.isReturn ? "true" : "false"}"`,
|
|
830
|
+
` data-rejected="${step.isRejected ? "true" : "false"}"`,
|
|
831
|
+
`>`,
|
|
832
|
+
step.isReturn ? `<span data-return-marker="true" aria-hidden="true">↩</span>` : "",
|
|
833
|
+
`<span data-step-label="true">${escapeHtml(label)}</span>`,
|
|
834
|
+
step.count > 1
|
|
835
|
+
? `<span data-step-count-label="true">×${escapeHtml(String(step.count))}</span>`
|
|
836
|
+
: "",
|
|
837
|
+
`</span>`,
|
|
838
|
+
].join("");
|
|
839
|
+
});
|
|
840
|
+
return [
|
|
841
|
+
`<nav class="breadcrumb" data-breadcrumb="true">`,
|
|
842
|
+
parts.join(`<span class="breadcrumb-sep" aria-hidden="true">→</span>`),
|
|
843
|
+
`</nav>`,
|
|
844
|
+
].join("");
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function columnLabel(key: string): string {
|
|
848
|
+
if (key.startsWith("other:")) return `${key.slice("other:".length)}(非常驻)`;
|
|
849
|
+
return COLUMN_LABELS[key] ?? key;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
type RenderedLane = {
|
|
853
|
+
html: string;
|
|
854
|
+
/** Unknown-station tickets in this lane (drives the page-level badge). */
|
|
855
|
+
unknownTickets: Array<{ issueNumber: number; title: string }>;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
async function renderLaneHtml(
|
|
859
|
+
bookKey: string,
|
|
860
|
+
ledgerDir: string,
|
|
861
|
+
tickets: readonly SnapshotTicket[],
|
|
862
|
+
now: Date,
|
|
863
|
+
options?: { hidden?: boolean },
|
|
864
|
+
): Promise<RenderedLane> {
|
|
865
|
+
const prepared = new Map<number, PreparedTicket>();
|
|
866
|
+
for (const ticket of tickets) {
|
|
867
|
+
prepared.set(ticket.issueNumber, await prepareTicket(ledgerDir, ticket, now));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const childrenByParent = buildDirectChildrenByParent(prepared);
|
|
871
|
+
|
|
872
|
+
// Tickets that appear as a child of any in-snapshot parent.
|
|
873
|
+
const nestedInSnapshot = new Set<number>();
|
|
874
|
+
for (const list of childrenByParent.values()) {
|
|
875
|
+
for (const child of list) nestedInSnapshot.add(child.ticket.issueNumber);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Family roots = in-snapshot tickets with descendants that are not themselves
|
|
879
|
+
// nested under another in-snapshot parent. One rooted whole-family aggregate.
|
|
880
|
+
const familyRoots = [...childrenByParent.keys()]
|
|
881
|
+
.filter((parentNum) => !nestedInSnapshot.has(parentNum))
|
|
882
|
+
.sort((a, b) => a - b);
|
|
883
|
+
|
|
884
|
+
const entries: LaneEntry[] = [];
|
|
885
|
+
const familyMembers = new Set<number>();
|
|
886
|
+
/** Closed members of open-root families → 已完成列 clusters keyed by family root. */
|
|
887
|
+
const clusterMembersByRoot = new Map<number, PreparedTicket[]>();
|
|
888
|
+
|
|
889
|
+
for (const rootNum of familyRoots) {
|
|
890
|
+
const parent = prepared.get(rootNum)!;
|
|
891
|
+
const descendants = collectDescendants(rootNum, childrenByParent);
|
|
892
|
+
familyMembers.add(rootNum);
|
|
893
|
+
for (const d of descendants) familyMembers.add(d.ticket.issueNumber);
|
|
894
|
+
|
|
895
|
+
if (parent.ticket.state === "closed") {
|
|
896
|
+
// Closed root: the whole family travels into 已完成 (父卡置顶、子卡缩进嵌套).
|
|
897
|
+
entries.push({
|
|
898
|
+
placement: "done",
|
|
899
|
+
band: stateSortBand("closed"),
|
|
900
|
+
activityMs: ticketSortActivityMs(parent),
|
|
901
|
+
issueNumber: rootNum,
|
|
902
|
+
html: renderFamily({ bookKey, parent, descendants, placement: "done", nestChildren: true }),
|
|
903
|
+
});
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Open root: family section placed by the root's own placement; members place
|
|
908
|
+
// independently (open) or extract to the 已完成 cluster (closed).
|
|
909
|
+
const rootPlacement = placeTicket(parent);
|
|
910
|
+
entries.push({
|
|
911
|
+
placement: rootPlacement,
|
|
912
|
+
band: stateSortBand(parent.currentState),
|
|
913
|
+
activityMs: ticketSortActivityMs(parent),
|
|
914
|
+
issueNumber: rootNum,
|
|
915
|
+
html: renderFamily({ bookKey, parent, descendants, placement: rootPlacement, nestChildren: false }),
|
|
916
|
+
});
|
|
917
|
+
for (const member of descendants) {
|
|
918
|
+
if (member.ticket.state === "closed") {
|
|
919
|
+
const list = clusterMembersByRoot.get(rootNum) ?? [];
|
|
920
|
+
list.push(member);
|
|
921
|
+
clusterMembersByRoot.set(rootNum, list);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const placement = placeTicket(member);
|
|
925
|
+
entries.push({
|
|
926
|
+
placement,
|
|
927
|
+
band: stateSortBand(member.currentState),
|
|
928
|
+
activityMs: ticketSortActivityMs(member),
|
|
929
|
+
issueNumber: member.ticket.issueNumber,
|
|
930
|
+
html: renderTicketArticle({
|
|
931
|
+
bookKey,
|
|
932
|
+
prepared: member,
|
|
933
|
+
nested: false,
|
|
934
|
+
placement,
|
|
935
|
+
familyRoot: rootNum,
|
|
936
|
+
}),
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Standalone tickets (no in-snapshot family): each places by its own facts.
|
|
942
|
+
for (const item of prepared.values()) {
|
|
943
|
+
if (familyMembers.has(item.ticket.issueNumber)) continue;
|
|
944
|
+
const placement = placeTicket(item);
|
|
945
|
+
entries.push({
|
|
946
|
+
placement,
|
|
947
|
+
band: stateSortBand(item.currentState),
|
|
948
|
+
activityMs: ticketSortActivityMs(item),
|
|
949
|
+
issueNumber: item.ticket.issueNumber,
|
|
950
|
+
html: renderTicketArticle({ bookKey, prepared: item, nested: false, placement }),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// 已完成列 clusters for closed members of open-root families.
|
|
955
|
+
for (const [rootNum, members] of [...clusterMembersByRoot.entries()].sort((a, b) => a[0] - b[0])) {
|
|
956
|
+
const sortedMembers = [...members].sort((a, b) => a.ticket.issueNumber - b.ticket.issueNumber);
|
|
957
|
+
entries.push({
|
|
958
|
+
placement: "done",
|
|
959
|
+
band: stateSortBand("closed"),
|
|
960
|
+
activityMs: Math.max(...sortedMembers.map((m) => ticketSortActivityMs(m))),
|
|
961
|
+
issueNumber: rootNum,
|
|
962
|
+
html: renderFamilyCluster({ bookKey, rootIssueNumber: rootNum, members: sortedMembers }),
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Group entries into columns; unknown never forms a column.
|
|
967
|
+
const byPlacement = new Map<string, LaneEntry[]>();
|
|
968
|
+
const unknownEntries: LaneEntry[] = [];
|
|
969
|
+
const unknownTickets: Array<{ issueNumber: number; title: string }> = [];
|
|
970
|
+
for (const entry of entries) {
|
|
971
|
+
if (entry.placement === "unknown") {
|
|
972
|
+
unknownEntries.push(entry);
|
|
973
|
+
unknownTickets.push({
|
|
974
|
+
issueNumber: entry.issueNumber,
|
|
975
|
+
title: prepared.get(entry.issueNumber)?.ticket.title ?? "",
|
|
976
|
+
});
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const list = byPlacement.get(entry.placement) ?? [];
|
|
980
|
+
list.push(entry);
|
|
981
|
+
byPlacement.set(entry.placement, list);
|
|
982
|
+
}
|
|
983
|
+
unknownEntries.sort(entryCompare);
|
|
984
|
+
unknownTickets.sort((a, b) => a.issueNumber - b.issueNumber);
|
|
985
|
+
|
|
986
|
+
// Six resident columns always present (empty → count 0); non-resident only when occupied.
|
|
987
|
+
const otherColumns = [...byPlacement.keys()]
|
|
988
|
+
.filter((key) => key.startsWith("other:"))
|
|
989
|
+
.sort((a, b) => a.localeCompare(b));
|
|
990
|
+
const columnOrder = [
|
|
991
|
+
...RESIDENT_COLUMN_ORDER.slice(0, -1), // pending…collector
|
|
992
|
+
...otherColumns,
|
|
993
|
+
"done",
|
|
994
|
+
];
|
|
995
|
+
|
|
996
|
+
const columnHtml = columnOrder
|
|
997
|
+
.map((key) => {
|
|
998
|
+
const group = (byPlacement.get(key) ?? []).slice().sort(entryCompare);
|
|
999
|
+
return [
|
|
1000
|
+
`<div class="column column-${attr(key.startsWith("other:") ? "other" : key)}"`,
|
|
1001
|
+
` data-column="${attr(key)}"`,
|
|
1002
|
+
` data-book="${attr(bookKey)}"`,
|
|
1003
|
+
` data-column-count="${attr(String(group.length))}"`,
|
|
1004
|
+
`>`,
|
|
1005
|
+
`<h3 class="column-title">${escapeHtml(columnLabel(key))} <span class="column-count" data-column-count-label="true">${group.length}</span></h3>`,
|
|
1006
|
+
group.map((entry) => entry.html).join("\n"),
|
|
1007
|
+
`</div>`,
|
|
1008
|
+
].join("");
|
|
1009
|
+
})
|
|
1010
|
+
.join("\n");
|
|
1011
|
+
|
|
1012
|
+
const unknownSetHtml =
|
|
1013
|
+
unknownEntries.length > 0
|
|
1014
|
+
? [
|
|
1015
|
+
`<details class="unknown-set" data-unknown-set="true" data-book="${attr(bookKey)}">`,
|
|
1016
|
+
`<summary>未知票 ×${unknownEntries.length}(本册,点击展开)</summary>`,
|
|
1017
|
+
unknownEntries.map((entry) => entry.html).join("\n"),
|
|
1018
|
+
`</details>`,
|
|
1019
|
+
].join("")
|
|
1020
|
+
: "";
|
|
1021
|
+
|
|
1022
|
+
return {
|
|
1023
|
+
html: [
|
|
1024
|
+
`<section class="lane" data-lane="${attr(bookKey)}" data-book="${attr(bookKey)}"${options?.hidden ? ' style="display:none"' : ""}>`,
|
|
1025
|
+
`<h2 class="lane-title">册 ${escapeHtml(bookKey)}</h2>`,
|
|
1026
|
+
`<div class="lane-columns" data-lane-tickets="${attr(bookKey)}">`,
|
|
1027
|
+
columnHtml,
|
|
1028
|
+
unknownSetHtml,
|
|
1029
|
+
`</div>`,
|
|
1030
|
+
`</section>`,
|
|
1031
|
+
].join("\n"),
|
|
1032
|
+
unknownTickets,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function boardStyles(): string {
|
|
1037
|
+
return `
|
|
1038
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.45; }
|
|
1039
|
+
body { margin: 0; padding: 1rem; }
|
|
1040
|
+
header.page { margin-bottom: 1rem; max-width: 72rem; }
|
|
1041
|
+
.generated { font-size: 0.9rem; opacity: 0.8; }
|
|
1042
|
+
.thresholds { font-size: 0.85rem; opacity: 0.9; display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
|
|
1043
|
+
.controls { margin: 0.5rem 0 0; display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; font-size: 0.9rem; }
|
|
1044
|
+
.lane {
|
|
1045
|
+
border: 1px solid color-mix(in srgb, CanvasText 22%, Canvas);
|
|
1046
|
+
border-radius: 0.6rem;
|
|
1047
|
+
padding: 0.85rem;
|
|
1048
|
+
margin: 1rem 0;
|
|
1049
|
+
}
|
|
1050
|
+
.lane-title { margin: 0 0 0.75rem; font-size: 1.2rem; }
|
|
1051
|
+
.lane-columns { display: flex; flex-wrap: nowrap; overflow-x: auto; gap: 0.75rem; align-items: flex-start; }
|
|
1052
|
+
.column {
|
|
1053
|
+
flex: 1 0 14rem;
|
|
1054
|
+
min-width: 13rem;
|
|
1055
|
+
border: 1px solid color-mix(in srgb, CanvasText 14%, Canvas);
|
|
1056
|
+
border-radius: 0.5rem;
|
|
1057
|
+
padding: 0.5rem 0.6rem;
|
|
1058
|
+
}
|
|
1059
|
+
.column-title { margin: 0 0 0.4rem; font-size: 0.95rem; }
|
|
1060
|
+
.column-count { opacity: 0.75; font-weight: 400; }
|
|
1061
|
+
.unknown-set { flex: 1 0 14rem; min-width: 13rem; }
|
|
1062
|
+
.family-cluster { margin: 0.4rem 0; }
|
|
1063
|
+
.family-cluster-title { margin: 0.2rem 0; font-size: 0.85rem; opacity: 0.85; }
|
|
1064
|
+
.family-child-index-list { margin: 0.25rem 0; padding-left: 1.2rem; font-size: 0.88rem; }
|
|
1065
|
+
.ticket-meta .state-label,
|
|
1066
|
+
.ticket-meta .milestone,
|
|
1067
|
+
.ticket-meta .cost,
|
|
1068
|
+
.ticket-meta .wall,
|
|
1069
|
+
.ticket-meta .landing,
|
|
1070
|
+
.ticket-meta .leg-age,
|
|
1071
|
+
.ticket-meta .last-activity { display: none; }
|
|
1072
|
+
.family-agg .cost { display: none; }
|
|
1073
|
+
.state-dot { font-size: 0.8em; }
|
|
1074
|
+
.yamen-tag, .family-badge {
|
|
1075
|
+
display: inline-block;
|
|
1076
|
+
padding: 0.02rem 0.4rem;
|
|
1077
|
+
border-radius: 0.3rem;
|
|
1078
|
+
font-size: 0.8rem;
|
|
1079
|
+
background: color-mix(in srgb, CanvasText 10%, Canvas);
|
|
1080
|
+
}
|
|
1081
|
+
.yamen-coder { background: color-mix(in srgb, teal 30%, Canvas); }
|
|
1082
|
+
.yamen-fixer { background: color-mix(in srgb, blueviolet 28%, Canvas); }
|
|
1083
|
+
.yamen-reviewer { background: color-mix(in srgb, deepskyblue 30%, Canvas); }
|
|
1084
|
+
.yamen-judge { background: color-mix(in srgb, palevioletred 30%, Canvas); }
|
|
1085
|
+
.unknown-badge { margin-left: auto; }
|
|
1086
|
+
.unknown-item { display: list-item; }
|
|
1087
|
+
.family {
|
|
1088
|
+
border: 1px dashed color-mix(in srgb, CanvasText 28%, Canvas);
|
|
1089
|
+
border-radius: 0.5rem;
|
|
1090
|
+
padding: 0.65rem;
|
|
1091
|
+
margin: 0.75rem 0;
|
|
1092
|
+
}
|
|
1093
|
+
.family-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
|
|
1094
|
+
.family-agg { margin: 0 0 0.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.9rem; opacity: 0.9; }
|
|
1095
|
+
.ticket {
|
|
1096
|
+
border-top: 1px solid color-mix(in srgb, CanvasText 12%, Canvas);
|
|
1097
|
+
padding: 0.55rem 0;
|
|
1098
|
+
}
|
|
1099
|
+
/* Five-state top strip: same hue family as the state dot (card stays put, strip changes). */
|
|
1100
|
+
.ticket[data-state-strip="unaccepted-flying"] {
|
|
1101
|
+
border-top: 3px solid color-mix(in srgb, seagreen 80%, CanvasText);
|
|
1102
|
+
}
|
|
1103
|
+
.ticket[data-state-strip="unaccepted-watch"] {
|
|
1104
|
+
border-top: 3px solid color-mix(in srgb, goldenrod 85%, CanvasText);
|
|
1105
|
+
}
|
|
1106
|
+
.ticket[data-state-strip="unaccepted-suspect"] {
|
|
1107
|
+
border-top: 3px solid color-mix(in srgb, tomato 85%, CanvasText);
|
|
1108
|
+
}
|
|
1109
|
+
.ticket[data-state-strip="accepted-awaiting"] {
|
|
1110
|
+
border-top: 3px solid color-mix(in srgb, dodgerblue 75%, CanvasText);
|
|
1111
|
+
}
|
|
1112
|
+
.ticket[data-state-strip="escalate-awaiting"] {
|
|
1113
|
+
border-top: 3px solid color-mix(in srgb, darkorange 90%, CanvasText);
|
|
1114
|
+
}
|
|
1115
|
+
.ticket-child { margin-left: 0.75rem; padding-left: 0.5rem; border-left: 2px solid color-mix(in srgb, CanvasText 18%, Canvas); }
|
|
1116
|
+
.ticket-title { margin: 0; font-size: 1rem; }
|
|
1117
|
+
.breadcrumb {
|
|
1118
|
+
margin: 0.2rem 0 0.35rem;
|
|
1119
|
+
font-size: 0.82rem;
|
|
1120
|
+
display: flex;
|
|
1121
|
+
flex-wrap: wrap;
|
|
1122
|
+
gap: 0.2rem 0.35rem;
|
|
1123
|
+
align-items: center;
|
|
1124
|
+
opacity: 0.92;
|
|
1125
|
+
}
|
|
1126
|
+
.breadcrumb-sep { opacity: 0.55; }
|
|
1127
|
+
.breadcrumb-step[data-rejected="true"] { text-decoration: line-through; opacity: 0.75; }
|
|
1128
|
+
.ticket-meta { margin: 0.25rem 0; display: flex; flex-wrap: wrap; gap: 0.5rem 0.85rem; font-size: 0.88rem; }
|
|
1129
|
+
.state { font-weight: 600; }
|
|
1130
|
+
.ticket.current-unaccepted-flying .state { color: color-mix(in srgb, seagreen 80%, CanvasText); }
|
|
1131
|
+
.ticket.current-unaccepted-watch .state { color: color-mix(in srgb, goldenrod 85%, CanvasText); }
|
|
1132
|
+
.ticket.current-unaccepted-suspect .state { color: color-mix(in srgb, tomato 85%, CanvasText); }
|
|
1133
|
+
.ticket.current-pending .state { opacity: 0.85; }
|
|
1134
|
+
.ticket.current-accepted-awaiting .state { color: color-mix(in srgb, dodgerblue 75%, CanvasText); }
|
|
1135
|
+
.ticket.current-escalate-awaiting .state { color: color-mix(in srgb, darkorange 90%, CanvasText); }
|
|
1136
|
+
.ticket.current-closed .state { opacity: 0.75; }
|
|
1137
|
+
.blocked-badge {
|
|
1138
|
+
display: inline-block;
|
|
1139
|
+
padding: 0.05rem 0.4rem;
|
|
1140
|
+
border-radius: 999px;
|
|
1141
|
+
background: color-mix(in srgb, darkorange 30%, Canvas);
|
|
1142
|
+
font-size: 0.8rem;
|
|
1143
|
+
}
|
|
1144
|
+
.pending-label { margin: 0.25rem 0; opacity: 0.85; }
|
|
1145
|
+
.station { border: 1px solid color-mix(in srgb, CanvasText 16%, Canvas); border-radius: 0.4rem; padding: 0.5rem; margin: 0.4rem 0; }
|
|
1146
|
+
.station-title { margin: 0 0 0.35rem; font-size: 0.95rem; }
|
|
1147
|
+
.run { padding: 0.35rem 0; border-top: 1px solid color-mix(in srgb, CanvasText 10%, Canvas); }
|
|
1148
|
+
.run:first-of-type { border-top: 0; }
|
|
1149
|
+
.run-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; justify-content: space-between; }
|
|
1150
|
+
.run-id { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
|
|
1151
|
+
.run-model { font-size: 0.8rem; opacity: 0.85; }
|
|
1152
|
+
.run-meta { margin: 0.2rem 0; font-size: 0.85rem; display: flex; flex-wrap: wrap; gap: 0.65rem; }
|
|
1153
|
+
.ledger { margin: 0.2rem 0 0; font-size: 0.75rem; word-break: break-all; }
|
|
1154
|
+
@media (max-width: 640px) {
|
|
1155
|
+
body { padding: 0.75rem; }
|
|
1156
|
+
.ticket-child { margin-left: 0.35rem; }
|
|
1157
|
+
}
|
|
1158
|
+
`;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function boardPageScript(): string {
|
|
1162
|
+
// Presentation-only reorder + filter; machine facts stay on data-* attrs.
|
|
1163
|
+
// Singular render-seam comparator lives only in this embedded page script.
|
|
1164
|
+
// Family lane entries carry aggregate data-cost-usd (parent + descendants) so nested
|
|
1165
|
+
// per-ticket burns (e.g. #130 under #78) participate without flattening the S2 nest.
|
|
1166
|
+
return `<script>
|
|
1167
|
+
(function () {
|
|
1168
|
+
var doc = document;
|
|
1169
|
+
function kids(el) { return Array.prototype.slice.call(el.children); }
|
|
1170
|
+
function costOf(el) {
|
|
1171
|
+
var v = el.getAttribute('data-cost-usd');
|
|
1172
|
+
var n = v == null ? 0 : Number(v);
|
|
1173
|
+
return Number.isFinite(n) ? n : 0;
|
|
1174
|
+
}
|
|
1175
|
+
function ticketNo(el) {
|
|
1176
|
+
// Tickets use data-ticket; family sections use data-parent (root issue);
|
|
1177
|
+
// completed clusters use data-family-cluster (root issue).
|
|
1178
|
+
var v = el.getAttribute('data-ticket');
|
|
1179
|
+
if (v == null || v === '') v = el.getAttribute('data-parent');
|
|
1180
|
+
if (v == null || v === '') v = el.getAttribute('data-family-cluster');
|
|
1181
|
+
var n = v == null ? 0 : Number(v);
|
|
1182
|
+
return Number.isFinite(n) ? n : 0;
|
|
1183
|
+
}
|
|
1184
|
+
function compareEntries(a, b, mode) {
|
|
1185
|
+
if (mode === 'cost-desc') {
|
|
1186
|
+
var d = costOf(b) - costOf(a);
|
|
1187
|
+
if (d !== 0) return d;
|
|
1188
|
+
} else if (mode === 'cost-asc') {
|
|
1189
|
+
var d2 = costOf(a) - costOf(b);
|
|
1190
|
+
if (d2 !== 0) return d2;
|
|
1191
|
+
}
|
|
1192
|
+
return ticketNo(a) - ticketNo(b);
|
|
1193
|
+
}
|
|
1194
|
+
function sortKey(node) {
|
|
1195
|
+
if (node.nodeType !== 1) return null;
|
|
1196
|
+
var el = node;
|
|
1197
|
+
// Column children only: family sections (aggregate burn), standalone tickets, clusters.
|
|
1198
|
+
if (el.hasAttribute && el.hasAttribute('data-family')) {
|
|
1199
|
+
return el.hasAttribute('data-cost-usd') ? el : null;
|
|
1200
|
+
}
|
|
1201
|
+
if (el.hasAttribute && el.hasAttribute('data-ticket')) return el;
|
|
1202
|
+
if (el.hasAttribute && el.hasAttribute('data-family-cluster')) return el;
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
function sortEntries(container, mode) {
|
|
1206
|
+
var nodes = kids(container).filter(function (n) { return sortKey(n); });
|
|
1207
|
+
nodes.sort(function (a, b) { return compareEntries(a, b, mode); });
|
|
1208
|
+
nodes.forEach(function (n) { container.appendChild(n); });
|
|
1209
|
+
}
|
|
1210
|
+
function applySort(mode) {
|
|
1211
|
+
doc.querySelectorAll('[data-lane-tickets]').forEach(function (lane) {
|
|
1212
|
+
var groups = kids(lane).filter(function (el) {
|
|
1213
|
+
return el.nodeType === 1 && (el.hasAttribute('data-column') || el.hasAttribute('data-unknown-set'));
|
|
1214
|
+
});
|
|
1215
|
+
if (groups.length > 0) {
|
|
1216
|
+
groups.forEach(function (g) { sortEntries(g, mode); });
|
|
1217
|
+
} else {
|
|
1218
|
+
sortEntries(lane, mode);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
var sel = doc.querySelector('[data-sort-control]');
|
|
1223
|
+
var projectSel = doc.querySelector('[data-project-filter]');
|
|
1224
|
+
var familySel = doc.querySelector('[data-family-filter]');
|
|
1225
|
+
var filterStore = (function () {
|
|
1226
|
+
try { return JSON.parse(localStorage.getItem('ak-board-filters') || '{}'); } catch (e) { return {}; }
|
|
1227
|
+
})();
|
|
1228
|
+
function persistFilters() {
|
|
1229
|
+
try {
|
|
1230
|
+
localStorage.setItem('ak-board-filters', JSON.stringify({
|
|
1231
|
+
project: projectSel ? projectSel.value : '',
|
|
1232
|
+
family: familySel ? familySel.value : 'all',
|
|
1233
|
+
sort: sel ? sel.value : ''
|
|
1234
|
+
}));
|
|
1235
|
+
} catch (e) {}
|
|
1236
|
+
}
|
|
1237
|
+
if (sel) {
|
|
1238
|
+
sel.addEventListener('change', function () {
|
|
1239
|
+
applySort(sel.value);
|
|
1240
|
+
persistFilters();
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
var badge = doc.querySelector('[data-unknown-badge]');
|
|
1245
|
+
|
|
1246
|
+
function parentEdgesByBook() {
|
|
1247
|
+
var map = {};
|
|
1248
|
+
doc.querySelectorAll('[data-ticket]').forEach(function (a) {
|
|
1249
|
+
var book = a.getAttribute('data-book') || '';
|
|
1250
|
+
var t = a.getAttribute('data-ticket');
|
|
1251
|
+
if (!t) return;
|
|
1252
|
+
if (!map[book]) map[book] = {};
|
|
1253
|
+
map[book][t] = a.getAttribute('data-parent-issue') || '';
|
|
1254
|
+
});
|
|
1255
|
+
return map;
|
|
1256
|
+
}
|
|
1257
|
+
function ancestorChain(edges, ticket) {
|
|
1258
|
+
var out = [];
|
|
1259
|
+
var seen = {};
|
|
1260
|
+
var cur = ticket;
|
|
1261
|
+
while (cur) {
|
|
1262
|
+
if (seen[cur]) break;
|
|
1263
|
+
seen[cur] = true;
|
|
1264
|
+
out.push(cur);
|
|
1265
|
+
cur = edges[cur] || '';
|
|
1266
|
+
}
|
|
1267
|
+
return out;
|
|
1268
|
+
}
|
|
1269
|
+
function entryChain(el, edges) {
|
|
1270
|
+
if (el.hasAttribute('data-family')) return [el.getAttribute('data-parent')];
|
|
1271
|
+
if (el.hasAttribute('data-family-cluster')) return [el.getAttribute('data-family-cluster')];
|
|
1272
|
+
if (el.hasAttribute('data-ticket')) {
|
|
1273
|
+
return ancestorChain(edges[el.getAttribute('data-book') || ''] || {}, el.getAttribute('data-ticket'));
|
|
1274
|
+
}
|
|
1275
|
+
return [];
|
|
1276
|
+
}
|
|
1277
|
+
function rebuildFamilyOptions(restoreStored) {
|
|
1278
|
+
if (!familySel || !projectSel) return;
|
|
1279
|
+
var book = projectSel.value;
|
|
1280
|
+
var openChildParents = {};
|
|
1281
|
+
var childCounts = {};
|
|
1282
|
+
var titles = {};
|
|
1283
|
+
doc.querySelectorAll('[data-ticket]').forEach(function (a) {
|
|
1284
|
+
if ((a.getAttribute('data-book') || '') !== book) return;
|
|
1285
|
+
titles[a.getAttribute('data-ticket')] = a.getAttribute('data-title') || '';
|
|
1286
|
+
if (a.getAttribute('data-ticket-state') !== 'open') return;
|
|
1287
|
+
var p = a.getAttribute('data-parent-issue');
|
|
1288
|
+
if (p) {
|
|
1289
|
+
openChildParents[p] = true;
|
|
1290
|
+
childCounts[p] = (childCounts[p] || 0) + 1;
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
while (familySel.firstChild) familySel.removeChild(familySel.firstChild);
|
|
1294
|
+
function addOpt(value, label, familyOption, childCount) {
|
|
1295
|
+
var opt = doc.createElement('option');
|
|
1296
|
+
opt.value = value;
|
|
1297
|
+
opt.textContent = label;
|
|
1298
|
+
if (familyOption != null) {
|
|
1299
|
+
opt.setAttribute('data-family-option', String(familyOption));
|
|
1300
|
+
opt.setAttribute('data-child-count', String(childCount == null ? 0 : childCount));
|
|
1301
|
+
}
|
|
1302
|
+
familySel.appendChild(opt);
|
|
1303
|
+
}
|
|
1304
|
+
addOpt('all', '全部');
|
|
1305
|
+
Object.keys(openChildParents)
|
|
1306
|
+
.sort(function (a, b) { return Number(a) - Number(b); })
|
|
1307
|
+
.forEach(function (p) {
|
|
1308
|
+
var n = childCounts[p] || 0;
|
|
1309
|
+
addOpt(
|
|
1310
|
+
p,
|
|
1311
|
+
'族 #' + p + (titles[p] ? ' · ' + titles[p] : '') + ' · 子 ' + n,
|
|
1312
|
+
p,
|
|
1313
|
+
n
|
|
1314
|
+
);
|
|
1315
|
+
});
|
|
1316
|
+
var wantFam = restoreStored && filterStore.family ? String(filterStore.family) : 'all';
|
|
1317
|
+
var hasFam = false;
|
|
1318
|
+
kids(familySel).forEach(function (o) {
|
|
1319
|
+
if (o.value === wantFam) hasFam = true;
|
|
1320
|
+
});
|
|
1321
|
+
familySel.value = hasFam ? wantFam : 'all';
|
|
1322
|
+
}
|
|
1323
|
+
function applyFilters() {
|
|
1324
|
+
var book = projectSel ? projectSel.value : '';
|
|
1325
|
+
doc.querySelectorAll('[data-lane]').forEach(function (lane) {
|
|
1326
|
+
lane.style.display = !book || lane.getAttribute('data-lane') === book ? '' : 'none';
|
|
1327
|
+
});
|
|
1328
|
+
var fam = familySel ? familySel.value : 'all';
|
|
1329
|
+
var edges = parentEdgesByBook();
|
|
1330
|
+
doc.querySelectorAll('[data-placement]').forEach(function (el) {
|
|
1331
|
+
var show = true;
|
|
1332
|
+
if (fam !== 'all') {
|
|
1333
|
+
show = entryChain(el, edges).indexOf(fam) >= 0;
|
|
1334
|
+
}
|
|
1335
|
+
el.style.display = show ? '' : 'none';
|
|
1336
|
+
});
|
|
1337
|
+
doc.querySelectorAll('[data-column]').forEach(function (col) {
|
|
1338
|
+
var n = 0;
|
|
1339
|
+
kids(col).forEach(function (el) {
|
|
1340
|
+
if (el.nodeType === 1 && el.hasAttribute('data-placement') && el.style.display !== 'none') n += 1;
|
|
1341
|
+
});
|
|
1342
|
+
col.setAttribute('data-column-count', String(n));
|
|
1343
|
+
var label = col.querySelector('[data-column-count-label]');
|
|
1344
|
+
if (label) label.textContent = String(n);
|
|
1345
|
+
});
|
|
1346
|
+
if (badge) {
|
|
1347
|
+
var u = 0;
|
|
1348
|
+
doc.querySelectorAll('[data-unknown-item]').forEach(function (item) {
|
|
1349
|
+
var ok = !book || (item.getAttribute('data-book') || '') === book;
|
|
1350
|
+
if (ok && fam !== 'all') {
|
|
1351
|
+
ok = ancestorChain(edges[item.getAttribute('data-book') || ''] || {}, item.getAttribute('data-unknown-item')).indexOf(fam) >= 0;
|
|
1352
|
+
}
|
|
1353
|
+
item.style.display = ok ? '' : 'none';
|
|
1354
|
+
if (ok) u += 1;
|
|
1355
|
+
});
|
|
1356
|
+
badge.setAttribute('data-unknown-count', String(u));
|
|
1357
|
+
var summary = badge.querySelector('[data-unknown-badge-summary]');
|
|
1358
|
+
if (summary) summary.textContent = '未知票 ×' + u;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (projectSel) {
|
|
1362
|
+
projectSel.addEventListener('change', function () {
|
|
1363
|
+
rebuildFamilyOptions(false);
|
|
1364
|
+
applyFilters();
|
|
1365
|
+
persistFilters();
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
if (familySel) {
|
|
1369
|
+
familySel.addEventListener('change', function () {
|
|
1370
|
+
applyFilters();
|
|
1371
|
+
persistFilters();
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
if (projectSel) {
|
|
1375
|
+
var restoredProject = false;
|
|
1376
|
+
if (filterStore.project) {
|
|
1377
|
+
kids(projectSel).forEach(function (o) {
|
|
1378
|
+
if (o.value === filterStore.project && projectSel.value !== o.value) {
|
|
1379
|
+
projectSel.value = o.value;
|
|
1380
|
+
restoredProject = true;
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
if (restoredProject) {
|
|
1385
|
+
rebuildFamilyOptions(true);
|
|
1386
|
+
} else if (filterStore.family && String(filterStore.family) !== 'all') {
|
|
1387
|
+
kids(familySel || []).forEach(function (o) {
|
|
1388
|
+
if (o.value === String(filterStore.family)) familySel.value = o.value;
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
if (sel && filterStore.sort) {
|
|
1393
|
+
sel.value = filterStore.sort;
|
|
1394
|
+
applySort(sel.value);
|
|
1395
|
+
}
|
|
1396
|
+
if (projectSel) applyFilters();
|
|
1397
|
+
})();
|
|
1398
|
+
</script>`;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Unique S2 seam: books + snapshot-or-error + now → HTML.
|
|
1403
|
+
* Read-only against every ledger.
|
|
1404
|
+
* When refreshBoundarySeconds is a positive finite number, the page declares that
|
|
1405
|
+
* bound (backed by startFactoryBoardPage regeneration). One-shot omits it.
|
|
1406
|
+
*/
|
|
1407
|
+
export async function renderFactoryBoardHtml(
|
|
1408
|
+
books: readonly FactoryBoardBook[],
|
|
1409
|
+
view: FactoryBoardView,
|
|
1410
|
+
now: Date,
|
|
1411
|
+
options?: { refreshBoundarySeconds?: number },
|
|
1412
|
+
): Promise<string> {
|
|
1413
|
+
if (!Array.isArray(books)) {
|
|
1414
|
+
throw new Error("books must be an array");
|
|
1415
|
+
}
|
|
1416
|
+
if (!isRecord(view) || typeof view.ok !== "boolean") {
|
|
1417
|
+
throw new Error("view must be a FactoryBoardView");
|
|
1418
|
+
}
|
|
1419
|
+
const generatedAt = now.toISOString();
|
|
1420
|
+
const refreshActive =
|
|
1421
|
+
options?.refreshBoundarySeconds !== undefined &&
|
|
1422
|
+
Number.isFinite(options.refreshBoundarySeconds) &&
|
|
1423
|
+
options.refreshBoundarySeconds > 0;
|
|
1424
|
+
const refreshBoundarySeconds = refreshActive ? options!.refreshBoundarySeconds! : undefined;
|
|
1425
|
+
if (!view.ok) {
|
|
1426
|
+
return renderErrorHtml(view.error, generatedAt);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Fail closed on duplicate bookKey — never Map-last-wins across lanes/ledgers.
|
|
1430
|
+
const duplicateKey = firstDuplicateBookKey(books.map((b) => b.bookKey));
|
|
1431
|
+
if (duplicateKey !== null) {
|
|
1432
|
+
return renderErrorHtml(
|
|
1433
|
+
{
|
|
1434
|
+
kind: "binding",
|
|
1435
|
+
bookKey: duplicateKey,
|
|
1436
|
+
message: `duplicate bookKey in books: ${duplicateKey}`,
|
|
1437
|
+
},
|
|
1438
|
+
generatedAt,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
const duplicateSnapKey = firstDuplicateBookKey(view.snapshot.books.map((b) => b.bookKey));
|
|
1442
|
+
if (duplicateSnapKey !== null) {
|
|
1443
|
+
return renderErrorHtml(
|
|
1444
|
+
{
|
|
1445
|
+
kind: "binding",
|
|
1446
|
+
bookKey: duplicateSnapKey,
|
|
1447
|
+
message: `duplicate bookKey in snapshot: ${duplicateSnapKey}`,
|
|
1448
|
+
},
|
|
1449
|
+
generatedAt,
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const bookByKey = new Map(books.map((b) => [b.bookKey, b]));
|
|
1454
|
+
// Project dropdown default: the home book when present, else the first snapshot book.
|
|
1455
|
+
const defaultBookKey =
|
|
1456
|
+
(view.snapshot.books.find((b) => b.repo === "ak-pi-workflow-roles") ?? view.snapshot.books[0])
|
|
1457
|
+
?.bookKey;
|
|
1458
|
+
const laneHtmlParts: string[] = [];
|
|
1459
|
+
const unknownByBook = new Map<string, Array<{ issueNumber: number; title: string }>>();
|
|
1460
|
+
for (const bookSnap of view.snapshot.books) {
|
|
1461
|
+
const book = bookByKey.get(bookSnap.bookKey);
|
|
1462
|
+
if (!book) {
|
|
1463
|
+
return renderErrorHtml(
|
|
1464
|
+
{
|
|
1465
|
+
kind: "binding",
|
|
1466
|
+
bookKey: bookSnap.bookKey,
|
|
1467
|
+
message: `no ledger binding for book ${bookSnap.bookKey}`,
|
|
1468
|
+
},
|
|
1469
|
+
generatedAt,
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
const lane = await renderLaneHtml(
|
|
1473
|
+
book.bookKey,
|
|
1474
|
+
resolve(book.ledgerDir),
|
|
1475
|
+
bookSnap.tickets,
|
|
1476
|
+
now,
|
|
1477
|
+
{ hidden: defaultBookKey !== undefined && bookSnap.bookKey !== defaultBookKey },
|
|
1478
|
+
);
|
|
1479
|
+
laneHtmlParts.push(lane.html);
|
|
1480
|
+
unknownByBook.set(bookSnap.bookKey, lane.unknownTickets);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Family dropdown (default book): parents with at least one open direct child + mechanical child count.
|
|
1484
|
+
const defaultBookSnap = view.snapshot.books.find((b) => b.bookKey === defaultBookKey);
|
|
1485
|
+
const familyOptions: Array<{ issueNumber: number; title: string; childCount: number }> = [];
|
|
1486
|
+
if (defaultBookSnap) {
|
|
1487
|
+
const byNumber = new Map(defaultBookSnap.tickets.map((t) => [t.issueNumber, t]));
|
|
1488
|
+
const openChildCountByParent = new Map<number, number>();
|
|
1489
|
+
for (const t of defaultBookSnap.tickets) {
|
|
1490
|
+
if (t.state !== "open" || t.parentIssueNumber === null) continue;
|
|
1491
|
+
if (!byNumber.has(t.parentIssueNumber)) continue;
|
|
1492
|
+
openChildCountByParent.set(
|
|
1493
|
+
t.parentIssueNumber,
|
|
1494
|
+
(openChildCountByParent.get(t.parentIssueNumber) ?? 0) + 1,
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
for (const n of [...openChildCountByParent.keys()].sort((a, b) => a - b)) {
|
|
1498
|
+
familyOptions.push({
|
|
1499
|
+
issueNumber: n,
|
|
1500
|
+
title: byNumber.get(n)?.title ?? "",
|
|
1501
|
+
childCount: openChildCountByParent.get(n) ?? 0,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const projectOptions = view.snapshot.books
|
|
1507
|
+
.map(
|
|
1508
|
+
(b) =>
|
|
1509
|
+
`<option value="${attr(b.bookKey)}"${b.bookKey === defaultBookKey ? " selected" : ""}>${escapeHtml(b.bookKey)}</option>`,
|
|
1510
|
+
)
|
|
1511
|
+
.join("");
|
|
1512
|
+
const familyOptionsHtml = [
|
|
1513
|
+
`<option value="all" selected>全部</option>`,
|
|
1514
|
+
...familyOptions.map(
|
|
1515
|
+
(f) =>
|
|
1516
|
+
`<option value="${attr(String(f.issueNumber))}" data-family-option="${attr(String(f.issueNumber))}" data-child-count="${attr(String(f.childCount))}">族 #${escapeHtml(String(f.issueNumber))} · ${escapeHtml(f.title)} · 子 ${escapeHtml(String(f.childCount))}</option>`,
|
|
1517
|
+
),
|
|
1518
|
+
].join("");
|
|
1519
|
+
|
|
1520
|
+
const allUnknown = view.snapshot.books.flatMap((b) =>
|
|
1521
|
+
(unknownByBook.get(b.bookKey) ?? []).map((u) => ({ bookKey: b.bookKey, ...u })),
|
|
1522
|
+
);
|
|
1523
|
+
const defaultUnknownCount = defaultBookKey === undefined ? 0 : (unknownByBook.get(defaultBookKey) ?? []).length;
|
|
1524
|
+
const unknownBadgeHtml =
|
|
1525
|
+
allUnknown.length > 0
|
|
1526
|
+
? [
|
|
1527
|
+
`<details class="unknown-badge" data-unknown-badge="true" data-unknown-count="${attr(String(defaultUnknownCount))}">`,
|
|
1528
|
+
`<summary data-unknown-badge-summary="true">未知票 ×${defaultUnknownCount}</summary>`,
|
|
1529
|
+
`<ul>`,
|
|
1530
|
+
...allUnknown.map(
|
|
1531
|
+
(u) =>
|
|
1532
|
+
`<li><span class="unknown-item" data-unknown-item="${attr(String(u.issueNumber))}" data-book="${attr(u.bookKey)}">#${escapeHtml(String(u.issueNumber))} · ${escapeHtml(u.title)}(${escapeHtml(u.bookKey)})</span></li>`,
|
|
1533
|
+
),
|
|
1534
|
+
`</ul>`,
|
|
1535
|
+
`</details>`,
|
|
1536
|
+
].join("")
|
|
1537
|
+
: "";
|
|
1538
|
+
|
|
1539
|
+
const lifecycleAttrs = refreshActive
|
|
1540
|
+
? ` data-lifecycle="refresh" data-refresh-boundary-seconds="${attr(String(refreshBoundarySeconds))}"`
|
|
1541
|
+
: ` data-lifecycle="oneshot"`;
|
|
1542
|
+
const refreshMeta = refreshActive
|
|
1543
|
+
? `
|
|
1544
|
+
<meta http-equiv="refresh" content="${attr(String(refreshBoundarySeconds))}"/>`
|
|
1545
|
+
: "";
|
|
1546
|
+
const refreshNote = refreshActive
|
|
1547
|
+
? `\n · refresh ≤ ${escapeHtml(String(refreshBoundarySeconds))}s`
|
|
1548
|
+
: "";
|
|
1549
|
+
|
|
1550
|
+
return `<!DOCTYPE html>
|
|
1551
|
+
<html lang="zh-CN" data-generated-at="${attr(generatedAt)}" data-board="true" data-threshold-flying-ms="${attr(String(UNACCEPTED_FLYING_MS))}" data-threshold-watch-ms="${attr(String(UNACCEPTED_WATCH_MS))}"${lifecycleAttrs}>
|
|
1552
|
+
<head>
|
|
1553
|
+
<meta charset="utf-8"/>
|
|
1554
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>${refreshMeta}
|
|
1555
|
+
<title>工厂进度板</title>
|
|
1556
|
+
<style>${boardStyles()}
|
|
1557
|
+
</style>
|
|
1558
|
+
</head>
|
|
1559
|
+
<body>
|
|
1560
|
+
<header class="page">
|
|
1561
|
+
<h1>工厂进度板</h1>
|
|
1562
|
+
<p class="generated">生成于 <time datetime="${attr(generatedAt)}">${escapeHtml(formatLocalDateTime(generatedAt))}</time>${refreshNote}</p>
|
|
1563
|
+
<p class="thresholds" data-thresholds="true">
|
|
1564
|
+
<span>未受理阈值:</span>
|
|
1565
|
+
<span data-threshold-flying-ms="${attr(String(UNACCEPTED_FLYING_MS))}"><${escapeHtml(formatDurationZh(UNACCEPTED_FLYING_MS))} 在飞</span>
|
|
1566
|
+
<span data-threshold-watch-ms="${attr(String(UNACCEPTED_WATCH_MS))}">${escapeHtml(formatDurationZh(UNACCEPTED_FLYING_MS))}–${escapeHtml(formatDurationZh(UNACCEPTED_WATCH_MS))} 观察</span>
|
|
1567
|
+
<span data-threshold-suspect="true">>${escapeHtml(formatDurationZh(UNACCEPTED_WATCH_MS))} 疑挂</span>
|
|
1568
|
+
</p>
|
|
1569
|
+
<p class="legend" data-legend="true">
|
|
1570
|
+
<span>图例:圆点=状态 · 方色=衙门 · 列=票据当前所在衙门机械归位</span>
|
|
1571
|
+
</p>
|
|
1572
|
+
<p class="controls">
|
|
1573
|
+
<label>项目
|
|
1574
|
+
<select data-project-filter="true">
|
|
1575
|
+
${projectOptions}
|
|
1576
|
+
</select>
|
|
1577
|
+
</label>
|
|
1578
|
+
<label>族
|
|
1579
|
+
<select data-family-filter="true">
|
|
1580
|
+
${familyOptionsHtml}
|
|
1581
|
+
</select>
|
|
1582
|
+
</label>
|
|
1583
|
+
<label>排序
|
|
1584
|
+
<select data-sort-control="true">
|
|
1585
|
+
<option value="ticket-asc" selected>票号</option>
|
|
1586
|
+
<option value="cost-desc">烧钱↓</option>
|
|
1587
|
+
<option value="cost-asc">烧钱↑</option>
|
|
1588
|
+
</select>
|
|
1589
|
+
</label>
|
|
1590
|
+
${unknownBadgeHtml}
|
|
1591
|
+
</p>
|
|
1592
|
+
</header>
|
|
1593
|
+
<main data-lane-count="${laneHtmlParts.length}">
|
|
1594
|
+
${laneHtmlParts.join("\n") || "<p data-empty-board=\"true\">no books in snapshot</p>"}
|
|
1595
|
+
</main>
|
|
1596
|
+
${boardPageScript()}
|
|
1597
|
+
</body>
|
|
1598
|
+
</html>
|
|
1599
|
+
`;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
async function assertOutputOutsideAllLedgers(
|
|
1603
|
+
books: readonly FactoryBoardBook[],
|
|
1604
|
+
outputPath: string,
|
|
1605
|
+
): Promise<{ ledgerRoots: string[]; outputAbsolute: string }> {
|
|
1606
|
+
const outputAbsolute = resolve(outputPath);
|
|
1607
|
+
const ledgerRoots: string[] = [];
|
|
1608
|
+
for (const book of books) {
|
|
1609
|
+
const ledgerResolved = resolve(book.ledgerDir);
|
|
1610
|
+
let ledgerRoot: string;
|
|
1611
|
+
try {
|
|
1612
|
+
ledgerRoot = await realpath(ledgerResolved);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
if (!isMissingPathError(error)) throw error;
|
|
1615
|
+
ledgerRoot = ledgerResolved;
|
|
1616
|
+
}
|
|
1617
|
+
ledgerRoots.push(ledgerRoot);
|
|
1618
|
+
|
|
1619
|
+
const missingTail: string[] = [];
|
|
1620
|
+
let cursor = outputAbsolute;
|
|
1621
|
+
for (;;) {
|
|
1622
|
+
try {
|
|
1623
|
+
await lstat(cursor);
|
|
1624
|
+
break;
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
if (!isMissingPathError(error)) throw error;
|
|
1627
|
+
const parent = dirname(cursor);
|
|
1628
|
+
if (parent === cursor) break;
|
|
1629
|
+
missingTail.push(basename(cursor));
|
|
1630
|
+
cursor = parent;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
let realPrefix: string;
|
|
1634
|
+
try {
|
|
1635
|
+
realPrefix = await realpath(cursor);
|
|
1636
|
+
} catch (error) {
|
|
1637
|
+
if (!isMissingPathError(error)) throw error;
|
|
1638
|
+
realPrefix = resolve(cursor);
|
|
1639
|
+
}
|
|
1640
|
+
const prospectiveReal =
|
|
1641
|
+
missingTail.length === 0 ? realPrefix : resolve(realPrefix, ...missingTail.reverse());
|
|
1642
|
+
if (
|
|
1643
|
+
isPathInside(ledgerRoot, prospectiveReal) ||
|
|
1644
|
+
isPathInside(ledgerRoot, realPrefix) ||
|
|
1645
|
+
isPathInside(ledgerRoot, outputAbsolute)
|
|
1646
|
+
) {
|
|
1647
|
+
throw new Error("factory board outputPath must be outside every ledger directory");
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return { ledgerRoots, outputAbsolute };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async function writeHtmlAtomically(outputAbsolute: string, html: string, ledgerRoots: string[]): Promise<string> {
|
|
1654
|
+
const parent = dirname(outputAbsolute);
|
|
1655
|
+
await mkdir(parent, { recursive: true });
|
|
1656
|
+
const parentReal = await realpath(parent);
|
|
1657
|
+
for (const root of ledgerRoots) {
|
|
1658
|
+
if (isPathInside(root, parentReal) || isPathInside(root, resolve(parentReal, basename(outputAbsolute)))) {
|
|
1659
|
+
throw new Error("factory board outputPath must be outside every ledger directory");
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
const temporary = join(parent, `.factory-board-${randomUUID()}.html.tmp`);
|
|
1663
|
+
try {
|
|
1664
|
+
await writeFile(temporary, html, "utf8");
|
|
1665
|
+
await rename(temporary, outputAbsolute);
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
1668
|
+
throw error;
|
|
1669
|
+
}
|
|
1670
|
+
return realpath(outputAbsolute);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
export async function writeFactoryBoardPage(input: {
|
|
1674
|
+
books: readonly FactoryBoardBook[];
|
|
1675
|
+
view: FactoryBoardView;
|
|
1676
|
+
now: Date;
|
|
1677
|
+
outputPath: string;
|
|
1678
|
+
refreshBoundarySeconds?: number;
|
|
1679
|
+
}): Promise<{ outputPath: string; html: string }> {
|
|
1680
|
+
const gate = await assertOutputOutsideAllLedgers(input.books, input.outputPath);
|
|
1681
|
+
const html = await renderFactoryBoardHtml(
|
|
1682
|
+
input.books,
|
|
1683
|
+
input.view,
|
|
1684
|
+
input.now,
|
|
1685
|
+
input.refreshBoundarySeconds !== undefined
|
|
1686
|
+
? { refreshBoundarySeconds: input.refreshBoundarySeconds }
|
|
1687
|
+
: undefined,
|
|
1688
|
+
);
|
|
1689
|
+
const outputPath = await writeHtmlAtomically(gate.outputAbsolute, html, gate.ledgerRoots);
|
|
1690
|
+
return { outputPath, html };
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
const defaultBoardScheduler: FactoryBoardScheduler = {
|
|
1694
|
+
every(ms, tick) {
|
|
1695
|
+
const timer = setInterval(tick, ms);
|
|
1696
|
+
timer.unref?.();
|
|
1697
|
+
return () => clearInterval(timer);
|
|
1698
|
+
},
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Production board lifecycle: write immediately, then regenerate on the declared
|
|
1703
|
+
* refresh boundary so the same viewing surface observes new runs / generated-at.
|
|
1704
|
+
* Snapshot bindings stay fixed; each tick re-reads ledgers (read-only).
|
|
1705
|
+
* Stop cancels further regeneration. A post-start regeneration failure faults the
|
|
1706
|
+
* lifecycle with the original cause (no silent continuation).
|
|
1707
|
+
*/
|
|
1708
|
+
export function startFactoryBoardPage(input: {
|
|
1709
|
+
books: readonly FactoryBoardBook[];
|
|
1710
|
+
/** Fixed snapshot view (one-shot-style watch). Omit when loadView is supplied. */
|
|
1711
|
+
view?: FactoryBoardView;
|
|
1712
|
+
/**
|
|
1713
|
+
* Snapshot loader invoked for every write, including the first (#162: watch does
|
|
1714
|
+
* not pin the startup snapshot — retention candidates refresh each tick).
|
|
1715
|
+
*/
|
|
1716
|
+
loadView?: () => Promise<FactoryBoardView>;
|
|
1717
|
+
outputPath: string;
|
|
1718
|
+
refreshBoundarySeconds?: number;
|
|
1719
|
+
clock?: FactoryBoardClock;
|
|
1720
|
+
scheduler?: FactoryBoardScheduler;
|
|
1721
|
+
}): FactoryBoardPageHandle {
|
|
1722
|
+
const refreshBoundarySeconds = input.refreshBoundarySeconds ?? DEFAULT_REFRESH_BOUNDARY_SECONDS;
|
|
1723
|
+
if (!(refreshBoundarySeconds > 0) || !Number.isFinite(refreshBoundarySeconds)) {
|
|
1724
|
+
throw new Error("refreshBoundarySeconds must be a positive finite number");
|
|
1725
|
+
}
|
|
1726
|
+
if (input.view === undefined && input.loadView === undefined) {
|
|
1727
|
+
throw new Error("view or loadView is required");
|
|
1728
|
+
}
|
|
1729
|
+
const clock = input.clock ?? (() => new Date());
|
|
1730
|
+
const scheduler = input.scheduler ?? defaultBoardScheduler;
|
|
1731
|
+
|
|
1732
|
+
let stopped = false;
|
|
1733
|
+
let cancel: (() => void) | undefined;
|
|
1734
|
+
let inFlight: Promise<void> | undefined;
|
|
1735
|
+
let lastRejection: unknown;
|
|
1736
|
+
let closedSettled = false;
|
|
1737
|
+
let resolveClosed!: () => void;
|
|
1738
|
+
let rejectClosed!: (error: unknown) => void;
|
|
1739
|
+
const closed = new Promise<void>((resolve, reject) => {
|
|
1740
|
+
resolveClosed = resolve;
|
|
1741
|
+
rejectClosed = reject;
|
|
1742
|
+
});
|
|
1743
|
+
void closed.catch(() => undefined);
|
|
1744
|
+
|
|
1745
|
+
const fault = (error: unknown): void => {
|
|
1746
|
+
if (lastRejection !== undefined) return;
|
|
1747
|
+
lastRejection = error;
|
|
1748
|
+
stopped = true;
|
|
1749
|
+
cancel?.();
|
|
1750
|
+
cancel = undefined;
|
|
1751
|
+
if (!closedSettled) {
|
|
1752
|
+
closedSettled = true;
|
|
1753
|
+
rejectClosed(error);
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
const settleClean = (): void => {
|
|
1758
|
+
if (closedSettled) return;
|
|
1759
|
+
closedSettled = true;
|
|
1760
|
+
resolveClosed();
|
|
1761
|
+
};
|
|
1762
|
+
|
|
1763
|
+
const writeOnce = async (): Promise<{ outputPath: string; html: string }> => {
|
|
1764
|
+
if (stopped && lastRejection === undefined) {
|
|
1765
|
+
throw new Error("factory board page lifecycle already stopped");
|
|
1766
|
+
}
|
|
1767
|
+
if (lastRejection !== undefined) throw lastRejection;
|
|
1768
|
+
const view = input.loadView !== undefined ? await input.loadView() : input.view!;
|
|
1769
|
+
return writeFactoryBoardPage({
|
|
1770
|
+
books: input.books,
|
|
1771
|
+
view,
|
|
1772
|
+
now: clock(),
|
|
1773
|
+
outputPath: input.outputPath,
|
|
1774
|
+
refreshBoundarySeconds,
|
|
1775
|
+
});
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
const queueWrite = (): void => {
|
|
1779
|
+
if (stopped || lastRejection !== undefined) return;
|
|
1780
|
+
inFlight = (inFlight ?? Promise.resolve()).then(async () => {
|
|
1781
|
+
if (stopped || lastRejection !== undefined) return;
|
|
1782
|
+
try {
|
|
1783
|
+
await writeOnce();
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
fault(error);
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
const started = writeOnce()
|
|
1791
|
+
.then((first) => {
|
|
1792
|
+
if (stopped || lastRejection !== undefined) return first;
|
|
1793
|
+
const intervalMs = Math.max(1, Math.round(refreshBoundarySeconds * 1000));
|
|
1794
|
+
cancel = scheduler.every(intervalMs, () => {
|
|
1795
|
+
queueWrite();
|
|
1796
|
+
});
|
|
1797
|
+
return first;
|
|
1798
|
+
})
|
|
1799
|
+
.catch((error) => {
|
|
1800
|
+
fault(error);
|
|
1801
|
+
throw error;
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
const outputPath = resolve(input.outputPath);
|
|
1805
|
+
|
|
1806
|
+
return {
|
|
1807
|
+
outputPath,
|
|
1808
|
+
started,
|
|
1809
|
+
closed,
|
|
1810
|
+
async stop() {
|
|
1811
|
+
stopped = true;
|
|
1812
|
+
cancel?.();
|
|
1813
|
+
cancel = undefined;
|
|
1814
|
+
await started.catch(() => undefined);
|
|
1815
|
+
if (inFlight) await inFlight.catch(() => undefined);
|
|
1816
|
+
if (lastRejection !== undefined) {
|
|
1817
|
+
throw lastRejection;
|
|
1818
|
+
}
|
|
1819
|
+
settleClean();
|
|
1820
|
+
},
|
|
1821
|
+
};
|
|
1822
|
+
}
|