feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+179
View File
@@ -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);
});
});