@kmmao/happy-agent 0.4.1 → 0.5.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/dist/index.cjs +418 -7
- package/dist/index.d.cts +187 -1
- package/dist/index.d.mts +187 -1
- package/dist/index.mjs +418 -7
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -18,7 +18,7 @@ var path = require('path');
|
|
|
18
18
|
var fs = require('fs');
|
|
19
19
|
var os = require('os');
|
|
20
20
|
|
|
21
|
-
var version = "0.
|
|
21
|
+
var version = "0.5.0";
|
|
22
22
|
|
|
23
23
|
function loadConfig() {
|
|
24
24
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -2025,11 +2025,11 @@ function stopSession(pid) {
|
|
|
2025
2025
|
}
|
|
2026
2026
|
}
|
|
2027
2027
|
|
|
2028
|
-
const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-prompts");
|
|
2028
|
+
const PROMPT_DIR$1 = path.join(os.tmpdir(), "happy", "agent-prompts");
|
|
2029
2029
|
async function writePromptFile(prefix, content) {
|
|
2030
|
-
await promises.mkdir(PROMPT_DIR, { recursive: true });
|
|
2030
|
+
await promises.mkdir(PROMPT_DIR$1, { recursive: true });
|
|
2031
2031
|
const filename = `${prefix}-${Date.now()}.md`;
|
|
2032
|
-
const filepath = path.join(PROMPT_DIR, filename);
|
|
2032
|
+
const filepath = path.join(PROMPT_DIR$1, filename);
|
|
2033
2033
|
await promises.writeFile(filepath, content, "utf-8");
|
|
2034
2034
|
return filepath;
|
|
2035
2035
|
}
|
|
@@ -2213,6 +2213,8 @@ class MachineClient {
|
|
|
2213
2213
|
automationServerUrl = "";
|
|
2214
2214
|
automationAuthToken = "";
|
|
2215
2215
|
scheduler = null;
|
|
2216
|
+
loopCoordinator = null;
|
|
2217
|
+
auditStore = null;
|
|
2216
2218
|
constructor(opts) {
|
|
2217
2219
|
this.token = opts.token;
|
|
2218
2220
|
this.machine = opts.machine;
|
|
@@ -2254,6 +2256,34 @@ class MachineClient {
|
|
|
2254
2256
|
return { sessions };
|
|
2255
2257
|
});
|
|
2256
2258
|
}
|
|
2259
|
+
registerLoopHandlers() {
|
|
2260
|
+
const coord = this.loopCoordinator;
|
|
2261
|
+
this.rpcHandlerManager.registerHandler("create-loop", async (data) => {
|
|
2262
|
+
const loop = coord.createLoop(data);
|
|
2263
|
+
return { loop: { id: loop.id, name: loop.name, state: loop.state } };
|
|
2264
|
+
});
|
|
2265
|
+
this.rpcHandlerManager.registerHandler("list-loops", async () => {
|
|
2266
|
+
return { loops: coord.listLoops() };
|
|
2267
|
+
});
|
|
2268
|
+
this.rpcHandlerManager.registerHandler("pause-loop", async (data) => {
|
|
2269
|
+
return { success: coord.pauseLoop(data.loopId) };
|
|
2270
|
+
});
|
|
2271
|
+
this.rpcHandlerManager.registerHandler("resume-loop", async (data) => {
|
|
2272
|
+
return { success: coord.resumeLoop(data.loopId) };
|
|
2273
|
+
});
|
|
2274
|
+
this.rpcHandlerManager.registerHandler("delete-loop", async (data) => {
|
|
2275
|
+
return { success: coord.deleteLoop(data.loopId) };
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
registerAuditHandlers() {
|
|
2279
|
+
const audit = this.auditStore;
|
|
2280
|
+
this.rpcHandlerManager.registerHandler("query-audit-log", async (data) => {
|
|
2281
|
+
return { events: audit.query(data) };
|
|
2282
|
+
});
|
|
2283
|
+
this.rpcHandlerManager.registerHandler("audit-summary", async () => {
|
|
2284
|
+
return { summary: audit.summarize() };
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2257
2287
|
// -----------------------------------------------------------------------
|
|
2258
2288
|
// Connection
|
|
2259
2289
|
// -----------------------------------------------------------------------
|
|
@@ -2435,11 +2465,19 @@ class MachineClient {
|
|
|
2435
2465
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
2436
2466
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2437
2467
|
*/
|
|
2438
|
-
enableAutomation(serverUrl, authToken, scheduler) {
|
|
2468
|
+
enableAutomation(serverUrl, authToken, scheduler, loopCoordinator, auditStore) {
|
|
2439
2469
|
this.automationEnabled = true;
|
|
2440
2470
|
this.automationServerUrl = serverUrl;
|
|
2441
2471
|
this.automationAuthToken = authToken;
|
|
2442
2472
|
this.scheduler = scheduler;
|
|
2473
|
+
this.loopCoordinator = loopCoordinator ?? null;
|
|
2474
|
+
this.auditStore = auditStore ?? null;
|
|
2475
|
+
if (this.loopCoordinator) {
|
|
2476
|
+
this.registerLoopHandlers();
|
|
2477
|
+
}
|
|
2478
|
+
if (this.auditStore) {
|
|
2479
|
+
this.registerAuditHandlers();
|
|
2480
|
+
}
|
|
2443
2481
|
logger.debug("[MACHINE] Automation enabled");
|
|
2444
2482
|
}
|
|
2445
2483
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
@@ -2560,6 +2598,7 @@ class AutomationScheduler {
|
|
|
2560
2598
|
retryDelayMs;
|
|
2561
2599
|
defaultMaxAttempts;
|
|
2562
2600
|
maxRecentCompletions;
|
|
2601
|
+
onAudit;
|
|
2563
2602
|
/** Active jobs indexed by id. */
|
|
2564
2603
|
jobs = /* @__PURE__ */ new Map();
|
|
2565
2604
|
/** dedupeKey → jobId for fast dedup lookups. */
|
|
@@ -2573,6 +2612,7 @@ class AutomationScheduler {
|
|
|
2573
2612
|
this.retryDelayMs = options?.retryDelayMs ?? 5e3;
|
|
2574
2613
|
this.defaultMaxAttempts = options?.maxAttempts ?? 3;
|
|
2575
2614
|
this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
|
|
2615
|
+
this.onAudit = options?.onAudit ?? null;
|
|
2576
2616
|
this.pumpTimer = setInterval(() => this.pump(), 1e3);
|
|
2577
2617
|
}
|
|
2578
2618
|
// -----------------------------------------------------------------------
|
|
@@ -2603,6 +2643,7 @@ class AutomationScheduler {
|
|
|
2603
2643
|
this.jobs.set(job.id, job);
|
|
2604
2644
|
this.dedupeIndex.set(job.dedupeKey, job.id);
|
|
2605
2645
|
logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
|
|
2646
|
+
this.onAudit?.({ kind: "job_enqueued", jobId: job.id, dedupeKey: job.dedupeKey, message: `${job.kind}:${job.priority}` });
|
|
2606
2647
|
this.pump();
|
|
2607
2648
|
return { job, deduped: false };
|
|
2608
2649
|
}
|
|
@@ -2612,6 +2653,7 @@ class AutomationScheduler {
|
|
|
2612
2653
|
job.status = "completed";
|
|
2613
2654
|
job.updatedAt = Date.now();
|
|
2614
2655
|
logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
|
|
2656
|
+
this.onAudit?.({ kind: "job_completed", jobId, dedupeKey: job.dedupeKey });
|
|
2615
2657
|
this.finalize(job);
|
|
2616
2658
|
}
|
|
2617
2659
|
markFailed(jobId, error) {
|
|
@@ -2623,11 +2665,13 @@ class AutomationScheduler {
|
|
|
2623
2665
|
job.status = "queued";
|
|
2624
2666
|
job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
|
|
2625
2667
|
logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
|
|
2668
|
+
this.onAudit?.({ kind: "job_retried", jobId, dedupeKey: job.dedupeKey, errorMessage: error, message: `attempt ${job.attempt}/${job.maxAttempts}` });
|
|
2626
2669
|
this.pump();
|
|
2627
2670
|
return;
|
|
2628
2671
|
}
|
|
2629
2672
|
job.status = "failed";
|
|
2630
2673
|
logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
|
|
2674
|
+
this.onAudit?.({ kind: "job_failed", jobId, dedupeKey: job.dedupeKey, errorMessage: error });
|
|
2631
2675
|
this.finalize(job);
|
|
2632
2676
|
}
|
|
2633
2677
|
getStatus() {
|
|
@@ -2687,6 +2731,7 @@ class AutomationScheduler {
|
|
|
2687
2731
|
job.attempt++;
|
|
2688
2732
|
job.updatedAt = Date.now();
|
|
2689
2733
|
logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
|
|
2734
|
+
this.onAudit?.({ kind: "job_dispatched", jobId: job.id, dedupeKey: job.dedupeKey, message: `attempt ${job.attempt}` });
|
|
2690
2735
|
job.run(job.id).then(({ pid }) => {
|
|
2691
2736
|
if (job.status === "dispatching") {
|
|
2692
2737
|
job.status = "running";
|
|
@@ -2722,6 +2767,364 @@ class AutomationScheduler {
|
|
|
2722
2767
|
}
|
|
2723
2768
|
}
|
|
2724
2769
|
|
|
2770
|
+
const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-loop-prompts");
|
|
2771
|
+
const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
2772
|
+
const DEFAULT_MAX_ITERATIONS = 0;
|
|
2773
|
+
class AgentLoopCoordinator {
|
|
2774
|
+
loops = /* @__PURE__ */ new Map();
|
|
2775
|
+
scheduler;
|
|
2776
|
+
serverUrl;
|
|
2777
|
+
authToken;
|
|
2778
|
+
guardian;
|
|
2779
|
+
tickTimer = null;
|
|
2780
|
+
constructor(scheduler, serverUrl, authToken, guardian) {
|
|
2781
|
+
this.scheduler = scheduler;
|
|
2782
|
+
this.serverUrl = serverUrl;
|
|
2783
|
+
this.authToken = authToken;
|
|
2784
|
+
this.guardian = guardian ?? null;
|
|
2785
|
+
}
|
|
2786
|
+
// -----------------------------------------------------------------------
|
|
2787
|
+
// Lifecycle
|
|
2788
|
+
// -----------------------------------------------------------------------
|
|
2789
|
+
start() {
|
|
2790
|
+
if (this.tickTimer) return;
|
|
2791
|
+
this.tickTimer = setInterval(() => this.tick(), 1e3);
|
|
2792
|
+
logger.debug("[LOOP] Coordinator started");
|
|
2793
|
+
}
|
|
2794
|
+
shutdown() {
|
|
2795
|
+
if (this.tickTimer) {
|
|
2796
|
+
clearInterval(this.tickTimer);
|
|
2797
|
+
this.tickTimer = null;
|
|
2798
|
+
}
|
|
2799
|
+
logger.debug("[LOOP] Coordinator shutdown");
|
|
2800
|
+
}
|
|
2801
|
+
// -----------------------------------------------------------------------
|
|
2802
|
+
// CRUD
|
|
2803
|
+
// -----------------------------------------------------------------------
|
|
2804
|
+
createLoop(input) {
|
|
2805
|
+
const loop = {
|
|
2806
|
+
id: crypto.randomUUID(),
|
|
2807
|
+
name: input.name,
|
|
2808
|
+
prompt: input.prompt,
|
|
2809
|
+
directory: input.directory,
|
|
2810
|
+
intervalMs: Math.max(input.intervalMs, 1e4),
|
|
2811
|
+
// min 10s
|
|
2812
|
+
createdAt: Date.now(),
|
|
2813
|
+
state: "idle",
|
|
2814
|
+
iteration: 0,
|
|
2815
|
+
nextRunAt: Date.now() + Math.max(input.intervalMs, 1e4),
|
|
2816
|
+
consecutiveFailures: 0,
|
|
2817
|
+
maxConsecutiveFailures: input.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,
|
|
2818
|
+
maxIterations: input.maxIterations ?? DEFAULT_MAX_ITERATIONS
|
|
2819
|
+
};
|
|
2820
|
+
this.loops.set(loop.id, loop);
|
|
2821
|
+
logger.debug(`[LOOP] Created: ${loop.name} (${loop.id}) interval=${loop.intervalMs}ms`);
|
|
2822
|
+
return loop;
|
|
2823
|
+
}
|
|
2824
|
+
getLoop(id) {
|
|
2825
|
+
return this.loops.get(id);
|
|
2826
|
+
}
|
|
2827
|
+
listLoops() {
|
|
2828
|
+
return [...this.loops.values()].map((l) => ({
|
|
2829
|
+
id: l.id,
|
|
2830
|
+
name: l.name,
|
|
2831
|
+
state: l.state,
|
|
2832
|
+
iteration: l.iteration,
|
|
2833
|
+
intervalMs: l.intervalMs,
|
|
2834
|
+
nextRunAt: l.nextRunAt,
|
|
2835
|
+
lastCompletedAt: l.lastCompletedAt
|
|
2836
|
+
}));
|
|
2837
|
+
}
|
|
2838
|
+
pauseLoop(id) {
|
|
2839
|
+
const loop = this.loops.get(id);
|
|
2840
|
+
if (!loop || loop.state === "paused") return false;
|
|
2841
|
+
loop.state = "paused";
|
|
2842
|
+
logger.debug(`[LOOP] Paused: ${loop.name} (${id})`);
|
|
2843
|
+
return true;
|
|
2844
|
+
}
|
|
2845
|
+
resumeLoop(id) {
|
|
2846
|
+
const loop = this.loops.get(id);
|
|
2847
|
+
if (!loop || loop.state !== "paused") return false;
|
|
2848
|
+
loop.state = "idle";
|
|
2849
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2850
|
+
loop.consecutiveFailures = 0;
|
|
2851
|
+
loop.errorMessage = void 0;
|
|
2852
|
+
logger.debug(`[LOOP] Resumed: ${loop.name} (${id})`);
|
|
2853
|
+
return true;
|
|
2854
|
+
}
|
|
2855
|
+
deleteLoop(id) {
|
|
2856
|
+
const deleted = this.loops.delete(id);
|
|
2857
|
+
if (deleted) logger.debug(`[LOOP] Deleted: ${id}`);
|
|
2858
|
+
return deleted;
|
|
2859
|
+
}
|
|
2860
|
+
// -----------------------------------------------------------------------
|
|
2861
|
+
// Scheduler callback
|
|
2862
|
+
// -----------------------------------------------------------------------
|
|
2863
|
+
onJobTerminal(loopId, status, errorMessage) {
|
|
2864
|
+
const loop = this.loops.get(loopId);
|
|
2865
|
+
if (!loop) return;
|
|
2866
|
+
loop.activeJobId = void 0;
|
|
2867
|
+
loop.lastCompletedAt = Date.now();
|
|
2868
|
+
if (status === "completed") {
|
|
2869
|
+
loop.consecutiveFailures = 0;
|
|
2870
|
+
loop.state = "idle";
|
|
2871
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2872
|
+
logger.debug(`[LOOP] Iteration ${loop.iteration} completed: ${loop.name}`);
|
|
2873
|
+
} else {
|
|
2874
|
+
loop.consecutiveFailures++;
|
|
2875
|
+
loop.errorMessage = errorMessage;
|
|
2876
|
+
if (loop.consecutiveFailures >= loop.maxConsecutiveFailures) {
|
|
2877
|
+
loop.state = "blocked";
|
|
2878
|
+
logger.debug(`[LOOP] Blocked after ${loop.consecutiveFailures} failures: ${loop.name}`);
|
|
2879
|
+
} else {
|
|
2880
|
+
loop.state = "idle";
|
|
2881
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2882
|
+
logger.debug(`[LOOP] Iteration ${loop.iteration} failed (${loop.consecutiveFailures}/${loop.maxConsecutiveFailures}): ${loop.name}`);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
// -----------------------------------------------------------------------
|
|
2887
|
+
// Tick
|
|
2888
|
+
// -----------------------------------------------------------------------
|
|
2889
|
+
tick() {
|
|
2890
|
+
const now = Date.now();
|
|
2891
|
+
for (const loop of this.loops.values()) {
|
|
2892
|
+
if (loop.state !== "idle") continue;
|
|
2893
|
+
if (loop.nextRunAt > now) continue;
|
|
2894
|
+
if (loop.maxIterations > 0 && loop.iteration >= loop.maxIterations) {
|
|
2895
|
+
loop.state = "paused";
|
|
2896
|
+
logger.debug(`[LOOP] Max iterations reached (${loop.maxIterations}): ${loop.name}`);
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
this.enqueueLoop(loop);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
enqueueLoop(loop) {
|
|
2903
|
+
loop.iteration++;
|
|
2904
|
+
loop.state = "active";
|
|
2905
|
+
loop.lastStartedAt = Date.now();
|
|
2906
|
+
const iterationNum = loop.iteration;
|
|
2907
|
+
const loopId = loop.id;
|
|
2908
|
+
const coordinator = this;
|
|
2909
|
+
const guardianSessionId = this.guardian?.resolve({ loopId: loop.id }) ?? void 0;
|
|
2910
|
+
const { job, deduped } = this.scheduler.enqueue({
|
|
2911
|
+
kind: "task",
|
|
2912
|
+
dedupeKey: `agent-loop:${loop.id}:${loop.iteration}`,
|
|
2913
|
+
priority: "background",
|
|
2914
|
+
run: async (jobId) => {
|
|
2915
|
+
const promptFile = await writeLoopPromptFile(loop.name, loop.prompt, iterationNum);
|
|
2916
|
+
const result = await spawnSession({
|
|
2917
|
+
directory: loop.directory,
|
|
2918
|
+
approvedNewDirectoryCreation: false,
|
|
2919
|
+
happySessionId: guardianSessionId,
|
|
2920
|
+
automationContext: {
|
|
2921
|
+
kind: "agent_loop",
|
|
2922
|
+
trigger: `loop:${loop.name}:iteration-${iterationNum}`
|
|
2923
|
+
},
|
|
2924
|
+
environmentVariables: {
|
|
2925
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2926
|
+
HAPPY_LOOP_ID: loopId,
|
|
2927
|
+
HAPPY_LOOP_NAME: loop.name,
|
|
2928
|
+
HAPPY_LOOP_ITERATION: String(iterationNum),
|
|
2929
|
+
HAPPY_SERVER_URL: coordinator.serverUrl,
|
|
2930
|
+
HAPPY_AUTH_TOKEN: coordinator.authToken
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
if (result.type !== "success") {
|
|
2934
|
+
throw new Error(result.type === "error" ? result.errorMessage : "Directory not approved");
|
|
2935
|
+
}
|
|
2936
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2937
|
+
if (tracked?.childProcess) {
|
|
2938
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2939
|
+
const status = code === 0 ? "completed" : "failed";
|
|
2940
|
+
coordinator.onJobTerminal(loopId, status, code !== 0 ? `exit code ${code}` : void 0);
|
|
2941
|
+
if (code === 0) coordinator.scheduler.markCompleted(jobId);
|
|
2942
|
+
else coordinator.scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
return { pid: result.pid };
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
if (deduped) {
|
|
2949
|
+
loop.iteration--;
|
|
2950
|
+
loop.state = "idle";
|
|
2951
|
+
logger.debug(`[LOOP] Enqueue deduped: ${loop.name} iteration ${iterationNum}`);
|
|
2952
|
+
} else {
|
|
2953
|
+
loop.activeJobId = job.id;
|
|
2954
|
+
logger.debug(`[LOOP] Enqueued: ${loop.name} iteration ${iterationNum} job=${job.id}`);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
async function writeLoopPromptFile(name, prompt, iteration) {
|
|
2959
|
+
await promises.mkdir(PROMPT_DIR, { recursive: true });
|
|
2960
|
+
const filename = `loop-${name.replace(/[^a-zA-Z0-9-]/g, "_")}-${iteration}-${Date.now()}.md`;
|
|
2961
|
+
const filepath = path.join(PROMPT_DIR, filename);
|
|
2962
|
+
const content = [
|
|
2963
|
+
`# Agent Loop: ${name}`,
|
|
2964
|
+
`Iteration: ${iteration}`,
|
|
2965
|
+
"",
|
|
2966
|
+
prompt
|
|
2967
|
+
].join("\n");
|
|
2968
|
+
await promises.writeFile(filepath, content, "utf-8");
|
|
2969
|
+
return filepath;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
class GuardianSessionRegistry {
|
|
2973
|
+
entries = /* @__PURE__ */ new Map();
|
|
2974
|
+
/**
|
|
2975
|
+
* Find an existing session to reuse.
|
|
2976
|
+
* Tries loop key first, then project key.
|
|
2977
|
+
*/
|
|
2978
|
+
resolve(input) {
|
|
2979
|
+
if (input.loopId) {
|
|
2980
|
+
const entry = this.entries.get(`loop:${input.loopId}`);
|
|
2981
|
+
if (entry) {
|
|
2982
|
+
logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for loop:${input.loopId}`);
|
|
2983
|
+
return entry.sessionId;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
if (input.projectId) {
|
|
2987
|
+
const entry = this.entries.get(`project:${input.projectId}`);
|
|
2988
|
+
if (entry) {
|
|
2989
|
+
logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for project:${input.projectId}`);
|
|
2990
|
+
return entry.sessionId;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Remember a session for future reuse.
|
|
2997
|
+
* Stores under both loop and project keys if available.
|
|
2998
|
+
*/
|
|
2999
|
+
remember(sessionId, input) {
|
|
3000
|
+
const now = Date.now();
|
|
3001
|
+
if (input.loopId) {
|
|
3002
|
+
const key = `loop:${input.loopId}`;
|
|
3003
|
+
this.entries.set(key, {
|
|
3004
|
+
key,
|
|
3005
|
+
sessionId,
|
|
3006
|
+
loopId: input.loopId,
|
|
3007
|
+
projectId: input.projectId,
|
|
3008
|
+
updatedAt: now
|
|
3009
|
+
});
|
|
3010
|
+
logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
|
|
3011
|
+
}
|
|
3012
|
+
if (input.projectId) {
|
|
3013
|
+
const key = `project:${input.projectId}`;
|
|
3014
|
+
if (!this.entries.has(key)) {
|
|
3015
|
+
this.entries.set(key, {
|
|
3016
|
+
key,
|
|
3017
|
+
sessionId,
|
|
3018
|
+
projectId: input.projectId,
|
|
3019
|
+
updatedAt: now
|
|
3020
|
+
});
|
|
3021
|
+
logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Forget a specific session (e.g., after it exits).
|
|
3027
|
+
*/
|
|
3028
|
+
forgetSession(sessionId) {
|
|
3029
|
+
let removed = 0;
|
|
3030
|
+
for (const [key, entry] of this.entries) {
|
|
3031
|
+
if (entry.sessionId === sessionId) {
|
|
3032
|
+
this.entries.delete(key);
|
|
3033
|
+
removed++;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
if (removed > 0) {
|
|
3037
|
+
logger.debug(`[GUARDIAN] Forgot session ${sessionId} (${removed} entries)`);
|
|
3038
|
+
}
|
|
3039
|
+
return removed;
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Forget all entries for a loop.
|
|
3043
|
+
*/
|
|
3044
|
+
forgetLoop(loopId) {
|
|
3045
|
+
return this.entries.delete(`loop:${loopId}`);
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Get all entries (for observability).
|
|
3049
|
+
*/
|
|
3050
|
+
getSnapshot() {
|
|
3051
|
+
return [...this.entries.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Number of tracked guardian entries.
|
|
3055
|
+
*/
|
|
3056
|
+
get size() {
|
|
3057
|
+
return this.entries.size;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
class AutomationAuditStore {
|
|
3062
|
+
maxEntries;
|
|
3063
|
+
events = [];
|
|
3064
|
+
nextId = 1;
|
|
3065
|
+
constructor(options) {
|
|
3066
|
+
this.maxEntries = options?.maxEntries ?? 500;
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Record an audit event.
|
|
3070
|
+
*/
|
|
3071
|
+
record(event) {
|
|
3072
|
+
const entry = {
|
|
3073
|
+
...event,
|
|
3074
|
+
id: this.nextId++,
|
|
3075
|
+
timestamp: Date.now()
|
|
3076
|
+
};
|
|
3077
|
+
this.events.push(entry);
|
|
3078
|
+
while (this.events.length > this.maxEntries) {
|
|
3079
|
+
this.events.shift();
|
|
3080
|
+
}
|
|
3081
|
+
logger.debug(`[AUDIT] ${entry.kind}: ${entry.message ?? entry.dedupeKey ?? entry.jobId ?? ""}`);
|
|
3082
|
+
return entry;
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Query events with optional filters.
|
|
3086
|
+
*/
|
|
3087
|
+
query(filter) {
|
|
3088
|
+
const limit = filter?.limit ?? 50;
|
|
3089
|
+
let results = this.events;
|
|
3090
|
+
if (filter?.kind) {
|
|
3091
|
+
results = results.filter((e) => e.kind === filter.kind);
|
|
3092
|
+
}
|
|
3093
|
+
if (filter?.loopId) {
|
|
3094
|
+
results = results.filter((e) => e.loopId === filter.loopId);
|
|
3095
|
+
}
|
|
3096
|
+
if (filter?.since) {
|
|
3097
|
+
results = results.filter((e) => e.timestamp >= filter.since);
|
|
3098
|
+
}
|
|
3099
|
+
return results.slice(-limit).reverse();
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Summary counts by kind.
|
|
3103
|
+
*/
|
|
3104
|
+
summarize() {
|
|
3105
|
+
const counts = {
|
|
3106
|
+
job_enqueued: 0,
|
|
3107
|
+
job_dispatched: 0,
|
|
3108
|
+
job_completed: 0,
|
|
3109
|
+
job_failed: 0,
|
|
3110
|
+
job_retried: 0,
|
|
3111
|
+
loop_started: 0,
|
|
3112
|
+
loop_blocked: 0,
|
|
3113
|
+
loop_paused: 0
|
|
3114
|
+
};
|
|
3115
|
+
for (const event of this.events) {
|
|
3116
|
+
counts[event.kind]++;
|
|
3117
|
+
}
|
|
3118
|
+
return counts;
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Total number of stored events.
|
|
3122
|
+
*/
|
|
3123
|
+
get size() {
|
|
3124
|
+
return this.events.length;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
|
|
2725
3128
|
function pidFilePath(homeDir) {
|
|
2726
3129
|
return node_path.join(homeDir, "agent-daemon.pid");
|
|
2727
3130
|
}
|
|
@@ -2789,9 +3192,16 @@ async function startDaemon(options) {
|
|
|
2789
3192
|
agentVersion: version,
|
|
2790
3193
|
workingDirectory: workDir
|
|
2791
3194
|
});
|
|
2792
|
-
const
|
|
3195
|
+
const auditStore = new AutomationAuditStore();
|
|
3196
|
+
const scheduler = new AutomationScheduler({
|
|
3197
|
+
maxConcurrentJobs: 2,
|
|
3198
|
+
onAudit: (event) => auditStore.record(event)
|
|
3199
|
+
});
|
|
3200
|
+
const guardian = new GuardianSessionRegistry();
|
|
3201
|
+
const loopCoordinator = new AgentLoopCoordinator(scheduler, config.serverUrl, creds.token, guardian);
|
|
2793
3202
|
client.setTailscaleInfo(fullTailscale);
|
|
2794
|
-
client.enableAutomation(config.serverUrl, creds.token, scheduler);
|
|
3203
|
+
client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
|
|
3204
|
+
loopCoordinator.start();
|
|
2795
3205
|
client.connect();
|
|
2796
3206
|
writePidFile(config.homeDir, process.pid);
|
|
2797
3207
|
console.log(`Daemon started (PID ${process.pid})`);
|
|
@@ -2802,6 +3212,7 @@ async function startDaemon(options) {
|
|
|
2802
3212
|
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2803
3213
|
console.log(`
|
|
2804
3214
|
Received ${signal}, shutting down...`);
|
|
3215
|
+
loopCoordinator.shutdown();
|
|
2805
3216
|
scheduler.shutdown();
|
|
2806
3217
|
client.shutdown();
|
|
2807
3218
|
removePidFile(config.homeDir);
|