@calltelemetry/openclaw-linear 0.9.3 → 0.9.4

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.
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
2
4
  import { resolveRepos, isMultiRepo, validateRepoPath, type RepoResolution } from "./multi-repo.ts";
3
5
 
4
6
  vi.mock("node:fs", async (importOriginal) => {
@@ -46,12 +48,12 @@ describe("resolveRepos", () => {
46
48
  });
47
49
 
48
50
  it("falls back to config repos when no markers/labels", () => {
49
- const config = { codexBaseRepo: "/home/claw/myproject" };
51
+ const config = { codexBaseRepo: "/tmp/test/myproject" };
50
52
  const result = resolveRepos("Plain description", [], config);
51
53
  expect(result.source).toBe("config_default");
52
54
  expect(result.repos).toHaveLength(1);
53
55
  expect(result.repos[0].name).toBe("default");
54
- expect(result.repos[0].path).toBe("/home/claw/myproject");
56
+ expect(result.repos[0].path).toBe("/tmp/test/myproject");
55
57
  });
56
58
 
57
59
  it("body markers take priority over labels", () => {
@@ -69,7 +71,7 @@ describe("resolveRepos", () => {
69
71
  expect(result.source).toBe("config_default");
70
72
  expect(result.repos).toHaveLength(1);
71
73
  expect(result.repos[0].name).toBe("default");
72
- expect(result.repos[0].path).toBe("/home/claw/ai-workspace");
74
+ expect(result.repos[0].path).toBe(path.join(homedir(), "ai-workspace"));
73
75
  });
74
76
 
75
77
  it("handles empty description + no labels (single repo fallback)", () => {
@@ -104,8 +106,8 @@ describe("isMultiRepo", () => {
104
106
  it("returns true for 2+ repos", () => {
105
107
  const resolution: RepoResolution = {
106
108
  repos: [
107
- { name: "api", path: "/home/claw/api" },
108
- { name: "frontend", path: "/home/claw/frontend" },
109
+ { name: "api", path: "/tmp/test/api" },
110
+ { name: "frontend", path: "/tmp/test/frontend" },
109
111
  ],
110
112
  source: "issue_body",
111
113
  };
@@ -114,7 +116,7 @@ describe("isMultiRepo", () => {
114
116
 
115
117
  it("returns false for 1 repo", () => {
116
118
  const resolution: RepoResolution = {
117
- repos: [{ name: "default", path: "/home/claw/ai-workspace" }],
119
+ repos: [{ name: "default", path: "/tmp/test/ai-workspace" }],
118
120
  source: "config_default",
119
121
  };
120
122
  expect(isMultiRepo(resolution)).toBe(false);
@@ -143,21 +145,21 @@ describe("validateRepoPath", () => {
143
145
  it("returns isGitRepo:true, isSubmodule:false for normal repo (.git is directory)", () => {
144
146
  mockExistsSync.mockReturnValue(true);
145
147
  mockStatSync.mockReturnValue({ isFile: () => false, isDirectory: () => true });
146
- const result = validateRepoPath("/home/claw/repos/api");
148
+ const result = validateRepoPath("/tmp/test/repos/api");
147
149
  expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: false });
148
150
  });
149
151
 
150
152
  it("returns isGitRepo:true, isSubmodule:true for submodule (.git is file)", () => {
151
153
  mockExistsSync.mockReturnValue(true);
152
154
  mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false });
153
- const result = validateRepoPath("/home/claw/workspace/submod");
155
+ const result = validateRepoPath("/tmp/test/workspace/submod");
154
156
  expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: true });
155
157
  });
156
158
 
157
159
  it("returns isGitRepo:false for directory without .git", () => {
158
160
  // First call: path exists. Second call: .git does not exist
159
161
  mockExistsSync.mockImplementation((p: string) => !String(p).endsWith(".git"));
160
- const result = validateRepoPath("/home/claw/not-a-repo");
162
+ const result = validateRepoPath("/tmp/test/not-a-repo");
161
163
  expect(result).toEqual({ exists: true, isGitRepo: false, isSubmodule: false });
162
164
  });
163
165
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, statSync } from "node:fs";
11
+ import { homedir } from "node:os";
11
12
  import path from "node:path";
12
13
 
13
14
  export interface RepoConfig {
@@ -62,7 +63,7 @@ export function resolveRepos(
62
63
  }
63
64
 
64
65
  // 3. Config default: single repo
65
- const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
66
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
66
67
  return {
67
68
  repos: [{ name: "default", path: baseRepo }],
68
69
  source: "config_default",
@@ -76,7 +77,7 @@ function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, stri
76
77
 
77
78
  function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
78
79
  // Convention: {parentDir}/{name}
79
- const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
80
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
80
81
  const parentDir = path.dirname(baseRepo);
81
82
  return path.join(parentDir, name);
82
83
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
+ import { homedir } from "node:os";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Types
@@ -25,7 +26,7 @@ export interface AgentProfile {
25
26
  // Cached profile loader (5s TTL)
26
27
  // ---------------------------------------------------------------------------
27
28
 
28
- const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
29
+ const PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
29
30
 
30
31
  let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
31
32
  const PROFILES_CACHE_TTL_MS = 5_000;
@@ -62,9 +62,9 @@ describe("renderTemplate", () => {
62
62
  const result = renderTemplate(template, {
63
63
  identifier: "CT-42",
64
64
  title: "Implement auth",
65
- worktreePath: "/home/claw/worktrees/ct-42",
65
+ worktreePath: "/tmp/test/worktrees/ct-42",
66
66
  });
67
- expect(result).toBe("Issue: CT-42\nTitle: Implement auth\nWorktree: /home/claw/worktrees/ct-42");
67
+ expect(result).toBe("Issue: CT-42\nTitle: Implement auth\nWorktree: /tmp/test/worktrees/ct-42");
68
68
  });
69
69
 
70
70
  it("handles special regex characters in values", () => {
@@ -10,6 +10,11 @@ import {
10
10
  getCurrentSession,
11
11
  getSessionCount,
12
12
  hydrateFromDispatchState,
13
+ recordIssueAffinity,
14
+ getIssueAffinity,
15
+ _configureAffinityTtl,
16
+ _getAffinityTtlMs,
17
+ _resetAffinityForTesting,
13
18
  type ActiveSession,
14
19
  } from "./active-session.js";
15
20
 
@@ -25,10 +30,11 @@ function makeSession(overrides?: Partial<ActiveSession>): ActiveSession {
25
30
 
26
31
  // Clean up after each test to avoid cross-contamination
27
32
  afterEach(() => {
28
- // Clear all known sessions
33
+ // Clear all known sessions (use sessions.delete directly to avoid triggering affinity)
29
34
  clearActiveSession("uuid-1");
30
35
  clearActiveSession("uuid-2");
31
36
  clearActiveSession("uuid-3");
37
+ _resetAffinityForTesting();
32
38
  });
33
39
 
34
40
  describe("set + get", () => {
@@ -152,3 +158,92 @@ describe("hydrateFromDispatchState", () => {
152
158
  expect(restored).toBe(0);
153
159
  });
154
160
  });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Issue-agent affinity
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("issue agent affinity", () => {
167
+ it("recordIssueAffinity + getIssueAffinity round-trip", () => {
168
+ recordIssueAffinity("uuid-1", "mal");
169
+ expect(getIssueAffinity("uuid-1")).toBe("mal");
170
+ });
171
+
172
+ it("returns null for unknown issue", () => {
173
+ expect(getIssueAffinity("no-such-id")).toBeNull();
174
+ });
175
+
176
+ it("returns null after TTL expires", () => {
177
+ _configureAffinityTtl(100); // 100ms TTL
178
+ recordIssueAffinity("uuid-1", "mal");
179
+ vi.useFakeTimers();
180
+ vi.advanceTimersByTime(150);
181
+ expect(getIssueAffinity("uuid-1")).toBeNull();
182
+ vi.useRealTimers();
183
+ });
184
+
185
+ it("returns agent within TTL", () => {
186
+ _configureAffinityTtl(60_000);
187
+ recordIssueAffinity("uuid-1", "kaylee");
188
+ expect(getIssueAffinity("uuid-1")).toBe("kaylee");
189
+ });
190
+
191
+ it("overwrites previous affinity for same issue", () => {
192
+ recordIssueAffinity("uuid-1", "mal");
193
+ recordIssueAffinity("uuid-1", "kaylee");
194
+ expect(getIssueAffinity("uuid-1")).toBe("kaylee");
195
+ });
196
+
197
+ it("tracks separate issues independently", () => {
198
+ recordIssueAffinity("uuid-1", "mal");
199
+ recordIssueAffinity("uuid-2", "kaylee");
200
+ expect(getIssueAffinity("uuid-1")).toBe("mal");
201
+ expect(getIssueAffinity("uuid-2")).toBe("kaylee");
202
+ });
203
+
204
+ it("clearActiveSession records affinity when agentId present", () => {
205
+ setActiveSession({
206
+ agentSessionId: "sess-1",
207
+ issueIdentifier: "API-100",
208
+ issueId: "uuid-1",
209
+ agentId: "mal",
210
+ startedAt: Date.now(),
211
+ });
212
+ clearActiveSession("uuid-1");
213
+ expect(getActiveSession("uuid-1")).toBeNull(); // session cleared
214
+ expect(getIssueAffinity("uuid-1")).toBe("mal"); // affinity preserved
215
+ });
216
+
217
+ it("clearActiveSession does NOT record affinity when agentId missing", () => {
218
+ setActiveSession({
219
+ agentSessionId: "sess-1",
220
+ issueIdentifier: "API-100",
221
+ issueId: "uuid-1",
222
+ startedAt: Date.now(),
223
+ // no agentId
224
+ });
225
+ clearActiveSession("uuid-1");
226
+ expect(getIssueAffinity("uuid-1")).toBeNull();
227
+ });
228
+
229
+ it("_resetAffinityForTesting clears all entries and resets TTL", () => {
230
+ _configureAffinityTtl(5000);
231
+ recordIssueAffinity("uuid-1", "mal");
232
+ recordIssueAffinity("uuid-2", "kaylee");
233
+ _resetAffinityForTesting();
234
+ expect(getIssueAffinity("uuid-1")).toBeNull();
235
+ expect(getIssueAffinity("uuid-2")).toBeNull();
236
+ expect(_getAffinityTtlMs()).toBe(30 * 60_000);
237
+ });
238
+
239
+ it("_configureAffinityTtl sets custom TTL", () => {
240
+ _configureAffinityTtl(5000);
241
+ expect(_getAffinityTtlMs()).toBe(5000);
242
+ });
243
+
244
+ it("_configureAffinityTtl resets to default when called with undefined", () => {
245
+ _configureAffinityTtl(5000);
246
+ _configureAffinityTtl(undefined);
247
+ expect(_getAffinityTtlMs()).toBe(30 * 60_000);
248
+ });
249
+ });
@@ -26,6 +26,19 @@ export interface ActiveSession {
26
26
  // Keyed by issue ID — one active session per issue at a time.
27
27
  const sessions = new Map<string, ActiveSession>();
28
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Issue-agent affinity: tracks which agent last handled each issue.
31
+ // Entries expire after a configurable TTL (default 30 min).
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface AffinityEntry {
35
+ agentId: string;
36
+ recordedAt: number;
37
+ }
38
+
39
+ const issueAgentAffinity = new Map<string, AffinityEntry>();
40
+ let _affinityTtlMs = 30 * 60_000; // 30 minutes default
41
+
29
42
  /**
30
43
  * Register the active session for an issue. Idempotent — calling again
31
44
  * for the same issue just updates the session.
@@ -36,8 +49,13 @@ export function setActiveSession(session: ActiveSession): void {
36
49
 
37
50
  /**
38
51
  * Clear the active session for an issue.
52
+ * If the session had an agentId, records it as affinity for future routing.
39
53
  */
40
54
  export function clearActiveSession(issueId: string): void {
55
+ const session = sessions.get(issueId);
56
+ if (session?.agentId) {
57
+ recordIssueAffinity(issueId, session.agentId);
58
+ }
41
59
  sessions.delete(issueId);
42
60
  }
43
61
 
@@ -104,3 +122,45 @@ export async function hydrateFromDispatchState(configPath?: string): Promise<num
104
122
  export function getSessionCount(): number {
105
123
  return sessions.size;
106
124
  }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Issue-agent affinity — public API
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Record which agent last handled an issue.
132
+ * Called automatically from clearActiveSession when an agentId is present.
133
+ */
134
+ export function recordIssueAffinity(issueId: string, agentId: string): void {
135
+ issueAgentAffinity.set(issueId, { agentId, recordedAt: Date.now() });
136
+ }
137
+
138
+ /**
139
+ * Look up which agent last handled an issue.
140
+ * Returns null if no affinity recorded or if the entry has expired.
141
+ */
142
+ export function getIssueAffinity(issueId: string): string | null {
143
+ const entry = issueAgentAffinity.get(issueId);
144
+ if (!entry) return null;
145
+ if (Date.now() - entry.recordedAt > _affinityTtlMs) {
146
+ issueAgentAffinity.delete(issueId);
147
+ return null;
148
+ }
149
+ return entry.agentId;
150
+ }
151
+
152
+ /** @internal — configure affinity TTL from pluginConfig. */
153
+ export function _configureAffinityTtl(ttlMs?: number): void {
154
+ _affinityTtlMs = ttlMs ?? 30 * 60_000;
155
+ }
156
+
157
+ /** @internal — read current affinity TTL (for testing). */
158
+ export function _getAffinityTtlMs(): number {
159
+ return _affinityTtlMs;
160
+ }
161
+
162
+ /** @internal — test-only; clears all affinity state and resets TTL. */
163
+ export function _resetAffinityForTesting(): void {
164
+ issueAgentAffinity.clear();
165
+ _affinityTtlMs = 30 * 60_000;
166
+ }
@@ -293,7 +293,7 @@ export function resolveOrchestratorWorkspace(
293
293
  api: any,
294
294
  pluginConfig?: Record<string, unknown>,
295
295
  ): string {
296
- const home = process.env.HOME ?? "/home/claw";
296
+ const home = homedir();
297
297
  const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
298
298
 
299
299
  try {
@@ -14,6 +14,9 @@ vi.mock("./dispatch-state.js", () => ({
14
14
  vi.mock("./active-session.js", () => ({
15
15
  setActiveSession: vi.fn(),
16
16
  clearActiveSession: vi.fn(),
17
+ getIssueAffinity: vi.fn().mockReturnValue(null),
18
+ _configureAffinityTtl: vi.fn(),
19
+ _resetAffinityForTesting: vi.fn(),
17
20
  }));
18
21
  vi.mock("../infra/notify.js", () => ({}));
19
22
  vi.mock("./artifacts.js", () => ({
@@ -39,6 +39,9 @@ vi.mock("../api/linear-api.js", () => ({
39
39
  vi.mock("./active-session.js", () => ({
40
40
  setActiveSession: vi.fn(),
41
41
  clearActiveSession: vi.fn(),
42
+ getIssueAffinity: vi.fn().mockReturnValue(null),
43
+ _configureAffinityTtl: vi.fn(),
44
+ _resetAffinityForTesting: vi.fn(),
42
45
  }));
43
46
 
44
47
  vi.mock("../infra/observability.js", () => ({