- commit
- ffda03b
- parent
- fdfa382
- author
- codex@macbookpro
- date
- 2026-04-01 19:32:28 +0800 CST
refactor: dedupe renewal utility helpers
7 files changed,
+117,
-92
Raw patch view.
1diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
2index 7e4bae0409347254c8254e06adc62d930d5e305e..eef834882b46e5d18c389835464ce25e7b45e5b0 100644
3--- a/apps/conductor-daemon/src/index.test.js
4+++ b/apps/conductor-daemon/src/index.test.js
5@@ -13,6 +13,7 @@ import {
6 ArtifactStore
7 } from "../../../packages/artifact-db/dist/index.js";
8 import "./artifacts.test.js";
9+import "./renewal/utils.test.js";
10 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
11 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
12 import {
13diff --git a/apps/conductor-daemon/src/renewal/automation.ts b/apps/conductor-daemon/src/renewal/automation.ts
14index b1abf7f71d9a999e3980c7e123a6327bf6e760e6..605b5eafbc29e7befe448dcad9fcfebf88f8f1d8 100644
15--- a/apps/conductor-daemon/src/renewal/automation.ts
16+++ b/apps/conductor-daemon/src/renewal/automation.ts
17@@ -6,6 +6,8 @@ import type {
18 LocalConversationRecord
19 } from "../../../../packages/artifact-db/dist/index.js";
20
21+import { normalizeOptionalString } from "./utils.js";
22+
23 export const CONVERSATION_AUTOMATION_FAILURE_THRESHOLD = 3;
24 export const CONVERSATION_REPEATED_MESSAGE_THRESHOLD = 3;
25 export const CONVERSATION_REPEATED_RENEWAL_THRESHOLD = 3;
26@@ -198,12 +200,3 @@ function normalizeAutomationText(value: string): string {
27 .replace(/\s+/gu, " ")
28 .trim();
29 }
30-
31-function normalizeOptionalString(value: string | null | undefined): string | null {
32- if (value == null) {
33- return null;
34- }
35-
36- const normalized = value.trim();
37- return normalized === "" ? null : normalized;
38-}
39diff --git a/apps/conductor-daemon/src/renewal/conversations.ts b/apps/conductor-daemon/src/renewal/conversations.ts
40index ffb3e058675ae35444bc8f2e396a7cf9cc4fa08a..14f40335057990f307a9dca08d8419b6a16a5f35 100644
41--- a/apps/conductor-daemon/src/renewal/conversations.ts
42+++ b/apps/conductor-daemon/src/renewal/conversations.ts
43@@ -11,6 +11,10 @@ import type {
44
45 import type { BaaDeliveryRouteSnapshot } from "../artifacts/types.js";
46 import { buildConversationAutomationResetPatch } from "./automation.js";
47+import {
48+ normalizeOptionalString,
49+ normalizeRequiredString
50+} from "./utils.js";
51
52 const LOCAL_CONVERSATION_ID_PREFIX = "lc_";
53 const CONVERSATION_LINK_ID_PREFIX = "link_";
54@@ -141,7 +145,9 @@ export async function listRenewalConversationDetails(
55 export async function observeRenewalConversation(
56 input: ObserveRenewalConversationInput
57 ): Promise<ObserveRenewalConversationResult> {
58- const platform = normalizeRequiredString(input.platform, "platform");
59+ const platform = normalizeRequiredString(input.platform, "platform", {
60+ errorStyle: "field"
61+ });
62 const observedAt = normalizeTimestamp(input.observedAt, "observedAt");
63 const clientId = normalizeOptionalString(input.clientId);
64 const observedRoute = buildObservedRoute(platform, {
65@@ -198,7 +204,9 @@ export async function observeRenewalConversation(
66
67 conversation = await input.store.upsertLocalConversation({
68 lastMessageAt: observedAt,
69- lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId"),
70+ lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId", {
71+ errorStyle: "field"
72+ }),
73 localConversationId: conversation.localConversationId,
74 platform: conversation.platform,
75 title: observedRoute.pageTitle ?? undefined,
76@@ -220,7 +228,9 @@ export async function setRenewalConversationAutomationStatus(input: {
77 pauseReason?: ConversationPauseReason | null;
78 store: ArtifactStore;
79 }): Promise<RenewalConversationDetail> {
80- const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId");
81+ const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId", {
82+ errorStyle: "field"
83+ });
84 const existing = await input.store.getLocalConversation(localConversationId);
85
86 if (existing == null) {
87@@ -526,7 +536,9 @@ async function loadOrCreateLocalConversation(
88 automationStatus: "manual",
89 createdAt: input.observedAt,
90 lastMessageAt: input.observedAt,
91- lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId"),
92+ lastMessageId: normalizeRequiredString(input.assistantMessageId, "assistantMessageId", {
93+ errorStyle: "field"
94+ }),
95 localConversationId: buildLocalConversationId(),
96 platform: input.platform,
97 title: input.pageTitle,
98@@ -785,30 +797,11 @@ function scoreConversationLink(
99 };
100 }
101
102-function normalizeOptionalString(value: unknown): string | null {
103- if (typeof value !== "string") {
104- return null;
105- }
106-
107- const normalized = value.trim();
108- return normalized === "" ? null : normalized;
109-}
110-
111 function normalizePathname(value: string): string {
112 const normalized = value.replace(/\/+$/u, "");
113 return normalized === "" ? "/" : normalized;
114 }
115
116-function normalizeRequiredString(value: unknown, fieldName: string): string {
117- const normalized = normalizeOptionalString(value);
118-
119- if (normalized == null) {
120- throw new Error(`Field "${fieldName}" must be a non-empty string.`);
121- }
122-
123- return normalized;
124-}
125-
126 function normalizeTimestamp(value: unknown, fieldName: string): number {
127 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
128 throw new Error(`Field "${fieldName}" must be a positive finite number.`);
129diff --git a/apps/conductor-daemon/src/renewal/dispatcher.ts b/apps/conductor-daemon/src/renewal/dispatcher.ts
130index e0d404de5e4d4134ae6f60023ec9271fed9dc64d..c4441df406e45e4656570fa1b2d2cf1d6ba2c11f 100644
131--- a/apps/conductor-daemon/src/renewal/dispatcher.ts
132+++ b/apps/conductor-daemon/src/renewal/dispatcher.ts
133@@ -26,6 +26,11 @@ import {
134 recordAutomationFailureSignal,
135 recordAutomationSuccessSignal
136 } from "./automation.js";
137+import {
138+ isPlainRecord,
139+ normalizeOptionalString,
140+ parseJsonValue
141+} from "./utils.js";
142
143 const DEFAULT_RECHECK_DELAY_MS = 10_000;
144 const DEFAULT_INTER_JOB_JITTER_MIN_MS = 500;
145@@ -1082,10 +1087,6 @@ function parseDownstreamStatusCode(message: string): number | null {
146 return Number(match[1]);
147 }
148
149-function isPlainRecord(value: unknown): value is Record<string, unknown> {
150- return typeof value === "object" && value != null && !Array.isArray(value);
151-}
152-
153 function isRenewalProjectorPayload(value: unknown): value is RenewalProjectorPayload {
154 return (
155 isPlainRecord(value)
156@@ -1109,27 +1110,6 @@ function isRenewalTargetSnapshot(value: unknown): value is RenewalProjectorTarge
157 );
158 }
159
160-function normalizeOptionalString(value: string | null | undefined): string | null {
161- if (value == null) {
162- return null;
163- }
164-
165- const normalized = value.trim();
166- return normalized === "" ? null : normalized;
167-}
168-
169-function parseJsonValue(value: string | null): unknown {
170- if (value == null) {
171- return null;
172- }
173-
174- try {
175- return JSON.parse(value);
176- } catch {
177- return null;
178- }
179-}
180-
181 function parseTabId(value: string | null): number | null {
182 const normalized = normalizeOptionalString(value);
183
184diff --git a/apps/conductor-daemon/src/renewal/projector.ts b/apps/conductor-daemon/src/renewal/projector.ts
185index 1db85df9c3f017cb826d301a16354b20a047fce3..0a9fcf056ac510adb20344f8ac0f3b595199eb82 100644
186--- a/apps/conductor-daemon/src/renewal/projector.ts
187+++ b/apps/conductor-daemon/src/renewal/projector.ts
188@@ -17,6 +17,12 @@ import {
189 recordAutomationFailureSignal,
190 recordRenewalPayloadSignal
191 } from "./automation.js";
192+import {
193+ isPlainRecord,
194+ normalizeOptionalString,
195+ normalizeRequiredString,
196+ parseJsonValue
197+} from "./utils.js";
198
199 const DEFAULT_CURSOR_STATE_KEY = "renewal.projector.cursor";
200 const DEFAULT_MESSAGE_ROLE = "assistant";
201@@ -644,25 +650,6 @@ function normalizeCursorStateKey(value: string | undefined): string {
202 return normalizeRequiredString(value ?? DEFAULT_CURSOR_STATE_KEY, "cursorStateKey");
203 }
204
205-function normalizeOptionalString(value: string | null | undefined): string | null {
206- if (value == null) {
207- return null;
208- }
209-
210- const normalized = value.trim();
211- return normalized === "" ? null : normalized;
212-}
213-
214-function normalizeRequiredString(value: string | null | undefined, name: string): string {
215- const normalized = normalizeOptionalString(value);
216-
217- if (normalized == null) {
218- throw new Error(`${name} must be a non-empty string.`);
219- }
220-
221- return normalized;
222-}
223-
224 function parseJsonRecord(value: string | null): Record<string, unknown> | null {
225 const parsed = parseJsonValue(value);
226 return isPlainRecord(parsed) ? parsed : null;
227@@ -685,18 +672,6 @@ function parseTargetTabId(value: string | null): number | null {
228 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
229 }
230
231-function parseJsonValue(value: string | null): unknown {
232- if (value == null) {
233- return null;
234- }
235-
236- try {
237- return JSON.parse(value);
238- } catch {
239- return null;
240- }
241-}
242-
243 async function saveCursor(
244 repository: Pick<ControlPlaneRepository, "putSystemState">,
245 cursorStateKey: string,
246@@ -713,10 +688,6 @@ async function saveCursor(
247 });
248 }
249
250-function isPlainRecord(value: unknown): value is Record<string, unknown> {
251- return typeof value === "object" && value != null && !Array.isArray(value);
252-}
253-
254 function truncateText(text: string, limit: number): string {
255 if (text.length <= limit) {
256 return text;
257diff --git a/apps/conductor-daemon/src/renewal/utils.test.js b/apps/conductor-daemon/src/renewal/utils.test.js
258new file mode 100644
259index 0000000000000000000000000000000000000000..12dc29ee3503133a3a6fde08d0113bd39f7c46f8
260--- /dev/null
261+++ b/apps/conductor-daemon/src/renewal/utils.test.js
262@@ -0,0 +1,41 @@
263+import assert from "node:assert/strict";
264+import test from "node:test";
265+
266+import {
267+ isPlainRecord,
268+ normalizeOptionalString,
269+ normalizeRequiredString,
270+ parseJsonValue
271+} from "../../dist/renewal/utils.js";
272+
273+test("renewal utils normalize optional and required strings without changing legacy error styles", () => {
274+ assert.equal(normalizeOptionalString(" hello "), "hello");
275+ assert.equal(normalizeOptionalString(" "), null);
276+ assert.equal(normalizeOptionalString(null), null);
277+ assert.equal(normalizeOptionalString(123), null);
278+
279+ assert.equal(normalizeRequiredString(" value ", "name"), "value");
280+ assert.throws(
281+ () => normalizeRequiredString(" ", "cursorStateKey"),
282+ /cursorStateKey must be a non-empty string\./u
283+ );
284+ assert.throws(
285+ () => normalizeRequiredString(null, "platform", {
286+ errorStyle: "field"
287+ }),
288+ /Field "platform" must be a non-empty string\./u
289+ );
290+});
291+
292+test("renewal utils parse JSON values and distinguish plain records", () => {
293+ const parsedRecord = parseJsonValue("{\"text\":\"hello\"}");
294+
295+ assert.deepEqual(parsedRecord, {
296+ text: "hello"
297+ });
298+ assert.equal(isPlainRecord(parsedRecord), true);
299+ assert.equal(isPlainRecord(parseJsonValue("[1,2,3]")), false);
300+ assert.equal(isPlainRecord(parseJsonValue("\"hello\"")), false);
301+ assert.equal(isPlainRecord(parseJsonValue(null)), false);
302+ assert.equal(parseJsonValue("{"), null);
303+});
304diff --git a/apps/conductor-daemon/src/renewal/utils.ts b/apps/conductor-daemon/src/renewal/utils.ts
305new file mode 100644
306index 0000000000000000000000000000000000000000..3470777f85ec4943871ed7578075dd04454e7761
307--- /dev/null
308+++ b/apps/conductor-daemon/src/renewal/utils.ts
309@@ -0,0 +1,46 @@
310+interface NormalizeRequiredStringOptions {
311+ errorStyle?: "field" | "plain";
312+}
313+
314+export function normalizeOptionalString(value: unknown): string | null {
315+ if (typeof value !== "string") {
316+ return null;
317+ }
318+
319+ const normalized = value.trim();
320+ return normalized === "" ? null : normalized;
321+}
322+
323+export function normalizeRequiredString(
324+ value: unknown,
325+ name: string,
326+ options: NormalizeRequiredStringOptions = {}
327+): string {
328+ const normalized = normalizeOptionalString(value);
329+
330+ if (normalized == null) {
331+ throw new Error(
332+ options.errorStyle === "field"
333+ ? `Field "${name}" must be a non-empty string.`
334+ : `${name} must be a non-empty string.`
335+ );
336+ }
337+
338+ return normalized;
339+}
340+
341+export function parseJsonValue(value: string | null | undefined): unknown {
342+ if (value == null) {
343+ return null;
344+ }
345+
346+ try {
347+ return JSON.parse(value);
348+ } catch {
349+ return null;
350+ }
351+}
352+
353+export function isPlainRecord(value: unknown): value is Record<string, unknown> {
354+ return typeof value === "object" && value != null && !Array.isArray(value);
355+}