feat(linkedin-studio): RE-R3c — autonomous trigger (scheduler + headless entry) [skip-docs]
Closes research-engine hulls (1) no autonomous trigger + (6) no headless entry. Makes the daily research loop closed + headless: deterministic-brief-only (C1), print-first (C2 — the tool never runs launchctl or the cron table; --install writes only the inert launchd plist file). - NEW scripts/trends/src/schedule.ts — pure string emitters (launchd plist + cron-line + install/uninstall instructions + defaultLabel). No clock/fs/env/AI; byte-deterministic. - NEW scripts/trends/run-daily.sh — bash-3.2 headless wrapper: resolves node, cd's into the package so tsx resolves, logs via the data-path twin seam; runs the deterministic brief and appends one compact cron.log line per fire. The (e) AI-capture seam is documented, not built. - EDIT cli.ts — schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <p>]; print-first, no new exit code; logPath anchored to dirname(defaultStorePath()) (not the --store override). - WIRE trend-spotter.md (one prose line) + README (scheduler + wrapper + the C1 boundary). - Gate: TRENDS_TESTS_FLOOR 171->192, ASSERT_BASELINE_FLOOR 105->111, new UNCONDITIONAL Section 16l (6 deps-absent greps + non-vacuity self-test), header-enum + floor-history append. TDD two-phase RED -> GREEN. trends 192/192, gate 126/0, hook-suite 139/0 (untouched), plutil -lint OK. No schema change (SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1). Counts 29/19/27 unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
parent
b43757462b
commit
3276e44dbf
9 changed files with 768 additions and 9 deletions
|
|
@ -439,3 +439,164 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)",
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print-first)", () => {
|
||||
// schedule is flag-driven + print-first. Capture stdout AND stderr (usage → stderr); override HOME
|
||||
// (the ~/Library/LaunchAgents target) + LINKEDIN_STUDIO_DATA (the cron.log seam) into a temp dir so
|
||||
// a real HOME is never touched. (os.homedir() respects $HOME on POSIX — verified.)
|
||||
function runSched(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "schedule", ...args], {
|
||||
input: "",
|
||||
encoding: "utf8",
|
||||
cwd: trendsDir,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
const tmpHome = () => mkdtempSync(join(tmpdir(), "sched-home-"));
|
||||
|
||||
// A dependency-free well-formedness check: tokenize element tags and assert they nest/balance
|
||||
// (the `<?xml?>` PI and `<!DOCTYPE>` are skipped — neither starts with a letter after `<`).
|
||||
// SC1 asserts balance + key-completeness; `plutil -lint` is the deps-present manual check (Step 7).
|
||||
function isBalancedXml(xml: string): boolean {
|
||||
const stack: string[] = [];
|
||||
const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
if (m[3] === "/") continue; // self-closing (e.g. <false/>)
|
||||
if (m[1] === "/") {
|
||||
if (stack.pop() !== m[2]) return false;
|
||||
} else {
|
||||
stack.push(m[2]);
|
||||
}
|
||||
}
|
||||
return stack.length === 0;
|
||||
}
|
||||
|
||||
test("SC1: --platform launchd --at 07:30 --print → key-complete, well-formed plist, exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(
|
||||
["--pillars", "ai,gov", "--platform", "launchd", "--at", "07:30", "--print"],
|
||||
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
|
||||
);
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /<key>Label<\/key>\s*<string>com\.linkedin-studio\.trends\.daily<\/string>/);
|
||||
assert.ok(stdout.includes("<key>ProgramArguments</key>"), "ProgramArguments present");
|
||||
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
|
||||
assert.ok(stdout.includes("--pillars") && stdout.includes("ai,gov"), "carries the pillars");
|
||||
assert.match(stdout, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
|
||||
assert.match(stdout, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
|
||||
assert.ok(stdout.includes("<key>StandardOutPath</key>") && stdout.includes("<key>StandardErrorPath</key>"), "Std*Path present");
|
||||
assert.ok(stdout.includes("cron.log"), "Std*Path point at cron.log");
|
||||
assert.ok(stdout.includes("<key>EnvironmentVariables</key>"), "EnvironmentVariables present");
|
||||
assert.ok(stdout.includes("NODE_BIN") && stdout.includes("LINKEDIN_STUDIO_DATA"), "env carries NODE_BIN + data root");
|
||||
assert.ok(isBalancedXml(stdout), "the plist XML is balanced / well-formed");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC1: two --print runs (same args) are byte-identical (the emitter is pure)", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const a = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
const b = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(a.status, 0);
|
||||
assert.equal(a.stdout, b.stdout);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC2: --platform cron --at 07:30 --print → cron line + install recipe (string only), exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(
|
||||
["--pillars", "ai,gov", "--platform", "cron", "--at", "07:30", "--print"],
|
||||
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
|
||||
);
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /^30 7 \* \* \* /m, "the cron line fires at 07:30 daily");
|
||||
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
|
||||
assert.ok(stdout.includes("--pillars ai,gov"), "carries the pillars");
|
||||
assert.ok(stdout.includes(">> ") && stdout.includes("2>&1"), "redirects stdout+stderr to the log");
|
||||
assert.ok(stdout.includes("com.linkedin-studio.trends.daily"), "the label comment");
|
||||
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (a string the operator runs)");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC3: platform auto (no --platform) → launchd on darwin, cron elsewhere", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
if (process.platform === "darwin") {
|
||||
assert.ok(stdout.includes("<key>Label</key>"), "darwin auto → launchd plist");
|
||||
} else {
|
||||
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "non-darwin auto → cron line");
|
||||
}
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC4: --print writes nothing (temp-HOME LaunchAgents stays absent), exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
assert.ok(stdout.includes("<key>Label</key>"), "the plist is on stdout");
|
||||
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "--print creates no LaunchAgents dir");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC5: --install launchd → inert plist FILE written + launchctl printed (never run), exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
const plist = join(home, "Library", "LaunchAgents", "com.linkedin-studio.trends.daily.plist");
|
||||
assert.ok(existsSync(plist), "the inert plist file is written");
|
||||
const content = readFileSync(plist, "utf8");
|
||||
assert.ok(content.includes("<key>Label</key>") && content.includes("com.linkedin-studio.trends.daily"), "the file holds the plist");
|
||||
assert.ok(stdout.includes("launchctl bootstrap"), "the activation command is PRINTED (the tool never runs launchctl)");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC6: --install cron → line + install recipe printed, writes no file, exit 0", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "cron", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "the cron line is printed");
|
||||
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (never executed)");
|
||||
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "cron --install writes no plist");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC9: no --pillars / bad --at / bad --platform → exit 2; the fs is untouched", () => {
|
||||
const home = tmpHome();
|
||||
try {
|
||||
assert.equal(runSched([], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "no --pillars → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--at", "25:00"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 25:00 → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--at", "7:99"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 7:99 → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--at", "noon"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at noon → exit 2");
|
||||
assert.equal(runSched(["--pillars", "ai", "--platform", "bogus"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--platform bogus → exit 2");
|
||||
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "a validation error writes nothing");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue