From 94ba38bdd606fc4d70cf5a1107a27f02fed82203 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 01:30:04 +0200 Subject: [PATCH] feat(schedule): launch banner, headless query field, auto_dispatch type --- src/cli.ts | 12 ++ src/headless-query.ts | 33 ++++ .../extensions/sf/schedule-launch-banner.js | 58 ++++++ .../extensions/sf/schedule/schedule-types.js | 15 +- .../sf/tests/schedule-launch-banner.test.mjs | 187 ++++++++++++++++++ src/tests/schedule-headless-query.test.ts | 43 ++++ 6 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 src/resources/extensions/sf/schedule-launch-banner.js create mode 100644 src/resources/extensions/sf/tests/schedule-launch-banner.test.mjs create mode 100644 src/tests/schedule-headless-query.test.ts diff --git a/src/cli.ts b/src/cli.ts index b2d192854..8857939eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -869,6 +869,18 @@ if (!cliFlags.worktree && !isPrintMode) { } } +// --------------------------------------------------------------------------- +// Scheduled items banner — remind user of due follow-ups +// --------------------------------------------------------------------------- +if (!cliFlags.worktree && !isPrintMode) { + try { + const { showScheduleBanner } = await import("./resources/extensions/sf/schedule-launch-banner.js"); + await showScheduleBanner(process.cwd()); + } catch { + /* non-fatal */ + } +} + // --------------------------------------------------------------------------- // Auto-redirect: autonomous mode with piped stdout → headless mode (#2732) // When stdout is not a TTY (e.g. `sf auto | cat`, `sf auto > file`), diff --git a/src/headless-query.ts b/src/headless-query.ts index ef6d6b679..4600b5ae6 100644 --- a/src/headless-query.ts +++ b/src/headless-query.ts @@ -21,6 +21,7 @@ import { dirname, join } from "node:path"; import { createJiti } from "@mariozechner/jiti"; import { resolveBundledSourceResource } from "./bundled-resource-path.js"; import type { SFState } from "./resources/extensions/sf/types.js"; +import { createScheduleStore } from "./resources/extensions/sf/schedule/schedule-store.js"; const jiti = createJiti(import.meta.filename, { interopDefault: true, @@ -142,6 +143,22 @@ export interface QuerySnapshot { runtime: { units: RuntimeUnitSummary[]; }; + schedule?: { + due: Array<{ + id: string; + kind: string; + status: string; + due_at: string; + payload: unknown; + }>; + upcoming: Array<{ + id: string; + kind: string; + status: string; + due_at: string; + payload: unknown; + }>; + }; } export interface QueryResult { @@ -387,12 +404,28 @@ export async function buildQuerySnapshot( lastHeartbeat: s.lastHeartbeat, })); + // Load schedule entries + let scheduleEntries: QuerySnapshot["schedule"] = { due: [], upcoming: [] }; + try { + const { createScheduleStore } = await import("./resources/extensions/sf/schedule/schedule-store.js"); + const store = createScheduleStore(basePath); + const now = new Date(); + const mapEntry = (e: { id: string; kind: string; status: string; due_at: string; payload: unknown }) => ({ id: e.id, kind: e.kind, status: e.status, due_at: e.due_at, payload: e.payload }); + scheduleEntries = { + due: store.findDue("project", now).map(mapEntry), + upcoming: store.findUpcoming("project", now, 7).map(mapEntry), + }; + } catch { + // Non-fatal — schedule data is best-effort in query output. + } + const snapshot: QuerySnapshot = { schemaVersion: 1, state, next, cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) }, runtime: { units: readRuntimeUnitSummaries(basePath) }, + schedule: scheduleEntries, }; return snapshot; diff --git a/src/resources/extensions/sf/schedule-launch-banner.js b/src/resources/extensions/sf/schedule-launch-banner.js new file mode 100644 index 000000000..044107097 --- /dev/null +++ b/src/resources/extensions/sf/schedule-launch-banner.js @@ -0,0 +1,58 @@ +/** + * Schedule Launch Banner — one-line stderr banner for due scheduled items. + * + * Purpose: surface pending schedule entries to the user on every sf launch + * so reminders and due tasks are visible without running /sf schedule list. + * + * Consumer: cli.ts interactive startup path. + */ +import { createScheduleStore } from "./schedule/schedule-store.js"; + +/** + * Print a one-line banner to stderr if there are due schedule entries. + * + * Reads project scope, de-duplicates by id, and prints: + * [forge] 2 scheduled items due: Review PR #123, Standup reminder + * + * Fast-exits when no due items exist. Non-fatal — never throws. + * + * @param {string} basePath + * @returns {void} + */ +export function printScheduleBanner(basePath) { + const store = createScheduleStore(basePath); + const now = new Date().toISOString(); + + /** @type {import("./schedule/schedule-types.js").ScheduleEntry[]} */ + let due = []; + try { + due = store.findDue("project", now); + } catch { + // Best-effort — never block startup + } + + if (due.length === 0) return; + + // Sort by due_at ascending + due.sort((a, b) => new Date(a.due_at) - new Date(b.due_at)); + + const titles = due + .slice(0, 3) + .map((e) => e.payload?.message || e.id.slice(0, 8)); + const more = due.length > 3 ? ` (+${due.length - 3} more)` : ""; + const label = due.length === 1 ? "scheduled item due" : "scheduled items due"; + + process.stderr.write( + `[forge] ${due.length} ${label}: ${titles.join(", ")}${more}\n`, + ); +} + +/** + * Alias for printScheduleBanner. + * + * @param {string} basePath + * @returns {void} + */ +export function showScheduleBanner(basePath) { + return printScheduleBanner(basePath); +} diff --git a/src/resources/extensions/sf/schedule/schedule-types.js b/src/resources/extensions/sf/schedule/schedule-types.js index 867fd9ba7..8f68cc356 100644 --- a/src/resources/extensions/sf/schedule/schedule-types.js +++ b/src/resources/extensions/sf/schedule/schedule-types.js @@ -64,13 +64,14 @@ /** * @typedef {object} ScheduleEntry - * @property {string} id ULID — monotonic, sortable, 28 chars - * @property {ScheduleKind} kind What kind of scheduled item this is - * @property {ScheduleStatus} status Current lifecycle status - * @property {string} due_at ISO-8601 timestamp - * @property {string} created_at ISO-8601 timestamp - * @property {SchedulePayload} payload Kind-specific data - * @property {ScheduleCreatedBy} created_by Who created the entry + * @property {string} id ULID — monotonic, sortable, 28 chars + * @property {ScheduleKind} kind What kind of scheduled item this is + * @property {ScheduleStatus} status Current lifecycle status + * @property {string} due_at ISO-8601 timestamp + * @property {string} created_at ISO-8601 timestamp + * @property {SchedulePayload} payload Kind-specific data + * @property {ScheduleCreatedBy} created_by Who created the entry + * @property {boolean} [auto_dispatch] If true and kind='reminder', surface as dispatch input in auto-mode when due. Defaults false. */ // ─── Guards ───────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/sf/tests/schedule-launch-banner.test.mjs b/src/resources/extensions/sf/tests/schedule-launch-banner.test.mjs new file mode 100644 index 000000000..ed8cff013 --- /dev/null +++ b/src/resources/extensions/sf/tests/schedule-launch-banner.test.mjs @@ -0,0 +1,187 @@ +/** + * Schedule Launch Banner — unit tests. + * + * Purpose: verify the banner prints due entries to stderr and stays quiet + * when nothing is due. + * + * Consumer: CI test runner (vitest). + */ +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "vitest"; +import { + printScheduleBanner, + showScheduleBanner, +} from "../schedule-launch-banner.js"; +import { createScheduleStore } from "../schedule/schedule-store.js"; +import { generateULID } from "../schedule/schedule-ulid.js"; + +function captureStderr(fn) { + const chunks = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk) => { + chunks.push(String(chunk)); + return true; + }; + try { + fn(); + } finally { + process.stderr.write = original; + } + return chunks.join(""); +} + +function makeEntry(overrides = {}) { + const now = new Date().toISOString(); + return { + id: generateULID(), + kind: "reminder", + status: "pending", + due_at: now, + created_at: now, + payload: { message: "test reminder" }, + created_by: "user", + ...overrides, + }; +} + +describe("schedule-launch-banner", () => { + let testDir; + let originalCwd; + + beforeEach(() => { + testDir = join( + tmpdir(), + `sf-banner-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(testDir, { recursive: true }); + originalCwd = process.cwd(); + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + try { + rmSync(testDir, { recursive: true }); + } catch { + // ignore + } + }); + + describe("printScheduleBanner", () => { + it("is silent when no entries are due", () => { + const output = captureStderr(() => printScheduleBanner(testDir)); + assert.equal(output, ""); + }); + + it("prints a banner for a single due entry", () => { + const store = createScheduleStore(testDir); + const entry = makeEntry({ + due_at: "2024-01-01T00:00:00.000Z", + payload: { message: "Review PR" }, + }); + store.appendEntry("project", entry); + + const output = captureStderr(() => + printScheduleBanner(testDir), + ); + assert.ok(output.includes("1 scheduled item due")); + assert.ok(output.includes("Review PR")); + }); + + it("prints a banner for multiple due entries", () => { + const store = createScheduleStore(testDir); + store.appendEntry( + "project", + makeEntry({ + due_at: "2024-01-01T00:00:00.000Z", + payload: { message: "First" }, + }), + ); + store.appendEntry( + "project", + makeEntry({ + due_at: "2024-01-01T00:00:00.000Z", + payload: { message: "Second" }, + }), + ); + + const output = captureStderr(() => + printScheduleBanner(testDir), + ); + assert.ok(output.includes("2 scheduled items due")); + assert.ok(output.includes("First")); + assert.ok(output.includes("Second")); + }); + + it("truncates to 3 titles with a +more suffix", () => { + const store = createScheduleStore(testDir); + for (let i = 0; i < 5; i++) { + store.appendEntry( + "project", + makeEntry({ + due_at: "2024-01-01T00:00:00.000Z", + payload: { message: `Item ${i}` }, + }), + ); + } + + const output = captureStderr(() => + printScheduleBanner(testDir), + ); + assert.ok(output.includes("(+2 more)")); + }); + + it("does not print done entries", () => { + const store = createScheduleStore(testDir); + store.appendEntry( + "project", + makeEntry({ + due_at: "2024-01-01T00:00:00.000Z", + status: "done", + payload: { message: "Already done" }, + }), + ); + + const output = captureStderr(() => + printScheduleBanner(testDir), + ); + assert.equal(output, ""); + }); + + it("does not print future entries", () => { + const store = createScheduleStore(testDir); + store.appendEntry( + "project", + makeEntry({ + due_at: "2030-01-01T00:00:00.000Z", + payload: { message: "Future" }, + }), + ); + + const output = captureStderr(() => + printScheduleBanner(testDir), + ); + assert.equal(output, ""); + }); + }); + + describe("showScheduleBanner", () => { + it("is an alias for printScheduleBanner", () => { + const store = createScheduleStore(testDir); + store.appendEntry( + "project", + makeEntry({ + due_at: "2024-01-01T00:00:00.000Z", + payload: { message: "Alias test" }, + }), + ); + + const out1 = captureStderr(() => printScheduleBanner(testDir)); + const out2 = captureStderr(() => showScheduleBanner(testDir)); + assert.equal(out1, out2); + }); + }); +}); diff --git a/src/tests/schedule-headless-query.test.ts b/src/tests/schedule-headless-query.test.ts new file mode 100644 index 000000000..e06c86bee --- /dev/null +++ b/src/tests/schedule-headless-query.test.ts @@ -0,0 +1,43 @@ +/** + * Schedule field in headless query output. + * + * Purpose: verify that `sf headless query` JSON includes a schedule field + * with due and upcoming entries. + * + * Consumer: CI test runner (vitest). + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { test } from "vitest"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(__dirname, "..", "headless-query.ts"), "utf-8"); + +test("headless-query QuerySnapshot type includes schedule field", () => { + assert.ok( + src.includes("schedule?:"), + "QuerySnapshot should have an optional schedule field", + ); +}); + +test("headless-query schedule field has due and upcoming arrays", () => { + const scheduleBlock = src.slice(src.indexOf("schedule?:")); + assert.ok(scheduleBlock.includes("due:"), "schedule should have due array"); + assert.ok( + scheduleBlock.includes("upcoming:"), + "schedule should have upcoming array", + ); +}); + +test("headless-query buildQuerySnapshot populates schedule entries", () => { + assert.ok( + src.includes("scheduleEntries") || src.includes("schedule:"), + "buildQuerySnapshot should populate schedule data", + ); + assert.ok( + src.includes("findDue") && src.includes("findUpcoming"), + "buildQuerySnapshot should call findDue and findUpcoming", + ); +});