@deskwork/core 0.9.5 → 0.9.7

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Rule: workflow-stale.
3
+ *
4
+ * Audit: a `DraftWorkflowItem` whose `(site, slug)` no longer resolves
5
+ * to a calendar entry on the workflow's site. Two failure modes:
6
+ * - The entry was deleted from the calendar (truly stale).
7
+ * - The entry's slug was renamed and the workflow predates the rename.
8
+ *
9
+ * Detecting the slug-rename case requires `entryId` on the workflow —
10
+ * not yet present on legacy records. For now the rule reports only
11
+ * "no entry found by site+slug" findings and ignores the rename case.
12
+ *
13
+ * Repair: clear the stale workflow record from the pipeline journal.
14
+ * The history journal is append-only and stays untouched (provenance).
15
+ * `--yes` applies; interactive prompts before deletion.
16
+ */
17
+
18
+ import { unlinkSync, readdirSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { pipelinePath } from '../../review/pipeline.ts';
21
+ import type { DraftWorkflowItem } from '../../review/types.ts';
22
+ import type {
23
+ DoctorContext,
24
+ DoctorRule,
25
+ Finding,
26
+ RepairPlan,
27
+ RepairResult,
28
+ } from '../types.ts';
29
+
30
+ const RULE_ID = 'workflow-stale';
31
+
32
+ function isStale(
33
+ workflow: DraftWorkflowItem,
34
+ ctx: DoctorContext,
35
+ ): boolean {
36
+ if (workflow.site !== ctx.site) return false;
37
+ if (workflow.state === 'applied' || workflow.state === 'cancelled') {
38
+ return false;
39
+ }
40
+ // Prefer entryId match when both sides have it. If the workflow has
41
+ // an entryId and the calendar doesn't carry that id, the workflow is
42
+ // stale regardless of slug. If it doesn't, fall back to slug match.
43
+ if (workflow.entryId) {
44
+ return !ctx.calendar.entries.some((e) => e.id === workflow.entryId);
45
+ }
46
+ return !ctx.calendar.entries.some((e) => e.slug === workflow.slug);
47
+ }
48
+
49
+ /**
50
+ * Find the pipeline-journal file backing a workflow id. Files are
51
+ * named `<normalizedTimestamp>-<id>.json`; we suffix-match on
52
+ * `-<id>.json`.
53
+ */
54
+ function findWorkflowFile(
55
+ projectRoot: string,
56
+ config: DoctorContext['config'],
57
+ workflowId: string,
58
+ ): string | null {
59
+ const dir = pipelinePath(projectRoot, config);
60
+ let names: string[];
61
+ try {
62
+ names = readdirSync(dir);
63
+ } catch {
64
+ return null;
65
+ }
66
+ const suffix = `-${workflowId}.json`;
67
+ for (const name of names) {
68
+ if (name.endsWith(suffix)) return join(dir, name);
69
+ }
70
+ return null;
71
+ }
72
+
73
+ const rule: DoctorRule = {
74
+ id: RULE_ID,
75
+ label: 'Workflow records that no longer match a calendar entry',
76
+
77
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
78
+ const findings: Finding[] = [];
79
+ for (const w of ctx.workflows) {
80
+ if (!isStale(w, ctx)) continue;
81
+ findings.push({
82
+ ruleId: RULE_ID,
83
+ site: ctx.site,
84
+ severity: 'warning',
85
+ message: `Workflow ${w.id} (slug "${w.slug}", state ${w.state}) has no matching calendar entry`,
86
+ details: {
87
+ workflowId: w.id,
88
+ slug: w.slug,
89
+ state: w.state,
90
+ entryId: w.entryId ?? null,
91
+ },
92
+ });
93
+ }
94
+ return findings;
95
+ },
96
+
97
+ async plan(_ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
98
+ const workflowId = String(finding.details.workflowId ?? '');
99
+ return {
100
+ kind: 'apply',
101
+ finding,
102
+ summary: `delete pipeline journal entry for workflow ${workflowId} (history journal preserved)`,
103
+ payload: { workflowId },
104
+ };
105
+ },
106
+
107
+ async apply(ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
108
+ if (plan.kind !== 'apply') {
109
+ return {
110
+ finding: plan.finding,
111
+ applied: false,
112
+ message: 'plan is not directly appliable; runner should resolve prompt first',
113
+ skipReason: 'apply-failed',
114
+ };
115
+ }
116
+ const workflowId = String(plan.payload.workflowId ?? '');
117
+ if (!workflowId) {
118
+ return {
119
+ finding: plan.finding,
120
+ applied: false,
121
+ message: 'apply payload missing workflowId',
122
+ skipReason: 'apply-failed',
123
+ };
124
+ }
125
+ const file = findWorkflowFile(ctx.projectRoot, ctx.config, workflowId);
126
+ if (!file) {
127
+ return {
128
+ finding: plan.finding,
129
+ applied: false,
130
+ message: `no pipeline file found for workflow ${workflowId}`,
131
+ skipReason: 'apply-failed',
132
+ };
133
+ }
134
+ try {
135
+ unlinkSync(file);
136
+ } catch (err) {
137
+ const reason = err instanceof Error ? err.message : String(err);
138
+ return {
139
+ finding: plan.finding,
140
+ applied: false,
141
+ message: `failed to delete ${file}: ${reason}`,
142
+ skipReason: 'apply-failed',
143
+ };
144
+ }
145
+ return {
146
+ finding: plan.finding,
147
+ applied: true,
148
+ message: `deleted pipeline entry for workflow ${workflowId}`,
149
+ details: { file, workflowId },
150
+ };
151
+ },
152
+ };
153
+
154
+ export default rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deskwork/core",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "type": "module",
5
5
  "description": "Editorial calendar + review pipeline library — shared by @deskwork/cli and @deskwork/studio",
6
6
  "homepage": "https://github.com/audiocontrol-org/deskwork#readme",
@@ -138,8 +138,8 @@
138
138
  }
139
139
  },
140
140
  "scripts": {
141
- "build": "tsc -b tsconfig.build.json && cp src/*.mjs dist/",
142
- "prepack": "tsc -b tsconfig.build.json && cp src/*.mjs dist/",
141
+ "build": "tsc -b tsconfig.build.json && cp src/*.mjs dist/ && mkdir -p dist/doctor/rules && cp src/doctor/rules/*.ts dist/doctor/rules/",
142
+ "prepack": "tsc -b tsconfig.build.json && cp src/*.mjs dist/ && mkdir -p dist/doctor/rules && cp src/doctor/rules/*.ts dist/doctor/rules/",
143
143
  "test": "vitest run",
144
144
  "test:watch": "vitest",
145
145
  "typecheck": "tsc --noEmit"