feat(schedule): launch banner, headless query field, auto_dispatch type
This commit is contained in:
parent
a3f76d2679
commit
94ba38bdd6
6 changed files with 341 additions and 7 deletions
12
src/cli.ts
12
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`),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
58
src/resources/extensions/sf/schedule-launch-banner.js
Normal file
58
src/resources/extensions/sf/schedule-launch-banner.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/tests/schedule-headless-query.test.ts
Normal file
43
src/tests/schedule-headless-query.test.ts
Normal file
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue