create-principles-disciple 1.144.14 → 1.144.16
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/_release/product-identity.json +1 -1
- package/console/dist/server/routes/update-history.d.ts +14 -0
- package/console/dist/server/routes/update-history.js +59 -9
- package/console/dist/ui/i18n/en.json +1 -0
- package/console/dist/ui/i18n/zh-CN.json +1 -0
- package/console/dist/ui/pages/focus/FocusPage.js +7 -7
- package/console/dist/ui/pages/focus/focus-validation.d.ts +21 -0
- package/console/dist/ui/pages/focus/focus-validation.js +37 -1
- package/console/dist/ui/pages/principles/PrincipleDetailPage.js +1 -1
- package/console/dist/web/assets/app.js +21 -21
- package/dist/installer.d.ts +7 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js.map +1 -1
- package/dist/update/console-surface.d.ts +1 -1
- package/dist/update/console-surface.d.ts.map +1 -1
- package/dist/update/console-surface.js +1 -1
- package/dist/update/console-surface.js.map +1 -1
- package/dist/update/release-manager.d.ts +8 -0
- package/dist/update/release-manager.d.ts.map +1 -1
- package/dist/update/release-manager.js +22 -1
- package/dist/update/release-manager.js.map +1 -1
- package/dist/update/transaction-journal.d.ts +8 -0
- package/dist/update/transaction-journal.d.ts.map +1 -1
- package/dist/update/transaction-journal.js +7 -0
- package/dist/update/transaction-journal.js.map +1 -1
- package/package.json +2 -2
- package/plugin/dist/openclaw.plugin.json +1 -1
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/dist/installer.d.ts +7 -0
- package/release-manager/dist/installer.d.ts.map +1 -1
- package/release-manager/dist/installer.js.map +1 -1
- package/release-manager/dist/update/console-surface.d.ts +1 -1
- package/release-manager/dist/update/console-surface.d.ts.map +1 -1
- package/release-manager/dist/update/console-surface.js +1 -1
- package/release-manager/dist/update/console-surface.js.map +1 -1
- package/release-manager/dist/update/release-manager.d.ts +8 -0
- package/release-manager/dist/update/release-manager.d.ts.map +1 -1
- package/release-manager/dist/update/release-manager.js +22 -1
- package/release-manager/dist/update/release-manager.js.map +1 -1
- package/release-manager/dist/update/transaction-journal.d.ts +8 -0
- package/release-manager/dist/update/transaction-journal.d.ts.map +1 -1
- package/release-manager/dist/update/transaction-journal.js +7 -0
- package/release-manager/dist/update/transaction-journal.js.map +1 -1
- package/release-manager/package.json +2 -2
|
@@ -32,6 +32,20 @@ export declare function appendUpdateHistory(workspaceDir: string, entry: Omit<Up
|
|
|
32
32
|
* executor, the initiating Console may be dead when an update reaches a
|
|
33
33
|
* terminal state — the journal is the durable fact, and this derives any
|
|
34
34
|
* missing history entries from it (idempotent by transactionId; append-only).
|
|
35
|
+
*
|
|
36
|
+
* PRI-926 truthfulness rules:
|
|
37
|
+
* - Only journals in the ReleaseManager update domain
|
|
38
|
+
* (`isReleaseManagerTransactionId`) are Owner-facing update facts —
|
|
39
|
+
* installer/test transactions sharing ~/.pd/transactions/ never synthesize
|
|
40
|
+
* rows, and rows previously projected from outside the domain are dropped
|
|
41
|
+
* (a wrongly-projected row of this read model, not Owner data).
|
|
42
|
+
* - A synthesized row carries the journal's own truth: fromVersion from the
|
|
43
|
+
* planned line's `preUpdateProductVersion` (absence stays 'unknown' — the
|
|
44
|
+
* honest gap for pre-PRI-926 journals, rc-9), and the timestamp of the
|
|
45
|
+
* journal's first line, never the scan moment.
|
|
46
|
+
* - The synthesized id is deterministic (`reconciled-<transactionId>`), so a
|
|
47
|
+
* row evicted by the 50-entry cap cannot resurrect under a fresh id; merged
|
|
48
|
+
* rows are ordered by fact time.
|
|
35
49
|
*/
|
|
36
50
|
export declare function reconcileUpdateHistoryFromJournals(workspaceDir: string, pdHome: string): void;
|
|
37
51
|
export declare function handleUpdateHistoryRoute(req: IncomingMessage, res: ServerResponse, workspaceDir: string, _subPath: string): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import { isReleaseManagerTransactionId } from 'create-principles-disciple/update-console';
|
|
3
4
|
import { sendSuccess, sendMethodNotAllowed } from '../utils/response.js';
|
|
4
5
|
/** Historical authority values remain readable; only ReleaseManager writes new attempts. */
|
|
5
6
|
export const UPDATE_HISTORY_AUTHORITIES = ['release-manager', 'legacy-console-updater'];
|
|
@@ -62,6 +63,17 @@ function parseHistoryEntry(value) {
|
|
|
62
63
|
function getHistoryPath(workspaceDir) {
|
|
63
64
|
return path.join(workspaceDir, '.pd', 'update-history.json');
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* PRI-926: sort key for fact-time ordering. History rows span eras and
|
|
68
|
+
* formats (legacy records, journal `at` values are loosely read), so string
|
|
69
|
+
* comparison would mis-order mixed shapes like '…00Z' vs '…00.000Z' or
|
|
70
|
+
* non-UTC offsets — compare parsed instants instead. Unparseable values take
|
|
71
|
+
* a stable epoch fallback (sort oldest); they are never dropped.
|
|
72
|
+
*/
|
|
73
|
+
function factTimeMs(timestamp) {
|
|
74
|
+
const ms = Date.parse(timestamp);
|
|
75
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
76
|
+
}
|
|
65
77
|
function loadHistory(historyPath) {
|
|
66
78
|
if (fs.existsSync(historyPath)) {
|
|
67
79
|
try {
|
|
@@ -106,13 +118,32 @@ export function appendUpdateHistory(workspaceDir, entry) {
|
|
|
106
118
|
* executor, the initiating Console may be dead when an update reaches a
|
|
107
119
|
* terminal state — the journal is the durable fact, and this derives any
|
|
108
120
|
* missing history entries from it (idempotent by transactionId; append-only).
|
|
121
|
+
*
|
|
122
|
+
* PRI-926 truthfulness rules:
|
|
123
|
+
* - Only journals in the ReleaseManager update domain
|
|
124
|
+
* (`isReleaseManagerTransactionId`) are Owner-facing update facts —
|
|
125
|
+
* installer/test transactions sharing ~/.pd/transactions/ never synthesize
|
|
126
|
+
* rows, and rows previously projected from outside the domain are dropped
|
|
127
|
+
* (a wrongly-projected row of this read model, not Owner data).
|
|
128
|
+
* - A synthesized row carries the journal's own truth: fromVersion from the
|
|
129
|
+
* planned line's `preUpdateProductVersion` (absence stays 'unknown' — the
|
|
130
|
+
* honest gap for pre-PRI-926 journals, rc-9), and the timestamp of the
|
|
131
|
+
* journal's first line, never the scan moment.
|
|
132
|
+
* - The synthesized id is deterministic (`reconciled-<transactionId>`), so a
|
|
133
|
+
* row evicted by the 50-entry cap cannot resurrect under a fresh id; merged
|
|
134
|
+
* rows are ordered by fact time.
|
|
109
135
|
*/
|
|
110
136
|
export function reconcileUpdateHistoryFromJournals(workspaceDir, pdHome) {
|
|
111
137
|
const transactionsDir = path.join(pdHome, 'transactions');
|
|
112
138
|
if (!fs.existsSync(transactionsDir))
|
|
113
139
|
return;
|
|
114
140
|
const historyPath = getHistoryPath(workspaceDir);
|
|
115
|
-
const
|
|
141
|
+
const loaded = loadHistory(historyPath);
|
|
142
|
+
// A row WITHOUT a transactionId has no domain evidence either way — realtime
|
|
143
|
+
// and legacy records stay untouched. A transactionId OUTSIDE the update
|
|
144
|
+
// domain proves the row was projected from a non-update journal.
|
|
145
|
+
const history = loaded.filter((entry) => entry.transactionId === undefined || isReleaseManagerTransactionId(entry.transactionId));
|
|
146
|
+
const changedByCleanup = history.length !== loaded.length;
|
|
116
147
|
const journaledIds = new Set(history
|
|
117
148
|
.filter((entry) => typeof entry.transactionId === 'string' && entry.transactionId.length > 0)
|
|
118
149
|
.map((entry) => entry.transactionId));
|
|
@@ -122,47 +153,66 @@ export function reconcileUpdateHistoryFromJournals(workspaceDir, pdHome) {
|
|
|
122
153
|
failed: { kind: 'failure', success: false, toVersion: 'failed' },
|
|
123
154
|
refused: { kind: 'refusal', success: false, toVersion: '' },
|
|
124
155
|
};
|
|
125
|
-
let
|
|
156
|
+
let added = 0;
|
|
126
157
|
for (const entry of fs.readdirSync(transactionsDir)) {
|
|
127
158
|
if (!entry.endsWith('.jsonl'))
|
|
128
159
|
continue;
|
|
129
160
|
const transactionId = entry.slice(0, -'.jsonl'.length);
|
|
161
|
+
if (!isReleaseManagerTransactionId(transactionId))
|
|
162
|
+
continue;
|
|
130
163
|
if (journaledIds.has(transactionId))
|
|
131
164
|
continue;
|
|
132
165
|
let lastState = null;
|
|
133
166
|
let productVersion = null;
|
|
167
|
+
let fromVersion = null;
|
|
168
|
+
let firstAt = null;
|
|
134
169
|
try {
|
|
135
170
|
const raw = fs.readFileSync(path.join(transactionsDir, entry), 'utf-8');
|
|
136
171
|
for (const line of raw.split('\n')) {
|
|
137
172
|
if (line.trim().length === 0)
|
|
138
173
|
continue;
|
|
139
|
-
const
|
|
174
|
+
const parsed = JSON.parse(line);
|
|
175
|
+
const { to: transitionTo, productVersion: transitionProductVersion } = parsed;
|
|
140
176
|
if (typeof transitionTo === 'string')
|
|
141
177
|
lastState = transitionTo;
|
|
142
178
|
if (typeof transitionProductVersion === 'string')
|
|
143
179
|
productVersion = transitionProductVersion;
|
|
180
|
+
// First-line truth: the planned transition carries the transaction's
|
|
181
|
+
// moment and the version the update started FROM (PRI-926).
|
|
182
|
+
if (firstAt === null && typeof parsed.at === 'string' && parsed.at.length > 0)
|
|
183
|
+
firstAt = parsed.at;
|
|
184
|
+
if (fromVersion === null && typeof parsed.preUpdateProductVersion === 'string' && parsed.preUpdateProductVersion.length > 0) {
|
|
185
|
+
fromVersion = parsed.preUpdateProductVersion;
|
|
186
|
+
}
|
|
144
187
|
}
|
|
145
188
|
}
|
|
146
189
|
catch {
|
|
147
190
|
// unreadable journal — recovery diagnostics own that; skip for history
|
|
148
191
|
continue;
|
|
149
192
|
}
|
|
150
|
-
|
|
193
|
+
// External input indexes a plain-object table — guard with Object.hasOwn
|
|
194
|
+
// before the lookup (ERR-013: '__proto__' must not resolve a mapping).
|
|
195
|
+
let mapping;
|
|
196
|
+
if (lastState !== null && Object.hasOwn(terminalKind, lastState)) {
|
|
197
|
+
mapping = terminalKind[lastState];
|
|
198
|
+
}
|
|
151
199
|
if (mapping === undefined)
|
|
152
200
|
continue;
|
|
153
201
|
history.push({
|
|
154
|
-
fromVersion: 'unknown',
|
|
202
|
+
fromVersion: fromVersion ?? 'unknown',
|
|
155
203
|
toVersion: mapping.toVersion === '' ? (productVersion ?? 'unknown') : mapping.toVersion,
|
|
156
204
|
success: mapping.success,
|
|
157
205
|
kind: mapping.kind,
|
|
158
206
|
authority: 'release-manager',
|
|
159
207
|
transactionId,
|
|
160
|
-
id: `
|
|
161
|
-
timestamp: new Date().toISOString(),
|
|
208
|
+
id: `reconciled-${transactionId}`,
|
|
209
|
+
timestamp: firstAt ?? new Date().toISOString(),
|
|
162
210
|
});
|
|
163
|
-
|
|
211
|
+
added += 1;
|
|
164
212
|
}
|
|
165
|
-
if (
|
|
213
|
+
if (added > 0 || changedByCleanup) {
|
|
214
|
+
// Order by fact time (stable for equal instants), then keep the newest 50.
|
|
215
|
+
history.sort((a, b) => factTimeMs(a.timestamp) - factTimeMs(b.timestamp));
|
|
166
216
|
if (history.length > 50) {
|
|
167
217
|
history.splice(0, history.length - 50);
|
|
168
218
|
}
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
"evidenceSummary": "Evidence: {{count}} record(s)",
|
|
98
98
|
"untitledCandidate": "Untitled candidate principle",
|
|
99
99
|
"artifactUnavailableNote": "The draft artifact this approval points to is no longer in the artifact store (likely superseded by a newer revision), so its content cannot be shown. If in doubt, reject it; the raw artifact ID is shown in the edit section's current-artifact field.",
|
|
100
|
+
"unlinkedCandidateNote": "No principle name is available for this card: its lineage cannot be resolved to a principle, and no readable title could be extracted from its stored content. Judge it from the evidence summary below; if you cannot decide, you can reject it.",
|
|
100
101
|
"approveAction": "Approve",
|
|
101
102
|
"rejectAction": "Reject",
|
|
102
103
|
"editAction": "Edit",
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
"evidenceSummary": "证据:{{count}} 条记录",
|
|
98
98
|
"untitledCandidate": "待命名候选原则",
|
|
99
99
|
"artifactUnavailableNote": "该审批指向的草稿原件已不在工件库中(可能已被新版本取代),内容无法显示。如无法判断,可选择拒绝;原始工件编号可点「编辑」后在「当前工件」一栏查看。",
|
|
100
|
+
"unlinkedCandidateNote": "这张卡片没有可展示的原则名称:无法把它的血缘解析到某条原则,也没能从其存档内容里提取出可读的标题。请依据下方的证据摘要判断;如无法判断,可选择拒绝。",
|
|
100
101
|
"approveAction": "批准",
|
|
101
102
|
"rejectAction": "拒绝",
|
|
102
103
|
"editAction": "编辑",
|
|
@@ -9,7 +9,7 @@ import { SectionTitle } from "../../components/layout/section-title.js";
|
|
|
9
9
|
import { DailyThoughtCard } from "../../components/focus/daily-thought-card.js";
|
|
10
10
|
import { ShinyText } from "../../components/ui/shiny-text.js";
|
|
11
11
|
import { fetchGovernanceQueue, fetchApprovalsGrouped, approveApproval, rejectApproval, editApproval, fetchAllActivations, fetchOwnerDecisions, } from "../../api.js";
|
|
12
|
-
import { selectApprovalGroupDisplayTitle, validateApprovalsGroupedData } from "./focus-validation.js";
|
|
12
|
+
import { selectApprovalGroupDisplayTitle, showsUnlinkedCandidateNote, validateApprovalsGroupedData } from "./focus-validation.js";
|
|
13
13
|
import { OwnerDecisionCard } from "./OwnerDecisionCard.js";
|
|
14
14
|
import { localizeApprovalWarning, splitApprovalWarnings } from "../../utils/approval-warning-localization.js";
|
|
15
15
|
/** PRI-908: how long the decided card stays visible before the pending list refresh removes it. */
|
|
@@ -178,12 +178,12 @@ function PendingReviewCard({ group, actionsLockedReason, onDecisionApplied, prom
|
|
|
178
178
|
}
|
|
179
179
|
// Display title: prefer candidateDescription (human-readable) over principleTitle
|
|
180
180
|
// (which falls back to a fabricated principleId when the principle isn't in ledger).
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// degradation note below explains why there is no content.
|
|
181
|
+
// The selection rule and each degradation reason live in focus-validation
|
|
182
|
+
// (PRI-940 / PRI-941, contract-tested); the mutually exclusive notes below say
|
|
183
|
+
// why a particular card has no title.
|
|
185
184
|
const displayTitle = selectApprovalGroupDisplayTitle(group)
|
|
186
185
|
?? t("pages.focus.untitledCandidate", { defaultValue: "待命名候选原则" });
|
|
186
|
+
const unlinkedCandidateNote = showsUnlinkedCandidateNote(group);
|
|
187
187
|
async function applyDecisionToAllRecords(action, reason) {
|
|
188
188
|
// Only process pending records — skip already-approved/rejected to avoid
|
|
189
189
|
// stable partial failures on mixed-status groups.
|
|
@@ -309,7 +309,7 @@ function PendingReviewCard({ group, actionsLockedReason, onDecisionApplied, prom
|
|
|
309
309
|
? "border-green/40 bg-green/10 text-green"
|
|
310
310
|
: "border-line bg-surface/80 text-ink-3"}`, role: "status", "data-testid": `decided-banner-${group.principleId}`, children: decidedOutcome === "approved"
|
|
311
311
|
? t("pages.focus.decidedApproved", { defaultValue: "已批准 ✓ 正在写入生效记录…" })
|
|
312
|
-
: t("pages.focus.decidedRejected", { defaultValue: "已拒绝" }) })), _jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "inline-flex items-center border border-line rounded-[2px] px-[7px] py-1 font-mono text-[11px] text-ink-3 bg-surface/80 uppercase", children: channelLabel }), isReversible && (_jsx("span", { className: "inline-flex items-center border border-amber/35 text-amber rounded-[2px] px-[7px] py-1 font-mono text-[11px] uppercase", children: primaryChannel === "prompt" ? t("pages.focus.tagReversible") : t("pages.focus.tagLowRisk") }))] }), _jsx("div", { className: "mt-[14px] mb-2 font-semibold text-ink leading-snug", children: displayTitle }), group.artifactUnavailable === true && (_jsx("div", { className: "mb-2 text-[12.5px] leading-relaxed text-amber", "data-testid": `artifact-unavailable-${group.principleId}`, children: t("pages.focus.artifactUnavailableNote") })), _jsx("div", { className: "mt-2 px-3 py-2 bg-surface/60 border-l-2 border-gov text-ink-3 text-[13px] leading-snug", children: t("pages.focus.evidenceSummary", { count: group.records.length }) }), actionsLocked && (_jsxs("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 p-3", "data-testid": `pending-actions-locked-${group.principleId}`, children: [_jsx("p", { className: "text-ink-2 text-[12.5px] leading-relaxed", children: actionsLockedReason }), _jsx(Link, { to: "/settings", "data-testid": `pending-actions-locked-settings-${group.principleId}`, className: "mt-2 inline-flex items-center border border-line text-ink bg-surface rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors", children: t("pages.focus.ownerDecision.goSettingsCta") })] })), decidedOutcome === null && primaryChannel === "prompt" && promptInjection?.truncated === true && (_jsx("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 px-3 py-2 text-[12.5px] text-amber", "data-testid": `approve-queue-badge-${group.principleId}`, children: promptInjection.productionRotates === true && group.fitsPromptBudget === true
|
|
312
|
+
: t("pages.focus.decidedRejected", { defaultValue: "已拒绝" }) })), _jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "inline-flex items-center border border-line rounded-[2px] px-[7px] py-1 font-mono text-[11px] text-ink-3 bg-surface/80 uppercase", children: channelLabel }), isReversible && (_jsx("span", { className: "inline-flex items-center border border-amber/35 text-amber rounded-[2px] px-[7px] py-1 font-mono text-[11px] uppercase", children: primaryChannel === "prompt" ? t("pages.focus.tagReversible") : t("pages.focus.tagLowRisk") }))] }), _jsx("div", { className: "mt-[14px] mb-2 font-semibold text-ink leading-snug", children: displayTitle }), group.artifactUnavailable === true && (_jsx("div", { className: "mb-2 text-[12.5px] leading-relaxed text-amber", "data-testid": `artifact-unavailable-${group.principleId}`, children: t("pages.focus.artifactUnavailableNote") })), unlinkedCandidateNote && (_jsx("div", { className: "mb-2 text-[12.5px] leading-relaxed text-amber", "data-testid": `unlinked-candidate-${group.principleId}`, children: t("pages.focus.unlinkedCandidateNote") })), _jsx("div", { className: "mt-2 px-3 py-2 bg-surface/60 border-l-2 border-gov text-ink-3 text-[13px] leading-snug", children: t("pages.focus.evidenceSummary", { count: group.records.length }) }), actionsLocked && (_jsxs("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 p-3", "data-testid": `pending-actions-locked-${group.principleId}`, children: [_jsx("p", { className: "text-ink-2 text-[12.5px] leading-relaxed", children: actionsLockedReason }), _jsx(Link, { to: "/settings", "data-testid": `pending-actions-locked-settings-${group.principleId}`, className: "mt-2 inline-flex items-center border border-line text-ink bg-surface rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors", children: t("pages.focus.ownerDecision.goSettingsCta") })] })), decidedOutcome === null && primaryChannel === "prompt" && promptInjection?.truncated === true && (_jsx("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 px-3 py-2 text-[12.5px] text-amber", "data-testid": `approve-queue-badge-${group.principleId}`, children: promptInjection.productionRotates === true && group.fitsPromptBudget === true
|
|
313
313
|
? t("pages.focus.approveQueueBadgeRotation", {
|
|
314
314
|
defaultValue: "批准后可能排队:提示词注入位已满({{used}}/{{budget}} 字符)。当前按公平轮转选取,新原则会在后续轮次自行轮到,无需停用旧原则。",
|
|
315
315
|
used: promptInjection.usedChars,
|
|
@@ -325,7 +325,7 @@ function PendingReviewCard({ group, actionsLockedReason, onDecisionApplied, prom
|
|
|
325
325
|
defaultValue: "批准后可能排队:提示词注入位已满({{used}}/{{budget}} 字符)。",
|
|
326
326
|
used: promptInjection.usedChars,
|
|
327
327
|
budget: promptInjection.budget,
|
|
328
|
-
}) })), _jsxs("div", { className: "flex gap-2 mt-4 flex-wrap items-center", children: [_jsx("button", { type: "button", onClick: handleApprove, disabled: decisionDisabled, "data-testid": `approve-btn-${group.principleId}`, className: "inline-flex items-center border border-gov bg-gov text-paper rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:bg-gov-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: actionLoading ? t("common.loading") + "…" : t("pages.focus.approveAction", { defaultValue: "批准" }) }), _jsx("button", { type: "button", onClick: () => { setShowEditInput((v) => !v); setShowRejectInput(false); }, disabled: decisionDisabled, "data-testid": `edit-btn-${group.principleId}`, className: "inline-flex items-center border border-line bg-surface text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.focus.editAction", { defaultValue: "编辑" }) }), _jsx("button", { type: "button", onClick: () => { setShowRejectInput((v) => !v); setShowEditInput(false); }, disabled: decisionDisabled, "data-testid": `reject-btn-${group.principleId}`, className: "inline-flex items-center border border-line bg-surface text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.focus.rejectAction", { defaultValue: "拒绝" }) }), _jsx(Link, { to: "/
|
|
328
|
+
}) })), _jsxs("div", { className: "flex gap-2 mt-4 flex-wrap items-center", children: [_jsx("button", { type: "button", onClick: handleApprove, disabled: decisionDisabled, "data-testid": `approve-btn-${group.principleId}`, className: "inline-flex items-center border border-gov bg-gov text-paper rounded-[3px] px-[14px] py-[6px] text-[12.5px] font-medium hover:bg-gov-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: actionLoading ? t("common.loading") + "…" : t("pages.focus.approveAction", { defaultValue: "批准" }) }), _jsx("button", { type: "button", onClick: () => { setShowEditInput((v) => !v); setShowRejectInput(false); }, disabled: decisionDisabled, "data-testid": `edit-btn-${group.principleId}`, className: "inline-flex items-center border border-line bg-surface text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.focus.editAction", { defaultValue: "编辑" }) }), _jsx("button", { type: "button", onClick: () => { setShowRejectInput((v) => !v); setShowEditInput(false); }, disabled: decisionDisabled, "data-testid": `reject-btn-${group.principleId}`, className: "inline-flex items-center border border-line bg-surface text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.focus.rejectAction", { defaultValue: "拒绝" }) }), _jsx(Link, { to: "/pain", className: "inline-flex items-center text-gov text-[12.5px] hover:underline focus-visible:outline-2 focus-visible:outline-gov focus-visible:outline-offset-2", children: t("pages.focus.viewFullChain") })] }), showRejectInput && (_jsxs("div", { className: "mt-3 p-3 border border-danger/20 rounded-[var(--radius-md)]", children: [_jsx("label", { className: "block font-mono text-[11px] uppercase tracking-[0.08em] text-ink-3 mb-2", children: t("pages.focus.rejectReasonLabel", { defaultValue: "拒绝原因(必填)" }) }), _jsx("textarea", { value: rejectReason, onChange: (e) => setRejectReason(e.target.value), "data-testid": `reject-reason-${group.principleId}`, placeholder: t("pages.focus.rejectReasonPlaceholder", { defaultValue: "请说明拒绝原因" }), className: "w-full border border-line rounded-[var(--radius-md)] bg-surface text-ink px-3 py-2 text-[13px] min-h-[80px] resize-y focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gov" }), _jsxs("div", { className: "flex gap-2 mt-2", children: [_jsx("button", { type: "button", onClick: handleReject, disabled: !rejectReason.trim() || actionLoading, "data-testid": `confirm-reject-${group.principleId}`, className: "inline-flex items-center border border-danger text-danger bg-surface rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:bg-danger/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: t("pages.focus.confirmReject", { defaultValue: "确认拒绝" }) }), _jsx("button", { type: "button", onClick: () => { setShowRejectInput(false); setRejectReason(""); }, className: "inline-flex items-center border border-line bg-surface text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors", children: t("pages.focus.cancelAction", { defaultValue: "取消" }) })] })] })), showEditInput && (_jsxs("div", { className: "mt-3 p-3 border border-gov/20 rounded-[var(--radius-md)]", children: [_jsx("label", { className: "block font-mono text-[11px] uppercase tracking-[0.08em] text-ink-3 mb-2", children: t("pages.focus.editReasonLabel", { defaultValue: "编辑原因(必填)" }) }), _jsxs("div", { className: "mb-2 text-ink-4 text-[12px] font-mono", children: [t("pages.focus.currentArtifactLabel", { defaultValue: "当前工件" }), ": ", currentArtifactId] }), _jsx("input", { type: "text", value: newArtifactId, onChange: (e) => setNewArtifactId(e.target.value), "data-testid": `edit-new-artifact-${group.principleId}`, placeholder: t("pages.focus.editNewArtifactPlaceholder", { defaultValue: "输入新的已验证工件 ID" }), className: "w-full border border-line rounded-[var(--radius-md)] bg-surface text-ink px-3 py-2 text-[13px] mb-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gov", "aria-label": t("pages.focus.editNewArtifactPlaceholder", { defaultValue: "输入新的已验证工件 ID" }) }), _jsx("textarea", { value: editReason, onChange: (e) => setEditReason(e.target.value), "data-testid": `edit-reason-${group.principleId}`, placeholder: t("pages.focus.editReasonPlaceholder", { defaultValue: "请说明编辑原因" }), className: "w-full border border-line rounded-[var(--radius-md)] bg-surface text-ink px-3 py-2 text-[13px] min-h-[80px] resize-y focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gov" }), _jsxs("div", { className: "flex gap-2 mt-2", children: [_jsx("button", { type: "button", onClick: handleEdit, disabled: !editReason.trim() || !newArtifactId.trim() || actionLoading, "data-testid": `confirm-edit-${group.principleId}`, className: "inline-flex items-center border border-gov text-gov bg-surface rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:bg-gov/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: t("pages.focus.confirmEdit", { defaultValue: "确认编辑" }) }), _jsx("button", { type: "button", onClick: () => { setShowEditInput(false); setEditReason(""); setNewArtifactId(""); }, className: "inline-flex items-center border border-line bg-surface text-ink rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors", children: t("pages.focus.cancelAction", { defaultValue: "取消" }) })] })] }))] }));
|
|
329
329
|
}
|
|
330
330
|
function StagnationSignalCard({ signal }) {
|
|
331
331
|
const { t } = useTranslation();
|
|
@@ -15,5 +15,26 @@ export declare function validateApprovalsGroupedData(raw: unknown): ApprovalsGro
|
|
|
15
15
|
* revision rendered `unlinked:pi-art-scribe-…` on the card). Returns undefined
|
|
16
16
|
* for that case so the caller falls back to the localized untitled copy; the
|
|
17
17
|
* degradation itself is explained by the artifactUnavailable note, not here.
|
|
18
|
+
*
|
|
19
|
+
* PRI-941 closes the second path to the same key: the artifact row exists, so
|
|
20
|
+
* `artifactUnavailable` is never set, yet it yields no description and its
|
|
21
|
+
* lineage still cannot be resolved. The grouping key — and therefore
|
|
22
|
+
* `principleTitle` — is the machine id either way, so the prefix check guards
|
|
23
|
+
* the render rule itself instead of relying on one producer's flag.
|
|
24
|
+
*
|
|
25
|
+
* What this predicate cannot catch: the prefix only marks unresolved lineage.
|
|
26
|
+
* When the ledger read fails, groups whose lineage DID resolve fall back to a
|
|
27
|
+
* bare principle UUID (`principleTitles.get(id) ?? id`), which renders as a
|
|
28
|
+
* machine id with no note — recorded as a follow-up, not papered over here.
|
|
18
29
|
*/
|
|
19
30
|
export declare function selectApprovalGroupDisplayTitle(group: Pick<ApprovalGroup, "candidateDescription" | "principleTitle" | "artifactUnavailable">): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* PRI-941: an untitled card must say why (rc-9 — degradation may not be silent).
|
|
33
|
+
* The artifactUnavailable copy already covers "the draft artifact is gone"; this
|
|
34
|
+
* is the other reason the title ends up empty: the artifact row exists but
|
|
35
|
+
* yields no description, and its grouping key never resolved to a principle.
|
|
36
|
+
* Derived from the title rule itself so the note can never contradict a title
|
|
37
|
+
* the card actually managed to show — a candidate whose description extracted
|
|
38
|
+
* fine keeps its title and gets no warning.
|
|
39
|
+
*/
|
|
40
|
+
export declare function showsUnlinkedCandidateNote(group: Pick<ApprovalGroup, "candidateDescription" | "principleTitle" | "artifactUnavailable">): boolean;
|
|
@@ -112,6 +112,16 @@ export function validateApprovalsGroupedData(raw) {
|
|
|
112
112
|
promptInjection: validatePromptInjectionBudgetStatus(raw.promptInjection) ?? undefined,
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* The server synthesizes this grouping key when an approval's lineage cannot be
|
|
117
|
+
* resolved (`ApprovalsGroupedConsoleModel`: `unlinked:${artifactId}`). It is an
|
|
118
|
+
* internal identifier, never Owner copy.
|
|
119
|
+
*/
|
|
120
|
+
const UNLINKED_GROUPING_KEY_PREFIX = "unlinked:";
|
|
121
|
+
/** True when a title is the synthesized grouping key rather than a real name. */
|
|
122
|
+
function isUnlinkedSyntheticPrincipleTitle(title) {
|
|
123
|
+
return title.startsWith(UNLINKED_GROUPING_KEY_PREFIX);
|
|
124
|
+
}
|
|
115
125
|
/**
|
|
116
126
|
* PRI-940: which string may the pending-review card render as its title.
|
|
117
127
|
*
|
|
@@ -121,6 +131,17 @@ export function validateApprovalsGroupedData(raw) {
|
|
|
121
131
|
* revision rendered `unlinked:pi-art-scribe-…` on the card). Returns undefined
|
|
122
132
|
* for that case so the caller falls back to the localized untitled copy; the
|
|
123
133
|
* degradation itself is explained by the artifactUnavailable note, not here.
|
|
134
|
+
*
|
|
135
|
+
* PRI-941 closes the second path to the same key: the artifact row exists, so
|
|
136
|
+
* `artifactUnavailable` is never set, yet it yields no description and its
|
|
137
|
+
* lineage still cannot be resolved. The grouping key — and therefore
|
|
138
|
+
* `principleTitle` — is the machine id either way, so the prefix check guards
|
|
139
|
+
* the render rule itself instead of relying on one producer's flag.
|
|
140
|
+
*
|
|
141
|
+
* What this predicate cannot catch: the prefix only marks unresolved lineage.
|
|
142
|
+
* When the ledger read fails, groups whose lineage DID resolve fall back to a
|
|
143
|
+
* bare principle UUID (`principleTitles.get(id) ?? id`), which renders as a
|
|
144
|
+
* machine id with no note — recorded as a follow-up, not papered over here.
|
|
124
145
|
*/
|
|
125
146
|
export function selectApprovalGroupDisplayTitle(group) {
|
|
126
147
|
if (group.candidateDescription !== undefined && group.candidateDescription !== "") {
|
|
@@ -128,5 +149,20 @@ export function selectApprovalGroupDisplayTitle(group) {
|
|
|
128
149
|
}
|
|
129
150
|
if (group.artifactUnavailable === true)
|
|
130
151
|
return undefined;
|
|
131
|
-
return group.principleTitle;
|
|
152
|
+
return isUnlinkedSyntheticPrincipleTitle(group.principleTitle) ? undefined : group.principleTitle;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* PRI-941: an untitled card must say why (rc-9 — degradation may not be silent).
|
|
156
|
+
* The artifactUnavailable copy already covers "the draft artifact is gone"; this
|
|
157
|
+
* is the other reason the title ends up empty: the artifact row exists but
|
|
158
|
+
* yields no description, and its grouping key never resolved to a principle.
|
|
159
|
+
* Derived from the title rule itself so the note can never contradict a title
|
|
160
|
+
* the card actually managed to show — a candidate whose description extracted
|
|
161
|
+
* fine keeps its title and gets no warning.
|
|
162
|
+
*/
|
|
163
|
+
export function showsUnlinkedCandidateNote(group) {
|
|
164
|
+
if (group.artifactUnavailable === true)
|
|
165
|
+
return false;
|
|
166
|
+
return (isUnlinkedSyntheticPrincipleTitle(group.principleTitle)
|
|
167
|
+
&& selectApprovalGroupDisplayTitle(group) === undefined);
|
|
132
168
|
}
|
|
@@ -440,7 +440,7 @@ export function PrincipleDetailPage() {
|
|
|
440
440
|
}
|
|
441
441
|
}, className: "font-mono text-[11px] h-7", children: evidenceCopied
|
|
442
442
|
? t("principles.detail.evidenceCopied", { defaultValue: "已复制" })
|
|
443
|
-
: t("principles.detail.copyEvidenceId", { defaultValue: "复制证据 ID" }) }), _jsx("button", { type: "button", onClick: () => setEvidenceExpanded((v) => !v), className: "font-mono text-[11px] text-ink-4 hover:text-ink-3 transition-colors underline-offset-2 hover:underline", children: t("principles.detail.expandTechDetails", { defaultValue: "展开技术细节" }) }), _jsx(Link, { to: "/
|
|
443
|
+
: t("principles.detail.copyEvidenceId", { defaultValue: "复制证据 ID" }) }), _jsx("button", { type: "button", onClick: () => setEvidenceExpanded((v) => !v), className: "font-mono text-[11px] text-ink-4 hover:text-ink-3 transition-colors underline-offset-2 hover:underline", children: t("principles.detail.expandTechDetails", { defaultValue: "展开技术细节" }) }), _jsx(Link, { to: "/pain", className: "font-mono text-[11px] text-gov hover:underline transition-colors", children: t("principles.detail.viewEvidenceChain", { defaultValue: "在证据链页查看 →" }) })] }), evidenceExpanded && (_jsx("div", { className: "mt-3 p-3 bg-paper-2 border border-line rounded-[var(--radius-sm)]", children: _jsx("div", { className: "grid grid-cols-[auto 1fr] gap-x-4 gap-y-2 text-[13px]", children: principle.derivedFromPainIds.map((painId, idx) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "font-mono text-ink-4", children: t("principles.detail.evidenceIdLabel", {
|
|
444
444
|
defaultValue: "证据 {{n}} ID",
|
|
445
445
|
n: idx + 1,
|
|
446
446
|
}) }), _jsx("span", { className: "font-mono text-ink-2 break-all", children: painId })] }, `${painId}-${idx}`))) }) }))] }))] }), _jsxs("section", { className: "mb-8 border-t border-line pt-6", children: [_jsx(SectionTitle, { children: t("principles.detail.ownerReflection") }), _jsxs("div", { className: "space-y-3", children: [_jsx("div", { className: "border-l-2 border-l-gov pl-3 text-ink-2 text-[13px] leading-relaxed", children: t("principles.detail.reflectionQ1") }), _jsx("div", { className: "border-l-2 border-l-gov pl-3 text-ink-2 text-[13px] leading-relaxed", children: t("principles.detail.reflectionQ2") }), _jsx("div", { className: "border-l-2 border-l-gov pl-3 text-ink-2 text-[13px] leading-relaxed", children: t("principles.detail.reflectionQ3") })] })] }), _jsxs("details", { className: "mb-8 border border-line rounded-[var(--radius-md)]", children: [_jsx("summary", { className: "px-4 py-3 cursor-pointer font-mono text-[11px] uppercase tracking-[0.1em] text-ink-3 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gov", children: t("principles.detail.trajectory") }), _jsxs("div", { className: "px-4 pb-4 border-t border-line pt-4", children: [governance !== null && (_jsxs("section", { className: "mb-6", "data-testid": "governance-timeline", children: [_jsx(SectionTitle, { children: t('principles.detail.governance.timeline') }), governance.timeline.length === 0 ? _jsx("p", { className: "text-[13px] text-ink-4", children: t('principles.detail.governance.timelineEmpty') }) : (_jsx("ol", { className: "space-y-3 border-l border-line pl-4", children: governance.timeline.map((event, index) => {
|