@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.
@@ -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
- readState,
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 statePath = requireArg(parsed, "state", "state JSON path");
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 accountOverride = optionalArg(parsed, "account");
113
- const routeOverride = optionalArg(parsed, "route") as RoutePrimary | undefined;
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
- ? (extrasRaw.split(",").map((s) => s.trim()) as RoutePrimary[])
129
+ ? extrasRaw.split(",").map((value) => parseRoutePrimary(value.trim(), "extra route"))
126
130
  : undefined;
127
131
 
128
132
  // Read state and body
129
- const state = await readState(statePath);
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 = todayDate();
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 (route.primary === "wechat-article") {
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
- // ── Write state ──
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(join(resolveWorkspacePaths(state.workspace_root).tempRoot, state.run_id), { recursive: true });
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: {
@@ -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
- readState,
20
+ acquireStateOperationLock,
21
+ readResolvedState,
21
22
  validateForPhase,
22
23
  writeState,
23
24
  type PublishTarget,
24
25
  type RoutePrimary,
25
26
  } from "../state";
26
- import { executePublishTargets } from "../providers/publish-core";
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 statePath = requireArg(parsed, "state", "state JSON path");
44
- const routeOverride = optionalArg(parsed, "route") as RoutePrimary | undefined;
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 state = await readState(statePath);
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
- // Derive from route (backward compat)
69
- targets = [{ route: state.route.primary, account: state.route.account }];
70
- for (const extra of state.route.extras) {
71
- targets.push({ route: extra, account: state.route.account });
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
- // Upsert results into state
84
- for (const result of results) {
85
- const idx = state.publish.results.findIndex(
86
- (r) => r.route === result.route && r.account === result.account,
87
- );
88
- if (idx >= 0) {
89
- state.publish.results[idx] = result;
90
- } else {
91
- state.publish.results.push(result);
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 && (r.status === "success" || r.status === "skipped");
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 { readState, writeState } from "../state";
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 statePath = requireArg(parsed, "state", "state JSON path");
21
- const state = await readState(statePath);
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
 
@@ -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
- readState,
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: Awaited<ReturnType<typeof readState>>): NewspicRenderSpec {
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 statePath = requireArg(parsed, "state", "state JSON path");
63
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
61
64
  const skipRender = flagArg(parsed, "skip-render");
62
65
 
63
- const state = await readState(statePath);
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
- if (!state.asset_path) {
67
- throw new Error("asset_path not set. Run prepare-finalize first.");
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 = renderResult.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: renderResult.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
- readState,
21
+ acquireStateOperationLock,
22
+ readResolvedState,
23
+ validateForPhase,
22
24
  writeState,
23
25
  type PublishTarget,
24
- type RoutePrimary,
25
26
  } from "../state";
26
- import { executePublishTargets } from "../providers/publish-core";
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 statePath = requireArg(parsed, "state", "state JSON path");
58
- const accountArg = optionalArg(parsed, "account");
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 state = await readState(statePath);
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() as RoutePrimary;
92
- const account = part.slice(atIdx + 1).trim();
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.trim(),
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
- // Append to publish_targets (dedupe handled by executePublishTargets)
109
- state.publish_targets = [...state.publish_targets, ...newTargets];
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
- // Upsert results
121
- for (const result of results) {
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
  });