mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* schedule-store.test.ts — Persistence + concurrency for ScheduleStore.
|
||||
*
|
||||
* Mirrors the patterns from pi-chonky-tasks's task-store testing: round-trip
|
||||
* load/save, parse-error self-heal, stale-lock recovery.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveStorePath, ScheduleStore } from "../src/schedule-store.js";
|
||||
import type { ScheduledSubagent } from "../src/types.js";
|
||||
|
||||
function makeJob(overrides: Partial<ScheduledSubagent> = {}): ScheduledSubagent {
|
||||
return {
|
||||
id: "job-" + Math.random().toString(36).slice(2, 10),
|
||||
name: "test-job",
|
||||
description: "test",
|
||||
schedule: "5m",
|
||||
scheduleType: "interval",
|
||||
intervalMs: 5 * 60_000,
|
||||
subagent_type: "general-purpose",
|
||||
prompt: "hello",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
runCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduleStore", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "schedule-store-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolveStorePath produces session-scoped path under .pi/subagent-schedules/", () => {
|
||||
const p = resolveStorePath("/repo", "abc123");
|
||||
expect(p).toBe("/repo/.pi/subagent-schedules/abc123.json");
|
||||
});
|
||||
|
||||
it("starts empty and round-trips a job through add/list", () => {
|
||||
const store = new ScheduleStore(join(tmp, "s.json"));
|
||||
expect(store.list()).toEqual([]);
|
||||
const job = makeJob();
|
||||
store.add(job);
|
||||
expect(store.list()).toEqual([job]);
|
||||
|
||||
// New instance on same file — verifies persistence
|
||||
const fresh = new ScheduleStore(join(tmp, "s.json"));
|
||||
expect(fresh.list()).toEqual([job]);
|
||||
});
|
||||
|
||||
it("update returns merged record and persists the patch", () => {
|
||||
const store = new ScheduleStore(join(tmp, "s.json"));
|
||||
const job = makeJob({ name: "before" });
|
||||
store.add(job);
|
||||
|
||||
const updated = store.update(job.id, { name: "after", runCount: 3 });
|
||||
expect(updated).toMatchObject({ id: job.id, name: "after", runCount: 3 });
|
||||
|
||||
const fresh = new ScheduleStore(join(tmp, "s.json"));
|
||||
expect(fresh.list()[0]).toMatchObject({ name: "after", runCount: 3 });
|
||||
});
|
||||
|
||||
it("update returns undefined for unknown id and does not create a record", () => {
|
||||
const store = new ScheduleStore(join(tmp, "s.json"));
|
||||
const r = store.update("nonexistent", { name: "x" });
|
||||
expect(r).toBeUndefined();
|
||||
expect(store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("remove returns true on existing job and false on missing", () => {
|
||||
const store = new ScheduleStore(join(tmp, "s.json"));
|
||||
const job = makeJob();
|
||||
store.add(job);
|
||||
expect(store.remove(job.id)).toBe(true);
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(store.remove(job.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasName excludes a given id (for rename safety)", () => {
|
||||
const store = new ScheduleStore(join(tmp, "s.json"));
|
||||
const job = makeJob({ name: "alpha" });
|
||||
store.add(job);
|
||||
expect(store.hasName("alpha")).toBe(true);
|
||||
expect(store.hasName("alpha", job.id)).toBe(false); // excluded — own record
|
||||
expect(store.hasName("beta")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses atomic temp+rename — write produces final file, no .tmp leftover", () => {
|
||||
const file = join(tmp, "s.json");
|
||||
const store = new ScheduleStore(file);
|
||||
store.add(makeJob());
|
||||
expect(existsSync(file)).toBe(true);
|
||||
expect(existsSync(file + ".tmp")).toBe(false);
|
||||
});
|
||||
|
||||
it("self-heals from a corrupt JSON file — load silently empties, next save rewrites", () => {
|
||||
const file = join(tmp, "s.json");
|
||||
writeFileSync(file, "{ this is not valid JSON");
|
||||
const store = new ScheduleStore(file);
|
||||
expect(store.list()).toEqual([]);
|
||||
|
||||
// Next mutation overwrites the broken file with healthy JSON
|
||||
store.add(makeJob({ id: "fresh" }));
|
||||
const data = JSON.parse(readFileSync(file, "utf-8"));
|
||||
expect(data.version).toBe(1);
|
||||
expect(data.jobs).toHaveLength(1);
|
||||
expect(data.jobs[0].id).toBe("fresh");
|
||||
});
|
||||
|
||||
it("recovers from a stale lock left by a dead process", () => {
|
||||
const file = join(tmp, "s.json");
|
||||
const lockFile = file + ".lock";
|
||||
// Simulate a stale lock file containing a non-existent PID.
|
||||
// PID 999_999_999 is virtually never a live process — kill -0 returns ESRCH.
|
||||
writeFileSync(lockFile, "999999999");
|
||||
|
||||
const store = new ScheduleStore(file);
|
||||
// The mutation will detect the stale lock, unlink it, and proceed.
|
||||
expect(() => store.add(makeJob())).not.toThrow();
|
||||
expect(store.list()).toHaveLength(1);
|
||||
expect(existsSync(lockFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the lock after a successful mutation so subsequent ones don't deadlock", () => {
|
||||
const store = new ScheduleStore(join(tmp, "s.json"));
|
||||
const a = makeJob({ id: "a" });
|
||||
const b = makeJob({ id: "b" });
|
||||
store.add(a);
|
||||
store.add(b); // would hang if the lock from the first add wasn't released
|
||||
expect(store.list().map(j => j.id).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("does not create the backing directory until a mutation persists", () => {
|
||||
const dir = join(tmp, ".pi", "subagent-schedules");
|
||||
const file = join(dir, "sess.json");
|
||||
|
||||
// Constructing + read-only use must not touch the filesystem.
|
||||
const store = new ScheduleStore(file);
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
|
||||
// First mutation lazily creates the directory.
|
||||
store.add(makeJob());
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
expect(existsSync(file)).toBe(true);
|
||||
});
|
||||
|
||||
it("no-op update/remove of an unknown id never creates the backing directory", () => {
|
||||
const dir = join(tmp, ".pi", "subagent-schedules");
|
||||
const file = join(dir, "sess.json");
|
||||
const store = new ScheduleStore(file);
|
||||
|
||||
expect(store.update("nonexistent", { name: "x" })).toBeUndefined();
|
||||
expect(store.remove("nonexistent")).toBe(false);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("deleteFileIfEmpty unlinks file only when no jobs remain", () => {
|
||||
const file = join(tmp, "s.json");
|
||||
const store = new ScheduleStore(file);
|
||||
const job = makeJob();
|
||||
store.add(job);
|
||||
store.deleteFileIfEmpty(); // not empty — should be a no-op
|
||||
expect(existsSync(file)).toBe(true);
|
||||
|
||||
store.remove(job.id);
|
||||
store.deleteFileIfEmpty();
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user