@sellable/mcp 0.1.511 → 0.1.512

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,49 @@
1
+ export declare const DRY_RUN_MARKER = "**DRY RUN \u2014 no mutations performed**";
2
+ export declare const JOURNAL_SECTION_HEADINGS: {
3
+ readonly bootstrap: "## Bootstrap";
4
+ readonly plan: "## Plan";
5
+ readonly terminal: "## Terminal";
6
+ };
7
+ export declare function resolveRefillRunsRoot(): string;
8
+ /**
9
+ * Throws on path-safety and permission failures. Callers must wrap the entire
10
+ * journal block and degrade to journalPath: null; journal failure must never
11
+ * abort a plan response.
12
+ */
13
+ export declare function createRunJournal(params: {
14
+ workspaceId: string;
15
+ runId?: string;
16
+ dryRun: boolean;
17
+ now?: Date;
18
+ }): {
19
+ filePath: string;
20
+ runId: string;
21
+ };
22
+ export declare function appendJournalEvent(filePath: string, block: string): void;
23
+ /**
24
+ * The index is a rebuildable cache. Directory listing is the source of truth;
25
+ * Phase 85 appends dry-run lines, Phase 86 maintains real-run entries.
26
+ */
27
+ export declare function appendIndexLine(entry: {
28
+ runId: string;
29
+ fileName: string;
30
+ workspaceId: string;
31
+ dryRun: boolean;
32
+ summary: string;
33
+ }): void;
34
+ export declare function renderBootstrapSection(params: {
35
+ summary: string;
36
+ senderSummary?: string;
37
+ laneSummary?: string;
38
+ targetSummary?: string;
39
+ creditSummary?: string;
40
+ }): string;
41
+ export declare function renderPlanSection(params: {
42
+ chosenSummary: string;
43
+ itinerarySummaries?: string[];
44
+ fallbackSummary?: string;
45
+ }): string;
46
+ export declare function renderTerminalSection(params: {
47
+ summary: string;
48
+ finalNumbers?: string;
49
+ }): string;
@@ -0,0 +1,134 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ const REFILL_RUNS_ROOT_ENV = "SELLABLE_REFILL_RUNS_DIR";
6
+ export const DRY_RUN_MARKER = "**DRY RUN — no mutations performed**";
7
+ export const JOURNAL_SECTION_HEADINGS = {
8
+ bootstrap: "## Bootstrap",
9
+ plan: "## Plan",
10
+ terminal: "## Terminal",
11
+ };
12
+ function expandLeadingTilde(value) {
13
+ if (value === "~")
14
+ return os.homedir();
15
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
16
+ return path.join(os.homedir(), value.slice(2));
17
+ }
18
+ return value;
19
+ }
20
+ function normalizeConfiguredRoot(value, label) {
21
+ const expanded = expandLeadingTilde(value);
22
+ if (!path.isAbsolute(expanded)) {
23
+ throw new Error(`${label} must be an absolute path.`);
24
+ }
25
+ return path.resolve(expanded);
26
+ }
27
+ export function resolveRefillRunsRoot() {
28
+ const envValue = process.env[REFILL_RUNS_ROOT_ENV]?.trim();
29
+ if (envValue) {
30
+ return normalizeConfiguredRoot(envValue, REFILL_RUNS_ROOT_ENV);
31
+ }
32
+ const home = os.homedir();
33
+ if (!home || !path.isAbsolute(home)) {
34
+ throw new Error(`Unable to resolve Sellable refill runs root: ${REFILL_RUNS_ROOT_ENV} is unset and home directory is unavailable.`);
35
+ }
36
+ return path.resolve(home, ".sellable/refill/runs");
37
+ }
38
+ function isPathInside(candidate, root) {
39
+ const relative = path.relative(root, candidate);
40
+ return (Boolean(relative) &&
41
+ !relative.startsWith("..") &&
42
+ !path.isAbsolute(relative));
43
+ }
44
+ function assertSafeRunId(runId) {
45
+ if (!/^[A-Za-z0-9_-]+$/.test(runId) ||
46
+ runId.includes("..") ||
47
+ runId.includes("/") ||
48
+ runId.includes("\\") ||
49
+ runId.includes("\0")) {
50
+ throw new Error("runId must be a safe filename segment.");
51
+ }
52
+ }
53
+ function utcBasicTimestamp(now) {
54
+ return `${now.toISOString().slice(0, 19).replace(/[-:]/g, "")}Z`;
55
+ }
56
+ function normalizeBlock(block) {
57
+ return block.endsWith("\n") ? block : `${block}\n`;
58
+ }
59
+ /**
60
+ * Throws on path-safety and permission failures. Callers must wrap the entire
61
+ * journal block and degrade to journalPath: null; journal failure must never
62
+ * abort a plan response.
63
+ */
64
+ export function createRunJournal(params) {
65
+ const runId = params.runId ?? randomBytes(4).toString("hex");
66
+ assertSafeRunId(runId);
67
+ const root = resolveRefillRunsRoot();
68
+ fs.mkdirSync(root, { recursive: true });
69
+ const normalizedRoot = path.resolve(root);
70
+ const fileName = `${utcBasicTimestamp(params.now ?? new Date())}-${runId}${params.dryRun ? "-dry" : ""}.md`;
71
+ const filePath = path.resolve(normalizedRoot, fileName);
72
+ if (!isPathInside(filePath, normalizedRoot)) {
73
+ throw new Error("Resolved journal path escapes refill runs root.");
74
+ }
75
+ const startedAt = (params.now ?? new Date()).toISOString();
76
+ const header = [
77
+ `# Refill Sends Evergreen Run ${runId}`,
78
+ "",
79
+ `- Run ID: ${runId}`,
80
+ `- Workspace ID: ${params.workspaceId}`,
81
+ `- Started at: ${startedAt}`,
82
+ params.dryRun ? `- ${DRY_RUN_MARKER}` : null,
83
+ "",
84
+ ]
85
+ .filter((line) => line !== null)
86
+ .join("\n");
87
+ fs.appendFileSync(filePath, normalizeBlock(header), "utf8");
88
+ return { filePath, runId };
89
+ }
90
+ export function appendJournalEvent(filePath, block) {
91
+ try {
92
+ fs.appendFileSync(filePath, normalizeBlock(block), "utf8");
93
+ }
94
+ catch {
95
+ // Best effort by design.
96
+ }
97
+ }
98
+ /**
99
+ * The index is a rebuildable cache. Directory listing is the source of truth;
100
+ * Phase 85 appends dry-run lines, Phase 86 maintains real-run entries.
101
+ */
102
+ export function appendIndexLine(entry) {
103
+ try {
104
+ const root = resolveRefillRunsRoot();
105
+ fs.mkdirSync(root, { recursive: true });
106
+ const marker = entry.dryRun ? " (dry)" : "";
107
+ fs.appendFileSync(path.join(root, "index.md"), `- ${entry.runId}${marker} [${entry.workspaceId}] ${entry.fileName} — ${entry.summary}\n`, "utf8");
108
+ }
109
+ catch {
110
+ // Best effort by design.
111
+ }
112
+ }
113
+ function renderLines(heading, lines) {
114
+ return normalizeBlock([heading, "", ...lines, ""].join("\n"));
115
+ }
116
+ export function renderBootstrapSection(params) {
117
+ return renderLines(JOURNAL_SECTION_HEADINGS.bootstrap, [
118
+ params.summary,
119
+ params.senderSummary,
120
+ params.laneSummary,
121
+ params.targetSummary,
122
+ params.creditSummary,
123
+ ].filter((line) => Boolean(line)));
124
+ }
125
+ export function renderPlanSection(params) {
126
+ return renderLines(JOURNAL_SECTION_HEADINGS.plan, [
127
+ `Chosen: ${params.chosenSummary}`,
128
+ ...(params.itinerarySummaries ?? []).map((summary) => `Next: ${summary}`),
129
+ params.fallbackSummary ? `Fallback: ${params.fallbackSummary}` : null,
130
+ ].filter((line) => Boolean(line)));
131
+ }
132
+ export function renderTerminalSection(params) {
133
+ return renderLines(JOURNAL_SECTION_HEADINGS.terminal, [params.summary, params.finalNumbers].filter((line) => Boolean(line)));
134
+ }
@@ -0,0 +1,31 @@
1
+ export interface RefillLaneMemoryEntry {
2
+ senderId: string;
3
+ laneKey: string;
4
+ outcome: string;
5
+ cooldownUntil: string;
6
+ reason: string;
7
+ }
8
+ export interface RefillPassRateObservation {
9
+ campaignId: string;
10
+ enrichedCount: number;
11
+ passedCount: number;
12
+ observedAt: string;
13
+ }
14
+ export interface RefillRunSummaryEntry {
15
+ runId: string;
16
+ endedAt: string;
17
+ terminalState: string;
18
+ sendsLoaded: number;
19
+ lanesTouched: string[];
20
+ }
21
+ export interface RefillWorkspaceState {
22
+ laneMemory: RefillLaneMemoryEntry[];
23
+ passRates: RefillPassRateObservation[];
24
+ recentRuns: RefillRunSummaryEntry[];
25
+ }
26
+ /**
27
+ * Advisory hints only. A missing, stale, corrupt, or malformed file means no
28
+ * hints; live planner reads remain the source of correctness. Phase 85 reads
29
+ * this section only. Phase 86 adds the writer plus writeJsonAtomic.
30
+ */
31
+ export declare function readRefillWorkspaceState(workspaceId: string): RefillWorkspaceState | null;
@@ -0,0 +1,96 @@
1
+ import { getConfig } from "./auth.js";
2
+ function isRecord(value) {
3
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ function isStringArray(value) {
6
+ return (Array.isArray(value) && value.every((entry) => typeof entry === "string"));
7
+ }
8
+ function readString(record, key) {
9
+ const value = record[key];
10
+ return typeof value === "string" ? value : null;
11
+ }
12
+ function readNumber(record, key) {
13
+ const value = record[key];
14
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
15
+ }
16
+ function parseLaneMemoryEntry(value) {
17
+ if (!isRecord(value))
18
+ return null;
19
+ const senderId = readString(value, "senderId");
20
+ const laneKey = readString(value, "laneKey");
21
+ const outcome = readString(value, "outcome");
22
+ const cooldownUntil = readString(value, "cooldownUntil");
23
+ const reason = readString(value, "reason");
24
+ if (!senderId || !laneKey || !outcome || !cooldownUntil || !reason) {
25
+ return null;
26
+ }
27
+ return { senderId, laneKey, outcome, cooldownUntil, reason };
28
+ }
29
+ function parsePassRateObservation(value) {
30
+ if (!isRecord(value))
31
+ return null;
32
+ const campaignId = readString(value, "campaignId");
33
+ const enrichedCount = readNumber(value, "enrichedCount");
34
+ const passedCount = readNumber(value, "passedCount");
35
+ const observedAt = readString(value, "observedAt");
36
+ if (!campaignId ||
37
+ enrichedCount === null ||
38
+ passedCount === null ||
39
+ !observedAt) {
40
+ return null;
41
+ }
42
+ return { campaignId, enrichedCount, passedCount, observedAt };
43
+ }
44
+ function parseRunSummaryEntry(value) {
45
+ if (!isRecord(value))
46
+ return null;
47
+ const runId = readString(value, "runId");
48
+ const endedAt = readString(value, "endedAt");
49
+ const terminalState = readString(value, "terminalState");
50
+ const sendsLoaded = readNumber(value, "sendsLoaded");
51
+ const lanesTouched = value.lanesTouched;
52
+ if (!runId ||
53
+ !endedAt ||
54
+ !terminalState ||
55
+ sendsLoaded === null ||
56
+ !isStringArray(lanesTouched)) {
57
+ return null;
58
+ }
59
+ return { runId, endedAt, terminalState, sendsLoaded, lanesTouched };
60
+ }
61
+ function parseArray(value, parser) {
62
+ if (!Array.isArray(value))
63
+ return [];
64
+ return value.flatMap((entry) => {
65
+ const parsed = parser(entry);
66
+ return parsed ? [parsed] : [];
67
+ });
68
+ }
69
+ /**
70
+ * Advisory hints only. A missing, stale, corrupt, or malformed file means no
71
+ * hints; live planner reads remain the source of correctness. Phase 85 reads
72
+ * this section only. Phase 86 adds the writer plus writeJsonAtomic.
73
+ */
74
+ export function readRefillWorkspaceState(workspaceId) {
75
+ try {
76
+ const config = getConfig();
77
+ const refill = config.skillState
78
+ ?.refill;
79
+ if (!isRecord(refill) || refill.version !== 1)
80
+ return null;
81
+ const byWorkspace = refill.byWorkspace;
82
+ if (!isRecord(byWorkspace))
83
+ return null;
84
+ const workspaceState = byWorkspace[workspaceId];
85
+ if (!isRecord(workspaceState))
86
+ return null;
87
+ return {
88
+ laneMemory: parseArray(workspaceState.laneMemory, parseLaneMemoryEntry),
89
+ passRates: parseArray(workspaceState.passRates, parsePassRateObservation),
90
+ recentRuns: parseArray(workspaceState.recentRuns, parseRunSummaryEntry),
91
+ };
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
package/dist/server.js CHANGED
@@ -10,7 +10,9 @@ import { resolveCampaignFillRoute } from "./tools/campaign-fill-routing.js";
10
10
  import { fillCampaignHorizon } from "./tools/campaign-horizon-fill.js";
11
11
  import { cancelPrepareCampaignMessages, getPrepareCampaignMessagesStatus, startPrepareCampaignMessages, } from "./tools/campaign-message-preparation.js";
12
12
  import { getCampaignRefillState } from "./tools/campaign-refill-state.js";
13
+ import { getEvergreenRefillPlan } from "./tools/evergreen-refill-plan.js";
13
14
  import { getRefillTargetPlan } from "./tools/refill-target-plan.js";
15
+ import { refillSendsEvergreenCommand } from "./tools/refill-sends-evergreen.js";
14
16
  import { getSchedulerFillCapacity } from "./tools/scheduler-fill-capacity.js";
15
17
  import { getCampaignTableSchema, queueCampaignCells, recordCampaignReviewBatch, reviseMessageTemplateAndRerun, selectCampaignCells, waitForCampaignProcessing, } from "./tools/campaign-processing.js";
16
18
  import { archiveCampaign, createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
@@ -223,6 +225,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
223
225
  case "get_campaign_refill_state":
224
226
  result = await getCampaignRefillState(args);
225
227
  break;
228
+ case "get_evergreen_refill_plan":
229
+ result = await getEvergreenRefillPlan(args);
230
+ break;
226
231
  case "get_refill_target_plan":
227
232
  result = await getRefillTargetPlan(args);
228
233
  break;
@@ -232,6 +237,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
232
237
  case "refill_sends":
233
238
  result = await executeRefillSendsCommand(args);
234
239
  break;
240
+ case "refill_sends_evergreen":
241
+ result = refillSendsEvergreenCommand(args);
242
+ break;
235
243
  case "setup_evergreen_campaigns":
236
244
  result = await setupEvergreenCampaigns(args);
237
245
  markEvergreenSetupCampaignsDirty(args);
@@ -0,0 +1,49 @@
1
+ type GetEvergreenRefillPlanInput = {
2
+ workspaceId?: string;
3
+ senderIds?: string[];
4
+ runState?: Record<string, unknown>;
5
+ journal?: boolean;
6
+ journalNote?: string;
7
+ };
8
+ export declare function sanitizeEvergreenRefillPlanResult(value: unknown): Record<string, unknown>;
9
+ export declare const evergreenRefillPlanToolDefinitions: {
10
+ name: string;
11
+ description: string;
12
+ inputSchema: {
13
+ type: string;
14
+ properties: {
15
+ workspaceId: {
16
+ type: string;
17
+ description: string;
18
+ };
19
+ senderIds: {
20
+ type: string;
21
+ items: {
22
+ type: string;
23
+ };
24
+ };
25
+ runState: {
26
+ type: string;
27
+ description: string;
28
+ };
29
+ journal: {
30
+ type: string;
31
+ description: string;
32
+ };
33
+ journalNote: {
34
+ type: string;
35
+ description: string;
36
+ };
37
+ };
38
+ required: string[];
39
+ additionalProperties: boolean;
40
+ };
41
+ }[];
42
+ export declare function getEvergreenRefillPlan(input: GetEvergreenRefillPlanInput): Promise<{
43
+ warnings: any[];
44
+ journalPath: string | null;
45
+ text: string;
46
+ workspaceId: string;
47
+ workspaceResolution: string;
48
+ }>;
49
+ export {};
@@ -0,0 +1,196 @@
1
+ import * as path from "node:path";
2
+ import { getApi } from "../api.js";
3
+ import { appendIndexLine, appendJournalEvent, createRunJournal, renderBootstrapSection, renderPlanSection, renderTerminalSection, } from "../refill-journal.js";
4
+ import { readRefillWorkspaceState } from "../refill-local-state.js";
5
+ import { normalizeExplicitWorkspaceId, workspaceRequestOptions, } from "./workspace-context.js";
6
+ const KNOWN_TOP_LEVEL_FIELDS = [
7
+ "readOnly",
8
+ "generatedAt",
9
+ "bootstrap",
10
+ "plans",
11
+ "globalActionQueue",
12
+ "evergreen",
13
+ "planRevision",
14
+ "stateRevision",
15
+ "packet",
16
+ "sideEffects",
17
+ "warnings",
18
+ ];
19
+ // MCP mirror maintenance note:
20
+ // Evergreen rung vocabulary restatements track canonical EVERGREEN_RUNG_ORDER in
21
+ // src/lib/workflow-tables/refill-target-plan.ts. pr1 token checks track
22
+ // PR1_TOKEN_REGEX in src/lib/workflow-tables/plan-revision.ts.
23
+ function isRecord(value) {
24
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+ function deepClone(value) {
27
+ return JSON.parse(JSON.stringify(value));
28
+ }
29
+ export function sanitizeEvergreenRefillPlanResult(value) {
30
+ if (!isRecord(value))
31
+ return {};
32
+ const sanitized = {};
33
+ for (const key of KNOWN_TOP_LEVEL_FIELDS) {
34
+ if (value[key] !== undefined)
35
+ sanitized[key] = deepClone(value[key]);
36
+ }
37
+ return sanitized;
38
+ }
39
+ function buildRunStateFromLocalHints(workspaceId) {
40
+ const state = readRefillWorkspaceState(workspaceId);
41
+ return {
42
+ version: 1,
43
+ senderCursors: [],
44
+ laneCooldowns: state?.laneMemory ?? [],
45
+ passRates: state?.passRates ?? [],
46
+ };
47
+ }
48
+ async function postEvergreenRefillPlan(body, workspaceId) {
49
+ const api = getApi();
50
+ const requestOptions = workspaceRequestOptions(workspaceId);
51
+ return requestOptions
52
+ ? api.post("/api/v3/mcp/evergreen-refill-plan", body, requestOptions)
53
+ : api.post("/api/v3/mcp/evergreen-refill-plan", body);
54
+ }
55
+ function planSummaryLines(result) {
56
+ const plans = Array.isArray(result.plans) ? result.plans : [];
57
+ return plans.flatMap((plan) => {
58
+ if (!isRecord(plan))
59
+ return [];
60
+ const itinerary = isRecord(plan.itinerary) ? plan.itinerary : null;
61
+ const chosen = isRecord(itinerary?.chosen) ? itinerary?.chosen : null;
62
+ const fallback = isRecord(itinerary?.fallback) ? itinerary?.fallback : null;
63
+ return [
64
+ `${String(plan.senderId ?? "unknown")}: ${String(chosen?.summary ?? "no chosen rung")}`,
65
+ fallback ? `fallback: ${String(fallback.trigger ?? "")}` : null,
66
+ ].filter((line) => Boolean(line));
67
+ });
68
+ }
69
+ function writeDryRunJournal(params) {
70
+ const created = createRunJournal({
71
+ workspaceId: params.workspaceId,
72
+ dryRun: true,
73
+ });
74
+ const bootstrap = isRecord(params.result.bootstrap)
75
+ ? params.result.bootstrap
76
+ : {};
77
+ appendJournalEvent(created.filePath, renderBootstrapSection({
78
+ summary: "Evergreen refill dry-run bootstrap",
79
+ senderSummary: JSON.stringify(bootstrap.senders ?? []),
80
+ laneSummary: JSON.stringify(bootstrap.laneOrder ?? []),
81
+ targetSummary: JSON.stringify(bootstrap.target ?? []),
82
+ creditSummary: JSON.stringify(bootstrap.paidCredit ?? []),
83
+ }));
84
+ const plans = Array.isArray(params.result.plans) ? params.result.plans : [];
85
+ for (const plan of plans) {
86
+ if (!isRecord(plan))
87
+ continue;
88
+ const itinerary = isRecord(plan.itinerary) ? plan.itinerary : {};
89
+ const chosen = isRecord(itinerary.chosen) ? itinerary.chosen : {};
90
+ const projected = Array.isArray(itinerary.projected)
91
+ ? itinerary.projected
92
+ : [];
93
+ const fallback = isRecord(itinerary.fallback) ? itinerary.fallback : {};
94
+ appendJournalEvent(created.filePath, renderPlanSection({
95
+ chosenSummary: String(chosen.summary ?? "No chosen rung"),
96
+ itinerarySummaries: projected
97
+ .filter(isRecord)
98
+ .map((entry) => String(entry.summary ?? "")),
99
+ fallbackSummary: String(fallback.trigger ?? ""),
100
+ }));
101
+ }
102
+ appendJournalEvent(created.filePath, renderTerminalSection({
103
+ summary: [
104
+ `planRevision=${String(params.result.planRevision ?? "")}`,
105
+ `stateRevision=${String(params.result.stateRevision ?? "")}`,
106
+ params.journalNote ?? null,
107
+ ]
108
+ .filter(Boolean)
109
+ .join(" "),
110
+ }));
111
+ appendIndexLine({
112
+ runId: created.runId,
113
+ fileName: path.basename(created.filePath),
114
+ workspaceId: params.workspaceId,
115
+ dryRun: true,
116
+ summary: "evergreen refill dry-run",
117
+ });
118
+ return created.filePath;
119
+ }
120
+ export const evergreenRefillPlanToolDefinitions = [
121
+ {
122
+ name: "get_evergreen_refill_plan",
123
+ description: "Read-only evergreen refill dry-run planner. It performs no mutations, does not schedule, send, approve, prepare, or refresh credits, and writes a local dry-run journal file unless journal:false is passed.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ workspaceId: {
128
+ type: "string",
129
+ description: "Explicit request-scoped workspace id.",
130
+ },
131
+ senderIds: {
132
+ type: "array",
133
+ items: { type: "string" },
134
+ },
135
+ runState: {
136
+ type: "object",
137
+ description: "Optional synthetic v1 run state. When provided, replaces local lane-memory hints.",
138
+ },
139
+ journal: {
140
+ type: "boolean",
141
+ description: "Set false to skip the local dry-run journal write.",
142
+ },
143
+ journalNote: {
144
+ type: "string",
145
+ description: "Optional note appended to the terminal journal section.",
146
+ },
147
+ },
148
+ required: ["workspaceId"],
149
+ additionalProperties: false,
150
+ },
151
+ },
152
+ ];
153
+ export async function getEvergreenRefillPlan(input) {
154
+ const workspaceId = normalizeExplicitWorkspaceId(input.workspaceId);
155
+ if (!workspaceId) {
156
+ throw new Error("workspaceId is required for get_evergreen_refill_plan.");
157
+ }
158
+ const runState = input.runState !== undefined
159
+ ? input.runState
160
+ : buildRunStateFromLocalHints(workspaceId);
161
+ const raw = await postEvergreenRefillPlan({
162
+ workspaceId,
163
+ senderIds: input.senderIds,
164
+ runState,
165
+ }, workspaceId);
166
+ const sanitized = sanitizeEvergreenRefillPlanResult(raw);
167
+ const warnings = Array.isArray(sanitized.warnings)
168
+ ? [...sanitized.warnings]
169
+ : [];
170
+ let journalPath = null;
171
+ if (input.journal !== false) {
172
+ try {
173
+ journalPath = writeDryRunJournal({
174
+ workspaceId,
175
+ result: sanitized,
176
+ journalNote: input.journalNote,
177
+ });
178
+ }
179
+ catch {
180
+ warnings.push("journalWriteFailed");
181
+ }
182
+ }
183
+ const summary = [
184
+ "Evergreen refill dry-run plan:",
185
+ ...planSummaryLines(sanitized),
186
+ journalPath ? `Journal: ${journalPath}` : "Journal: not written",
187
+ ].join("\n");
188
+ return {
189
+ ...sanitized,
190
+ warnings,
191
+ journalPath,
192
+ text: summary,
193
+ workspaceId,
194
+ workspaceResolution: "explicit",
195
+ };
196
+ }