@calltelemetry/openclaw-linear 0.6.1 → 0.7.1
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/LICENSE +21 -0
- package/README.md +115 -17
- package/index.ts +57 -22
- package/openclaw.plugin.json +37 -4
- package/package.json +2 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +193 -19
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +284 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +4 -4
- package/src/infra/doctor.ts +7 -29
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +222 -43
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +287 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +178 -0
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +90 -17
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
|
@@ -0,0 +1,287 @@
|
|
|
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
|
+
opts?: { onApproved?: (projectId: string) => void },
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const { api, linearApi, pluginConfig } = ctx;
|
|
155
|
+
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
156
|
+
|
|
157
|
+
// Detect finalization intent
|
|
158
|
+
if (FINALIZE_PATTERN.test(input.commentBody)) {
|
|
159
|
+
await runPlanAudit(ctx, session, { onApproved: opts?.onApproved });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Detect abandon intent
|
|
164
|
+
if (ABANDON_PATTERN.test(input.commentBody)) {
|
|
165
|
+
await endPlanningSession(session.projectId, "abandoned", configPath);
|
|
166
|
+
await linearApi.createComment(
|
|
167
|
+
session.rootIssueId,
|
|
168
|
+
`Planning mode ended for **${session.projectName}**. Session abandoned.`,
|
|
169
|
+
);
|
|
170
|
+
api.logger.info(`Planning: session abandoned for ${session.projectName}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Increment turn count
|
|
175
|
+
const newTurnCount = session.turnCount + 1;
|
|
176
|
+
await updatePlanningSession(session.projectId, { turnCount: newTurnCount }, configPath);
|
|
177
|
+
|
|
178
|
+
// Build plan snapshot
|
|
179
|
+
const issues = await linearApi.getProjectIssues(session.projectId);
|
|
180
|
+
const planSnapshot = buildPlanSnapshot(issues);
|
|
181
|
+
|
|
182
|
+
// Build comment history (last 10 comments on root issue)
|
|
183
|
+
let commentHistory = "";
|
|
184
|
+
try {
|
|
185
|
+
const details = await linearApi.getIssueDetails(session.rootIssueId);
|
|
186
|
+
commentHistory = details.comments?.nodes
|
|
187
|
+
?.map((c) => `**${c.user?.name ?? "Unknown"}:** ${c.body.slice(0, 300)}`)
|
|
188
|
+
.join("\n\n") ?? "";
|
|
189
|
+
} catch { /* best effort */ }
|
|
190
|
+
|
|
191
|
+
// Build prompt
|
|
192
|
+
const prompts = loadPlannerPrompts(pluginConfig);
|
|
193
|
+
const taskPrompt = renderTemplate(prompts.interview, {
|
|
194
|
+
projectName: session.projectName,
|
|
195
|
+
rootIdentifier: session.rootIdentifier,
|
|
196
|
+
planSnapshot,
|
|
197
|
+
turnCount: String(newTurnCount),
|
|
198
|
+
commentHistory,
|
|
199
|
+
userMessage: input.commentBody,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Set planner context for tools
|
|
203
|
+
setActivePlannerContext({
|
|
204
|
+
linearApi,
|
|
205
|
+
projectId: session.projectId,
|
|
206
|
+
teamId: session.teamId,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const sessionId = `planner-${session.rootIdentifier}-turn-${newTurnCount}`;
|
|
211
|
+
const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
212
|
+
|
|
213
|
+
api.logger.info(`Planning: turn ${newTurnCount} for ${session.projectName}`);
|
|
214
|
+
|
|
215
|
+
const result = await runAgent({
|
|
216
|
+
api,
|
|
217
|
+
agentId,
|
|
218
|
+
sessionId,
|
|
219
|
+
message: `${prompts.system}\n\n${taskPrompt}`,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Post agent response as comment
|
|
223
|
+
if (result.output) {
|
|
224
|
+
await linearApi.createComment(session.rootIssueId, result.output);
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
clearActivePlannerContext();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Plan audit
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
export async function runPlanAudit(
|
|
236
|
+
ctx: PlannerContext,
|
|
237
|
+
session: PlanningSession,
|
|
238
|
+
opts?: { onApproved?: (projectId: string) => void },
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
const { api, linearApi, pluginConfig } = ctx;
|
|
241
|
+
const configPath = pluginConfig?.planningStatePath as string | undefined;
|
|
242
|
+
|
|
243
|
+
api.logger.info(`Planning: running audit for ${session.projectName}`);
|
|
244
|
+
|
|
245
|
+
// Run deterministic audit
|
|
246
|
+
const issues = await linearApi.getProjectIssues(session.projectId);
|
|
247
|
+
const result = auditPlan(issues);
|
|
248
|
+
|
|
249
|
+
if (result.pass) {
|
|
250
|
+
// Build final summary
|
|
251
|
+
const snapshot = buildPlanSnapshot(issues);
|
|
252
|
+
const warningsList = result.warnings.length > 0
|
|
253
|
+
? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
|
254
|
+
: "";
|
|
255
|
+
|
|
256
|
+
await linearApi.createComment(
|
|
257
|
+
session.rootIssueId,
|
|
258
|
+
`## Plan Approved\n\n` +
|
|
259
|
+
`The plan for **${session.projectName}** passed all checks.\n\n` +
|
|
260
|
+
`**${issues.length} issues** created with valid dependency graph.${warningsList}\n\n` +
|
|
261
|
+
`### Final Plan\n${snapshot}\n\n` +
|
|
262
|
+
`---\n*Planning mode complete. Project is ready for implementation dispatch.*`,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await endPlanningSession(session.projectId, "approved", configPath);
|
|
266
|
+
api.logger.info(`Planning: session approved for ${session.projectName}`);
|
|
267
|
+
|
|
268
|
+
// Trigger DAG-based dispatch if callback provided
|
|
269
|
+
opts?.onApproved?.(session.projectId);
|
|
270
|
+
} else {
|
|
271
|
+
// Post problems and keep planning
|
|
272
|
+
const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
|
|
273
|
+
const warningsList = result.warnings.length > 0
|
|
274
|
+
? `\n\n**Warnings:**\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
|
275
|
+
: "";
|
|
276
|
+
|
|
277
|
+
await linearApi.createComment(
|
|
278
|
+
session.rootIssueId,
|
|
279
|
+
`## Plan Audit Failed\n\n` +
|
|
280
|
+
`The following issues need attention before the plan can be approved:\n\n` +
|
|
281
|
+
`**Problems:**\n${problemsList}${warningsList}\n\n` +
|
|
282
|
+
`Please address these issues, then say "finalize plan" again.`,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
api.logger.info(`Planning: audit failed for ${session.projectName} (${result.problems.length} problems)`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planning-state.ts — File-backed persistent planning session state.
|
|
3
|
+
*
|
|
4
|
+
* Tracks active planning sessions across gateway restarts.
|
|
5
|
+
* Uses file-level locking to prevent concurrent read-modify-write races.
|
|
6
|
+
* Mirrors the dispatch-state.ts pattern.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export type PlanningStatus = "interviewing" | "plan_review" | "approved" | "abandoned";
|
|
18
|
+
|
|
19
|
+
export interface PlanningSession {
|
|
20
|
+
projectId: string;
|
|
21
|
+
projectName: string;
|
|
22
|
+
rootIssueId: string;
|
|
23
|
+
rootIdentifier: string;
|
|
24
|
+
teamId: string;
|
|
25
|
+
agentSessionId?: string;
|
|
26
|
+
status: PlanningStatus;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
turnCount: number;
|
|
29
|
+
planningLabelId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PlanningState {
|
|
33
|
+
sessions: Record<string, PlanningSession>; // keyed by projectId
|
|
34
|
+
processedEvents: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Defaults
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const DEFAULT_STATE_PATH = path.join(homedir(), ".openclaw", "linear-planning-state.json");
|
|
42
|
+
const MAX_PROCESSED_EVENTS = 200;
|
|
43
|
+
|
|
44
|
+
function resolveStatePath(configPath?: string): string {
|
|
45
|
+
if (!configPath) return DEFAULT_STATE_PATH;
|
|
46
|
+
if (configPath.startsWith("~/")) return configPath.replace("~", homedir());
|
|
47
|
+
return configPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// File locking (shared utility)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
import { acquireLock, releaseLock } from "../infra/file-lock.js";
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Read / Write
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function emptyState(): PlanningState {
|
|
61
|
+
return { sessions: {}, processedEvents: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readPlanningState(configPath?: string): Promise<PlanningState> {
|
|
65
|
+
const filePath = resolveStatePath(configPath);
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
68
|
+
const parsed = JSON.parse(raw) as PlanningState;
|
|
69
|
+
if (!parsed.sessions) parsed.sessions = {};
|
|
70
|
+
if (!parsed.processedEvents) parsed.processedEvents = [];
|
|
71
|
+
return parsed;
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
if (err.code === "ENOENT") return emptyState();
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function writePlanningState(data: PlanningState, configPath?: string): Promise<void> {
|
|
79
|
+
const filePath = resolveStatePath(configPath);
|
|
80
|
+
const dir = path.dirname(filePath);
|
|
81
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
82
|
+
if (data.processedEvents.length > MAX_PROCESSED_EVENTS) {
|
|
83
|
+
data.processedEvents = data.processedEvents.slice(-MAX_PROCESSED_EVENTS);
|
|
84
|
+
}
|
|
85
|
+
const tmpPath = filePath + ".tmp";
|
|
86
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
87
|
+
await fs.rename(tmpPath, filePath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Session operations
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export async function registerPlanningSession(
|
|
95
|
+
projectId: string,
|
|
96
|
+
session: PlanningSession,
|
|
97
|
+
configPath?: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const filePath = resolveStatePath(configPath);
|
|
100
|
+
await acquireLock(filePath);
|
|
101
|
+
try {
|
|
102
|
+
const data = await readPlanningState(configPath);
|
|
103
|
+
data.sessions[projectId] = session;
|
|
104
|
+
await writePlanningState(data, configPath);
|
|
105
|
+
} finally {
|
|
106
|
+
await releaseLock(filePath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function updatePlanningSession(
|
|
111
|
+
projectId: string,
|
|
112
|
+
updates: Partial<PlanningSession>,
|
|
113
|
+
configPath?: string,
|
|
114
|
+
): Promise<PlanningSession> {
|
|
115
|
+
const filePath = resolveStatePath(configPath);
|
|
116
|
+
await acquireLock(filePath);
|
|
117
|
+
try {
|
|
118
|
+
const data = await readPlanningState(configPath);
|
|
119
|
+
const session = data.sessions[projectId];
|
|
120
|
+
if (!session) throw new Error(`No planning session for project ${projectId}`);
|
|
121
|
+
Object.assign(session, updates);
|
|
122
|
+
await writePlanningState(data, configPath);
|
|
123
|
+
return session;
|
|
124
|
+
} finally {
|
|
125
|
+
await releaseLock(filePath);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getPlanningSession(
|
|
130
|
+
state: PlanningState,
|
|
131
|
+
projectId: string,
|
|
132
|
+
): PlanningSession | null {
|
|
133
|
+
return state.sessions[projectId] ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function endPlanningSession(
|
|
137
|
+
projectId: string,
|
|
138
|
+
status: "approved" | "abandoned",
|
|
139
|
+
configPath?: string,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const filePath = resolveStatePath(configPath);
|
|
142
|
+
await acquireLock(filePath);
|
|
143
|
+
try {
|
|
144
|
+
const data = await readPlanningState(configPath);
|
|
145
|
+
const session = data.sessions[projectId];
|
|
146
|
+
if (session) {
|
|
147
|
+
session.status = status;
|
|
148
|
+
await writePlanningState(data, configPath);
|
|
149
|
+
}
|
|
150
|
+
clearPlanningCache(projectId);
|
|
151
|
+
} finally {
|
|
152
|
+
await releaseLock(filePath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isInPlanningMode(state: PlanningState, projectId: string): boolean {
|
|
157
|
+
const session = state.sessions[projectId];
|
|
158
|
+
if (!session) return false;
|
|
159
|
+
return session.status === "interviewing" || session.status === "plan_review";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// In-memory cache for fast webhook routing
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const planningCache = new Map<string, PlanningSession>();
|
|
167
|
+
|
|
168
|
+
export function setPlanningCache(session: PlanningSession): void {
|
|
169
|
+
planningCache.set(session.projectId, session);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function clearPlanningCache(projectId: string): void {
|
|
173
|
+
planningCache.delete(projectId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getActivePlanningByProjectId(projectId: string): PlanningSession | null {
|
|
177
|
+
return planningCache.get(projectId) ?? null;
|
|
178
|
+
}
|