pi-cicd 1.0.13 → 1.0.15
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/install.mjs +7 -0
- package/package.json +1 -1
- package/src/config.ts +37 -29
- package/src/deploy/canary-deploy.ts +4 -4
- package/src/deploy/landing-queue.ts +3 -1
- package/src/headless/answer-injector.ts +12 -2
- package/src/headless/orchestrator.ts +67 -15
- package/src/index.ts +23 -19
- package/src/types.ts +9 -2
- package/src/workflow/deployment-workflow.ts +46 -16
package/install.mjs
CHANGED
|
@@ -34,6 +34,13 @@ const projectRoot = findProjectRoot(pkgRoot);
|
|
|
34
34
|
function copySkills() {
|
|
35
35
|
const skillsSrc = path.join(pkgRoot, "skills");
|
|
36
36
|
const skillsDest = path.join(projectRoot, "skills", pkgName);
|
|
37
|
+
|
|
38
|
+
// Ensure destination is contained within projectRoot to prevent path traversal
|
|
39
|
+
const relativeDest = path.relative(projectRoot, skillsDest);
|
|
40
|
+
if (relativeDest.startsWith('..') || path.isAbsolute(relativeDest)) {
|
|
41
|
+
console.error(`ERROR: Refusing to copy skills outside project root: ${skillsDest}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
37
44
|
|
|
38
45
|
console.log(`Copying skills from: ${skillsSrc}`);
|
|
39
46
|
console.log(`Copying skills to: ${skillsDest}`);
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -51,6 +51,29 @@ const DEFAULT_CONFIG: PiCiConfig = {
|
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
/** Recursively merge `override` into `base`, handling nested objects.
|
|
55
|
+
* Does not mutate either argument.
|
|
56
|
+
*/
|
|
57
|
+
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
|
58
|
+
const result: Record<string, unknown> = { ...base };
|
|
59
|
+
for (const key of Object.keys(override)) {
|
|
60
|
+
const bv = base[key];
|
|
61
|
+
const ov = override[key];
|
|
62
|
+
if (
|
|
63
|
+
bv !== undefined && ov !== undefined &&
|
|
64
|
+
typeof bv === "object" && !Array.isArray(bv) &&
|
|
65
|
+
typeof ov === "object" && !Array.isArray(ov)
|
|
66
|
+
) {
|
|
67
|
+
result[key] = deepMerge(bv as Record<string, unknown>, ov as Record<string, unknown>);
|
|
68
|
+
} else {
|
|
69
|
+
result[key] = ov;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { deepMerge };
|
|
76
|
+
|
|
54
77
|
/**
|
|
55
78
|
* Load pi-ci configuration from `.pi/pi-ci.json` (if present) merged with defaults.
|
|
56
79
|
*/
|
|
@@ -62,38 +85,23 @@ export async function loadCiConfig(cwd?: string): Promise<PiCiConfig> {
|
|
|
62
85
|
try {
|
|
63
86
|
text = await readFile(configPath, "utf-8");
|
|
64
87
|
} catch {
|
|
65
|
-
return
|
|
88
|
+
return structuredClone(DEFAULT_CONFIG) as PiCiConfig;
|
|
66
89
|
}
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
let raw: unknown;
|
|
92
|
+
try {
|
|
93
|
+
raw = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
raw = null;
|
|
96
|
+
}
|
|
97
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
98
|
+
return structuredClone(DEFAULT_CONFIG) as PiCiConfig;
|
|
71
99
|
}
|
|
72
100
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
typeof user.idleTimeoutMs === "number" ? user.idleTimeoutMs : DEFAULT_CONFIG.idleTimeoutMs,
|
|
78
|
-
maxRetries:
|
|
79
|
-
typeof user.maxRetries === "number" ? user.maxRetries : DEFAULT_CONFIG.maxRetries,
|
|
80
|
-
retryBackoffMaxMs:
|
|
81
|
-
typeof user.retryBackoffMaxMs === "number"
|
|
82
|
-
? user.retryBackoffMaxMs
|
|
83
|
-
: DEFAULT_CONFIG.retryBackoffMaxMs,
|
|
84
|
-
exitCodes: {
|
|
85
|
-
...DEFAULT_CONFIG.exitCodes,
|
|
86
|
-
...(typeof user.exitCodes === "object" && user.exitCodes !== null
|
|
87
|
-
? (user.exitCodes as Partial<PiCiExitCodeConfig>)
|
|
88
|
-
: {}),
|
|
89
|
-
},
|
|
90
|
-
report: {
|
|
91
|
-
...DEFAULT_CONFIG.report,
|
|
92
|
-
...(typeof user.report === "object" && user.report !== null
|
|
93
|
-
? (user.report as Partial<PiCiReportConfig>)
|
|
94
|
-
: {}),
|
|
95
|
-
},
|
|
96
|
-
};
|
|
101
|
+
return deepMerge(
|
|
102
|
+
structuredClone(DEFAULT_CONFIG) as unknown as Record<string, unknown>,
|
|
103
|
+
raw as Record<string, unknown>,
|
|
104
|
+
) as unknown as PiCiConfig;
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
export { DEFAULT_CONFIG };
|
|
107
|
+
export { DEFAULT_CONFIG };
|
|
@@ -127,10 +127,10 @@ export class CanaryDeploy {
|
|
|
127
127
|
|
|
128
128
|
return {
|
|
129
129
|
timestamp: Date.now(),
|
|
130
|
-
successRate: 95 +
|
|
131
|
-
latency: 50 +
|
|
132
|
-
errorRate:
|
|
133
|
-
requests: Math.floor(100 +
|
|
130
|
+
successRate: 95 + (new Uint32Array(1)[0]! % 5),
|
|
131
|
+
latency: 50 + (new Uint32Array(1)[0]! % 100),
|
|
132
|
+
errorRate: (new Uint32Array(1)[0]! % 3),
|
|
133
|
+
requests: Math.floor(100 + (new Uint32Array(1)[0]! % 900)),
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Based on gstack /landing-report pattern
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
6
8
|
export type DeployStatus = 'pending' | 'deploying' | 'deployed' | 'failed' | 'cancelled';
|
|
7
9
|
export type DeployEnvironment = 'staging' | 'production';
|
|
8
10
|
|
|
@@ -37,7 +39,7 @@ export class LandingQueue {
|
|
|
37
39
|
*/
|
|
38
40
|
enqueue(version: string, environment: DeployEnvironment, message?: string): QueuedDeploy {
|
|
39
41
|
const deploy: QueuedDeploy = {
|
|
40
|
-
id: `deploy-${
|
|
42
|
+
id: `deploy-${randomUUID()}`,
|
|
41
43
|
version,
|
|
42
44
|
environment,
|
|
43
45
|
status: 'pending',
|
|
@@ -23,7 +23,12 @@ export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
|
|
|
23
23
|
return [];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
let raw: unknown;
|
|
27
|
+
try {
|
|
28
|
+
raw = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
raw = { answer: text };
|
|
31
|
+
}
|
|
27
32
|
|
|
28
33
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
29
34
|
throw new Error(`Answers file must contain a JSON object with an "answers" array`);
|
|
@@ -54,7 +59,12 @@ export async function loadAnswers(filePath: string): Promise<AnswerEntry[]> {
|
|
|
54
59
|
* Synchronous variant that reads from a string (useful for testing).
|
|
55
60
|
*/
|
|
56
61
|
export function parseAnswers(jsonText: string): AnswerEntry[] {
|
|
57
|
-
|
|
62
|
+
let raw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
raw = JSON.parse(jsonText);
|
|
65
|
+
} catch {
|
|
66
|
+
raw = { answer: jsonText };
|
|
67
|
+
}
|
|
58
68
|
|
|
59
69
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
60
70
|
throw new Error(`Answers file must contain a JSON object with an "answers" array`);
|
|
@@ -68,6 +68,9 @@ export class HeadlessOrchestrator {
|
|
|
68
68
|
* 2. Execute steps via the hook, checking for answer injection
|
|
69
69
|
* 3. On idle timeout → retry with exponential backoff
|
|
70
70
|
* 4. Emit ci_end with the resolved exit code
|
|
71
|
+
*
|
|
72
|
+
* In "plan" mode, iterates through all plan steps sequentially.
|
|
73
|
+
* In "single" mode, executes one prompt to completion.
|
|
71
74
|
*/
|
|
72
75
|
async run(prompt: string, mode: "single" | "plan"): Promise<OrchestratorResult> {
|
|
73
76
|
const startTime = Date.now();
|
|
@@ -81,24 +84,29 @@ export class HeadlessOrchestrator {
|
|
|
81
84
|
this.emit(startEvent);
|
|
82
85
|
|
|
83
86
|
let lastExitCode: ExitCode = EXIT_CODES.ERROR;
|
|
84
|
-
let retries = 0;
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
lastExitCode =
|
|
88
|
+
if (mode === "plan") {
|
|
89
|
+
// Plan mode: iterate through steps until done or failure
|
|
90
|
+
lastExitCode = await this.runPlanMode(prompt);
|
|
91
|
+
} else {
|
|
92
|
+
// Single mode: one attempt with retries
|
|
93
|
+
let retries = 0;
|
|
94
|
+
while (retries <= this.maxRetries) {
|
|
95
|
+
const result = await this.runAttempt(prompt);
|
|
96
|
+
lastExitCode = result;
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
if (lastExitCode === EXIT_CODES.SUCCESS) break;
|
|
99
|
+
if (lastExitCode === EXIT_CODES.BLOCKED || lastExitCode === EXIT_CODES.CANCELLED) break;
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
retries++;
|
|
102
|
+
if (retries <= this.maxRetries) {
|
|
103
|
+
const delay = Math.min(
|
|
104
|
+
RESTART_CONFIG.baseDelayMs *
|
|
105
|
+
Math.pow(RESTART_CONFIG.backoffMultiplier, retries - 1),
|
|
106
|
+
RESTART_CONFIG.maxDelayMs,
|
|
107
|
+
);
|
|
108
|
+
await sleep(delay);
|
|
109
|
+
}
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -118,6 +126,50 @@ export class HeadlessOrchestrator {
|
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Run in plan mode: iterate through steps until all complete or one fails.
|
|
131
|
+
* Each step prompt is derived from the original prompt + step context.
|
|
132
|
+
*/
|
|
133
|
+
private async runPlanMode(prompt: string): Promise<ExitCode> {
|
|
134
|
+
let stepIndex = 0;
|
|
135
|
+
let lastExitCode: ExitCode = EXIT_CODES.SUCCESS;
|
|
136
|
+
|
|
137
|
+
while (stepIndex <= this.maxRetries) {
|
|
138
|
+
const stepPrompt = `[Step ${stepIndex + 1}] ${prompt}`;
|
|
139
|
+
const result = await this.runAttempt(stepPrompt);
|
|
140
|
+
lastExitCode = result;
|
|
141
|
+
|
|
142
|
+
if (result === EXIT_CODES.SUCCESS) {
|
|
143
|
+
// Step succeeded, emit step completion and continue to next
|
|
144
|
+
this.emit({
|
|
145
|
+
type: "ci_step_complete",
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
step: stepIndex + 1,
|
|
148
|
+
});
|
|
149
|
+
stepIndex++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result === EXIT_CODES.BLOCKED || result === EXIT_CODES.CANCELLED) {
|
|
154
|
+
// Stop without retry
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Error or timeout - retry this step
|
|
159
|
+
stepIndex++;
|
|
160
|
+
if (stepIndex <= this.maxRetries) {
|
|
161
|
+
const delay = Math.min(
|
|
162
|
+
RESTART_CONFIG.baseDelayMs *
|
|
163
|
+
Math.pow(RESTART_CONFIG.backoffMultiplier, stepIndex - 1),
|
|
164
|
+
RESTART_CONFIG.maxDelayMs,
|
|
165
|
+
);
|
|
166
|
+
await sleep(delay);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return lastExitCode;
|
|
171
|
+
}
|
|
172
|
+
|
|
121
173
|
/**
|
|
122
174
|
* Single attempt: run with idle detection.
|
|
123
175
|
*/
|
package/src/index.ts
CHANGED
|
@@ -4,35 +4,39 @@
|
|
|
4
4
|
* Registers the /ci status command and CI lifecycle hooks.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { CIEvent, CIOptions, ExitCode } from "./
|
|
8
|
-
import { EXIT_CODES } from "./
|
|
7
|
+
import type { CIEvent, CIOptions, ExitCode } from "./types.ts";
|
|
8
|
+
import { EXIT_CODES } from "./types.ts";
|
|
9
9
|
import {
|
|
10
10
|
ciStatusHandler,
|
|
11
11
|
createRunTracker,
|
|
12
12
|
clearRuns,
|
|
13
13
|
registerRun,
|
|
14
14
|
type CIRunRecord,
|
|
15
|
-
} from "./
|
|
16
|
-
import { CIPipeline, type PipelineResult } from "./
|
|
17
|
-
import { generateReport } from "./
|
|
15
|
+
} from "./tools/ci_status.ts";
|
|
16
|
+
import { CIPipeline, type PipelineResult } from "./ci/pipeline.ts";
|
|
17
|
+
import { generateReport } from "./ci/report.ts";
|
|
18
18
|
|
|
19
19
|
// Re-export for consumers
|
|
20
|
-
export { EXIT_CODES } from "./
|
|
21
|
-
export { resolveExitCode } from "./
|
|
22
|
-
export { loadAnswers, matchAnswer, parseAnswers } from "./
|
|
23
|
-
export { IdleDetector } from "./
|
|
24
|
-
export { CIEventCollector, writeCIEvent } from "./
|
|
25
|
-
export { HeadlessOrchestrator } from "./
|
|
26
|
-
export { CIPipeline } from "./
|
|
27
|
-
export { createPR, detectBaseBranch } from "./
|
|
28
|
-
export { parseTestResults } from "./
|
|
29
|
-
export { generateReport } from "./
|
|
30
|
-
export { ciStatusHandler, registerRun, clearRuns, createRunTracker } from "./
|
|
31
|
-
export { loadCiConfig, DEFAULT_CONFIG } from "./
|
|
32
|
-
export type { PiCiConfig } from "./
|
|
20
|
+
export { EXIT_CODES } from "./types.ts";
|
|
21
|
+
export { resolveExitCode } from "./headless/exit-codes.ts";
|
|
22
|
+
export { loadAnswers, matchAnswer, parseAnswers } from "./headless/answer-injector.ts";
|
|
23
|
+
export { IdleDetector } from "./headless/idle-detector.ts";
|
|
24
|
+
export { CIEventCollector, writeCIEvent } from "./headless/jsonl-stream.ts";
|
|
25
|
+
export { HeadlessOrchestrator } from "./headless/orchestrator.ts";
|
|
26
|
+
export { CIPipeline } from "./ci/pipeline.ts";
|
|
27
|
+
export { createPR, detectBaseBranch } from "./ci/pr-creator.ts";
|
|
28
|
+
export { parseTestResults } from "./ci/test-runner.ts";
|
|
29
|
+
export { generateReport } from "./ci/report.ts";
|
|
30
|
+
export { ciStatusHandler, registerRun, clearRuns, createRunTracker } from "./tools/ci_status.ts";
|
|
31
|
+
export { loadCiConfig, DEFAULT_CONFIG } from "./config.ts";
|
|
32
|
+
export type { PiCiConfig } from "./config.ts";
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Extension API type
|
|
35
|
+
* Extension API type.
|
|
36
|
+
* NOTE: We define this inline rather than importing from @earendil-works/pi-coding-agent
|
|
37
|
+
* because the peer dependency does not export the full ExtensionAPI shape.
|
|
38
|
+
* The minimal interface covers only the methods pi-cicd actually uses (registerCommand, on).
|
|
39
|
+
* If the host API adds required methods in future, update this interface accordingly.
|
|
36
40
|
*/
|
|
37
41
|
interface ExtensionAPI {
|
|
38
42
|
registerCommand?: (name: string, handler: (args: unknown) => string | Promise<string>) => void;
|
package/src/types.ts
CHANGED
|
@@ -29,7 +29,8 @@ export type CIEventType =
|
|
|
29
29
|
| "ci_edit"
|
|
30
30
|
| "ci_test"
|
|
31
31
|
| "ci_cost"
|
|
32
|
-
| "ci_end"
|
|
32
|
+
| "ci_end"
|
|
33
|
+
| "ci_step_complete";
|
|
33
34
|
|
|
34
35
|
export interface CIEventBase {
|
|
35
36
|
type: CIEventType;
|
|
@@ -81,13 +82,19 @@ export interface CIEndEvent extends CIEventBase {
|
|
|
81
82
|
duration_ms: number;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
export interface CIStepCompleteEvent extends CIEventBase {
|
|
86
|
+
type: "ci_step_complete";
|
|
87
|
+
step: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
84
90
|
export type CIEvent =
|
|
85
91
|
| CIStartEvent
|
|
86
92
|
| CIProgressEvent
|
|
87
93
|
| CIEditEvent
|
|
88
94
|
| CITestEvent
|
|
89
95
|
| CICostEvent
|
|
90
|
-
| CIEndEvent
|
|
96
|
+
| CIEndEvent
|
|
97
|
+
| CIStepCompleteEvent;
|
|
91
98
|
|
|
92
99
|
// ---------------------------------------------------------------------------
|
|
93
100
|
// Answer injection
|
|
@@ -40,6 +40,7 @@ export interface DeploymentState {
|
|
|
40
40
|
failedSteps: string[];
|
|
41
41
|
startedAt: string;
|
|
42
42
|
finishedAt?: string;
|
|
43
|
+
error?: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -103,7 +104,8 @@ export function createDeploymentWorkflow(
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
* Execute a deployment workflow
|
|
107
|
+
* Execute a deployment workflow with proper dependency handling.
|
|
108
|
+
* Steps are processed in dependency order, re-queued when dependencies are met.
|
|
107
109
|
*/
|
|
108
110
|
export async function* executeDeploymentWorkflow(
|
|
109
111
|
workflow: DeploymentWorkflow,
|
|
@@ -123,24 +125,52 @@ export async function* executeDeploymentWorkflow(
|
|
|
123
125
|
const stepMap = new Map(workflow.steps.map(s => [s.id, s]));
|
|
124
126
|
const completed = new Set<string>();
|
|
125
127
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
state.status = "running";
|
|
133
|
-
state.currentStep = step.id;
|
|
134
|
-
yield state;
|
|
128
|
+
// Track pending steps that haven't been processed yet
|
|
129
|
+
const pendingSteps = [...workflow.steps];
|
|
130
|
+
|
|
131
|
+
// Keep iterating until all steps are processed
|
|
132
|
+
while (pendingSteps.length > 0) {
|
|
133
|
+
let madeProgress = false;
|
|
135
134
|
|
|
136
|
-
|
|
135
|
+
// Find steps whose dependencies are all met
|
|
136
|
+
for (let i = 0; i < pendingSteps.length; i++) {
|
|
137
|
+
const step = pendingSteps[i];
|
|
138
|
+
|
|
139
|
+
// Check if all dependencies are satisfied
|
|
140
|
+
if (step.dependsOn?.some(dep => !completed.has(dep))) {
|
|
141
|
+
continue; // Dependencies not met yet, try next step
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Dependencies are met - execute this step
|
|
145
|
+
pendingSteps.splice(i, 1); // Remove from pending
|
|
146
|
+
i--; // Adjust index after splice
|
|
147
|
+
madeProgress = true;
|
|
148
|
+
|
|
149
|
+
state.status = "running";
|
|
150
|
+
state.currentStep = step.id;
|
|
151
|
+
yield state;
|
|
152
|
+
|
|
153
|
+
const result = await executor(step);
|
|
154
|
+
|
|
155
|
+
if (result.success) {
|
|
156
|
+
completed.add(step.id);
|
|
157
|
+
state.completedSteps.push(step.id);
|
|
158
|
+
} else {
|
|
159
|
+
state.failedSteps.push(step.id);
|
|
160
|
+
state.status = "failed";
|
|
161
|
+
state.error = result.error;
|
|
162
|
+
state.finishedAt = new Date().toISOString();
|
|
163
|
+
yield state;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
137
167
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
state.failedSteps.push(step.id);
|
|
168
|
+
// If no progress but steps remain, we have circular dependencies
|
|
169
|
+
if (!madeProgress && pendingSteps.length > 0) {
|
|
170
|
+
const circularDeps = pendingSteps.map(s => s.id).join(", ");
|
|
171
|
+
state.failedSteps.push(...pendingSteps.map(s => s.id));
|
|
143
172
|
state.status = "failed";
|
|
173
|
+
state.error = `Circular dependency detected among: ${circularDeps}`;
|
|
144
174
|
state.finishedAt = new Date().toISOString();
|
|
145
175
|
yield state;
|
|
146
176
|
return;
|