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
134
scripts/trends/tests/run-daily.test.ts
Normal file
134
scripts/trends/tests/run-daily.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { defaultStorePath } from "../src/store.js";
|
||||
// The hooks `.mjs` twin of the data-root seam — SC8 binds it into the consistency check.
|
||||
import { getDataRoot } from "../../../hooks/scripts/data-root.mjs";
|
||||
|
||||
// Package root (scripts/trends) so the wrapper path + `tsx` resolution are cwd-independent.
|
||||
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const wrapper = join(trendsDir, "run-daily.sh");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Invoke THROUGH `bash` (never a direct executable spawn): an ABSENT wrapper exits 127
|
||||
// (a clean assertion-RED), where a direct exec of a missing file would throw ENOENT /
|
||||
// status:null (a module-not-found-class failure). Folded — plan-critic #7.
|
||||
function runWrapper(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
cwd: string = trendsDir,
|
||||
): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("bash", [wrapper, ...args], {
|
||||
input: "",
|
||||
encoding: "utf8",
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
|
||||
describe("trends headless wrapper — run-daily.sh (RE-R3c / SC7, SC8)", () => {
|
||||
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
// One temp dir is BOTH the LINKEDIN_STUDIO_DATA root (so cron.log lands at <dir>/trends/cron.log)
|
||||
// AND holds the seeded store + brief out-dir, so the real per-user data dir is never touched.
|
||||
function fixture(): { dir: string; store: string; out: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "r3c-wrap-"));
|
||||
const store = join(dir, "s.json");
|
||||
writeFileSync(
|
||||
store,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
trends: [
|
||||
{
|
||||
id: "a",
|
||||
title: "Fresh Match",
|
||||
url: "https://e/a",
|
||||
source: "tavily",
|
||||
capturedAt: freshIso,
|
||||
publishedAt: freshIso,
|
||||
topics: ["ai", "gov"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return { dir, store, out: join(dir, "mb") };
|
||||
}
|
||||
|
||||
test("SC7: seeded store → dated brief .md written + exactly one compact cron.log line + exit 0", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
try {
|
||||
const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], {
|
||||
LINKEDIN_STUDIO_DATA: dir,
|
||||
});
|
||||
assert.equal(status, 0, "the deterministic brief exits 0");
|
||||
assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief .md is written");
|
||||
const log = readFileSync(join(dir, "trends", "cron.log"), "utf8");
|
||||
const lines = log.split("\n").filter((l) => l.trim().length > 0);
|
||||
assert.equal(lines.length, 1, "exactly one log line (the pretty-printed brief json compacted to one line)");
|
||||
assert.match(lines[0], /^\S+ exit=0 /, "the line is `<ts> exit=0 <compact-json>`");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC7: a second same-day run → byte-identical .md, surfacedCount not double-counted, second log line", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
try {
|
||||
assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0);
|
||||
const md1 = readFileSync(join(out, `${today}.md`), "utf8");
|
||||
assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0);
|
||||
const md2 = readFileSync(join(out, `${today}.md`), "utf8");
|
||||
assert.equal(md1, md2, "the same-day re-render is byte-identical");
|
||||
const persisted = JSON.parse(readFileSync(store, "utf8"));
|
||||
assert.equal(persisted.trends[0].surfacedCount, 1, "surfacedCount stays 1 (R3b per-day idempotency)");
|
||||
const lines = readFileSync(join(dir, "trends", "cron.log"), "utf8").split("\n").filter((l) => l.trim().length > 0);
|
||||
assert.equal(lines.length, 2, "two runs → two log lines");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC7: CWD-independent — runs from an unrelated cwd (the wrapper's `cd \"$DIR\"` resolves tsx)", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
const otherCwd = mkdtempSync(join(tmpdir(), "r3c-cwd-"));
|
||||
try {
|
||||
const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }, otherCwd);
|
||||
assert.equal(status, 0, "the wrapper resolves tsx from a cwd that is not the package dir");
|
||||
assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief is still written");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(otherCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SC8: data-path twin consistency — wrapper log dir == dirname(defaultStorePath()) == getDataRoot('trends')", () => {
|
||||
const { dir, store, out } = fixture();
|
||||
const saved = process.env.LINKEDIN_STUDIO_DATA;
|
||||
try {
|
||||
runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir });
|
||||
const wrapperLogDir = join(dir, "trends"); // where the wrapper actually wrote cron.log
|
||||
assert.ok(existsSync(join(wrapperLogDir, "cron.log")), "the wrapper wrote into <data>/trends");
|
||||
|
||||
// The TS store twin + the hooks `.mjs` twin, resolved under the SAME override.
|
||||
process.env.LINKEDIN_STUDIO_DATA = dir;
|
||||
assert.equal(dirname(defaultStorePath()), wrapperLogDir, "TS dirname(defaultStorePath()) == wrapper log dir");
|
||||
assert.equal(getDataRoot("trends"), wrapperLogDir, "hooks getDataRoot('trends') == wrapper log dir");
|
||||
|
||||
// Override-independence: a different root still resolves the two TS/JS twins identically.
|
||||
const other = mkdtempSync(join(tmpdir(), "r3c-twin-"));
|
||||
process.env.LINKEDIN_STUDIO_DATA = other;
|
||||
assert.equal(dirname(defaultStorePath()), getDataRoot("trends"), "the twins agree for any override root");
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
|
||||
else process.env.LINKEDIN_STUDIO_DATA = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue