pi-cicd 0.3.0 → 1.0.1
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.
- package/CHANGELOG.md +19 -0
- package/README.md +34 -40
- package/docs/API.md +61 -0
- package/docs/COMMANDS.md +138 -0
- package/docs/CONFIG.md +123 -0
- package/docs/GUIDE.md +171 -0
- package/docs/PATTERNS.md +49 -0
- package/docs/QUICKSTART.md +99 -0
- package/{dist/index.d.ts → index.ts} +26 -4
- package/install.mjs +34 -0
- package/package.json +21 -21
- package/skills/intelligent-deploy/SKILL.md +229 -0
- package/src/ci/pipeline.ts +130 -0
- package/src/ci/pr-creator.ts +74 -0
- package/src/ci/report.ts +65 -0
- package/src/ci/test-runner.ts +129 -0
- package/src/config.ts +99 -0
- package/src/deploy/canary-deploy.ts +211 -0
- package/src/deploy/landing-queue.ts +222 -0
- package/src/headless/answer-injector.ts +99 -0
- package/src/headless/exit-codes.ts +32 -0
- package/src/headless/idle-detector.ts +76 -0
- package/src/headless/jsonl-stream.ts +90 -0
- package/src/headless/orchestrator.ts +207 -0
- package/{dist/index.js → src/index.ts} +30 -9
- package/src/tools/ci_status.ts +137 -0
- package/src/types.ts +149 -0
- package/src/workflow/deployment-workflow.ts +153 -0
- package/dist/ci/pipeline.d.ts +0 -43
- package/dist/ci/pipeline.d.ts.map +0 -1
- package/dist/ci/pipeline.js +0 -107
- package/dist/ci/pipeline.js.map +0 -1
- package/dist/ci/pr-creator.d.ts +0 -17
- package/dist/ci/pr-creator.d.ts.map +0 -1
- package/dist/ci/pr-creator.js +0 -67
- package/dist/ci/pr-creator.js.map +0 -1
- package/dist/ci/report.d.ts +0 -14
- package/dist/ci/report.d.ts.map +0 -1
- package/dist/ci/report.js +0 -51
- package/dist/ci/report.js.map +0 -1
- package/dist/ci/test-runner.d.ts +0 -10
- package/dist/ci/test-runner.d.ts.map +0 -1
- package/dist/ci/test-runner.js +0 -111
- package/dist/ci/test-runner.js.map +0 -1
- package/dist/config.d.ts +0 -33
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -67
- package/dist/config.js.map +0 -1
- package/dist/deploy/canary-deploy.d.ts +0 -80
- package/dist/deploy/canary-deploy.d.ts.map +0 -1
- package/dist/deploy/canary-deploy.js +0 -145
- package/dist/deploy/canary-deploy.js.map +0 -1
- package/dist/deploy/landing-queue.d.ts +0 -83
- package/dist/deploy/landing-queue.d.ts.map +0 -1
- package/dist/deploy/landing-queue.js +0 -172
- package/dist/deploy/landing-queue.js.map +0 -1
- package/dist/headless/answer-injector.d.ts +0 -27
- package/dist/headless/answer-injector.d.ts.map +0 -1
- package/dist/headless/answer-injector.js +0 -80
- package/dist/headless/answer-injector.js.map +0 -1
- package/dist/headless/exit-codes.d.ts +0 -13
- package/dist/headless/exit-codes.d.ts.map +0 -1
- package/dist/headless/exit-codes.js +0 -29
- package/dist/headless/exit-codes.js.map +0 -1
- package/dist/headless/idle-detector.d.ts +0 -32
- package/dist/headless/idle-detector.d.ts.map +0 -1
- package/dist/headless/idle-detector.js +0 -62
- package/dist/headless/idle-detector.js.map +0 -1
- package/dist/headless/jsonl-stream.d.ts +0 -28
- package/dist/headless/jsonl-stream.d.ts.map +0 -1
- package/dist/headless/jsonl-stream.js +0 -65
- package/dist/headless/jsonl-stream.js.map +0 -1
- package/dist/headless/orchestrator.d.ts +0 -63
- package/dist/headless/orchestrator.d.ts.map +0 -1
- package/dist/headless/orchestrator.js +0 -156
- package/dist/headless/orchestrator.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tools/ci_status.d.ts +0 -40
- package/dist/tools/ci_status.d.ts.map +0 -1
- package/dist/tools/ci_status.js +0 -110
- package/dist/tools/ci_status.js.map +0 -1
- package/dist/types.d.ts +0 -93
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -17
- package/dist/types.js.map +0 -1
- package/dist/workflow/deployment-workflow.d.ts +0 -56
- package/dist/workflow/deployment-workflow.d.ts.map +0 -1
- package/dist/workflow/deployment-workflow.js +0 -95
- package/dist/workflow/deployment-workflow.js.map +0 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Landing Queue - Process deployments in order
|
|
3
|
+
* Based on gstack /landing-report pattern
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DeployStatus = 'pending' | 'deploying' | 'deployed' | 'failed' | 'cancelled';
|
|
7
|
+
export type DeployEnvironment = 'staging' | 'production';
|
|
8
|
+
|
|
9
|
+
export interface QueuedDeploy {
|
|
10
|
+
id: string;
|
|
11
|
+
version: string;
|
|
12
|
+
environment: DeployEnvironment;
|
|
13
|
+
status: DeployStatus;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
deployedAt?: number;
|
|
16
|
+
message?: string;
|
|
17
|
+
logs: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LandingQueueStats {
|
|
21
|
+
total: number;
|
|
22
|
+
pending: number;
|
|
23
|
+
deploying: number;
|
|
24
|
+
deployed: number;
|
|
25
|
+
failed: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Landing Queue Manager
|
|
30
|
+
*/
|
|
31
|
+
export class LandingQueue {
|
|
32
|
+
private queue: QueuedDeploy[] = [];
|
|
33
|
+
private current: QueuedDeploy | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add deployment to queue
|
|
37
|
+
*/
|
|
38
|
+
enqueue(version: string, environment: DeployEnvironment, message?: string): QueuedDeploy {
|
|
39
|
+
const deploy: QueuedDeploy = {
|
|
40
|
+
id: `deploy-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
41
|
+
version,
|
|
42
|
+
environment,
|
|
43
|
+
status: 'pending',
|
|
44
|
+
createdAt: Date.now(),
|
|
45
|
+
message,
|
|
46
|
+
logs: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
this.queue.push(deploy);
|
|
50
|
+
this.log(deploy.id, `Added to queue: ${version} -> ${environment}`);
|
|
51
|
+
|
|
52
|
+
return deploy;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get next pending deployment
|
|
57
|
+
*/
|
|
58
|
+
peek(): QueuedDeploy | undefined {
|
|
59
|
+
return this.queue.find((d) => d.status === 'pending');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start deploying next item
|
|
64
|
+
*/
|
|
65
|
+
async startNext(): Promise<QueuedDeploy | null> {
|
|
66
|
+
const next = this.peek();
|
|
67
|
+
if (!next) return null;
|
|
68
|
+
|
|
69
|
+
// Mark as deploying
|
|
70
|
+
next.status = 'deploying';
|
|
71
|
+
this.current = next;
|
|
72
|
+
this.log(next.id, 'Starting deployment');
|
|
73
|
+
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mark deployment as complete
|
|
79
|
+
*/
|
|
80
|
+
complete(id: string, success: boolean): void {
|
|
81
|
+
const deploy = this.queue.find((d) => d.id === id);
|
|
82
|
+
if (!deploy) return;
|
|
83
|
+
|
|
84
|
+
deploy.status = success ? 'deployed' : 'failed';
|
|
85
|
+
deploy.deployedAt = Date.now();
|
|
86
|
+
this.log(id, success ? 'Deployment successful' : 'Deployment failed');
|
|
87
|
+
|
|
88
|
+
if (this.current?.id === id) {
|
|
89
|
+
this.current = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cancel a deployment
|
|
95
|
+
*/
|
|
96
|
+
cancel(id: string): void {
|
|
97
|
+
const deploy = this.queue.find((d) => d.id === id);
|
|
98
|
+
if (!deploy) return;
|
|
99
|
+
|
|
100
|
+
if (deploy.status === 'deploying') {
|
|
101
|
+
this.log(id, 'Cannot cancel - deployment in progress');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
deploy.status = 'cancelled';
|
|
106
|
+
this.log(id, 'Cancelled');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get deployment by ID
|
|
111
|
+
*/
|
|
112
|
+
get(id: string): QueuedDeploy | undefined {
|
|
113
|
+
return this.queue.find((d) => d.id === id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get queue status
|
|
118
|
+
*/
|
|
119
|
+
getStats(): LandingQueueStats {
|
|
120
|
+
return {
|
|
121
|
+
total: this.queue.length,
|
|
122
|
+
pending: this.queue.filter((d) => d.status === 'pending').length,
|
|
123
|
+
deploying: this.queue.filter((d) => d.status === 'deploying').length,
|
|
124
|
+
deployed: this.queue.filter((d) => d.status === 'deployed').length,
|
|
125
|
+
failed: this.queue.filter((d) => d.status === 'failed').length,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get all deployments
|
|
131
|
+
*/
|
|
132
|
+
getAll(): QueuedDeploy[] {
|
|
133
|
+
return [...this.queue].sort((a, b) => b.createdAt - a.createdAt);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get pending deployments
|
|
138
|
+
*/
|
|
139
|
+
getPending(): QueuedDeploy[] {
|
|
140
|
+
return this.queue
|
|
141
|
+
.filter((d) => d.status === 'pending')
|
|
142
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get current deploying
|
|
147
|
+
*/
|
|
148
|
+
getCurrent(): QueuedDeploy | null {
|
|
149
|
+
return this.current;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Add log entry
|
|
154
|
+
*/
|
|
155
|
+
private log(id: string, message: string): void {
|
|
156
|
+
const deploy = this.queue.find((d) => d.id === id);
|
|
157
|
+
if (deploy) {
|
|
158
|
+
deploy.logs.push(`[${new Date().toISOString()}] ${message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Clear completed deployments
|
|
164
|
+
*/
|
|
165
|
+
clearCompleted(): void {
|
|
166
|
+
this.queue = this.queue.filter(
|
|
167
|
+
(d) => d.status === 'pending' || d.status === 'deploying'
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Format queue as markdown report
|
|
173
|
+
*/
|
|
174
|
+
formatReport(): string {
|
|
175
|
+
const stats = this.getStats();
|
|
176
|
+
const lines: string[] = [];
|
|
177
|
+
|
|
178
|
+
lines.push('## Landing Queue Report\n');
|
|
179
|
+
lines.push(`**Total:** ${stats.total} | **Pending:** ${stats.pending} | **Deploying:** ${stats.deploying} | **Deployed:** ${stats.deployed} | **Failed:** ${stats.failed}\n`);
|
|
180
|
+
|
|
181
|
+
const current = this.getCurrent();
|
|
182
|
+
if (current) {
|
|
183
|
+
lines.push('### Currently Deploying\n');
|
|
184
|
+
lines.push(`**${current.version}** -> ${current.environment}`);
|
|
185
|
+
lines.push(`Status: ${current.status}`);
|
|
186
|
+
lines.push('');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pending = this.getPending();
|
|
190
|
+
if (pending.length > 0) {
|
|
191
|
+
lines.push('### Queue\n');
|
|
192
|
+
lines.push('| # | Version | Environment | Message |');
|
|
193
|
+
lines.push('|---|--------|------------|---------|');
|
|
194
|
+
|
|
195
|
+
pending.forEach((d, i) => {
|
|
196
|
+
lines.push(`| ${i + 1} | ${d.version} | ${d.environment} | ${d.message || '-'} |`);
|
|
197
|
+
});
|
|
198
|
+
lines.push('');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const recent = this.queue
|
|
202
|
+
.filter((d) => d.status === 'deployed' || d.status === 'failed')
|
|
203
|
+
.slice(0, 5);
|
|
204
|
+
|
|
205
|
+
if (recent.length > 0) {
|
|
206
|
+
lines.push('### Recent\n');
|
|
207
|
+
lines.push('| Version | Environment | Status | Time |');
|
|
208
|
+
lines.push('|---------|------------|--------|------|');
|
|
209
|
+
|
|
210
|
+
for (const d of recent) {
|
|
211
|
+
const icon = d.status === 'deployed' ? '✅' : '❌';
|
|
212
|
+
const time = d.deployedAt
|
|
213
|
+
? new Date(d.deployedAt).toLocaleTimeString()
|
|
214
|
+
: '-';
|
|
215
|
+
lines.push(`| ${d.version} | ${d.environment} | ${icon} ${d.status} | ${time} |`);
|
|
216
|
+
}
|
|
217
|
+
lines.push('');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return lines.join('\n');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
import * as fs from "fs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read and validate an answers JSON file.
|
|
13
|
+
*
|
|
14
|
+
* - Returns an empty array if the file cannot be read.
|
|
15
|
+
* - Skips entries that are missing `match` or `answer` fields.
|
|
16
|
+
* - Throws on invalid JSON.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
|
|
19
|
+
let text: string;
|
|
20
|
+
try {
|
|
21
|
+
text = fs.readFileSync(filePath, "utf-8");
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const raw: unknown = JSON.parse(text);
|
|
27
|
+
|
|
28
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
29
|
+
throw new Error(`Answers file must contain a JSON object with an "answers" array`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const obj = raw as Record<string, unknown>;
|
|
33
|
+
if (!Array.isArray(obj.answers)) {
|
|
34
|
+
throw new Error(`Answers file must contain an "answers" array`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const entries: AnswerEntry[] = [];
|
|
38
|
+
for (const item of obj.answers) {
|
|
39
|
+
if (
|
|
40
|
+
typeof item === "object" &&
|
|
41
|
+
item !== null &&
|
|
42
|
+
typeof (item as Record<string, unknown>).match === "string" &&
|
|
43
|
+
typeof (item as Record<string, unknown>).answer === "string"
|
|
44
|
+
) {
|
|
45
|
+
entries.push(item as AnswerEntry);
|
|
46
|
+
}
|
|
47
|
+
// Silently skip malformed entries
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Synchronous variant that reads from a string (useful for testing).
|
|
55
|
+
*/
|
|
56
|
+
export function parseAnswers(jsonText: string): AnswerEntry[] {
|
|
57
|
+
const raw: unknown = JSON.parse(jsonText);
|
|
58
|
+
|
|
59
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
60
|
+
throw new Error(`Answers file must contain a JSON object with an "answers" array`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const obj = raw as Record<string, unknown>;
|
|
64
|
+
if (!Array.isArray(obj.answers)) {
|
|
65
|
+
throw new Error(`Answers file must contain an "answers" array`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const entries: AnswerEntry[] = [];
|
|
69
|
+
for (const item of obj.answers) {
|
|
70
|
+
if (
|
|
71
|
+
typeof item === "object" &&
|
|
72
|
+
item !== null &&
|
|
73
|
+
typeof (item as Record<string, unknown>).match === "string" &&
|
|
74
|
+
typeof (item as Record<string, unknown>).answer === "string"
|
|
75
|
+
) {
|
|
76
|
+
entries.push(item as AnswerEntry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Find a matching answer for the given prompt using substring matching.
|
|
85
|
+
*
|
|
86
|
+
* Returns the first answer whose `match` is found as a substring of `prompt`,
|
|
87
|
+
* or `undefined` if no match is found.
|
|
88
|
+
*/
|
|
89
|
+
export function matchAnswer(
|
|
90
|
+
entries: AnswerEntry[],
|
|
91
|
+
prompt: string,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (prompt.includes(entry.match)) {
|
|
95
|
+
return entry.answer;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|