@zzclub/pipeline 0.9.0 → 0.10.0
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/package.json +1 -1
- package/src/adapter-loader.ts +6 -1
- package/src/commands/analytics.test.ts +3 -2
- package/src/commands/analytics.ts +21 -4
- package/src/commands/attach-body-images.ts +23 -3
- package/src/commands/attach-body.ts +12 -3
- package/src/commands/attach-newspic-spec.ts +14 -3
- package/src/commands/config.ts +1 -0
- package/src/commands/ingest-handoff.ts +12 -6
- package/src/commands/init.ts +20 -36
- package/src/commands/prepare-finalize.ts +75 -43
- package/src/commands/prepare.ts +44 -17
- package/src/commands/publish.ts +63 -29
- package/src/commands/reconcile.ts +5 -3
- package/src/commands/render.ts +50 -9
- package/src/commands/republish.ts +73 -30
- package/src/commands/reset.test.ts +43 -0
- package/src/commands/reset.ts +25 -32
- package/src/commands/review.ts +5 -3
- package/src/commands/sync-blog.ts +20 -3
- package/src/config.ts +0 -0
- package/src/providers/blog.test.ts +37 -1
- package/src/providers/blog.ts +51 -13
- package/src/providers/cos.ts +23 -19
- package/src/providers/index.ts +28 -22
- package/src/providers/publish-core.ts +47 -26
- package/src/providers/wechat.test.ts +55 -0
- package/src/providers/wechat.ts +126 -14
- package/src/publish-targets.ts +143 -0
- package/src/schema/state.ts +1 -0
- package/src/state.test.ts +151 -0
- package/src/state.ts +267 -11
- package/src/task-manager.ts +62 -29
- package/src/workflow-materials.test.ts +46 -4
- package/src/workflow-materials.ts +41 -7
- package/src/workflow.test.ts +295 -23
package/src/commands/prepare.ts
CHANGED
|
@@ -25,17 +25,19 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { copyFile, mkdir, readFile, writeFile } from "fs/promises";
|
|
28
|
-
import { extname, join, resolve } from "path";
|
|
28
|
+
import { dirname, extname, join, resolve } from "path";
|
|
29
29
|
import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
|
|
30
30
|
import { printResult, renderPrepare } from "../output";
|
|
31
31
|
import { resolveWorkspacePaths } from "../config";
|
|
32
32
|
import {
|
|
33
33
|
defaultBodyInputs,
|
|
34
|
-
|
|
34
|
+
readResolvedState,
|
|
35
|
+
reenterPrepare,
|
|
35
36
|
writeState,
|
|
36
37
|
} from "../state";
|
|
37
38
|
import { resolveFullRoute } from "../routes";
|
|
38
39
|
import { resolveAuthoring, hasStyleRequest } from "../profiles";
|
|
40
|
+
import { parseAccountName, parseRoutePrimary } from "../publish-targets";
|
|
39
41
|
import {
|
|
40
42
|
formatArticle,
|
|
41
43
|
generateSlug,
|
|
@@ -106,11 +108,13 @@ Options:
|
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
const
|
|
111
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
110
112
|
const bodyPath = optionalArg(parsed, "body");
|
|
111
113
|
const titleOverride = optionalArg(parsed, "title");
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
+
const accountRaw = optionalArg(parsed, "account");
|
|
115
|
+
const accountOverride = accountRaw ? parseAccountName(accountRaw) : undefined;
|
|
116
|
+
const routeRaw = optionalArg(parsed, "route");
|
|
117
|
+
const routeOverride = routeRaw ? parseRoutePrimary(routeRaw) : undefined;
|
|
114
118
|
const extrasRaw = optionalArg(parsed, "extras");
|
|
115
119
|
const isStyleRequest = flagArg(parsed, "style-request");
|
|
116
120
|
const bodyOutPath = optionalArg(parsed, "body-out");
|
|
@@ -122,11 +126,13 @@ Options:
|
|
|
122
126
|
: undefined;
|
|
123
127
|
|
|
124
128
|
const extras = extrasRaw
|
|
125
|
-
?
|
|
129
|
+
? extrasRaw.split(",").map((value) => parseRoutePrimary(value.trim(), "extra route"))
|
|
126
130
|
: undefined;
|
|
127
131
|
|
|
128
132
|
// Read state and body
|
|
129
|
-
const
|
|
133
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
134
|
+
const statePath = resolved.path;
|
|
135
|
+
const state = resolved.state;
|
|
130
136
|
const intentText = optionalArg(parsed, "intent-text") ?? state.intent.intent_text ?? "";
|
|
131
137
|
const resolvedBodyPath = bodyPath
|
|
132
138
|
? await stageManagedBodyFile(state.workspace_root, state.run_id, bodyPath)
|
|
@@ -136,6 +142,13 @@ Options:
|
|
|
136
142
|
}
|
|
137
143
|
const bodyRaw = await readFile(resolvedBodyPath, "utf-8");
|
|
138
144
|
const cleanBody = stripFrontmatter(bodyRaw);
|
|
145
|
+
reenterPrepare(state, {
|
|
146
|
+
redoHint: state.redo_hint,
|
|
147
|
+
resetReview: Boolean(bodyPath),
|
|
148
|
+
});
|
|
149
|
+
if (bodyPath && state.handoff.review_policy === "trust_user") {
|
|
150
|
+
state.content_review = { status: "passed", feedback: null };
|
|
151
|
+
}
|
|
139
152
|
state.source_body_path = resolvedBodyPath;
|
|
140
153
|
state.intent.intent_text = intentText || state.intent.intent_text;
|
|
141
154
|
|
|
@@ -151,7 +164,7 @@ Options:
|
|
|
151
164
|
: resolveFullRoute(intentText, {
|
|
152
165
|
primary: routeOverride,
|
|
153
166
|
extras,
|
|
154
|
-
account: accountOverride,
|
|
167
|
+
account: accountOverride ?? (state.route?.account || undefined),
|
|
155
168
|
contentForm: state.intent.content_form,
|
|
156
169
|
targets: state.intent.targets,
|
|
157
170
|
});
|
|
@@ -215,6 +228,9 @@ Options:
|
|
|
215
228
|
break;
|
|
216
229
|
}
|
|
217
230
|
}
|
|
231
|
+
if (!title) {
|
|
232
|
+
throw new Error("Unable to derive a title from an empty body. Pass --title explicitly.");
|
|
233
|
+
}
|
|
218
234
|
|
|
219
235
|
// Prefer editor-supplied slug; sanitize it defensively, then fall back to auto-generate
|
|
220
236
|
const slug = suggestedSlug
|
|
@@ -224,11 +240,16 @@ Options:
|
|
|
224
240
|
.replace(/^-+|-+$/g, "")
|
|
225
241
|
.slice(0, 80) || generateSlug(title)
|
|
226
242
|
: generateSlug(title);
|
|
227
|
-
const date =
|
|
243
|
+
const date = state.asset_path && state.metadata.date
|
|
244
|
+
? state.metadata.date
|
|
245
|
+
: todayDate();
|
|
228
246
|
|
|
229
247
|
// Description: required for wechat-article, optional otherwise
|
|
230
248
|
let description: string | null = null;
|
|
231
|
-
if (
|
|
249
|
+
if (
|
|
250
|
+
route.primary === "wechat-article" ||
|
|
251
|
+
route.extras.includes("wechat-article")
|
|
252
|
+
) {
|
|
232
253
|
description = extractDescription(bodyFormatted);
|
|
233
254
|
}
|
|
234
255
|
|
|
@@ -252,7 +273,7 @@ Options:
|
|
|
252
273
|
route.extras.includes("wechat-newspic");
|
|
253
274
|
|
|
254
275
|
if (needsArticleInputs) {
|
|
255
|
-
const markers = findIllustrationMarkers(cleanBody);
|
|
276
|
+
const markers = [...new Set(findIllustrationMarkers(cleanBody))];
|
|
256
277
|
if (markers.length > 0) {
|
|
257
278
|
const previousInputs =
|
|
258
279
|
state.images.body_inputs.scope === "article"
|
|
@@ -279,18 +300,24 @@ Options:
|
|
|
279
300
|
state.images.body_inputs = defaultBodyInputs();
|
|
280
301
|
}
|
|
281
302
|
|
|
282
|
-
|
|
303
|
+
if (
|
|
304
|
+
state.redo_hint === "format" ||
|
|
305
|
+
state.redo_hint === "asset-meta" ||
|
|
306
|
+
state.redo_hint === "channel-route"
|
|
307
|
+
) {
|
|
308
|
+
state.redo_hint = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Commit the formatted artifact before state starts pointing at it.
|
|
283
312
|
state.formatted_body_path =
|
|
284
313
|
bodyOutPath ?? getManagedFormattedBodyPath(state.workspace_root, state.run_id);
|
|
285
|
-
await writeState(statePath, state);
|
|
286
|
-
|
|
287
|
-
// ── Output formatted body ──
|
|
288
314
|
const formattedBodyPath = state.formatted_body_path;
|
|
289
|
-
await mkdir(
|
|
315
|
+
await mkdir(dirname(formattedBodyPath), { recursive: true });
|
|
290
316
|
await writeFile(formattedBodyPath, bodyFormatted, "utf-8");
|
|
317
|
+
await writeState(statePath, state);
|
|
291
318
|
|
|
292
319
|
// Output summary for orchestrator
|
|
293
|
-
const illustrationMarkers = findIllustrationMarkers(cleanBody);
|
|
320
|
+
const illustrationMarkers = [...new Set(findIllustrationMarkers(cleanBody))];
|
|
294
321
|
const output = {
|
|
295
322
|
state_path: statePath,
|
|
296
323
|
route: {
|
package/src/commands/publish.ts
CHANGED
|
@@ -17,13 +17,23 @@ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
|
|
|
17
17
|
import { printResult, renderPublish } from "../output";
|
|
18
18
|
import { loadConfig, resolveWorkspacePaths } from "../config";
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
acquireStateOperationLock,
|
|
21
|
+
readResolvedState,
|
|
21
22
|
validateForPhase,
|
|
22
23
|
writeState,
|
|
23
24
|
type PublishTarget,
|
|
24
25
|
type RoutePrimary,
|
|
25
26
|
} from "../state";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
getStatePublishTargets,
|
|
29
|
+
parseRoutePrimary,
|
|
30
|
+
validatePublishTargetCompatibility,
|
|
31
|
+
} from "../publish-targets";
|
|
32
|
+
import {
|
|
33
|
+
executePublishTargets,
|
|
34
|
+
upsertPublishResult,
|
|
35
|
+
} from "../providers/publish-core";
|
|
36
|
+
import { reconcileStateArtifacts } from "../workflow-materials";
|
|
27
37
|
|
|
28
38
|
export async function publish(args: string[]): Promise<void> {
|
|
29
39
|
const parsed = parseArgs(args);
|
|
@@ -40,36 +50,52 @@ Options:
|
|
|
40
50
|
return;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
53
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
54
|
+
const routeRaw = optionalArg(parsed, "route");
|
|
55
|
+
const routeOverride: RoutePrimary | undefined = routeRaw
|
|
56
|
+
? parseRoutePrimary(routeRaw)
|
|
57
|
+
: undefined;
|
|
45
58
|
const dryRun = flagArg(parsed, "dry-run");
|
|
46
59
|
|
|
47
|
-
const
|
|
60
|
+
const initialResolved = await readResolvedState(requestedStatePath);
|
|
61
|
+
const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
|
|
62
|
+
try {
|
|
63
|
+
const resolved = await readResolvedState(initialResolved.path);
|
|
64
|
+
const statePath = resolved.path;
|
|
65
|
+
const state = resolved.state;
|
|
66
|
+
await reconcileStateArtifacts(state);
|
|
48
67
|
const config = loadConfig();
|
|
49
68
|
const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
|
|
50
|
-
const validationErrors = validateForPhase(state, "publish");
|
|
51
69
|
|
|
52
70
|
if (!state.asset_path) {
|
|
53
71
|
throw new Error("asset_path not set. Cannot publish.");
|
|
54
72
|
}
|
|
55
|
-
if (validationErrors.length > 0) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`Publish validation failed: ${validationErrors.map((item) => `${item.field}: ${item.message}`).join("; ")}`,
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
73
|
|
|
61
74
|
// Determine targets
|
|
62
75
|
let targets: PublishTarget[];
|
|
63
76
|
if (routeOverride) {
|
|
64
77
|
targets = [{ route: routeOverride, account: state.route.account }];
|
|
65
|
-
} else if (state.publish_targets.length > 0) {
|
|
66
|
-
targets = state.publish_targets;
|
|
67
78
|
} else {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
targets = getStatePublishTargets(state);
|
|
80
|
+
}
|
|
81
|
+
validatePublishTargetCompatibility(targets, state.intent.content_form);
|
|
82
|
+
const validationState = {
|
|
83
|
+
...state,
|
|
84
|
+
state_path: state.state_path || statePath,
|
|
85
|
+
publish_targets: targets,
|
|
86
|
+
};
|
|
87
|
+
const validationErrors = validateForPhase(validationState, "publish");
|
|
88
|
+
if (validationErrors.length > 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Publish validation failed: ${validationErrors.map((item) => `${item.field}: ${item.message}`).join("; ")}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const wechatTargets = targets.filter((target) => target.route.startsWith("wechat-"));
|
|
95
|
+
if (state.intent.existing_draft_media_id && wechatTargets.length > 1) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"existing_draft_media_id can only be used with one WeChat publish target",
|
|
98
|
+
);
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
const { results, errors } = await executePublishTargets({
|
|
@@ -78,18 +104,23 @@ Options:
|
|
|
78
104
|
dryRun,
|
|
79
105
|
config,
|
|
80
106
|
workspacePaths,
|
|
107
|
+
onResult: dryRun
|
|
108
|
+
? undefined
|
|
109
|
+
: async (result) => {
|
|
110
|
+
upsertPublishResult(state, result);
|
|
111
|
+
await writeState(statePath, state);
|
|
112
|
+
},
|
|
81
113
|
});
|
|
82
114
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
}
|
|
115
|
+
if (dryRun) {
|
|
116
|
+
printResult({
|
|
117
|
+
publish_results: results,
|
|
118
|
+
mode: state.mode,
|
|
119
|
+
phase: state.phase.current,
|
|
120
|
+
dry_run: true,
|
|
121
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
122
|
+
}, renderPublish);
|
|
123
|
+
return;
|
|
93
124
|
}
|
|
94
125
|
|
|
95
126
|
// Check if all targets are done
|
|
@@ -97,7 +128,7 @@ Options:
|
|
|
97
128
|
const r = state.publish.results.find(
|
|
98
129
|
(x) => x.route === target.route && x.account === target.account,
|
|
99
130
|
);
|
|
100
|
-
return r
|
|
131
|
+
return r?.status === "success";
|
|
101
132
|
});
|
|
102
133
|
|
|
103
134
|
if (allDone) {
|
|
@@ -121,4 +152,7 @@ Options:
|
|
|
121
152
|
output.errors = errors;
|
|
122
153
|
}
|
|
123
154
|
printResult(output, renderPublish);
|
|
155
|
+
} finally {
|
|
156
|
+
await releaseOperationLock();
|
|
157
|
+
}
|
|
124
158
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs, requireArg } from "../args";
|
|
2
2
|
import { printResult, renderTaskShape } from "../output";
|
|
3
|
-
import {
|
|
3
|
+
import { readResolvedState, writeState } from "../state";
|
|
4
4
|
import { getTaskByStatePath } from "../task-manager";
|
|
5
5
|
import { reconcileStateArtifacts } from "../workflow-materials";
|
|
6
6
|
|
|
@@ -17,8 +17,10 @@ Options:
|
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
21
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
22
|
+
const statePath = resolved.path;
|
|
23
|
+
const state = resolved.state;
|
|
22
24
|
await reconcileStateArtifacts(state);
|
|
23
25
|
await writeState(statePath, state);
|
|
24
26
|
|
package/src/commands/render.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Output: Updated state with images.plan, images.render_assets
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { readFile } from "fs/promises";
|
|
17
|
+
import { access, readFile } from "fs/promises";
|
|
18
18
|
import { join } from "path";
|
|
19
19
|
|
|
20
20
|
import { parseArgs, requireArg, flagArg } from "../args";
|
|
@@ -22,11 +22,14 @@ import { printResult, renderRender } from "../output";
|
|
|
22
22
|
import { loadConfig } from "../config";
|
|
23
23
|
import { resolveImageRenderer } from "../adapter-loader";
|
|
24
24
|
import {
|
|
25
|
+
acquireStateOperationLock,
|
|
25
26
|
defaultBodyInputs,
|
|
26
27
|
normalizeNewspicRenderSpec,
|
|
27
|
-
|
|
28
|
+
readResolvedState,
|
|
29
|
+
validateForPhase,
|
|
28
30
|
writeState,
|
|
29
31
|
type NewspicRenderSpec,
|
|
32
|
+
type WorkflowState,
|
|
30
33
|
} from "../state";
|
|
31
34
|
import {
|
|
32
35
|
stripFrontmatter,
|
|
@@ -35,7 +38,7 @@ import {
|
|
|
35
38
|
import { getLongformTheme } from "../routes";
|
|
36
39
|
import { collectNewspicRequiredMarkers } from "../workflow-materials";
|
|
37
40
|
|
|
38
|
-
function getNewspicRenderSpec(state:
|
|
41
|
+
function getNewspicRenderSpec(state: WorkflowState): NewspicRenderSpec {
|
|
39
42
|
const spec = normalizeNewspicRenderSpec(state.intent.newspic_render);
|
|
40
43
|
return {
|
|
41
44
|
...spec,
|
|
@@ -57,14 +60,22 @@ Options:
|
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
const
|
|
63
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
61
64
|
const skipRender = flagArg(parsed, "skip-render");
|
|
62
65
|
|
|
63
|
-
const
|
|
66
|
+
const initialResolved = await readResolvedState(requestedStatePath);
|
|
67
|
+
const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
|
|
68
|
+
try {
|
|
69
|
+
const resolved = await readResolvedState(initialResolved.path);
|
|
70
|
+
const statePath = resolved.path;
|
|
71
|
+
const state = resolved.state;
|
|
64
72
|
|
|
65
73
|
// Validate prerequisites
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const validationErrors = validateForPhase(state, "render");
|
|
75
|
+
if (validationErrors.length > 0) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Render validation failed: ${validationErrors.map((item) => `${item.field}: ${item.message}`).join("; ")}`,
|
|
78
|
+
);
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
const postPath = join(state.asset_path, "post.md");
|
|
@@ -89,6 +100,8 @@ Options:
|
|
|
89
100
|
state.images.render_assets = [];
|
|
90
101
|
state.phase.render = { status: "done", error: null };
|
|
91
102
|
state.phase.current = state.intent.requires.publish ? "publish" : "done";
|
|
103
|
+
state.mode = state.phase.current === "done" ? "done" : "active";
|
|
104
|
+
state.redo_hint = null;
|
|
92
105
|
await writeState(statePath, state);
|
|
93
106
|
printResult({
|
|
94
107
|
plan: state.images.plan,
|
|
@@ -224,21 +237,49 @@ Options:
|
|
|
224
237
|
theme: routePrimary === "wechat-newspic" ? getLongformTheme(state.route.account) : undefined,
|
|
225
238
|
template,
|
|
226
239
|
});
|
|
240
|
+
const routeAssets = renderResult.assets.filter(
|
|
241
|
+
(asset) => asset.route === routePrimary && asset.path.trim().length > 0,
|
|
242
|
+
);
|
|
243
|
+
if (routeAssets.length === 0) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Image renderer '${imageRenderer.name}' returned no assets for ${routePrimary}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
routePrimary === "wechat-article" &&
|
|
250
|
+
!routeAssets.some((asset) => asset.kind === "cover")
|
|
251
|
+
) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Image renderer '${imageRenderer.name}' did not return a WeChat article cover`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
for (const asset of routeAssets) {
|
|
257
|
+
await access(asset.path).catch(() => {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Image renderer '${imageRenderer.name}' returned a missing asset: ${asset.path}`,
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
227
263
|
|
|
228
264
|
// ── Update state ────────────────────────────────────────────────
|
|
229
265
|
|
|
230
|
-
state.images.render_assets =
|
|
266
|
+
state.images.render_assets = routeAssets;
|
|
231
267
|
state.images.plan.status = "rendered";
|
|
232
268
|
state.phase.render = { status: "done", error: null };
|
|
233
269
|
state.phase.current = state.intent.requires.publish ? "publish" : "done";
|
|
270
|
+
state.mode = state.phase.current === "done" ? "done" : "active";
|
|
271
|
+
state.redo_hint = null;
|
|
234
272
|
state.artifacts.render_version += 1;
|
|
235
273
|
|
|
236
274
|
await writeState(statePath, state);
|
|
237
275
|
|
|
238
276
|
printResult({
|
|
239
277
|
plan: state.images.plan,
|
|
240
|
-
render_assets:
|
|
278
|
+
render_assets: routeAssets,
|
|
241
279
|
render_version: state.artifacts.render_version,
|
|
242
280
|
phase: state.phase.current,
|
|
243
281
|
}, renderRender);
|
|
282
|
+
} finally {
|
|
283
|
+
await releaseOperationLock();
|
|
284
|
+
}
|
|
244
285
|
}
|
|
@@ -18,12 +18,24 @@ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
|
|
|
18
18
|
import { printResult } from "../output";
|
|
19
19
|
import { loadConfig, resolveWorkspacePaths } from "../config";
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
acquireStateOperationLock,
|
|
22
|
+
readResolvedState,
|
|
23
|
+
validateForPhase,
|
|
22
24
|
writeState,
|
|
23
25
|
type PublishTarget,
|
|
24
|
-
type RoutePrimary,
|
|
25
26
|
} from "../state";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
getStatePublishTargets,
|
|
29
|
+
parseAccountName,
|
|
30
|
+
parseRoutePrimary,
|
|
31
|
+
validatePublishTargetCompatibility,
|
|
32
|
+
} from "../publish-targets";
|
|
33
|
+
import {
|
|
34
|
+
dedupeTargets,
|
|
35
|
+
executePublishTargets,
|
|
36
|
+
upsertPublishResult,
|
|
37
|
+
} from "../providers/publish-core";
|
|
38
|
+
import { reconcileStateArtifacts } from "../workflow-materials";
|
|
27
39
|
|
|
28
40
|
export async function republish(args: string[]): Promise<void> {
|
|
29
41
|
const parsed = parseArgs(args);
|
|
@@ -54,12 +66,19 @@ Examples:
|
|
|
54
66
|
return;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
const
|
|
58
|
-
const
|
|
69
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
70
|
+
const accountRaw = optionalArg(parsed, "account");
|
|
71
|
+
const accountArg = accountRaw ? parseAccountName(accountRaw) : undefined;
|
|
59
72
|
const targetsArg = optionalArg(parsed, "targets");
|
|
60
73
|
const dryRun = flagArg(parsed, "dry-run");
|
|
61
74
|
|
|
62
|
-
const
|
|
75
|
+
const initialResolved = await readResolvedState(requestedStatePath);
|
|
76
|
+
const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
|
|
77
|
+
try {
|
|
78
|
+
const resolved = await readResolvedState(initialResolved.path);
|
|
79
|
+
const statePath = resolved.path;
|
|
80
|
+
const state = resolved.state;
|
|
81
|
+
await reconcileStateArtifacts(state);
|
|
63
82
|
const config = loadConfig();
|
|
64
83
|
const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
|
|
65
84
|
|
|
@@ -88,14 +107,14 @@ Examples:
|
|
|
88
107
|
for (const part of parts) {
|
|
89
108
|
const atIdx = part.indexOf("@");
|
|
90
109
|
if (atIdx !== -1) {
|
|
91
|
-
const route = part.slice(0, atIdx).trim()
|
|
92
|
-
const account = part.slice(atIdx + 1)
|
|
110
|
+
const route = parseRoutePrimary(part.slice(0, atIdx).trim());
|
|
111
|
+
const account = parseAccountName(part.slice(atIdx + 1), `account in target '${part}'`);
|
|
93
112
|
newTargets.push({ route, account });
|
|
94
113
|
} else {
|
|
95
114
|
// No @: use state.route.primary as route, part as account
|
|
96
115
|
newTargets.push({
|
|
97
116
|
route: state.route.primary,
|
|
98
|
-
account: part
|
|
117
|
+
account: parseAccountName(part, "target account"),
|
|
99
118
|
});
|
|
100
119
|
}
|
|
101
120
|
}
|
|
@@ -104,9 +123,40 @@ Examples:
|
|
|
104
123
|
if (newTargets.length === 0) {
|
|
105
124
|
throw new Error("No targets specified. Use --account or --targets.");
|
|
106
125
|
}
|
|
126
|
+
if (
|
|
127
|
+
state.intent.existing_draft_media_id &&
|
|
128
|
+
newTargets.some((target) =>
|
|
129
|
+
target.route.startsWith("wechat-") &&
|
|
130
|
+
(target.route !== state.route.primary || target.account !== state.route.account))
|
|
131
|
+
) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"existing_draft_media_id cannot be republished to a different WeChat target",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
107
136
|
|
|
108
|
-
|
|
109
|
-
|
|
137
|
+
const mergedTargets = dedupeTargets([
|
|
138
|
+
...getStatePublishTargets(state),
|
|
139
|
+
...newTargets,
|
|
140
|
+
]);
|
|
141
|
+
validatePublishTargetCompatibility(mergedTargets, state.intent.content_form);
|
|
142
|
+
const validationErrors = validateForPhase(
|
|
143
|
+
{
|
|
144
|
+
...state,
|
|
145
|
+
state_path: state.state_path || statePath,
|
|
146
|
+
publish_targets: mergedTargets,
|
|
147
|
+
},
|
|
148
|
+
"publish",
|
|
149
|
+
);
|
|
150
|
+
if (validationErrors.length > 0) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Republish validation failed: ${validationErrors.map((item) => `${item.field}: ${item.message}`).join("; ")}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!dryRun) {
|
|
157
|
+
state.publish_targets = mergedTargets;
|
|
158
|
+
await writeState(statePath, state);
|
|
159
|
+
}
|
|
110
160
|
|
|
111
161
|
// Execute (executePublishTargets handles dedupe and idempotent filter)
|
|
112
162
|
const { results, errors } = await executePublishTargets({
|
|
@@ -115,32 +165,25 @@ Examples:
|
|
|
115
165
|
dryRun,
|
|
116
166
|
config,
|
|
117
167
|
workspacePaths,
|
|
168
|
+
onResult: dryRun
|
|
169
|
+
? undefined
|
|
170
|
+
: async (result) => {
|
|
171
|
+
upsertPublishResult(state, result);
|
|
172
|
+
await writeState(statePath, state);
|
|
173
|
+
},
|
|
118
174
|
});
|
|
119
175
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const idx = state.publish.results.findIndex(
|
|
123
|
-
(r) => r.route === result.route && r.account === result.account,
|
|
124
|
-
);
|
|
125
|
-
if (idx >= 0) {
|
|
126
|
-
state.publish.results[idx] = result;
|
|
127
|
-
} else {
|
|
128
|
-
state.publish.results.push(result);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Mode stays done (or whatever it was)
|
|
133
|
-
state.updated_at = new Date().toISOString();
|
|
134
|
-
|
|
135
|
-
await writeState(statePath, state);
|
|
136
|
-
|
|
137
|
-
const output: any = {
|
|
138
|
-
publish_results: state.publish.results,
|
|
176
|
+
const output: Record<string, unknown> = {
|
|
177
|
+
publish_results: dryRun ? results : state.publish.results,
|
|
139
178
|
mode: state.mode,
|
|
140
179
|
new_targets: newTargets,
|
|
180
|
+
...(dryRun ? { dry_run: true } : {}),
|
|
141
181
|
};
|
|
142
182
|
if (errors.length > 0) {
|
|
143
183
|
output.errors = errors;
|
|
144
184
|
}
|
|
145
185
|
printResult(output);
|
|
186
|
+
} finally {
|
|
187
|
+
await releaseOperationLock();
|
|
188
|
+
}
|
|
146
189
|
}
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from "os";
|
|
|
5
5
|
|
|
6
6
|
import { reset } from "./reset";
|
|
7
7
|
import { defaultState, readState, writeState } from "../state";
|
|
8
|
+
import { getTaskByStatePath } from "../task-manager";
|
|
8
9
|
|
|
9
10
|
const TEST_CONFIG_PATH = join(tmpdir(), `zzhub-pipeline-test-config-reset-${process.pid}.json`);
|
|
10
11
|
process.env.ZZHUB_PIPELINE_CONFIG = TEST_CONFIG_PATH;
|
|
@@ -45,6 +46,8 @@ describe("reset", () => {
|
|
|
45
46
|
state.phase.publish = { status: "done", error: null };
|
|
46
47
|
state.phase.current = "done";
|
|
47
48
|
state.content_review = { status: "passed", feedback: null };
|
|
49
|
+
state.source_body_path = join(workspace, "source.md");
|
|
50
|
+
await writeFile(state.source_body_path, "Existing body", "utf-8");
|
|
48
51
|
await writeState(statePath, state);
|
|
49
52
|
|
|
50
53
|
const output = await captureJsonOutput<{ reset_mode: string; start_step: string | null }>(() =>
|
|
@@ -62,6 +65,7 @@ describe("reset", () => {
|
|
|
62
65
|
expect(updated.mode).toBe("active");
|
|
63
66
|
expect(updated.content_review.status).toBe("unchecked");
|
|
64
67
|
expect(updated.redo_hint).toBe("writer");
|
|
68
|
+
expect((await getTaskByStatePath(statePath)).next_action.action).toBe("revise-content");
|
|
65
69
|
});
|
|
66
70
|
|
|
67
71
|
test("mode=render keeps prepare=done but resets render and publish", async () => {
|
|
@@ -148,4 +152,43 @@ describe("reset", () => {
|
|
|
148
152
|
reset(["--state", statePath, "--mode", "bogus"]),
|
|
149
153
|
).rejects.toThrow("Invalid reset mode: bogus");
|
|
150
154
|
});
|
|
155
|
+
|
|
156
|
+
test("mode=publish clears current-version success so publish can run again", async () => {
|
|
157
|
+
const workspace = await makeTempDir("zzhub-reset-publish-");
|
|
158
|
+
const statePath = join(workspace, "state.json");
|
|
159
|
+
const state = defaultState();
|
|
160
|
+
state.workspace_root = workspace;
|
|
161
|
+
state.state_path = statePath;
|
|
162
|
+
state.run_id = "run-reset-publish";
|
|
163
|
+
state.artifacts.content_version = 3;
|
|
164
|
+
state.artifacts.render_version = 2;
|
|
165
|
+
state.publish.results = [
|
|
166
|
+
{
|
|
167
|
+
route: "wechat-article",
|
|
168
|
+
account: "default",
|
|
169
|
+
status: "success",
|
|
170
|
+
detail: null,
|
|
171
|
+
published_at: "2026-07-12T00:00:00.000Z",
|
|
172
|
+
content_version: 3,
|
|
173
|
+
render_version: 2,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
route: "blog",
|
|
177
|
+
account: "default",
|
|
178
|
+
status: "success",
|
|
179
|
+
detail: null,
|
|
180
|
+
published_at: "2026-07-11T00:00:00.000Z",
|
|
181
|
+
content_version: 2,
|
|
182
|
+
render_version: 2,
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
await writeState(statePath, state);
|
|
186
|
+
|
|
187
|
+
await captureJsonOutput(() => reset(["--state", statePath, "--mode", "publish"]));
|
|
188
|
+
|
|
189
|
+
const updated = await readState(statePath);
|
|
190
|
+
expect(updated.publish.results).toHaveLength(1);
|
|
191
|
+
expect(updated.publish.results[0]?.route).toBe("blog");
|
|
192
|
+
expect(updated.phase.current).toBe("publish");
|
|
193
|
+
});
|
|
151
194
|
});
|