iriai-build 0.5.2 → 0.6.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/bridge-v3.js +1 -1
- package/cli/bootstrap.js +1 -1
- package/cli/commands/slack.js +1 -1
- package/cli/commands/transfer.js +2 -2
- package/package.json +1 -1
- package/v3/adapters/slack-adapter.js +7 -5
- package/v3/artifact-portal.js +52 -1
- package/v3/orchestrator.js +128 -3
- package/v3/review-sessions.js +115 -15
package/bridge-v3.js
CHANGED
|
@@ -47,7 +47,7 @@ async function start() {
|
|
|
47
47
|
console.log(`Slack connected. Bot user: ${adapter.botUserId}`);
|
|
48
48
|
|
|
49
49
|
// 3. Create orchestrator with review session support
|
|
50
|
-
const reviewSessions = new ReviewSessionManager();
|
|
50
|
+
const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
|
|
51
51
|
const orchestrator = new Orchestrator({ adapter, reviewSessions });
|
|
52
52
|
_orchestrator = orchestrator;
|
|
53
53
|
adapter.setOrchestrator(orchestrator);
|
package/cli/bootstrap.js
CHANGED
|
@@ -24,7 +24,7 @@ export function bootstrap({ budgetOverride } = {}) {
|
|
|
24
24
|
|
|
25
25
|
const budget = resolveBudget(budgetOverride);
|
|
26
26
|
const adapter = new TerminalAdapter();
|
|
27
|
-
const reviewSessions = new ReviewSessionManager();
|
|
27
|
+
const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
|
|
28
28
|
const orchestrator = new Orchestrator({ adapter, reviewSessions, budget });
|
|
29
29
|
const input = new TerminalInput({ orchestrator });
|
|
30
30
|
adapter.setInputHandler(input);
|
package/cli/commands/slack.js
CHANGED
|
@@ -41,7 +41,7 @@ export async function slackCommand() {
|
|
|
41
41
|
await adapter.connect();
|
|
42
42
|
console.log(`Slack connected. Bot user: ${adapter.botUserId}`);
|
|
43
43
|
|
|
44
|
-
const reviewSessions = new ReviewSessionManager();
|
|
44
|
+
const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
|
|
45
45
|
const orchestrator = new Orchestrator({ adapter, reviewSessions });
|
|
46
46
|
adapter.setOrchestrator(orchestrator);
|
|
47
47
|
|
package/cli/commands/transfer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Orchestrator } from "../../v3/orchestrator.js";
|
|
|
8
8
|
import { Recovery } from "../../v3/recovery.js";
|
|
9
9
|
import { ReviewSessionManager } from "../../v3/review-sessions.js";
|
|
10
10
|
import * as queries from "../../v3/queries.js";
|
|
11
|
-
import { DB_PATH } from "../../v3/constants.js";
|
|
11
|
+
import { DB_PATH, PORTAL_PORT } from "../../v3/constants.js";
|
|
12
12
|
import * as config from "../config.js";
|
|
13
13
|
import { banner, systemMsg, successMsg, errorMsg } from "../display.js";
|
|
14
14
|
|
|
@@ -50,7 +50,7 @@ export async function transferToSlackCommand() {
|
|
|
50
50
|
successMsg(`Slack connected. Bot user: ${adapter.botUserId}`);
|
|
51
51
|
|
|
52
52
|
// Create orchestrator
|
|
53
|
-
const reviewSessions = new ReviewSessionManager();
|
|
53
|
+
const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
|
|
54
54
|
const orchestrator = new Orchestrator({ adapter, reviewSessions });
|
|
55
55
|
adapter.setOrchestrator(orchestrator);
|
|
56
56
|
|
package/package.json
CHANGED
|
@@ -147,7 +147,8 @@ export class SlackAdapter extends InterfaceAdapter {
|
|
|
147
147
|
async uploadArtifact(featureId, filePath, title) {
|
|
148
148
|
const channel = this._resolveChannel(featureId);
|
|
149
149
|
const feature = queries.getFeatureById(featureId);
|
|
150
|
-
|
|
150
|
+
// Only thread uploads in the planning channel — feature channels don't have this thread
|
|
151
|
+
const threadTs = (channel === this.planningChannel) ? (feature?.thread_ts || null) : null;
|
|
151
152
|
await slackUploadArtifact(this.web, channel, threadTs, filePath, title);
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -216,10 +217,11 @@ export class SlackAdapter extends InterfaceAdapter {
|
|
|
216
217
|
const designPath = findArtifact("design-decisions", planDir);
|
|
217
218
|
const planPath = findArtifact("implementation-plan", planDir);
|
|
218
219
|
|
|
219
|
-
// Upload artifacts FIRST
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
if (
|
|
220
|
+
// Upload artifacts FIRST — no thread_ts in feature channels (thread belongs to planning channel)
|
|
221
|
+
const artThreadTs = (channel === this.planningChannel) ? feature.thread_ts : null;
|
|
222
|
+
if (prdPath) await slackUploadArtifact(this.web, channel, artThreadTs, prdPath, "PRD");
|
|
223
|
+
if (designPath) await slackUploadArtifact(this.web, channel, artThreadTs, designPath, "Design Decisions");
|
|
224
|
+
if (planPath) await slackUploadArtifact(this.web, channel, artThreadTs, planPath, "Implementation Plan");
|
|
223
225
|
|
|
224
226
|
// Post approval with Block Kit buttons AFTER uploads
|
|
225
227
|
const blocks = buildDecisionBlocks(
|
package/v3/artifact-portal.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Serves on a single port (default 8900) with feature index, tabbed detail, and individual artifact views.
|
|
3
3
|
// Reuses plan-compiler.js CSS/rendering patterns (Charter fonts, frosted glass, marked.js + mermaid.js).
|
|
4
4
|
|
|
5
|
-
import { createServer } from "node:http";
|
|
5
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { IMPL_BASE } from "./constants.js";
|
|
@@ -61,6 +61,12 @@ export class ArtifactPortal {
|
|
|
61
61
|
return this._serveArtifact(res, artifactMatch[1], artifactMatch[2]);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Review session proxy: /review/<decisionId>/... → internal iriai-feedback port
|
|
65
|
+
const reviewMatch = pathname.match(/^\/review\/([^/]+)(\/.*)?$/);
|
|
66
|
+
if (reviewMatch) {
|
|
67
|
+
return this._proxyReviewSession(req, res, decodeURIComponent(reviewMatch[1]), reviewMatch[2] || "/");
|
|
68
|
+
}
|
|
69
|
+
|
|
64
70
|
res.writeHead(404, { "Content-Type": "text/html" });
|
|
65
71
|
res.end(this._render404());
|
|
66
72
|
} catch (err) {
|
|
@@ -158,6 +164,51 @@ export class ArtifactPortal {
|
|
|
158
164
|
res.end(content);
|
|
159
165
|
}
|
|
160
166
|
|
|
167
|
+
// ─── Review Session Proxy ─────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reverse proxy requests from /review/<decisionId>/... to the internal
|
|
171
|
+
* iriai-feedback port for that session. Supports multiple concurrent
|
|
172
|
+
* review sessions (e.g., parallel gate reviews for different teams).
|
|
173
|
+
*/
|
|
174
|
+
_proxyReviewSession(clientReq, clientRes, decisionId, subpath) {
|
|
175
|
+
if (!this._reviewSessions) {
|
|
176
|
+
clientRes.writeHead(502, { "Content-Type": "text/plain" });
|
|
177
|
+
clientRes.end("Review sessions not available");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const port = this._reviewSessions.getSessionPort(decisionId);
|
|
182
|
+
if (!port) {
|
|
183
|
+
clientRes.writeHead(404, { "Content-Type": "text/plain" });
|
|
184
|
+
clientRes.end(`No active review session for: ${decisionId}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const proxyOpts = {
|
|
189
|
+
hostname: "127.0.0.1",
|
|
190
|
+
port,
|
|
191
|
+
path: subpath,
|
|
192
|
+
method: clientReq.method,
|
|
193
|
+
headers: { ...clientReq.headers, host: `localhost:${port}` },
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const proxyReq = httpRequest(proxyOpts, (proxyRes) => {
|
|
197
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
198
|
+
proxyRes.pipe(clientRes, { end: true });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
proxyReq.on("error", (err) => {
|
|
202
|
+
console.error(`[artifact-portal] Review proxy error (${decisionId}):`, err.message);
|
|
203
|
+
if (!clientRes.headersSent) {
|
|
204
|
+
clientRes.writeHead(502, { "Content-Type": "text/plain" });
|
|
205
|
+
clientRes.end("Review session unavailable");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
clientReq.pipe(proxyReq, { end: true });
|
|
210
|
+
}
|
|
211
|
+
|
|
161
212
|
// ─── Data Helpers ──────────────────────────────────────────────────
|
|
162
213
|
|
|
163
214
|
_getAllFeaturesWithArtifacts() {
|
package/v3/orchestrator.js
CHANGED
|
@@ -1653,6 +1653,97 @@ export class Orchestrator {
|
|
|
1653
1653
|
queries.insertEvent(featureId, "gate-rejected", "bridge", `Gate rejected (teams ${reviewTeams.join(", ")}): ${reason}`);
|
|
1654
1654
|
}
|
|
1655
1655
|
|
|
1656
|
+
/**
|
|
1657
|
+
* Handle a gate [DECISION] block from the Feature Lead's .agent-response.
|
|
1658
|
+
* Mirrors the planning deferred-decision pattern: start doc-review, create
|
|
1659
|
+
* the decision as deferred, relay through operator as "decision-needed",
|
|
1660
|
+
* and ack the FL to stop re-posting.
|
|
1661
|
+
*/
|
|
1662
|
+
async _handleGateDecisionFromFL(feature, gateDecision, plainText) {
|
|
1663
|
+
const tree = this._signalTrees[feature.slug];
|
|
1664
|
+
|
|
1665
|
+
const options = gateDecision.options.map(o => ({
|
|
1666
|
+
id: o.id,
|
|
1667
|
+
label: o.label,
|
|
1668
|
+
style: o.style || "default",
|
|
1669
|
+
description: o.description || "",
|
|
1670
|
+
}));
|
|
1671
|
+
|
|
1672
|
+
// Start doc-review session for gate evidence HTML (same as inline gate code in impl:operatorResponse)
|
|
1673
|
+
let reviewUrl = null;
|
|
1674
|
+
let qaUrl = null;
|
|
1675
|
+
const reviewSessionKey = `${gateDecision.id}@${feature.slug}`;
|
|
1676
|
+
|
|
1677
|
+
if (this.reviewSessions) {
|
|
1678
|
+
const evidencePath = path.join(feature.signal_dir, "feature-lead", ".gate-evidence.html");
|
|
1679
|
+
if (fs.existsSync(evidencePath)) {
|
|
1680
|
+
reviewUrl = await this.reviewSessions.startDocReview(
|
|
1681
|
+
reviewSessionKey, evidencePath, `Gate Evidence — ${gateDecision.title}`, { featureId: feature.id }
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Start QA session if verification config specifies local-server
|
|
1686
|
+
const verification = this._readVerificationConfig(feature);
|
|
1687
|
+
if (verification && verification.type === "local-server" && verification.url) {
|
|
1688
|
+
let devServerOk = !verification.command;
|
|
1689
|
+
if (verification.command) {
|
|
1690
|
+
try {
|
|
1691
|
+
const featureReposDir = path.join(PROJECT_ROOT, ".features", feature.slug, "repos");
|
|
1692
|
+
await new Promise((resolve) => {
|
|
1693
|
+
const proc = cpSpawn("sh", ["-c", verification.command], {
|
|
1694
|
+
cwd: featureReposDir,
|
|
1695
|
+
stdio: "ignore",
|
|
1696
|
+
detached: true,
|
|
1697
|
+
});
|
|
1698
|
+
proc.on("error", (err) => {
|
|
1699
|
+
console.error(`[orchestrator] Dev server spawn error: ${err.message}`);
|
|
1700
|
+
resolve(false);
|
|
1701
|
+
});
|
|
1702
|
+
proc.unref();
|
|
1703
|
+
setTimeout(() => resolve(true), 3000);
|
|
1704
|
+
}).then(ok => { devServerOk = ok; });
|
|
1705
|
+
} catch (err) {
|
|
1706
|
+
console.error(`[orchestrator] Failed to start dev server for gate review:`, err.message);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
if (devServerOk) {
|
|
1710
|
+
qaUrl = await this.reviewSessions.startQaSession(
|
|
1711
|
+
reviewSessionKey, verification.url, { featureId: feature.id }
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const decision = {
|
|
1718
|
+
id: gateDecision.id,
|
|
1719
|
+
title: gateDecision.title,
|
|
1720
|
+
context: gateDecision.context,
|
|
1721
|
+
type: gateDecision.type || "approval",
|
|
1722
|
+
options,
|
|
1723
|
+
reviewUrl,
|
|
1724
|
+
qaUrl,
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
// Defer the decision post until after the Operator relay completes
|
|
1728
|
+
this._deferredDecisions[feature.id] = { decision, featureId: feature.id };
|
|
1729
|
+
|
|
1730
|
+
// Notify the Operator via relay (eventHint: "decision-needed" ensures
|
|
1731
|
+
// fallback skips raw text and just posts the deferred decision)
|
|
1732
|
+
this._notifyOperatorOfDecision(feature, decision, plainText);
|
|
1733
|
+
|
|
1734
|
+
// Ack the FL so it stops polling / re-posting gate evidence
|
|
1735
|
+
if (tree?.featureLead) {
|
|
1736
|
+
writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
|
|
1737
|
+
`Gate evidence received and posted for user review. Waiting for user decision — do NOT re-post.`);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// Pre-set gate_evidence_ts with a placeholder so FL refresh prompts know
|
|
1741
|
+
// evidence was already posted (actual Slack ts is set after deferred post)
|
|
1742
|
+
queries.updateFeatureGateEvidenceTs(feature.id, "pending");
|
|
1743
|
+
|
|
1744
|
+
console.log(`[orchestrator] Gate decision ${gateDecision.id} intercepted from FL, deferred for operator relay`);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1656
1747
|
// ─── Gate Team Dependency Resolution ─────────────────────────────────
|
|
1657
1748
|
|
|
1658
1749
|
/**
|
|
@@ -2272,6 +2363,16 @@ export class Orchestrator {
|
|
|
2272
2363
|
if (!content) return;
|
|
2273
2364
|
const feature = queries.getFeatureBySlug(slug);
|
|
2274
2365
|
if (!feature) return;
|
|
2366
|
+
|
|
2367
|
+
// Intercept gate decisions: use deferred decision pattern (same as planning)
|
|
2368
|
+
// so the gate review gets doc-review + properly formatted Block Kit buttons
|
|
2369
|
+
const parsed = parseOperatorResponse(content);
|
|
2370
|
+
const gateDecision = parsed.decisions.find(d => d.id && d.id.startsWith("gate-"));
|
|
2371
|
+
if (gateDecision) {
|
|
2372
|
+
await this._handleGateDecisionFromFL(feature, gateDecision, parsed.plainText);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2275
2376
|
await this._enqueueForOperatorRelay(feature, "Feature Lead", "fl-response", content);
|
|
2276
2377
|
} catch (err) {
|
|
2277
2378
|
console.error("[orchestrator] Impl response error:", err.message);
|
|
@@ -3405,16 +3506,40 @@ modify a SPECIFIC section — never re-read them in full.\n`;
|
|
|
3405
3506
|
}
|
|
3406
3507
|
|
|
3407
3508
|
/**
|
|
3408
|
-
* Post a deferred decision (stored by _requestPhaseReview
|
|
3409
|
-
* relay completes, so the decision buttons appear after the Operator's message.
|
|
3509
|
+
* Post a deferred decision (stored by _requestPhaseReview or _handleGateDecisionFromFL)
|
|
3510
|
+
* after the Operator relay completes, so the decision buttons appear after the Operator's message.
|
|
3410
3511
|
*/
|
|
3411
3512
|
async _postDeferredDecision(featureId) {
|
|
3412
3513
|
const deferred = this._deferredDecisions[featureId];
|
|
3413
3514
|
if (!deferred) return;
|
|
3414
3515
|
delete this._deferredDecisions[featureId];
|
|
3415
3516
|
|
|
3517
|
+
const { decision } = deferred;
|
|
3518
|
+
|
|
3416
3519
|
try {
|
|
3417
|
-
await this.adapter.postDecision(deferred.featureId,
|
|
3520
|
+
const decResult = await this.adapter.postDecision(deferred.featureId, decision);
|
|
3521
|
+
|
|
3522
|
+
// For gate decisions: store in DB and update gate_evidence_ts with actual ref
|
|
3523
|
+
if (decision.type === "approval" && decision.id.startsWith("gate-")) {
|
|
3524
|
+
queries.createDecision({
|
|
3525
|
+
featureId: deferred.featureId,
|
|
3526
|
+
decisionId: decision.id,
|
|
3527
|
+
decisionType: decision.type || "approval",
|
|
3528
|
+
title: decision.title,
|
|
3529
|
+
contextText: decision.context || null,
|
|
3530
|
+
options: decision.options,
|
|
3531
|
+
});
|
|
3532
|
+
|
|
3533
|
+
const feature = queries.getFeatureById(deferred.featureId);
|
|
3534
|
+
const decChannel = feature?.feature_channel || this.adapter.planningChannel;
|
|
3535
|
+
queries.updateDecisionSlack(deferred.featureId, decision.id, {
|
|
3536
|
+
slackTs: decResult?.ref || null,
|
|
3537
|
+
slackChannel: decChannel || null,
|
|
3538
|
+
permalink: null,
|
|
3539
|
+
});
|
|
3540
|
+
|
|
3541
|
+
queries.updateFeatureGateEvidenceTs(deferred.featureId, decResult?.ref || null);
|
|
3542
|
+
}
|
|
3418
3543
|
} catch (err) {
|
|
3419
3544
|
console.error("[orchestrator] Failed to post deferred decision:", err.message);
|
|
3420
3545
|
}
|
package/v3/review-sessions.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// review-sessions.js — Manages interactive review tool sessions for decisions.
|
|
2
2
|
// Loosely coupled: uses iriai-feedback CLI (child_process.spawn) + HTTP API.
|
|
3
3
|
// No direct imports from iriai-feedback.
|
|
4
|
+
//
|
|
5
|
+
// Doc-review and QA sessions are proxied through the artifact portal so users
|
|
6
|
+
// see stable URLs (http://localhost:<portalPort>/review/<decisionId>/...) instead
|
|
7
|
+
// of ephemeral per-session ports.
|
|
4
8
|
|
|
5
9
|
import { spawn } from "node:child_process";
|
|
6
10
|
import { createServer } from "node:http";
|
|
7
11
|
import { get as httpGet } from "node:http";
|
|
8
12
|
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { createConnection } from "node:net";
|
|
9
14
|
import * as queries from "./queries.js";
|
|
10
15
|
import { get as configGet } from "../cli/config.js";
|
|
11
16
|
|
|
@@ -14,18 +19,55 @@ const PORT_MIN = 9001;
|
|
|
14
19
|
const PORT_MAX = 9020;
|
|
15
20
|
|
|
16
21
|
export class ReviewSessionManager {
|
|
17
|
-
constructor() {
|
|
22
|
+
constructor({ portalPort } = {}) {
|
|
18
23
|
this._sessions = new Map(); // decisionId -> { sessionId, url, port, process, type, qa? }
|
|
19
24
|
this._nextPort = PORT_MIN;
|
|
25
|
+
this._portalPort = portalPort || 8900;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Probe whether a port is actually free by attempting a TCP connection.
|
|
30
|
+
* Returns true if nothing is listening (connection refused), false if occupied.
|
|
31
|
+
*/
|
|
32
|
+
_isPortFree(port) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const conn = createConnection({ port, host: "127.0.0.1" });
|
|
35
|
+
conn.once("connect", () => {
|
|
36
|
+
conn.destroy();
|
|
37
|
+
resolve(false); // something is listening → port is occupied
|
|
38
|
+
});
|
|
39
|
+
conn.once("error", () => {
|
|
40
|
+
resolve(true); // connection refused → port is free
|
|
41
|
+
});
|
|
42
|
+
conn.setTimeout(500, () => {
|
|
43
|
+
conn.destroy();
|
|
44
|
+
resolve(true); // timeout → assume free
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async _allocPort() {
|
|
23
50
|
const usedPorts = new Set(
|
|
24
51
|
[...this._sessions.values()].flatMap(s =>
|
|
25
52
|
[s.port, s.qa?.port].filter(Boolean)
|
|
26
53
|
)
|
|
27
54
|
);
|
|
28
55
|
let port = this._nextPort;
|
|
56
|
+
let attempts = 0;
|
|
57
|
+
const range = PORT_MAX - PORT_MIN + 1;
|
|
58
|
+
|
|
59
|
+
while (attempts < range) {
|
|
60
|
+
if (!usedPorts.has(port) && await this._isPortFree(port)) {
|
|
61
|
+
this._nextPort = port >= PORT_MAX ? PORT_MIN : port + 1;
|
|
62
|
+
return port;
|
|
63
|
+
}
|
|
64
|
+
port = port >= PORT_MAX ? PORT_MIN : port + 1;
|
|
65
|
+
attempts++;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fallback: return next non-map port (best effort)
|
|
69
|
+
console.warn("[review-sessions] No verified-free ports; falling back to map-only check");
|
|
70
|
+
port = this._nextPort;
|
|
29
71
|
while (usedPorts.has(port)) {
|
|
30
72
|
port = port >= PORT_MAX ? PORT_MIN : port + 1;
|
|
31
73
|
}
|
|
@@ -33,19 +75,27 @@ export class ReviewSessionManager {
|
|
|
33
75
|
return port;
|
|
34
76
|
}
|
|
35
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Build the user-facing URL for a review session.
|
|
80
|
+
* Routes through the artifact portal's reverse proxy.
|
|
81
|
+
*/
|
|
82
|
+
_buildReviewUrl(decisionId, subpath = "/doc-review") {
|
|
83
|
+
return `http://localhost:${this._portalPort}/review/${encodeURIComponent(decisionId)}${subpath}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
/**
|
|
37
87
|
* Start a doc review session for a decision.
|
|
38
88
|
* Spawns `iriai-feedback review <docPath> --port <port>`.
|
|
39
|
-
* Returns the review URL.
|
|
89
|
+
* Returns the portal-proxied review URL.
|
|
40
90
|
*/
|
|
41
91
|
async startDocReview(decisionId, docPath, title, { featureId, port: preferredPort } = {}) {
|
|
42
|
-
const port = preferredPort || this._allocPort();
|
|
92
|
+
const port = preferredPort || await this._allocPort();
|
|
43
93
|
const args = ["review", docPath, "--port", String(port)];
|
|
44
94
|
if (title) args.push("--title", title);
|
|
45
95
|
|
|
46
96
|
try {
|
|
47
97
|
const { proc, sessionId } = await this._spawnAndParse(args);
|
|
48
|
-
const url =
|
|
98
|
+
const url = this._buildReviewUrl(decisionId);
|
|
49
99
|
this._sessions.set(decisionId, { sessionId, url, port, process: proc, type: "doc", docPath });
|
|
50
100
|
|
|
51
101
|
// Persist to SQLite for restart recovery
|
|
@@ -69,7 +119,7 @@ export class ReviewSessionManager {
|
|
|
69
119
|
*/
|
|
70
120
|
async startMockupReview(decisionId, htmlPath, { featureId } = {}) {
|
|
71
121
|
// 1. Spin up a tiny static file server for the HTML mockup
|
|
72
|
-
const staticPort = this._allocPort();
|
|
122
|
+
const staticPort = await this._allocPort();
|
|
73
123
|
const staticServer = await this._startStaticServer(htmlPath, staticPort);
|
|
74
124
|
|
|
75
125
|
// 2. Wrap it with QA proxy
|
|
@@ -128,12 +178,12 @@ export class ReviewSessionManager {
|
|
|
128
178
|
* Attaches to an existing decision entry if one exists.
|
|
129
179
|
*/
|
|
130
180
|
async startQaSession(decisionId, targetUrl, { featureId, port: preferredPort } = {}) {
|
|
131
|
-
const port = preferredPort || this._allocPort();
|
|
181
|
+
const port = preferredPort || await this._allocPort();
|
|
132
182
|
const args = ["start", targetUrl, "--port", String(port)];
|
|
133
183
|
|
|
134
184
|
try {
|
|
135
185
|
const { proc, sessionId } = await this._spawnAndParse(args);
|
|
136
|
-
const qaUrl =
|
|
186
|
+
const qaUrl = this._buildReviewUrl(decisionId, "");
|
|
137
187
|
const existing = this._sessions.get(decisionId);
|
|
138
188
|
|
|
139
189
|
if (existing) {
|
|
@@ -156,6 +206,22 @@ export class ReviewSessionManager {
|
|
|
156
206
|
}
|
|
157
207
|
}
|
|
158
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Get the internal port for a session (used by the artifact portal proxy).
|
|
211
|
+
*/
|
|
212
|
+
getSessionPort(decisionId) {
|
|
213
|
+
const session = this._sessions.get(decisionId);
|
|
214
|
+
return session?.port || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get the QA session port for a session (used by the artifact portal proxy).
|
|
219
|
+
*/
|
|
220
|
+
getQaPort(decisionId) {
|
|
221
|
+
const session = this._sessions.get(decisionId);
|
|
222
|
+
return session?.qa?.port || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
159
225
|
/**
|
|
160
226
|
* Collect annotations from all active review servers for a decision.
|
|
161
227
|
* Returns array of annotations or null if none.
|
|
@@ -219,17 +285,15 @@ export class ReviewSessionManager {
|
|
|
219
285
|
});
|
|
220
286
|
}
|
|
221
287
|
|
|
222
|
-
// Re-spawn the doc review server
|
|
288
|
+
// Re-spawn the doc review server (don't reuse stored port — it may be stale)
|
|
223
289
|
const url = await this.startDocReview(decisionId, row.doc_path, null, {
|
|
224
290
|
featureId: row.feature_id,
|
|
225
|
-
port: row.port,
|
|
226
291
|
});
|
|
227
292
|
|
|
228
293
|
// Re-spawn QA session if one was active
|
|
229
|
-
if (row.qa_target_url
|
|
294
|
+
if (row.qa_target_url) {
|
|
230
295
|
await this.startQaSession(decisionId, row.qa_target_url, {
|
|
231
296
|
featureId: row.feature_id,
|
|
232
|
-
port: row.qa_port,
|
|
233
297
|
});
|
|
234
298
|
}
|
|
235
299
|
|
|
@@ -243,6 +307,19 @@ export class ReviewSessionManager {
|
|
|
243
307
|
return this._sessions.has(decisionId);
|
|
244
308
|
}
|
|
245
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Get all active sessions (for portal listing).
|
|
312
|
+
*/
|
|
313
|
+
getAllSessions() {
|
|
314
|
+
return [...this._sessions.entries()].map(([id, s]) => ({
|
|
315
|
+
decisionId: id,
|
|
316
|
+
type: s.type,
|
|
317
|
+
port: s.port,
|
|
318
|
+
url: s.url,
|
|
319
|
+
docPath: s.docPath,
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
|
|
246
323
|
// ─── Internal helpers ────────────────────────────────────────────────
|
|
247
324
|
|
|
248
325
|
/**
|
|
@@ -258,13 +335,20 @@ export class ReviewSessionManager {
|
|
|
258
335
|
});
|
|
259
336
|
|
|
260
337
|
let stdout = "";
|
|
338
|
+
let stderr = "";
|
|
261
339
|
let resolved = false;
|
|
262
340
|
|
|
263
341
|
const timer = setTimeout(() => {
|
|
264
342
|
if (!resolved) {
|
|
265
343
|
resolved = true;
|
|
266
|
-
//
|
|
267
|
-
|
|
344
|
+
// Check stderr for bind errors before accepting
|
|
345
|
+
if (stderr.includes("EADDRINUSE") || stderr.includes("address already in use")) {
|
|
346
|
+
reject(new Error(`Port already in use: ${stderr.trim()}`));
|
|
347
|
+
try { proc.kill("SIGKILL"); } catch { /* ok */ }
|
|
348
|
+
} else {
|
|
349
|
+
// Even if we didn't parse a session ID, the server may be running
|
|
350
|
+
resolve({ proc, sessionId: `session-${Date.now()}` });
|
|
351
|
+
}
|
|
268
352
|
}
|
|
269
353
|
}, 5000);
|
|
270
354
|
|
|
@@ -282,6 +366,17 @@ export class ReviewSessionManager {
|
|
|
282
366
|
}
|
|
283
367
|
});
|
|
284
368
|
|
|
369
|
+
proc.stderr.on("data", (chunk) => {
|
|
370
|
+
stderr += chunk.toString();
|
|
371
|
+
// Detect bind errors immediately
|
|
372
|
+
if (!resolved && (stderr.includes("EADDRINUSE") || stderr.includes("address already in use"))) {
|
|
373
|
+
resolved = true;
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
reject(new Error(`Port already in use: ${stderr.trim()}`));
|
|
376
|
+
try { proc.kill("SIGKILL"); } catch { /* ok */ }
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
285
380
|
proc.on("error", (err) => {
|
|
286
381
|
if (!resolved) {
|
|
287
382
|
resolved = true;
|
|
@@ -324,7 +419,12 @@ export class ReviewSessionManager {
|
|
|
324
419
|
_killProc(proc) {
|
|
325
420
|
if (!proc) return;
|
|
326
421
|
try {
|
|
327
|
-
proc.kill("
|
|
422
|
+
proc.kill("SIGTERM");
|
|
423
|
+
// Follow up with SIGKILL after grace period to ensure cleanup
|
|
424
|
+
const killTimer = setTimeout(() => {
|
|
425
|
+
try { proc.kill("SIGKILL"); } catch { /* already dead */ }
|
|
426
|
+
}, 2000);
|
|
427
|
+
killTimer.unref();
|
|
328
428
|
} catch {
|
|
329
429
|
// Process may already be dead
|
|
330
430
|
}
|