@gobing-ai/knowledge-kit 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +128 -25
- package/package.json +1 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
- package/plugins/kk/scripts/daily-stages.ts +1057 -108
- package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
- package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +168 -121
- package/plugins/kk/workflows/kk-daily-safety.yaml +129 -50
- package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/csdn-pub/dist/index.js +2 -1
- package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/infoq-pub/dist/index.js +2 -1
- package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/jike-pub/dist/index.js +2 -1
- package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/juejin-pub/dist/index.js +2 -1
- package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/podcast-pub/dist/index.js +46 -8
- package/plugins/publishings/podcast-pub/src/index.ts +62 -5
- package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/sspai-pub/dist/index.js +2 -1
- package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/substack-pub/dist/index.js +2 -1
- package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +460 -52
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
- package/plugins/publishings/surfdash-pub/src/index.ts +589 -64
- package/plugins/publishings/wechatmp-pub/dist/index.js +933 -98
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
- package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
- package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
- package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
- package/plugins/publishings/weibo-pub/dist/index.js +2 -1
- package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/dist/index.js +2 -1
- package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/xhs-pub/dist/index.js +2 -1
- package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
copyFileSync,
|
|
58
58
|
existsSync,
|
|
59
59
|
mkdirSync,
|
|
60
|
+
readdirSync,
|
|
60
61
|
readFileSync,
|
|
61
62
|
renameSync,
|
|
62
63
|
rmSync,
|
|
@@ -64,7 +65,7 @@ import {
|
|
|
64
65
|
unlinkSync,
|
|
65
66
|
writeFileSync,
|
|
66
67
|
} from 'node:fs';
|
|
67
|
-
import { basename, join, resolve } from 'node:path';
|
|
68
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
68
69
|
import {
|
|
69
70
|
ADMISSION_CRITERIA,
|
|
70
71
|
applyTopicBoundary,
|
|
@@ -135,11 +136,42 @@ export function dailyQcContent(args: string[]): void {
|
|
|
135
136
|
const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
|
|
136
137
|
const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
|
|
137
138
|
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
139
|
+
const cachePath = `${candidates}.cache.json`;
|
|
140
|
+
const fingerprint = stageCacheFingerprint(planContent, {
|
|
141
|
+
qcCategories,
|
|
142
|
+
minQuality,
|
|
143
|
+
minImportance,
|
|
144
|
+
minUrgency,
|
|
145
|
+
minImpact,
|
|
146
|
+
minEpisodeItems,
|
|
147
|
+
minMinutes,
|
|
148
|
+
vdriver,
|
|
149
|
+
});
|
|
150
|
+
let recomputed = false;
|
|
138
151
|
timed(workDir, 'quality-control-content', () => {
|
|
139
|
-
|
|
152
|
+
// Voice1's standalone QC tests and old workspaces may carry a candidates artifact without
|
|
153
|
+
// the newer plan input or sidecar. Preserve that explicitly cache-only surface; voice2
|
|
154
|
+
// always has planContent, so its stale/malformed cache is recomputed below.
|
|
155
|
+
const legacyCandidatesCache = isFile(candidates) && !isFile(planContent) && !isFile(cachePath);
|
|
156
|
+
if (stageCacheMatches(candidates, cachePath, fingerprint) || legacyCandidatesCache) {
|
|
140
157
|
console.log(`candidates cached: ${candidates}`);
|
|
141
158
|
return;
|
|
142
159
|
}
|
|
160
|
+
if (isFile(candidates)) {
|
|
161
|
+
const descendants = authoredPlanDescendants(workDir, runDate);
|
|
162
|
+
if (descendants.length > 0) {
|
|
163
|
+
failWith(
|
|
164
|
+
1,
|
|
165
|
+
`QC cache changed after downstream artifacts were authored; refusing to pair new candidates with stale outputs (${descendants.join(', ')})`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
console.log(`candidates cache stale: ${candidates}`);
|
|
169
|
+
}
|
|
170
|
+
for (const stale of [exclusions, `${runDate}_04_quality-control-content_floor-refusal.json`].map((name) =>
|
|
171
|
+
name.startsWith('/') ? name : join(workDir, '21-plan', name),
|
|
172
|
+
)) {
|
|
173
|
+
if (isFile(stale)) unlinkSync(stale);
|
|
174
|
+
}
|
|
143
175
|
sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
|
|
144
176
|
kk(
|
|
145
177
|
[
|
|
@@ -164,6 +196,9 @@ export function dailyQcContent(args: string[]): void {
|
|
|
164
196
|
},
|
|
165
197
|
},
|
|
166
198
|
);
|
|
199
|
+
if (!isFile(candidates)) failWith(1, `quality-control-content: ${candidates} not written`);
|
|
200
|
+
sted('write', cachePath, () => writeStageCache(cachePath, fingerprint));
|
|
201
|
+
recomputed = true;
|
|
167
202
|
});
|
|
168
203
|
// Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
|
|
169
204
|
// candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
|
|
@@ -212,7 +247,48 @@ export function dailyQcContent(args: string[]): void {
|
|
|
212
247
|
// already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
|
|
213
248
|
// topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
|
|
214
249
|
// gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
|
|
215
|
-
applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
|
|
250
|
+
applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver, recomputed);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Cache only deterministic stage inputs. The source bytes are part of the key so a new collect
|
|
255
|
+
* pool or translated plan cannot inherit a prior run's refusal; the invocation parameters cover
|
|
256
|
+
* every threshold that changes the QC/floor result.
|
|
257
|
+
*/
|
|
258
|
+
function stageCacheFingerprint(sourcePath: string, inputs: Record<string, unknown>): string {
|
|
259
|
+
const source = isFile(sourcePath) ? readFileSync(sourcePath, 'utf-8') : '<missing>';
|
|
260
|
+
return createHash('sha256')
|
|
261
|
+
.update(JSON.stringify({ version: 1, source, inputs }))
|
|
262
|
+
.digest('hex');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stageCacheMatches(outputPath: string, cachePath: string, fingerprint: string): boolean {
|
|
266
|
+
if (!isFile(outputPath) || !isFile(cachePath)) return false;
|
|
267
|
+
try {
|
|
268
|
+
const cache = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: unknown; fingerprint?: unknown };
|
|
269
|
+
return cache.version === 1 && cache.fingerprint === fingerprint;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function writeStageCache(path: string, fingerprint: string): void {
|
|
276
|
+
writeJsonText(path, { version: 1, fingerprint });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function authoredPlanDescendants(workDir: string, runDate: string): string[] {
|
|
280
|
+
const descendants: string[] = [];
|
|
281
|
+
for (const directory of ['22-article', '23-cover', '24-script', '30-audio', '31-publish']) {
|
|
282
|
+
const root = join(workDir, directory);
|
|
283
|
+
if (!existsSync(root)) continue;
|
|
284
|
+
// The directory contents are stage-owned artifacts. A date-prefixed file means that a
|
|
285
|
+
// previous non-empty run already authored downstream material that must not be silently
|
|
286
|
+
// paired with a newly generated plan.
|
|
287
|
+
for (const entry of readdirSync(root)) {
|
|
288
|
+
if (entry.startsWith(`${runDate}_`)) descendants.push(join(root, entry));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return descendants;
|
|
216
292
|
}
|
|
217
293
|
|
|
218
294
|
/**
|
|
@@ -261,18 +337,26 @@ function applyFloorGate(
|
|
|
261
337
|
runDate: string,
|
|
262
338
|
minMinutes: string,
|
|
263
339
|
vdriver: string,
|
|
340
|
+
clearStaleRefusal = false,
|
|
264
341
|
): void {
|
|
342
|
+
const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
|
|
265
343
|
const floor = Number(minMinutes);
|
|
266
|
-
if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0)
|
|
344
|
+
if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) {
|
|
345
|
+
if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
267
348
|
const content = readJson(candidates);
|
|
268
349
|
const docs = metadataOf(content).docs;
|
|
269
|
-
if (!Array.isArray(docs) || docs.length === 0)
|
|
350
|
+
if (!Array.isArray(docs) || docs.length === 0) {
|
|
351
|
+
if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
270
354
|
const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
|
|
271
355
|
if (estimatedSeconds >= floor * 60) {
|
|
356
|
+
if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
|
|
272
357
|
console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
|
|
273
358
|
return;
|
|
274
359
|
}
|
|
275
|
-
const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
|
|
276
360
|
const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
|
|
277
361
|
sted('write', refusal, () =>
|
|
278
362
|
writeJqText(refusal, {
|
|
@@ -1511,11 +1595,28 @@ export function dailyPlan(args: string[]): void {
|
|
|
1511
1595
|
// translation. Voice1 omits the arg and still materializes the file (its translate node has no
|
|
1512
1596
|
// skip guard and overwrites it).
|
|
1513
1597
|
const deferDocs = deferPlanDocs === 'true';
|
|
1598
|
+
const target = deferDocs ? planContent : planDocs;
|
|
1599
|
+
const targetCache = `${target}.cache.json`;
|
|
1600
|
+
const fingerprint = stageCacheFingerprint(blended, {
|
|
1601
|
+
runDate,
|
|
1602
|
+
planMaxItems,
|
|
1603
|
+
deferPlanDocs,
|
|
1604
|
+
});
|
|
1514
1605
|
timed(workDir, 'plan', () => {
|
|
1515
|
-
if (
|
|
1606
|
+
if (stageCacheMatches(target, targetCache, fingerprint)) {
|
|
1516
1607
|
console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
|
|
1517
1608
|
return;
|
|
1518
1609
|
}
|
|
1610
|
+
if (isFile(target)) {
|
|
1611
|
+
const descendants = authoredPlanDescendants(workDir, runDate);
|
|
1612
|
+
if (descendants.length > 0) {
|
|
1613
|
+
failWith(
|
|
1614
|
+
1,
|
|
1615
|
+
`plan cache changed after downstream artifacts were authored; refusing to pair a new plan with stale outputs (${descendants.join(', ')})`,
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
console.log(`plan cache stale: ${target}`);
|
|
1619
|
+
}
|
|
1519
1620
|
kk(
|
|
1520
1621
|
['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
|
|
1521
1622
|
{
|
|
@@ -1524,9 +1625,17 @@ export function dailyPlan(args: string[]): void {
|
|
|
1524
1625
|
);
|
|
1525
1626
|
const docs = metadataOf(readJson(planContent)).docs;
|
|
1526
1627
|
if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
|
|
1527
|
-
if (deferDocs)
|
|
1528
|
-
|
|
1529
|
-
|
|
1628
|
+
if (deferDocs) {
|
|
1629
|
+
// A translated plan is a descendant of this content plan. Remove only the stale
|
|
1630
|
+
// translation when no authored downstream artifact exists; otherwise the guard above
|
|
1631
|
+
// fails closed and leaves the operator a reviewable workspace.
|
|
1632
|
+
if (isFile(planDocs)) unlinkSync(planDocs);
|
|
1633
|
+
if (isFile(`${planDocs}.cache.json`)) unlinkSync(`${planDocs}.cache.json`);
|
|
1634
|
+
} else {
|
|
1635
|
+
sted('write', planDocs, () => writeJsonText(planDocs, docs));
|
|
1636
|
+
if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
|
|
1637
|
+
}
|
|
1638
|
+
sted('write', targetCache, () => writeStageCache(targetCache, fingerprint));
|
|
1530
1639
|
});
|
|
1531
1640
|
}
|
|
1532
1641
|
|
|
@@ -1915,19 +2024,38 @@ export function readKeyPointItems(filePath: string): string[] | undefined {
|
|
|
1915
2024
|
}
|
|
1916
2025
|
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1];
|
|
1917
2026
|
if (!block) return undefined;
|
|
1918
|
-
// Slice from `key_points` to the first top-level key (or block end), then
|
|
1919
|
-
// (`- `)
|
|
2027
|
+
// Slice from `key_points` to the first top-level key (or block end), then read the item lines
|
|
2028
|
+
// (`- `). Folded (`>-`) YAML items need their indented continuation lines joined before the
|
|
2029
|
+
// strict scheduled validator can check the `Label — sentence` contract.
|
|
1920
2030
|
const head = /^key_points:\n/m.exec(block);
|
|
1921
2031
|
if (!head) return undefined;
|
|
1922
2032
|
const body = block.slice(head.index + head[0].length);
|
|
1923
2033
|
const endMatch = /^\S/m.exec(body);
|
|
1924
2034
|
const list = endMatch ? body.slice(0, endMatch.index) : body;
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2035
|
+
const items: string[] = [];
|
|
2036
|
+
let current: string | undefined;
|
|
2037
|
+
let folded = false;
|
|
2038
|
+
const flush = (): void => {
|
|
2039
|
+
if (current === undefined) return;
|
|
2040
|
+
// A quoted scalar's quotes are YAML syntax, not text — strip them so label lengths are real.
|
|
2041
|
+
items.push(current.replace(/^(['"])(.*)\1$/, '$2').trim());
|
|
2042
|
+
current = undefined;
|
|
2043
|
+
folded = false;
|
|
2044
|
+
};
|
|
2045
|
+
for (const line of list.split('\n')) {
|
|
2046
|
+
const item = /^[ \t]+- (.*)$/.exec(line);
|
|
2047
|
+
if (item !== null) {
|
|
2048
|
+
flush();
|
|
2049
|
+
current = item[1].trim();
|
|
2050
|
+
folded = current === '>' || current === '>-' || current === '|-' || current === '|';
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
if (current === undefined || !folded) continue;
|
|
2054
|
+
const continuation = line.trim();
|
|
2055
|
+
if (continuation !== '') current = `${current} ${continuation}`;
|
|
2056
|
+
}
|
|
2057
|
+
flush();
|
|
2058
|
+
return items;
|
|
1931
2059
|
}
|
|
1932
2060
|
|
|
1933
2061
|
/** Count `key_points` list items in a Surfing post's frontmatter; undefined when absent/unreadable. */
|
|
@@ -2113,36 +2241,662 @@ export function dailyPublishPrep(args: string[]): void {
|
|
|
2113
2241
|
});
|
|
2114
2242
|
}
|
|
2115
2243
|
|
|
2244
|
+
type SurfdashDeliveryState = 'missing' | 'success' | 'failure' | 'uncertain';
|
|
2245
|
+
|
|
2246
|
+
interface SurfdashSendIntent {
|
|
2247
|
+
version: 1;
|
|
2248
|
+
target: 'surfdash-pub';
|
|
2249
|
+
runDate: string;
|
|
2250
|
+
payloadPath: string;
|
|
2251
|
+
payloadSha256: string;
|
|
2252
|
+
createdAt: string;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
function surfdashPayloadSha256(path: string): string {
|
|
2256
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function surfdashNotSentReceipt(resultPath: string, intentPath: string, runDate: string, reason: string): void {
|
|
2260
|
+
const error = `surfdash publish was not sent: ${reason}`;
|
|
2261
|
+
sted('write', resultPath, () =>
|
|
2262
|
+
writeJsonText(resultPath, {
|
|
2263
|
+
ok: false,
|
|
2264
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2265
|
+
targets: [
|
|
2266
|
+
{
|
|
2267
|
+
target: 'surfdash-pub',
|
|
2268
|
+
ok: false,
|
|
2269
|
+
status: 'failed',
|
|
2270
|
+
error,
|
|
2271
|
+
metadata: { deliveryState: 'not-sent', runDate, intentPath },
|
|
2272
|
+
},
|
|
2273
|
+
],
|
|
2274
|
+
}),
|
|
2275
|
+
);
|
|
2276
|
+
console.error(error);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
function readSurfdashSendIntent(
|
|
2280
|
+
path: string,
|
|
2281
|
+
): { kind: 'missing' } | { kind: 'valid'; value: SurfdashSendIntent } | { kind: 'invalid'; reason: string } {
|
|
2282
|
+
if (!isFile(path)) return { kind: 'missing' };
|
|
2283
|
+
const parsed = readJson(path);
|
|
2284
|
+
if (parsed === null) return { kind: 'invalid', reason: 'send intent is unreadable or invalid JSON' };
|
|
2285
|
+
if (
|
|
2286
|
+
parsed.version !== 1 ||
|
|
2287
|
+
parsed.target !== 'surfdash-pub' ||
|
|
2288
|
+
typeof parsed.runDate !== 'string' ||
|
|
2289
|
+
typeof parsed.payloadPath !== 'string' ||
|
|
2290
|
+
typeof parsed.createdAt !== 'string' ||
|
|
2291
|
+
typeof parsed.payloadSha256 !== 'string' ||
|
|
2292
|
+
!/^[0-9a-f]{64}$/.test(parsed.payloadSha256)
|
|
2293
|
+
) {
|
|
2294
|
+
return { kind: 'invalid', reason: 'send intent is malformed' };
|
|
2295
|
+
}
|
|
2296
|
+
return { kind: 'valid', value: parsed as unknown as SurfdashSendIntent };
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function surfdashTargetEntries(result: Record<string, unknown> | null): Record<string, unknown>[] {
|
|
2300
|
+
const targets = result?.targets;
|
|
2301
|
+
if (!Array.isArray(targets)) return [];
|
|
2302
|
+
return targets.filter(
|
|
2303
|
+
(entry): entry is Record<string, unknown> =>
|
|
2304
|
+
entry !== null && typeof entry === 'object' && (entry as Record<string, unknown>).target === 'surfdash-pub',
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/** A push can have reached the remote even when the publisher reports a failed receipt. */
|
|
2309
|
+
function surfdashFailureMayHaveRemoteEffect(entry: Record<string, unknown>): boolean {
|
|
2310
|
+
return (
|
|
2311
|
+
entry.ok === false &&
|
|
2312
|
+
entry.status === 'failed' &&
|
|
2313
|
+
(entry.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'not-sent'
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function surfdashTargetState(result: Record<string, unknown> | null): SurfdashDeliveryState {
|
|
2318
|
+
const matches = surfdashTargetEntries(result);
|
|
2319
|
+
if (matches.length === 0) return 'missing';
|
|
2320
|
+
// More than one receipt for the same target is ambiguous even when every row says success:
|
|
2321
|
+
// retrying could duplicate a remote post. Preserve the uncertainty until reconciliation.
|
|
2322
|
+
if (matches.length > 1) return 'uncertain';
|
|
2323
|
+
if (
|
|
2324
|
+
matches.some(
|
|
2325
|
+
(entry) =>
|
|
2326
|
+
(entry.metadata as Record<string, unknown> | undefined)?.deliveryState === 'uncertain' ||
|
|
2327
|
+
surfdashFailureMayHaveRemoteEffect(entry),
|
|
2328
|
+
)
|
|
2329
|
+
) {
|
|
2330
|
+
return 'uncertain';
|
|
2331
|
+
}
|
|
2332
|
+
const target = matches[0];
|
|
2333
|
+
if (target.ok === true && target.status === 'published') return 'success';
|
|
2334
|
+
if (target.ok === false && target.status === 'failed') return 'failure';
|
|
2335
|
+
return 'missing';
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function writeSurfdashUncertainReceipt(
|
|
2339
|
+
resultPath: string,
|
|
2340
|
+
intentPath: string,
|
|
2341
|
+
runDate: string,
|
|
2342
|
+
payloadSha256: string | undefined,
|
|
2343
|
+
currentPayloadSha256: string | undefined,
|
|
2344
|
+
reason: string,
|
|
2345
|
+
identity: { postPath?: string; localeFingerprint?: string } = {},
|
|
2346
|
+
): void {
|
|
2347
|
+
const metadata: Record<string, unknown> = {
|
|
2348
|
+
deliveryState: 'uncertain',
|
|
2349
|
+
runDate,
|
|
2350
|
+
intentPath,
|
|
2351
|
+
...(payloadSha256 ? { payloadSha256 } : {}),
|
|
2352
|
+
...(currentPayloadSha256 ? { currentPayloadSha256 } : {}),
|
|
2353
|
+
...(identity.postPath ? { postPath: identity.postPath } : {}),
|
|
2354
|
+
...(identity.localeFingerprint ? { localeFingerprint: identity.localeFingerprint } : {}),
|
|
2355
|
+
};
|
|
2356
|
+
const error = `surfdash delivery is uncertain: ${reason}; reconcile the dated remote post/commit before clearing this uncertainty and retrying`;
|
|
2357
|
+
sted('write', resultPath, () =>
|
|
2358
|
+
writeJsonText(resultPath, {
|
|
2359
|
+
ok: false,
|
|
2360
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2361
|
+
targets: [
|
|
2362
|
+
{
|
|
2363
|
+
target: 'surfdash-pub',
|
|
2364
|
+
ok: false,
|
|
2365
|
+
status: 'failed',
|
|
2366
|
+
error,
|
|
2367
|
+
metadata,
|
|
2368
|
+
},
|
|
2369
|
+
],
|
|
2370
|
+
}),
|
|
2371
|
+
);
|
|
2372
|
+
console.error(error);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const SURFDASH_LOCALES = ['zh', 'en', 'ja'] as const;
|
|
2376
|
+
type SurfdashLocale = (typeof SURFDASH_LOCALES)[number];
|
|
2377
|
+
|
|
2378
|
+
interface SurfdashLocaleValidationEvidence {
|
|
2379
|
+
runDate: string;
|
|
2380
|
+
postPath: string;
|
|
2381
|
+
localePaths: Record<SurfdashLocale, string>;
|
|
2382
|
+
localeDigests: Record<SurfdashLocale, string>;
|
|
2383
|
+
fingerprint: string;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
interface SurfdashPublishIdentity {
|
|
2387
|
+
postPath: string;
|
|
2388
|
+
payloadSha256: string;
|
|
2389
|
+
localeFingerprint: string;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function surfdashPublishIdentity(entry: Record<string, unknown> | undefined): SurfdashPublishIdentity | undefined {
|
|
2393
|
+
if (entry === undefined) return undefined;
|
|
2394
|
+
const metadata = metadataOf(entry);
|
|
2395
|
+
if (
|
|
2396
|
+
typeof metadata.postPath !== 'string' ||
|
|
2397
|
+
typeof metadata.payloadSha256 !== 'string' ||
|
|
2398
|
+
!/^[0-9a-f]{64}$/.test(metadata.payloadSha256) ||
|
|
2399
|
+
typeof metadata.localeFingerprint !== 'string' ||
|
|
2400
|
+
!/^[0-9a-f]{64}$/.test(metadata.localeFingerprint)
|
|
2401
|
+
) {
|
|
2402
|
+
return undefined;
|
|
2403
|
+
}
|
|
2404
|
+
return {
|
|
2405
|
+
postPath: metadata.postPath,
|
|
2406
|
+
payloadSha256: metadata.payloadSha256,
|
|
2407
|
+
localeFingerprint: metadata.localeFingerprint,
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function localeSiblingPaths(zhPostPath: string): Record<SurfdashLocale, string> | undefined {
|
|
2412
|
+
if (!zhPostPath.includes('/zh/')) return undefined;
|
|
2413
|
+
const en = zhPostPath.replace('/zh/', '/en/');
|
|
2414
|
+
const ja = zhPostPath.replace('/zh/', '/ja/');
|
|
2415
|
+
if (en === zhPostPath || ja === zhPostPath) return undefined;
|
|
2416
|
+
return { zh: zhPostPath, en, ja };
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function validateLocaleMarkers(workDir: string, runDate: string, zhPostPath: string, step: string): void {
|
|
2420
|
+
const localePaths = localeSiblingPaths(zhPostPath);
|
|
2421
|
+
if (localePaths === undefined) {
|
|
2422
|
+
failWith(1, `${step}: zh post path does not contain a /zh/ locale segment: ${zhPostPath}`);
|
|
2423
|
+
}
|
|
2424
|
+
for (const locale of ['en', 'ja'] as const) {
|
|
2425
|
+
const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_${locale}-post-path.txt`);
|
|
2426
|
+
const markerPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
|
|
2427
|
+
const expectedPath = localePaths[locale];
|
|
2428
|
+
if (markerPath === '' || !isAbsolute(markerPath) || markerPath !== expectedPath || !isFile(expectedPath)) {
|
|
2429
|
+
failWith(1, `${step}: ${locale} marker must name the absolute derived sibling ${expectedPath}: ${marker}`);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
function surfdashLocaleValidationEvidence(
|
|
2435
|
+
zhPostPath: string,
|
|
2436
|
+
runDate: string,
|
|
2437
|
+
): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
|
|
2438
|
+
const localePaths = localeSiblingPaths(zhPostPath);
|
|
2439
|
+
if (localePaths === undefined) {
|
|
2440
|
+
return { ok: false, reason: `zh post path does not contain a /zh/ locale segment: ${zhPostPath}` };
|
|
2441
|
+
}
|
|
2442
|
+
const localeDigests = {} as Record<SurfdashLocale, string>;
|
|
2443
|
+
for (const locale of SURFDASH_LOCALES) {
|
|
2444
|
+
const path = localePaths[locale];
|
|
2445
|
+
if (!isFile(path)) return { ok: false, reason: `${locale} locale file is missing: ${path}` };
|
|
2446
|
+
try {
|
|
2447
|
+
localeDigests[locale] = createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
2448
|
+
} catch (error: unknown) {
|
|
2449
|
+
return {
|
|
2450
|
+
ok: false,
|
|
2451
|
+
reason: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
const identity = { runDate, postPath: zhPostPath, localePaths, localeDigests };
|
|
2456
|
+
return {
|
|
2457
|
+
ok: true,
|
|
2458
|
+
evidence: {
|
|
2459
|
+
...identity,
|
|
2460
|
+
fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
|
|
2461
|
+
},
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
function validateSurfdashLocaleGate(
|
|
2466
|
+
validationPath: string,
|
|
2467
|
+
prepareResultPath: string,
|
|
2468
|
+
runDate: string,
|
|
2469
|
+
): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
|
|
2470
|
+
const validation = isFile(validationPath) ? readJson(validationPath) : null;
|
|
2471
|
+
if (validation?.ok !== true || validation.target !== 'surfdash-pub' || validation.status !== 'validated') {
|
|
2472
|
+
return { ok: false, reason: `missing or invalid validation receipt: ${validationPath}` };
|
|
2473
|
+
}
|
|
2474
|
+
const metadata = metadataOf(validation);
|
|
2475
|
+
if (metadata.runDate !== runDate || typeof metadata.postPath !== 'string') {
|
|
2476
|
+
return { ok: false, reason: `validation receipt date/path does not match run ${runDate}` };
|
|
2477
|
+
}
|
|
2478
|
+
const prepared = isFile(prepareResultPath) ? readJson(prepareResultPath) : null;
|
|
2479
|
+
const preparedTargets = Array.isArray(prepared?.targets)
|
|
2480
|
+
? prepared.targets.filter(
|
|
2481
|
+
(entry): entry is Record<string, unknown> =>
|
|
2482
|
+
entry !== null &&
|
|
2483
|
+
typeof entry === 'object' &&
|
|
2484
|
+
(entry as Record<string, unknown>).target === 'surfdash-pub',
|
|
2485
|
+
)
|
|
2486
|
+
: [];
|
|
2487
|
+
if (
|
|
2488
|
+
preparedTargets.length !== 1 ||
|
|
2489
|
+
preparedTargets[0].ok !== true ||
|
|
2490
|
+
preparedTargets[0].status !== 'prepared' ||
|
|
2491
|
+
metadataOf(preparedTargets[0]).postPath !== metadata.postPath
|
|
2492
|
+
) {
|
|
2493
|
+
return { ok: false, reason: `current Surfdash prepare receipt is missing or does not match ${validationPath}` };
|
|
2494
|
+
}
|
|
2495
|
+
const current = surfdashLocaleValidationEvidence(metadata.postPath, runDate);
|
|
2496
|
+
if (!current.ok) return current;
|
|
2497
|
+
const recordedPaths = metadata.localePaths;
|
|
2498
|
+
const recordedDigests = metadata.localeDigests;
|
|
2499
|
+
if (
|
|
2500
|
+
recordedPaths === null ||
|
|
2501
|
+
typeof recordedPaths !== 'object' ||
|
|
2502
|
+
recordedDigests === null ||
|
|
2503
|
+
typeof recordedDigests !== 'object' ||
|
|
2504
|
+
typeof metadata.fingerprint !== 'string'
|
|
2505
|
+
) {
|
|
2506
|
+
return { ok: false, reason: `validation receipt fingerprint is missing or malformed: ${validationPath}` };
|
|
2507
|
+
}
|
|
2508
|
+
if (
|
|
2509
|
+
JSON.stringify(recordedPaths) !== JSON.stringify(current.evidence.localePaths) ||
|
|
2510
|
+
JSON.stringify(recordedDigests) !== JSON.stringify(current.evidence.localeDigests) ||
|
|
2511
|
+
metadata.fingerprint !== current.evidence.fingerprint
|
|
2512
|
+
) {
|
|
2513
|
+
return { ok: false, reason: `locale files changed after validation; refresh ${validationPath}` };
|
|
2514
|
+
}
|
|
2515
|
+
return current;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2116
2518
|
export function dailyPublishSurfdash(args: string[]): void {
|
|
2117
|
-
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2519
|
+
const [workDir = '', runDate = '', pluginsPath = '', requireLocaleValidation = 'false'] = args;
|
|
2118
2520
|
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2119
2521
|
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
2522
|
+
const intentPath = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_intent.json`);
|
|
2523
|
+
const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
|
|
2524
|
+
const validationPath = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
|
|
2525
|
+
const preparedInput = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_prepared_content.json`);
|
|
2526
|
+
const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_transport_result.json`);
|
|
2120
2527
|
timed(workDir, 'publish-surfdash', () => {
|
|
2121
|
-
|
|
2528
|
+
const priorResult = readJson(result);
|
|
2529
|
+
const priorTargets = surfdashTargetEntries(priorResult);
|
|
2530
|
+
const priorState = surfdashTargetState(priorResult);
|
|
2531
|
+
if (priorState === 'uncertain') {
|
|
2532
|
+
const priorTarget = priorTargets[0];
|
|
2533
|
+
if (
|
|
2534
|
+
priorTarget !== undefined &&
|
|
2535
|
+
(priorTarget.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'uncertain'
|
|
2536
|
+
) {
|
|
2537
|
+
const priorError =
|
|
2538
|
+
typeof priorTarget.error === 'string'
|
|
2539
|
+
? priorTarget.error
|
|
2540
|
+
: 'publisher reported an ambiguous delivery failure';
|
|
2541
|
+
writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, priorError);
|
|
2542
|
+
}
|
|
2543
|
+
console.log(`publish-surfdash blocked: uncertain delivery is sticky in ${result}`);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// The two-phase voice2 workflow explicitly requires a successful local locale gate before
|
|
2548
|
+
// the publisher can invoke refine/CMS/git. Legacy voice1/manual callers omit the fourth
|
|
2549
|
+
// argument and retain their historical publish behavior.
|
|
2550
|
+
let currentIdentity: SurfdashPublishIdentity | undefined;
|
|
2551
|
+
let currentLocaleEvidence: SurfdashLocaleValidationEvidence | undefined;
|
|
2552
|
+
if (requireLocaleValidation === 'true') {
|
|
2553
|
+
const validation = validateSurfdashLocaleGate(validationPath, prepareResult, runDate);
|
|
2554
|
+
if (!validation.ok) {
|
|
2555
|
+
const detail = validation.reason;
|
|
2556
|
+
if (priorState === 'success') {
|
|
2557
|
+
writeSurfdashUncertainReceipt(
|
|
2558
|
+
result,
|
|
2559
|
+
intentPath,
|
|
2560
|
+
runDate,
|
|
2561
|
+
undefined,
|
|
2562
|
+
undefined,
|
|
2563
|
+
`current locale validation is not the one used for the cached success: ${detail}`,
|
|
2564
|
+
);
|
|
2565
|
+
} else {
|
|
2566
|
+
surfdashNotSentReceipt(result, intentPath, runDate, `blocked by locale validation: ${detail}`);
|
|
2567
|
+
}
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
currentLocaleEvidence = validation.evidence;
|
|
2571
|
+
if (!isFile(prep)) {
|
|
2572
|
+
const detail = `prepared payload is missing: ${prep}`;
|
|
2573
|
+
if (priorState === 'success') {
|
|
2574
|
+
writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, detail);
|
|
2575
|
+
} else {
|
|
2576
|
+
surfdashNotSentReceipt(result, intentPath, runDate, detail);
|
|
2577
|
+
}
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
currentIdentity = {
|
|
2581
|
+
postPath: validation.evidence.postPath,
|
|
2582
|
+
payloadSha256: surfdashPayloadSha256(prep),
|
|
2583
|
+
localeFingerprint: validation.evidence.fingerprint,
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
if (priorState === 'success') {
|
|
2588
|
+
// Voice1 and manual callers omit the locale-validation argument and historically
|
|
2589
|
+
// treated a recorded success as a resume-safe cache hit. Voice2 supplies `true` and
|
|
2590
|
+
// must keep the exact identity binding below; do not make the legacy surface depend
|
|
2591
|
+
// on the new locale fingerprint receipt.
|
|
2592
|
+
if (requireLocaleValidation !== 'true') {
|
|
2593
|
+
console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
const cachedIdentity = surfdashPublishIdentity(priorTargets[0]);
|
|
2597
|
+
if (
|
|
2598
|
+
currentIdentity === undefined ||
|
|
2599
|
+
cachedIdentity === undefined ||
|
|
2600
|
+
cachedIdentity.postPath !== currentIdentity.postPath ||
|
|
2601
|
+
cachedIdentity.payloadSha256 !== currentIdentity.payloadSha256 ||
|
|
2602
|
+
cachedIdentity.localeFingerprint !== currentIdentity.localeFingerprint
|
|
2603
|
+
) {
|
|
2604
|
+
writeSurfdashUncertainReceipt(
|
|
2605
|
+
result,
|
|
2606
|
+
intentPath,
|
|
2607
|
+
runDate,
|
|
2608
|
+
cachedIdentity?.payloadSha256,
|
|
2609
|
+
currentIdentity?.payloadSha256,
|
|
2610
|
+
'cached success has no exact postPath/payloadSha256/localeFingerprint identity for the current material',
|
|
2611
|
+
currentIdentity,
|
|
2612
|
+
);
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2122
2615
|
console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
|
|
2123
2616
|
return;
|
|
2124
2617
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2618
|
+
|
|
2619
|
+
const intent = readSurfdashSendIntent(intentPath);
|
|
2620
|
+
if (intent.kind === 'invalid') {
|
|
2621
|
+
writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, intent.reason);
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
let payloadSha256: string | undefined = currentIdentity?.payloadSha256;
|
|
2626
|
+
if (intent.kind === 'valid') {
|
|
2627
|
+
if (intent.value.runDate !== runDate) {
|
|
2628
|
+
writeSurfdashUncertainReceipt(
|
|
2629
|
+
result,
|
|
2630
|
+
intentPath,
|
|
2631
|
+
runDate,
|
|
2632
|
+
intent.value.payloadSha256,
|
|
2633
|
+
undefined,
|
|
2634
|
+
`send intent run date ${intent.value.runDate} does not match requested run date ${runDate}`,
|
|
2635
|
+
);
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
// An existing intent binds this dated post to the original bytes. A changed payload is
|
|
2639
|
+
// never allowed to overwrite the same slug after an interrupted remote send.
|
|
2640
|
+
if (isFile(prep)) payloadSha256 = surfdashPayloadSha256(prep);
|
|
2641
|
+
if (payloadSha256 !== undefined && payloadSha256 !== intent.value.payloadSha256) {
|
|
2642
|
+
writeSurfdashUncertainReceipt(
|
|
2643
|
+
result,
|
|
2644
|
+
intentPath,
|
|
2645
|
+
runDate,
|
|
2646
|
+
intent.value.payloadSha256,
|
|
2647
|
+
payloadSha256,
|
|
2648
|
+
'prepared payload changed after the send intent',
|
|
2649
|
+
);
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
if (priorState === 'missing') {
|
|
2653
|
+
writeSurfdashUncertainReceipt(
|
|
2654
|
+
result,
|
|
2655
|
+
intentPath,
|
|
2656
|
+
runDate,
|
|
2657
|
+
intent.value.payloadSha256,
|
|
2658
|
+
payloadSha256,
|
|
2659
|
+
'no publish result was recorded after the prior send intent',
|
|
2660
|
+
);
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (payloadSha256 === undefined) {
|
|
2664
|
+
writeSurfdashUncertainReceipt(
|
|
2665
|
+
result,
|
|
2666
|
+
intentPath,
|
|
2667
|
+
runDate,
|
|
2668
|
+
intent.value.payloadSha256,
|
|
2669
|
+
undefined,
|
|
2670
|
+
'prepared payload is missing, so the original request cannot be replayed safely',
|
|
2671
|
+
);
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
} else {
|
|
2675
|
+
if (!isFile(prep)) failWith(1, `publish-surfdash: required input not found: ${prep}`);
|
|
2676
|
+
payloadSha256 = surfdashPayloadSha256(prep);
|
|
2677
|
+
const sendIntent: SurfdashSendIntent = {
|
|
2678
|
+
version: 1,
|
|
2679
|
+
target: 'surfdash-pub',
|
|
2680
|
+
runDate,
|
|
2681
|
+
payloadPath: prep,
|
|
2682
|
+
payloadSha256,
|
|
2683
|
+
createdAt: new Date().toISOString(),
|
|
2684
|
+
};
|
|
2685
|
+
sted('write', intentPath, () => writeJsonText(intentPath, sendIntent));
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
if (requireLocaleValidation === 'true' && currentIdentity !== undefined) {
|
|
2689
|
+
const prepared = requireJson(prep, 'publish-surfdash');
|
|
2690
|
+
sted('write', preparedInput, () =>
|
|
2691
|
+
writeJqText(preparedInput, {
|
|
2692
|
+
...prepared,
|
|
2693
|
+
options: {
|
|
2694
|
+
operation: 'publish-prepared',
|
|
2695
|
+
postPath: currentIdentity.postPath,
|
|
2696
|
+
runDate,
|
|
2697
|
+
expectedPostSha256: currentLocaleEvidence.localeDigests.zh,
|
|
2698
|
+
expectedLocaleFingerprint: currentIdentity.localeFingerprint,
|
|
2699
|
+
},
|
|
2700
|
+
}),
|
|
2701
|
+
);
|
|
2702
|
+
// `kk executor run` normally clears --out itself, but a discovery failure can return
|
|
2703
|
+
// before the executor owns that path. Remove any previous transport receipt here so a
|
|
2704
|
+
// stale success can never be promoted into this invocation's publish result.
|
|
2705
|
+
rmSync(transportResult, { force: true });
|
|
2706
|
+
kk(
|
|
2707
|
+
[
|
|
2708
|
+
'executor',
|
|
2709
|
+
'run',
|
|
2710
|
+
'surfdash-pub',
|
|
2711
|
+
'--in',
|
|
2712
|
+
preparedInput,
|
|
2713
|
+
'--out',
|
|
2714
|
+
transportResult,
|
|
2715
|
+
...pluginsArgs(pluginsPath),
|
|
2716
|
+
],
|
|
2717
|
+
{
|
|
2718
|
+
unset: NESTED_SPUR_UNSET,
|
|
2719
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
2720
|
+
soft: true,
|
|
2721
|
+
},
|
|
2722
|
+
);
|
|
2723
|
+
const transported = readJson(transportResult);
|
|
2724
|
+
const identityMetadata = {
|
|
2725
|
+
postPath: currentIdentity.postPath,
|
|
2726
|
+
payloadSha256: currentIdentity.payloadSha256,
|
|
2727
|
+
localeFingerprint: currentIdentity.localeFingerprint,
|
|
2728
|
+
runDate,
|
|
2729
|
+
};
|
|
2730
|
+
if (transported?.ok === true) {
|
|
2731
|
+
sted('write', result, () =>
|
|
2732
|
+
writeJsonText(result, {
|
|
2733
|
+
ok: true,
|
|
2734
|
+
summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
2735
|
+
targets: [
|
|
2736
|
+
{
|
|
2737
|
+
target: 'surfdash-pub',
|
|
2738
|
+
ok: true,
|
|
2739
|
+
status: 'published',
|
|
2740
|
+
...(typeof transported.url === 'string' ? { url: transported.url } : {}),
|
|
2741
|
+
metadata: { ...metadataOf(transported), ...identityMetadata },
|
|
2742
|
+
},
|
|
2743
|
+
],
|
|
2744
|
+
}),
|
|
2745
|
+
);
|
|
2746
|
+
} else if (transported?.ok === false && metadataOf(transported).deliveryState === 'not-sent') {
|
|
2747
|
+
sted('write', result, () =>
|
|
2748
|
+
writeJsonText(result, {
|
|
2749
|
+
ok: false,
|
|
2750
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2751
|
+
targets: [
|
|
2752
|
+
{
|
|
2753
|
+
target: 'surfdash-pub',
|
|
2754
|
+
ok: false,
|
|
2755
|
+
status: 'failed',
|
|
2756
|
+
error:
|
|
2757
|
+
typeof transported.error === 'string'
|
|
2758
|
+
? transported.error
|
|
2759
|
+
: 'publisher rejected the prepared post',
|
|
2760
|
+
metadata: {
|
|
2761
|
+
...metadataOf(transported),
|
|
2762
|
+
...identityMetadata,
|
|
2763
|
+
deliveryState: 'not-sent',
|
|
2764
|
+
},
|
|
2765
|
+
},
|
|
2766
|
+
],
|
|
2767
|
+
}),
|
|
2768
|
+
);
|
|
2769
|
+
} else {
|
|
2770
|
+
writeSurfdashUncertainReceipt(
|
|
2771
|
+
result,
|
|
2772
|
+
intentPath,
|
|
2773
|
+
runDate,
|
|
2774
|
+
payloadSha256,
|
|
2775
|
+
payloadSha256,
|
|
2776
|
+
typeof transported?.error === 'string'
|
|
2777
|
+
? `publisher reported an ambiguous delivery failure: ${transported.error}`
|
|
2778
|
+
: 'no publish result was recorded after prepared dispatch',
|
|
2779
|
+
currentIdentity,
|
|
2780
|
+
);
|
|
2781
|
+
}
|
|
2782
|
+
} else {
|
|
2783
|
+
kk(
|
|
2784
|
+
[
|
|
2785
|
+
'executor',
|
|
2786
|
+
'fan-out',
|
|
2787
|
+
'--content',
|
|
2788
|
+
prep,
|
|
2789
|
+
'--target',
|
|
2790
|
+
'surfdash-pub',
|
|
2791
|
+
'--out',
|
|
2792
|
+
result,
|
|
2793
|
+
...pluginsArgs(pluginsPath),
|
|
2794
|
+
],
|
|
2795
|
+
{
|
|
2796
|
+
unset: NESTED_SPUR_UNSET,
|
|
2797
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
2798
|
+
soft: true,
|
|
2799
|
+
},
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
// A killed or unusable child can leave a real remote commit with no aggregate receipt. The
|
|
2804
|
+
// intent now makes that state explicit; the next invocation will refuse to dispatch again.
|
|
2805
|
+
const afterState = surfdashTargetState(readJson(result));
|
|
2806
|
+
if (afterState === 'missing' || afterState === 'uncertain') {
|
|
2807
|
+
const afterTarget = surfdashTargetEntries(readJson(result))[0];
|
|
2808
|
+
const afterError =
|
|
2809
|
+
afterState === 'uncertain' && afterTarget !== undefined && typeof afterTarget.error === 'string'
|
|
2810
|
+
? `publisher reported an ambiguous delivery failure: ${afterTarget.error}`
|
|
2811
|
+
: 'no publish result was recorded after dispatch';
|
|
2812
|
+
writeSurfdashUncertainReceipt(
|
|
2134
2813
|
result,
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2814
|
+
intentPath,
|
|
2815
|
+
runDate,
|
|
2816
|
+
payloadSha256,
|
|
2817
|
+
payloadSha256,
|
|
2818
|
+
afterError,
|
|
2819
|
+
currentIdentity,
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2142
2822
|
console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
|
|
2143
2823
|
});
|
|
2144
2824
|
}
|
|
2145
2825
|
|
|
2826
|
+
/**
|
|
2827
|
+
* Prepare the local zh post before locale scaffolding/translation. This stage deliberately stops
|
|
2828
|
+
* at the publisher's file-materialization seam; the normal `publish-surfdash` stage remains the
|
|
2829
|
+
* only operation allowed to invoke Surfdash's refine/CMS/git publishing path.
|
|
2830
|
+
*/
|
|
2831
|
+
export function dailyPrepareSurfdash(args: string[]): void {
|
|
2832
|
+
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2833
|
+
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2834
|
+
const input = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_content.json`);
|
|
2835
|
+
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
|
|
2836
|
+
const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare-transport_result.json`);
|
|
2837
|
+
timed(workDir, 'prepare-surfdash', () => {
|
|
2838
|
+
try {
|
|
2839
|
+
if (!isFile(prep)) failWith(1, `prepare-surfdash: required input not found: ${prep}`);
|
|
2840
|
+
const content = requireJson(prep, 'prepare-surfdash');
|
|
2841
|
+
sted('write', input, () => writeJqText(input, { ...content, options: { operation: 'prepare' } }));
|
|
2842
|
+
kk(
|
|
2843
|
+
[
|
|
2844
|
+
'executor',
|
|
2845
|
+
'run',
|
|
2846
|
+
'surfdash-pub',
|
|
2847
|
+
'--in',
|
|
2848
|
+
input,
|
|
2849
|
+
'--out',
|
|
2850
|
+
transportResult,
|
|
2851
|
+
...pluginsArgs(pluginsPath),
|
|
2852
|
+
],
|
|
2853
|
+
{
|
|
2854
|
+
unset: NESTED_SPUR_UNSET,
|
|
2855
|
+
// The scheduled source post is always zh. Do not let a caller's inherited
|
|
2856
|
+
// locale select an en/ja materialization and then make the sibling gate
|
|
2857
|
+
// compare the wrong source tree.
|
|
2858
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
2859
|
+
},
|
|
2860
|
+
);
|
|
2861
|
+
const prepared = readJson(transportResult);
|
|
2862
|
+
const postPath = metadataOf(prepared).postPath;
|
|
2863
|
+
if (prepared?.ok !== true || typeof postPath !== 'string') {
|
|
2864
|
+
failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${transportResult}`);
|
|
2865
|
+
}
|
|
2866
|
+
if (postPath === '' || !isFile(postPath)) {
|
|
2867
|
+
failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${result}`);
|
|
2868
|
+
}
|
|
2869
|
+
sted('write', result, () =>
|
|
2870
|
+
writeJqText(result, {
|
|
2871
|
+
ok: true,
|
|
2872
|
+
summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
2873
|
+
targets: [
|
|
2874
|
+
{
|
|
2875
|
+
target: 'surfdash-pub',
|
|
2876
|
+
ok: true,
|
|
2877
|
+
status: 'prepared',
|
|
2878
|
+
...(typeof prepared?.url === 'string' ? { url: prepared.url } : {}),
|
|
2879
|
+
metadata: metadataOf(prepared),
|
|
2880
|
+
},
|
|
2881
|
+
],
|
|
2882
|
+
}),
|
|
2883
|
+
);
|
|
2884
|
+
console.log(`prepare-surfdash: prepared zh post ${postPath}`);
|
|
2885
|
+
} catch (error: unknown) {
|
|
2886
|
+
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
2887
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2888
|
+
sted('write', result, () =>
|
|
2889
|
+
writeJqText(result, {
|
|
2890
|
+
ok: false,
|
|
2891
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2892
|
+
targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
|
|
2893
|
+
}),
|
|
2894
|
+
);
|
|
2895
|
+
console.error(message);
|
|
2896
|
+
}
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2146
2900
|
/**
|
|
2147
2901
|
* WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
|
|
2148
2902
|
* draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
|
|
@@ -3063,22 +3817,29 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
3063
3817
|
* deterministic mechanics — `scaffold-locale` for both target locales — while the
|
|
3064
3818
|
* workflow `translate` state's agent step owns the LLM translation of the drafts.
|
|
3065
3819
|
* Writes one marker file per locale (zh post path + en/ja draft paths); empty
|
|
3066
|
-
* marker = locale absent (callers skip). Reuses an existing on-disk draft
|
|
3067
|
-
*
|
|
3820
|
+
* marker = locale absent (callers skip). Reuses an existing on-disk draft only when its
|
|
3821
|
+
* source-hash sidecar matches the current zh post — a changed source must invalidate the
|
|
3822
|
+
* translation cache before the next unattended run.
|
|
3068
3823
|
*/
|
|
3069
3824
|
export function dailyTranslateLocalize(args: string[]): void {
|
|
3070
3825
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
3071
|
-
//
|
|
3072
|
-
//
|
|
3073
|
-
|
|
3826
|
+
// The prepare result is available before translation; the final publish result is retained as
|
|
3827
|
+
// a compatibility fallback for manual recovery of pre-two-phase workspaces.
|
|
3828
|
+
const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
|
|
3074
3829
|
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
3075
3830
|
const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
3076
3831
|
timed(workDir, 'translate-localize', () => {
|
|
3077
|
-
const postPath =
|
|
3832
|
+
const postPath =
|
|
3833
|
+
firstPostPath(readJson(prepareResult), 'surfdash-pub') || firstPostPath(readJson(result), 'surfdash-pub');
|
|
3078
3834
|
const zh = postPath.endsWith('index.md') ? postPath : '';
|
|
3079
3835
|
sted('write', zhFile, () => writeRaw(zhFile, zh));
|
|
3080
3836
|
if (zh === '') {
|
|
3081
3837
|
for (const target of ['en', 'ja'] as const) {
|
|
3838
|
+
const sourceHashFile = join(
|
|
3839
|
+
workDir,
|
|
3840
|
+
'31-publish',
|
|
3841
|
+
`${runDate}_20_translate-localize_${target}-source-sha256.txt`,
|
|
3842
|
+
);
|
|
3082
3843
|
sted(
|
|
3083
3844
|
'write',
|
|
3084
3845
|
join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
|
|
@@ -3088,18 +3849,37 @@ export function dailyTranslateLocalize(args: string[]): void {
|
|
|
3088
3849
|
'',
|
|
3089
3850
|
),
|
|
3090
3851
|
);
|
|
3852
|
+
sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
|
|
3091
3853
|
}
|
|
3092
3854
|
console.log('no zh post path — skip localize');
|
|
3093
3855
|
return;
|
|
3094
3856
|
}
|
|
3857
|
+
let sourceHash: string;
|
|
3858
|
+
try {
|
|
3859
|
+
sourceHash = createHash('sha256').update(readFileSync(zh)).digest('hex');
|
|
3860
|
+
} catch (error: unknown) {
|
|
3861
|
+
failWith(
|
|
3862
|
+
1,
|
|
3863
|
+
`translate-localize: cannot fingerprint zh source ${zh}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3864
|
+
);
|
|
3865
|
+
}
|
|
3095
3866
|
for (const target of ['en', 'ja'] as const) {
|
|
3096
3867
|
const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
|
|
3097
3868
|
const existing = zh.replace('/zh/', `/${target}/`);
|
|
3098
|
-
|
|
3869
|
+
const sourceHashFile = join(
|
|
3870
|
+
workDir,
|
|
3871
|
+
'31-publish',
|
|
3872
|
+
`${runDate}_20_translate-localize_${target}-source-sha256.txt`,
|
|
3873
|
+
);
|
|
3874
|
+
const cachedSourceHash = isFile(sourceHashFile) ? readFileSync(sourceHashFile, 'utf-8').trim() : '';
|
|
3875
|
+
if (isFile(existing) && cachedSourceHash === sourceHash) {
|
|
3099
3876
|
sted('write', markerFile, () => writeRaw(markerFile, existing));
|
|
3100
3877
|
console.log(`${target} draft cached: ${existing}`);
|
|
3101
3878
|
continue;
|
|
3102
3879
|
}
|
|
3880
|
+
if (isFile(existing)) {
|
|
3881
|
+
console.log(`${target} draft invalidated: zh source fingerprint changed or is missing`);
|
|
3882
|
+
}
|
|
3103
3883
|
const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
|
|
3104
3884
|
const scaffoldResult = join(
|
|
3105
3885
|
workDir,
|
|
@@ -3107,20 +3887,236 @@ export function dailyTranslateLocalize(args: string[]): void {
|
|
|
3107
3887
|
`${runDate}_20_translate-localize_scaffold-${target}-result.json`,
|
|
3108
3888
|
);
|
|
3109
3889
|
sted('write', scaffold, () =>
|
|
3110
|
-
writeJqText(scaffold, {
|
|
3890
|
+
writeJqText(scaffold, {
|
|
3891
|
+
body: '',
|
|
3892
|
+
options: {
|
|
3893
|
+
operation: 'scaffold-locale',
|
|
3894
|
+
postPath: zh,
|
|
3895
|
+
target,
|
|
3896
|
+
...(isFile(existing) ? { force: true } : {}),
|
|
3897
|
+
},
|
|
3898
|
+
}),
|
|
3111
3899
|
);
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3900
|
+
try {
|
|
3901
|
+
kk([
|
|
3902
|
+
'executor',
|
|
3903
|
+
'run',
|
|
3904
|
+
'surfdash-pub',
|
|
3905
|
+
'--in',
|
|
3906
|
+
scaffold,
|
|
3907
|
+
'--out',
|
|
3908
|
+
scaffoldResult,
|
|
3909
|
+
...pluginsArgs(pluginsPath),
|
|
3910
|
+
]);
|
|
3911
|
+
const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
|
|
3912
|
+
const draft = typeof draftPath === 'string' ? draftPath : '';
|
|
3913
|
+
sted('write', markerFile, () => writeRaw(markerFile, draft));
|
|
3914
|
+
if (draft !== '') {
|
|
3915
|
+
sted('write', sourceHashFile, () => writeRaw(sourceHashFile, sourceHash));
|
|
3916
|
+
} else {
|
|
3917
|
+
sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
|
|
3918
|
+
}
|
|
3919
|
+
} catch (error: unknown) {
|
|
3920
|
+
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
3921
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3922
|
+
sted('write', markerFile, () => writeRaw(markerFile, ''));
|
|
3923
|
+
sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
|
|
3924
|
+
console.error(`translate-localize: ${target} scaffold failed: ${message}`);
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
/**
|
|
3931
|
+
* Run Surfdash's own refine command over the complete locale set before the validation fingerprint
|
|
3932
|
+
* is written. This is deliberately a separate local-only phase: the final publish FSM also runs
|
|
3933
|
+
* refine, but it must be idempotent by the time any CMS/git side effect is reachable.
|
|
3934
|
+
*/
|
|
3935
|
+
function normalizeTranslatedLocales(zhPostPath: string, failUnsafe = true): void {
|
|
3936
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
3937
|
+
const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
|
|
3938
|
+
if (!isFile(sibling)) continue;
|
|
3939
|
+
const raw = readFileSync(sibling, 'utf-8');
|
|
3940
|
+
const { markdown, remaining } = normalizeSeriesNames(raw, locale);
|
|
3941
|
+
if (failUnsafe && remaining.length > 0) {
|
|
3942
|
+
failWith(1, `series-name normalization left unsafe names in ${locale}: ${remaining.join(' / ')}`);
|
|
3943
|
+
}
|
|
3944
|
+
if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
export function dailySurfdashRefine(args: string[]): void {
|
|
3949
|
+
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
3950
|
+
const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
3951
|
+
const input = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_content.json`);
|
|
3952
|
+
const result = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_result.json`);
|
|
3953
|
+
timed(workDir, 'surfdash-refine', () => {
|
|
3954
|
+
try {
|
|
3955
|
+
const zhPostPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
|
|
3956
|
+
if (zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
|
|
3957
|
+
failWith(1, `surfdash-refine: zh marker must name an existing post: ${marker}`);
|
|
3958
|
+
}
|
|
3959
|
+
validateLocaleMarkers(workDir, runDate, zhPostPath, 'surfdash-refine');
|
|
3960
|
+
// The validator is intentionally read-only. Repair known series spellings before
|
|
3961
|
+
// site-owned refine computes managed fields, otherwise the final publisher would
|
|
3962
|
+
// rewrite the refined bytes after the locale fingerprint was admitted.
|
|
3963
|
+
normalizeTranslatedLocales(zhPostPath);
|
|
3964
|
+
sted('write', input, () =>
|
|
3965
|
+
writeJqText(input, {
|
|
3966
|
+
body: '',
|
|
3967
|
+
format: 'markdown',
|
|
3968
|
+
options: { operation: 'refine-prepared', postPath: zhPostPath, runDate },
|
|
3969
|
+
}),
|
|
3970
|
+
);
|
|
3971
|
+
rmSync(result, { force: true });
|
|
3972
|
+
kk(['executor', 'run', 'surfdash-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
|
|
3973
|
+
unset: NESTED_SPUR_UNSET,
|
|
3974
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
3975
|
+
});
|
|
3976
|
+
const refined = readJson(result);
|
|
3977
|
+
if (refined?.ok !== true) {
|
|
3978
|
+
failWith(1, `surfdash-refine: publisher did not return a successful refine receipt: ${result}`);
|
|
3979
|
+
}
|
|
3980
|
+
sted('write', result, () =>
|
|
3981
|
+
writeJqText(result, {
|
|
3982
|
+
ok: true,
|
|
3983
|
+
summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
3984
|
+
targets: [
|
|
3985
|
+
{
|
|
3986
|
+
target: 'surfdash-pub',
|
|
3987
|
+
ok: true,
|
|
3988
|
+
status: 'refined',
|
|
3989
|
+
metadata: metadataOf(refined),
|
|
3990
|
+
},
|
|
3991
|
+
],
|
|
3992
|
+
}),
|
|
3993
|
+
);
|
|
3994
|
+
console.log(`surfdash-refine: site-owned refine completed for ${zhPostPath}`);
|
|
3995
|
+
} catch (error: unknown) {
|
|
3996
|
+
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
3997
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3998
|
+
sted('write', result, () =>
|
|
3999
|
+
writeJqText(result, {
|
|
4000
|
+
ok: false,
|
|
4001
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
4002
|
+
targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
|
|
4003
|
+
}),
|
|
4004
|
+
);
|
|
4005
|
+
console.error(`surfdash-refine: ${message}`);
|
|
4006
|
+
}
|
|
4007
|
+
});
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
/** Validates locale siblings without contacting Surfdash or mutating their bytes. */
|
|
4011
|
+
function validateTranslatedLocales(zhPostPath: string, step: 'translate-validate' | 'translate-sync'): void {
|
|
4012
|
+
const zhPoints = countKeyPoints(zhPostPath);
|
|
4013
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
4014
|
+
const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
|
|
4015
|
+
if (!isFile(sibling)) {
|
|
4016
|
+
if (locale !== 'zh' && step === 'translate-validate') {
|
|
4017
|
+
failWith(1, `${step}: ${locale} draft is missing: ${sibling}`);
|
|
4018
|
+
}
|
|
4019
|
+
continue;
|
|
4020
|
+
}
|
|
4021
|
+
const problems: string[] = [];
|
|
4022
|
+
const raw = readFileSync(sibling, 'utf-8');
|
|
4023
|
+
const { markdown, remaining } = normalizeSeriesNames(raw, locale);
|
|
4024
|
+
if (markdown !== raw) problems.push('series-name normalization is pending; run the refine preparation first');
|
|
4025
|
+
if (remaining.length > 0) {
|
|
4026
|
+
problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
|
|
4027
|
+
}
|
|
4028
|
+
const items = readKeyPointItems(sibling);
|
|
4029
|
+
if (step === 'translate-validate') {
|
|
4030
|
+
if (readFrontmatterScalar(sibling, 'refined') !== 'true') {
|
|
4031
|
+
problems.push('site-owned refine has not completed (refined: true is required)');
|
|
4032
|
+
}
|
|
4033
|
+
if (items === undefined || items.length === 0) {
|
|
4034
|
+
problems.push('key_points missing or malformed');
|
|
4035
|
+
} else {
|
|
4036
|
+
const malformed = items.filter((item) => {
|
|
4037
|
+
const separator = item.indexOf(' — ');
|
|
4038
|
+
return separator <= 0 || item.slice(separator + 3).trim() === '';
|
|
4039
|
+
});
|
|
4040
|
+
if (malformed.length > 0) problems.push('key_points missing or malformed');
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
if (locale !== 'zh') {
|
|
4044
|
+
if (readFrontmatterScalar(sibling, 'description') === undefined) {
|
|
4045
|
+
problems.push('description missing/empty');
|
|
4046
|
+
}
|
|
4047
|
+
const points = countKeyPoints(sibling);
|
|
4048
|
+
if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
|
|
4049
|
+
problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
|
|
4050
|
+
}
|
|
4051
|
+
// The scheduled path must not let an untranslated scaffold reach the strict CMS sync.
|
|
4052
|
+
if (step === 'translate-validate' && readFrontmatterScalar(sibling, 'draft') !== 'false') {
|
|
4053
|
+
problems.push('draft is still true — locale was scaffolded but not translated');
|
|
4054
|
+
}
|
|
4055
|
+
// Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
|
|
4056
|
+
// and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
|
|
4057
|
+
// sentence and never an ellipsis-truncated fragment.
|
|
4058
|
+
if (items !== undefined) {
|
|
4059
|
+
const cap = locale === 'en' ? 28 : 14;
|
|
4060
|
+
const overLimit = items.filter((item) => {
|
|
4061
|
+
const separator = item.indexOf(' — ');
|
|
4062
|
+
if (separator < 0) return false;
|
|
4063
|
+
const label = item.slice(0, separator);
|
|
4064
|
+
return label.includes('…') || Array.from(label).length > cap;
|
|
4065
|
+
});
|
|
4066
|
+
if (overLimit.length > 0) {
|
|
4067
|
+
problems.push(
|
|
4068
|
+
`key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
|
|
4069
|
+
);
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
if (problems.length > 0) {
|
|
4074
|
+
failWith(
|
|
4075
|
+
1,
|
|
4076
|
+
`${step}: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
|
|
4077
|
+
);
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
/** Local pre-CMS gate for the scheduled two-phase Surfdash flow. */
|
|
4083
|
+
export function dailyTranslateValidate(args: string[]): void {
|
|
4084
|
+
const [workDir = '', runDate = '', _pluginsPath = '', zhPathFile = ''] = args;
|
|
4085
|
+
const validationResult = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
|
|
4086
|
+
timed(workDir, 'translate-validate', () => {
|
|
4087
|
+
const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
|
|
4088
|
+
try {
|
|
4089
|
+
if (zhPathFile.endsWith('.md') || zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
|
|
4090
|
+
failWith(
|
|
4091
|
+
1,
|
|
4092
|
+
`translate-validate: 4th argument must name an existing zh marker file (${runDate}_20_translate-localize_zh-post-path.txt)`,
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
validateLocaleMarkers(workDir, runDate, zhPostPath, 'translate-validate');
|
|
4096
|
+
validateTranslatedLocales(zhPostPath, 'translate-validate');
|
|
4097
|
+
const evidence = surfdashLocaleValidationEvidence(zhPostPath, runDate);
|
|
4098
|
+
if (!evidence.ok) failWith(1, `translate-validate: ${evidence.reason}`);
|
|
4099
|
+
sted('write', validationResult, () =>
|
|
4100
|
+
writeJqText(validationResult, {
|
|
4101
|
+
ok: true,
|
|
4102
|
+
target: 'surfdash-pub',
|
|
4103
|
+
status: 'validated',
|
|
4104
|
+
metadata: evidence.evidence,
|
|
4105
|
+
}),
|
|
4106
|
+
);
|
|
4107
|
+
console.log(`translate-validate: locale files ready for final Surfdash publish (${zhPostPath})`);
|
|
4108
|
+
} catch (error: unknown) {
|
|
4109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4110
|
+
sted('write', validationResult, () =>
|
|
4111
|
+
writeJqText(validationResult, {
|
|
4112
|
+
ok: false,
|
|
4113
|
+
target: 'surfdash-pub',
|
|
4114
|
+
status: 'rejected',
|
|
4115
|
+
error: message,
|
|
4116
|
+
metadata: { postPath: zhPostPath },
|
|
4117
|
+
}),
|
|
4118
|
+
);
|
|
4119
|
+
console.error(`translate-validate: ${message}`);
|
|
3124
4120
|
}
|
|
3125
4121
|
});
|
|
3126
4122
|
}
|
|
@@ -3146,58 +4142,11 @@ export function dailyTranslateSync(args: string[]): void {
|
|
|
3146
4142
|
`translate-sync: 4th argument must be the zh marker file (${runDate}_20_translate-localize_zh-post-path.txt), not the post itself — '${zhPathFile}' holds ${zhPostPath.includes('\n') ? 'a multi-line document' : `'${zhPostPath}'`}`,
|
|
3147
4143
|
);
|
|
3148
4144
|
}
|
|
3149
|
-
//
|
|
3150
|
-
|
|
3151
|
-
//
|
|
3152
|
-
//
|
|
3153
|
-
|
|
3154
|
-
// locale deterministically (URLs untouched) and any leftover it cannot make safe fails the
|
|
3155
|
-
// sync. The zh post rides the same sync, so it gets the same series-name pass.
|
|
3156
|
-
const zhPoints = countKeyPoints(zhPostPath);
|
|
3157
|
-
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
3158
|
-
const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
|
|
3159
|
-
if (!isFile(sibling)) continue;
|
|
3160
|
-
const problems: string[] = [];
|
|
3161
|
-
const raw = readFileSync(sibling, 'utf-8');
|
|
3162
|
-
const { markdown, remaining } = normalizeSeriesNames(raw, locale);
|
|
3163
|
-
if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
|
|
3164
|
-
if (remaining.length > 0) {
|
|
3165
|
-
problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
|
|
3166
|
-
}
|
|
3167
|
-
if (locale !== 'zh') {
|
|
3168
|
-
if (readFrontmatterScalar(sibling, 'description') === undefined) {
|
|
3169
|
-
problems.push('description missing/empty');
|
|
3170
|
-
}
|
|
3171
|
-
const points = countKeyPoints(sibling);
|
|
3172
|
-
if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
|
|
3173
|
-
problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
|
|
3174
|
-
}
|
|
3175
|
-
// Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
|
|
3176
|
-
// and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
|
|
3177
|
-
// sentence and never an ellipsis-truncated fragment.
|
|
3178
|
-
const items = readKeyPointItems(sibling);
|
|
3179
|
-
if (items !== undefined) {
|
|
3180
|
-
const cap = locale === 'en' ? 28 : 14;
|
|
3181
|
-
const overLimit = items.filter((item) => {
|
|
3182
|
-
const sep = item.indexOf(' — ');
|
|
3183
|
-
if (sep < 0) return false;
|
|
3184
|
-
const label = item.slice(0, sep);
|
|
3185
|
-
return label.includes('…') || Array.from(label).length > cap;
|
|
3186
|
-
});
|
|
3187
|
-
if (overLimit.length > 0) {
|
|
3188
|
-
problems.push(
|
|
3189
|
-
`key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
|
|
3190
|
-
);
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
|
-
}
|
|
3194
|
-
if (problems.length > 0) {
|
|
3195
|
-
failWith(
|
|
3196
|
-
1,
|
|
3197
|
-
`translate-sync: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
|
|
3198
|
-
);
|
|
3199
|
-
}
|
|
3200
|
-
}
|
|
4145
|
+
// Preserve the legacy explicit sync repair behavior before the shared read-only gate.
|
|
4146
|
+
normalizeTranslatedLocales(zhPostPath, false);
|
|
4147
|
+
// The 2026-10-23 locale structural gate is shared with the scheduled two-phase validator.
|
|
4148
|
+
// This explicit recovery command still performs the real CMS sync after validation.
|
|
4149
|
+
validateTranslatedLocales(zhPostPath, 'translate-sync');
|
|
3201
4150
|
sted('write', sync, () =>
|
|
3202
4151
|
writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
|
|
3203
4152
|
);
|