@calltelemetry/openclaw-linear 0.3.1 → 0.4.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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * dispatch-service.ts — Background service for dispatch health monitoring.
3
+ *
4
+ * Registered via api.registerService(). Runs on a 5-minute interval.
5
+ * Zero LLM tokens — all logic is deterministic code.
6
+ *
7
+ * Responsibilities:
8
+ * - Hydrate active sessions from dispatch-state.json on startup
9
+ * - Detect stale dispatches (active >2h with no progress)
10
+ * - Verify worktree health for active dispatches
11
+ * - Prune completed dispatches older than 7 days
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
15
+ import { hydrateFromDispatchState } from "./active-session.js";
16
+ import {
17
+ readDispatchState,
18
+ listStaleDispatches,
19
+ removeActiveDispatch,
20
+ pruneCompleted,
21
+ } from "./dispatch-state.js";
22
+ import { getWorktreeStatus } from "./codex-worktree.js";
23
+
24
+ const INTERVAL_MS = 5 * 60_000; // 5 minutes
25
+ const STALE_THRESHOLD_MS = 2 * 60 * 60_000; // 2 hours
26
+ const COMPLETED_MAX_AGE_MS = 7 * 24 * 60 * 60_000; // 7 days
27
+
28
+ type ServiceContext = {
29
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
30
+ };
31
+
32
+ export function createDispatchService(api: OpenClawPluginApi) {
33
+ let intervalId: ReturnType<typeof setInterval> | null = null;
34
+
35
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
36
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
37
+
38
+ return {
39
+ id: "linear-dispatch-monitor",
40
+
41
+ start: async (ctx: ServiceContext) => {
42
+ // Hydrate active sessions on startup
43
+ try {
44
+ const restored = await hydrateFromDispatchState(statePath);
45
+ if (restored > 0) {
46
+ ctx.logger.info(`linear-dispatch: hydrated ${restored} active session(s) from dispatch state`);
47
+ }
48
+ } catch (err) {
49
+ ctx.logger.warn(`linear-dispatch: hydration failed: ${err}`);
50
+ }
51
+
52
+ ctx.logger.info(`linear-dispatch: service started (interval: ${INTERVAL_MS / 1000}s)`);
53
+
54
+ intervalId = setInterval(() => runTick(ctx), INTERVAL_MS);
55
+ },
56
+
57
+ stop: async (ctx: ServiceContext) => {
58
+ if (intervalId) {
59
+ clearInterval(intervalId);
60
+ intervalId = null;
61
+ ctx.logger.info("linear-dispatch: service stopped");
62
+ }
63
+ },
64
+ };
65
+
66
+ async function runTick(ctx: ServiceContext): Promise<void> {
67
+ try {
68
+ const state = await readDispatchState(statePath);
69
+ const activeCount = Object.keys(state.dispatches.active).length;
70
+
71
+ // Skip tick if nothing to do
72
+ if (activeCount === 0 && Object.keys(state.dispatches.completed).length === 0) return;
73
+
74
+ // 1. Stale dispatch detection
75
+ const stale = listStaleDispatches(state, STALE_THRESHOLD_MS);
76
+ for (const dispatch of stale) {
77
+ // Check if worktree still exists and has progress
78
+ if (existsSync(dispatch.worktreePath)) {
79
+ const status = getWorktreeStatus(dispatch.worktreePath);
80
+ if (status.hasUncommitted || status.lastCommit) {
81
+ // Worktree has activity — not truly stale, just slow
82
+ continue;
83
+ }
84
+ }
85
+ ctx.logger.warn(
86
+ `linear-dispatch: stale dispatch ${dispatch.issueIdentifier} ` +
87
+ `(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status})`
88
+ );
89
+ }
90
+
91
+ // 2. Worktree health — verify active dispatches have valid worktrees
92
+ for (const [id, dispatch] of Object.entries(state.dispatches.active)) {
93
+ if (!existsSync(dispatch.worktreePath)) {
94
+ ctx.logger.warn(
95
+ `linear-dispatch: worktree missing for ${id} at ${dispatch.worktreePath}`
96
+ );
97
+ }
98
+ }
99
+
100
+ // 3. Prune old completed entries
101
+ const pruned = await pruneCompleted(COMPLETED_MAX_AGE_MS, statePath);
102
+ if (pruned > 0) {
103
+ ctx.logger.info(`linear-dispatch: pruned ${pruned} old completed dispatch(es)`);
104
+ }
105
+
106
+ if (activeCount > 0) {
107
+ ctx.logger.info(`linear-dispatch: tick — ${activeCount} active, ${stale.length} stale`);
108
+ }
109
+ } catch (err) {
110
+ ctx.logger.error(`linear-dispatch: tick failed: ${err}`);
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * dispatch-state.ts — File-backed persistent dispatch state.
3
+ *
4
+ * Tracks active and completed dispatches across gateway restarts.
5
+ * Uses file-level locking to prevent concurrent read-modify-write races.
6
+ *
7
+ * Pattern borrowed from DevClaw's projects.ts — atomic writes with
8
+ * exclusive lock, stale lock detection, retry loop.
9
+ */
10
+ import fs from "node:fs/promises";
11
+ import { existsSync, mkdirSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type Tier = "junior" | "medior" | "senior";
20
+
21
+ export interface ActiveDispatch {
22
+ issueId: string;
23
+ issueIdentifier: string;
24
+ worktreePath: string;
25
+ branch: string;
26
+ tier: Tier;
27
+ model: string;
28
+ status: "dispatched" | "running" | "failed";
29
+ dispatchedAt: string;
30
+ agentSessionId?: string;
31
+ project?: string;
32
+ }
33
+
34
+ export interface CompletedDispatch {
35
+ issueIdentifier: string;
36
+ tier: Tier;
37
+ status: "done" | "failed";
38
+ completedAt: string;
39
+ prUrl?: string;
40
+ project?: string;
41
+ }
42
+
43
+ export interface DispatchState {
44
+ dispatches: {
45
+ active: Record<string, ActiveDispatch>;
46
+ completed: Record<string, CompletedDispatch>;
47
+ };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Defaults
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const DEFAULT_STATE_PATH = path.join(homedir(), ".openclaw", "linear-dispatch-state.json");
55
+
56
+ function resolveStatePath(configPath?: string): string {
57
+ if (!configPath) return DEFAULT_STATE_PATH;
58
+ if (configPath.startsWith("~/")) return configPath.replace("~", homedir());
59
+ return configPath;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // File locking
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const LOCK_STALE_MS = 30_000;
67
+ const LOCK_RETRY_MS = 50;
68
+ const LOCK_TIMEOUT_MS = 10_000;
69
+
70
+ function lockPath(statePath: string): string {
71
+ return statePath + ".lock";
72
+ }
73
+
74
+ async function acquireLock(statePath: string): Promise<void> {
75
+ const lock = lockPath(statePath);
76
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
77
+
78
+ while (Date.now() < deadline) {
79
+ try {
80
+ await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
81
+ return;
82
+ } catch (err: any) {
83
+ if (err.code !== "EEXIST") throw err;
84
+
85
+ // Check for stale lock
86
+ try {
87
+ const content = await fs.readFile(lock, "utf-8");
88
+ const lockTime = Number(content);
89
+ if (Date.now() - lockTime > LOCK_STALE_MS) {
90
+ try { await fs.unlink(lock); } catch { /* race */ }
91
+ continue;
92
+ }
93
+ } catch { /* lock disappeared — retry */ }
94
+
95
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
96
+ }
97
+ }
98
+
99
+ // Last resort: force remove potentially stale lock
100
+ try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
101
+ await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
102
+ }
103
+
104
+ async function releaseLock(statePath: string): Promise<void> {
105
+ try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Read / Write
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function emptyState(): DispatchState {
113
+ return { dispatches: { active: {}, completed: {} } };
114
+ }
115
+
116
+ export async function readDispatchState(configPath?: string): Promise<DispatchState> {
117
+ const filePath = resolveStatePath(configPath);
118
+ try {
119
+ const raw = await fs.readFile(filePath, "utf-8");
120
+ return JSON.parse(raw) as DispatchState;
121
+ } catch (err: any) {
122
+ if (err.code === "ENOENT") return emptyState();
123
+ throw err;
124
+ }
125
+ }
126
+
127
+ async function writeDispatchState(filePath: string, data: DispatchState): Promise<void> {
128
+ const dir = path.dirname(filePath);
129
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
130
+ const tmpPath = filePath + ".tmp";
131
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
132
+ await fs.rename(tmpPath, filePath);
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Operations (all use file locking)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export async function registerDispatch(
140
+ issueIdentifier: string,
141
+ dispatch: ActiveDispatch,
142
+ configPath?: string,
143
+ ): Promise<void> {
144
+ const filePath = resolveStatePath(configPath);
145
+ await acquireLock(filePath);
146
+ try {
147
+ const data = await readDispatchState(configPath);
148
+ data.dispatches.active[issueIdentifier] = dispatch;
149
+ await writeDispatchState(filePath, data);
150
+ } finally {
151
+ await releaseLock(filePath);
152
+ }
153
+ }
154
+
155
+ export async function completeDispatch(
156
+ issueIdentifier: string,
157
+ result: Omit<CompletedDispatch, "issueIdentifier">,
158
+ configPath?: string,
159
+ ): Promise<void> {
160
+ const filePath = resolveStatePath(configPath);
161
+ await acquireLock(filePath);
162
+ try {
163
+ const data = await readDispatchState(configPath);
164
+ const active = data.dispatches.active[issueIdentifier];
165
+ delete data.dispatches.active[issueIdentifier];
166
+ data.dispatches.completed[issueIdentifier] = {
167
+ issueIdentifier,
168
+ tier: active?.tier ?? result.tier,
169
+ status: result.status,
170
+ completedAt: result.completedAt,
171
+ prUrl: result.prUrl,
172
+ project: active?.project ?? result.project,
173
+ };
174
+ await writeDispatchState(filePath, data);
175
+ } finally {
176
+ await releaseLock(filePath);
177
+ }
178
+ }
179
+
180
+ export async function updateDispatchStatus(
181
+ issueIdentifier: string,
182
+ status: ActiveDispatch["status"],
183
+ configPath?: string,
184
+ ): Promise<void> {
185
+ const filePath = resolveStatePath(configPath);
186
+ await acquireLock(filePath);
187
+ try {
188
+ const data = await readDispatchState(configPath);
189
+ const dispatch = data.dispatches.active[issueIdentifier];
190
+ if (dispatch) {
191
+ dispatch.status = status;
192
+ await writeDispatchState(filePath, data);
193
+ }
194
+ } finally {
195
+ await releaseLock(filePath);
196
+ }
197
+ }
198
+
199
+ export function getActiveDispatch(
200
+ state: DispatchState,
201
+ issueIdentifier: string,
202
+ ): ActiveDispatch | null {
203
+ return state.dispatches.active[issueIdentifier] ?? null;
204
+ }
205
+
206
+ export function listActiveDispatches(state: DispatchState): ActiveDispatch[] {
207
+ return Object.values(state.dispatches.active);
208
+ }
209
+
210
+ export function listStaleDispatches(
211
+ state: DispatchState,
212
+ maxAgeMs: number,
213
+ ): ActiveDispatch[] {
214
+ const now = Date.now();
215
+ return Object.values(state.dispatches.active).filter((d) => {
216
+ const age = now - new Date(d.dispatchedAt).getTime();
217
+ return age > maxAgeMs;
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Remove completed dispatches older than maxAgeMs.
223
+ * Returns the number of entries pruned.
224
+ */
225
+ export async function pruneCompleted(
226
+ maxAgeMs: number,
227
+ configPath?: string,
228
+ ): Promise<number> {
229
+ const filePath = resolveStatePath(configPath);
230
+ await acquireLock(filePath);
231
+ try {
232
+ const data = await readDispatchState(configPath);
233
+ const now = Date.now();
234
+ let pruned = 0;
235
+ for (const [key, entry] of Object.entries(data.dispatches.completed)) {
236
+ const age = now - new Date(entry.completedAt).getTime();
237
+ if (age > maxAgeMs) {
238
+ delete data.dispatches.completed[key];
239
+ pruned++;
240
+ }
241
+ }
242
+ if (pruned > 0) await writeDispatchState(filePath, data);
243
+ return pruned;
244
+ } finally {
245
+ await releaseLock(filePath);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Remove an active dispatch (e.g. when worktree is gone and branch is gone).
251
+ */
252
+ export async function removeActiveDispatch(
253
+ issueIdentifier: string,
254
+ configPath?: string,
255
+ ): Promise<void> {
256
+ const filePath = resolveStatePath(configPath);
257
+ await acquireLock(filePath);
258
+ try {
259
+ const data = await readDispatchState(configPath);
260
+ delete data.dispatches.active[issueIdentifier];
261
+ await writeDispatchState(filePath, data);
262
+ } finally {
263
+ await releaseLock(filePath);
264
+ }
265
+ }
@@ -0,0 +1,238 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+ import type { ActivityContent } from "./linear-api.js";
5
+ import {
6
+ buildLinearApi,
7
+ resolveSession,
8
+ extractPrompt,
9
+ DEFAULT_TIMEOUT_MS,
10
+ DEFAULT_BASE_REPO,
11
+ type CliToolParams,
12
+ type CliResult,
13
+ } from "./cli-shared.js";
14
+
15
+ const GEMINI_BIN = "/home/claw/.npm-global/bin/gemini";
16
+
17
+ /**
18
+ * Map a Gemini CLI stream-json JSONL event to a Linear activity.
19
+ *
20
+ * Gemini event types:
21
+ * init → message(user) → message(assistant) → tool_use → tool_result → result
22
+ */
23
+ function mapGeminiEventToActivity(event: any): ActivityContent | null {
24
+ const type = event?.type;
25
+
26
+ // Assistant message (delta text)
27
+ if (type === "message" && event.role === "assistant") {
28
+ const text = event.content;
29
+ if (text) return { type: "thought", body: text.slice(0, 1000) };
30
+ return null;
31
+ }
32
+
33
+ // Tool use — running a command or tool
34
+ if (type === "tool_use") {
35
+ const toolName = event.tool_name ?? "tool";
36
+ const params = event.parameters ?? {};
37
+ let paramSummary: string;
38
+ if (params.command) {
39
+ paramSummary = String(params.command).slice(0, 200);
40
+ } else if (params.file_path) {
41
+ paramSummary = String(params.file_path);
42
+ } else if (params.description) {
43
+ paramSummary = String(params.description).slice(0, 200);
44
+ } else {
45
+ paramSummary = JSON.stringify(params).slice(0, 200);
46
+ }
47
+ return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
48
+ }
49
+
50
+ // Tool result
51
+ if (type === "tool_result") {
52
+ const status = event.status ?? "unknown";
53
+ const output = event.output ?? "";
54
+ const truncated = output.length > 300 ? output.slice(0, 300) + "..." : output;
55
+ return {
56
+ type: "action",
57
+ action: `Tool ${status}`,
58
+ parameter: truncated || "(no output)",
59
+ };
60
+ }
61
+
62
+ // Final result
63
+ if (type === "result") {
64
+ const stats = event.stats;
65
+ const parts: string[] = ["Gemini completed"];
66
+ if (stats) {
67
+ if (stats.duration_ms) parts.push(`${Math.round(stats.duration_ms / 1000)}s`);
68
+ if (stats.total_tokens) parts.push(`${stats.total_tokens} tokens`);
69
+ if (stats.tool_calls) parts.push(`${stats.tool_calls} tool calls`);
70
+ }
71
+ return { type: "thought", body: parts.join(" — ") };
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Run Gemini CLI with JSONL streaming, mapping events to Linear activities.
79
+ */
80
+ export async function runGemini(
81
+ api: OpenClawPluginApi,
82
+ params: CliToolParams,
83
+ pluginConfig?: Record<string, unknown>,
84
+ ): Promise<CliResult> {
85
+ api.logger.info(`gemini_run params: ${JSON.stringify(params).slice(0, 500)}`);
86
+
87
+ const prompt = extractPrompt(params);
88
+ if (!prompt) {
89
+ return {
90
+ success: false,
91
+ output: `gemini_run error: no prompt provided. Received keys: ${Object.keys(params).join(", ")}`,
92
+ error: "missing prompt",
93
+ };
94
+ }
95
+
96
+ const { model, timeoutMs } = params;
97
+ const { agentSessionId, issueIdentifier } = resolveSession(params);
98
+
99
+ api.logger.info(`gemini_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
100
+
101
+ const timeout = timeoutMs ?? (pluginConfig?.geminiTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
102
+ const workingDir = params.workingDir ?? (pluginConfig?.geminiBaseRepo as string) ?? DEFAULT_BASE_REPO;
103
+
104
+ const linearApi = buildLinearApi(api, agentSessionId);
105
+
106
+ if (linearApi && agentSessionId) {
107
+ await linearApi.emitActivity(agentSessionId, {
108
+ type: "thought",
109
+ body: `Starting Gemini: "${prompt.slice(0, 100)}${prompt.length > 100 ? "..." : ""}"`,
110
+ }).catch(() => {});
111
+ }
112
+
113
+ // Build gemini command — no -C flag, use cwd in spawn options
114
+ const args = [
115
+ "-p", prompt,
116
+ "-o", "stream-json",
117
+ "--yolo",
118
+ ];
119
+ if (model ?? pluginConfig?.geminiModel) {
120
+ args.push("-m", (model ?? pluginConfig?.geminiModel) as string);
121
+ }
122
+
123
+ api.logger.info(`Gemini exec: ${GEMINI_BIN} ${args.join(" ").slice(0, 200)}...`);
124
+
125
+ return new Promise<CliResult>((resolve) => {
126
+ const child = spawn(GEMINI_BIN, args, {
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ cwd: workingDir,
129
+ env: { ...process.env },
130
+ timeout: 0,
131
+ });
132
+
133
+ let killed = false;
134
+ const timer = setTimeout(() => {
135
+ killed = true;
136
+ child.kill("SIGTERM");
137
+ setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
138
+ }, timeout);
139
+
140
+ const collectedMessages: string[] = [];
141
+ const collectedCommands: string[] = [];
142
+ let stderrOutput = "";
143
+
144
+ const rl = createInterface({ input: child.stdout! });
145
+ rl.on("line", (line) => {
146
+ if (!line.trim()) return;
147
+
148
+ let event: any;
149
+ try {
150
+ event = JSON.parse(line);
151
+ } catch {
152
+ // Non-JSON lines (e.g. "YOLO mode" warnings) — skip
153
+ return;
154
+ }
155
+
156
+ // Collect assistant text for final output
157
+ if (event.type === "message" && event.role === "assistant") {
158
+ const text = event.content;
159
+ if (text) collectedMessages.push(text);
160
+ }
161
+
162
+ // Collect tool use/result for final output
163
+ if (event.type === "tool_use") {
164
+ const toolName = event.tool_name ?? "tool";
165
+ const cmd = event.parameters?.command ?? event.parameters?.description ?? "";
166
+ if (cmd) collectedCommands.push(`\`${toolName}\`: ${String(cmd).slice(0, 200)}`);
167
+ }
168
+
169
+ if (event.type === "tool_result") {
170
+ const output = event.output ?? "";
171
+ const status = event.status ?? "unknown";
172
+ const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
173
+ if (truncOutput) {
174
+ collectedCommands.push(
175
+ `→ ${status}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`
176
+ );
177
+ }
178
+ }
179
+
180
+ // Stream activity to Linear
181
+ const activity = mapGeminiEventToActivity(event);
182
+ if (activity && linearApi && agentSessionId) {
183
+ linearApi.emitActivity(agentSessionId, activity).catch((err) => {
184
+ api.logger.warn(`Failed to emit Gemini activity: ${err}`);
185
+ });
186
+ }
187
+ });
188
+
189
+ child.stderr?.on("data", (chunk) => {
190
+ stderrOutput += chunk.toString();
191
+ });
192
+
193
+ child.on("close", (code) => {
194
+ clearTimeout(timer);
195
+ rl.close();
196
+
197
+ const parts: string[] = [];
198
+ if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
199
+ if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
200
+ const output = parts.join("\n\n") || stderrOutput || "(no output)";
201
+
202
+ if (killed) {
203
+ api.logger.warn(`Gemini timed out after ${timeout}ms`);
204
+ resolve({
205
+ success: false,
206
+ output: `Gemini timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
207
+ error: "timeout",
208
+ });
209
+ return;
210
+ }
211
+
212
+ if (code !== 0) {
213
+ api.logger.warn(`Gemini exited with code ${code}`);
214
+ resolve({
215
+ success: false,
216
+ output: `Gemini failed (exit ${code}):\n${output}`,
217
+ error: `exit ${code}`,
218
+ });
219
+ return;
220
+ }
221
+
222
+ api.logger.info(`Gemini completed successfully`);
223
+ resolve({ success: true, output });
224
+ });
225
+
226
+ child.on("error", (err) => {
227
+ clearTimeout(timer);
228
+ rl.close();
229
+ api.logger.error(`Gemini spawn error: ${err}`);
230
+ resolve({
231
+ success: false,
232
+ output: `Failed to start Gemini: ${err.message}`,
233
+ error: err.message,
234
+ });
235
+ });
236
+ });
237
+ }
238
+