@calltelemetry/openclaw-linear 0.6.0 → 0.7.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/README.md +115 -17
- package/index.ts +18 -22
- package/openclaw.plugin.json +35 -2
- package/package.json +1 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.ts +180 -9
- package/src/infra/cli.ts +214 -0
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +759 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +114 -35
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +282 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +216 -0
- package/src/pipeline/webhook.ts +69 -17
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planner.ts — Orchestration for the project planning pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Manages the interview-style planning flow:
|
|
5
|
+
* - initiatePlanningSession: enters planning mode for a project
|
|
6
|
+
* - handlePlannerTurn: processes each user comment during planning
|
|
7
|
+
* - runPlanAudit: validates the plan before finalizing
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
14
|
+
import type { LinearAgentApi } from "../api/linear-api.js";
|
|
15
|
+
import { runAgent } from "../agent/agent.js";
|
|
16
|
+
import {
|
|
17
|
+
type PlanningSession,
|
|
18
|
+
registerPlanningSession,
|
|
19
|
+
updatePlanningSession,
|
|
20
|
+
endPlanningSession,
|
|
21
|
+
setPlanningCache,
|
|
22
|
+
} from "./planning-state.js";
|
|
23
|
+
import {
|
|
24
|
+
setActivePlannerContext,
|
|
25
|
+
clearActivePlannerContext,
|
|
26
|
+
buildPlanSnapshot,
|
|
27
|
+
auditPlan,
|
|
28
|
+
} from "../tools/planner-tools.js";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Types
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export interface PlannerContext {
|
|
35
|
+
api: OpenClawPluginApi;
|
|
36
|
+
linearApi: LinearAgentApi;
|
|
37
|
+
pluginConfig?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PlannerPrompts {
|
|
41
|
+
system: string;
|
|
42
|
+
interview: string;
|
|
43
|
+
audit_prompt: string;
|
|
44
|
+
welcome: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Prompt loading
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerPrompts {
|
|
52
|
+
const defaults: PlannerPrompts = {
|
|
53
|
+
system: "You are a product planning specialist. Interview the user about features and create Linear issues.",
|
|
54
|
+
interview: "Project: {{projectName}}\n\nPlan:\n{{planSnapshot}}\n\nUser said: {{userMessage}}\n\nContinue planning.",
|
|
55
|
+
audit_prompt: "Run audit_plan for {{projectName}}.",
|
|
56
|
+
welcome: "Entering planning mode for **{{projectName}}**. What are the main feature areas?",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
61
|
+
let raw: string;
|
|
62
|
+
|
|
63
|
+
if (customPath) {
|
|
64
|
+
const resolved = customPath.startsWith("~")
|
|
65
|
+
? customPath.replace("~", process.env.HOME ?? "")
|
|
66
|
+
: customPath;
|
|
67
|
+
raw = readFileSync(resolved, "utf-8");
|
|
68
|
+
} else {
|
|
69
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
70
|
+
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parsed = parseYaml(raw) as any;
|
|
74
|
+
if (parsed?.planner) {
|
|
75
|
+
return {
|
|
76
|
+
system: parsed.planner.system ?? defaults.system,
|
|
77
|
+
interview: parsed.planner.interview ?? defaults.interview,
|
|
78
|
+
audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
|
|
79
|
+
welcome: parsed.planner.welcome ?? defaults.welcome,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
} catch { /* use defaults */ }
|
|
83
|
+
|
|
84
|
+
return defaults;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
88
|
+
let result = template;
|
|
89
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
90
|
+
result = result.replaceAll(`{{${key}}}`, value);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Planning initiation
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export async function initiatePlanningSession(
|
|
100
|
+
ctx: PlannerContext,
|
|
101
|
+
projectId: string,
|
|
102
|
+
rootIssue: { id: string; identifier: string; title: string; team?: { id: string } },
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const { api, linearApi, pluginConfig } = ctx;
|
|
105
|
+
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
106
|
+
|
|
107
|
+
// Fetch project metadata
|
|
108
|
+
const project = await linearApi.getProject(projectId);
|
|
109
|
+
const teamId = rootIssue.team?.id ?? project.teams?.nodes?.[0]?.id;
|
|
110
|
+
if (!teamId) throw new Error(`Cannot determine team for project ${projectId}`);
|
|
111
|
+
|
|
112
|
+
// Fetch team states for reference
|
|
113
|
+
await linearApi.getTeamStates(teamId);
|
|
114
|
+
|
|
115
|
+
// Register session
|
|
116
|
+
const session: PlanningSession = {
|
|
117
|
+
projectId,
|
|
118
|
+
projectName: project.name,
|
|
119
|
+
rootIssueId: rootIssue.id,
|
|
120
|
+
rootIdentifier: rootIssue.identifier,
|
|
121
|
+
teamId,
|
|
122
|
+
status: "interviewing",
|
|
123
|
+
startedAt: new Date().toISOString(),
|
|
124
|
+
turnCount: 0,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await registerPlanningSession(projectId, session, configPath);
|
|
128
|
+
setPlanningCache(session);
|
|
129
|
+
|
|
130
|
+
api.logger.info(`Planning: initiated session for ${project.name} (${rootIssue.identifier})`);
|
|
131
|
+
|
|
132
|
+
// Post welcome comment
|
|
133
|
+
const prompts = loadPlannerPrompts(pluginConfig);
|
|
134
|
+
const welcomeMsg = renderTemplate(prompts.welcome, {
|
|
135
|
+
projectName: project.name,
|
|
136
|
+
rootIdentifier: rootIssue.identifier,
|
|
137
|
+
});
|
|
138
|
+
await linearApi.createComment(rootIssue.id, welcomeMsg);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Interview turn
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const FINALIZE_PATTERN = /\b(finalize\s+plan|finalize|done\s+planning|approve\s+plan|plan\s+looks\s+good)\b/i;
|
|
146
|
+
const ABANDON_PATTERN = /\b(abandon|cancel\s+planning|stop\s+planning|exit\s+planning)\b/i;
|
|
147
|
+
|
|
148
|
+
export async function handlePlannerTurn(
|
|
149
|
+
ctx: PlannerContext,
|
|
150
|
+
session: PlanningSession,
|
|
151
|
+
input: { issueId: string; commentBody: string; commentorName: string },
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const { api, linearApi, pluginConfig } = ctx;
|
|
154
|
+
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
155
|
+
|
|
156
|
+
// Detect finalization intent
|
|
157
|
+
if (FINALIZE_PATTERN.test(input.commentBody)) {
|
|
158
|
+
await runPlanAudit(ctx, session);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Detect abandon intent
|
|
163
|
+
if (ABANDON_PATTERN.test(input.commentBody)) {
|
|
164
|
+
await endPlanningSession(session.projectId, "abandoned", configPath);
|
|
165
|
+
await linearApi.createComment(
|
|
166
|
+
session.rootIssueId,
|
|
167
|
+
`Planning mode ended for **${session.projectName}**. Session abandoned.`,
|
|
168
|
+
);
|
|
169
|
+
api.logger.info(`Planning: session abandoned for ${session.projectName}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Increment turn count
|
|
174
|
+
const newTurnCount = session.turnCount + 1;
|
|
175
|
+
await updatePlanningSession(session.projectId, { turnCount: newTurnCount }, configPath);
|
|
176
|
+
|
|
177
|
+
// Build plan snapshot
|
|
178
|
+
const issues = await linearApi.getProjectIssues(session.projectId);
|
|
179
|
+
const planSnapshot = buildPlanSnapshot(issues);
|
|
180
|
+
|
|
181
|
+
// Build comment history (last 10 comments on root issue)
|
|
182
|
+
let commentHistory = "";
|
|
183
|
+
try {
|
|
184
|
+
const details = await linearApi.getIssueDetails(session.rootIssueId);
|
|
185
|
+
commentHistory = details.comments?.nodes
|
|
186
|
+
?.map((c) => `**${c.user?.name ?? "Unknown"}:** ${c.body.slice(0, 300)}`)
|
|
187
|
+
.join("\n\n") ?? "";
|
|
188
|
+
} catch { /* best effort */ }
|
|
189
|
+
|
|
190
|
+
// Build prompt
|
|
191
|
+
const prompts = loadPlannerPrompts(pluginConfig);
|
|
192
|
+
const taskPrompt = renderTemplate(prompts.interview, {
|
|
193
|
+
projectName: session.projectName,
|
|
194
|
+
rootIdentifier: session.rootIdentifier,
|
|
195
|
+
planSnapshot,
|
|
196
|
+
turnCount: String(newTurnCount),
|
|
197
|
+
commentHistory,
|
|
198
|
+
userMessage: input.commentBody,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Set planner context for tools
|
|
202
|
+
setActivePlannerContext({
|
|
203
|
+
linearApi,
|
|
204
|
+
projectId: session.projectId,
|
|
205
|
+
teamId: session.teamId,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const sessionId = `planner-${session.rootIdentifier}-turn-${newTurnCount}`;
|
|
210
|
+
const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
211
|
+
|
|
212
|
+
api.logger.info(`Planning: turn ${newTurnCount} for ${session.projectName}`);
|
|
213
|
+
|
|
214
|
+
const result = await runAgent({
|
|
215
|
+
api,
|
|
216
|
+
agentId,
|
|
217
|
+
sessionId,
|
|
218
|
+
message: `${prompts.system}\n\n${taskPrompt}`,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Post agent response as comment
|
|
222
|
+
if (result.output) {
|
|
223
|
+
await linearApi.createComment(session.rootIssueId, result.output);
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
clearActivePlannerContext();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Plan audit
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
export async function runPlanAudit(
|
|
235
|
+
ctx: PlannerContext,
|
|
236
|
+
session: PlanningSession,
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const { api, linearApi, pluginConfig } = ctx;
|
|
239
|
+
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
240
|
+
|
|
241
|
+
api.logger.info(`Planning: running audit for ${session.projectName}`);
|
|
242
|
+
|
|
243
|
+
// Run deterministic audit
|
|
244
|
+
const issues = await linearApi.getProjectIssues(session.projectId);
|
|
245
|
+
const result = auditPlan(issues);
|
|
246
|
+
|
|
247
|
+
if (result.pass) {
|
|
248
|
+
// Build final summary
|
|
249
|
+
const snapshot = buildPlanSnapshot(issues);
|
|
250
|
+
const warningsList = result.warnings.length > 0
|
|
251
|
+
? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
|
252
|
+
: "";
|
|
253
|
+
|
|
254
|
+
await linearApi.createComment(
|
|
255
|
+
session.rootIssueId,
|
|
256
|
+
`## Plan Approved\n\n` +
|
|
257
|
+
`The plan for **${session.projectName}** passed all checks.\n\n` +
|
|
258
|
+
`**${issues.length} issues** created with valid dependency graph.${warningsList}\n\n` +
|
|
259
|
+
`### Final Plan\n${snapshot}\n\n` +
|
|
260
|
+
`---\n*Planning mode complete. Project is ready for implementation dispatch.*`,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await endPlanningSession(session.projectId, "approved", configPath);
|
|
264
|
+
api.logger.info(`Planning: session approved for ${session.projectName}`);
|
|
265
|
+
} else {
|
|
266
|
+
// Post problems and keep planning
|
|
267
|
+
const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
|
|
268
|
+
const warningsList = result.warnings.length > 0
|
|
269
|
+
? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
|
270
|
+
: "";
|
|
271
|
+
|
|
272
|
+
await linearApi.createComment(
|
|
273
|
+
session.rootIssueId,
|
|
274
|
+
`## Plan Audit Failed\n\n` +
|
|
275
|
+
`The following issues need attention before the plan can be approved:\n\n` +
|
|
276
|
+
`**Problems:**\n${problemsList}${warningsList}\n\n` +
|
|
277
|
+
`Please address these issues, then say "finalize plan" again.`,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
api.logger.info(`Planning: audit failed for ${session.projectName} (${result.problems.length} problems)`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
readPlanningState,
|
|
7
|
+
writePlanningState,
|
|
8
|
+
registerPlanningSession,
|
|
9
|
+
updatePlanningSession,
|
|
10
|
+
getPlanningSession,
|
|
11
|
+
endPlanningSession,
|
|
12
|
+
isInPlanningMode,
|
|
13
|
+
setPlanningCache,
|
|
14
|
+
clearPlanningCache,
|
|
15
|
+
getActivePlanningByProjectId,
|
|
16
|
+
type PlanningSession,
|
|
17
|
+
type PlanningState,
|
|
18
|
+
} from "./planning-state.js";
|
|
19
|
+
|
|
20
|
+
function tmpStatePath(): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-ps-"));
|
|
22
|
+
return join(dir, "state.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makePlanningSession(overrides?: Partial<PlanningSession>): PlanningSession {
|
|
26
|
+
return {
|
|
27
|
+
projectId: "proj-1",
|
|
28
|
+
projectName: "Test Project",
|
|
29
|
+
rootIssueId: "issue-uuid-1",
|
|
30
|
+
rootIdentifier: "PLAN-100",
|
|
31
|
+
teamId: "team-uuid-1",
|
|
32
|
+
status: "interviewing",
|
|
33
|
+
startedAt: new Date().toISOString(),
|
|
34
|
+
turnCount: 0,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Read / Write
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe("readPlanningState", () => {
|
|
44
|
+
it("returns empty state when file missing", async () => {
|
|
45
|
+
const p = tmpStatePath();
|
|
46
|
+
const state = await readPlanningState(p);
|
|
47
|
+
expect(state.sessions).toEqual({});
|
|
48
|
+
expect(state.processedEvents).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reads back written state", async () => {
|
|
52
|
+
const p = tmpStatePath();
|
|
53
|
+
const session = makePlanningSession();
|
|
54
|
+
const data: PlanningState = {
|
|
55
|
+
sessions: { "proj-1": session },
|
|
56
|
+
processedEvents: ["evt-1"],
|
|
57
|
+
};
|
|
58
|
+
await writePlanningState(data, p);
|
|
59
|
+
const state = await readPlanningState(p);
|
|
60
|
+
expect(state.sessions["proj-1"]).toBeDefined();
|
|
61
|
+
expect(state.sessions["proj-1"].projectId).toBe("proj-1");
|
|
62
|
+
expect(state.processedEvents).toEqual(["evt-1"]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Register
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe("registerPlanningSession", () => {
|
|
71
|
+
it("registers and reads back", async () => {
|
|
72
|
+
const p = tmpStatePath();
|
|
73
|
+
const session = makePlanningSession();
|
|
74
|
+
await registerPlanningSession("proj-1", session, p);
|
|
75
|
+
const state = await readPlanningState(p);
|
|
76
|
+
const found = getPlanningSession(state, "proj-1");
|
|
77
|
+
expect(found).not.toBeNull();
|
|
78
|
+
expect(found!.projectId).toBe("proj-1");
|
|
79
|
+
expect(found!.projectName).toBe("Test Project");
|
|
80
|
+
expect(found!.status).toBe("interviewing");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("overwrites existing session for same projectId", async () => {
|
|
84
|
+
const p = tmpStatePath();
|
|
85
|
+
const session1 = makePlanningSession({ projectName: "First" });
|
|
86
|
+
const session2 = makePlanningSession({ projectName: "Second" });
|
|
87
|
+
await registerPlanningSession("proj-1", session1, p);
|
|
88
|
+
await registerPlanningSession("proj-1", session2, p);
|
|
89
|
+
const state = await readPlanningState(p);
|
|
90
|
+
const found = getPlanningSession(state, "proj-1");
|
|
91
|
+
expect(found).not.toBeNull();
|
|
92
|
+
expect(found!.projectName).toBe("Second");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Update
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe("updatePlanningSession", () => {
|
|
101
|
+
it("increments turnCount", async () => {
|
|
102
|
+
const p = tmpStatePath();
|
|
103
|
+
await registerPlanningSession("proj-1", makePlanningSession({ turnCount: 2 }), p);
|
|
104
|
+
const updated = await updatePlanningSession("proj-1", { turnCount: 3 }, p);
|
|
105
|
+
expect(updated.turnCount).toBe(3);
|
|
106
|
+
const state = await readPlanningState(p);
|
|
107
|
+
expect(getPlanningSession(state, "proj-1")!.turnCount).toBe(3);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("updates status", async () => {
|
|
111
|
+
const p = tmpStatePath();
|
|
112
|
+
await registerPlanningSession("proj-1", makePlanningSession(), p);
|
|
113
|
+
const updated = await updatePlanningSession("proj-1", { status: "plan_review" }, p);
|
|
114
|
+
expect(updated.status).toBe("plan_review");
|
|
115
|
+
const state = await readPlanningState(p);
|
|
116
|
+
expect(getPlanningSession(state, "proj-1")!.status).toBe("plan_review");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("throws on missing session", async () => {
|
|
120
|
+
const p = tmpStatePath();
|
|
121
|
+
await expect(
|
|
122
|
+
updatePlanningSession("no-such-project", { turnCount: 1 }, p),
|
|
123
|
+
).rejects.toThrow("No planning session for project no-such-project");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Get
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
describe("getPlanningSession", () => {
|
|
132
|
+
it("returns session by projectId", async () => {
|
|
133
|
+
const p = tmpStatePath();
|
|
134
|
+
await registerPlanningSession("proj-1", makePlanningSession(), p);
|
|
135
|
+
const state = await readPlanningState(p);
|
|
136
|
+
const session = getPlanningSession(state, "proj-1");
|
|
137
|
+
expect(session).not.toBeNull();
|
|
138
|
+
expect(session!.projectId).toBe("proj-1");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns null for unknown projectId", async () => {
|
|
142
|
+
const p = tmpStatePath();
|
|
143
|
+
const state = await readPlanningState(p);
|
|
144
|
+
const session = getPlanningSession(state, "unknown-proj");
|
|
145
|
+
expect(session).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// End
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe("endPlanningSession", () => {
|
|
154
|
+
it("sets status to approved", async () => {
|
|
155
|
+
const p = tmpStatePath();
|
|
156
|
+
await registerPlanningSession("proj-1", makePlanningSession(), p);
|
|
157
|
+
await endPlanningSession("proj-1", "approved", p);
|
|
158
|
+
const state = await readPlanningState(p);
|
|
159
|
+
const session = getPlanningSession(state, "proj-1");
|
|
160
|
+
expect(session).not.toBeNull();
|
|
161
|
+
expect(session!.status).toBe("approved");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("sets status to abandoned", async () => {
|
|
165
|
+
const p = tmpStatePath();
|
|
166
|
+
await registerPlanningSession("proj-1", makePlanningSession(), p);
|
|
167
|
+
await endPlanningSession("proj-1", "abandoned", p);
|
|
168
|
+
const state = await readPlanningState(p);
|
|
169
|
+
const session = getPlanningSession(state, "proj-1");
|
|
170
|
+
expect(session).not.toBeNull();
|
|
171
|
+
expect(session!.status).toBe("abandoned");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// isInPlanningMode
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe("isInPlanningMode", () => {
|
|
180
|
+
it("true for interviewing", async () => {
|
|
181
|
+
const p = tmpStatePath();
|
|
182
|
+
await registerPlanningSession("proj-1", makePlanningSession({ status: "interviewing" }), p);
|
|
183
|
+
const state = await readPlanningState(p);
|
|
184
|
+
expect(isInPlanningMode(state, "proj-1")).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("true for plan_review", async () => {
|
|
188
|
+
const p = tmpStatePath();
|
|
189
|
+
await registerPlanningSession("proj-1", makePlanningSession({ status: "plan_review" }), p);
|
|
190
|
+
const state = await readPlanningState(p);
|
|
191
|
+
expect(isInPlanningMode(state, "proj-1")).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("false for approved", async () => {
|
|
195
|
+
const p = tmpStatePath();
|
|
196
|
+
await registerPlanningSession("proj-1", makePlanningSession({ status: "approved" }), p);
|
|
197
|
+
const state = await readPlanningState(p);
|
|
198
|
+
expect(isInPlanningMode(state, "proj-1")).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("false for abandoned", async () => {
|
|
202
|
+
const p = tmpStatePath();
|
|
203
|
+
await registerPlanningSession("proj-1", makePlanningSession({ status: "abandoned" }), p);
|
|
204
|
+
const state = await readPlanningState(p);
|
|
205
|
+
expect(isInPlanningMode(state, "proj-1")).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("false for missing project", async () => {
|
|
209
|
+
const p = tmpStatePath();
|
|
210
|
+
const state = await readPlanningState(p);
|
|
211
|
+
expect(isInPlanningMode(state, "no-such-project")).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// In-memory cache
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
describe("setPlanningCache / getActivePlanningByProjectId", () => {
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
clearPlanningCache("cache-proj-1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("round-trip in-memory + clearPlanningCache", () => {
|
|
225
|
+
const session = makePlanningSession({ projectId: "cache-proj-1" });
|
|
226
|
+
setPlanningCache(session);
|
|
227
|
+
const cached = getActivePlanningByProjectId("cache-proj-1");
|
|
228
|
+
expect(cached).not.toBeNull();
|
|
229
|
+
expect(cached!.projectId).toBe("cache-proj-1");
|
|
230
|
+
expect(cached!.projectName).toBe("Test Project");
|
|
231
|
+
|
|
232
|
+
clearPlanningCache("cache-proj-1");
|
|
233
|
+
const cleared = getActivePlanningByProjectId("cache-proj-1");
|
|
234
|
+
expect(cleared).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
});
|