@calltelemetry/openclaw-linear 0.4.0 → 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.
- package/index.ts +4 -0
- package/openclaw.plugin.json +3 -1
- package/package.json +1 -1
- package/src/active-session.ts +40 -0
- package/src/code-tool.ts +2 -2
- package/src/codex-worktree.ts +162 -36
- package/src/dispatch-service.ts +113 -0
- package/src/dispatch-state.ts +265 -0
- package/src/pipeline.ts +311 -82
- package/src/tier-assess.ts +157 -0
- package/src/webhook.ts +223 -197
|
@@ -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
|
+
}
|