jobarbiter 0.3.12 → 0.4.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.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * LaunchAgent Management for macOS
3
+ *
4
+ * Manages the ai.jobarbiter.observer LaunchAgent for periodic transcript polling.
5
+ * Generates plist, writes to ~/Library/LaunchAgents/, manages via launchctl.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { execSync } from "node:child_process";
12
+
13
+ // ── Constants ──────────────────────────────────────────────────────────
14
+
15
+ const LABEL = "ai.jobarbiter.observer";
16
+ const LAUNCH_AGENTS_DIR = join(homedir(), "Library", "LaunchAgents");
17
+ const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`);
18
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
19
+ const LOG_PATH = join(OBSERVER_DIR, "poll.log");
20
+
21
+ // ── Types ──────────────────────────────────────────────────────────────
22
+
23
+ export interface DaemonStatus {
24
+ installed: boolean;
25
+ loaded: boolean;
26
+ interval: number | null;
27
+ plistPath: string;
28
+ }
29
+
30
+ // ── Helpers ────────────────────────────────────────────────────────────
31
+
32
+ function resolveJobarbiterPath(): string {
33
+ // Try to find the jobarbiter binary
34
+ try {
35
+ const path = execSync("command -v jobarbiter", { encoding: "utf-8", timeout: 3000 }).trim();
36
+ if (path) return path;
37
+ } catch {
38
+ // Fall through
39
+ }
40
+
41
+ // Fallback: use process.execPath with the dist path
42
+ // This covers the case where jobarbiter is run via node directly
43
+ return process.execPath;
44
+ }
45
+
46
+ function generatePlist(intervalSeconds: number): string {
47
+ const binaryPath = resolveJobarbiterPath();
48
+
49
+ // If the binary is node itself, we need to figure out the script path
50
+ const isNodeBinary = binaryPath.includes("node") || binaryPath.includes("bun");
51
+ let programArgs: string;
52
+
53
+ if (isNodeBinary) {
54
+ // Find the actual CLI entry point
55
+ const cliPath = join(__dirname, "..", "index.js");
56
+ programArgs = ` <string>${binaryPath}</string>
57
+ <string>${cliPath}</string>
58
+ <string>observe</string>
59
+ <string>poll</string>`;
60
+ } else {
61
+ programArgs = ` <string>${binaryPath}</string>
62
+ <string>observe</string>
63
+ <string>poll</string>`;
64
+ }
65
+
66
+ return `<?xml version="1.0" encoding="UTF-8"?>
67
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
+ <plist version="1.0">
69
+ <dict>
70
+ <key>Label</key>
71
+ <string>${LABEL}</string>
72
+ <key>ProgramArguments</key>
73
+ <array>
74
+ ${programArgs}
75
+ </array>
76
+ <key>StartInterval</key>
77
+ <integer>${intervalSeconds}</integer>
78
+ <key>StandardOutPath</key>
79
+ <string>${LOG_PATH}</string>
80
+ <key>StandardErrorPath</key>
81
+ <string>${LOG_PATH}</string>
82
+ <key>EnvironmentVariables</key>
83
+ <dict>
84
+ <key>PATH</key>
85
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
86
+ </dict>
87
+ <key>RunAtLoad</key>
88
+ <true/>
89
+ <key>Nice</key>
90
+ <integer>10</integer>
91
+ </dict>
92
+ </plist>
93
+ `;
94
+ }
95
+
96
+ function isLoaded(): boolean {
97
+ try {
98
+ const output = execSync(`launchctl list 2>/dev/null`, { encoding: "utf-8", timeout: 5000 });
99
+ return output.includes(LABEL);
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ // ── Public API ─────────────────────────────────────────────────────────
106
+
107
+ export function installDaemon(intervalSeconds = 7200): void {
108
+ mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
109
+ mkdirSync(OBSERVER_DIR, { recursive: true });
110
+
111
+ // Unload existing if present
112
+ if (isLoaded()) {
113
+ try {
114
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
115
+ } catch {
116
+ // May not be loaded
117
+ }
118
+ }
119
+
120
+ // Write plist
121
+ const plist = generatePlist(intervalSeconds);
122
+ writeFileSync(PLIST_PATH, plist);
123
+
124
+ // Load
125
+ try {
126
+ execSync(`launchctl load "${PLIST_PATH}"`, { timeout: 5000 });
127
+ } catch (err) {
128
+ throw new Error(`Failed to load LaunchAgent: ${err instanceof Error ? err.message : String(err)}`);
129
+ }
130
+ }
131
+
132
+ export function uninstallDaemon(): void {
133
+ // Unload
134
+ if (isLoaded()) {
135
+ try {
136
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
137
+ } catch {
138
+ // Best effort
139
+ }
140
+ }
141
+
142
+ // Delete plist
143
+ if (existsSync(PLIST_PATH)) {
144
+ try {
145
+ unlinkSync(PLIST_PATH);
146
+ } catch {
147
+ // Best effort
148
+ }
149
+ }
150
+ }
151
+
152
+ export function getDaemonStatus(): DaemonStatus {
153
+ const installed = existsSync(PLIST_PATH);
154
+ const loaded = isLoaded();
155
+
156
+ let interval: number | null = null;
157
+ if (installed) {
158
+ try {
159
+ const content = readFileSync(PLIST_PATH, "utf-8");
160
+ const match = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
161
+ if (match) {
162
+ interval = parseInt(match[1], 10);
163
+ }
164
+ } catch {
165
+ // Best effort
166
+ }
167
+ }
168
+
169
+ return {
170
+ installed,
171
+ loaded,
172
+ interval,
173
+ plistPath: PLIST_PATH,
174
+ };
175
+ }
176
+
177
+ export function reloadDaemon(): void {
178
+ if (!existsSync(PLIST_PATH)) {
179
+ throw new Error("Daemon not installed. Run 'jobarbiter observe daemon install' first.");
180
+ }
181
+
182
+ try {
183
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
184
+ } catch {
185
+ // May not be loaded
186
+ }
187
+
188
+ try {
189
+ execSync(`launchctl load "${PLIST_PATH}"`, { timeout: 5000 });
190
+ } catch (err) {
191
+ throw new Error(`Failed to reload daemon: ${err instanceof Error ? err.message : String(err)}`);
192
+ }
193
+ }
@@ -125,9 +125,29 @@ const fs = require("fs");
125
125
  const path = require("path");
126
126
  const os = require("os");
127
127
 
128
- const OBSERVATIONS_FILE = path.join(
129
- os.homedir(), ".config", "jobarbiter", "observer", "observations.json"
130
- );
128
+ const OBSERVER_DIR = path.join(os.homedir(), ".config", "jobarbiter", "observer");
129
+ const OBSERVATIONS_FILE = path.join(OBSERVER_DIR, "observations.json");
130
+ const PAUSED_FILE = path.join(OBSERVER_DIR, "PAUSED");
131
+
132
+ // Check PAUSED sentinel FIRST — if paused, exit immediately
133
+ try {
134
+ if (fs.existsSync(PAUSED_FILE)) {
135
+ const raw = fs.readFileSync(PAUSED_FILE, "utf-8");
136
+ const pauseData = JSON.parse(raw);
137
+ if (pauseData.expiresAt) {
138
+ const expiresAt = new Date(pauseData.expiresAt).getTime();
139
+ if (Date.now() < expiresAt) {
140
+ process.exit(0); // Still paused
141
+ }
142
+ // Expired — clean up and continue
143
+ fs.unlinkSync(PAUSED_FILE);
144
+ } else {
145
+ process.exit(0); // Paused indefinitely
146
+ }
147
+ }
148
+ } catch {
149
+ // If PAUSED file is corrupted, continue
150
+ }
131
151
 
132
152
  // Read stdin
133
153
  let input = "";
@@ -283,8 +303,14 @@ function appendObservation(obs) {
283
303
  // Fire-and-forget: trigger analysis pipeline via CLI
284
304
  // Spawns detached process so the AI tool is never blocked
285
305
  try {
286
- const { spawn } = require("child_process");
287
- // Try npx jobarbiter first, fall back to direct node invocation
306
+ const { spawn, execSync } = require("child_process");
307
+ // Self-healing: verify jobarbiter binary exists before spawning
308
+ try {
309
+ execSync("command -v jobarbiter", { stdio: "ignore", timeout: 3000 });
310
+ } catch {
311
+ // jobarbiter CLI not found — exit silently
312
+ return;
313
+ }
288
314
  const child = spawn("jobarbiter", ["analyze", "--auto"], {
289
315
  detached: true,
290
316
  stdio: "ignore",
@@ -21,6 +21,7 @@ import {
21
21
  type DetectedTool,
22
22
  type ToolCategory,
23
23
  } from "./detect-tools.js";
24
+ import { installDaemon } from "./launchd.js";
24
25
  import {
25
26
  loadProviderKeys,
26
27
  saveProviderKey,
@@ -215,21 +216,71 @@ export async function runOnboardWizard(opts: { force?: boolean; baseUrl?: string
215
216
  state.userType = userType;
216
217
 
217
218
  // Step 2: Email & Verification
218
- const { email, apiKey, userId } = await handleEmailVerification(prompt, baseUrl, userType);
219
- state.email = email;
220
- state.apiKey = apiKey;
221
- state.userId = userId;
219
+ const verificationResult = await handleEmailVerification(prompt, baseUrl, userType);
220
+ state.email = verificationResult.email;
221
+ state.apiKey = verificationResult.apiKey;
222
+ state.userId = verificationResult.userId;
223
+
224
+ // If returning user, override userType from backend
225
+ const effectiveUserType = verificationResult.isReturningUser && verificationResult.userType
226
+ ? (verificationResult.userType as "worker" | "employer")
227
+ : userType;
228
+ state.userType = effectiveUserType;
222
229
 
223
230
  // Save config immediately after verification (with step progress)
224
231
  saveConfig({
225
- apiKey,
232
+ apiKey: verificationResult.apiKey,
226
233
  baseUrl,
227
- userType,
234
+ userType: effectiveUserType,
228
235
  onboardingStep: 1,
229
236
  onboardingComplete: false,
230
237
  });
231
238
 
232
- if (userType === "worker") {
239
+ // Returning user: show progress and resume from first incomplete step
240
+ if (verificationResult.isReturningUser) {
241
+ const config: Config = {
242
+ apiKey: verificationResult.apiKey,
243
+ baseUrl,
244
+ userType: effectiveUserType,
245
+ };
246
+ const progress = await fetchOnboardingProgress(config);
247
+ const { firstIncomplete } = showReturningUserProgress(progress);
248
+
249
+ if (firstIncomplete > (effectiveUserType === "worker" ? 6 : 5)) {
250
+ // Everything done except completion step
251
+ const continueAnyway = await prompt.confirm(`All steps look complete. Re-run onboarding anyway?`, false);
252
+ if (!continueAnyway) {
253
+ saveConfig({ ...config, onboardingComplete: true, onboardingStep: effectiveUserType === "worker" ? 7 : 6 });
254
+ console.log(`\n${sym.check} ${c.success("You're all set!")} Run ${c.highlight("jobarbiter status")} to check your account.\n`);
255
+ prompt.close();
256
+ return;
257
+ }
258
+ } else {
259
+ const continueFromStep = await prompt.confirm(`Continue from step ${firstIncomplete}?`);
260
+ if (!continueFromStep) {
261
+ // Let them start from step 2
262
+ console.log(c.dim("Starting from the beginning...\n"));
263
+ if (effectiveUserType === "worker") {
264
+ await runWorkerFlow(prompt, state as OnboardState, 2);
265
+ } else {
266
+ await runEmployerFlow(prompt, state as OnboardState);
267
+ }
268
+ prompt.close();
269
+ return;
270
+ }
271
+ }
272
+
273
+ // Resume from firstIncomplete
274
+ if (effectiveUserType === "worker") {
275
+ await runWorkerFlow(prompt, state as OnboardState, firstIncomplete);
276
+ } else {
277
+ await runEmployerFlow(prompt, state as OnboardState);
278
+ }
279
+ prompt.close();
280
+ return;
281
+ }
282
+
283
+ if (effectiveUserType === "worker") {
233
284
  await runWorkerFlow(prompt, state as OnboardState);
234
285
  } else {
235
286
  await runEmployerFlow(prompt, state as OnboardState);
@@ -282,7 +333,7 @@ async function handleEmailVerification(
282
333
  prompt: Prompt,
283
334
  baseUrl: string,
284
335
  userType: "worker" | "employer"
285
- ): Promise<{ email: string; apiKey: string; userId: string }> {
336
+ ): Promise<{ email: string; apiKey: string; userId: string; isReturningUser: boolean; userType?: string }> {
286
337
  // Workers: 1) Account, 2) Tool Detection, 3) AI Accounts, 4) Domains, 5) GitHub, 6) LinkedIn, 7) Done
287
338
  // Employers: 1) Account, 2) (skip verification), 3) Company, 4) Domain, 5) What You Need, 6) Done (stays at 6)
288
339
  const totalSteps = userType === "employer" ? 6 : 7;
@@ -300,6 +351,8 @@ async function handleEmailVerification(
300
351
  // Call register API
301
352
  console.log(c.dim("\nSending verification code..."));
302
353
 
354
+ let isReturningUser = false;
355
+
303
356
  try {
304
357
  await apiUnauthenticated(baseUrl, "POST", "/v1/auth/register", {
305
358
  email,
@@ -307,37 +360,53 @@ async function handleEmailVerification(
307
360
  });
308
361
  } catch (err) {
309
362
  if (err instanceof ApiError && err.status === 409) {
310
- // Email already registered and verified
311
- throw new Error(`This email is already registered. Run 'jobarbiter verify-email --email ${email}' if you need to re-verify, or use a different email.`);
363
+ // Email already registered switch to login flow
364
+ isReturningUser = true;
365
+ console.log(`\n${sym.rocket} ${c.bold("Welcome back!")} Sending verification code...`);
366
+ try {
367
+ await apiUnauthenticated(baseUrl, "POST", "/v1/auth/login", { email });
368
+ } catch (loginErr) {
369
+ throw new Error("Could not send login verification code. Please try again later.");
370
+ }
371
+ } else {
372
+ throw err;
312
373
  }
313
- throw err;
314
374
  }
315
375
 
316
- console.log(`\n${sym.check} Verification code sent to ${c.highlight(email)}`);
376
+ if (!isReturningUser) {
377
+ console.log(`\n${sym.check} Verification code sent to ${c.highlight(email)}`);
378
+ } else {
379
+ console.log(`${sym.check} Verification code sent to ${c.highlight(email)}`);
380
+ }
317
381
  console.log(c.dim(" (Check your inbox and spam folder. Code expires in 15 minutes.)\n"));
318
382
 
319
383
  // Get verification code
320
384
  let apiKey: string | undefined;
321
385
  let userId: string | undefined;
386
+ let returnedUserType: string | undefined;
322
387
  let attempts = 0;
323
388
  const maxAttempts = 5;
389
+ const verifyEndpoint = isReturningUser ? "/v1/auth/verify-login" : "/v1/auth/verify";
324
390
 
325
391
  while (attempts < maxAttempts) {
326
392
  const code = await prompt.question(`Enter 6-digit code: `);
327
-
393
+
328
394
  if (!code || code.length !== 6) {
329
395
  console.log(c.error("Code must be 6 digits"));
330
396
  continue;
331
397
  }
332
398
 
333
399
  try {
334
- const result = await apiUnauthenticated(baseUrl, "POST", "/v1/auth/verify", {
400
+ const result = await apiUnauthenticated(baseUrl, "POST", verifyEndpoint, {
335
401
  email,
336
402
  code: code.trim(),
337
403
  });
338
404
 
339
405
  apiKey = result.apiKey as string;
340
406
  userId = result.id as string;
407
+ if (isReturningUser) {
408
+ returnedUserType = result.userType as string;
409
+ }
341
410
  break;
342
411
  } catch (err) {
343
412
  attempts++;
@@ -358,9 +427,125 @@ async function handleEmailVerification(
358
427
  throw new Error("Verification failed. Please try again.");
359
428
  }
360
429
 
361
- console.log(`\n${sym.check} ${c.success("Email verified! Account created.")}\n`);
430
+ if (isReturningUser) {
431
+ console.log(`\n${sym.check} ${c.success("Welcome back! Logged in successfully.")}\n`);
432
+ } else {
433
+ console.log(`\n${sym.check} ${c.success("Email verified! Account created.")}\n`);
434
+ }
435
+
436
+ return { email, apiKey, userId, isReturningUser, userType: returnedUserType };
437
+ }
438
+
439
+ // ── Returning User Progress ─────────────────────────────────────────────
362
440
 
363
- return { email, apiKey, userId };
441
+ interface OnboardProgress {
442
+ accountCreated: boolean;
443
+ userType: "worker" | "employer";
444
+ toolsDetected: string[];
445
+ aiAccountsConnected: boolean;
446
+ domainsSet: string[];
447
+ githubConnected: boolean;
448
+ linkedinConnected: boolean;
449
+ bio?: string;
450
+ githubUsername?: string;
451
+ }
452
+
453
+ async function fetchOnboardingProgress(config: Config): Promise<OnboardProgress> {
454
+ const progress: OnboardProgress = {
455
+ accountCreated: true,
456
+ userType: config.userType as "worker" | "employer",
457
+ toolsDetected: [],
458
+ aiAccountsConnected: false,
459
+ domainsSet: [],
460
+ githubConnected: false,
461
+ linkedinConnected: false,
462
+ };
463
+
464
+ // Fetch profile
465
+ try {
466
+ const profile = await api(config, "GET", "/v1/profile");
467
+ if (profile.domains && Array.isArray(profile.domains) && (profile.domains as string[]).length > 0) {
468
+ progress.domainsSet = profile.domains as string[];
469
+ }
470
+ if (profile.tools && typeof profile.tools === "object") {
471
+ const tools = profile.tools as Record<string, unknown>;
472
+ if (tools.primary && Array.isArray(tools.primary) && (tools.primary as string[]).length > 0) {
473
+ progress.toolsDetected = tools.primary as string[];
474
+ }
475
+ }
476
+ if (profile.bio) {
477
+ progress.bio = profile.bio as string;
478
+ }
479
+ if (profile.githubUsername) {
480
+ progress.githubConnected = true;
481
+ progress.githubUsername = profile.githubUsername as string;
482
+ }
483
+ } catch {
484
+ // Profile doesn't exist yet — that's fine
485
+ }
486
+
487
+ // Check AI accounts
488
+ const existingProviders = loadProviderKeys();
489
+ progress.aiAccountsConnected = existingProviders.length > 0;
490
+
491
+ // Check verification status (LinkedIn etc.)
492
+ try {
493
+ const verificationStatus = await api(config, "GET", "/v1/verification/status");
494
+ if (verificationStatus.linkedin) {
495
+ progress.linkedinConnected = true;
496
+ }
497
+ } catch {
498
+ // Endpoint may not exist — gracefully ignore
499
+ }
500
+
501
+ return progress;
502
+ }
503
+
504
+ function showReturningUserProgress(progress: OnboardProgress): { firstIncomplete: number } {
505
+ const isWorker = progress.userType === "worker";
506
+ const totalSteps = isWorker ? 7 : 6;
507
+
508
+ console.log(`${c.bold("Here's your onboarding progress:")}\n`);
509
+
510
+ if (isWorker) {
511
+ const steps: Array<{ done: boolean; label: string }> = [
512
+ { done: true, label: `Account created (${progress.userType})` },
513
+ { done: progress.toolsDetected.length > 0, label: progress.toolsDetected.length > 0
514
+ ? `AI Tools detected (${progress.toolsDetected.join(", ")})`
515
+ : "AI Tools not detected" },
516
+ { done: progress.aiAccountsConnected, label: progress.aiAccountsConnected
517
+ ? "AI Accounts connected"
518
+ : "AI Accounts not connected" },
519
+ { done: progress.domainsSet.length > 0, label: progress.domainsSet.length > 0
520
+ ? `Domains set (${progress.domainsSet.join(", ")})`
521
+ : "Domains not set" },
522
+ { done: progress.githubConnected, label: progress.githubConnected
523
+ ? `GitHub connected (${progress.githubUsername})`
524
+ : "GitHub not connected" },
525
+ { done: progress.linkedinConnected, label: progress.linkedinConnected
526
+ ? "LinkedIn connected"
527
+ : "LinkedIn not connected" },
528
+ { done: false, label: "Completion" },
529
+ ];
530
+
531
+ let firstIncomplete = totalSteps; // default to last
532
+ for (let i = 0; i < steps.length; i++) {
533
+ const step = steps[i];
534
+ const icon = step.done ? sym.check : sym.cross;
535
+ console.log(` ${icon} Step ${i + 1}/${totalSteps} — ${step.label}`);
536
+ if (!step.done && firstIncomplete === totalSteps) {
537
+ firstIncomplete = i + 1;
538
+ }
539
+ }
540
+ console.log();
541
+ return { firstIncomplete };
542
+ } else {
543
+ // Employer — simpler progress
544
+ console.log(` ${sym.check} Step 1/6 — Account created (employer)`);
545
+ console.log(` ${sym.cross} Step 2/6 — Remaining setup`);
546
+ console.log();
547
+ return { firstIncomplete: 2 };
548
+ }
364
549
  }
365
550
 
366
551
  // ── Worker Flow ────────────────────────────────────────────────────────
@@ -580,6 +765,24 @@ async function runToolDetectionStep(
580
765
 
581
766
  if (result.installed.length > 0) {
582
767
  console.log(`\n ${sym.check} ${c.success(`${result.installed.length} observer${result.installed.length > 1 ? "s" : ""} installed!`)}`);
768
+
769
+ // Install background polling daemon
770
+ try {
771
+ installDaemon(7200);
772
+ console.log(` ${sym.check} ${c.success("Background poller installed (every 2 hours)")}`);
773
+ } catch {
774
+ console.log(` ${c.dim("Background poller skipped (install later: jobarbiter observe daemon install)")}`);
775
+ }
776
+
777
+ // Show poller-observable tools
778
+ const pollerTools = allTools.filter((t) => t.installed && t.observationMethod === "poller");
779
+ if (pollerTools.length > 0) {
780
+ console.log(`\n ${c.bold("Poller will also scan these tools:")}`);
781
+ for (const t of pollerTools) {
782
+ console.log(` ${sym.bullet} ${formatToolDisplay(t)}`);
783
+ }
784
+ }
785
+
583
786
  console.log(`\n ${c.bold("What happens now?")}`);
584
787
  console.log(` Observers activate each time you use your AI tools.`);
585
788
  console.log(` Just work normally — every session builds your profile.\n`);
@@ -588,6 +791,8 @@ async function runToolDetectionStep(
588
791
  console.log(` a work report to JobArbiter. Our agents interpret these reports`);
589
792
  console.log(` to build and evolve your narrative profile over time.`);
590
793
  console.log(` Raw session data never leaves your machine.\n`);
794
+
795
+ console.log(` ${c.dim("Manage observers: jobarbiter observe --help")}`);
591
796
  }
592
797
  } else {
593
798
  console.log(c.dim("\n Skipped — you can install observers later with 'jobarbiter observe install'.\n"));
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Poll State Management
3
+ *
4
+ * Read/write ~/.config/jobarbiter/observer/poll-state.json
5
+ * Tracks per-source poll times, known sessions, pause windows,
6
+ * disabled sources, and interval configuration.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────
14
+
15
+ export interface PauseWindow {
16
+ start: string; // ISO timestamp
17
+ end: string; // ISO timestamp
18
+ }
19
+
20
+ export interface PollState {
21
+ version: number;
22
+ lastPoll: Record<string, string>; // source -> ISO timestamp
23
+ knownSessionIds: string[]; // rolling 1000
24
+ pauseWindows: PauseWindow[];
25
+ disabledSources: string[];
26
+ interval: number; // seconds
27
+ }
28
+
29
+ // ── Paths ──────────────────────────────────────────────────────────────
30
+
31
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
32
+ const POLL_STATE_FILE = join(OBSERVER_DIR, "poll-state.json");
33
+
34
+ function defaultPollState(): PollState {
35
+ return {
36
+ version: 1,
37
+ lastPoll: {},
38
+ knownSessionIds: [],
39
+ pauseWindows: [],
40
+ disabledSources: [],
41
+ interval: 7200,
42
+ };
43
+ }
44
+
45
+ // ── Core Functions ─────────────────────────────────────────────────────
46
+
47
+ export function loadPollState(): PollState {
48
+ try {
49
+ if (!existsSync(POLL_STATE_FILE)) return defaultPollState();
50
+ const raw = readFileSync(POLL_STATE_FILE, "utf-8");
51
+ const data = JSON.parse(raw) as Partial<PollState>;
52
+ return {
53
+ ...defaultPollState(),
54
+ ...data,
55
+ };
56
+ } catch {
57
+ return defaultPollState();
58
+ }
59
+ }
60
+
61
+ export function savePollState(state: PollState): void {
62
+ mkdirSync(OBSERVER_DIR, { recursive: true });
63
+ writeFileSync(POLL_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
64
+ }
65
+
66
+ export function addPauseWindow(start: string, end: string): void {
67
+ const state = loadPollState();
68
+ state.pauseWindows.push({ start, end });
69
+ // Keep last 50 windows
70
+ if (state.pauseWindows.length > 50) {
71
+ state.pauseWindows = state.pauseWindows.slice(-50);
72
+ }
73
+ savePollState(state);
74
+ }
75
+
76
+ export function isInPauseWindow(timestamp: string): boolean {
77
+ const state = loadPollState();
78
+ const ts = new Date(timestamp).getTime();
79
+ return state.pauseWindows.some((w) => {
80
+ const start = new Date(w.start).getTime();
81
+ const end = new Date(w.end).getTime();
82
+ return ts >= start && ts <= end;
83
+ });
84
+ }
85
+
86
+ export function addKnownSession(sessionId: string): void {
87
+ const state = loadPollState();
88
+ if (!state.knownSessionIds.includes(sessionId)) {
89
+ state.knownSessionIds.push(sessionId);
90
+ // Rolling window of 1000
91
+ if (state.knownSessionIds.length > 1000) {
92
+ state.knownSessionIds = state.knownSessionIds.slice(-1000);
93
+ }
94
+ savePollState(state);
95
+ }
96
+ }
97
+
98
+ export function isKnownSession(sessionId: string): boolean {
99
+ const state = loadPollState();
100
+ return state.knownSessionIds.includes(sessionId);
101
+ }
102
+
103
+ export function getDisabledSources(): string[] {
104
+ return loadPollState().disabledSources;
105
+ }
106
+
107
+ export function setDisabledSource(source: string): void {
108
+ const state = loadPollState();
109
+ if (!state.disabledSources.includes(source)) {
110
+ state.disabledSources.push(source);
111
+ savePollState(state);
112
+ }
113
+ }
114
+
115
+ export function removeDisabledSource(source: string): void {
116
+ const state = loadPollState();
117
+ state.disabledSources = state.disabledSources.filter((s) => s !== source);
118
+ savePollState(state);
119
+ }