dev-loops 0.1.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/dev-loop/defaults.yaml +477 -0
- package/AGENTS.md +25 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/agents/dev-loop.agent.md +82 -0
- package/agents/developer.agent.md +37 -0
- package/agents/docs.agent.md +33 -0
- package/agents/fixer.agent.md +53 -0
- package/agents/quality.agent.md +28 -0
- package/agents/refiner.agent.md +87 -0
- package/agents/review.agent.md +64 -0
- package/cli/index.mjs +424 -0
- package/extension/README.md +233 -0
- package/extension/checks.ts +94 -0
- package/extension/index.ts +131 -0
- package/extension/post-merge-update.ts +512 -0
- package/extension/presentation.ts +107 -0
- package/lib/dev-loops-core.mjs +284 -0
- package/package.json +103 -0
- package/scripts/README.md +1007 -0
- package/scripts/_cli-primitives.mjs +10 -0
- package/scripts/_core-helpers.mjs +30 -0
- package/scripts/docs/validate-links.mjs +567 -0
- package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
- package/scripts/github/_review-thread-mutations.mjs +214 -0
- package/scripts/github/capture-review-threads.mjs +180 -0
- package/scripts/github/create-draft-pr.mjs +108 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
- package/scripts/github/detect-linked-issue-pr.mjs +331 -0
- package/scripts/github/manage-sub-issues.mjs +394 -0
- package/scripts/github/probe-copilot-review.mjs +323 -0
- package/scripts/github/ready-for-review.mjs +93 -0
- package/scripts/github/reconcile-draft-gate.mjs +328 -0
- package/scripts/github/reply-resolve-review-thread.mjs +42 -0
- package/scripts/github/reply-resolve-review-threads.mjs +329 -0
- package/scripts/github/request-copilot-review.mjs +551 -0
- package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
- package/scripts/github/stage-reviewer-draft.mjs +191 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
- package/scripts/github/verify-fresh-review-context.mjs +125 -0
- package/scripts/github/write-gate-findings-log.mjs +212 -0
- package/scripts/loop/_checkpoint-io.mjs +55 -0
- package/scripts/loop/_checkpoint-paths.mjs +28 -0
- package/scripts/loop/_handoff-contract.mjs +230 -0
- package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
- package/scripts/loop/_loop-evidence.mjs +32 -0
- package/scripts/loop/_pr-runner-coordination.mjs +611 -0
- package/scripts/loop/_stale-runner-detection.mjs +145 -0
- package/scripts/loop/_steering-state-file.mjs +134 -0
- package/scripts/loop/build-handoff-envelope.mjs +181 -0
- package/scripts/loop/checkpoint-contract.mjs +49 -0
- package/scripts/loop/conductor-monitor.mjs +1850 -0
- package/scripts/loop/conductor.mjs +214 -0
- package/scripts/loop/copilot-pr-handoff.mjs +493 -0
- package/scripts/loop/debt-remediate.mjs +304 -0
- package/scripts/loop/detect-change-scope.mjs +102 -0
- package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
- package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
- package/scripts/loop/detect-internal-only-pr.mjs +270 -0
- package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
- package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
- package/scripts/loop/detect-stale-runner.mjs +250 -0
- package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
- package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
- package/scripts/loop/info.mjs +267 -0
- package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
- package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
- package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
- package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
- package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
- package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
- package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
- package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
- package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
- package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
- package/scripts/loop/inspect-run-viewer.mjs +82 -0
- package/scripts/loop/inspect-run.mjs +382 -0
- package/scripts/loop/outer-loop.mjs +419 -0
- package/scripts/loop/pr-runner-coordination.mjs +143 -0
- package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
- package/scripts/loop/pre-flight-gate.mjs +236 -0
- package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
- package/scripts/loop/pre-push-main-guard.mjs +103 -0
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
- package/scripts/loop/print-gates.mjs +42 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
- package/scripts/loop/run-conductor-cycle.mjs +322 -0
- package/scripts/loop/run-queue.mjs +124 -0
- package/scripts/loop/run-refinement-audit.mjs +513 -0
- package/scripts/loop/run-watch-cycle.mjs +358 -0
- package/scripts/loop/steer-loop.mjs +841 -0
- package/scripts/loop/ui-designer-review-contract.mjs +76 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
- package/scripts/projects/add-queue-item.mjs +528 -0
- package/scripts/projects/ensure-queue-board.mjs +837 -0
- package/scripts/projects/list-queue-items.mjs +489 -0
- package/scripts/projects/move-queue-item.mjs +549 -0
- package/scripts/projects/reorder-queue-item.mjs +518 -0
- package/scripts/refine/_refine-helpers.mjs +258 -0
- package/scripts/refine/prose-linkage-detector.mjs +92 -0
- package/scripts/refine/refinement-completeness-checker.mjs +88 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
- package/scripts/refine/tree-integrity-validator.mjs +211 -0
- package/scripts/refine/verify.mjs +178 -0
- package/scripts/repo-wiki-local.mjs +156 -0
- package/scripts/repo-wiki.mjs +119 -0
- package/skills/copilot-pr-followup/SKILL.md +380 -0
- package/skills/dev-loop/SKILL.md +141 -0
- package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
- package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
- package/skills/dev-loop/scripts/init-phase.mjs +71 -0
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
- package/skills/dev-loop/scripts/phase-files.mjs +29 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
- package/skills/dev-loop/scripts/render-template.mjs +82 -0
- package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
- package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/skills/dev-loop/templates/phase-doc.md +27 -0
- package/skills/dev-loop/templates/phase-summary.md +13 -0
- package/skills/dev-loop/templates/phase-variant.md +15 -0
- package/skills/dev-loop/templates/retrospective.md +11 -0
- package/skills/dev-loop/templates/review.md +32 -0
- package/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/skills/docs/acceptance-criteria-verification.md +21 -0
- package/skills/docs/anti-patterns.md +21 -0
- package/skills/docs/artifact-authority-contract.md +119 -0
- package/skills/docs/confirmation-rules.md +28 -0
- package/skills/docs/copilot-ci-status-contract.md +52 -0
- package/skills/docs/copilot-loop-operations.md +233 -0
- package/skills/docs/debt-remediation-contract.md +107 -0
- package/skills/docs/entrypoint-strategies.md +115 -0
- package/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/skills/docs/issue-intake-procedure.md +235 -0
- package/skills/docs/main-agent-contract.md +72 -0
- package/skills/docs/merge-preconditions.md +29 -0
- package/skills/docs/pr-lifecycle-contract.md +209 -0
- package/skills/docs/public-dev-loop-contract.md +497 -0
- package/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/skills/docs/stop-conditions.md +29 -0
- package/skills/docs/structural-quality.md +42 -0
- package/skills/docs/tracker-first-loop-state.md +281 -0
- package/skills/docs/validation-policy.md +27 -0
- package/skills/docs/workflow-handoff-contract.md +135 -0
- package/skills/final-approval/SKILL.md +19 -0
- package/skills/local-implementation/SKILL.md +640 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_INBOX_MODE,
|
|
7
|
+
DEFAULT_INBOX_PAGE,
|
|
8
|
+
DEFAULT_INBOX_PAGE_SIZE,
|
|
9
|
+
DEFAULT_INBOX_PR_STATE,
|
|
10
|
+
DEFAULT_INBOX_UPDATED_WITHIN_DAYS,
|
|
11
|
+
INBOX_MODE_FILTER_VALUES,
|
|
12
|
+
INBOX_STATE_FILTER_VALUES,
|
|
13
|
+
MAX_INBOX_RESULT_LIMIT,
|
|
14
|
+
MERMAID_BROWSER_ASSET_ROUTE,
|
|
15
|
+
} from "./constants.mjs";
|
|
16
|
+
import { normalizeCliRepoOption } from "./cli.mjs";
|
|
17
|
+
import {
|
|
18
|
+
deriveInboxSignalFromSnapshot,
|
|
19
|
+
loadMermaidBrowserScript,
|
|
20
|
+
normalizeInboxSignal,
|
|
21
|
+
renderInspectRunViewerHtml,
|
|
22
|
+
renderTargetKey,
|
|
23
|
+
} from "./rendering.mjs";
|
|
24
|
+
import {
|
|
25
|
+
createInspectionViewerAdapter,
|
|
26
|
+
normalizeInspectionTarget,
|
|
27
|
+
} from "../_inspect-run-viewer-adapter.mjs";
|
|
28
|
+
import { dedupeRepoSlugOptions, repoSlugEquals } from "@dev-loops/core/github/repo-slug";
|
|
29
|
+
import { buildDevLoopHandoffEnvelope } from "@dev-loops/core/loop/handoff-envelope";
|
|
30
|
+
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
31
|
+
|
|
32
|
+
const execFile = promisify(execFileCallback);
|
|
33
|
+
|
|
34
|
+
function makeAdapterOptions(options) {
|
|
35
|
+
const adapterOptions = {};
|
|
36
|
+
if (options.steeringStateFile !== undefined) {
|
|
37
|
+
adapterOptions.steeringStateFile = options.steeringStateFile;
|
|
38
|
+
}
|
|
39
|
+
if (options.reviewerLogin !== undefined) {
|
|
40
|
+
adapterOptions.reviewerLogin = options.reviewerLogin;
|
|
41
|
+
}
|
|
42
|
+
if (options.copilotInputPath !== undefined) {
|
|
43
|
+
adapterOptions.copilotInputPath = options.copilotInputPath;
|
|
44
|
+
}
|
|
45
|
+
if (options.reviewerInputPath !== undefined) {
|
|
46
|
+
adapterOptions.reviewerInputPath = options.reviewerInputPath;
|
|
47
|
+
}
|
|
48
|
+
return adapterOptions;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setNoStore(response) {
|
|
52
|
+
response.setHeader("cache-control", "no-store");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeText(response, statusCode, body, headers = {}) {
|
|
56
|
+
setNoStore(response);
|
|
57
|
+
response.statusCode = statusCode;
|
|
58
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
59
|
+
response.setHeader(name, value);
|
|
60
|
+
}
|
|
61
|
+
response.end(body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeJson(response, statusCode, payload) {
|
|
65
|
+
setNoStore(response);
|
|
66
|
+
writeText(
|
|
67
|
+
response,
|
|
68
|
+
statusCode,
|
|
69
|
+
`${JSON.stringify(payload, null, 2)}\n`,
|
|
70
|
+
{ "content-type": "application/json; charset=utf-8" },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeHtml(response, html) {
|
|
75
|
+
setNoStore(response);
|
|
76
|
+
writeText(response, 200, html, { "content-type": "text/html; charset=utf-8" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function jsonErrorPayload(target, error) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
target,
|
|
83
|
+
error: {
|
|
84
|
+
message: error instanceof Error ? error.message : String(error),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requireSnapshotForJson(snapshot) {
|
|
90
|
+
if (snapshot === null || snapshot === undefined) {
|
|
91
|
+
throw new Error("inspection snapshot unavailable");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return snapshot;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async function runResolverForTarget(target, { repoRoot = process.cwd() } = {}) {
|
|
99
|
+
if (typeof target.repo !== "string" || target.repo.trim().length === 0) {
|
|
100
|
+
throw new Error("Cannot resolve handoff envelope: target repo is required");
|
|
101
|
+
}
|
|
102
|
+
const args = ["scripts/loop/resolve-dev-loop-startup.mjs", "--pr", String(target.pr)];
|
|
103
|
+
const env = { ...process.env, PI_SUBAGENT_RUN_ID: "viewer-operator-tool" };
|
|
104
|
+
const { stdout, stderr } = await execFile("node", args, { cwd: repoRoot, timeout: 30000, env });
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(stdout);
|
|
107
|
+
} catch (_err) {
|
|
108
|
+
const preview = (stdout || stderr || "").trim().slice(0, 300);
|
|
109
|
+
throw new Error("Invalid resolver JSON output: " + preview);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseUpdatedWithinDaysFromUrl(rawValue) {
|
|
114
|
+
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
|
115
|
+
return DEFAULT_INBOX_UPDATED_WITHIN_DAYS;
|
|
116
|
+
}
|
|
117
|
+
const trimmed = rawValue.trim().toLowerCase();
|
|
118
|
+
if (trimmed === "all") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (/^\d+$/.test(trimmed) && Number(trimmed) > 0) {
|
|
122
|
+
return Number(trimmed);
|
|
123
|
+
}
|
|
124
|
+
const error = new Error("updated must be a positive integer or 'all'");
|
|
125
|
+
error.code = "MALFORMED_TARGET";
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseInboxPageFromUrl(rawValue) {
|
|
130
|
+
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
|
131
|
+
return DEFAULT_INBOX_PAGE;
|
|
132
|
+
}
|
|
133
|
+
const trimmed = rawValue.trim().toLowerCase();
|
|
134
|
+
if (/^\d+$/.test(trimmed) && Number(trimmed) > 0) {
|
|
135
|
+
return Number(trimmed);
|
|
136
|
+
}
|
|
137
|
+
const error = new Error("page must be a positive integer");
|
|
138
|
+
error.code = "MALFORMED_TARGET";
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseInboxStateFromUrl(rawValue) {
|
|
143
|
+
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
|
144
|
+
return DEFAULT_INBOX_PR_STATE;
|
|
145
|
+
}
|
|
146
|
+
const trimmed = rawValue.trim().toLowerCase();
|
|
147
|
+
if (INBOX_STATE_FILTER_VALUES.has(trimmed)) {
|
|
148
|
+
return trimmed;
|
|
149
|
+
}
|
|
150
|
+
const error = new Error(`state must be one of: ${Array.from(INBOX_STATE_FILTER_VALUES).join(", ")}`);
|
|
151
|
+
error.code = "MALFORMED_TARGET";
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseInboxModeFromUrl(rawValue) {
|
|
156
|
+
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
|
157
|
+
return DEFAULT_INBOX_MODE;
|
|
158
|
+
}
|
|
159
|
+
const trimmed = rawValue.trim().toLowerCase();
|
|
160
|
+
if (INBOX_MODE_FILTER_VALUES.has(trimmed)) {
|
|
161
|
+
return trimmed;
|
|
162
|
+
}
|
|
163
|
+
const error = new Error(`mode must be one of: ${Array.from(INBOX_MODE_FILTER_VALUES).join(", ")}`);
|
|
164
|
+
error.code = "MALFORMED_TARGET";
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeRepoQueryParam(rawValue) {
|
|
169
|
+
try {
|
|
170
|
+
return normalizeCliRepoOption(rawValue);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const wrapped = new Error(error instanceof Error ? error.message : String(error));
|
|
173
|
+
wrapped.code = "MALFORMED_TARGET";
|
|
174
|
+
wrapped.cause = error;
|
|
175
|
+
throw wrapped;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeRequestedViewFromUrl(rawUrl, fixedRepo = null, fallbackTarget = null) {
|
|
180
|
+
if (typeof rawUrl !== "string" || rawUrl.length === 0) {
|
|
181
|
+
return {
|
|
182
|
+
scopeFilter: fixedRepo,
|
|
183
|
+
target: fallbackTarget,
|
|
184
|
+
updatedWithinDays: DEFAULT_INBOX_UPDATED_WITHIN_DAYS,
|
|
185
|
+
state: DEFAULT_INBOX_PR_STATE,
|
|
186
|
+
mode: DEFAULT_INBOX_MODE,
|
|
187
|
+
page: DEFAULT_INBOX_PAGE,
|
|
188
|
+
pageExplicit: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const url = new URL(rawUrl, "http://localhost");
|
|
193
|
+
const requestedScope = url.searchParams.get("scope");
|
|
194
|
+
const normalizedScope = requestedScope === null || requestedScope.trim().length === 0
|
|
195
|
+
? null
|
|
196
|
+
: normalizeRepoQueryParam(requestedScope);
|
|
197
|
+
const selectedRepo = url.searchParams.get("repo");
|
|
198
|
+
const normalizedSelectedRepo = selectedRepo === null || selectedRepo.trim().length === 0
|
|
199
|
+
? null
|
|
200
|
+
: normalizeRepoQueryParam(selectedRepo);
|
|
201
|
+
|
|
202
|
+
if (fixedRepo !== null && normalizedScope !== null && !repoSlugEquals(normalizedScope, fixedRepo)) {
|
|
203
|
+
const error = new Error("scope query param must match the repo-scoped viewer");
|
|
204
|
+
error.code = "MALFORMED_TARGET";
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
if (fixedRepo !== null && normalizedSelectedRepo !== null && !repoSlugEquals(normalizedSelectedRepo, fixedRepo)) {
|
|
208
|
+
const error = new Error("repo query param must match the repo-scoped viewer");
|
|
209
|
+
error.code = "MALFORMED_TARGET";
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const effectiveScope = fixedRepo ?? normalizedScope;
|
|
214
|
+
const effectiveSelectedRepo = fixedRepo ?? normalizedSelectedRepo;
|
|
215
|
+
const pr = url.searchParams.get("pr");
|
|
216
|
+
if (pr !== null && effectiveSelectedRepo === null) {
|
|
217
|
+
const error = new Error("repo is required when selecting a PR without --repo");
|
|
218
|
+
error.code = "MALFORMED_TARGET";
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
scopeFilter: effectiveScope,
|
|
224
|
+
target: pr === null ? fallbackTarget : normalizeInspectionTarget({ repo: effectiveSelectedRepo, pr }),
|
|
225
|
+
updatedWithinDays: parseUpdatedWithinDaysFromUrl(url.searchParams.get("updated")),
|
|
226
|
+
state: parseInboxStateFromUrl(url.searchParams.get("state")),
|
|
227
|
+
mode: parseInboxModeFromUrl(url.searchParams.get("mode")),
|
|
228
|
+
page: parseInboxPageFromUrl(url.searchParams.get("page")),
|
|
229
|
+
pageExplicit: url.searchParams.has("page"),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function dedupeInboxEntries(entries) {
|
|
234
|
+
const seen = new Map();
|
|
235
|
+
const deduped = [];
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
const key = renderTargetKey(entry.target);
|
|
238
|
+
const existing = seen.get(key);
|
|
239
|
+
if (existing) {
|
|
240
|
+
if ((existing.title === null || existing.title === undefined) && entry.title) {
|
|
241
|
+
existing.title = entry.title;
|
|
242
|
+
}
|
|
243
|
+
if ((existing.updatedAt === null || existing.updatedAt === undefined) && entry.updatedAt) {
|
|
244
|
+
existing.updatedAt = entry.updatedAt;
|
|
245
|
+
}
|
|
246
|
+
if ((existing.signal === null || existing.signal === undefined || existing.signal === "unknown") && entry.signal) {
|
|
247
|
+
existing.signal = normalizeInboxSignal(entry.signal);
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const normalizedEntry = {
|
|
252
|
+
target: entry.target,
|
|
253
|
+
title: entry.title ?? null,
|
|
254
|
+
updatedAt: entry.updatedAt ?? null,
|
|
255
|
+
signal: normalizeInboxSignal(entry.signal),
|
|
256
|
+
};
|
|
257
|
+
seen.set(key, normalizedEntry);
|
|
258
|
+
deduped.push(normalizedEntry);
|
|
259
|
+
}
|
|
260
|
+
return deduped;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collectScopeOptions(entries, { selectedTarget = null, scopeFilter = null } = {}) {
|
|
264
|
+
const repos = [];
|
|
265
|
+
if (typeof scopeFilter === "string") {
|
|
266
|
+
repos.push(scopeFilter);
|
|
267
|
+
}
|
|
268
|
+
if (selectedTarget?.repo) {
|
|
269
|
+
repos.push(selectedTarget.repo);
|
|
270
|
+
}
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (entry?.target?.repo) {
|
|
273
|
+
repos.push(entry.target.repo);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return dedupeRepoSlugOptions(repos).sort((left, right) => left.localeCompare(right));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function formatInspectRunViewerUrl(host, port) {
|
|
280
|
+
const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
281
|
+
return new URL(`http://${formattedHost}:${port}`).toString().replace(/\/$/, "");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isLsofNoListenerResult(error) {
|
|
285
|
+
if (!error || error.code !== 1) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
290
|
+
return stderr.length === 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function listListeningPidsForPort(port, { execFileImpl = execFile } = {}) {
|
|
294
|
+
try {
|
|
295
|
+
const { stdout } = await execFileImpl("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"]);
|
|
296
|
+
return stdout
|
|
297
|
+
.split(/\r?\n/)
|
|
298
|
+
.map((line) => Number(line.trim()))
|
|
299
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (isLsofNoListenerResult(error)) {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function restartExistingPortListener(
|
|
309
|
+
port,
|
|
310
|
+
{
|
|
311
|
+
listListeningPidsImpl = listListeningPidsForPort,
|
|
312
|
+
killProcessImpl = (pid, signal) => process.kill(pid, signal),
|
|
313
|
+
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
314
|
+
timeoutMs = 1500,
|
|
315
|
+
pollIntervalMs = 50,
|
|
316
|
+
} = {},
|
|
317
|
+
) {
|
|
318
|
+
const pids = (await listListeningPidsImpl(port)).filter((pid) => pid !== process.pid);
|
|
319
|
+
if (pids.length === 0) {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const pid of pids) {
|
|
324
|
+
try {
|
|
325
|
+
killProcessImpl(pid, "SIGTERM");
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error?.code !== "ESRCH") {
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const deadline = Date.now() + timeoutMs;
|
|
334
|
+
while (Date.now() < deadline) {
|
|
335
|
+
const remainingListeners = (await listListeningPidsImpl(port)).filter((pid) => pid !== process.pid);
|
|
336
|
+
if (remainingListeners.length === 0) {
|
|
337
|
+
return pids;
|
|
338
|
+
}
|
|
339
|
+
await sleepImpl(pollIntervalMs);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw new Error(`--restart could not stop existing listener on port ${port}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function createInspectRunViewerServer(options, deps = {}) {
|
|
346
|
+
const adapter = deps.adapter ?? createInspectionViewerAdapter();
|
|
347
|
+
const loadMermaidBrowserScriptImpl = deps.loadMermaidBrowserScriptImpl ?? loadMermaidBrowserScript;
|
|
348
|
+
const logErrorImpl = deps.logErrorImpl ?? (() => {});
|
|
349
|
+
const fixedRepo = options.repo === undefined ? null : normalizeCliRepoOption(options.repo);
|
|
350
|
+
const fallbackTarget = options.pr === undefined || options.pr === null || fixedRepo === null
|
|
351
|
+
? null
|
|
352
|
+
: normalizeInspectionTarget({ repo: fixedRepo, pr: options.pr });
|
|
353
|
+
const adapterOptions = makeAdapterOptions(options);
|
|
354
|
+
const supportsAssignedInbox = options.copilotInputPath === undefined && options.reviewerInputPath === undefined;
|
|
355
|
+
const jsonErrorTarget = fallbackTarget ?? { repo: fixedRepo, pr: null };
|
|
356
|
+
const cachedInboxSignals = new Map();
|
|
357
|
+
const CACHED_INBOX_SIGNALS_MAX = 200;
|
|
358
|
+
function setCachedInboxSignal(key, value) {
|
|
359
|
+
if (cachedInboxSignals.size >= CACHED_INBOX_SIGNALS_MAX) {
|
|
360
|
+
cachedInboxSignals.delete(cachedInboxSignals.keys().next().value);
|
|
361
|
+
}
|
|
362
|
+
cachedInboxSignals.set(key, value);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return createServer(async (request, response) => {
|
|
366
|
+
try {
|
|
367
|
+
const requestPath = request.url ? new URL(request.url, "http://localhost").pathname : "/";
|
|
368
|
+
const method = request.method ?? "GET";
|
|
369
|
+
|
|
370
|
+
if (requestPath === "/favicon.ico") {
|
|
371
|
+
response.statusCode = 204;
|
|
372
|
+
response.end();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (requestPath !== "/" && requestPath !== "/snapshot.json" && requestPath !== "/handoff-envelope.json" && requestPath !== MERMAID_BROWSER_ASSET_ROUTE) {
|
|
377
|
+
writeText(response, 404, "Not Found", {
|
|
378
|
+
"content-type": "text/plain; charset=utf-8",
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (method !== "GET") {
|
|
384
|
+
writeText(response, 405, "Method Not Allowed", {
|
|
385
|
+
allow: "GET",
|
|
386
|
+
"content-type": "text/plain; charset=utf-8",
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (requestPath === MERMAID_BROWSER_ASSET_ROUTE) {
|
|
392
|
+
try {
|
|
393
|
+
const mermaidBrowserScript = await loadMermaidBrowserScriptImpl();
|
|
394
|
+
writeText(response, 200, mermaidBrowserScript, {
|
|
395
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
396
|
+
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
logErrorImpl(error);
|
|
399
|
+
writeText(response, 500, "Mermaid browser asset unavailable", {
|
|
400
|
+
"content-type": "text/plain; charset=utf-8",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let requestedView;
|
|
407
|
+
try {
|
|
408
|
+
requestedView = normalizeRequestedViewFromUrl(request.url, fixedRepo, fallbackTarget);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (requestPath === "/snapshot.json" && error?.code === "MALFORMED_TARGET") {
|
|
411
|
+
writeJson(response, 400, jsonErrorPayload(jsonErrorTarget, error));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const listAssignedPullRequests = typeof adapter.listAssignedPullRequests === "function"
|
|
418
|
+
? adapter.listAssignedPullRequests.bind(adapter)
|
|
419
|
+
: async () => [];
|
|
420
|
+
const normalizeAssignedEntries = (rawEntries) => (Array.isArray(rawEntries)
|
|
421
|
+
? rawEntries.flatMap((entry) => {
|
|
422
|
+
try {
|
|
423
|
+
if (entry && typeof entry === "object" && entry.target) {
|
|
424
|
+
return [{
|
|
425
|
+
target: normalizeInspectionTarget(entry.target),
|
|
426
|
+
title: entry.title ?? null,
|
|
427
|
+
updatedAt: entry.updatedAt ?? null,
|
|
428
|
+
signal: normalizeInboxSignal(entry.signal),
|
|
429
|
+
}];
|
|
430
|
+
}
|
|
431
|
+
return [{ target: normalizeInspectionTarget(entry), title: null, updatedAt: null, signal: "unknown" }];
|
|
432
|
+
} catch {
|
|
433
|
+
return [];
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
: []);
|
|
437
|
+
|
|
438
|
+
let assignedEntries = [];
|
|
439
|
+
let scopeSourceEntries = [];
|
|
440
|
+
if (supportsAssignedInbox) {
|
|
441
|
+
try {
|
|
442
|
+
if (fixedRepo !== null) {
|
|
443
|
+
const rawAssignedEntries = await listAssignedPullRequests({
|
|
444
|
+
...adapterOptions,
|
|
445
|
+
repo: fixedRepo,
|
|
446
|
+
updatedWithinDays: requestedView.updatedWithinDays,
|
|
447
|
+
limit: MAX_INBOX_RESULT_LIMIT,
|
|
448
|
+
state: requestedView.state,
|
|
449
|
+
mode: requestedView.mode,
|
|
450
|
+
});
|
|
451
|
+
assignedEntries = normalizeAssignedEntries(rawAssignedEntries);
|
|
452
|
+
scopeSourceEntries = assignedEntries;
|
|
453
|
+
} else {
|
|
454
|
+
const loadAssignedEntries = (repo) => listAssignedPullRequests({
|
|
455
|
+
...adapterOptions,
|
|
456
|
+
repo,
|
|
457
|
+
updatedWithinDays: requestedView.updatedWithinDays,
|
|
458
|
+
limit: MAX_INBOX_RESULT_LIMIT,
|
|
459
|
+
state: requestedView.state,
|
|
460
|
+
mode: requestedView.mode,
|
|
461
|
+
});
|
|
462
|
+
if (requestedView.scopeFilter === null) {
|
|
463
|
+
const rawAssignedEntries = await loadAssignedEntries(undefined);
|
|
464
|
+
assignedEntries = normalizeAssignedEntries(rawAssignedEntries);
|
|
465
|
+
scopeSourceEntries = assignedEntries;
|
|
466
|
+
} else {
|
|
467
|
+
const [rawScopeEntries, rawAssignedEntries] = await Promise.all([
|
|
468
|
+
loadAssignedEntries(undefined),
|
|
469
|
+
loadAssignedEntries(requestedView.scopeFilter),
|
|
470
|
+
]);
|
|
471
|
+
scopeSourceEntries = normalizeAssignedEntries(rawScopeEntries);
|
|
472
|
+
assignedEntries = normalizeAssignedEntries(rawAssignedEntries);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
logErrorImpl(error);
|
|
477
|
+
assignedEntries = [];
|
|
478
|
+
scopeSourceEntries = [];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const requestedPage = requestedView.page ?? DEFAULT_INBOX_PAGE;
|
|
483
|
+
const selectedTargetMatches = requestedView.target !== null
|
|
484
|
+
&& assignedEntries.some((entry) => renderTargetKey(entry.target) === renderTargetKey(requestedView.target));
|
|
485
|
+
const effectiveSelectedTarget = supportsAssignedInbox && requestedView.target !== null
|
|
486
|
+
? (assignedEntries.length === 0 || selectedTargetMatches ? requestedView.target : null)
|
|
487
|
+
: requestedView.target;
|
|
488
|
+
const selectedIndex = effectiveSelectedTarget === null
|
|
489
|
+
? -1
|
|
490
|
+
: assignedEntries.findIndex((entry) => renderTargetKey(entry.target) === renderTargetKey(effectiveSelectedTarget));
|
|
491
|
+
const totalPages = Math.max(1, Math.ceil(assignedEntries.length / DEFAULT_INBOX_PAGE_SIZE));
|
|
492
|
+
const explicitRequestedPage = requestedView.pageExplicit === true;
|
|
493
|
+
const effectivePage = !explicitRequestedPage && selectedIndex >= 0
|
|
494
|
+
? (Math.floor(selectedIndex / DEFAULT_INBOX_PAGE_SIZE) + 1)
|
|
495
|
+
: Math.min(Math.max(requestedPage, DEFAULT_INBOX_PAGE), totalPages);
|
|
496
|
+
const pageStart = (effectivePage - 1) * DEFAULT_INBOX_PAGE_SIZE;
|
|
497
|
+
const pagedEntries = assignedEntries.slice(pageStart, pageStart + DEFAULT_INBOX_PAGE_SIZE);
|
|
498
|
+
const requestTarget = requestedView.target ?? effectiveSelectedTarget ?? pagedEntries[0]?.target ?? null;
|
|
499
|
+
|
|
500
|
+
if (requestPath === "/handoff-envelope.json") {
|
|
501
|
+
if (requestTarget === null) {
|
|
502
|
+
writeJson(response, 400, jsonErrorPayload(jsonErrorTarget, new Error("handoff-envelope.json requires ?pr=<number> when no PR is currently selected")));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
const resolverResult = await runResolverForTarget(requestTarget, { repoRoot: process.cwd() });
|
|
507
|
+
if (!resolverResult || resolverResult.bundleKind !== "resolved") {
|
|
508
|
+
writeJson(response, 400, { ok: false, target: requestTarget, error: { message: "Resolver did not return a resolved bundle; handoff envelope unavailable." } });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const { config: devLoopConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: process.cwd() });
|
|
512
|
+
if (configErrors && configErrors.length > 0) {
|
|
513
|
+
writeJson(response, 500, { ok: false, target: requestTarget, error: { message: "Dev-loop config has validation errors; handoff envelope unavailable." } });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
let gateState = {};
|
|
517
|
+
try {
|
|
518
|
+
const snapshot = await adapter.loadSnapshot(requestTarget, adapterOptions);
|
|
519
|
+
if (snapshot) {
|
|
520
|
+
gateState = {
|
|
521
|
+
currentHeadSha: snapshot.currentHeadSha || null,
|
|
522
|
+
ciStatus: snapshot.ciStatus || null,
|
|
523
|
+
unresolvedThreadCount: typeof snapshot.unresolvedThreadCount === "number" ? snapshot.unresolvedThreadCount : 0,
|
|
524
|
+
copilotRoundCount: typeof snapshot.copilotRoundCount === "number" ? snapshot.copilotRoundCount : 0,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
// Snapshot unavailable — gateState stays empty
|
|
529
|
+
}
|
|
530
|
+
const envelope = buildDevLoopHandoffEnvelope(resolverResult, devLoopConfig, gateState);
|
|
531
|
+
writeJson(response, 200, envelope);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
writeJson(response, 500, jsonErrorPayload(requestTarget, error));
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (requestPath === "/snapshot.json") {
|
|
539
|
+
if (requestTarget === null) {
|
|
540
|
+
writeJson(response, 400, jsonErrorPayload(jsonErrorTarget, new Error("snapshot.json requires ?pr=<number> when no PR is currently selected")));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const snapshot = requireSnapshotForJson(await adapter.loadSnapshot(requestTarget, adapterOptions));
|
|
545
|
+
setCachedInboxSignal(renderTargetKey(requestTarget), deriveInboxSignalFromSnapshot(snapshot));
|
|
546
|
+
writeJson(response, 200, snapshot);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
writeJson(response, 500, jsonErrorPayload(requestTarget, error));
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const inboxEntries = dedupeInboxEntries(pagedEntries);
|
|
554
|
+
|
|
555
|
+
let snapshot = null;
|
|
556
|
+
let handoffEnvelope = null;
|
|
557
|
+
let error = null;
|
|
558
|
+
if (requestTarget !== null) {
|
|
559
|
+
try {
|
|
560
|
+
snapshot = await adapter.loadSnapshot(requestTarget, adapterOptions);
|
|
561
|
+
if (snapshot !== null && snapshot !== undefined) {
|
|
562
|
+
setCachedInboxSignal(renderTargetKey(requestTarget), deriveInboxSignalFromSnapshot(snapshot));
|
|
563
|
+
}
|
|
564
|
+
} catch (caught) {
|
|
565
|
+
error = caught instanceof Error ? caught : new Error(String(caught));
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
if (typeof adapter.loadHandoffEnvelope === "function") {
|
|
569
|
+
handoffEnvelope = await adapter.loadHandoffEnvelope(requestTarget, snapshot, adapterOptions);
|
|
570
|
+
} else {
|
|
571
|
+
const resolverResult = await runResolverForTarget(requestTarget, { repoRoot: process.cwd() });
|
|
572
|
+
if (resolverResult && resolverResult.bundleKind === "resolved") {
|
|
573
|
+
const { config: devLoopConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot: process.cwd() });
|
|
574
|
+
if (!configErrors || configErrors.length === 0) {
|
|
575
|
+
const gateState = snapshot ? {
|
|
576
|
+
currentHeadSha: snapshot.currentHeadSha || null,
|
|
577
|
+
ciStatus: snapshot.ciStatus || null,
|
|
578
|
+
unresolvedThreadCount: typeof snapshot.unresolvedThreadCount === "number" ? snapshot.unresolvedThreadCount : 0,
|
|
579
|
+
copilotRoundCount: typeof snapshot.copilotRoundCount === "number" ? snapshot.copilotRoundCount : 0,
|
|
580
|
+
} : {};
|
|
581
|
+
handoffEnvelope = buildDevLoopHandoffEnvelope(
|
|
582
|
+
resolverResult,
|
|
583
|
+
devLoopConfig,
|
|
584
|
+
gateState,
|
|
585
|
+
{ repoSlug: requestTarget.repo },
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch (caught) {
|
|
591
|
+
logErrorImpl(Object.assign(new Error("handoff envelope resolution failed"), { cause: caught }));
|
|
592
|
+
handoffEnvelope = null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const inboxItems = inboxEntries.map((inboxEntry) => {
|
|
597
|
+
const inboxTarget = inboxEntry.target;
|
|
598
|
+
const inboxTargetKey = renderTargetKey(inboxTarget);
|
|
599
|
+
const selected = requestTarget !== null && inboxTargetKey === renderTargetKey(requestTarget);
|
|
600
|
+
return {
|
|
601
|
+
target: inboxTarget,
|
|
602
|
+
title: inboxEntry.title ?? `PR #${inboxTarget.pr}`,
|
|
603
|
+
updatedAt: inboxEntry.updatedAt ?? null,
|
|
604
|
+
signal: normalizeInboxSignal(cachedInboxSignals.get(inboxTargetKey), normalizeInboxSignal(inboxEntry.signal)),
|
|
605
|
+
snapshot: selected ? (snapshot ?? null) : null,
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const html = renderInspectRunViewerHtml({
|
|
610
|
+
repo: requestedView.scopeFilter,
|
|
611
|
+
target: requestTarget,
|
|
612
|
+
snapshot: snapshot ?? null,
|
|
613
|
+
handoffEnvelope,
|
|
614
|
+
error,
|
|
615
|
+
inboxItems,
|
|
616
|
+
selectedTitle: requestTarget === null
|
|
617
|
+
? null
|
|
618
|
+
: assignedEntries.find((entry) => renderTargetKey(entry.target) === renderTargetKey(requestTarget))?.title ?? null,
|
|
619
|
+
scopeOptions: collectScopeOptions(scopeSourceEntries, { selectedTarget: requestTarget, scopeFilter: requestedView.scopeFilter }),
|
|
620
|
+
inboxUpdatedWithinDays: requestedView.updatedWithinDays,
|
|
621
|
+
inboxState: requestedView.state,
|
|
622
|
+
inboxMode: requestedView.mode,
|
|
623
|
+
inboxPage: effectivePage,
|
|
624
|
+
inboxTotalPages: totalPages,
|
|
625
|
+
});
|
|
626
|
+
writeHtml(response, html);
|
|
627
|
+
} catch (caught) {
|
|
628
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
629
|
+
const malformedRequest = /invalid url|uri malformed/i.test(message) || caught?.code === "MALFORMED_TARGET";
|
|
630
|
+
writeText(
|
|
631
|
+
response,
|
|
632
|
+
malformedRequest ? 400 : 500,
|
|
633
|
+
malformedRequest ? "Bad Request" : "Internal Server Error",
|
|
634
|
+
{ "content-type": "text/plain; charset=utf-8" },
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|