pi-workflow-engine 0.12.0 → 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.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { WorkflowPauseError } from "./cancellation.ts";
|
|
2
|
+
import { isRecord } from "./guards.ts";
|
|
2
3
|
import {
|
|
3
4
|
WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
|
|
4
5
|
type ResolvedWorkflowRunOptions,
|
|
5
6
|
} from "./options.ts";
|
|
7
|
+
import { truncateText } from "./text.ts";
|
|
6
8
|
import type { WorkflowProviderUsageLimitPause } from "./workflow-run-record.ts";
|
|
7
9
|
|
|
8
10
|
export const WORKFLOW_PROVIDER_USAGE_LIMIT_CODE = "WORKFLOW_PROVIDER_USAGE_LIMIT";
|
|
@@ -43,25 +45,33 @@ export class WorkflowProviderUsageLimitError extends WorkflowPauseError {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
now = Date.now(),
|
|
49
|
-
): WorkflowProviderUsageLimitError | undefined {
|
|
48
|
+
/** The last assistant message when it ended its turn with a provider error, its error text ("" if absent), and its non-empty provider metadata. */
|
|
49
|
+
export function lastAssistantError(messages: readonly unknown[]) {
|
|
50
50
|
const message = messages.findLast(isAssistantMessage);
|
|
51
51
|
if (!message || message.stopReason !== "error") return undefined;
|
|
52
|
-
const providerMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
|
53
|
-
if (!providerMessage || !isUsageLimitMessage(providerMessage)) return undefined;
|
|
54
|
-
const reset = parseProviderResetHint(providerMessage, now);
|
|
55
52
|
const provider = stringDetail(message.provider);
|
|
56
53
|
const model = stringDetail(message.model);
|
|
57
54
|
const api = stringDetail(message.api);
|
|
55
|
+
return {
|
|
56
|
+
message,
|
|
57
|
+
errorMessage: typeof message.errorMessage === "string" ? message.errorMessage : "",
|
|
58
|
+
details: { ...(provider ? { provider } : {}), ...(model ? { model } : {}), ...(api ? { api } : {}) },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function providerUsageLimitFromMessages(
|
|
63
|
+
messages: readonly unknown[],
|
|
64
|
+
now = Date.now(),
|
|
65
|
+
): WorkflowProviderUsageLimitError | undefined {
|
|
66
|
+
const failure = lastAssistantError(messages);
|
|
67
|
+
if (!failure) return undefined;
|
|
68
|
+
const providerMessage = failure.errorMessage.trim();
|
|
69
|
+
if (!providerMessage || !isUsageLimitMessage(providerMessage)) return undefined;
|
|
58
70
|
return new WorkflowProviderUsageLimitError({
|
|
59
71
|
stopReason: "error",
|
|
60
72
|
providerMessage,
|
|
61
|
-
...
|
|
62
|
-
...(
|
|
63
|
-
...(api ? { api } : {}),
|
|
64
|
-
...reset,
|
|
73
|
+
...failure.details,
|
|
74
|
+
...parseProviderResetHint(providerMessage, now),
|
|
65
75
|
});
|
|
66
76
|
}
|
|
67
77
|
|
|
@@ -119,8 +129,9 @@ export function parseProviderResetHint(
|
|
|
119
129
|
: { resetHint: boundedHint(iso) };
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
// Units must be whole words on the hint's line ("6m0s" stays compact; "1 month" and "30\nmessage" are not durations).
|
|
122
133
|
const labelled = message.match(
|
|
123
|
-
/(?:retry[- ]after|try again in|resets? in|available again in|x-ratelimit-reset(?:-[a-z-]+)?)\s*[:=]?\s*((?:\d+(?:\.\d+)
|
|
134
|
+
/(?:retry[- ]after|try again in|resets? in|available again in|x-ratelimit-reset(?:-[a-z-]+)?)\s*[:=]?\s*((?:\d+(?:\.\d+)?[ \t]*(?:milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)(?![a-z])[ \t]*)+)/i,
|
|
124
135
|
);
|
|
125
136
|
if (labelled?.[1]) {
|
|
126
137
|
const delayMs = parseDurationMs(labelled[1]);
|
|
@@ -150,27 +161,24 @@ function isUsageLimitMessage(message: string): boolean {
|
|
|
150
161
|
|| /\b(?:usage|rate)\s+limit\s+(?:(?:has\s+been|was)\s+)?(?:reached|exceeded)\b/i.test(message);
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
/** Sum a duration already captured by parseProviderResetHint's labelled grammar. */
|
|
153
165
|
function parseDurationMs(value: string): number | undefined {
|
|
154
166
|
const unitPattern = /(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)/gi;
|
|
155
167
|
let total = 0;
|
|
156
|
-
let matched = "";
|
|
157
168
|
for (const part of value.matchAll(unitPattern)) {
|
|
158
169
|
const amount = Number(part[1]);
|
|
159
|
-
const unit = part[2]
|
|
160
|
-
if (!Number.isFinite(amount) || !unit) return undefined;
|
|
161
|
-
matched += part[0];
|
|
170
|
+
const unit = (part[2] ?? "").toLowerCase();
|
|
162
171
|
if (unit === "ms" || unit.startsWith("millisecond")) total += amount;
|
|
163
172
|
else if (unit === "s" || unit.startsWith("sec")) total += amount * 1_000;
|
|
164
173
|
else if (unit === "m" || unit.startsWith("min")) total += amount * 60_000;
|
|
165
174
|
else if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) total += amount * 3_600_000;
|
|
166
175
|
else total += amount * 86_400_000;
|
|
167
176
|
}
|
|
168
|
-
|
|
169
|
-
return Number.isFinite(total) && total >= 0 ? total : undefined;
|
|
177
|
+
return Number.isFinite(total) ? total : undefined;
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
function boundedHint(value: string): string {
|
|
173
|
-
return value
|
|
181
|
+
return truncateText(value, MAX_RESET_HINT_LENGTH);
|
|
174
182
|
}
|
|
175
183
|
|
|
176
184
|
function isAssistantMessage(value: unknown): value is {
|
|
@@ -181,7 +189,7 @@ function isAssistantMessage(value: unknown): value is {
|
|
|
181
189
|
readonly model?: unknown;
|
|
182
190
|
readonly api?: unknown;
|
|
183
191
|
} {
|
|
184
|
-
return
|
|
192
|
+
return isRecord(value) && value.role === "assistant";
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
function stringDetail(value: unknown): string | undefined {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isPathWithin } from "./tree-fingerprint.ts";
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { isPathWithin, portableRelativePath } from "./tree-fingerprint.ts";
|
|
3
3
|
|
|
4
4
|
export interface ReplayWorkspaceRoots {
|
|
5
5
|
readonly sessionCwd: string;
|
|
@@ -20,11 +20,6 @@ export function normalizeWorkspaceReferences(value: string, roots: ReplayWorkspa
|
|
|
20
20
|
return paths.reduce((normalized, path) => replacePathPrefix(normalized, path, "<workspace>"), value);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function portableRelativePath(root: string, path: string): string {
|
|
24
|
-
const value = relative(root, path).split(sep).join("/");
|
|
25
|
-
return value.length === 0 ? "." : value;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
function resolvedWorkspaceRoots(roots: ReplayWorkspaceRoots): readonly string[] {
|
|
29
24
|
return [...new Set([resolve(roots.sessionCwd), resolve(roots.workspaceRoot)])];
|
|
30
25
|
}
|
|
@@ -39,7 +39,6 @@ export const ResearchEvidenceSchema = Type.Object({
|
|
|
39
39
|
export type ResearchEvidence = Static<typeof ResearchEvidenceSchema>;
|
|
40
40
|
|
|
41
41
|
export const ResearchLaneResultSchema = Type.Object({
|
|
42
|
-
laneId: Type.String({ minLength: 1 }),
|
|
43
42
|
evidence: Type.Array(ResearchEvidenceSchema, { maxItems: MAX_LANE_EVIDENCE }),
|
|
44
43
|
gaps: Type.Array(Type.String()),
|
|
45
44
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
MAX_RESEARCH_LANES,
|
|
3
2
|
MAX_VERIFICATION_CLAIMS,
|
|
4
3
|
type ResearchClaimCandidate,
|
|
5
4
|
type ResearchEvidence,
|
|
@@ -22,31 +21,21 @@ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
|
|
|
22
21
|
const id = normalizedText(lane.id);
|
|
23
22
|
const title = normalizedText(lane.title);
|
|
24
23
|
const objective = normalizedText(lane.objective);
|
|
25
|
-
const queries = dedupeText(lane.queries)
|
|
24
|
+
const queries = dedupeText(lane.queries);
|
|
26
25
|
if (!id || !title || !objective || queries.length === 0 || seen.has(id.toLowerCase())) continue;
|
|
27
26
|
seen.add(id.toLowerCase());
|
|
28
27
|
lanes.push({ id, title, objective, queries });
|
|
29
|
-
if (lanes.length >= MAX_RESEARCH_LANES) break;
|
|
30
28
|
}
|
|
31
29
|
return lanes;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
export function sanitizeLaneResults(results: readonly ResearchLaneResult[]): ResearchLaneResult[] {
|
|
35
|
-
return results.map((result) => ({
|
|
36
|
-
laneId: normalizedText(result.laneId),
|
|
37
|
-
gaps: dedupeText(result.gaps),
|
|
38
|
-
evidence: dedupeEvidence(result.evidence.map(sanitizeEvidence).filter(isDefined)),
|
|
39
|
-
}));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
32
|
export function buildClaimCandidates(results: readonly ResearchLaneResult[]): ResearchClaimCandidate[] {
|
|
43
33
|
const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
|
|
44
|
-
for (const item of results.flatMap((result) => result.evidence)) {
|
|
45
|
-
const key =
|
|
46
|
-
if (!key) continue;
|
|
34
|
+
for (const item of results.flatMap((result) => result.evidence.map(sanitizeEvidence).filter(isDefined))) {
|
|
35
|
+
const key = item.claim.toLowerCase();
|
|
47
36
|
const current = byClaim.get(key);
|
|
48
37
|
if (!current) {
|
|
49
|
-
byClaim.set(key, { claim:
|
|
38
|
+
byClaim.set(key, { claim: item.claim, importance: item.importance, evidence: [item] });
|
|
50
39
|
continue;
|
|
51
40
|
}
|
|
52
41
|
if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
|
|
@@ -142,10 +131,7 @@ function verificationSources(
|
|
|
142
131
|
return byClaim;
|
|
143
132
|
}
|
|
144
133
|
|
|
145
|
-
export function fallbackResearchReport(
|
|
146
|
-
verifications: readonly ResearchVerification[],
|
|
147
|
-
limitation: string,
|
|
148
|
-
): ResearchReport {
|
|
134
|
+
export function fallbackResearchReport(verifications: readonly ResearchVerification[]): ResearchReport {
|
|
149
135
|
const entry = (verification: ResearchVerification): ResearchReportEntry => ({
|
|
150
136
|
claim: verification.claim,
|
|
151
137
|
explanation: verification.explanation,
|
|
@@ -158,22 +144,32 @@ export function fallbackResearchReport(
|
|
|
158
144
|
uncertainties: verifications.filter((item) => item.verdict === "UNCERTAIN").map(entry),
|
|
159
145
|
inferences: verifications.filter((item) => item.verdict === "INFERENCE").map(entry),
|
|
160
146
|
sources: dedupeSources(verifications.flatMap((verification) => verification.sources)),
|
|
161
|
-
limitations: [
|
|
147
|
+
limitations: ["The synthesis stage failed; verified claims are listed without a narrative answer."],
|
|
162
148
|
nextSteps: ["Review the verified claim groups and rerun synthesis if a narrative answer is required."],
|
|
163
149
|
};
|
|
164
150
|
}
|
|
165
151
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
152
|
+
const UNAVAILABLE_RESEARCH = {
|
|
153
|
+
"empty-question": {
|
|
154
|
+
message: "No research question was provided.",
|
|
155
|
+
nextStep: "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments.",
|
|
156
|
+
},
|
|
157
|
+
"missing-capability": {
|
|
158
|
+
message: "Research could not start because pi exposed no installed external web-search or URL-extraction tool.",
|
|
159
|
+
nextStep: "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow.",
|
|
160
|
+
},
|
|
161
|
+
"lanes-failed": {
|
|
162
|
+
message: "Research could not gather direct-page evidence because one or more research lanes failed.",
|
|
163
|
+
nextStep: "Check the installed external-search tool and its provider, then rerun the workflow.",
|
|
164
|
+
},
|
|
165
|
+
"no-evidence": {
|
|
166
|
+
message: "Research completed without enough direct-page evidence to support an answer.",
|
|
167
|
+
nextStep: "Check the installed external-search tool, narrow the question, or provide preferred source domains.",
|
|
168
|
+
},
|
|
169
|
+
} as const;
|
|
170
|
+
|
|
171
|
+
export function unavailableResearchReport(reason: keyof typeof UNAVAILABLE_RESEARCH): ResearchReport {
|
|
172
|
+
const { message, nextStep } = UNAVAILABLE_RESEARCH[reason];
|
|
177
173
|
return {
|
|
178
174
|
answer: message,
|
|
179
175
|
supportedClaims: [],
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { lstat, stat } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { throwIfAborted } from "./cancellation.ts";
|
|
4
5
|
import { isMissingPathError } from "./filesystem-error.ts";
|
|
6
|
+
import { isGitObjectId, parseGitTopLevel, runGit } from "./git.ts";
|
|
7
|
+
import { isRecord } from "./guards.ts";
|
|
5
8
|
import type {
|
|
6
9
|
EffectiveAgentSessionIdentity,
|
|
7
10
|
EffectiveToolIdentity,
|
|
8
11
|
} from "./agent-session-identity.ts";
|
|
9
|
-
import {
|
|
12
|
+
import type { BoundedProcessResult } from "./process-runner.ts";
|
|
10
13
|
import {
|
|
11
14
|
BoundedFingerprint,
|
|
12
15
|
captureDeclaredInputFingerprint,
|
|
13
|
-
|
|
16
|
+
captureSourceTreeFingerprint,
|
|
17
|
+
FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
18
|
+
isExcludedDeclaredInput,
|
|
14
19
|
isPathWithin,
|
|
15
20
|
resolveDeclaredInputPaths,
|
|
16
21
|
validateTreeFile,
|
|
@@ -57,11 +62,8 @@ export interface AgentResumeBaseContext {
|
|
|
57
62
|
readonly workflow: WorkflowResumeContext;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
export interface
|
|
65
|
+
export interface AgentResumeContext {
|
|
61
66
|
readonly workflow: VerifiedWorkflowResumeContext;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface AgentResumeContext extends VerifiedAgentResumeBaseContext {
|
|
65
67
|
readonly repository: VerifiedRepositoryResumeContext;
|
|
66
68
|
readonly session: EffectiveAgentSessionIdentity;
|
|
67
69
|
readonly skills: readonly ResolvedSkillIdentity[];
|
|
@@ -85,7 +87,6 @@ type BoundedProcessFailureResult = Extract<BoundedProcessResult, { readonly ok:
|
|
|
85
87
|
const GIT_CONTEXT_TIMEOUT_MS = 15_000;
|
|
86
88
|
const GIT_CONTEXT_MAX_BYTES = 32 << 20;
|
|
87
89
|
const CONTENT_FINGERPRINT_MAX_BYTES = 32 << 20;
|
|
88
|
-
const SOURCE_TREE_MAX_FILES = 4096;
|
|
89
90
|
const REPOSITORY_INPUT_MAX_ENTRIES = 4096;
|
|
90
91
|
const GIT_UNTRACKED_MAX_ENTRIES = 32_768;
|
|
91
92
|
const GIT_VISIBLE_PATHS = [
|
|
@@ -93,14 +94,6 @@ const GIT_VISIBLE_PATHS = [
|
|
|
93
94
|
":(exclude).pi/.workflow-runs/**",
|
|
94
95
|
":(glob,exclude)**/.pi/.workflow-runs/**",
|
|
95
96
|
] as const;
|
|
96
|
-
export const FINGERPRINT_EXCLUDED_RELATIVE_PATHS = new Set([
|
|
97
|
-
".git",
|
|
98
|
-
".pi/.workflow-runs",
|
|
99
|
-
]);
|
|
100
|
-
|
|
101
|
-
export function unverifiableRepositoryResumeContext(reason: string): RepositoryResumeContext {
|
|
102
|
-
return { kind: "unverifiable", reason };
|
|
103
|
-
}
|
|
104
97
|
|
|
105
98
|
export function unverifiableWorkflowResumeContext(name: string, reason: string): WorkflowResumeContext {
|
|
106
99
|
return { kind: "unverifiable", name, reason };
|
|
@@ -151,9 +144,9 @@ export async function captureIsolatedRepositoryContext(
|
|
|
151
144
|
return { kind: "unverifiable", reason: "isolated baseline is not a full commit object ID" };
|
|
152
145
|
}
|
|
153
146
|
const [commit, tree, entries] = await Promise.all([
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
147
|
+
runContextGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{commit}`], signal),
|
|
148
|
+
runContextGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{tree}`], signal),
|
|
149
|
+
runContextGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", baselineOid], signal),
|
|
157
150
|
]);
|
|
158
151
|
throwIfAborted(signal);
|
|
159
152
|
if (!commit.ok) return { kind: "unverifiable", reason: processFailureReason("isolated baseline commit probe", commit) };
|
|
@@ -188,7 +181,7 @@ async function captureNonGitRepositoryContext(
|
|
|
188
181
|
const capture = await captureRepositoryInputs(cwd, inputs, signal);
|
|
189
182
|
return capture.kind === "verified"
|
|
190
183
|
? { kind: "verified", state: "non-git", workingTreeFingerprint: capture.fingerprint }
|
|
191
|
-
:
|
|
184
|
+
: capture;
|
|
192
185
|
}
|
|
193
186
|
|
|
194
187
|
async function captureRepositoryInputs(
|
|
@@ -239,7 +232,7 @@ async function inspectRepository(
|
|
|
239
232
|
signal: AbortSignal | undefined,
|
|
240
233
|
): Promise<RepositoryInspection> {
|
|
241
234
|
throwIfAborted(signal);
|
|
242
|
-
const probe = await
|
|
235
|
+
const probe = await runContextGit(cwd, ["rev-parse", "--is-inside-work-tree"], signal);
|
|
243
236
|
throwIfAborted(signal);
|
|
244
237
|
if (!probe.ok) {
|
|
245
238
|
if (probe.failure.kind === "exit") {
|
|
@@ -257,8 +250,8 @@ async function inspectRepository(
|
|
|
257
250
|
}
|
|
258
251
|
|
|
259
252
|
const [rootResult, headResult] = await Promise.all([
|
|
260
|
-
|
|
261
|
-
|
|
253
|
+
runContextGit(cwd, ["rev-parse", "--show-toplevel"], signal),
|
|
254
|
+
runContextGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], signal),
|
|
262
255
|
]);
|
|
263
256
|
throwIfAborted(signal);
|
|
264
257
|
if (!rootResult.ok) {
|
|
@@ -282,57 +275,50 @@ async function captureGitVisibleState(
|
|
|
282
275
|
signal: AbortSignal | undefined,
|
|
283
276
|
): Promise<FingerprintCapture> {
|
|
284
277
|
try {
|
|
285
|
-
|
|
286
|
-
|
|
278
|
+
const diffFlags = [
|
|
279
|
+
"--binary",
|
|
280
|
+
"--full-index",
|
|
281
|
+
"--no-ext-diff",
|
|
282
|
+
"--no-textconv",
|
|
283
|
+
"--no-color",
|
|
284
|
+
"--ignore-submodules=none",
|
|
285
|
+
] as const;
|
|
286
|
+
const [unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags] = await Promise.all([
|
|
287
|
+
runContextGit(cwd, ["diff", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
|
|
288
|
+
runContextGit(cwd, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", ...diffFlags.slice(2), "--", ...GIT_VISIBLE_PATHS], signal),
|
|
289
|
+
runContextGit(cwd, ["diff", "--cached", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
|
|
290
|
+
runContextGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...GIT_VISIBLE_PATHS], signal),
|
|
291
|
+
runContextGit(cwd, ["ls-files", "--stage", "--full-name", "-z"], signal),
|
|
292
|
+
runContextGit(cwd, ["ls-files", "-v", "--full-name", "-z"], signal),
|
|
293
|
+
]);
|
|
287
294
|
throwIfAborted(signal);
|
|
288
|
-
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
295
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
signal: AbortSignal | undefined,
|
|
295
|
-
): Promise<FingerprintCapture> {
|
|
296
|
-
const diffFlags = [
|
|
297
|
-
"--binary",
|
|
298
|
-
"--full-index",
|
|
299
|
-
"--no-ext-diff",
|
|
300
|
-
"--no-textconv",
|
|
301
|
-
"--no-color",
|
|
302
|
-
"--ignore-submodules=none",
|
|
303
|
-
] as const;
|
|
304
|
-
const [unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags] = await Promise.all([
|
|
305
|
-
runGit(cwd, ["diff", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
|
|
306
|
-
runGit(cwd, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", ...diffFlags.slice(2), "--", ...GIT_VISIBLE_PATHS], signal),
|
|
307
|
-
runGit(cwd, ["diff", "--cached", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
|
|
308
|
-
runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...GIT_VISIBLE_PATHS], signal),
|
|
309
|
-
runGit(cwd, ["ls-files", "--stage", "--full-name", "-z"], signal),
|
|
310
|
-
runGit(cwd, ["ls-files", "-v", "--full-name", "-z"], signal),
|
|
311
|
-
]);
|
|
312
|
-
throwIfAborted(signal);
|
|
296
|
+
const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
|
|
297
|
+
if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
|
|
313
298
|
|
|
314
|
-
|
|
315
|
-
|
|
299
|
+
const untracked = await captureDeclaredInputFingerprint({
|
|
300
|
+
root: cwd,
|
|
301
|
+
inputs: validated.untrackedPaths.filter((path) => !isExcludedDeclaredInput(path, FINGERPRINT_EXCLUDED_RELATIVE_PATHS)),
|
|
302
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
303
|
+
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
304
|
+
maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
|
|
305
|
+
signal,
|
|
306
|
+
});
|
|
307
|
+
if (untracked.kind === "unverifiable") return untracked;
|
|
316
308
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
kind: "
|
|
329
|
-
|
|
330
|
-
["unstaged", validated.unstaged],
|
|
331
|
-
["staged", validated.staged],
|
|
332
|
-
["index", validated.indexEntries],
|
|
333
|
-
["untracked", untracked.fingerprint],
|
|
334
|
-
]),
|
|
335
|
-
};
|
|
309
|
+
return {
|
|
310
|
+
kind: "verified",
|
|
311
|
+
fingerprint: combineFingerprints([
|
|
312
|
+
["unstaged", validated.unstaged],
|
|
313
|
+
["staged", validated.staged],
|
|
314
|
+
["index", validated.indexEntries],
|
|
315
|
+
["untracked", untracked.fingerprint],
|
|
316
|
+
]),
|
|
317
|
+
};
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throwIfAborted(signal);
|
|
320
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
321
|
+
}
|
|
336
322
|
}
|
|
337
323
|
|
|
338
324
|
interface GitVisibleProcessOutputs {
|
|
@@ -473,13 +459,7 @@ export async function captureWorkflowResumeContext(
|
|
|
473
459
|
return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source file is not part of its source tree: ${validation.reason}`);
|
|
474
460
|
}
|
|
475
461
|
|
|
476
|
-
const current = await
|
|
477
|
-
root: sourceRoot,
|
|
478
|
-
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
479
|
-
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
480
|
-
maxFiles: SOURCE_TREE_MAX_FILES,
|
|
481
|
-
signal,
|
|
482
|
-
});
|
|
462
|
+
const current = await captureSourceTreeFingerprint(sourceRoot, signal);
|
|
483
463
|
if (current.kind === "unverifiable") {
|
|
484
464
|
return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source tree could not be verified: ${current.reason}`);
|
|
485
465
|
}
|
|
@@ -489,18 +469,20 @@ export async function captureWorkflowResumeContext(
|
|
|
489
469
|
return { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint };
|
|
490
470
|
}
|
|
491
471
|
|
|
492
|
-
export function
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
472
|
+
export function repositoryMismatchReason(
|
|
473
|
+
stored: VerifiedRepositoryResumeContext,
|
|
474
|
+
current: VerifiedRepositoryResumeContext,
|
|
475
|
+
): string | undefined {
|
|
476
|
+
if (stored.state !== current.state) return "repository state changed";
|
|
477
|
+
if (
|
|
478
|
+
(stored.state === "git" || stored.state === "isolated") &&
|
|
479
|
+
(current.state === "git" || current.state === "isolated") &&
|
|
480
|
+
stored.head !== current.head
|
|
481
|
+
) {
|
|
482
|
+
return stored.state === "git" ? "repository HEAD changed" : "isolated baseline changed";
|
|
483
|
+
}
|
|
484
|
+
if (stored.workingTreeFingerprint !== current.workingTreeFingerprint) return "working tree contents changed";
|
|
485
|
+
return undefined;
|
|
504
486
|
}
|
|
505
487
|
|
|
506
488
|
export function resumeContextMismatchReason(
|
|
@@ -508,17 +490,8 @@ export function resumeContextMismatchReason(
|
|
|
508
490
|
current: AgentResumeContext,
|
|
509
491
|
options: ResumeContextComparisonOptions = {},
|
|
510
492
|
): string | undefined {
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
(stored.repository.state === "git" || stored.repository.state === "isolated") &&
|
|
514
|
-
(current.repository.state === "git" || current.repository.state === "isolated") &&
|
|
515
|
-
stored.repository.head !== current.repository.head
|
|
516
|
-
) {
|
|
517
|
-
return stored.repository.state === "git" ? "repository HEAD changed" : "isolated baseline changed";
|
|
518
|
-
}
|
|
519
|
-
if (stored.repository.workingTreeFingerprint !== current.repository.workingTreeFingerprint) {
|
|
520
|
-
return "working tree contents changed";
|
|
521
|
-
}
|
|
493
|
+
const repository = repositoryMismatchReason(stored.repository, current.repository);
|
|
494
|
+
if (repository) return repository;
|
|
522
495
|
if (stored.workflow.name !== current.workflow.name) return "workflow name changed";
|
|
523
496
|
if (
|
|
524
497
|
!options.allowWorkflowSourceMismatch
|
|
@@ -559,22 +532,65 @@ export function isAgentResumeContext(value: unknown): value is AgentResumeContex
|
|
|
559
532
|
);
|
|
560
533
|
}
|
|
561
534
|
|
|
562
|
-
async function
|
|
563
|
-
return await
|
|
564
|
-
file: "git",
|
|
565
|
-
args,
|
|
535
|
+
async function runContextGit(cwd: string, args: readonly string[], signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
|
|
536
|
+
return await runGit({
|
|
566
537
|
cwd,
|
|
567
|
-
|
|
538
|
+
args,
|
|
539
|
+
label: "git resume-context capture",
|
|
568
540
|
signal,
|
|
569
541
|
timeoutMs: GIT_CONTEXT_TIMEOUT_MS,
|
|
570
542
|
maxBufferBytes: GIT_CONTEXT_MAX_BYTES,
|
|
571
|
-
abortError: "git resume-context capture aborted",
|
|
572
|
-
timeoutError: `git resume-context capture timed out after ${GIT_CONTEXT_TIMEOUT_MS}ms`,
|
|
573
|
-
maxBufferError: `git resume-context capture exceeded ${GIT_CONTEXT_MAX_BYTES} bytes`,
|
|
574
|
-
exitError: (stderr, code, exitSignal) => stderr.trim() || `git exited with code ${code ?? `signal ${exitSignal ?? "unknown"}`}`,
|
|
575
543
|
});
|
|
576
544
|
}
|
|
577
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Decide whether a `.git` entry found during the ancestor walk marks a repository
|
|
548
|
+
* that git may have failed to open, or a stray entry that git's own discovery
|
|
549
|
+
* also skips.
|
|
550
|
+
*
|
|
551
|
+
* The rule mirrors git's discovery loop so that "absent" means git itself would
|
|
552
|
+
* have walked past every `.git` entry and its "not a repository" answer is
|
|
553
|
+
* authoritative:
|
|
554
|
+
*
|
|
555
|
+
* - A regular file is a gitfile. Git halts on any regular `.git` file, even when
|
|
556
|
+
* its content is malformed or its `gitdir:` target is missing, so the entry is
|
|
557
|
+
* a marker regardless of content.
|
|
558
|
+
* - A directory is a repository only when `HEAD`, `objects/`, and `refs/` all
|
|
559
|
+
* exist. Git skips a directory that lacks any of them (an empty `.git` is the
|
|
560
|
+
* common case), so such a directory is not a marker.
|
|
561
|
+
* - A symbolic link is resolved first, as git uses `stat`. A dangling link is
|
|
562
|
+
* skipped by git and is therefore not a marker.
|
|
563
|
+
* - Any other entry type (socket, FIFO, device) is skipped by git.
|
|
564
|
+
*
|
|
565
|
+
* A missing path (`ENOENT`) is a negative answer, not a failure. Any other
|
|
566
|
+
* filesystem error propagates so the caller reports the probe as "unknown".
|
|
567
|
+
*/
|
|
568
|
+
async function isRepositoryMarker(entryPath: string, entry: Stats): Promise<boolean> {
|
|
569
|
+
let target = entry;
|
|
570
|
+
if (entry.isSymbolicLink()) {
|
|
571
|
+
const resolved = await statIfPresent(entryPath);
|
|
572
|
+
if (!resolved) return false;
|
|
573
|
+
target = resolved;
|
|
574
|
+
}
|
|
575
|
+
if (target.isFile()) return true;
|
|
576
|
+
if (!target.isDirectory()) return false;
|
|
577
|
+
for (const name of GIT_DIRECTORY_REQUIRED_ENTRIES) {
|
|
578
|
+
if (!(await statIfPresent(join(entryPath, name)))) return false;
|
|
579
|
+
}
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const GIT_DIRECTORY_REQUIRED_ENTRIES = ["HEAD", "objects", "refs"] as const;
|
|
584
|
+
|
|
585
|
+
async function statIfPresent(path: string): Promise<Stats | undefined> {
|
|
586
|
+
try {
|
|
587
|
+
return await stat(path);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (isMissingPathError(error)) return undefined;
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
578
594
|
type GitControlPathResult =
|
|
579
595
|
| { readonly kind: "present" | "absent" }
|
|
580
596
|
| { readonly kind: "unknown"; readonly reason: string };
|
|
@@ -583,9 +599,10 @@ async function findGitControlPath(cwd: string, signal: AbortSignal | undefined):
|
|
|
583
599
|
let current = resolve(cwd);
|
|
584
600
|
while (true) {
|
|
585
601
|
throwIfAborted(signal);
|
|
602
|
+
const entryPath = join(current, ".git");
|
|
586
603
|
try {
|
|
587
|
-
await lstat(
|
|
588
|
-
return { kind: "present" };
|
|
604
|
+
const entry = await lstat(entryPath);
|
|
605
|
+
if (await isRepositoryMarker(entryPath, entry)) return { kind: "present" };
|
|
589
606
|
} catch (error) {
|
|
590
607
|
if (!isMissingPathError(error)) {
|
|
591
608
|
return { kind: "unknown", reason: `git control-path probe failed: ${unknownErrorMessage(error)}` };
|
|
@@ -665,37 +682,6 @@ function parseNullTerminatedRecords(output: string, operation: string): string[]
|
|
|
665
682
|
return output.slice(0, -1).split("\0");
|
|
666
683
|
}
|
|
667
684
|
|
|
668
|
-
function parseGitTopLevel(output: string, cwd: string): string | undefined {
|
|
669
|
-
const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
670
|
-
const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
|
|
671
|
-
if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
|
|
672
|
-
const root = resolve(cwd, value);
|
|
673
|
-
return isPathWithin(root, cwd) ? root : undefined;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function isGitObjectId(value: string): boolean {
|
|
677
|
-
return /^[0-9a-f]{40,64}$/i.test(value);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function isFingerprintExcludedPath(path: string): boolean {
|
|
681
|
-
const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
682
|
-
for (const excluded of FINGERPRINT_EXCLUDED_RELATIVE_PATHS) {
|
|
683
|
-
if (
|
|
684
|
-
normalized === excluded ||
|
|
685
|
-
normalized.startsWith(`${excluded}/`) ||
|
|
686
|
-
normalized.endsWith(`/${excluded}`) ||
|
|
687
|
-
normalized.includes(`/${excluded}/`)
|
|
688
|
-
) {
|
|
689
|
-
return true;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return false;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
696
|
-
return typeof value === "object" && value !== null;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
685
|
function sanitizeProcessMessage(message: string): string {
|
|
700
686
|
return message.trim().replace(/\s+/g, " ").slice(0, 500);
|
|
701
687
|
}
|