@shykaruu/jarvis-brain 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.
- package/LICENSE +153 -0
- package/README.md +428 -0
- package/bin/jarvis.ts +449 -0
- package/package.json +79 -0
- package/roles/activity-observer.yaml +60 -0
- package/roles/ceo-founder.yaml +144 -0
- package/roles/chief-of-staff.yaml +158 -0
- package/roles/dev-lead.yaml +182 -0
- package/roles/executive-assistant.yaml +77 -0
- package/roles/marketing-director.yaml +168 -0
- package/roles/personal-assistant.yaml +266 -0
- package/roles/research-specialist.yaml +60 -0
- package/roles/specialists/content-writer.yaml +53 -0
- package/roles/specialists/customer-support.yaml +57 -0
- package/roles/specialists/data-analyst.yaml +57 -0
- package/roles/specialists/financial-analyst.yaml +56 -0
- package/roles/specialists/hr-specialist.yaml +55 -0
- package/roles/specialists/legal-advisor.yaml +58 -0
- package/roles/specialists/marketing-strategist.yaml +56 -0
- package/roles/specialists/project-coordinator.yaml +55 -0
- package/roles/specialists/research-analyst.yaml +58 -0
- package/roles/specialists/software-engineer.yaml +57 -0
- package/roles/specialists/system-administrator.yaml +57 -0
- package/roles/system-admin.yaml +76 -0
- package/scripts/ensure-bun.cjs +16 -0
- package/src/actions/README.md +421 -0
- package/src/actions/app-control/desktop-controller.test.ts +26 -0
- package/src/actions/app-control/desktop-controller.ts +438 -0
- package/src/actions/app-control/interface.ts +64 -0
- package/src/actions/app-control/linux.ts +273 -0
- package/src/actions/app-control/macos.ts +54 -0
- package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
- package/src/actions/app-control/sidecar-launcher.ts +286 -0
- package/src/actions/app-control/windows.ts +44 -0
- package/src/actions/browser/cdp.ts +138 -0
- package/src/actions/browser/chrome-launcher.ts +261 -0
- package/src/actions/browser/session.ts +506 -0
- package/src/actions/browser/stealth.ts +49 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/terminal/executor.ts +157 -0
- package/src/actions/terminal/wsl-bridge.ts +126 -0
- package/src/actions/test.ts +93 -0
- package/src/actions/tools/agents.ts +363 -0
- package/src/actions/tools/builtin.ts +950 -0
- package/src/actions/tools/commitments.ts +192 -0
- package/src/actions/tools/content.ts +217 -0
- package/src/actions/tools/delegate.ts +147 -0
- package/src/actions/tools/desktop.test.ts +55 -0
- package/src/actions/tools/desktop.ts +305 -0
- package/src/actions/tools/documents.ts +169 -0
- package/src/actions/tools/goals.ts +376 -0
- package/src/actions/tools/local-tools-guard.ts +31 -0
- package/src/actions/tools/registry.ts +173 -0
- package/src/actions/tools/research.ts +111 -0
- package/src/actions/tools/sidecar-list.ts +57 -0
- package/src/actions/tools/sidecar-route.ts +105 -0
- package/src/actions/tools/workflows.ts +216 -0
- package/src/agents/agent.ts +132 -0
- package/src/agents/delegation.ts +107 -0
- package/src/agents/hierarchy.ts +113 -0
- package/src/agents/index.ts +19 -0
- package/src/agents/messaging.ts +125 -0
- package/src/agents/orchestrator.ts +592 -0
- package/src/agents/role-discovery.ts +61 -0
- package/src/agents/sub-agent-runner.ts +309 -0
- package/src/agents/task-manager.ts +151 -0
- package/src/authority/approval-delivery.ts +59 -0
- package/src/authority/approval.ts +196 -0
- package/src/authority/audit.ts +158 -0
- package/src/authority/authority.test.ts +519 -0
- package/src/authority/deferred-executor.ts +103 -0
- package/src/authority/emergency.ts +66 -0
- package/src/authority/engine.ts +301 -0
- package/src/authority/index.ts +12 -0
- package/src/authority/learning.ts +111 -0
- package/src/authority/tool-action-map.ts +74 -0
- package/src/awareness/analytics.ts +466 -0
- package/src/awareness/awareness.test.ts +332 -0
- package/src/awareness/capture-engine.ts +305 -0
- package/src/awareness/context-graph.ts +130 -0
- package/src/awareness/context-tracker.ts +349 -0
- package/src/awareness/index.ts +25 -0
- package/src/awareness/intelligence.ts +321 -0
- package/src/awareness/ocr-engine.ts +88 -0
- package/src/awareness/service.ts +528 -0
- package/src/awareness/struggle-detector.ts +342 -0
- package/src/awareness/suggestion-engine.ts +476 -0
- package/src/awareness/types.ts +201 -0
- package/src/cli/autostart.ts +417 -0
- package/src/cli/deps.ts +449 -0
- package/src/cli/doctor.ts +238 -0
- package/src/cli/helpers.ts +401 -0
- package/src/cli/onboard.ts +827 -0
- package/src/cli/uninstall.test.ts +37 -0
- package/src/cli/uninstall.ts +202 -0
- package/src/comms/README.md +329 -0
- package/src/comms/auth-error.html +48 -0
- package/src/comms/channels/discord.ts +228 -0
- package/src/comms/channels/signal.ts +56 -0
- package/src/comms/channels/telegram.ts +316 -0
- package/src/comms/channels/whatsapp.ts +60 -0
- package/src/comms/channels.test.ts +173 -0
- package/src/comms/dashboard-auth.ts +75 -0
- package/src/comms/desktop-notify.ts +114 -0
- package/src/comms/example.ts +129 -0
- package/src/comms/index.ts +129 -0
- package/src/comms/streaming.ts +149 -0
- package/src/comms/voice.test.ts +504 -0
- package/src/comms/voice.ts +341 -0
- package/src/comms/websocket.test.ts +409 -0
- package/src/comms/websocket.ts +669 -0
- package/src/config/README.md +389 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +183 -0
- package/src/config/loader.ts +148 -0
- package/src/config/types.ts +293 -0
- package/src/daemon/README.md +232 -0
- package/src/daemon/agent-service-interface.ts +9 -0
- package/src/daemon/agent-service.ts +667 -0
- package/src/daemon/api-routes.ts +3067 -0
- package/src/daemon/background-agent-service.ts +396 -0
- package/src/daemon/background-agent.test.ts +78 -0
- package/src/daemon/channel-service.ts +201 -0
- package/src/daemon/commitment-executor.ts +297 -0
- package/src/daemon/dashboard-auth.test.ts +170 -0
- package/src/daemon/event-classifier.ts +239 -0
- package/src/daemon/event-coalescer.ts +123 -0
- package/src/daemon/event-reactor.ts +214 -0
- package/src/daemon/flock.c +7 -0
- package/src/daemon/health.ts +220 -0
- package/src/daemon/index.ts +1070 -0
- package/src/daemon/llm-settings.test.ts +78 -0
- package/src/daemon/llm-settings.ts +450 -0
- package/src/daemon/observer-service.ts +150 -0
- package/src/daemon/pid.test.ts +283 -0
- package/src/daemon/pid.ts +224 -0
- package/src/daemon/research-queue.ts +155 -0
- package/src/daemon/services.ts +175 -0
- package/src/daemon/ws-service.ts +926 -0
- package/src/global.d.ts +4 -0
- package/src/goals/accountability.ts +240 -0
- package/src/goals/awareness-bridge.ts +185 -0
- package/src/goals/estimator.ts +185 -0
- package/src/goals/events.ts +28 -0
- package/src/goals/goals.test.ts +400 -0
- package/src/goals/integration.test.ts +329 -0
- package/src/goals/nl-builder.test.ts +220 -0
- package/src/goals/nl-builder.ts +256 -0
- package/src/goals/rhythm.test.ts +177 -0
- package/src/goals/rhythm.ts +275 -0
- package/src/goals/service.test.ts +135 -0
- package/src/goals/service.ts +407 -0
- package/src/goals/types.ts +106 -0
- package/src/goals/workflow-bridge.ts +96 -0
- package/src/integrations/google-api.ts +134 -0
- package/src/integrations/google-auth.ts +175 -0
- package/src/llm/README.md +291 -0
- package/src/llm/anthropic.ts +400 -0
- package/src/llm/gemini.ts +380 -0
- package/src/llm/groq.ts +406 -0
- package/src/llm/history.ts +147 -0
- package/src/llm/index.ts +21 -0
- package/src/llm/manager.ts +226 -0
- package/src/llm/ollama.ts +316 -0
- package/src/llm/openai.ts +411 -0
- package/src/llm/openrouter.ts +390 -0
- package/src/llm/provider.test.ts +487 -0
- package/src/llm/provider.ts +61 -0
- package/src/llm/test.ts +88 -0
- package/src/observers/README.md +278 -0
- package/src/observers/calendar.ts +113 -0
- package/src/observers/clipboard.ts +136 -0
- package/src/observers/email.ts +109 -0
- package/src/observers/example.ts +58 -0
- package/src/observers/file-watcher.ts +124 -0
- package/src/observers/index.ts +159 -0
- package/src/observers/notifications.ts +197 -0
- package/src/observers/observers.test.ts +203 -0
- package/src/observers/processes.ts +225 -0
- package/src/personality/README.md +61 -0
- package/src/personality/adapter.ts +196 -0
- package/src/personality/index.ts +20 -0
- package/src/personality/learner.ts +209 -0
- package/src/personality/model.ts +132 -0
- package/src/personality/personality.test.ts +236 -0
- package/src/roles/README.md +252 -0
- package/src/roles/authority.ts +120 -0
- package/src/roles/example-usage.ts +198 -0
- package/src/roles/index.ts +42 -0
- package/src/roles/loader.ts +143 -0
- package/src/roles/prompt-builder.ts +218 -0
- package/src/roles/test-multi.ts +102 -0
- package/src/roles/test-role.yaml +77 -0
- package/src/roles/test-utils.ts +93 -0
- package/src/roles/test.ts +106 -0
- package/src/roles/tool-guide.ts +195 -0
- package/src/roles/types.ts +36 -0
- package/src/roles/utils.ts +200 -0
- package/src/scripts/google-setup.ts +168 -0
- package/src/sidecar/connection.ts +179 -0
- package/src/sidecar/index.ts +6 -0
- package/src/sidecar/manager.ts +542 -0
- package/src/sidecar/protocol.ts +85 -0
- package/src/sidecar/rpc.ts +161 -0
- package/src/sidecar/scheduler.ts +136 -0
- package/src/sidecar/types.ts +112 -0
- package/src/sidecar/validator.ts +144 -0
- package/src/sites/builder-tools.ts +215 -0
- package/src/sites/dev-server-manager.ts +286 -0
- package/src/sites/fixtures/security-test-site/.jarvis-project.json +6 -0
- package/src/sites/fixtures/security-test-site/Makefile +15 -0
- package/src/sites/fixtures/security-test-site/README.md +18 -0
- package/src/sites/fixtures/security-test-site/index.html +12 -0
- package/src/sites/fixtures/security-test-site/index.ts +16 -0
- package/src/sites/fixtures/security-test-site/package.json +13 -0
- package/src/sites/fixtures/security-test-site/src/app.tsx +780 -0
- package/src/sites/fixtures/security-test-site/tsconfig.json +10 -0
- package/src/sites/git-manager.ts +240 -0
- package/src/sites/github-manager.ts +355 -0
- package/src/sites/index.ts +25 -0
- package/src/sites/project-manager.ts +389 -0
- package/src/sites/proxy.ts +133 -0
- package/src/sites/service.ts +136 -0
- package/src/sites/templates.ts +169 -0
- package/src/sites/types.ts +89 -0
- package/src/user/profile-followup.test.ts +84 -0
- package/src/user/profile-followup.ts +185 -0
- package/src/user/profile.ts +224 -0
- package/src/vault/README.md +110 -0
- package/src/vault/awareness.ts +341 -0
- package/src/vault/commitments.ts +299 -0
- package/src/vault/content-pipeline.ts +270 -0
- package/src/vault/conversations.ts +173 -0
- package/src/vault/dashboard-sessions.ts +44 -0
- package/src/vault/documents.ts +130 -0
- package/src/vault/entities.ts +185 -0
- package/src/vault/extractor.test.ts +356 -0
- package/src/vault/extractor.ts +345 -0
- package/src/vault/facts.ts +190 -0
- package/src/vault/goals.ts +477 -0
- package/src/vault/index.ts +87 -0
- package/src/vault/keychain.ts +99 -0
- package/src/vault/observations.ts +115 -0
- package/src/vault/relationships.ts +178 -0
- package/src/vault/retrieval.test.ts +139 -0
- package/src/vault/retrieval.ts +258 -0
- package/src/vault/schema.ts +709 -0
- package/src/vault/settings.ts +38 -0
- package/src/vault/user-profile.test.ts +113 -0
- package/src/vault/user-profile.ts +176 -0
- package/src/vault/vectors.ts +92 -0
- package/src/vault/webapp-template-seeds.ts +116 -0
- package/src/vault/webapp-templates.ts +244 -0
- package/src/vault/workflows.ts +403 -0
- package/src/workflows/auto-suggest.ts +290 -0
- package/src/workflows/engine.ts +366 -0
- package/src/workflows/events.ts +24 -0
- package/src/workflows/executor.ts +207 -0
- package/src/workflows/nl-builder.ts +198 -0
- package/src/workflows/nodes/actions/agent-task.ts +73 -0
- package/src/workflows/nodes/actions/calendar-action.ts +85 -0
- package/src/workflows/nodes/actions/code-execution.ts +73 -0
- package/src/workflows/nodes/actions/discord.ts +77 -0
- package/src/workflows/nodes/actions/file-write.ts +73 -0
- package/src/workflows/nodes/actions/gmail.ts +69 -0
- package/src/workflows/nodes/actions/http-request.ts +117 -0
- package/src/workflows/nodes/actions/notification.ts +85 -0
- package/src/workflows/nodes/actions/run-tool.ts +55 -0
- package/src/workflows/nodes/actions/send-message.ts +82 -0
- package/src/workflows/nodes/actions/shell-command.ts +76 -0
- package/src/workflows/nodes/actions/telegram.ts +60 -0
- package/src/workflows/nodes/builtin.ts +119 -0
- package/src/workflows/nodes/error/error-handler.ts +37 -0
- package/src/workflows/nodes/error/fallback.ts +47 -0
- package/src/workflows/nodes/error/retry.ts +82 -0
- package/src/workflows/nodes/logic/delay.ts +42 -0
- package/src/workflows/nodes/logic/if-else.ts +41 -0
- package/src/workflows/nodes/logic/loop.ts +90 -0
- package/src/workflows/nodes/logic/merge.ts +38 -0
- package/src/workflows/nodes/logic/race.ts +40 -0
- package/src/workflows/nodes/logic/switch.ts +59 -0
- package/src/workflows/nodes/logic/template-render.ts +53 -0
- package/src/workflows/nodes/logic/variable-get.ts +37 -0
- package/src/workflows/nodes/logic/variable-set.ts +59 -0
- package/src/workflows/nodes/registry.ts +99 -0
- package/src/workflows/nodes/transform/aggregate.ts +99 -0
- package/src/workflows/nodes/transform/csv-parse.ts +70 -0
- package/src/workflows/nodes/transform/json-parse.ts +63 -0
- package/src/workflows/nodes/transform/map-filter.ts +84 -0
- package/src/workflows/nodes/transform/regex-match.ts +89 -0
- package/src/workflows/nodes/triggers/calendar.ts +33 -0
- package/src/workflows/nodes/triggers/clipboard.ts +32 -0
- package/src/workflows/nodes/triggers/cron.ts +40 -0
- package/src/workflows/nodes/triggers/email.ts +40 -0
- package/src/workflows/nodes/triggers/file-change.ts +45 -0
- package/src/workflows/nodes/triggers/git.ts +46 -0
- package/src/workflows/nodes/triggers/manual.ts +23 -0
- package/src/workflows/nodes/triggers/poll.ts +81 -0
- package/src/workflows/nodes/triggers/process.ts +44 -0
- package/src/workflows/nodes/triggers/screen-event.ts +37 -0
- package/src/workflows/nodes/triggers/webhook.ts +39 -0
- package/src/workflows/safe-eval.ts +139 -0
- package/src/workflows/template.ts +118 -0
- package/src/workflows/triggers/cron.ts +311 -0
- package/src/workflows/triggers/manager.ts +285 -0
- package/src/workflows/triggers/observer-bridge.ts +172 -0
- package/src/workflows/triggers/poller.ts +201 -0
- package/src/workflows/triggers/screen-condition.ts +218 -0
- package/src/workflows/triggers/triggers.test.ts +740 -0
- package/src/workflows/triggers/webhook.ts +191 -0
- package/src/workflows/types.ts +133 -0
- package/src/workflows/variables.ts +72 -0
- package/src/workflows/workflows.test.ts +383 -0
- package/src/workflows/yaml.ts +104 -0
- package/ui/dist/index-3gr23jt9.js +112614 -0
- package/ui/dist/index-9vmj8127.css +14239 -0
- package/ui/dist/index-hy9pc1gm.js +112873 -0
- package/ui/dist/index-j2ep5d1w.js +112374 -0
- package/ui/dist/index-jt00vjqs.js +112858 -0
- package/ui/dist/index-k9ymx5qb.js +112374 -0
- package/ui/dist/index.html +16 -0
- package/ui/public/audio/pcm-capture-processor.js +11 -0
- package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
- package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
- package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
- package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
- package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import {
|
|
6
|
+
acquireLock,
|
|
7
|
+
isLocked,
|
|
8
|
+
releaseLock,
|
|
9
|
+
readPid,
|
|
10
|
+
getPidPath,
|
|
11
|
+
getLogPath,
|
|
12
|
+
getLogDir,
|
|
13
|
+
} from './pid.ts';
|
|
14
|
+
|
|
15
|
+
const JARVIS_DIR = join(homedir(), '.jarvis');
|
|
16
|
+
const LOCK_PATH = join(JARVIS_DIR, 'jarvis.pid');
|
|
17
|
+
const PID_MODULE = join(import.meta.dir, 'pid.ts');
|
|
18
|
+
const READY_SIGNAL = join(tmpdir(), 'jarvis-test-lock-ready');
|
|
19
|
+
const HOLDER_SCRIPT = join(tmpdir(), 'jarvis-test-lock-holder.ts');
|
|
20
|
+
|
|
21
|
+
function cleanup(): void {
|
|
22
|
+
releaseLock();
|
|
23
|
+
try { unlinkSync(READY_SIGNAL); } catch {}
|
|
24
|
+
try { unlinkSync(HOLDER_SCRIPT); } catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spawn a child process that acquires the flock and holds it until killed.
|
|
29
|
+
* Returns once the child has confirmed it holds the lock.
|
|
30
|
+
*/
|
|
31
|
+
async function spawnLockHolder(): Promise<{ proc: ReturnType<typeof Bun.spawn>; pid: number }> {
|
|
32
|
+
try { unlinkSync(READY_SIGNAL); } catch {}
|
|
33
|
+
|
|
34
|
+
writeFileSync(HOLDER_SCRIPT, `
|
|
35
|
+
import { acquireLock } from ${JSON.stringify(PID_MODULE)};
|
|
36
|
+
import { writeFileSync } from 'node:fs';
|
|
37
|
+
const ok = acquireLock(process.pid);
|
|
38
|
+
writeFileSync(${JSON.stringify(READY_SIGNAL)}, ok ? String(process.pid) : 'FAIL');
|
|
39
|
+
await Bun.sleep(60000);
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
const proc = Bun.spawn(['bun', HOLDER_SCRIPT], {
|
|
43
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < 50; i++) {
|
|
47
|
+
await Bun.sleep(100);
|
|
48
|
+
if (existsSync(READY_SIGNAL)) {
|
|
49
|
+
const content = readFileSync(READY_SIGNAL, 'utf-8').trim();
|
|
50
|
+
if (content === 'FAIL') {
|
|
51
|
+
proc.kill();
|
|
52
|
+
await proc.exited;
|
|
53
|
+
throw new Error('Child process failed to acquire lock');
|
|
54
|
+
}
|
|
55
|
+
return { proc, pid: parseInt(content, 10) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
proc.kill();
|
|
59
|
+
await proc.exited;
|
|
60
|
+
throw new Error('Timed out waiting for child to acquire lock');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Process Lock Manager', () => {
|
|
64
|
+
beforeEach(() => cleanup());
|
|
65
|
+
afterEach(() => cleanup());
|
|
66
|
+
|
|
67
|
+
// ── acquireLock ──────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe('acquireLock', () => {
|
|
70
|
+
test('returns true and creates lock file', () => {
|
|
71
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
72
|
+
expect(existsSync(LOCK_PATH)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('writes PID to lock file', () => {
|
|
76
|
+
acquireLock(process.pid);
|
|
77
|
+
const content = readFileSync(LOCK_PATH, 'utf-8').trim();
|
|
78
|
+
expect(content).toBe(String(process.pid));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('creates ~/.jarvis dir if missing', () => {
|
|
82
|
+
// Dir almost certainly exists already, but acquireLock should not fail
|
|
83
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('second acquire in same process returns false', () => {
|
|
87
|
+
// First fd holds an exclusive flock; second open+flock is denied
|
|
88
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
89
|
+
expect(acquireLock(process.pid)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── isLocked ─────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('isLocked', () => {
|
|
96
|
+
test('returns null when no lock file exists', () => {
|
|
97
|
+
expect(isLocked()).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns null for stale file (file exists, no lock held)', () => {
|
|
101
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
102
|
+
writeFileSync(LOCK_PATH, '99999');
|
|
103
|
+
// No flock held — probe should succeed → not locked
|
|
104
|
+
expect(isLocked()).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('returns PID when lock is held by this process', () => {
|
|
108
|
+
acquireLock(process.pid);
|
|
109
|
+
// isLocked opens a second fd; flock is denied because our fd holds it
|
|
110
|
+
expect(isLocked()).toBe(process.pid);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── releaseLock ──────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe('releaseLock', () => {
|
|
117
|
+
test('releases lock and removes file', () => {
|
|
118
|
+
acquireLock(process.pid);
|
|
119
|
+
expect(existsSync(LOCK_PATH)).toBe(true);
|
|
120
|
+
|
|
121
|
+
releaseLock();
|
|
122
|
+
expect(existsSync(LOCK_PATH)).toBe(false);
|
|
123
|
+
expect(isLocked()).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('is idempotent — safe to call without prior acquire', () => {
|
|
127
|
+
releaseLock();
|
|
128
|
+
releaseLock();
|
|
129
|
+
releaseLock();
|
|
130
|
+
// Should not throw
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('allows re-acquire after release', () => {
|
|
134
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
135
|
+
releaseLock();
|
|
136
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('multiple acquire/release cycles work', () => {
|
|
140
|
+
for (let i = 0; i < 5; i++) {
|
|
141
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
142
|
+
expect(isLocked()).toBe(process.pid);
|
|
143
|
+
releaseLock();
|
|
144
|
+
expect(isLocked()).toBeNull();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── readPid ──────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('readPid', () => {
|
|
152
|
+
test('returns null when no file exists', () => {
|
|
153
|
+
expect(readPid()).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('returns PID from file', () => {
|
|
157
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
158
|
+
writeFileSync(LOCK_PATH, '12345');
|
|
159
|
+
expect(readPid()).toBe(12345);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('trims whitespace', () => {
|
|
163
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
164
|
+
writeFileSync(LOCK_PATH, ' 42\n');
|
|
165
|
+
expect(readPid()).toBe(42);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('returns null for non-numeric content', () => {
|
|
169
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
170
|
+
writeFileSync(LOCK_PATH, 'not-a-pid');
|
|
171
|
+
expect(readPid()).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('returns null for empty file', () => {
|
|
175
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
176
|
+
writeFileSync(LOCK_PATH, '');
|
|
177
|
+
expect(readPid()).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('returns null for zero', () => {
|
|
181
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
182
|
+
writeFileSync(LOCK_PATH, '0');
|
|
183
|
+
expect(readPid()).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('returns null for negative PID', () => {
|
|
187
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
188
|
+
writeFileSync(LOCK_PATH, '-1');
|
|
189
|
+
expect(readPid()).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── path getters ─────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe('path getters', () => {
|
|
196
|
+
test('getPidPath returns ~/.jarvis/jarvis.pid', () => {
|
|
197
|
+
expect(getPidPath()).toBe(join(homedir(), '.jarvis', 'jarvis.pid'));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('getLogPath returns path and creates logs dir', () => {
|
|
201
|
+
const logPath = getLogPath();
|
|
202
|
+
expect(logPath).toBe(join(JARVIS_DIR, 'logs', 'jarvis.log'));
|
|
203
|
+
expect(existsSync(join(JARVIS_DIR, 'logs'))).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('getLogDir returns logs directory', () => {
|
|
207
|
+
expect(getLogDir()).toBe(join(JARVIS_DIR, 'logs'));
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── cross-process locking ────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe('cross-process locking', () => {
|
|
214
|
+
let childProc: ReturnType<typeof Bun.spawn> | null = null;
|
|
215
|
+
|
|
216
|
+
afterEach(async () => {
|
|
217
|
+
if (childProc) {
|
|
218
|
+
childProc.kill();
|
|
219
|
+
await childProc.exited;
|
|
220
|
+
childProc = null;
|
|
221
|
+
}
|
|
222
|
+
cleanup();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('isLocked detects lock held by another process', async () => {
|
|
226
|
+
const { proc, pid } = await spawnLockHolder();
|
|
227
|
+
childProc = proc;
|
|
228
|
+
|
|
229
|
+
const result = isLocked();
|
|
230
|
+
expect(result).toBe(pid);
|
|
231
|
+
}, { timeout: 15000 });
|
|
232
|
+
|
|
233
|
+
test('acquireLock fails when another process holds lock', async () => {
|
|
234
|
+
const { proc } = await spawnLockHolder();
|
|
235
|
+
childProc = proc;
|
|
236
|
+
|
|
237
|
+
expect(acquireLock(process.pid)).toBe(false);
|
|
238
|
+
}, { timeout: 15000 });
|
|
239
|
+
|
|
240
|
+
test('lock is released when holder is SIGKILLed', async () => {
|
|
241
|
+
const { proc } = await spawnLockHolder();
|
|
242
|
+
childProc = proc;
|
|
243
|
+
|
|
244
|
+
// Lock is held
|
|
245
|
+
expect(isLocked()).not.toBeNull();
|
|
246
|
+
|
|
247
|
+
// SIGKILL — OS closes all fds, releasing the flock
|
|
248
|
+
proc.kill(9);
|
|
249
|
+
await proc.exited;
|
|
250
|
+
childProc = null;
|
|
251
|
+
|
|
252
|
+
// Lock is now free
|
|
253
|
+
expect(isLocked()).toBeNull();
|
|
254
|
+
}, { timeout: 15000 });
|
|
255
|
+
|
|
256
|
+
test('can acquire lock after previous holder crashes', async () => {
|
|
257
|
+
const { proc } = await spawnLockHolder();
|
|
258
|
+
childProc = proc;
|
|
259
|
+
|
|
260
|
+
proc.kill(9);
|
|
261
|
+
await proc.exited;
|
|
262
|
+
childProc = null;
|
|
263
|
+
|
|
264
|
+
// New instance should succeed immediately
|
|
265
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
266
|
+
expect(readPid()).toBe(process.pid);
|
|
267
|
+
}, { timeout: 15000 });
|
|
268
|
+
|
|
269
|
+
test('lock survives SIGTERM of holder (graceful)', async () => {
|
|
270
|
+
const { proc, pid } = await spawnLockHolder();
|
|
271
|
+
childProc = proc;
|
|
272
|
+
|
|
273
|
+
// SIGTERM — child exits, OS releases flock
|
|
274
|
+
proc.kill(15);
|
|
275
|
+
await proc.exited;
|
|
276
|
+
childProc = null;
|
|
277
|
+
|
|
278
|
+
// Lock freed after process exits
|
|
279
|
+
expect(isLocked()).toBeNull();
|
|
280
|
+
expect(acquireLock(process.pid)).toBe(true);
|
|
281
|
+
}, { timeout: 15000 });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process Lock Manager for J.A.R.V.I.S. Daemon
|
|
3
|
+
*
|
|
4
|
+
* Uses flock()-based advisory locks to prevent duplicate daemon instances.
|
|
5
|
+
* Unlike PID-based checks, flock locks are automatically released by the OS
|
|
6
|
+
* when the process dies (even on SIGKILL, OOM, or crash), making this
|
|
7
|
+
* container-safe and race-free.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import {
|
|
13
|
+
constants,
|
|
14
|
+
existsSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
openSync,
|
|
19
|
+
closeSync,
|
|
20
|
+
writeSync,
|
|
21
|
+
ftruncateSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { cc } from 'bun:ffi';
|
|
24
|
+
import flockSource from './flock.c' with { type: 'file' };
|
|
25
|
+
|
|
26
|
+
const JARVIS_DIR = join(homedir(), '.jarvis');
|
|
27
|
+
const LOG_DIR = join(JARVIS_DIR, 'logs');
|
|
28
|
+
const LOCK_PATH = join(JARVIS_DIR, 'jarvis.pid');
|
|
29
|
+
const LOG_PATH = join(LOG_DIR, 'jarvis.log');
|
|
30
|
+
|
|
31
|
+
// ── flock() via Bun cc() ────────────────────────────────────────────
|
|
32
|
+
// Compiled at startup by Bun's embedded TinyCC. Resolves libc via
|
|
33
|
+
// system headers — works on Linux, macOS, and any POSIX platform
|
|
34
|
+
// without hardcoding a shared library path.
|
|
35
|
+
|
|
36
|
+
const isWindows = process.platform === 'win32';
|
|
37
|
+
|
|
38
|
+
let flock: any = null;
|
|
39
|
+
if (!isWindows) {
|
|
40
|
+
const lib = cc({
|
|
41
|
+
source: flockSource,
|
|
42
|
+
symbols: {
|
|
43
|
+
do_flock: { args: ['i32', 'i32'], returns: 'i32' },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
flock = lib.symbols;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LOCK_EX = 2; // Exclusive lock
|
|
50
|
+
const LOCK_NB = 4; // Non-blocking
|
|
51
|
+
const LOCK_UN = 8; // Unlock
|
|
52
|
+
|
|
53
|
+
// ── Lock state ───────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
// The open FD that holds the flock — kept alive for the process lifetime.
|
|
56
|
+
// When the process exits (normally, SIGKILL, OOM, crash), the OS closes it
|
|
57
|
+
// and the advisory lock is automatically released.
|
|
58
|
+
let lockFd: number | null = null;
|
|
59
|
+
|
|
60
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Acquire an exclusive lock on the lock file and write the PID.
|
|
64
|
+
* Returns true if the lock was acquired, false if another instance holds it.
|
|
65
|
+
*/
|
|
66
|
+
export function acquireLock(pid: number): boolean {
|
|
67
|
+
if (lockFd !== null) return false;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(JARVIS_DIR, { recursive: true });
|
|
71
|
+
|
|
72
|
+
if (isWindows) {
|
|
73
|
+
const existingPid = readPid();
|
|
74
|
+
if (existingPid !== null && existingPid !== pid) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(existingPid, 0);
|
|
77
|
+
return false;
|
|
78
|
+
} catch {
|
|
79
|
+
// Process not running, stale lock file
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Open (or create) the lock file — don't truncate before locking
|
|
85
|
+
const fd = openSync(LOCK_PATH, constants.O_WRONLY | constants.O_CREAT, 0o644);
|
|
86
|
+
|
|
87
|
+
// Try non-blocking exclusive lock
|
|
88
|
+
if (!isWindows) {
|
|
89
|
+
const result = flock.do_flock(fd, LOCK_EX | LOCK_NB);
|
|
90
|
+
if (result !== 0) {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Lock acquired — truncate and write PID for display purposes
|
|
97
|
+
ftruncateSync(fd, 0);
|
|
98
|
+
writeSync(fd, String(pid));
|
|
99
|
+
|
|
100
|
+
// Keep the FD open — closing it would release the lock
|
|
101
|
+
lockFd = fd;
|
|
102
|
+
return true;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error(`[PID] Failed to acquire lock: ${err}`);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if the daemon lock is currently held.
|
|
111
|
+
* Returns the PID if locked (daemon running), null otherwise.
|
|
112
|
+
*/
|
|
113
|
+
export function isLocked(): number | null {
|
|
114
|
+
if (!existsSync(LOCK_PATH)) return null;
|
|
115
|
+
|
|
116
|
+
let fd: number;
|
|
117
|
+
try {
|
|
118
|
+
fd = openSync(LOCK_PATH, constants.O_RDONLY);
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
if (!isWindows) {
|
|
125
|
+
// Try non-blocking exclusive lock to probe
|
|
126
|
+
const result = flock.do_flock(fd, LOCK_EX | LOCK_NB);
|
|
127
|
+
if (result === 0) {
|
|
128
|
+
// Lock acquired — no daemon running. Release immediately.
|
|
129
|
+
flock.do_flock(fd, LOCK_UN);
|
|
130
|
+
closeSync(fd);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Lock held by another process (or we are on Windows) — daemon is running
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
const pid = readPid();
|
|
138
|
+
|
|
139
|
+
if (isWindows && pid !== null) {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(pid, 0);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Container safety: if PID is 1 and we're inside a container,
|
|
148
|
+
// the lock file is stale from a previous container lifecycle
|
|
149
|
+
if (pid === 1 && isInsideContainer()) {
|
|
150
|
+
releaseLock();
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return pid;
|
|
155
|
+
} catch {
|
|
156
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Release the lock (close the FD) and remove the lock file.
|
|
163
|
+
*/
|
|
164
|
+
export function releaseLock(): void {
|
|
165
|
+
if (lockFd !== null) {
|
|
166
|
+
try {
|
|
167
|
+
closeSync(lockFd);
|
|
168
|
+
} catch {
|
|
169
|
+
// Already closed
|
|
170
|
+
}
|
|
171
|
+
lockFd = null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
if (existsSync(LOCK_PATH)) unlinkSync(LOCK_PATH);
|
|
175
|
+
} catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read the PID from the lock file. Returns null if no file or invalid content.
|
|
180
|
+
*/
|
|
181
|
+
export function readPid(): number | null {
|
|
182
|
+
if (!existsSync(LOCK_PATH)) return null;
|
|
183
|
+
try {
|
|
184
|
+
const content = readFileSync(LOCK_PATH, 'utf-8').trim();
|
|
185
|
+
const pid = parseInt(content, 10);
|
|
186
|
+
if (isNaN(pid) || pid <= 0) return null;
|
|
187
|
+
return pid;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get the lock file path (for display purposes).
|
|
195
|
+
*/
|
|
196
|
+
export function getPidPath(): string {
|
|
197
|
+
return LOCK_PATH;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get the log file path. Creates the log directory if needed.
|
|
202
|
+
*/
|
|
203
|
+
export function getLogPath(): string {
|
|
204
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
205
|
+
return LOG_PATH;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get the log directory path.
|
|
210
|
+
*/
|
|
211
|
+
export function getLogDir(): string {
|
|
212
|
+
return LOG_DIR;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function isInsideContainer(): boolean {
|
|
218
|
+
if (existsSync('/.dockerenv')) return true;
|
|
219
|
+
try {
|
|
220
|
+
return readFileSync('/proc/1/cgroup', 'utf-8').includes('docker');
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResearchQueue — Background Research Engine
|
|
3
|
+
*
|
|
4
|
+
* Manages a queue of topics for JARVIS to research during idle time.
|
|
5
|
+
* Topics come from conversations, explicit requests, or the agent itself.
|
|
6
|
+
* The heartbeat picks up the next topic when nothing urgent is happening.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ResearchPriority = 'high' | 'normal' | 'low';
|
|
10
|
+
export type ResearchStatus = 'queued' | 'in_progress' | 'completed' | 'failed';
|
|
11
|
+
|
|
12
|
+
export type ResearchTopic = {
|
|
13
|
+
id: string;
|
|
14
|
+
topic: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
source: string; // 'user' | 'agent' | 'conversation'
|
|
17
|
+
priority: ResearchPriority;
|
|
18
|
+
status: ResearchStatus;
|
|
19
|
+
result?: string;
|
|
20
|
+
failReason?: string;
|
|
21
|
+
created_at: number;
|
|
22
|
+
completed_at?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MAX_QUEUE_SIZE = 50;
|
|
26
|
+
|
|
27
|
+
export class ResearchQueue {
|
|
28
|
+
private topics: Map<string, ResearchTopic> = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add a topic to the research queue.
|
|
32
|
+
*/
|
|
33
|
+
addTopic(
|
|
34
|
+
topic: string,
|
|
35
|
+
reason: string,
|
|
36
|
+
source: string = 'user',
|
|
37
|
+
priority: ResearchPriority = 'normal'
|
|
38
|
+
): ResearchTopic {
|
|
39
|
+
// Enforce max queue size (drop oldest low-priority)
|
|
40
|
+
if (this.topics.size >= MAX_QUEUE_SIZE) {
|
|
41
|
+
this.evictOldest();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entry: ResearchTopic = {
|
|
45
|
+
id: crypto.randomUUID(),
|
|
46
|
+
topic,
|
|
47
|
+
reason,
|
|
48
|
+
source,
|
|
49
|
+
priority,
|
|
50
|
+
status: 'queued',
|
|
51
|
+
created_at: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.topics.set(entry.id, entry);
|
|
55
|
+
console.log(`[ResearchQueue] Added: "${topic}" (${priority}, from ${source})`);
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the next highest-priority queued topic.
|
|
61
|
+
*/
|
|
62
|
+
getNext(): ResearchTopic | null {
|
|
63
|
+
const queued = Array.from(this.topics.values())
|
|
64
|
+
.filter((t) => t.status === 'queued')
|
|
65
|
+
.sort((a, b) => {
|
|
66
|
+
// Priority order: high > normal > low
|
|
67
|
+
const pOrder: Record<ResearchPriority, number> = { high: 0, normal: 1, low: 2 };
|
|
68
|
+
const pDiff = pOrder[a.priority] - pOrder[b.priority];
|
|
69
|
+
if (pDiff !== 0) return pDiff;
|
|
70
|
+
// Older topics first within same priority
|
|
71
|
+
return a.created_at - b.created_at;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return queued[0] ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mark a topic as in-progress.
|
|
79
|
+
*/
|
|
80
|
+
startResearch(id: string): boolean {
|
|
81
|
+
const topic = this.topics.get(id);
|
|
82
|
+
if (!topic || topic.status !== 'queued') return false;
|
|
83
|
+
topic.status = 'in_progress';
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mark a topic as completed with a result.
|
|
89
|
+
*/
|
|
90
|
+
complete(id: string, result: string): boolean {
|
|
91
|
+
const topic = this.topics.get(id);
|
|
92
|
+
if (!topic) return false;
|
|
93
|
+
topic.status = 'completed';
|
|
94
|
+
topic.result = result;
|
|
95
|
+
topic.completed_at = Date.now();
|
|
96
|
+
console.log(`[ResearchQueue] Completed: "${topic.topic}"`);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mark a topic as failed.
|
|
102
|
+
*/
|
|
103
|
+
fail(id: string, reason: string): boolean {
|
|
104
|
+
const topic = this.topics.get(id);
|
|
105
|
+
if (!topic) return false;
|
|
106
|
+
topic.status = 'failed';
|
|
107
|
+
topic.failReason = reason;
|
|
108
|
+
topic.completed_at = Date.now();
|
|
109
|
+
console.log(`[ResearchQueue] Failed: "${topic.topic}" — ${reason}`);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Remove a topic from the queue.
|
|
115
|
+
*/
|
|
116
|
+
remove(id: string): boolean {
|
|
117
|
+
return this.topics.delete(id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List all topics, optionally filtered by status.
|
|
122
|
+
*/
|
|
123
|
+
list(status?: ResearchStatus): ResearchTopic[] {
|
|
124
|
+
const all = Array.from(this.topics.values());
|
|
125
|
+
if (status) return all.filter((t) => t.status === status);
|
|
126
|
+
return all;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get count of queued topics.
|
|
131
|
+
*/
|
|
132
|
+
queuedCount(): number {
|
|
133
|
+
return Array.from(this.topics.values()).filter((t) => t.status === 'queued').length;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private evictOldest(): void {
|
|
137
|
+
// Evict oldest completed, then oldest low-priority queued
|
|
138
|
+
const completed = Array.from(this.topics.values())
|
|
139
|
+
.filter((t) => t.status === 'completed' || t.status === 'failed')
|
|
140
|
+
.sort((a, b) => a.created_at - b.created_at);
|
|
141
|
+
|
|
142
|
+
if (completed.length > 0) {
|
|
143
|
+
this.topics.delete(completed[0]!.id);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const low = Array.from(this.topics.values())
|
|
148
|
+
.filter((t) => t.priority === 'low' && t.status === 'queued')
|
|
149
|
+
.sort((a, b) => a.created_at - b.created_at);
|
|
150
|
+
|
|
151
|
+
if (low.length > 0) {
|
|
152
|
+
this.topics.delete(low[0]!.id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|