pi-cicd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * pi-ci — Answer injection from a JSON file.
3
+ *
4
+ * When Pi encounters an interactive prompt in CI mode, it consults an
5
+ * answers file for a pre-supplied response.
6
+ */
7
+
8
+ import type { AnswerEntry, AnswerFile } from "../types.ts";
9
+
10
+ /**
11
+ * Read and validate an answers JSON file.
12
+ *
13
+ * - Returns an empty array if the file cannot be read.
14
+ * - Skips entries that are missing `match` or `answer` fields.
15
+ * - Throws on invalid JSON.
16
+ */
17
+ export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
18
+ let text: string;
19
+ try {
20
+ text = await Bun.file(filePath).text();
21
+ } catch {
22
+ return [];
23
+ }
24
+
25
+ const raw: unknown = JSON.parse(text);
26
+
27
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
28
+ throw new Error(`Answers file must contain a JSON object with an "answers" array`);
29
+ }
30
+
31
+ const obj = raw as Record<string, unknown>;
32
+ if (!Array.isArray(obj.answers)) {
33
+ throw new Error(`Answers file must contain an "answers" array`);
34
+ }
35
+
36
+ const entries: AnswerEntry[] = [];
37
+ for (const item of obj.answers) {
38
+ if (
39
+ typeof item === "object" &&
40
+ item !== null &&
41
+ typeof (item as Record<string, unknown>).match === "string" &&
42
+ typeof (item as Record<string, unknown>).answer === "string"
43
+ ) {
44
+ entries.push(item as AnswerEntry);
45
+ }
46
+ // Silently skip malformed entries
47
+ }
48
+
49
+ return entries;
50
+ }
51
+
52
+ /**
53
+ * Synchronous variant that reads from a string (useful for testing).
54
+ */
55
+ export function parseAnswers(jsonText: string): AnswerEntry[] {
56
+ const raw: unknown = JSON.parse(jsonText);
57
+
58
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
59
+ throw new Error(`Answers file must contain a JSON object with an "answers" array`);
60
+ }
61
+
62
+ const obj = raw as Record<string, unknown>;
63
+ if (!Array.isArray(obj.answers)) {
64
+ throw new Error(`Answers file must contain an "answers" array`);
65
+ }
66
+
67
+ const entries: AnswerEntry[] = [];
68
+ for (const item of obj.answers) {
69
+ if (
70
+ typeof item === "object" &&
71
+ item !== null &&
72
+ typeof (item as Record<string, unknown>).match === "string" &&
73
+ typeof (item as Record<string, unknown>).answer === "string"
74
+ ) {
75
+ entries.push(item as AnswerEntry);
76
+ }
77
+ }
78
+
79
+ return entries;
80
+ }
81
+
82
+ /**
83
+ * Find a matching answer for the given prompt using substring matching.
84
+ *
85
+ * Returns the first answer whose `match` is found as a substring of `prompt`,
86
+ * or `undefined` if no match is found.
87
+ */
88
+ export function matchAnswer(
89
+ entries: AnswerEntry[],
90
+ prompt: string,
91
+ ): string | undefined {
92
+ for (const entry of entries) {
93
+ if (prompt.includes(entry.match)) {
94
+ return entry.answer;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * pi-ci — Exit code resolution helpers.
3
+ */
4
+
5
+ import { EXIT_CODES, type ExitCode } from "../types.ts";
6
+
7
+ /**
8
+ * Map a symbolic status string to a numeric exit code.
9
+ *
10
+ * Unknown / unexpected statuses resolve to ERROR (1).
11
+ */
12
+ export function resolveExitCode(status: string): ExitCode {
13
+ switch (status) {
14
+ case "success":
15
+ return EXIT_CODES.SUCCESS;
16
+ case "error":
17
+ case "timeout":
18
+ return EXIT_CODES.ERROR;
19
+ case "blocked":
20
+ return EXIT_CODES.BLOCKED;
21
+ case "cancelled":
22
+ return EXIT_CODES.CANCELLED;
23
+ case "needs_input":
24
+ case "needs-input":
25
+ return EXIT_CODES.NEEDS_INPUT;
26
+ default:
27
+ return EXIT_CODES.ERROR;
28
+ }
29
+ }
30
+
31
+ export { EXIT_CODES };
32
+ export type { ExitCode };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * pi-ci — Idle timeout detection.
3
+ *
4
+ * If no activity is detected within the configured timeout, the callback fires.
5
+ */
6
+
7
+ export interface IdleDetectorOptions {
8
+ /** Timeout in milliseconds. Default: 15 000. */
9
+ idleTimeoutMs?: number;
10
+ /** Called when the idle timeout is reached. */
11
+ onTimeout: () => void;
12
+ }
13
+
14
+ const DEFAULT_IDLE_TIMEOUT_MS = 15_000;
15
+
16
+ export class IdleDetector {
17
+ private readonly idleTimeoutMs: number;
18
+ private readonly onTimeout: () => void;
19
+ private timer: ReturnType<typeof setTimeout> | null = null;
20
+ private _running = false;
21
+ private _fired = false;
22
+
23
+ constructor(options: IdleDetectorOptions) {
24
+ this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
25
+ this.onTimeout = options.onTimeout;
26
+ }
27
+
28
+ /** Whether the detector is currently running. */
29
+ get running(): boolean {
30
+ return this._running;
31
+ }
32
+
33
+ /** Whether the timeout has already fired. */
34
+ get fired(): boolean {
35
+ return this._fired;
36
+ }
37
+
38
+ /** Start the idle timer. Safe to call multiple times (no-op if already running). */
39
+ start(): void {
40
+ if (this._running) return;
41
+ this._running = true;
42
+ this._fired = false;
43
+ this.scheduleTimer();
44
+ }
45
+
46
+ /** Reset the idle timer. If not running, this is a no-op. */
47
+ reset(): void {
48
+ if (!this._running) return;
49
+ this.clearTimer();
50
+ this.scheduleTimer();
51
+ }
52
+
53
+ /** Stop the idle timer. */
54
+ stop(): void {
55
+ this._running = false;
56
+ this._fired = false;
57
+ this.clearTimer();
58
+ }
59
+
60
+ private scheduleTimer(): void {
61
+ this.timer = setTimeout(() => {
62
+ if (this._running) {
63
+ this._running = false;
64
+ this._fired = true;
65
+ this.onTimeout();
66
+ }
67
+ }, this.idleTimeoutMs);
68
+ }
69
+
70
+ private clearTimer(): void {
71
+ if (this.timer !== null) {
72
+ clearTimeout(this.timer);
73
+ this.timer = null;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * pi-ci — JSONL event stream utilities.
3
+ *
4
+ * Writes CI events as single-line JSON to a writable stream and provides
5
+ * type-guard helpers for event discrimination.
6
+ */
7
+
8
+ import type { Writable } from "node:stream";
9
+ import type {
10
+ CIEvent,
11
+ CIStartEvent,
12
+ CIProgressEvent,
13
+ CIEditEvent,
14
+ CITestEvent,
15
+ CICostEvent,
16
+ CIEndEvent,
17
+ } from "../types.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Write helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Ensure an event has a timestamp, injecting `now` if missing.
25
+ */
26
+ function ensureTimestamp<T extends CIEvent>(event: T): T & { timestamp: string } {
27
+ if (!event.timestamp) {
28
+ return { ...event, timestamp: new Date().toISOString() };
29
+ }
30
+ return event as T & { timestamp: string };
31
+ }
32
+
33
+ /**
34
+ * Serialise a CI event and write it as a single JSONL line to the stream.
35
+ */
36
+ export function writeCIEvent(stream: Writable, event: CIEvent): void {
37
+ const stamped = ensureTimestamp(event);
38
+ stream.write(JSON.stringify(stamped) + "\n");
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Event emitter (collects events for reporting)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export class CIEventCollector {
46
+ private readonly events: CIEvent[] = [];
47
+
48
+ /** Record an event. Auto-fills timestamp if missing. */
49
+ emit(event: CIEvent): void {
50
+ this.events.push(ensureTimestamp(event));
51
+ }
52
+
53
+ /** Return all collected events in order. */
54
+ all(): CIEvent[] {
55
+ return [...this.events];
56
+ }
57
+
58
+ /** Reset the collector. */
59
+ clear(): void {
60
+ this.events.length = 0;
61
+ }
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Type guards
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export function isCIStartEvent(e: CIEvent): e is CIStartEvent {
69
+ return e.type === "ci_start";
70
+ }
71
+
72
+ export function isCIProgressEvent(e: CIEvent): e is CIProgressEvent {
73
+ return e.type === "ci_progress";
74
+ }
75
+
76
+ export function isCIEditEvent(e: CIEvent): e is CIEditEvent {
77
+ return e.type === "ci_edit";
78
+ }
79
+
80
+ export function isCITestEvent(e: CIEvent): e is CITestEvent {
81
+ return e.type === "ci_test";
82
+ }
83
+
84
+ export function isCICostEvent(e: CIEvent): e is CICostEvent {
85
+ return e.type === "ci_cost";
86
+ }
87
+
88
+ export function isCIEndEvent(e: CIEvent): e is CIEndEvent {
89
+ return e.type === "ci_end";
90
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * pi-ci — Headless orchestrator.
3
+ *
4
+ * Ties together exit codes, answer injection, idle detection, and JSONL
5
+ * streaming into a single execution loop.
6
+ */
7
+
8
+ import type { ExitCode, CIEvent, CIOptions } from "../types.ts";
9
+ import { EXIT_CODES } from "../types.ts";
10
+ import { resolveExitCode } from "./exit-codes.ts";
11
+ import { matchAnswer, type AnswerEntry } from "./answer-injector.ts";
12
+ import { IdleDetector } from "./idle-detector.ts";
13
+ import { CIEventCollector, writeCIEvent } from "./jsonl-stream.ts";
14
+ import type { Writable } from "node:stream";
15
+
16
+ export interface OrchestratorResult {
17
+ exitCode: ExitCode;
18
+ events: CIEvent[];
19
+ durationMs: number;
20
+ }
21
+
22
+ export interface OrchestratorHooks {
23
+ /** Called for each step of execution. Return a status string to signal completion. */
24
+ executeStep: (
25
+ prompt: string,
26
+ injectAnswer: (question: string) => string | undefined,
27
+ ) => Promise<StepResult>;
28
+ /** Optional writable for JSONL streaming. */
29
+ outputStream?: Writable;
30
+ }
31
+
32
+ export interface StepResult {
33
+ status: string;
34
+ edits?: { file: string; lines_added: number; lines_removed: number }[];
35
+ tests?: { command: string; passed: number; failed: number }[];
36
+ cost?: { tokens: { input: number; output: number }; cost_usd: number };
37
+ }
38
+
39
+ const RESTART_CONFIG = {
40
+ baseDelayMs: 5_000,
41
+ maxDelayMs: 30_000,
42
+ backoffMultiplier: 2,
43
+ };
44
+
45
+ export class HeadlessOrchestrator {
46
+ private readonly collector = new CIEventCollector();
47
+ private readonly answers: AnswerEntry[];
48
+ private readonly idleTimeoutMs: number;
49
+ private readonly maxRetries: number;
50
+ private readonly hooks: OrchestratorHooks;
51
+
52
+ constructor(
53
+ answers: AnswerEntry[],
54
+ options: CIOptions,
55
+ hooks: OrchestratorHooks,
56
+ ) {
57
+ this.answers = answers;
58
+ this.idleTimeoutMs = options.idleTimeoutMs ?? 15_000;
59
+ this.maxRetries = options.maxRetries ?? 3;
60
+ this.hooks = hooks;
61
+ }
62
+
63
+ /**
64
+ * Run the orchestrator loop.
65
+ *
66
+ * 1. Emit ci_start
67
+ * 2. Execute steps via the hook, checking for answer injection
68
+ * 3. On idle timeout → retry with exponential backoff
69
+ * 4. Emit ci_end with the resolved exit code
70
+ */
71
+ async run(prompt: string, mode: "single" | "plan"): Promise<OrchestratorResult> {
72
+ const startTime = Date.now();
73
+
74
+ const startEvent: CIEvent = {
75
+ type: "ci_start",
76
+ timestamp: new Date().toISOString(),
77
+ task: prompt,
78
+ mode,
79
+ };
80
+ this.emit(startEvent);
81
+
82
+ let lastExitCode: ExitCode = EXIT_CODES.ERROR;
83
+ let retries = 0;
84
+
85
+ while (retries <= this.maxRetries) {
86
+ const result = await this.runAttempt(prompt);
87
+ lastExitCode = result;
88
+
89
+ if (lastExitCode === EXIT_CODES.SUCCESS) break;
90
+ if (lastExitCode === EXIT_CODES.BLOCKED || lastExitCode === EXIT_CODES.CANCELLED) break;
91
+
92
+ // Retry on error/timeout
93
+ retries++;
94
+ if (retries <= this.maxRetries) {
95
+ const delay = Math.min(
96
+ RESTART_CONFIG.baseDelayMs *
97
+ Math.pow(RESTART_CONFIG.backoffMultiplier, retries - 1),
98
+ RESTART_CONFIG.maxDelayMs,
99
+ );
100
+ await sleep(delay);
101
+ }
102
+ }
103
+
104
+ const durationMs = Date.now() - startTime;
105
+ const endEvent: CIEvent = {
106
+ type: "ci_end",
107
+ timestamp: new Date().toISOString(),
108
+ exit_code: lastExitCode,
109
+ duration_ms: durationMs,
110
+ };
111
+ this.emit(endEvent);
112
+
113
+ return {
114
+ exitCode: lastExitCode,
115
+ events: this.collector.all(),
116
+ durationMs,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Single attempt: run with idle detection.
122
+ */
123
+ private async runAttempt(prompt: string): Promise<ExitCode> {
124
+ return new Promise<ExitCode>((resolve) => {
125
+ let settled = false;
126
+
127
+ const idle = new IdleDetector({
128
+ idleTimeoutMs: this.idleTimeoutMs,
129
+ onTimeout: () => {
130
+ if (!settled) {
131
+ settled = true;
132
+ resolve(EXIT_CODES.TIMEOUT);
133
+ }
134
+ },
135
+ });
136
+
137
+ const injectAnswer = (question: string): string | undefined => {
138
+ idle.reset();
139
+ return matchAnswer(this.answers, question);
140
+ };
141
+
142
+ idle.start();
143
+
144
+ this.hooks
145
+ .executeStep(prompt, injectAnswer)
146
+ .then((stepResult) => {
147
+ if (!settled) {
148
+ settled = true;
149
+ idle.stop();
150
+
151
+ // Emit detail events
152
+ if (stepResult.edits) {
153
+ for (const edit of stepResult.edits) {
154
+ this.emit({
155
+ type: "ci_edit",
156
+ timestamp: new Date().toISOString(),
157
+ file: edit.file,
158
+ lines_added: edit.lines_added,
159
+ lines_removed: edit.lines_removed,
160
+ });
161
+ }
162
+ }
163
+ if (stepResult.tests) {
164
+ for (const t of stepResult.tests) {
165
+ this.emit({
166
+ type: "ci_test",
167
+ timestamp: new Date().toISOString(),
168
+ command: t.command,
169
+ passed: t.passed,
170
+ failed: t.failed,
171
+ });
172
+ }
173
+ }
174
+ if (stepResult.cost) {
175
+ this.emit({
176
+ type: "ci_cost",
177
+ timestamp: new Date().toISOString(),
178
+ tokens: stepResult.cost.tokens,
179
+ cost_usd: stepResult.cost.cost_usd,
180
+ });
181
+ }
182
+
183
+ resolve(resolveExitCode(stepResult.status));
184
+ }
185
+ })
186
+ .catch(() => {
187
+ if (!settled) {
188
+ settled = true;
189
+ idle.stop();
190
+ resolve(EXIT_CODES.ERROR);
191
+ }
192
+ });
193
+ });
194
+ }
195
+
196
+ private emit(event: CIEvent): void {
197
+ this.collector.emit(event);
198
+ if (this.hooks.outputStream) {
199
+ writeCIEvent(this.hooks.outputStream, event);
200
+ }
201
+ }
202
+ }
203
+
204
+ function sleep(ms: number): Promise<void> {
205
+ return new Promise((r) => setTimeout(r, ms));
206
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * pi-ci — /ci status command handler.
3
+ *
4
+ * Shows the status of the current or last CI run.
5
+ */
6
+
7
+ import { CIEventCollector } from "../headless/jsonl-stream.ts";
8
+ import { generateReport } from "../ci/report.ts";
9
+ import type { CIEvent, CIEndEvent, ExitCode } from "../types.ts";
10
+ import { isCIEndEvent } from "../headless/jsonl-stream.ts";
11
+
12
+ export interface CIRunRecord {
13
+ id: string;
14
+ startTime: string;
15
+ events: CIEvent[];
16
+ exitCode?: ExitCode;
17
+ durationMs?: number;
18
+ }
19
+
20
+ /**
21
+ * Simple in-memory registry of CI runs (for the status command).
22
+ */
23
+ const runRegistry = new Map<string, CIRunRecord>();
24
+
25
+ /**
26
+ * Register a CI run for status lookups.
27
+ */
28
+ export function registerRun(record: CIRunRecord): void {
29
+ runRegistry.set(record.id, record);
30
+ }
31
+
32
+ /**
33
+ * Clear all registered runs (useful for testing).
34
+ */
35
+ export function clearRuns(): void {
36
+ runRegistry.clear();
37
+ }
38
+
39
+ /**
40
+ * Get a specific run by ID (prefix match supported).
41
+ */
42
+ export function getRun(id: string): CIRunRecord | undefined {
43
+ // Exact match first
44
+ if (runRegistry.has(id)) {
45
+ return runRegistry.get(id);
46
+ }
47
+ // Prefix match
48
+ for (const [key, value] of runRegistry) {
49
+ if (key.startsWith(id)) {
50
+ return value;
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * Handle the /ci status command.
58
+ *
59
+ * Returns a human-readable status string.
60
+ */
61
+ export function ciStatusHandler(args: unknown): string {
62
+ const runId = typeof args === "string" ? args : undefined;
63
+
64
+ if (runId) {
65
+ const run = getRun(runId);
66
+ if (!run) {
67
+ return `No CI run found matching: ${runId}`;
68
+ }
69
+ return formatRunStatus(run);
70
+ }
71
+
72
+ // Show all runs
73
+ if (runRegistry.size === 0) {
74
+ return "No CI runs found.";
75
+ }
76
+
77
+ const lines: string[] = [];
78
+ for (const run of runRegistry.values()) {
79
+ lines.push(formatRunStatus(run));
80
+ lines.push("");
81
+ }
82
+ return lines.join("\n").trimEnd();
83
+ }
84
+
85
+ function formatRunStatus(run: CIRunRecord): string {
86
+ const lines: string[] = [];
87
+ lines.push(`Run: ${run.id}`);
88
+ lines.push(`Started: ${run.startTime}`);
89
+
90
+ const endEvent = run.events.find((e): e is CIEndEvent => isCIEndEvent(e));
91
+ if (endEvent) {
92
+ lines.push(`Exit Code: ${endEvent.exit_code}`);
93
+ lines.push(`Duration: ${(endEvent.duration_ms / 1000).toFixed(1)}s`);
94
+
95
+ const status =
96
+ endEvent.exit_code === 0
97
+ ? "SUCCESS"
98
+ : endEvent.exit_code === 10
99
+ ? "BLOCKED"
100
+ : endEvent.exit_code === 11
101
+ ? "CANCELLED"
102
+ : "ERROR";
103
+ lines.push(`Status: ${status}`);
104
+ } else {
105
+ lines.push("Status: RUNNING");
106
+ }
107
+
108
+ return lines.join("\n");
109
+ }
110
+
111
+ /**
112
+ * Create a CI run tracker that collects events and registers the run.
113
+ */
114
+ export function createRunTracker(runId: string): {
115
+ collector: CIEventCollector;
116
+ finalize: () => CIRunRecord;
117
+ } {
118
+ const collector = new CIEventCollector();
119
+ const startTime = new Date().toISOString();
120
+
121
+ return {
122
+ collector,
123
+ finalize() {
124
+ const events = collector.all();
125
+ const endEvent = events.find((e): e is CIEndEvent => isCIEndEvent(e));
126
+ const record: CIRunRecord = {
127
+ id: runId,
128
+ startTime,
129
+ events,
130
+ exitCode: endEvent?.exit_code,
131
+ durationMs: endEvent?.duration_ms,
132
+ };
133
+ registerRun(record);
134
+ return record;
135
+ },
136
+ };
137
+ }