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
|
|
@ -343,6 +343,11 @@ written to `<data-dir>/trends/morning-brief/YYYY-MM-DD.md` and ranks only on per
|
|||
(pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with
|
||||
`--fresh-days N`). Skip silently if the store has no deps installed — same escape hatch as Step 4.5.
|
||||
|
||||
The morning brief can also be **scheduled** to regenerate autonomously each morning — deterministic,
|
||||
from the current store — via `src/cli.ts schedule` (print-first: it emits a launchd/cron entry firing
|
||||
the `run-daily.sh` headless wrapper). That nightly run re-renders the brief only; your polling above
|
||||
stays the capture path (autonomous AI polling is a later slice).
|
||||
|
||||
**Step 5: Compile digest**
|
||||
|
||||
- Format using output template below
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@
|
|||
# 'export type TrendStatus' AND carries 'surfacedCount', scripts/trends/src/store.ts owns
|
||||
# 'export function markSurfaced', scripts/trends/src/brief.ts excludes handled via 'effectiveStatus',
|
||||
# AND scripts/trends/src/cli.ts exposes 'command === "act"', with a non-vacuity self-test) in
|
||||
# Section 16k; the assertion-count anti-erosion floor (SC6) in Section 18. All
|
||||
# Section 16k; the trends-scheduler/headless wiring guard (RE-R3c: scripts/trends/src/schedule.ts
|
||||
# emits 'export function launchdPlist' AND 'export function crontabLine', scripts/trends/src/cli.ts
|
||||
# exposes 'command === "schedule"', scripts/trends/run-daily.sh runs 'cli.ts" brief' AND uses
|
||||
# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the assertion-count
|
||||
# anti-erosion floor (SC6) in Section 18. All
|
||||
# are live below (Sections 8–18).
|
||||
#
|
||||
# Usage: bash scripts/test-runner.sh
|
||||
|
|
@ -706,7 +710,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then
|
|||
TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" )
|
||||
TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
TRENDS_TESTS_FLOOR=171 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log)
|
||||
TRENDS_TESTS_FLOOR=192 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper)
|
||||
if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then
|
||||
pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
|
||||
else
|
||||
|
|
@ -1304,6 +1308,71 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 16l: Trends Scheduler / Headless Wiring (research-engine RE-R3c) ---
|
||||
echo "--- Trends Scheduler / Headless Wiring ---"
|
||||
|
||||
# RE-R3c adds the autonomous trigger + headless entry: a `schedule` CLI verb emitting a launchd plist /
|
||||
# cron line (print-first), and a bash wrapper that runs the DETERMINISTIC brief headless. Five literals
|
||||
# must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
|
||||
# (1) schedule.ts emits the launchd plist, by 'export function launchdPlist';
|
||||
# (2) schedule.ts emits the cron line, by 'export function crontabLine';
|
||||
# (3) cli.ts exposes the schedule verb, by 'command === "schedule"';
|
||||
# (4) run-daily.sh runs the deterministic brief, by 'cli.ts" brief' (the wrapper owns the subcommand);
|
||||
# (5) run-daily.sh uses the data-path twin seam, by 'LINKEDIN_STUDIO_DATA:-'.
|
||||
# Non-vacuity self-test mirrors Section 16k: a probe carrying the launchdPlist pointer is accepted and
|
||||
# one without it rejected. Placed after Section 16k / before Section 18 (anti-erosion must run last so it
|
||||
# sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR.
|
||||
SCHED_PLIST_LIT='export function launchdPlist'
|
||||
SCHED_CRON_LIT='export function crontabLine'
|
||||
SCHED_VERB_LIT='command === "schedule"'
|
||||
SCHED_BRIEF_LIT='cli.ts" brief'
|
||||
SCHED_DATA_LIT='LINKEDIN_STUDIO_DATA:-'
|
||||
|
||||
I16L_SELFTEST_OK=1
|
||||
if ! echo 'a wired scheduler declares: export function launchdPlist(spec)' | grep -qF "$SCHED_PLIST_LIT"; then
|
||||
I16L_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired scheduler-emit probe was not detected"
|
||||
fi
|
||||
if echo 'an unwired module emits no artifact at all' | grep -qF "$SCHED_PLIST_LIT"; then
|
||||
I16L_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the scheduler-emit pointer"
|
||||
fi
|
||||
if [ "$I16L_SELFTEST_OK" -eq 1 ]; then
|
||||
pass "trends-scheduler self-test: the emit pointer is detected, the no-emit form rejected"
|
||||
else
|
||||
fail "trends-scheduler self-test failed — the scheduler-wiring lint is vacuous or over-eager"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCHED_PLIST_LIT" scripts/trends/src/schedule.ts; then
|
||||
pass "schedule.ts emits the launchd plist ('$SCHED_PLIST_LIT')"
|
||||
else
|
||||
fail "schedule.ts has no launchd emitter — add '$SCHED_PLIST_LIT' (RE-R3c scheduler)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCHED_CRON_LIT" scripts/trends/src/schedule.ts; then
|
||||
pass "schedule.ts emits the cron line ('$SCHED_CRON_LIT')"
|
||||
else
|
||||
fail "schedule.ts has no cron-line emitter — add '$SCHED_CRON_LIT' (RE-R3c scheduler)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCHED_VERB_LIT" scripts/trends/src/cli.ts; then
|
||||
pass "cli.ts exposes the schedule verb ('$SCHED_VERB_LIT')"
|
||||
else
|
||||
fail "cli.ts has no schedule verb — add '$SCHED_VERB_LIT' (RE-R3c trigger)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCHED_BRIEF_LIT" scripts/trends/run-daily.sh; then
|
||||
pass "run-daily.sh runs the deterministic brief ('$SCHED_BRIEF_LIT')"
|
||||
else
|
||||
fail "run-daily.sh does not run the brief — wire '$SCHED_BRIEF_LIT' (RE-R3c headless entry)"
|
||||
fi
|
||||
|
||||
if grep -qF "$SCHED_DATA_LIT" scripts/trends/run-daily.sh; then
|
||||
pass "run-daily.sh uses the data-path twin seam ('$SCHED_DATA_LIT')"
|
||||
else
|
||||
fail "run-daily.sh has no data-path seam — add '$SCHED_DATA_LIT' (RE-R3c fourth twin)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
|
||||
# The lint self-modifies its own checks, so a green run could mask a silently dropped
|
||||
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
|
||||
|
|
@ -1321,12 +1390,17 @@ echo ""
|
|||
# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep +
|
||||
# session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks
|
||||
# (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep +
|
||||
# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99.
|
||||
# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99; +6 for RE-R3b's six
|
||||
# UNCONDITIONAL Section-16k checks (lifecycle self-test + types.ts TrendStatus grep + types.ts
|
||||
# surfacedCount grep + store.ts markSurfaced grep + brief.ts effectiveStatus grep + cli.ts
|
||||
# act-verb grep) = 105; +6 for RE-R3c's six UNCONDITIONAL Section-16l checks (scheduler self-test
|
||||
# + schedule.ts launchdPlist grep + schedule.ts crontabLine grep + cli.ts schedule-verb grep +
|
||||
# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111.
|
||||
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
|
||||
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
|
||||
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
|
||||
# clone). Runs last so TOTAL_CHECKS sees every prior check.
|
||||
ASSERT_BASELINE_FLOOR=105
|
||||
ASSERT_BASELINE_FLOOR=111
|
||||
TOTAL_CHECKS=$((PASS + FAIL))
|
||||
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
|
||||
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ node --import tsx src/cli.ts brief --pillars "agents,engineering" \
|
|||
node --import tsx src/cli.ts act --id <id> # wrote about it
|
||||
node --import tsx src/cli.ts skip --id <id> # decided to pass on it
|
||||
node --import tsx src/cli.ts reset --id <id> # return it to the queue
|
||||
|
||||
# Autonomous trigger (RE-R3c) — emit/install a daily headless brief. PRINT-FIRST: the tool never runs
|
||||
# launchctl or the cron table; --install writes only the inert launchd plist file. Deterministic
|
||||
# (no AI capture — a later slice). Default 07:00; --platform auto → launchd on macOS, cron on Linux.
|
||||
node --import tsx src/cli.ts schedule --pillars "agents,engineering" \
|
||||
[--at 07:00] [--fresh-days 7] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
|
||||
```
|
||||
|
||||
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend
|
||||
|
|
@ -124,8 +130,27 @@ a scored entry shows `· <priority> (<mode>)` and the summary names the top entr
|
|||
As of **RE-R3b** the brief is a **work queue**: it **excludes** `acted`/`skipped` trends, shows
|
||||
each entry's `id` in backticks (copy-paste-ready for `act`/`skip --id`), flags a re-surfaced item
|
||||
with `· sett Nx` (prior-day count, ≥2), and — unless `--no-mark` — **records surfacing** on the
|
||||
store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render. An autonomous
|
||||
nightly trigger and a brief-history diff remain later slices.
|
||||
store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render.
|
||||
|
||||
## Autonomous trigger + headless entry (RE-R3c)
|
||||
|
||||
`schedule` makes the daily loop **closed**: it emits — print-first — a launchd plist (macOS) or cron
|
||||
line (Linux) firing the brief every morning, plus the exact activation command. `--install` writes only
|
||||
the **inert** launchd plist file under `~/Library/LaunchAgents/`; the tool **never** runs `launchctl`
|
||||
or the cron table — you run the one printed command. `--uninstall` prints the removal recipe (and
|
||||
removes the plist file if present).
|
||||
|
||||
Both schedulers invoke one tested headless wrapper, `run-daily.sh`, which runs the **deterministic**
|
||||
brief from a scheduler's profile-less environment: it resolves node (baked `NODE_BIN`, else
|
||||
`command -v`, else common locations), `cd`s into the package so `tsx` resolves, and appends one compact
|
||||
line `<ISO-ts> exit=<code> <json>` to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`.
|
||||
|
||||
The nightly run is **deterministic-brief-only (C1)**: it re-renders the brief from the current store
|
||||
— freshness-aging drops stale trends, `surfacedCount` accumulates day-over-day — but does **not** poll
|
||||
new sources. A double-fire on the same day is a safe no-op (RE-R3b per-day idempotency: byte-identical
|
||||
`.md`, `surfacedCount` not double-counted). The autonomous AI capture step (poll → score → capture
|
||||
before the brief) plugs into the documented seam in `run-daily.sh` as a later slice (e); a
|
||||
brief-history diff is also a later slice.
|
||||
|
||||
## Tests
|
||||
|
||||
|
|
|
|||
39
scripts/trends/run-daily.sh
Executable file
39
scripts/trends/run-daily.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's
|
||||
# profile-less env (launchd/cron inherit no shell profile). No AI.
|
||||
#
|
||||
# The (e) slice will insert a pre-brief AI capture step here (poll -> score -> capture)
|
||||
# before the `brief` call below; R3c builds ONLY the deterministic store -> artifact path.
|
||||
#
|
||||
# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot
|
||||
# / analytics/src/utils/storage.ts:getDataRoot (the shell form of references/data-path-convention.md
|
||||
# rule 1). Keep in sync. Bash 3.2-compatible: ASCII-only, all expansions quoted, no bash-4 features.
|
||||
set -eu
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR" # so `--import tsx` resolves node_modules even under cron's $HOME cwd
|
||||
|
||||
NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}"
|
||||
if [ -z "$NODE_BIN" ]; then
|
||||
for c in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node; do
|
||||
if [ -x "$c" ]; then NODE_BIN="$c"; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log"
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [ -z "$NODE_BIN" ]; then
|
||||
printf '%s exit=127 node not found\n' "$TS" >> "$LOG"
|
||||
exit 127
|
||||
fi
|
||||
|
||||
set +e
|
||||
OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$?
|
||||
set -e
|
||||
|
||||
# `brief --json` is pretty-printed (cli.ts JSON.stringify(…, null, 2)); collapse it to ONE line.
|
||||
OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')"
|
||||
printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG"
|
||||
exit "$CODE"
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
|
||||
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
|
||||
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
|
||||
*
|
||||
* The capture agent (research-engine) folds freshly-polled trends into the store via
|
||||
* `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput →
|
||||
|
|
@ -33,12 +35,20 @@
|
|||
* `capture` normalizes + folds each valid item into the store (persisting `publishedAt`),
|
||||
* reporting content-invalid items in the summary `errors[]`, never via the exit code.
|
||||
*
|
||||
* `schedule` (RE-R3c) is PRINT-FIRST: it emits a launchd plist (macOS) / cron line (Linux) firing the
|
||||
* DETERMINISTIC `brief` daily via the `run-daily.sh` headless wrapper, plus the exact activation command.
|
||||
* `--install` writes only the inert launchd plist FILE; the tool never runs `launchctl` or the cron table
|
||||
* (the operator runs the one printed command). No AI capture in the nightly run — that is a later slice.
|
||||
*
|
||||
* Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an
|
||||
* argument-class error; capture's content-invalid items stay in errors[] (never via the exit code).
|
||||
* `schedule` adds no new exit code: an autonomy install never RUNS the system mutation.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
addTrend,
|
||||
|
|
@ -56,6 +66,8 @@ import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
|
|||
import { triage } from "./score.js";
|
||||
import type { ScoreMode } from "./score.js";
|
||||
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js";
|
||||
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
|
||||
import type { ScheduleSpec } from "./schedule.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
|
@ -95,7 +107,8 @@ function usage(msg: string): never {
|
|||
" normalize < raw-item-or-batch.json\n" +
|
||||
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
|
||||
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]",
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
|
||||
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -327,6 +340,83 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "schedule") {
|
||||
// RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing
|
||||
// the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2).
|
||||
const pillars = splitTopics(flags.pillars);
|
||||
if (pillars.length === 0) usage("schedule needs --pillars <a,b>");
|
||||
|
||||
const at = flags.at && flags.at !== "true" ? flags.at : "07:00";
|
||||
const [hStr, mStr] = at.split(":");
|
||||
const hour = Number.parseInt(hStr, 10);
|
||||
const minute = Number.parseInt(mStr ?? "", 10);
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
usage("--at must be HH:MM (00:00-23:59)");
|
||||
}
|
||||
|
||||
let freshDays = 7;
|
||||
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
|
||||
const n = Number.parseInt(flags["fresh-days"], 10);
|
||||
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
|
||||
freshDays = n;
|
||||
}
|
||||
|
||||
const platformFlag = flags.platform && flags.platform !== "true" ? flags.platform : "auto";
|
||||
if (platformFlag !== "auto" && platformFlag !== "launchd" && platformFlag !== "cron") {
|
||||
usage("--platform must be auto|launchd|cron");
|
||||
}
|
||||
const platform: "launchd" | "cron" =
|
||||
platformFlag === "auto"
|
||||
? process.platform === "darwin"
|
||||
? "launchd"
|
||||
: "cron"
|
||||
: (platformFlag as "launchd" | "cron");
|
||||
|
||||
// Resolve every absolute path FROM THE RUNTIME (never hard-coded → domain-general).
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // .../scripts/trends/src
|
||||
const wrapperPath = join(here, "..", "run-daily.sh");
|
||||
const workingDir = join(here, "..");
|
||||
const nodeBin = process.execPath;
|
||||
// logPath is anchored to the DATA ROOT (dirname(defaultStorePath())), NOT the --store override, so a
|
||||
// custom --store never splits the plist StandardOutPath from the wrapper's own cron.log (sibling of
|
||||
// morning-brief/, matching brief.ts defaultBriefDir's dirname(defaultStorePath()) idiom).
|
||||
const logPath = join(dirname(defaultStorePath()), "cron.log");
|
||||
const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
|
||||
// env is canonical: always NODE_BIN + a resolved-absolute data root — pins the scheduled run to the
|
||||
// install-time root AND removes the wrapper's $HOME-unset `set -u` edge under a profile-less env.
|
||||
const env: Record<string, string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root };
|
||||
|
||||
// The wrapper hard-codes the `brief` subcommand → the baked args carry NO leading "brief".
|
||||
const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)];
|
||||
if (flags.store && flags.store !== "true") args.push("--store", storePath);
|
||||
|
||||
const label = defaultLabel();
|
||||
const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, minute, logPath, workingDir, env };
|
||||
const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
||||
|
||||
if (flags.uninstall === "true") {
|
||||
console.log(uninstallInstructions(spec, platform === "launchd" ? plistTarget : undefined));
|
||||
if (platform === "launchd" && existsSync(plistTarget)) rmSync(plistTarget);
|
||||
return;
|
||||
}
|
||||
if (flags.install === "true") {
|
||||
if (platform === "launchd") {
|
||||
mkdirSync(dirname(plistTarget), { recursive: true });
|
||||
writeFileSync(plistTarget, launchdPlist(spec), "utf8");
|
||||
console.log(plistTarget);
|
||||
console.log(installInstructions(spec, plistTarget));
|
||||
} else {
|
||||
console.log(crontabLine(spec));
|
||||
console.log(installInstructions(spec));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// default / --print — emit the artifact + the activation command. No fs.
|
||||
console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec));
|
||||
console.log(installInstructions(spec, platform === "launchd" ? plistTarget : undefined));
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
|
|
|
|||
111
scripts/trends/src/schedule.ts
Normal file
111
scripts/trends/src/schedule.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* RE-R3c — pure string emitters for the autonomous-trigger artifacts (research-engine).
|
||||
*
|
||||
* No clock, no fs, no env, no AI: every value the emitters render is injected via `ScheduleSpec`
|
||||
* (the CLI is the only edge that reads `process.execPath` / `import.meta.url` / `defaultStorePath`).
|
||||
* Mirrors `brief.ts`'s `renderBrief` purity → byte-deterministic given inputs, fully testable.
|
||||
*
|
||||
* Print-first (C2): these are STRINGS. `launchdPlist`/`crontabLine` emit the artifact; the install/
|
||||
* uninstall instructions emit the exact command the operator runs. Nothing here ever executes
|
||||
* `launchctl` or the cron table — the emitted recipes are surfaced for the operator to run.
|
||||
*/
|
||||
|
||||
export interface ScheduleSpec {
|
||||
platform: "launchd" | "cron";
|
||||
label: string;
|
||||
nodeBin: string;
|
||||
wrapperPath: string;
|
||||
args: string[];
|
||||
hour: number;
|
||||
minute: number;
|
||||
logPath: string;
|
||||
workingDir: string;
|
||||
/** Canonical injected environment — always { NODE_BIN, LINKEDIN_STUDIO_DATA(resolved-absolute) }. */
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Defensive XML escaping for well-formedness (paths are normally safe, but escape regardless). */
|
||||
function xmlEscape(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* The launchd plist (macOS). A pinned, well-formed `<?xml … !DOCTYPE plist …>` template firing
|
||||
* the wrapper daily via StartCalendarInterval. `RunAtLoad` is false (a calendar job, not boot-time).
|
||||
*/
|
||||
export function launchdPlist(spec: ScheduleSpec): string {
|
||||
const programArguments = ["/bin/bash", spec.wrapperPath, ...spec.args]
|
||||
.map((a) => ` <string>${xmlEscape(a)}</string>`)
|
||||
.join("\n");
|
||||
const environment = Object.entries(spec.env)
|
||||
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${xmlEscape(spec.label)}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${programArguments}
|
||||
</array>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>${spec.hour}</integer>
|
||||
<key>Minute</key>
|
||||
<integer>${spec.minute}</integer>
|
||||
</dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
${environment}
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${xmlEscape(spec.workingDir)}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${xmlEscape(spec.logPath)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${xmlEscape(spec.logPath)}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cron schedule line (Linux). Returns the line as a STRING; never executes the cron table.
|
||||
* `<min> <hour> * * * <env-prefix> /bin/bash <wrapper> <args…> >> <log> 2>&1 # <label>`.
|
||||
*/
|
||||
export function crontabLine(spec: ScheduleSpec): string {
|
||||
const envPrefix = Object.entries(spec.env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" ");
|
||||
const prefix = envPrefix.length > 0 ? `${envPrefix} ` : "";
|
||||
return (
|
||||
`${spec.minute} ${spec.hour} * * * ${prefix}/bin/bash ${spec.wrapperPath} ` +
|
||||
`${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}`
|
||||
);
|
||||
}
|
||||
|
||||
/** The exact activation command for the operator to run (print-first; the tool never runs it). */
|
||||
export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string {
|
||||
if (spec.platform === "launchd") {
|
||||
return `Wrote ${plistTargetPath}. Activate it with:\n launchctl bootstrap gui/$(id -u) ${plistTargetPath}`;
|
||||
}
|
||||
return `Add the line above to your schedule with:\n (crontab -l 2>/dev/null; echo '${crontabLine(spec)}') | crontab -`;
|
||||
}
|
||||
|
||||
/** The symmetric removal command (print-first). */
|
||||
export function uninstallInstructions(spec: ScheduleSpec, plistTargetPath?: string): string {
|
||||
if (spec.platform === "launchd") {
|
||||
return `Deactivate + remove with:\n launchctl bootout gui/$(id -u)/${spec.label} && rm ${plistTargetPath}`;
|
||||
}
|
||||
return `Remove the scheduled line with:\n crontab -l | grep -vF '# ${spec.label}' | crontab -`;
|
||||
}
|
||||
|
||||
/** The reverse-DNS plugin namespace — domain-general, no vendor/sector token. */
|
||||
export function defaultLabel(): string {
|
||||
return "com.linkedin-studio.trends.daily";
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
120
scripts/trends/tests/schedule.test.ts
Normal file
120
scripts/trends/tests/schedule.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
launchdPlist,
|
||||
crontabLine,
|
||||
installInstructions,
|
||||
uninstallInstructions,
|
||||
defaultLabel,
|
||||
type ScheduleSpec,
|
||||
} from "../src/schedule.js";
|
||||
|
||||
// A fully-resolved spec, exactly as the CLI builds it at generation time (every value injected —
|
||||
// the emitters read no clock/fs/env). `env` is canonical: always { NODE_BIN, LINKEDIN_STUDIO_DATA }.
|
||||
function launchdSpec(): ScheduleSpec {
|
||||
return {
|
||||
platform: "launchd",
|
||||
label: "com.linkedin-studio.trends.daily",
|
||||
nodeBin: "/usr/local/bin/node",
|
||||
wrapperPath: "/repo/scripts/trends/run-daily.sh",
|
||||
args: ["--pillars", "ai,gov", "--fresh-days", "7"],
|
||||
hour: 7,
|
||||
minute: 30,
|
||||
logPath: "/data/trends/cron.log",
|
||||
workingDir: "/repo/scripts/trends",
|
||||
env: { NODE_BIN: "/usr/local/bin/node", LINKEDIN_STUDIO_DATA: "/data" },
|
||||
};
|
||||
}
|
||||
const cronSpec = (): ScheduleSpec => ({ ...launchdSpec(), platform: "cron" });
|
||||
|
||||
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;
|
||||
if (m[1] === "/") {
|
||||
if (stack.pop() !== m[2]) return false;
|
||||
} else {
|
||||
stack.push(m[2]);
|
||||
}
|
||||
}
|
||||
return stack.length === 0;
|
||||
}
|
||||
|
||||
describe("schedule.ts — pure launchd plist emitter (SC1)", () => {
|
||||
test("key-complete: Label, ProgramArguments, StartCalendarInterval(H/M), Std*Path, EnvironmentVariables", () => {
|
||||
const p = launchdPlist(launchdSpec());
|
||||
assert.ok(p.includes("<key>Label</key>") && p.includes("com.linkedin-studio.trends.daily"), "Label");
|
||||
assert.ok(p.includes("<key>ProgramArguments</key>") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments");
|
||||
assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as <string> entries");
|
||||
assert.ok(p.includes("<key>StartCalendarInterval</key>"), "StartCalendarInterval");
|
||||
assert.match(p, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
|
||||
assert.match(p, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
|
||||
assert.ok(p.includes("<key>StandardOutPath</key>") && p.includes("<key>StandardErrorPath</key>"), "Std*Path");
|
||||
assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log");
|
||||
assert.ok(p.includes("<key>EnvironmentVariables</key>") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered");
|
||||
assert.ok(p.includes("<key>WorkingDirectory</key>") && p.includes("/repo/scripts/trends"), "WorkingDirectory");
|
||||
assert.ok(p.includes("RunAtLoad"), "RunAtLoad declared");
|
||||
});
|
||||
|
||||
test("well-formed: balanced tags + plist DOCTYPE wrapper", () => {
|
||||
const p = launchdPlist(launchdSpec());
|
||||
assert.ok(p.startsWith("<?xml"), "begins with the XML declaration");
|
||||
assert.ok(p.includes("<!DOCTYPE plist"), "has the plist DOCTYPE");
|
||||
assert.ok(p.trimEnd().endsWith("</plist>"), "closes the plist element");
|
||||
assert.ok(isBalancedXml(p), "every element tag is balanced");
|
||||
});
|
||||
|
||||
test("pure / deterministic: two calls are byte-identical", () => {
|
||||
assert.equal(launchdPlist(launchdSpec()), launchdPlist(launchdSpec()));
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule.ts — pure cron line emitter (SC2, string only)", () => {
|
||||
test("the line carries time, env prefix, /bin/bash, wrapper, args, log redirect, label comment", () => {
|
||||
const line = crontabLine(cronSpec());
|
||||
assert.match(line, /^30 7 \* \* \* /, "fires 07:30 daily");
|
||||
assert.ok(line.includes("NODE_BIN=/usr/local/bin/node"), "the env prefix carries NODE_BIN");
|
||||
assert.ok(line.includes("LINKEDIN_STUDIO_DATA=/data"), "the env prefix carries the data root");
|
||||
assert.ok(line.includes("/bin/bash") && line.includes("run-daily.sh"), "invokes the wrapper via bash");
|
||||
assert.ok(line.includes("--pillars ai,gov"), "carries the pillars");
|
||||
assert.ok(line.includes(">> /data/trends/cron.log 2>&1"), "redirects stdout+stderr to the log");
|
||||
assert.ok(line.trimEnd().endsWith("# com.linkedin-studio.trends.daily"), "ends with the label comment");
|
||||
assert.ok(!line.includes("\n"), "a single line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule.ts — install / uninstall instruction strings", () => {
|
||||
const plistTarget = "/home/u/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist";
|
||||
|
||||
test("install launchd: written-path + launchctl bootstrap recipe", () => {
|
||||
const out = installInstructions(launchdSpec(), plistTarget);
|
||||
assert.ok(out.includes(plistTarget), "names the written plist path");
|
||||
assert.ok(out.includes("launchctl bootstrap"), "the activation recipe");
|
||||
});
|
||||
|
||||
test("install cron: the crontab - install recipe carrying the line", () => {
|
||||
const out = installInstructions(cronSpec());
|
||||
assert.ok(out.includes("crontab -"), "the install recipe");
|
||||
assert.ok(out.includes("run-daily.sh"), "carries the line to install");
|
||||
});
|
||||
|
||||
test("uninstall launchd: bootout + rm the plist", () => {
|
||||
const out = uninstallInstructions(launchdSpec(), plistTarget);
|
||||
assert.ok(out.includes("launchctl bootout"), "the deactivation recipe");
|
||||
assert.ok(out.includes(plistTarget), "removes the plist file");
|
||||
});
|
||||
|
||||
test("uninstall cron: the line-removal recipe", () => {
|
||||
const out = uninstallInstructions(cronSpec());
|
||||
assert.ok(out.includes("crontab -") && out.includes("com.linkedin-studio.trends.daily"), "removes by the label comment");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule.ts — defaultLabel (SC1/SC2 the plugin namespace)", () => {
|
||||
test("is the reverse-DNS plugin namespace", () => {
|
||||
assert.equal(defaultLabel(), "com.linkedin-studio.trends.daily");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue