rivet-design 0.9.2 → 0.9.4
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/dist/mcp/agent-variants/SessionStore.d.ts +78 -2
- package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
- package/dist/mcp/agent-variants/SessionStore.js +464 -62
- package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +331 -9
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1985 -61
- package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts +65 -0
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -0
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +162 -0
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -0
- package/dist/mcp/agent-variants/contracts.d.ts +2508 -10
- package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
- package/dist/mcp/agent-variants/contracts.js +295 -5
- package/dist/mcp/agent-variants/contracts.js.map +1 -1
- package/dist/mcp/agent-variants/createProjectArtifacts.d.ts +78 -0
- package/dist/mcp/agent-variants/createProjectArtifacts.d.ts.map +1 -0
- package/dist/mcp/agent-variants/createProjectArtifacts.js +123 -0
- package/dist/mcp/agent-variants/createProjectArtifacts.js.map +1 -0
- package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +241 -0
- package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -0
- package/dist/mcp/agent-variants/createZeroToOneTool.js +213 -0
- package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -0
- package/dist/mcp/agent-variants/designContextStore.d.ts +160 -0
- package/dist/mcp/agent-variants/designContextStore.d.ts.map +1 -0
- package/dist/mcp/agent-variants/designContextStore.js +295 -0
- package/dist/mcp/agent-variants/designContextStore.js.map +1 -0
- package/dist/mcp/agent-variants/elementRefToTarget.d.ts +21 -0
- package/dist/mcp/agent-variants/elementRefToTarget.d.ts.map +1 -0
- package/dist/mcp/agent-variants/elementRefToTarget.js +47 -0
- package/dist/mcp/agent-variants/elementRefToTarget.js.map +1 -0
- package/dist/mcp/agent-variants/errors.d.ts +1 -1
- package/dist/mcp/agent-variants/errors.d.ts.map +1 -1
- package/dist/mcp/agent-variants/errors.js +7 -0
- package/dist/mcp/agent-variants/errors.js.map +1 -1
- package/dist/mcp/agent-variants/index.d.ts +4 -2
- package/dist/mcp/agent-variants/index.d.ts.map +1 -1
- package/dist/mcp/agent-variants/index.js +7 -1
- package/dist/mcp/agent-variants/index.js.map +1 -1
- package/dist/mcp/agent-variants/inspirationDesignContext.d.ts +440 -0
- package/dist/mcp/agent-variants/inspirationDesignContext.d.ts.map +1 -0
- package/dist/mcp/agent-variants/inspirationDesignContext.js +2467 -0
- package/dist/mcp/agent-variants/inspirationDesignContext.js.map +1 -0
- package/dist/mcp/agent-variants/pendingChangesAdapter.d.ts.map +1 -1
- package/dist/mcp/agent-variants/pendingChangesAdapter.js +21 -7
- package/dist/mcp/agent-variants/pendingChangesAdapter.js.map +1 -1
- package/dist/mcp/agent-variants/previewQa.d.ts +61 -0
- package/dist/mcp/agent-variants/previewQa.d.ts.map +1 -0
- package/dist/mcp/agent-variants/previewQa.js +374 -0
- package/dist/mcp/agent-variants/previewQa.js.map +1 -0
- package/dist/mcp/agent-variants/sourceContext.d.ts +8 -0
- package/dist/mcp/agent-variants/sourceContext.d.ts.map +1 -0
- package/dist/mcp/agent-variants/sourceContext.js +183 -0
- package/dist/mcp/agent-variants/sourceContext.js.map +1 -0
- package/dist/mcp/agent-variants/tools.d.ts +36 -0
- package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
- package/dist/mcp/agent-variants/tools.js +451 -19
- package/dist/mcp/agent-variants/tools.js.map +1 -1
- package/dist/mcp/changeBatchClassification.d.ts +30 -0
- package/dist/mcp/changeBatchClassification.d.ts.map +1 -0
- package/dist/mcp/changeBatchClassification.js +65 -0
- package/dist/mcp/changeBatchClassification.js.map +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +258 -41
- package/dist/mcp/server.js.map +1 -1
- package/dist/prompts/agentModPrompts.js +4 -4
- package/dist/prompts/agentModPrompts.js.map +1 -1
- package/dist/proxy-middleware/proxy-config.d.ts.map +1 -1
- package/dist/proxy-middleware/proxy-config.js +1 -15
- package/dist/proxy-middleware/proxy-config.js.map +1 -1
- package/dist/routes/agentVariants.d.ts +3 -1
- package/dist/routes/agentVariants.d.ts.map +1 -1
- package/dist/routes/agentVariants.js +138 -13
- package/dist/routes/agentVariants.js.map +1 -1
- package/dist/routes/mcp.d.ts +7 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +139 -16
- package/dist/routes/mcp.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +23 -5
- package/dist/server.js.map +1 -1
- package/dist/services/ProjectDetectionService.d.ts.map +1 -1
- package/dist/services/ProjectDetectionService.js +9 -0
- package/dist/services/ProjectDetectionService.js.map +1 -1
- package/dist/services/SessionBridgeService.d.ts +22 -0
- package/dist/services/SessionBridgeService.d.ts.map +1 -1
- package/dist/services/SessionBridgeService.js +61 -0
- package/dist/services/SessionBridgeService.js.map +1 -1
- package/dist/services/TelemetryService.d.ts +121 -0
- package/dist/services/TelemetryService.d.ts.map +1 -1
- package/dist/services/TelemetryService.js +155 -0
- package/dist/services/TelemetryService.js.map +1 -1
- package/dist/services/WorktreeManager.d.ts +116 -6
- package/dist/services/WorktreeManager.d.ts.map +1 -1
- package/dist/services/WorktreeManager.js +394 -19
- package/dist/services/WorktreeManager.js.map +1 -1
- package/dist/services/agent/AgentModService.js +6 -6
- package/dist/services/agent/AgentModService.js.map +1 -1
- package/dist/services/templates/designCatalog.d.ts +27 -0
- package/dist/services/templates/designCatalog.d.ts.map +1 -0
- package/dist/services/templates/designCatalog.js +141 -0
- package/dist/services/templates/designCatalog.js.map +1 -0
- package/dist/services/templates/designmd/airbnb.md +545 -0
- package/dist/services/templates/designmd/airtable.md +554 -0
- package/dist/services/templates/designmd/apple.md +562 -0
- package/dist/services/templates/designmd/binance.md +634 -0
- package/dist/services/templates/designmd/bmw-m.md +503 -0
- package/dist/services/templates/designmd/bmw.md +544 -0
- package/dist/services/templates/designmd/bugatti.md +454 -0
- package/dist/services/templates/designmd/cal.md +542 -0
- package/dist/services/templates/designmd/claude.md +589 -0
- package/dist/services/templates/designmd/clay.md +541 -0
- package/dist/services/templates/designmd/cohere.md +451 -0
- package/dist/services/templates/designmd/cursor.md +537 -0
- package/dist/services/templates/designmd/expo.md +526 -0
- package/dist/services/templates/designmd/figma.md +578 -0
- package/dist/services/templates/designmd/framer.md +544 -0
- package/dist/services/templates/designmd/hp.md +670 -0
- package/dist/services/templates/designmd/linear.app.md +548 -0
- package/dist/services/templates/designmd/mintlify.md +852 -0
- package/dist/services/templates/designmd/miro.md +825 -0
- package/dist/services/templates/designmd/notion.md +821 -0
- package/dist/services/templates/designmd/raycast.md +669 -0
- package/dist/services/templates/designmd/resend.md +585 -0
- package/dist/services/templates/designmd/sentry.md +262 -0
- package/dist/services/templates/designmd/shopify.md +350 -0
- package/dist/services/templates/designmd/spotify.md +246 -0
- package/dist/services/templates/designmd/stripe.md +322 -0
- package/dist/services/templates/designmd/supabase.md +255 -0
- package/dist/services/templates/designmd/superhuman.md +252 -0
- package/dist/services/templates/designmd/uber.md +295 -0
- package/dist/services/templates/designmd/vercel.md +310 -0
- package/dist/services/templates/viteReactTs.d.ts +48 -0
- package/dist/services/templates/viteReactTs.d.ts.map +1 -0
- package/dist/services/templates/viteReactTs.js +274 -0
- package/dist/services/templates/viteReactTs.js.map +1 -0
- package/dist/types/change-request-types.d.ts +29 -3
- package/dist/types/change-request-types.d.ts.map +1 -1
- package/dist/utils/skills/claude-skill.d.ts +2 -2
- package/dist/utils/skills/claude-skill.d.ts.map +1 -1
- package/dist/utils/skills/claude-skill.js +19 -98
- package/dist/utils/skills/claude-skill.js.map +1 -1
- package/dist/utils/skills/cursor-rules.d.ts +2 -2
- package/dist/utils/skills/cursor-rules.d.ts.map +1 -1
- package/dist/utils/skills/cursor-rules.js +15 -80
- package/dist/utils/skills/cursor-rules.js.map +1 -1
- package/dist/utils/skills/shared-variants-protocol.d.ts +23 -0
- package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -0
- package/dist/utils/skills/shared-variants-protocol.js +130 -0
- package/dist/utils/skills/shared-variants-protocol.js.map +1 -0
- package/package.json +6 -6
- package/src/ui/dist/assets/main-CpX7fB64.js +382 -0
- package/src/ui/dist/assets/main-Qqe2_oMT.css +1 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/main-AsPCtLsx.js +0 -382
- package/src/ui/dist/assets/main-BzmseUDd.css +0 -1
|
@@ -1,11 +1,191 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.AgentVariantsOrchestrator = void 0;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
4
8
|
const events_1 = require("events");
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const simple_git_1 = require("simple-git");
|
|
5
13
|
const logger_1 = require("../../utils/logger");
|
|
6
14
|
const errors_1 = require("./errors");
|
|
15
|
+
const createProjectArtifacts_1 = require("./createProjectArtifacts");
|
|
7
16
|
const contracts_1 = require("./contracts");
|
|
17
|
+
const viteReactTs_1 = require("../../services/templates/viteReactTs");
|
|
18
|
+
const designCatalog_1 = require("../../services/templates/designCatalog");
|
|
19
|
+
const previewQa_1 = require("./previewQa");
|
|
8
20
|
const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
|
|
21
|
+
const FRESH_DEV_SERVER_HOST = '127.0.0.1';
|
|
22
|
+
/**
|
|
23
|
+
* Allowlist of asset file extensions an agent-planned source may have.
|
|
24
|
+
* `assetPlan` is sized for large local *assets* (3D models, images,
|
|
25
|
+
* audio, video, fonts) — never code, dotfiles, secrets, or arbitrary
|
|
26
|
+
* configs. The check is defense-in-depth alongside the path / symlink
|
|
27
|
+
* filters in `copyAssetIntoWorktree`; without it, a prompt-injected
|
|
28
|
+
* source plan could copy readable host files such as SSH keys or
|
|
29
|
+
* project secrets into the generated app.
|
|
30
|
+
*
|
|
31
|
+
* Intentionally excludes `.json`, `.txt`, `.md`, `.csv`: these are
|
|
32
|
+
* common carriers for credentials (`credentials.json`, `.aws/config`,
|
|
33
|
+
* shell history `.txt`, README-shaped secrets) and have no legitimate
|
|
34
|
+
* use as binary preview assets. If a future flow needs structured data
|
|
35
|
+
* it should ship it through the manifest instead of file copy.
|
|
36
|
+
*/
|
|
37
|
+
const ALLOWED_ASSET_EXTENSIONS = new Set([
|
|
38
|
+
// 3D / models
|
|
39
|
+
'.glb', '.gltf', '.obj', '.fbx', '.usdz',
|
|
40
|
+
// images
|
|
41
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
|
|
42
|
+
// video
|
|
43
|
+
'.mp4', '.webm', '.mov',
|
|
44
|
+
// audio
|
|
45
|
+
'.mp3', '.wav', '.ogg', '.m4a',
|
|
46
|
+
// fonts
|
|
47
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
48
|
+
// PDFs
|
|
49
|
+
'.pdf',
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Reject paths that traverse through a "sensitive" directory anywhere
|
|
53
|
+
* in their ancestry — `.ssh`, `.aws`, `.config`, `.gnupg`, etc. Even if
|
|
54
|
+
* a copy passes the extension allowlist, an agent-controlled source
|
|
55
|
+
* resolving inside `~/.ssh/` is almost certainly an exfiltration
|
|
56
|
+
* attempt rather than a legitimate asset reference.
|
|
57
|
+
*/
|
|
58
|
+
const SENSITIVE_PATH_SEGMENTS = new Set([
|
|
59
|
+
'.ssh',
|
|
60
|
+
'.aws',
|
|
61
|
+
'.gnupg',
|
|
62
|
+
'.kube',
|
|
63
|
+
'.docker',
|
|
64
|
+
'.config',
|
|
65
|
+
'.npm',
|
|
66
|
+
'.cargo',
|
|
67
|
+
'.gem',
|
|
68
|
+
'.netrc',
|
|
69
|
+
'private',
|
|
70
|
+
'secrets',
|
|
71
|
+
'credentials',
|
|
72
|
+
]);
|
|
73
|
+
function hasSensitivePathSegment(absolutePath) {
|
|
74
|
+
// Split on any path separator; check each segment case-insensitively.
|
|
75
|
+
const segments = absolutePath.split(/[/\\]/).filter(Boolean);
|
|
76
|
+
return segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Returns true when `absolutePath` is the root itself or one of its
|
|
80
|
+
* descendants after both paths have been resolved.
|
|
81
|
+
*/
|
|
82
|
+
function isPathWithinRoot(absolutePath, root) {
|
|
83
|
+
const relative = path_1.default.relative(root, absolutePath);
|
|
84
|
+
return (relative === '' ||
|
|
85
|
+
(relative.length > 0 &&
|
|
86
|
+
!relative.startsWith('..') &&
|
|
87
|
+
!path_1.default.isAbsolute(relative)));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Copy one agent-planned asset file into a variant worktree at the
|
|
91
|
+
* project-relative `destination`. The agent decides routing
|
|
92
|
+
* (`destination` like `public/assets/avatar.glb`) so all variants share
|
|
93
|
+
* a consistent layout. Fails loudly when the source is missing or the
|
|
94
|
+
* destination tries to escape the worktree — silent fallbacks here
|
|
95
|
+
* would re-introduce the broken-asset failure mode this routing is
|
|
96
|
+
* supposed to prevent.
|
|
97
|
+
*
|
|
98
|
+
* Security: `entry.source` comes from `report_source_plan` and is
|
|
99
|
+
* agent-controlled. To prevent a prompt-injected source plan from
|
|
100
|
+
* exfiltrating arbitrary readable host files (SSH keys, dotfiles,
|
|
101
|
+
* cloud credentials, project secrets) into the generated app, the
|
|
102
|
+
* source must (1) be an absolute path that resolves via realpath
|
|
103
|
+
* (no traversal, no symlinks pointing outside the resolved location),
|
|
104
|
+
* (2) be a regular file (not a device, socket, fifo, or directory),
|
|
105
|
+
* (3) live under the approved asset root, (4) have an extension on the
|
|
106
|
+
* asset allowlist.
|
|
107
|
+
*/
|
|
108
|
+
function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
|
|
109
|
+
if (!assetSourceRoot) {
|
|
110
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source requires an approved asset root before copying '${entry.source}'`);
|
|
111
|
+
}
|
|
112
|
+
if (!path_1.default.isAbsolute(assetSourceRoot)) {
|
|
113
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot must be an absolute path, got '${assetSourceRoot}'`);
|
|
114
|
+
}
|
|
115
|
+
if (!path_1.default.isAbsolute(entry.source)) {
|
|
116
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be an absolute path, got '${entry.source}'`);
|
|
117
|
+
}
|
|
118
|
+
const ext = path_1.default.extname(entry.source).toLowerCase();
|
|
119
|
+
if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
|
|
120
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source extension '${ext || '(none)'}' is not on the allowlist (got '${entry.source}'). Only inert media/font/document assets may be copied.`);
|
|
121
|
+
}
|
|
122
|
+
// lstat first: a symlink whose target lies outside the source path
|
|
123
|
+
// could otherwise smuggle in arbitrary files. Reject symlinks outright;
|
|
124
|
+
// realpath would resolve them but the user-visible intent of an
|
|
125
|
+
// assetPlan entry is to name a concrete file.
|
|
126
|
+
let lstat;
|
|
127
|
+
try {
|
|
128
|
+
lstat = fs_1.default.lstatSync(entry.source);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source not found on disk: ${entry.source}`);
|
|
132
|
+
}
|
|
133
|
+
if (lstat.isSymbolicLink()) {
|
|
134
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must not be a symlink: ${entry.source}`);
|
|
135
|
+
}
|
|
136
|
+
if (!lstat.isFile()) {
|
|
137
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be a regular file: ${entry.source}`);
|
|
138
|
+
}
|
|
139
|
+
// Resolve realpath as additional defense: even with no symlinks at the
|
|
140
|
+
// leaf, an intermediate directory could be a symlink. Cross-check the
|
|
141
|
+
// file kind on the resolved path.
|
|
142
|
+
let resolvedSource;
|
|
143
|
+
try {
|
|
144
|
+
resolvedSource = fs_1.default.realpathSync(entry.source);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source could not be resolved: ${entry.source}`);
|
|
148
|
+
}
|
|
149
|
+
let resolvedAssetSourceRoot;
|
|
150
|
+
try {
|
|
151
|
+
resolvedAssetSourceRoot = fs_1.default.realpathSync(assetSourceRoot);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot could not be resolved: ${assetSourceRoot}`);
|
|
155
|
+
}
|
|
156
|
+
const resolvedStat = fs_1.default.statSync(resolvedSource);
|
|
157
|
+
if (!resolvedStat.isFile()) {
|
|
158
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved target must be a regular file: ${entry.source}`);
|
|
159
|
+
}
|
|
160
|
+
// Symlinked parent directory defense: even though we rejected a symlink
|
|
161
|
+
// leaf and confirmed the resolved file is regular, an intermediate dir
|
|
162
|
+
// could have been a symlink that quietly forwards into a sensitive
|
|
163
|
+
// ancestor (e.g. `<asset-root>/avatar -> ~/.ssh`). Cross-check that
|
|
164
|
+
// NEITHER the user-supplied path NOR its realpath traverses a known
|
|
165
|
+
// sensitive segment such as `.ssh`, `.aws`, `credentials`, etc. Also
|
|
166
|
+
// re-verify the extension on the resolved path so a `.glb` symlink
|
|
167
|
+
// chain cannot smuggle in a `.json` realpath.
|
|
168
|
+
if (hasSensitivePathSegment(entry.source) ||
|
|
169
|
+
hasSensitivePathSegment(resolvedSource)) {
|
|
170
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source traverses a sensitive directory and is refused: ${entry.source}`);
|
|
171
|
+
}
|
|
172
|
+
if (!isPathWithinRoot(resolvedSource, resolvedAssetSourceRoot)) {
|
|
173
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must stay inside the approved asset root: ${entry.source}`);
|
|
174
|
+
}
|
|
175
|
+
const resolvedExt = path_1.default.extname(resolvedSource).toLowerCase();
|
|
176
|
+
if (!ALLOWED_ASSET_EXTENSIONS.has(resolvedExt)) {
|
|
177
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
|
|
178
|
+
}
|
|
179
|
+
const normalizedDest = path_1.default.normalize(entry.destination);
|
|
180
|
+
if (normalizedDest.startsWith('..') ||
|
|
181
|
+
path_1.default.isAbsolute(normalizedDest)) {
|
|
182
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
|
|
183
|
+
}
|
|
184
|
+
const absDest = path_1.default.join(worktreePath, normalizedDest);
|
|
185
|
+
fs_1.default.mkdirSync(path_1.default.dirname(absDest), { recursive: true });
|
|
186
|
+
fs_1.default.copyFileSync(resolvedSource, absDest);
|
|
187
|
+
}
|
|
188
|
+
const defaultPreviewQaRunner = ({ html }) => (0, previewQa_1.runPreviewQa)({ html });
|
|
9
189
|
const NOOP_TELEMETRY = { track: () => undefined };
|
|
10
190
|
/**
|
|
11
191
|
* Wraps SessionStore for the operations that have side effects: approve
|
|
@@ -25,7 +205,31 @@ class AgentVariantsOrchestrator {
|
|
|
25
205
|
adapter;
|
|
26
206
|
resolveEnv;
|
|
27
207
|
telemetry;
|
|
208
|
+
installDependencies;
|
|
209
|
+
materializeProject;
|
|
210
|
+
previewQaRunner;
|
|
211
|
+
switchPreviewPort;
|
|
28
212
|
resources = new Map();
|
|
213
|
+
/**
|
|
214
|
+
* Committed dev servers from prior sessions that survived teardown. The
|
|
215
|
+
* orchestrator outlives any single session — once a new session starts, the
|
|
216
|
+
* prior committed dev server is no longer needed (the next session opens
|
|
217
|
+
* its own preview chip), so stop it before the new session's first preview
|
|
218
|
+
* goes live. Keyed by sessionId for diagnostics; values are kept alive
|
|
219
|
+
* intentionally between commit and next `propose` / `shutdown`.
|
|
220
|
+
*/
|
|
221
|
+
lingeringCommittedDevServers = new Map();
|
|
222
|
+
/**
|
|
223
|
+
* Per-session in-flight commit chain. Two overlapping commitVariant calls
|
|
224
|
+
* would otherwise both pass the `getVariantPick` idempotency check (the
|
|
225
|
+
* pick isn't recorded until after `applyDiffToProject`) and double-invoke
|
|
226
|
+
* `git apply` — which corrupts the working tree on the second run. The
|
|
227
|
+
* chain serializes commits per session so the second caller waits for the
|
|
228
|
+
* first to finish and then hits the idempotency check (returning
|
|
229
|
+
* `duplicate: true` for the same variantId, or `PENDING_CHANGE_CONFLICT`
|
|
230
|
+
* for a different one).
|
|
231
|
+
*/
|
|
232
|
+
inflightCommits = new Map();
|
|
29
233
|
/** Most recent agent-variants sessionId — read by the iframe chip via
|
|
30
234
|
* GET /api/variants/active. Cleared on cancel or commit. */
|
|
31
235
|
activeSessionId = null;
|
|
@@ -38,9 +242,21 @@ class AgentVariantsOrchestrator {
|
|
|
38
242
|
this.adapter = deps.pendingChangesAdapter;
|
|
39
243
|
this.resolveEnv = deps.resolveProjectEnvironment;
|
|
40
244
|
this.telemetry = deps.telemetry ?? NOOP_TELEMETRY;
|
|
245
|
+
this.installDependencies =
|
|
246
|
+
deps.installDependencies ?? defaultInstallDependencies;
|
|
247
|
+
this.materializeProject =
|
|
248
|
+
deps.materializeProject ?? defaultMaterializeProject;
|
|
249
|
+
this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
|
|
250
|
+
this.switchPreviewPort = deps.switchPreviewPort;
|
|
41
251
|
}
|
|
42
252
|
// --- Pure delegations (no side effects) ---------------------------------
|
|
43
253
|
propose(args) {
|
|
254
|
+
// A new session means any lingering committed dev server from a prior
|
|
255
|
+
// session is no longer needed — the next session opens its own preview.
|
|
256
|
+
// Best-effort stop; failures here don't block propose.
|
|
257
|
+
void this.stopLingeringCommittedDevServers().catch((err) => {
|
|
258
|
+
log.warn('Stopping lingering committed dev servers failed', err);
|
|
259
|
+
});
|
|
44
260
|
const result = this.store.propose(args);
|
|
45
261
|
this.activeSessionId = result.sessionId;
|
|
46
262
|
this.ensureResources(result.sessionId);
|
|
@@ -82,13 +298,11 @@ class AgentVariantsOrchestrator {
|
|
|
82
298
|
stage === 'cancelled'
|
|
83
299
|
? this.store.getSummary(sessionId)
|
|
84
300
|
: null;
|
|
85
|
-
const variants =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
worktreePath: this.getWorktreePath(sessionId, v.workItemId),
|
|
91
|
-
}));
|
|
301
|
+
const variants = this.getVariants(sessionId);
|
|
302
|
+
const sessionProjectContext = this.store.getProjectContext(sessionId);
|
|
303
|
+
const projectContext = toActiveProjectContext(sessionProjectContext);
|
|
304
|
+
const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
|
|
305
|
+
const artifacts = buildSessionArtifacts(sessionProjectContext);
|
|
92
306
|
return {
|
|
93
307
|
active: true,
|
|
94
308
|
sessionId,
|
|
@@ -97,6 +311,9 @@ class AgentVariantsOrchestrator {
|
|
|
97
311
|
progress,
|
|
98
312
|
summary,
|
|
99
313
|
variants,
|
|
314
|
+
projectContext,
|
|
315
|
+
...(destinationPath ? { destinationPath } : {}),
|
|
316
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
100
317
|
};
|
|
101
318
|
}
|
|
102
319
|
emitChange() {
|
|
@@ -109,6 +326,26 @@ class AgentVariantsOrchestrator {
|
|
|
109
326
|
}
|
|
110
327
|
reportBriefs(args) {
|
|
111
328
|
const result = this.store.reportBriefs(args);
|
|
329
|
+
const evidenceBackedCount = result.briefs.filter((brief) => /\b(source|evidence|preserve|borrow)\b/i.test(brief.body)).length;
|
|
330
|
+
const shortBriefCount = result.briefs.filter((brief) => brief.body.length <= 200).length;
|
|
331
|
+
this.telemetry.trackAgentVariantsBriefQuality?.({
|
|
332
|
+
sessionId: args.sessionId,
|
|
333
|
+
briefCount: result.briefs.length,
|
|
334
|
+
evidenceBackedCount,
|
|
335
|
+
shortBriefCount,
|
|
336
|
+
});
|
|
337
|
+
this.emitChange();
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
reportSourcePlan(args) {
|
|
341
|
+
const result = this.store.reportSourcePlan(args);
|
|
342
|
+
this.telemetry.trackAgentVariantsSourceContextQuality?.({
|
|
343
|
+
sessionId: args.sessionId,
|
|
344
|
+
sourceUrlCount: args.sourcePlan.sourceIntent.sources.length,
|
|
345
|
+
artifactCount: args.sourcePlan.sourceContext.sourceFindings.length,
|
|
346
|
+
hasScreenshotReferences: (args.sourcePlan.sourceContext.screenshotReferences?.length ?? 0) > 0,
|
|
347
|
+
preserveBrand: args.sourcePlan.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
|
|
348
|
+
});
|
|
112
349
|
this.emitChange();
|
|
113
350
|
return result;
|
|
114
351
|
}
|
|
@@ -156,9 +393,83 @@ class AgentVariantsOrchestrator {
|
|
|
156
393
|
getSummary(sessionId) {
|
|
157
394
|
return this.store.getSummary(sessionId);
|
|
158
395
|
}
|
|
396
|
+
getVariants(sessionId) {
|
|
397
|
+
const resources = this.resources.get(sessionId);
|
|
398
|
+
return this.store.getVariants(sessionId).map((variant) => {
|
|
399
|
+
const port = this.getDevServerPort(sessionId, variant.workItemId);
|
|
400
|
+
// For fresh sessions, static_preview items ARE the variants — look up
|
|
401
|
+
// directly by workItemId. Fall back to briefId search for existing-project
|
|
402
|
+
// sessions where code_gen items were paired with companion static_preview items.
|
|
403
|
+
const staticPreview = resources?.staticPreviews.get(variant.workItemId) ??
|
|
404
|
+
this.getStaticPreviewByBriefId(sessionId, variant.briefId);
|
|
405
|
+
let preview;
|
|
406
|
+
if (staticPreview) {
|
|
407
|
+
preview = {
|
|
408
|
+
kind: 'static_artifact',
|
|
409
|
+
url: this.buildStaticPreviewUrl(sessionId, staticPreview.workItemId),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
else if (!port) {
|
|
413
|
+
// handleSucceededReport is fire-and-forget so staticPreviews may not be
|
|
414
|
+
// populated yet when getVariants is called (e.g. in the terminal response).
|
|
415
|
+
// item.output is set synchronously by store.reportComplete, so it's always
|
|
416
|
+
// available. Read it directly as a fallback so previewUrls in terminal
|
|
417
|
+
// responses include the variant that just triggered terminal state.
|
|
418
|
+
try {
|
|
419
|
+
const output = this.store.getWorkItemOutput(sessionId, variant.workItemId);
|
|
420
|
+
if (typeof output?.html === 'string' && output.html.length > 0) {
|
|
421
|
+
preview = {
|
|
422
|
+
kind: 'static_artifact',
|
|
423
|
+
url: this.buildStaticPreviewUrl(sessionId, variant.workItemId),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch { /* work item may not exist in edge cases */ }
|
|
428
|
+
}
|
|
429
|
+
if (!preview && port) {
|
|
430
|
+
preview = { kind: 'dev_server', port };
|
|
431
|
+
}
|
|
432
|
+
const qa = resources?.qaResults.get(variant.workItemId);
|
|
433
|
+
const isSucceeded = variant.status === 'succeeded';
|
|
434
|
+
const qaFailed = qa?.status === 'failed';
|
|
435
|
+
const canView = Boolean(preview) || (isSucceeded && Boolean(port));
|
|
436
|
+
const canCommit = isSucceeded && !qaFailed;
|
|
437
|
+
const commitDisabledReason = qaFailed
|
|
438
|
+
? qa?.summary ?? 'Variant failed QA'
|
|
439
|
+
: 'Wait for a successful variant';
|
|
440
|
+
return {
|
|
441
|
+
...variant,
|
|
442
|
+
design: enrichDesignSource(variant.design),
|
|
443
|
+
...(preview ? { preview } : {}),
|
|
444
|
+
port,
|
|
445
|
+
...(qa ? { qa } : {}),
|
|
446
|
+
actions: {
|
|
447
|
+
view: canView
|
|
448
|
+
? { enabled: true }
|
|
449
|
+
: {
|
|
450
|
+
enabled: false,
|
|
451
|
+
reason: isSucceeded
|
|
452
|
+
? 'Preview is unavailable for this variant'
|
|
453
|
+
: 'Variant is still running',
|
|
454
|
+
},
|
|
455
|
+
commit: canCommit
|
|
456
|
+
? { enabled: true }
|
|
457
|
+
: {
|
|
458
|
+
enabled: false,
|
|
459
|
+
reason: commitDisabledReason,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
}
|
|
159
465
|
hasSession(sessionId) {
|
|
160
466
|
return this.store.hasSession(sessionId);
|
|
161
467
|
}
|
|
468
|
+
/** Read the projectContext for a session — used by Express routes that
|
|
469
|
+
* build the chip snapshot via `stateForSession`. */
|
|
470
|
+
getProjectContext(sessionId) {
|
|
471
|
+
return this.store.getProjectContext(sessionId);
|
|
472
|
+
}
|
|
162
473
|
/** Resolve a worktree path for a code-gen work item, if provisioned. */
|
|
163
474
|
getWorktreePath(sessionId, workItemId) {
|
|
164
475
|
return this.resources.get(sessionId)?.worktrees.get(workItemId)
|
|
@@ -168,16 +479,45 @@ class AgentVariantsOrchestrator {
|
|
|
168
479
|
getDevServerPort(sessionId, workItemId) {
|
|
169
480
|
return this.resources.get(sessionId)?.worktrees.get(workItemId)?.port;
|
|
170
481
|
}
|
|
482
|
+
getStaticPreviewHtml(sessionId, workItemId) {
|
|
483
|
+
// Primary: from the staticPreviews Map populated by handleSucceededReport.
|
|
484
|
+
const fromMap = this.resources.get(sessionId)?.staticPreviews.get(workItemId)?.html;
|
|
485
|
+
if (fromMap)
|
|
486
|
+
return fromMap;
|
|
487
|
+
// Fallback: read directly from the work item's stored output — available
|
|
488
|
+
// as soon as reportComplete runs, before handleSucceededReport fires.
|
|
489
|
+
try {
|
|
490
|
+
const output = this.store.getWorkItemOutput(sessionId, workItemId);
|
|
491
|
+
return typeof output?.html === 'string' && output.html.length > 0
|
|
492
|
+
? buildStaticPreviewDocument({ html: output.html })
|
|
493
|
+
: undefined;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
getStaticPreviewByBriefId(sessionId, briefId) {
|
|
500
|
+
const resources = this.resources.get(sessionId);
|
|
501
|
+
if (!resources)
|
|
502
|
+
return undefined;
|
|
503
|
+
return [...resources.staticPreviews.values()].find((preview) => preview.briefId === briefId);
|
|
504
|
+
}
|
|
505
|
+
buildStaticPreviewUrl(sessionId, workItemId) {
|
|
506
|
+
const safeSessionId = encodeURIComponent(sessionId);
|
|
507
|
+
const safeWorkItemId = encodeURIComponent(workItemId);
|
|
508
|
+
return `/api/variants/${safeSessionId}/static/${safeWorkItemId}`;
|
|
509
|
+
}
|
|
171
510
|
// --- Mutation wrappers (state + side effects) ---------------------------
|
|
172
511
|
async approve(args) {
|
|
173
512
|
const result = this.store.approve(args);
|
|
174
|
-
this.ensureResources(args.sessionId);
|
|
513
|
+
const resources = this.ensureResources(args.sessionId);
|
|
514
|
+
resources.approveStartedAt = Date.now();
|
|
175
515
|
this.telemetry.track('agent_variants.approved', {
|
|
176
516
|
source: 'mcp',
|
|
177
517
|
sessionId: args.sessionId,
|
|
178
518
|
approvedCount: result.approvedCount,
|
|
179
519
|
totalBriefs: result.totalCount,
|
|
180
|
-
isFresh:
|
|
520
|
+
isFresh: this.store.getProjectContext(args.sessionId).kind === 'fresh',
|
|
181
521
|
});
|
|
182
522
|
this.emitChange();
|
|
183
523
|
// Await provisioning before returning so request_work always sees a
|
|
@@ -195,28 +535,153 @@ class AgentVariantsOrchestrator {
|
|
|
195
535
|
}
|
|
196
536
|
return result;
|
|
197
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Unified one-call kickoff that collapses propose → report_briefs →
|
|
540
|
+
* approve. Synthesizes placeholder briefs server-side (the agent's
|
|
541
|
+
* streamed first-line label replaces them in the UI) and immediately
|
|
542
|
+
* approves so the session lands in `work_items_ready`.
|
|
543
|
+
*
|
|
544
|
+
* Supports both existing-project sessions (spawns code_gen work items
|
|
545
|
+
* for the agent to lease) and zero-to-one sessions (spawns
|
|
546
|
+
* static_preview work items; the server runs scaffold_base in the
|
|
547
|
+
* background). For zero-to-one sessions with source URLs / inspiration
|
|
548
|
+
* extraction, callers must use create_zero_to_one_project instead —
|
|
549
|
+
* this method does not run source research.
|
|
550
|
+
*
|
|
551
|
+
* Host-agnostic: no LLM calls happen here. The agent (Claude Code,
|
|
552
|
+
* Cursor, Codex) generates the label and code when it leases the
|
|
553
|
+
* work items via continue_variants(action="request_work").
|
|
554
|
+
*/
|
|
555
|
+
async startUnified(args) {
|
|
556
|
+
const count = args.briefs?.length ?? args.count ?? 4;
|
|
557
|
+
const projectContext = args.projectContext ?? { kind: 'existing' };
|
|
558
|
+
const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
|
|
559
|
+
const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
|
|
560
|
+
Boolean(sourceContext?.sourceArtifacts?.length) ||
|
|
561
|
+
Boolean(sourceContext?.sourceIntent) ||
|
|
562
|
+
Boolean(sourceContext?.artifact);
|
|
563
|
+
if (isSourceGrounded) {
|
|
564
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-grounded sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
|
|
565
|
+
}
|
|
566
|
+
const proposeResult = this.propose({
|
|
567
|
+
prompt: args.prompt,
|
|
568
|
+
count,
|
|
569
|
+
target: args.target,
|
|
570
|
+
projectContext,
|
|
571
|
+
});
|
|
572
|
+
if (proposeResult.stage !== 'awaiting_briefs' ||
|
|
573
|
+
!proposeResult.briefWorkItem) {
|
|
574
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `start_variants expected awaiting_briefs from propose, got ${proposeResult.stage}`);
|
|
575
|
+
}
|
|
576
|
+
const briefs = args.briefs
|
|
577
|
+
? args.briefs.map((b, i) => ({
|
|
578
|
+
briefId: `v${i + 1}`,
|
|
579
|
+
label: b.label,
|
|
580
|
+
body: b.body,
|
|
581
|
+
...(b.visualReferenceUrl
|
|
582
|
+
? { visualReferenceUrl: b.visualReferenceUrl }
|
|
583
|
+
: {}),
|
|
584
|
+
}))
|
|
585
|
+
: Array.from({ length: count }, (_, i) => ({
|
|
586
|
+
briefId: `v${i + 1}`,
|
|
587
|
+
label: `Variant ${i + 1}`,
|
|
588
|
+
body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
|
|
589
|
+
}));
|
|
590
|
+
this.store.reportBriefs({
|
|
591
|
+
sessionId: proposeResult.sessionId,
|
|
592
|
+
workItemId: proposeResult.briefWorkItem.id,
|
|
593
|
+
attempt: proposeResult.briefWorkItem.attempt,
|
|
594
|
+
briefs,
|
|
595
|
+
});
|
|
596
|
+
const approveResult = await this.approve({
|
|
597
|
+
sessionId: proposeResult.sessionId,
|
|
598
|
+
selections: briefs.map((b) => ({ briefId: b.briefId })),
|
|
599
|
+
});
|
|
600
|
+
this.telemetry.track('agent_variants.unified_started', {
|
|
601
|
+
source: 'mcp',
|
|
602
|
+
sessionId: proposeResult.sessionId,
|
|
603
|
+
count,
|
|
604
|
+
hasTarget: Boolean(args.target),
|
|
605
|
+
targetType: args.target?.type ?? null,
|
|
606
|
+
projectContextKind: projectContext.kind,
|
|
607
|
+
});
|
|
608
|
+
// For existing-project sessions, approve() returns the new code_gen
|
|
609
|
+
// ids in codeGenWorkItemIds. For zero-to-one, approve() spawns
|
|
610
|
+
// static_preview items instead — those ids aren't in codeGenWorkItemIds,
|
|
611
|
+
// so read them out of the active-variants snapshot which covers both
|
|
612
|
+
// kinds. Snapshot order mirrors brief order (insertion order).
|
|
613
|
+
const variantIds = projectContext.kind === 'fresh'
|
|
614
|
+
? this.store
|
|
615
|
+
.getVariants(proposeResult.sessionId)
|
|
616
|
+
.map((v) => v.workItemId)
|
|
617
|
+
: approveResult.codeGenWorkItemIds;
|
|
618
|
+
const variants = variantIds.map((workItemId, i) => ({
|
|
619
|
+
variantId: workItemId,
|
|
620
|
+
briefId: briefs[i].briefId,
|
|
621
|
+
label: briefs[i].label,
|
|
622
|
+
status: 'pending',
|
|
623
|
+
}));
|
|
624
|
+
let leaseId;
|
|
625
|
+
let leaseTtlMs;
|
|
626
|
+
let leasedWorkItems;
|
|
627
|
+
if (args.leaseOwner) {
|
|
628
|
+
const lease = this.requestWork({
|
|
629
|
+
sessionId: proposeResult.sessionId,
|
|
630
|
+
leaseOwner: args.leaseOwner,
|
|
631
|
+
});
|
|
632
|
+
leaseId = lease.leaseId;
|
|
633
|
+
leaseTtlMs = lease.leaseTtlMs;
|
|
634
|
+
leasedWorkItems = lease.leasedWorkItems;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
sessionId: proposeResult.sessionId,
|
|
638
|
+
variants,
|
|
639
|
+
scaffoldBaseWorkItemId: approveResult.scaffoldBaseWorkItemId ?? null,
|
|
640
|
+
leaseId,
|
|
641
|
+
leaseTtlMs,
|
|
642
|
+
leasedWorkItems,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
198
645
|
async reportComplete(args) {
|
|
199
|
-
|
|
646
|
+
// QA gate: for `succeeded` static_preview reports, run preview QA
|
|
647
|
+
// synchronously before recording success in the store. A failed QA
|
|
648
|
+
// verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
|
|
649
|
+
// so the variant never reaches `ready` and `getVariants` can disable
|
|
650
|
+
// its commit action.
|
|
651
|
+
const qaOverride = await this.evaluateQaForReport(args);
|
|
652
|
+
const effectiveArgs = qaOverride?.overrideArgs ?? args;
|
|
653
|
+
const result = this.store.reportComplete(effectiveArgs);
|
|
200
654
|
this.emitChange();
|
|
201
655
|
// Per-variant telemetry on terminal item statuses (skip 'running'
|
|
202
656
|
// heartbeats and the brief work item — only code_gen / scaffold_base
|
|
203
657
|
// matter for the funnel).
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
658
|
+
if (effectiveArgs.status === 'succeeded' ||
|
|
659
|
+
effectiveArgs.status === 'failed' ||
|
|
660
|
+
effectiveArgs.status === 'cancelled') {
|
|
207
661
|
const resources = this.resources.get(args.sessionId);
|
|
208
662
|
const leasedAt = resources?.leasedAt.get(args.workItemId);
|
|
209
663
|
const durationMs = leasedAt !== undefined ? Date.now() - leasedAt : null;
|
|
664
|
+
const approveStartedAt = resources?.approveStartedAt;
|
|
665
|
+
const approveToCompleteMs = approveStartedAt !== undefined ? Date.now() - approveStartedAt : null;
|
|
666
|
+
// Byte size of generated output for HTML vs full-stack comparison.
|
|
667
|
+
const outputBytes = estimateOutputBytes(args.output);
|
|
668
|
+
const projectContextKind = this.store.getProjectContext(args.sessionId).kind;
|
|
210
669
|
this.telemetry.track('agent_variants.variant_completed', {
|
|
211
670
|
source: 'mcp',
|
|
212
671
|
sessionId: args.sessionId,
|
|
213
672
|
workItemId: args.workItemId,
|
|
214
|
-
status:
|
|
673
|
+
status: effectiveArgs.status,
|
|
215
674
|
attempt: args.attempt,
|
|
216
675
|
durationMs,
|
|
217
|
-
|
|
218
|
-
|
|
676
|
+
approveToCompleteMs,
|
|
677
|
+
hasError: Boolean(effectiveArgs.error),
|
|
678
|
+
errorCode: effectiveArgs.error?.code ?? null,
|
|
219
679
|
isScaffold: resources?.scaffoldBaseWorkItemId === args.workItemId,
|
|
680
|
+
projectContextKind,
|
|
681
|
+
tokensIn: args.tokensIn ?? null,
|
|
682
|
+
tokensOut: args.tokensOut ?? null,
|
|
683
|
+
model: args.model ?? null,
|
|
684
|
+
outputBytes,
|
|
220
685
|
});
|
|
221
686
|
}
|
|
222
687
|
if (contracts_1.TERMINAL_STAGES.has(result.stage)) {
|
|
@@ -224,6 +689,9 @@ class AgentVariantsOrchestrator {
|
|
|
224
689
|
if (resources && resources.terminalAt === undefined) {
|
|
225
690
|
resources.terminalAt = Date.now();
|
|
226
691
|
const totalLatencyMs = resources.terminalAt - resources.startedAt;
|
|
692
|
+
const approveToTerminalMs = resources.approveStartedAt !== undefined
|
|
693
|
+
? resources.terminalAt - resources.approveStartedAt
|
|
694
|
+
: null;
|
|
227
695
|
const summary = result.summary ?? this.store.getSummary(args.sessionId);
|
|
228
696
|
this.telemetry.track('agent_variants.terminal_state', {
|
|
229
697
|
source: 'mcp',
|
|
@@ -233,11 +701,17 @@ class AgentVariantsOrchestrator {
|
|
|
233
701
|
failureCount: summary.failureCount,
|
|
234
702
|
cancelledCount: summary.cancelledCount,
|
|
235
703
|
totalLatencyMs,
|
|
704
|
+
approveToTerminalMs,
|
|
705
|
+
projectContextKind: this.store.getProjectContext(args.sessionId).kind,
|
|
236
706
|
});
|
|
237
707
|
}
|
|
238
708
|
}
|
|
239
|
-
if (
|
|
240
|
-
void this.handleSucceededReport(
|
|
709
|
+
if (effectiveArgs.status === 'succeeded') {
|
|
710
|
+
void this.handleSucceededReport({
|
|
711
|
+
sessionId: args.sessionId,
|
|
712
|
+
workItemId: args.workItemId,
|
|
713
|
+
output: args.output,
|
|
714
|
+
}).catch((err) => {
|
|
241
715
|
log.error(`handleSucceededReport failed for ${args.sessionId}/${args.workItemId}`, err);
|
|
242
716
|
});
|
|
243
717
|
}
|
|
@@ -267,6 +741,37 @@ class AgentVariantsOrchestrator {
|
|
|
267
741
|
});
|
|
268
742
|
return result;
|
|
269
743
|
}
|
|
744
|
+
/**
|
|
745
|
+
* Cancel a single variant within a session. Siblings continue. Idempotent
|
|
746
|
+
* on terminal states. Per-variant worktree teardown is deferred to a
|
|
747
|
+
* follow-up; the lease is released, so requestWork won't re-surface this
|
|
748
|
+
* variant and the agent's report_variant_complete for it (if it lands
|
|
749
|
+
* after cancellation) is rejected by the stale-lease check.
|
|
750
|
+
*/
|
|
751
|
+
async cancelVariant(args) {
|
|
752
|
+
const result = this.store.cancelWorkItem({
|
|
753
|
+
sessionId: args.sessionId,
|
|
754
|
+
workItemId: args.variantId,
|
|
755
|
+
reason: args.reason,
|
|
756
|
+
});
|
|
757
|
+
this.telemetry.track('agent_variants.variant_cancelled', {
|
|
758
|
+
source: 'mcp',
|
|
759
|
+
sessionId: args.sessionId,
|
|
760
|
+
variantId: args.variantId,
|
|
761
|
+
finalStatus: result.finalStatus,
|
|
762
|
+
sessionStage: result.sessionStage,
|
|
763
|
+
alreadyTerminal: result.alreadyTerminal,
|
|
764
|
+
reason: args.reason ?? null,
|
|
765
|
+
});
|
|
766
|
+
this.emitChange();
|
|
767
|
+
return {
|
|
768
|
+
sessionId: args.sessionId,
|
|
769
|
+
variantId: args.variantId,
|
|
770
|
+
finalStatus: result.finalStatus,
|
|
771
|
+
sessionStage: result.sessionStage,
|
|
772
|
+
alreadyTerminal: result.alreadyTerminal,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
270
775
|
/**
|
|
271
776
|
* User has reviewed the rendered variants in chat and picked one. Look up
|
|
272
777
|
* the captured diff, build a VariantPickEnvelope, and enqueue to the
|
|
@@ -274,6 +779,29 @@ class AgentVariantsOrchestrator {
|
|
|
274
779
|
* the call returns duplicate=true without re-enqueueing.
|
|
275
780
|
*/
|
|
276
781
|
async commitVariant(args) {
|
|
782
|
+
// Serialize per-session so two overlapping commits can't both run
|
|
783
|
+
// `applyDiffToProject` (or the fresh rename) before the first records
|
|
784
|
+
// its pick. The second caller waits for the first to settle, then runs
|
|
785
|
+
// commitVariantImpl which hits the idempotency check in
|
|
786
|
+
// `getVariantPick` / `recordVariantPick`.
|
|
787
|
+
const prior = this.inflightCommits.get(args.sessionId);
|
|
788
|
+
const run = (async () => {
|
|
789
|
+
if (prior) {
|
|
790
|
+
await prior.catch(() => undefined);
|
|
791
|
+
}
|
|
792
|
+
return this.commitVariantImpl(args);
|
|
793
|
+
})();
|
|
794
|
+
this.inflightCommits.set(args.sessionId, run);
|
|
795
|
+
try {
|
|
796
|
+
return await run;
|
|
797
|
+
}
|
|
798
|
+
finally {
|
|
799
|
+
if (this.inflightCommits.get(args.sessionId) === run) {
|
|
800
|
+
this.inflightCommits.delete(args.sessionId);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async commitVariantImpl(args) {
|
|
277
805
|
// Idempotent path — SessionStore.recordVariantPick is the source of truth
|
|
278
806
|
// for which variant the user picked. If the same variant is being
|
|
279
807
|
// committed again, replay the previously stored envelope's metadata
|
|
@@ -284,43 +812,212 @@ class AgentVariantsOrchestrator {
|
|
|
284
812
|
enqueued: false,
|
|
285
813
|
duplicate: true,
|
|
286
814
|
changedFilesCount: existingPick.changedFilesCount,
|
|
815
|
+
payloadKind: existingPick.payload.kind === 'new-project'
|
|
816
|
+
? 'project-created'
|
|
817
|
+
: existingPick.payload.kind,
|
|
818
|
+
destinationPath: existingPick.destinationPath,
|
|
287
819
|
};
|
|
288
820
|
}
|
|
289
821
|
const resources = this.resources.get(args.sessionId);
|
|
290
822
|
if (!resources) {
|
|
291
823
|
throw new errors_1.AgentVariantsError('SESSION_NOT_FOUND', `No live resources for session ${args.sessionId} (already torn down?)`);
|
|
292
824
|
}
|
|
293
|
-
const
|
|
294
|
-
if (!
|
|
295
|
-
throw new errors_1.AgentVariantsError('
|
|
296
|
-
}
|
|
297
|
-
if (record.diff === undefined) {
|
|
298
|
-
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
|
|
825
|
+
const variantSnapshot = this.getVariants(args.sessionId).find((variant) => variant.workItemId === args.variantId);
|
|
826
|
+
if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
|
|
827
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
|
|
299
828
|
}
|
|
300
|
-
const env = await this.resolveEnv(args.sessionId);
|
|
301
829
|
const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
|
|
302
830
|
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
831
|
+
let payload;
|
|
832
|
+
let envelopeDestination;
|
|
833
|
+
let changedFilesCount;
|
|
834
|
+
let freshVariantFolderName;
|
|
835
|
+
if (projectContext.kind === 'fresh') {
|
|
836
|
+
const destinationPath = projectContext.workspacePath;
|
|
837
|
+
this.assertDestinationAvailable(destinationPath);
|
|
838
|
+
const variantFolderName = this.getFreshVariantFolderName({
|
|
839
|
+
sessionId: args.sessionId,
|
|
840
|
+
variantId: args.variantId,
|
|
841
|
+
variantName: input.briefLabel,
|
|
842
|
+
});
|
|
843
|
+
freshVariantFolderName = variantFolderName;
|
|
844
|
+
const freshMode = projectContext.executionPlan?.mode === 'vite_app'
|
|
845
|
+
? 'vite_app'
|
|
846
|
+
: 'static_preview';
|
|
847
|
+
if (freshMode === 'vite_app') {
|
|
848
|
+
// Vite_app: the deliverable is the entire variant worktree, not a
|
|
849
|
+
// single HTML file. When the worktree lives on the same volume as
|
|
850
|
+
// the destination (the common case — provisionFreshWorktrees scaffolds
|
|
851
|
+
// under destinationParent/.rivet-variants/) we can rename it into
|
|
852
|
+
// place. That moves the entire tree — including the materialized
|
|
853
|
+
// node_modules — in O(1) with no copy, and the rename is atomic.
|
|
854
|
+
// For cross-volume cases (user passed a destinationParent on a
|
|
855
|
+
// different mount than the .rivet-variants dir, e.g. tests stubbing
|
|
856
|
+
// workspacePath to a tmp dir), fall back to the recursive-copy +
|
|
857
|
+
// background-install path so the feature still works.
|
|
858
|
+
const record = resources.worktrees.get(args.variantId);
|
|
859
|
+
if (!record) {
|
|
860
|
+
throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
|
|
861
|
+
}
|
|
862
|
+
let usedRename = false;
|
|
863
|
+
let isDependencyInstallRequired = false;
|
|
864
|
+
try {
|
|
865
|
+
if (areSameVolume(record.worktreePath, destinationPath)) {
|
|
866
|
+
// Vite's dev server writes its module cache to `<worktreePath>/.vite/`
|
|
867
|
+
// and recreates that directory on the fly whenever it's missing. If
|
|
868
|
+
// we rename a worktree out from under a running dev server, Vite
|
|
869
|
+
// will materialize an empty `.vite/` shell back at the OLD path —
|
|
870
|
+
// leaving behind orphan stub dirs in `.rivet-variants/` and
|
|
871
|
+
// confusing later cleanup. Stop every variant's dev server first
|
|
872
|
+
// (including the chosen variant, which is about to move) so no
|
|
873
|
+
// process is holding the old paths open during the rename.
|
|
874
|
+
//
|
|
875
|
+
// We stop them eagerly here rather than relying on teardownSession
|
|
876
|
+
// because teardown runs AFTER the rename — too late to prevent the
|
|
877
|
+
// stub-dir race.
|
|
878
|
+
await this.stopAllDevServersForSession(resources);
|
|
879
|
+
// node_modules in fresh variants 1..N is a symlink into variant 0
|
|
880
|
+
// (see runBackgroundInstall). After rename, variant 0's
|
|
881
|
+
// node_modules vanishes during cleanup of the sibling worktrees,
|
|
882
|
+
// leaving the chosen variant with a dangling link. Resolve the
|
|
883
|
+
// symlink to a real directory first so the destination has real
|
|
884
|
+
// deps and the rename is self-contained.
|
|
885
|
+
isDependencyInstallRequired = await resolveNodeModulesSymlink(record.worktreePath);
|
|
886
|
+
await this.renameWorktreeIntoDestination(record.worktreePath, destinationPath);
|
|
887
|
+
usedRename = true;
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
await this.materializeProject(record.worktreePath, destinationPath);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
895
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to materialize vite_app variant to ${destinationPath}: ${message}`);
|
|
896
|
+
}
|
|
897
|
+
changedFilesCount = countWorktreeFiles(destinationPath);
|
|
898
|
+
if (!usedRename) {
|
|
899
|
+
// Cross-volume fallback: materializeProject excludes node_modules
|
|
900
|
+
// so we have to re-install at the destination. Don't await the
|
|
901
|
+
// install — the agent's commit_variant call should return
|
|
902
|
+
// immediately — but chain the dev-server start to it so the iframe
|
|
903
|
+
// recovers without manual intervention once deps are ready.
|
|
904
|
+
void this.installDependencies(destinationPath)
|
|
905
|
+
.then(() => this.startCommittedDevServer({
|
|
906
|
+
resources,
|
|
907
|
+
destinationPath,
|
|
908
|
+
}))
|
|
909
|
+
.catch((err) => {
|
|
910
|
+
log.warn(`Background install + committed dev server start after vite_app commit failed for ${destinationPath}`, err);
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
else if (isDependencyInstallRequired) {
|
|
914
|
+
await this.installDependencies(destinationPath);
|
|
915
|
+
}
|
|
916
|
+
if (usedRename) {
|
|
917
|
+
// Preserve the unchosen variants as runnable siblings of the
|
|
918
|
+
// chosen project. Best-effort: failures here log and continue so
|
|
919
|
+
// a partial history never blocks the commit handoff.
|
|
920
|
+
try {
|
|
921
|
+
this.preserveUnchosenVariants({
|
|
922
|
+
sessionId: args.sessionId,
|
|
923
|
+
chosenVariantId: args.variantId,
|
|
924
|
+
destinationPath,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
log.warn(`preserveUnchosenVariants failed for session ${args.sessionId}; continuing without history`, err);
|
|
929
|
+
}
|
|
930
|
+
// Stopped every dev server pre-rename to dodge the .vite/ stub-dir
|
|
931
|
+
// race. The iframe is still pointed at the (now-dead) chosen
|
|
932
|
+
// variant's port via the proxy, so without a fresh server at the
|
|
933
|
+
// destination the user would see "preview disconnected" the moment
|
|
934
|
+
// they commit. Spawn one and retarget the proxy. Best-effort: if
|
|
935
|
+
// it fails, the user can `npm run dev` themselves at destination.
|
|
936
|
+
await this.startCommittedDevServer({
|
|
937
|
+
resources,
|
|
938
|
+
destinationPath,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
payload = {
|
|
942
|
+
kind: 'project-created',
|
|
943
|
+
destinationPath,
|
|
944
|
+
changedFilesCount,
|
|
945
|
+
note: usedRename
|
|
946
|
+
? 'Vite app moved to destinationPath (rename); node_modules came along.'
|
|
947
|
+
: 'Vite app materialized at destinationPath; `npm install` running in the background.',
|
|
948
|
+
};
|
|
949
|
+
envelopeDestination = destinationPath;
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
// Static_preview: HTML is the entire deliverable. Write index.html.
|
|
953
|
+
const staticPreview = resources.staticPreviews.get(args.variantId);
|
|
954
|
+
const persistedSnapshotIndexPath = (0, createProjectArtifacts_1.createProjectVariantIndexPath)(destinationPath, variantFolderName);
|
|
955
|
+
const htmlFromSnapshot = fs_1.default.existsSync(persistedSnapshotIndexPath)
|
|
956
|
+
? fs_1.default.readFileSync(persistedSnapshotIndexPath, 'utf8')
|
|
957
|
+
: null;
|
|
958
|
+
if (!staticPreview && !htmlFromSnapshot) {
|
|
959
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
fs_1.default.mkdirSync(destinationPath, { recursive: true });
|
|
963
|
+
fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
967
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
|
|
968
|
+
}
|
|
969
|
+
changedFilesCount = 1;
|
|
970
|
+
payload = {
|
|
971
|
+
kind: 'project-created',
|
|
972
|
+
destinationPath,
|
|
973
|
+
changedFilesCount,
|
|
974
|
+
note: 'Static preview written to index.html at destinationPath.',
|
|
975
|
+
};
|
|
976
|
+
envelopeDestination = destinationPath;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
// Existing project: diff against the user's repo. resolveEnv lives in
|
|
981
|
+
// this branch only — fresh sessions don't have a user-supplied project
|
|
982
|
+
// and the bridge.getProjectPath() it reads from would either be stale
|
|
983
|
+
// or empty for them.
|
|
984
|
+
const env = await this.resolveEnv(args.sessionId);
|
|
985
|
+
const record = resources.worktrees.get(args.variantId);
|
|
986
|
+
if (!record) {
|
|
987
|
+
throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
|
|
988
|
+
}
|
|
989
|
+
if (record.diff === undefined) {
|
|
990
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
|
|
991
|
+
}
|
|
992
|
+
changedFilesCount = countDiffFiles(record.diff);
|
|
993
|
+
// Apply the diff to the user's working tree right now so the variant
|
|
994
|
+
// lands on the current branch (uncommitted). Previously this just
|
|
995
|
+
// enqueued the diff for an agent to apply; non-MCP sessions had no
|
|
996
|
+
// agent and the variant was effectively lost.
|
|
997
|
+
if (!this.worktrees.applyDiffToProject) {
|
|
998
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'WorktreeManager does not support applying diffs to the project tree');
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
await this.worktrees.applyDiffToProject(record.diff, env.projectPath);
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to apply variant ${args.variantId} to ${env.projectPath}: ${message}`);
|
|
310
1006
|
}
|
|
311
|
-
|
|
312
|
-
kind: 'diff',
|
|
1007
|
+
payload = {
|
|
1008
|
+
kind: 'diff-applied',
|
|
313
1009
|
diff: record.diff,
|
|
314
1010
|
target: input.target,
|
|
315
1011
|
changedFilesCount,
|
|
1012
|
+
note: 'Variant diff applied to the user\'s working tree (uncommitted).',
|
|
316
1013
|
};
|
|
1014
|
+
envelopeDestination = env.projectPath;
|
|
1015
|
+
}
|
|
317
1016
|
const envelope = {
|
|
318
1017
|
sourceSessionId: args.sessionId,
|
|
319
1018
|
variantId: args.variantId,
|
|
320
1019
|
variantLabel: input.briefLabel,
|
|
321
|
-
destinationPath:
|
|
322
|
-
? projectContext.workspacePath
|
|
323
|
-
: env.projectPath,
|
|
1020
|
+
destinationPath: envelopeDestination,
|
|
324
1021
|
changedFilesCount,
|
|
325
1022
|
payload,
|
|
326
1023
|
};
|
|
@@ -331,6 +1028,27 @@ class AgentVariantsOrchestrator {
|
|
|
331
1028
|
sessionId: args.sessionId,
|
|
332
1029
|
envelope,
|
|
333
1030
|
});
|
|
1031
|
+
if (projectContext.kind === 'fresh') {
|
|
1032
|
+
// Manifest persistence is bookkeeping for the .rivet/ artifacts and must
|
|
1033
|
+
// never strand the session — `recordVariantPick` above has already marked
|
|
1034
|
+
// this variant as the selection, so a retry would short-circuit via the
|
|
1035
|
+
// `duplicate: true` path and skip the enqueue below. Catch and log
|
|
1036
|
+
// instead of letting a filesystem hiccup block the handoff to pending-
|
|
1037
|
+
// changes.
|
|
1038
|
+
try {
|
|
1039
|
+
this.persistFreshSelectionMetadata({
|
|
1040
|
+
sessionId: args.sessionId,
|
|
1041
|
+
variantId: args.variantId,
|
|
1042
|
+
variantName: input.briefLabel,
|
|
1043
|
+
projectPath: projectContext.workspacePath,
|
|
1044
|
+
variantFolderName: freshVariantFolderName ?? (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel),
|
|
1045
|
+
changedFilesCount,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
log.warn(`persistFreshSelectionMetadata failed for session ${args.sessionId} variant ${args.variantId} — continuing with enqueue`, err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
334
1052
|
const enqueueResult = this.adapter.enqueue(envelope);
|
|
335
1053
|
resources.committedVariantIds.add(args.variantId);
|
|
336
1054
|
if (this.activeSessionId === args.sessionId) {
|
|
@@ -350,24 +1068,63 @@ class AgentVariantsOrchestrator {
|
|
|
350
1068
|
});
|
|
351
1069
|
this.emitChange();
|
|
352
1070
|
log.info(`Variant ${args.variantId} (${input.briefLabel}) committed by user → enqueued=${enqueueResult.enqueued}`);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1071
|
+
if (!args.skipTeardown) {
|
|
1072
|
+
// Once a variant is committed, the unpicked worktrees are no longer
|
|
1073
|
+
// useful. Schedule teardown in the background so the agent's
|
|
1074
|
+
// commit_variant call returns immediately.
|
|
1075
|
+
void this.teardownSession(args.sessionId, 'committed').catch((err) => {
|
|
1076
|
+
log.warn(`teardownSession after commit failed for ${args.sessionId}`, err);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
359
1079
|
return {
|
|
360
1080
|
enqueued: enqueueResult.enqueued,
|
|
361
1081
|
duplicate: enqueueResult.duplicate,
|
|
362
1082
|
changedFilesCount,
|
|
1083
|
+
payloadKind: payload.kind,
|
|
1084
|
+
destinationPath: envelopeDestination,
|
|
363
1085
|
};
|
|
364
1086
|
}
|
|
1087
|
+
async cleanupCommittedSession(sessionId) {
|
|
1088
|
+
await this.teardownSession(sessionId, 'committed');
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Ensure the user-facing destination path can receive the new project.
|
|
1092
|
+
* Rejects when the path exists and is non-empty.
|
|
1093
|
+
*/
|
|
1094
|
+
assertDestinationAvailable(destinationPath) {
|
|
1095
|
+
if (!fs_1.default.existsSync(destinationPath))
|
|
1096
|
+
return;
|
|
1097
|
+
const entries = fs_1.default.readdirSync(destinationPath);
|
|
1098
|
+
const userVisibleEntries = entries.filter((entry) => entry !== '.rivet');
|
|
1099
|
+
if (userVisibleEntries.length === 0)
|
|
1100
|
+
return;
|
|
1101
|
+
throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
|
|
1102
|
+
}
|
|
365
1103
|
/** Read the captured diff for a code-gen variant, if available. */
|
|
366
1104
|
getVariantDiff(sessionId, variantId) {
|
|
367
1105
|
return this.resources.get(sessionId)?.worktrees.get(variantId)?.diff;
|
|
368
1106
|
}
|
|
369
1107
|
// --- Side-effect implementations ----------------------------------------
|
|
370
1108
|
async provisionWorktrees(sessionId, approveResult) {
|
|
1109
|
+
const projectContext = this.store.getProjectContext(sessionId);
|
|
1110
|
+
if (projectContext.kind === 'fresh') {
|
|
1111
|
+
// Fresh + static_preview: no worktrees, no scaffold, HTML is the
|
|
1112
|
+
// deliverable. Fresh + vite_app: provision a Vite skeleton per variant
|
|
1113
|
+
// and copy the agent-planned assetPlan files before the agent leases
|
|
1114
|
+
// its code_gen items.
|
|
1115
|
+
if (approveResult.scaffoldBaseWorkItemId) {
|
|
1116
|
+
await this.provisionFreshWorktrees(sessionId, approveResult, projectContext);
|
|
1117
|
+
}
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
await this.provisionExistingWorktrees(sessionId, approveResult);
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Existing-project flow: clone the user's repo N times via git worktree.
|
|
1124
|
+
* Runs synchronously because the operation is fast (no install needed —
|
|
1125
|
+
* node_modules is symlinked from the source project).
|
|
1126
|
+
*/
|
|
1127
|
+
async provisionExistingWorktrees(sessionId, approveResult) {
|
|
371
1128
|
const resources = this.ensureResources(sessionId);
|
|
372
1129
|
const totalCount = approveResult.codeGenWorkItemIds.length +
|
|
373
1130
|
(approveResult.scaffoldBaseWorkItemId ? 1 : 0);
|
|
@@ -385,14 +1142,268 @@ class AgentVariantsOrchestrator {
|
|
|
385
1142
|
cursor += 1;
|
|
386
1143
|
}
|
|
387
1144
|
for (const id of approveResult.codeGenWorkItemIds) {
|
|
388
|
-
const
|
|
1145
|
+
const p = paths[cursor];
|
|
389
1146
|
cursor += 1;
|
|
390
|
-
resources.worktrees.set(id, { workItemId: id, worktreePath:
|
|
1147
|
+
resources.worktrees.set(id, { workItemId: id, worktreePath: p });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Fresh-project flow: scaffold one template per variant + DESIGN.md per
|
|
1152
|
+
* slot synchronously (fast — just file writes + git init), then dispatch a
|
|
1153
|
+
* background `npm install` task per worktree. Returns immediately so the
|
|
1154
|
+
* `approve_variant_briefs` MCP tool doesn't time out on cold installs.
|
|
1155
|
+
*
|
|
1156
|
+
* On install success: `sessionStore.completeInternal(scaffold_base)` runs,
|
|
1157
|
+
* which unblocks the dependent code_gen items.
|
|
1158
|
+
*
|
|
1159
|
+
* On install failure: the scaffold_base work item is marked failed via
|
|
1160
|
+
* `failInternal`, which cascades DEPENDENCY_FAILED to all dependent
|
|
1161
|
+
* code_gen items so the session degrades cleanly.
|
|
1162
|
+
*/
|
|
1163
|
+
async provisionFreshWorktrees(sessionId, approveResult, projectContext) {
|
|
1164
|
+
const resources = this.ensureResources(sessionId);
|
|
1165
|
+
const scaffoldId = approveResult.scaffoldBaseWorkItemId;
|
|
1166
|
+
if (!scaffoldId) {
|
|
1167
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Fresh session ${sessionId} approved without a scaffold_base item`);
|
|
1168
|
+
}
|
|
1169
|
+
const codeGenIds = approveResult.codeGenWorkItemIds;
|
|
1170
|
+
if (codeGenIds.length === 0)
|
|
1171
|
+
return;
|
|
1172
|
+
const designContext = projectContext.designContext
|
|
1173
|
+
? codeGenIds.map((codeGenId) => {
|
|
1174
|
+
const input = this.store.getWorkItemInput(sessionId, codeGenId);
|
|
1175
|
+
return input.designContextEntry;
|
|
1176
|
+
})
|
|
1177
|
+
: undefined;
|
|
1178
|
+
const sourceContext = projectContext.sourceContext;
|
|
1179
|
+
const createFresh = this.worktrees.createFreshWorktrees;
|
|
1180
|
+
if (!createFresh) {
|
|
1181
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'worktreeManager does not implement createFreshWorktrees');
|
|
1182
|
+
}
|
|
1183
|
+
const scaffoldStartedAt = Date.now();
|
|
1184
|
+
trackScaffoldStarted(this.telemetry, {
|
|
1185
|
+
sessionId,
|
|
1186
|
+
variantCount: codeGenIds.length,
|
|
1187
|
+
designContext: summarizeDesignContext(designContext),
|
|
1188
|
+
});
|
|
1189
|
+
log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
|
|
1190
|
+
// destinationParent for fresh worktrees: same parent the materialized
|
|
1191
|
+
// project will land in. Keeping the worktree on the same volume turns
|
|
1192
|
+
// commit into a directory rename instead of a recursive copy.
|
|
1193
|
+
const destinationParent = path_1.default.dirname(projectContext.workspacePath);
|
|
1194
|
+
const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
|
|
1195
|
+
resources.scaffoldBaseWorkItemId = scaffoldId;
|
|
1196
|
+
resources.freshDestinationParent = destinationParent;
|
|
1197
|
+
// Each code_gen item maps 1:1 to a fresh worktree. The scaffold_base
|
|
1198
|
+
// work item is internal — no dedicated worktree.
|
|
1199
|
+
for (let i = 0; i < codeGenIds.length; i++) {
|
|
1200
|
+
resources.worktrees.set(codeGenIds[i], {
|
|
1201
|
+
workItemId: codeGenIds[i],
|
|
1202
|
+
worktreePath: paths[i],
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
// Copy agent-planned assets (e.g. avatar.glb) into each worktree so
|
|
1206
|
+
// generated code can reference them via plan.referenceAs. Done before
|
|
1207
|
+
// the background install so the assets are on disk by the time the
|
|
1208
|
+
// agent leases its code_gen item.
|
|
1209
|
+
// If copy throws (e.g. source asset deleted between planning and
|
|
1210
|
+
// provisioning), fail scaffold_base immediately so dependent code_gen
|
|
1211
|
+
// items cascade DEPENDENCY_FAILED instead of leaving the session
|
|
1212
|
+
// permanently stuck in `work_items_ready`.
|
|
1213
|
+
const assetPlan = projectContext.executionPlan?.assetPlan ?? [];
|
|
1214
|
+
if (assetPlan.length > 0) {
|
|
1215
|
+
try {
|
|
1216
|
+
for (const worktreePath of paths) {
|
|
1217
|
+
for (const entry of assetPlan) {
|
|
1218
|
+
copyAssetIntoWorktree(worktreePath, entry, projectContext.assetSourceRoot);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
catch (err) {
|
|
1223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1224
|
+
log.warn(`asset copy failed for session ${sessionId}; marking scaffold_base failed`, err);
|
|
1225
|
+
trackScaffoldFailed(this.telemetry, {
|
|
1226
|
+
sessionId,
|
|
1227
|
+
errorCode: 'SCAFFOLD_FAILED',
|
|
1228
|
+
durationMs: Date.now() - scaffoldStartedAt,
|
|
1229
|
+
});
|
|
1230
|
+
try {
|
|
1231
|
+
this.store.failInternal({
|
|
1232
|
+
sessionId,
|
|
1233
|
+
workItemId: scaffoldId,
|
|
1234
|
+
error: { code: 'SCAFFOLD_FAILED', message },
|
|
1235
|
+
});
|
|
1236
|
+
this.emitChange();
|
|
1237
|
+
}
|
|
1238
|
+
catch (storeErr) {
|
|
1239
|
+
log.error(`failInternal failed for ${sessionId}/${scaffoldId}`, storeErr);
|
|
1240
|
+
}
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
391
1243
|
}
|
|
1244
|
+
// Background install: don't await. The orchestrator finishes the
|
|
1245
|
+
// approve→provision call immediately; the agent polls for scaffold
|
|
1246
|
+
// completion via continue_variants(action='check').
|
|
1247
|
+
void this.runBackgroundInstall(sessionId, scaffoldId, paths, scaffoldStartedAt).catch((err) => {
|
|
1248
|
+
log.error(`runBackgroundInstall failed for session ${sessionId}`, err);
|
|
1249
|
+
});
|
|
392
1250
|
}
|
|
393
|
-
|
|
1251
|
+
/**
|
|
1252
|
+
* Run `npm install` in parallel across all fresh worktrees. On all-success,
|
|
1253
|
+
* mark scaffold_base succeeded via `completeInternal`. On any failure,
|
|
1254
|
+
* mark scaffold_base failed via `failInternal` and emit telemetry.
|
|
1255
|
+
*/
|
|
1256
|
+
async runBackgroundInstall(sessionId, scaffoldWorkItemId, worktreePaths, scaffoldStartedAt) {
|
|
1257
|
+
const installStartedAt = Date.now();
|
|
1258
|
+
try {
|
|
1259
|
+
const [firstWorktree, ...rest] = worktreePaths;
|
|
1260
|
+
await this.installDependencies(firstWorktree);
|
|
1261
|
+
await Promise.all(rest.map(async (p) => {
|
|
1262
|
+
const target = path_1.default.join(firstWorktree, 'node_modules');
|
|
1263
|
+
const link = path_1.default.join(p, 'node_modules');
|
|
1264
|
+
// The fresh template has no node_modules until install runs, so
|
|
1265
|
+
// the link target is guaranteed absent in `p` — no need to remove
|
|
1266
|
+
// first.
|
|
1267
|
+
try {
|
|
1268
|
+
fs_1.default.symlinkSync(target, link, 'dir');
|
|
1269
|
+
}
|
|
1270
|
+
catch (symlinkErr) {
|
|
1271
|
+
log.warn(`Symlink node_modules failed for ${p}, falling back to install`, symlinkErr);
|
|
1272
|
+
await this.installDependencies(p);
|
|
1273
|
+
}
|
|
1274
|
+
}));
|
|
1275
|
+
}
|
|
1276
|
+
catch (err) {
|
|
1277
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1278
|
+
log.warn(`npm install failed for session ${sessionId}; marking scaffold_base failed`, err);
|
|
1279
|
+
trackScaffoldFailed(this.telemetry, {
|
|
1280
|
+
sessionId,
|
|
1281
|
+
errorCode: 'SCAFFOLD_FAILED',
|
|
1282
|
+
durationMs: Date.now() - scaffoldStartedAt,
|
|
1283
|
+
});
|
|
1284
|
+
try {
|
|
1285
|
+
this.store.failInternal({
|
|
1286
|
+
sessionId,
|
|
1287
|
+
workItemId: scaffoldWorkItemId,
|
|
1288
|
+
error: { code: 'SCAFFOLD_FAILED', message },
|
|
1289
|
+
});
|
|
1290
|
+
this.emitChange();
|
|
1291
|
+
}
|
|
1292
|
+
catch (storeErr) {
|
|
1293
|
+
log.error(`failInternal failed for ${sessionId}/${scaffoldWorkItemId}`, storeErr);
|
|
1294
|
+
}
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
this.store.completeInternal({
|
|
1299
|
+
sessionId,
|
|
1300
|
+
workItemId: scaffoldWorkItemId,
|
|
1301
|
+
output: { worktreePaths },
|
|
1302
|
+
});
|
|
1303
|
+
this.emitChange();
|
|
1304
|
+
}
|
|
1305
|
+
catch (err) {
|
|
1306
|
+
log.warn(`completeInternal failed for ${sessionId}/${scaffoldWorkItemId}`, err);
|
|
1307
|
+
}
|
|
1308
|
+
trackScaffoldCompleted(this.telemetry, {
|
|
1309
|
+
sessionId,
|
|
1310
|
+
durationMs: Date.now() - scaffoldStartedAt,
|
|
1311
|
+
installDurationMs: Date.now() - installStartedAt,
|
|
1312
|
+
variantCount: worktreePaths.length,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Gate `succeeded` reports for `static_preview` items through preview QA
|
|
1317
|
+
* before they reach the store. Returns either `null` (caller continues
|
|
1318
|
+
* with the original args — non-static-preview report, non-succeeded
|
|
1319
|
+
* status, no parseable HTML, or QA passed) or an override carrying a
|
|
1320
|
+
* `failed` rewrite of the report. The QA result is also memoized on the
|
|
1321
|
+
* session resources so `getVariants` can surface it.
|
|
1322
|
+
*
|
|
1323
|
+
* Only fresh-project sessions go through this gate today; existing-
|
|
1324
|
+
* project `code_gen` variants produce a diff with no preview document
|
|
1325
|
+
* to inspect, so they skip QA and behave exactly as before.
|
|
1326
|
+
*/
|
|
1327
|
+
async evaluateQaForReport(args) {
|
|
1328
|
+
if (args.status !== 'succeeded')
|
|
1329
|
+
return null;
|
|
1330
|
+
if (!this.store.hasSession(args.sessionId))
|
|
1331
|
+
return null;
|
|
1332
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1333
|
+
if (projectContext.kind !== 'fresh')
|
|
1334
|
+
return null;
|
|
1335
|
+
const parsed = parseStaticPreviewOutput(normalizeOutput(args.output));
|
|
1336
|
+
if (!parsed)
|
|
1337
|
+
return null;
|
|
1338
|
+
let qa;
|
|
1339
|
+
try {
|
|
1340
|
+
qa = await this.previewQaRunner({
|
|
1341
|
+
sessionId: args.sessionId,
|
|
1342
|
+
workItemId: args.workItemId,
|
|
1343
|
+
html: parsed.html,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1348
|
+
qa = {
|
|
1349
|
+
status: 'failed',
|
|
1350
|
+
issues: [
|
|
1351
|
+
{
|
|
1352
|
+
kind: 'preview_unavailable',
|
|
1353
|
+
detail: 'qa_runner_error',
|
|
1354
|
+
message,
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
summary: `Preview QA runner threw: ${message}`,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
const resources = this.ensureResources(args.sessionId);
|
|
1361
|
+
resources.qaResults.set(args.workItemId, qa);
|
|
1362
|
+
if (qa.status === 'failed') {
|
|
1363
|
+
return {
|
|
1364
|
+
qa,
|
|
1365
|
+
overrideArgs: {
|
|
1366
|
+
...args,
|
|
1367
|
+
status: 'failed',
|
|
1368
|
+
error: { code: 'VARIANT_QA_FAILED', message: qa.summary },
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
return { qa };
|
|
1373
|
+
}
|
|
1374
|
+
async handleSucceededReport(args) {
|
|
1375
|
+
const { sessionId, workItemId } = args;
|
|
394
1376
|
const resources = this.resources.get(sessionId);
|
|
395
1377
|
const record = resources?.worktrees.get(workItemId);
|
|
1378
|
+
if (resources && !record) {
|
|
1379
|
+
const staticPreview = parseStaticPreviewOutput(normalizeOutput(args.output));
|
|
1380
|
+
if (staticPreview) {
|
|
1381
|
+
const input = this.store.getWorkItemInput(sessionId, workItemId);
|
|
1382
|
+
if (input.briefId) {
|
|
1383
|
+
const record = {
|
|
1384
|
+
workItemId,
|
|
1385
|
+
briefId: input.briefId,
|
|
1386
|
+
html: staticPreview.html,
|
|
1387
|
+
};
|
|
1388
|
+
resources.staticPreviews.set(workItemId, record);
|
|
1389
|
+
if (this.store.getProjectContext(sessionId).kind === 'fresh') {
|
|
1390
|
+
this.persistFreshVariantArtifacts({
|
|
1391
|
+
sessionId,
|
|
1392
|
+
workItemId,
|
|
1393
|
+
html: staticPreview.html,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
const leasedAt = resources.leasedAt.get(workItemId);
|
|
1397
|
+
trackStaticPreviewCompleted(this.telemetry, {
|
|
1398
|
+
sessionId,
|
|
1399
|
+
variantId: workItemId,
|
|
1400
|
+
durationMs: leasedAt !== undefined ? Date.now() - leasedAt : null,
|
|
1401
|
+
});
|
|
1402
|
+
this.emitChange();
|
|
1403
|
+
}
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
396
1407
|
if (!resources || !record) {
|
|
397
1408
|
log.warn(`No worktree record for ${sessionId}/${workItemId}; skipping diff capture`);
|
|
398
1409
|
return;
|
|
@@ -403,6 +1414,8 @@ class AgentVariantsOrchestrator {
|
|
|
403
1414
|
log.info(`scaffold_base ${workItemId} succeeded; awaiting dependent code-gen`);
|
|
404
1415
|
return;
|
|
405
1416
|
}
|
|
1417
|
+
const projectContext = this.store.getProjectContext(sessionId);
|
|
1418
|
+
const isFresh = projectContext.kind === 'fresh';
|
|
406
1419
|
try {
|
|
407
1420
|
record.diff = await this.worktrees.getDiff(record.worktreePath);
|
|
408
1421
|
log.info(`Variant ${workItemId} diff captured (${countDiffFiles(record.diff)} files)`);
|
|
@@ -415,26 +1428,457 @@ class AgentVariantsOrchestrator {
|
|
|
415
1428
|
// through live variants in the iframe via the chip. Failures here are
|
|
416
1429
|
// logged but non-fatal — the user can still pick by reading the diff.
|
|
417
1430
|
try {
|
|
418
|
-
const env = await this.resolveEnv(sessionId);
|
|
419
1431
|
const port = await this.worktrees.getFreePort();
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
? env.buildDevCommand(port)
|
|
423
|
-
: {
|
|
424
|
-
cmd: env.packageManager,
|
|
425
|
-
args: [env.devCommand, '--port', String(port)],
|
|
426
|
-
env: { PORT: String(port) },
|
|
427
|
-
};
|
|
428
|
-
const proc = await this.worktrees.startDevServer(cwd, port, cmd, args, spawnEnv);
|
|
1432
|
+
const dev = await this.resolveDevServer(sessionId, record.worktreePath, port, isFresh);
|
|
1433
|
+
const proc = await this.worktrees.startDevServer(dev.cwd, port, dev.cmd, dev.args, dev.env);
|
|
429
1434
|
record.port = port;
|
|
430
1435
|
record.devServerProcess = proc;
|
|
1436
|
+
// If the dev server dies on its own (crash, OOM, Vite hard-fail),
|
|
1437
|
+
// clear the port so the iframe stops routing the chip onto a dead
|
|
1438
|
+
// socket. Without this the proxy retargets onto an unreachable port
|
|
1439
|
+
// and the chip shows "upstream_unreachable" instead of the accurate
|
|
1440
|
+
// "Preview is unavailable for this variant".
|
|
1441
|
+
proc.once('exit', (code, signal) => {
|
|
1442
|
+
if (record.devServerProcess !== proc)
|
|
1443
|
+
return;
|
|
1444
|
+
record.port = undefined;
|
|
1445
|
+
record.devServerProcess = undefined;
|
|
1446
|
+
log.warn(`Variant ${workItemId} dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}); preview disabled`);
|
|
1447
|
+
this.emitChange();
|
|
1448
|
+
});
|
|
431
1449
|
this.emitChange();
|
|
432
|
-
|
|
1450
|
+
trackFreshDevServerStarted(this.telemetry, {
|
|
1451
|
+
sessionId,
|
|
1452
|
+
variantId: workItemId,
|
|
1453
|
+
port,
|
|
1454
|
+
});
|
|
1455
|
+
log.info(`Variant ${workItemId} dev server up on port ${port} (worktree ${record.worktreePath}; cmd: ${dev.cmd} ${dev.args.join(' ')})`);
|
|
433
1456
|
}
|
|
434
1457
|
catch (err) {
|
|
1458
|
+
trackFreshDevServerFailed(this.telemetry, {
|
|
1459
|
+
sessionId,
|
|
1460
|
+
variantId: workItemId,
|
|
1461
|
+
errorCode: 'DEV_SERVER_START_FAILED',
|
|
1462
|
+
});
|
|
435
1463
|
log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
|
|
436
1464
|
}
|
|
437
1465
|
}
|
|
1466
|
+
persistFreshVariantArtifacts(args) {
|
|
1467
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1468
|
+
if (projectContext.kind !== 'fresh') {
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const projectPath = projectContext.workspacePath;
|
|
1472
|
+
const now = new Date().toISOString();
|
|
1473
|
+
const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(projectPath);
|
|
1474
|
+
const existingProjectManifest = this.readManifest(projectManifestPath);
|
|
1475
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
|
|
1476
|
+
createdAt: existingProjectManifest?.createdAt ?? now,
|
|
1477
|
+
selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
|
|
1478
|
+
latestVariantSessionId: args.sessionId,
|
|
1479
|
+
selectedVariantId: existingProjectManifest?.selectedVariantId,
|
|
1480
|
+
}));
|
|
1481
|
+
const briefInput = this.store.getWorkItemInput(args.sessionId, args.workItemId);
|
|
1482
|
+
const variantFolderName = this.getFreshVariantFolderName({
|
|
1483
|
+
sessionId: args.sessionId,
|
|
1484
|
+
variantId: args.workItemId,
|
|
1485
|
+
variantName: briefInput.briefLabel,
|
|
1486
|
+
});
|
|
1487
|
+
const snapshotPath = (0, createProjectArtifacts_1.createProjectVariantSnapshotPath)(projectPath, variantFolderName);
|
|
1488
|
+
fs_1.default.rmSync(snapshotPath, { recursive: true, force: true });
|
|
1489
|
+
fs_1.default.mkdirSync(snapshotPath, { recursive: true });
|
|
1490
|
+
const briefPath = (0, createProjectArtifacts_1.createProjectVariantBriefPath)(projectPath, variantFolderName);
|
|
1491
|
+
fs_1.default.writeFileSync(briefPath, `# ${briefInput.briefLabel}\n\n${briefInput.briefBody}\n`, 'utf8');
|
|
1492
|
+
const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(projectPath, variantFolderName);
|
|
1493
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
|
|
1494
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1495
|
+
createdAt: now,
|
|
1496
|
+
sessionId: args.sessionId,
|
|
1497
|
+
variantId: args.workItemId,
|
|
1498
|
+
variantName: briefInput.briefLabel,
|
|
1499
|
+
variantFolderName,
|
|
1500
|
+
changedFilesCount: 1,
|
|
1501
|
+
});
|
|
1502
|
+
fs_1.default.writeFileSync((0, createProjectArtifacts_1.createProjectVariantIndexPath)(projectPath, variantFolderName), args.html, 'utf8');
|
|
1503
|
+
}
|
|
1504
|
+
persistFreshSelectionMetadata(args) {
|
|
1505
|
+
const now = new Date().toISOString();
|
|
1506
|
+
const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(args.projectPath);
|
|
1507
|
+
const existingProjectManifest = this.readManifest(projectManifestPath);
|
|
1508
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
|
|
1509
|
+
createdAt: existingProjectManifest?.createdAt ?? now,
|
|
1510
|
+
selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
|
|
1511
|
+
latestVariantSessionId: args.sessionId,
|
|
1512
|
+
selectedVariantId: args.variantId,
|
|
1513
|
+
}));
|
|
1514
|
+
const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(args.projectPath, args.variantFolderName);
|
|
1515
|
+
const existingVariantManifest = this.readManifest(variantManifestPath);
|
|
1516
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
|
|
1517
|
+
...existingVariantManifest,
|
|
1518
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1519
|
+
createdAt: existingVariantManifest?.createdAt ?? now,
|
|
1520
|
+
sessionId: args.sessionId,
|
|
1521
|
+
variantId: args.variantId,
|
|
1522
|
+
variantName: args.variantName,
|
|
1523
|
+
variantFolderName: args.variantFolderName,
|
|
1524
|
+
changedFilesCount: args.changedFilesCount,
|
|
1525
|
+
selectedVariantId: args.variantId,
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
getFreshVariantFolderName(args) {
|
|
1529
|
+
const baseSlug = (0, createProjectArtifacts_1.createProjectVariantSlug)(args.variantName);
|
|
1530
|
+
const variants = this.store.getVariants(args.sessionId);
|
|
1531
|
+
const matchingVariants = variants.filter((variant) => {
|
|
1532
|
+
const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
|
|
1533
|
+
return (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel ?? '') === baseSlug;
|
|
1534
|
+
});
|
|
1535
|
+
const index = matchingVariants.findIndex((variant) => variant.workItemId === args.variantId);
|
|
1536
|
+
if (index <= 0) {
|
|
1537
|
+
return baseSlug;
|
|
1538
|
+
}
|
|
1539
|
+
return `${baseSlug}-${index + 1}`;
|
|
1540
|
+
}
|
|
1541
|
+
readManifest(manifestPath) {
|
|
1542
|
+
if (!fs_1.default.existsSync(manifestPath)) {
|
|
1543
|
+
return undefined;
|
|
1544
|
+
}
|
|
1545
|
+
try {
|
|
1546
|
+
const raw = fs_1.default.readFileSync(manifestPath, 'utf8');
|
|
1547
|
+
return JSON.parse(raw);
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
return undefined;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Resolve dev server invocation for a worktree. Fresh-project worktrees
|
|
1555
|
+
* always use the Vite template's npm command at the worktree root; existing
|
|
1556
|
+
* projects defer to the user's framework/packageManager config.
|
|
1557
|
+
*/
|
|
1558
|
+
async resolveDevServer(sessionId, worktreePath, port, isFresh) {
|
|
1559
|
+
if (isFresh) {
|
|
1560
|
+
return {
|
|
1561
|
+
cwd: worktreePath,
|
|
1562
|
+
cmd: 'npm',
|
|
1563
|
+
args: [
|
|
1564
|
+
'run',
|
|
1565
|
+
'dev',
|
|
1566
|
+
'--',
|
|
1567
|
+
'--port',
|
|
1568
|
+
String(port),
|
|
1569
|
+
'--host',
|
|
1570
|
+
FRESH_DEV_SERVER_HOST,
|
|
1571
|
+
],
|
|
1572
|
+
env: { PORT: String(port) },
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
const env = await this.resolveEnv(sessionId);
|
|
1576
|
+
const cwd = await this.worktrees.getProjectCwdInWorktree(worktreePath);
|
|
1577
|
+
if (env.buildDevCommand) {
|
|
1578
|
+
return { cwd, ...env.buildDevCommand(port) };
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
cwd,
|
|
1582
|
+
cmd: env.packageManager,
|
|
1583
|
+
args: [env.devCommand, '--port', String(port)],
|
|
1584
|
+
env: { PORT: String(port) },
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* After the chosen variant has been renamed into `destinationPath`, move
|
|
1589
|
+
* each unchosen sibling worktree into a `<destinationParent>/<slug>-variants/NN-<label>/`
|
|
1590
|
+
* folder and repoint its `node_modules` symlink at the chosen project's
|
|
1591
|
+
* real install. Writes a manifest documenting all 5 variants (the chosen
|
|
1592
|
+
* one with `folder: null` and `chosenPath: '../<slug>'`).
|
|
1593
|
+
*
|
|
1594
|
+
* Sets `vitePreservedSiblings = true` on session resources so the
|
|
1595
|
+
* subsequent teardown skips `cleanupFreshWorktrees` (which would otherwise
|
|
1596
|
+
* delete the just-preserved history). Dev servers still get stopped.
|
|
1597
|
+
*/
|
|
1598
|
+
preserveUnchosenVariants(args) {
|
|
1599
|
+
const resources = this.resources.get(args.sessionId);
|
|
1600
|
+
if (!resources)
|
|
1601
|
+
return;
|
|
1602
|
+
const destinationParent = path_1.default.dirname(args.destinationPath);
|
|
1603
|
+
const projectSlug = path_1.default.basename(args.destinationPath);
|
|
1604
|
+
const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
|
|
1605
|
+
const sharedNodeModulesAbs = path_1.default.join(args.destinationPath, 'node_modules');
|
|
1606
|
+
const briefs = this.store.getBriefs(args.sessionId);
|
|
1607
|
+
const variants = this.store.getVariants(args.sessionId);
|
|
1608
|
+
const briefIndexById = new Map(briefs.map((b, i) => [b.briefId, i]));
|
|
1609
|
+
const manifestEntries = [];
|
|
1610
|
+
let chosenSlug = '';
|
|
1611
|
+
for (const variant of variants) {
|
|
1612
|
+
const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
|
|
1613
|
+
const label = input.briefLabel ?? variant.label ?? 'variant';
|
|
1614
|
+
const slug = (0, createProjectArtifacts_1.createProjectVariantSlug)(label);
|
|
1615
|
+
const briefIdx = briefIndexById.get(variant.briefId) ?? 0;
|
|
1616
|
+
const numericPrefix = String(briefIdx + 1).padStart(2, '0');
|
|
1617
|
+
const folderName = `${numericPrefix}-${slug}`;
|
|
1618
|
+
if (variant.workItemId === args.chosenVariantId) {
|
|
1619
|
+
chosenSlug = slug;
|
|
1620
|
+
manifestEntries.push({
|
|
1621
|
+
variantId: variant.workItemId,
|
|
1622
|
+
label,
|
|
1623
|
+
slug,
|
|
1624
|
+
folder: null,
|
|
1625
|
+
isChosen: true,
|
|
1626
|
+
chosenPath: `../${projectSlug}`,
|
|
1627
|
+
});
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
const record = resources.worktrees.get(variant.workItemId);
|
|
1631
|
+
if (!record) {
|
|
1632
|
+
// No worktree (e.g. failed variant) — still list it in manifest with
|
|
1633
|
+
// null folder so the user can see why it's absent.
|
|
1634
|
+
manifestEntries.push({
|
|
1635
|
+
variantId: variant.workItemId,
|
|
1636
|
+
label,
|
|
1637
|
+
slug,
|
|
1638
|
+
folder: null,
|
|
1639
|
+
isChosen: false,
|
|
1640
|
+
});
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (!fs_1.default.existsSync(record.worktreePath)) {
|
|
1644
|
+
manifestEntries.push({
|
|
1645
|
+
variantId: variant.workItemId,
|
|
1646
|
+
label,
|
|
1647
|
+
slug,
|
|
1648
|
+
folder: null,
|
|
1649
|
+
isChosen: false,
|
|
1650
|
+
});
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
try {
|
|
1654
|
+
const newPath = this.worktrees.moveWorktreeToHistory
|
|
1655
|
+
? this.worktrees.moveWorktreeToHistory(record.worktreePath, historyDir, folderName)
|
|
1656
|
+
: (() => {
|
|
1657
|
+
fs_1.default.mkdirSync(historyDir, { recursive: true });
|
|
1658
|
+
const dest = path_1.default.join(historyDir, folderName);
|
|
1659
|
+
fs_1.default.renameSync(record.worktreePath, dest);
|
|
1660
|
+
return dest;
|
|
1661
|
+
})();
|
|
1662
|
+
// Repoint node_modules at the chosen project's real install.
|
|
1663
|
+
try {
|
|
1664
|
+
if (this.worktrees.repointNodeModulesSymlink) {
|
|
1665
|
+
this.worktrees.repointNodeModulesSymlink(newPath, sharedNodeModulesAbs);
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
const link = path_1.default.join(newPath, 'node_modules');
|
|
1669
|
+
try {
|
|
1670
|
+
fs_1.default.rmSync(link, { force: true, recursive: true });
|
|
1671
|
+
}
|
|
1672
|
+
catch {
|
|
1673
|
+
/* may not exist */
|
|
1674
|
+
}
|
|
1675
|
+
const target = path_1.default.relative(path_1.default.dirname(link), sharedNodeModulesAbs);
|
|
1676
|
+
fs_1.default.symlinkSync(target, link, 'dir');
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
catch (err) {
|
|
1680
|
+
log.warn(`repointNodeModulesSymlink failed for ${newPath}; variant still runnable after npm install`, err);
|
|
1681
|
+
}
|
|
1682
|
+
// Update the in-memory record so teardown doesn't try to operate on
|
|
1683
|
+
// the stale path.
|
|
1684
|
+
record.worktreePath = newPath;
|
|
1685
|
+
manifestEntries.push({
|
|
1686
|
+
variantId: variant.workItemId,
|
|
1687
|
+
label,
|
|
1688
|
+
slug,
|
|
1689
|
+
folder: folderName,
|
|
1690
|
+
isChosen: false,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
catch (err) {
|
|
1694
|
+
log.warn(`moveWorktreeToHistory failed for variant ${variant.workItemId}`, err);
|
|
1695
|
+
manifestEntries.push({
|
|
1696
|
+
variantId: variant.workItemId,
|
|
1697
|
+
label,
|
|
1698
|
+
slug,
|
|
1699
|
+
folder: null,
|
|
1700
|
+
isChosen: false,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const manifest = {
|
|
1705
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1706
|
+
createdAt: new Date().toISOString(),
|
|
1707
|
+
sessionId: args.sessionId,
|
|
1708
|
+
chosenVariantId: args.chosenVariantId,
|
|
1709
|
+
chosenSlug: chosenSlug || projectSlug,
|
|
1710
|
+
variants: manifestEntries,
|
|
1711
|
+
};
|
|
1712
|
+
try {
|
|
1713
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)((0, createProjectArtifacts_1.createVariantsHistoryManifestPath)(destinationParent, projectSlug), manifest);
|
|
1714
|
+
}
|
|
1715
|
+
catch (err) {
|
|
1716
|
+
log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
|
|
1717
|
+
}
|
|
1718
|
+
resources.vitePreservedSiblings = true;
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Rename `sourceWorktreePath` into `destinationPath`, then replace the
|
|
1722
|
+
* worktree's `.git` with a fresh-history init + initial commit. Matches
|
|
1723
|
+
* the historical materializeProject behavior of "user sees a fresh repo
|
|
1724
|
+
* at destinationPath" so we don't surprise them with a `rivet: variant
|
|
1725
|
+
* baseline` commit they didn't intend.
|
|
1726
|
+
*
|
|
1727
|
+
* The rename is the load-bearing operation: it moves the entire variant
|
|
1728
|
+
* tree (template files + materialized node_modules) in O(1) when the
|
|
1729
|
+
* source and destination share a volume. The git reset that follows is
|
|
1730
|
+
* best-effort housekeeping; a failure there leaves files in place.
|
|
1731
|
+
*/
|
|
1732
|
+
async renameWorktreeIntoDestination(sourceWorktreePath, destinationPath) {
|
|
1733
|
+
// destinationPath must not exist for fs.rename to behave as a move.
|
|
1734
|
+
// assertDestinationAvailable already enforced that any existing directory
|
|
1735
|
+
// contains only `.rivet`, which we preserve across the move.
|
|
1736
|
+
const destinationParent = path_1.default.dirname(destinationPath);
|
|
1737
|
+
await fs_1.default.promises.mkdir(destinationParent, { recursive: true });
|
|
1738
|
+
let preservedRivetParent;
|
|
1739
|
+
const existingRivetPath = path_1.default.join(destinationPath, '.rivet');
|
|
1740
|
+
if (fs_1.default.existsSync(destinationPath)) {
|
|
1741
|
+
preservedRivetParent = await fs_1.default.promises.mkdtemp(path_1.default.join(destinationParent, '.rivet-preserve-'));
|
|
1742
|
+
if (fs_1.default.existsSync(existingRivetPath)) {
|
|
1743
|
+
await fs_1.default.promises.rename(existingRivetPath, path_1.default.join(preservedRivetParent, '.rivet'));
|
|
1744
|
+
}
|
|
1745
|
+
await fs_1.default.promises.rm(destinationPath, { recursive: true, force: true });
|
|
1746
|
+
}
|
|
1747
|
+
try {
|
|
1748
|
+
await fs_1.default.promises.rename(sourceWorktreePath, destinationPath);
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
if (preservedRivetParent && !fs_1.default.existsSync(destinationPath)) {
|
|
1752
|
+
await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
|
|
1753
|
+
const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
|
|
1754
|
+
if (fs_1.default.existsSync(preservedRivetPath)) {
|
|
1755
|
+
await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
throw err;
|
|
1759
|
+
}
|
|
1760
|
+
if (preservedRivetParent) {
|
|
1761
|
+
const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
|
|
1762
|
+
if (fs_1.default.existsSync(preservedRivetPath)) {
|
|
1763
|
+
await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
|
|
1764
|
+
}
|
|
1765
|
+
await fs_1.default.promises.rm(preservedRivetParent, {
|
|
1766
|
+
recursive: true,
|
|
1767
|
+
force: true,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
// Replace the variant-baseline git history with a fresh init so the
|
|
1771
|
+
// user doesn't inherit a `rivet: variant baseline` commit.
|
|
1772
|
+
const dotGit = path_1.default.join(destinationPath, '.git');
|
|
1773
|
+
try {
|
|
1774
|
+
fs_1.default.rmSync(dotGit, { recursive: true, force: true });
|
|
1775
|
+
}
|
|
1776
|
+
catch {
|
|
1777
|
+
// Best-effort — leaving the baseline .git is preferable to failing
|
|
1778
|
+
// the commit after we've moved the tree.
|
|
1779
|
+
}
|
|
1780
|
+
try {
|
|
1781
|
+
const git = (0, simple_git_1.simpleGit)(destinationPath);
|
|
1782
|
+
await git.raw(['init']);
|
|
1783
|
+
await git.raw(['add', '-A']);
|
|
1784
|
+
await git
|
|
1785
|
+
.raw([
|
|
1786
|
+
'-c',
|
|
1787
|
+
'user.name=Rivet',
|
|
1788
|
+
'-c',
|
|
1789
|
+
'user.email=hello@tryrivet.design',
|
|
1790
|
+
'commit',
|
|
1791
|
+
'--no-gpg-sign',
|
|
1792
|
+
'-m',
|
|
1793
|
+
'Initial commit (created with Rivet)',
|
|
1794
|
+
])
|
|
1795
|
+
.catch(() => {
|
|
1796
|
+
// Best-effort: missing user identity or empty tree leaves the
|
|
1797
|
+
// commit step un-applied. Files are still in place.
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
catch (err) {
|
|
1801
|
+
log.warn(`Initial git history setup at ${destinationPath} failed`, err);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Stop every variant dev server currently running for `resources`. Clears
|
|
1806
|
+
* the `devServerProcess` / `port` fields on each record so subsequent
|
|
1807
|
+
* teardown logic doesn't try to stop them a second time. Called from
|
|
1808
|
+
* `commitVariant` (vite_app rename path) BEFORE the worktree directory is
|
|
1809
|
+
* renamed — leaving Vite alive across the rename leaks empty `.vite/`
|
|
1810
|
+
* stub dirs at the old paths.
|
|
1811
|
+
*/
|
|
1812
|
+
async stopAllDevServersForSession(resources) {
|
|
1813
|
+
const stops = [];
|
|
1814
|
+
for (const record of resources.worktrees.values()) {
|
|
1815
|
+
if (!record.devServerProcess)
|
|
1816
|
+
continue;
|
|
1817
|
+
const proc = record.devServerProcess;
|
|
1818
|
+
stops.push(this.worktrees
|
|
1819
|
+
.stopDevServer(proc)
|
|
1820
|
+
.catch((err) => log.warn(`stopDevServer pre-commit failed for ${resources.sessionId}/${record.workItemId}`, err)));
|
|
1821
|
+
record.devServerProcess = undefined;
|
|
1822
|
+
record.port = undefined;
|
|
1823
|
+
}
|
|
1824
|
+
await Promise.all(stops);
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Spawn a fresh dev server at the user-facing destinationPath and retarget
|
|
1828
|
+
* the proxy so the iframe stays live across commit. Stores the process on
|
|
1829
|
+
* `resources.committedDevServer` so teardown can hand it off to the
|
|
1830
|
+
* lingering registry instead of killing it. Best-effort: a failure here is
|
|
1831
|
+
* non-fatal — the commit still succeeds; the user just has to run
|
|
1832
|
+
* `npm run dev` themselves to bring the preview back.
|
|
1833
|
+
*/
|
|
1834
|
+
async startCommittedDevServer(args) {
|
|
1835
|
+
try {
|
|
1836
|
+
const port = await this.worktrees.getFreePort();
|
|
1837
|
+
const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
|
|
1838
|
+
'run',
|
|
1839
|
+
'dev',
|
|
1840
|
+
'--',
|
|
1841
|
+
'--port',
|
|
1842
|
+
String(port),
|
|
1843
|
+
'--host',
|
|
1844
|
+
FRESH_DEV_SERVER_HOST,
|
|
1845
|
+
], { PORT: String(port) });
|
|
1846
|
+
args.resources.committedDevServer = {
|
|
1847
|
+
proc,
|
|
1848
|
+
port,
|
|
1849
|
+
path: args.destinationPath,
|
|
1850
|
+
};
|
|
1851
|
+
try {
|
|
1852
|
+
this.switchPreviewPort?.(port);
|
|
1853
|
+
}
|
|
1854
|
+
catch (err) {
|
|
1855
|
+
log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
|
|
1856
|
+
}
|
|
1857
|
+
log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
|
|
1858
|
+
}
|
|
1859
|
+
catch (err) {
|
|
1860
|
+
log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Stop any committed dev servers that were left alive past their session's
|
|
1865
|
+
* teardown. Called at the start of a new `propose` (so the next session's
|
|
1866
|
+
* preview doesn't compete with stale processes) and exposed for explicit
|
|
1867
|
+
* shutdown hooks. Best-effort: per-server failures are logged but don't
|
|
1868
|
+
* propagate.
|
|
1869
|
+
*/
|
|
1870
|
+
async stopLingeringCommittedDevServers() {
|
|
1871
|
+
const entries = [...this.lingeringCommittedDevServers.entries()];
|
|
1872
|
+
this.lingeringCommittedDevServers.clear();
|
|
1873
|
+
await Promise.all(entries.map(async ([sessionId, entry]) => {
|
|
1874
|
+
try {
|
|
1875
|
+
await this.worktrees.stopDevServer(entry.proc);
|
|
1876
|
+
}
|
|
1877
|
+
catch (err) {
|
|
1878
|
+
log.warn(`stopDevServer for lingering committed server (session ${sessionId}, port ${entry.port}) failed`, err);
|
|
1879
|
+
}
|
|
1880
|
+
}));
|
|
1881
|
+
}
|
|
438
1882
|
async teardownSession(sessionId, reason) {
|
|
439
1883
|
const resources = this.resources.get(sessionId);
|
|
440
1884
|
if (!resources)
|
|
@@ -444,17 +1888,46 @@ class AgentVariantsOrchestrator {
|
|
|
444
1888
|
resources.cleanupStarted = true;
|
|
445
1889
|
log.info(`Tearing down session ${sessionId} (reason: ${reason})`);
|
|
446
1890
|
// Stop dev servers in parallel; ignore individual failures.
|
|
1891
|
+
// NOTE: For the fresh vite_app rename path, commitVariant already stopped
|
|
1892
|
+
// these dev servers BEFORE the rename (so Vite couldn't recreate `.vite/`
|
|
1893
|
+
// stub dirs at the old worktree paths). In that case devServerProcess is
|
|
1894
|
+
// already cleared and this loop is a no-op — exactly the desired behavior.
|
|
447
1895
|
const stops = [...resources.worktrees.values()]
|
|
448
1896
|
.filter((r) => r.devServerProcess)
|
|
449
1897
|
.map((r) => this.worktrees
|
|
450
1898
|
.stopDevServer(r.devServerProcess)
|
|
451
1899
|
.catch((err) => log.warn(`stopDevServer failed for ${sessionId}/${r.workItemId}`, err)));
|
|
452
1900
|
await Promise.all(stops);
|
|
453
|
-
|
|
454
|
-
|
|
1901
|
+
// The committed dev server (spawned at destinationPath after a fresh
|
|
1902
|
+
// vite_app commit) intentionally outlives this teardown — the iframe is
|
|
1903
|
+
// pointed at it via the proxy and the user just chose this project. Hand
|
|
1904
|
+
// it off to the lingering registry; it'll be stopped on the next
|
|
1905
|
+
// `propose` or explicit shutdown.
|
|
1906
|
+
if (resources.committedDevServer && reason === 'committed') {
|
|
1907
|
+
this.lingeringCommittedDevServers.set(sessionId, resources.committedDevServer);
|
|
455
1908
|
}
|
|
456
|
-
|
|
457
|
-
|
|
1909
|
+
else if (resources.committedDevServer) {
|
|
1910
|
+
// Non-committed teardown (cancel/shutdown) with a committed dev server
|
|
1911
|
+
// shouldn't happen in current code paths, but stop it defensively so we
|
|
1912
|
+
// don't leak procs.
|
|
1913
|
+
try {
|
|
1914
|
+
await this.worktrees.stopDevServer(resources.committedDevServer.proc);
|
|
1915
|
+
}
|
|
1916
|
+
catch (err) {
|
|
1917
|
+
log.warn(`stopDevServer for committed dev server (session ${sessionId}) failed`, err);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
// When a vite_app commit already moved the unchosen worktrees into a
|
|
1921
|
+
// sibling history dir, calling cleanupSession would re-delete them. Skip
|
|
1922
|
+
// the worktree removal step in that case — the move already cleared the
|
|
1923
|
+
// .rivet-variants/ entries for the chosen + preserved variants.
|
|
1924
|
+
if (!resources.vitePreservedSiblings) {
|
|
1925
|
+
try {
|
|
1926
|
+
await this.worktrees.cleanupSession(sessionId, resources.freshDestinationParent);
|
|
1927
|
+
}
|
|
1928
|
+
catch (err) {
|
|
1929
|
+
log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
|
|
1930
|
+
}
|
|
458
1931
|
}
|
|
459
1932
|
this.resources.delete(sessionId);
|
|
460
1933
|
}
|
|
@@ -464,10 +1937,13 @@ class AgentVariantsOrchestrator {
|
|
|
464
1937
|
r = {
|
|
465
1938
|
sessionId,
|
|
466
1939
|
worktrees: new Map(),
|
|
1940
|
+
staticPreviews: new Map(),
|
|
467
1941
|
cleanupStarted: false,
|
|
468
1942
|
committedVariantIds: new Set(),
|
|
469
1943
|
startedAt: Date.now(),
|
|
470
1944
|
leasedAt: new Map(),
|
|
1945
|
+
qaResults: new Map(),
|
|
1946
|
+
vitePreservedSiblings: false,
|
|
471
1947
|
};
|
|
472
1948
|
this.resources.set(sessionId, r);
|
|
473
1949
|
}
|
|
@@ -475,8 +1951,456 @@ class AgentVariantsOrchestrator {
|
|
|
475
1951
|
}
|
|
476
1952
|
}
|
|
477
1953
|
exports.AgentVariantsOrchestrator = AgentVariantsOrchestrator;
|
|
1954
|
+
/** MCP bridges sometimes serialize the output field as a JSON string instead
|
|
1955
|
+
* of a parsed object. Normalize it before field-level inspection. */
|
|
1956
|
+
/** Rough byte-size of the agent's output. Used as a proxy for "how much
|
|
1957
|
+
* code did the agent write" when comparing HTML vs full-stack flows. */
|
|
1958
|
+
function estimateOutputBytes(output) {
|
|
1959
|
+
if (output === undefined || output === null)
|
|
1960
|
+
return 0;
|
|
1961
|
+
try {
|
|
1962
|
+
return typeof output === 'string'
|
|
1963
|
+
? output.length
|
|
1964
|
+
: JSON.stringify(output).length;
|
|
1965
|
+
}
|
|
1966
|
+
catch {
|
|
1967
|
+
return 0;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function normalizeOutput(output) {
|
|
1971
|
+
if (typeof output === 'string') {
|
|
1972
|
+
try {
|
|
1973
|
+
return JSON.parse(output);
|
|
1974
|
+
}
|
|
1975
|
+
catch {
|
|
1976
|
+
return output;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return output;
|
|
1980
|
+
}
|
|
1981
|
+
function parseStaticPreviewOutput(output) {
|
|
1982
|
+
if (!output || typeof output !== 'object')
|
|
1983
|
+
return null;
|
|
1984
|
+
const html = output.html;
|
|
1985
|
+
if (typeof html !== 'string' || html.trim().length === 0)
|
|
1986
|
+
return null;
|
|
1987
|
+
const css = output.css;
|
|
1988
|
+
const js = output.js;
|
|
1989
|
+
return {
|
|
1990
|
+
html: buildStaticPreviewDocument({
|
|
1991
|
+
html,
|
|
1992
|
+
css: typeof css === 'string' ? css : undefined,
|
|
1993
|
+
js: typeof js === 'string' ? js : undefined,
|
|
1994
|
+
}),
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function buildStaticPreviewDocument(input) {
|
|
1998
|
+
if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
|
|
1999
|
+
return input.html;
|
|
2000
|
+
}
|
|
2001
|
+
const style = input.css ? `<style>\n${input.css}\n</style>` : '';
|
|
2002
|
+
const script = input.js ? `<script>\n${input.js}\n</script>` : '';
|
|
2003
|
+
return `<!doctype html>
|
|
2004
|
+
<html lang="en">
|
|
2005
|
+
<head>
|
|
2006
|
+
<meta charset="UTF-8" />
|
|
2007
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
2008
|
+
${style}
|
|
2009
|
+
</head>
|
|
2010
|
+
<body>
|
|
2011
|
+
${input.html}
|
|
2012
|
+
${script}
|
|
2013
|
+
</body>
|
|
2014
|
+
</html>`;
|
|
2015
|
+
}
|
|
478
2016
|
function countDiffFiles(diff) {
|
|
479
2017
|
// Each file in a unified diff starts with "diff --git ".
|
|
480
2018
|
return (diff.match(/^diff --git /gm) ?? []).length;
|
|
481
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Count source files under a materialized fresh-project destination,
|
|
2022
|
+
* skipping install artifacts (`node_modules`, `.git`, build outputs) so the
|
|
2023
|
+
* commit telemetry / chip count reflects the agent-visible surface area
|
|
2024
|
+
* rather than the install footprint.
|
|
2025
|
+
*/
|
|
2026
|
+
function countWorktreeFiles(destinationPath) {
|
|
2027
|
+
let count = 0;
|
|
2028
|
+
const walk = (dir) => {
|
|
2029
|
+
let entries;
|
|
2030
|
+
try {
|
|
2031
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
2032
|
+
}
|
|
2033
|
+
catch {
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
for (const entry of entries) {
|
|
2037
|
+
if (MATERIALIZE_EXCLUDE.has(entry.name))
|
|
2038
|
+
continue;
|
|
2039
|
+
const full = path_1.default.join(dir, entry.name);
|
|
2040
|
+
if (entry.isDirectory())
|
|
2041
|
+
walk(full);
|
|
2042
|
+
else if (entry.isFile())
|
|
2043
|
+
count += 1;
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
walk(destinationPath);
|
|
2047
|
+
return count;
|
|
2048
|
+
}
|
|
2049
|
+
const enrichDesignSource = (design) => {
|
|
2050
|
+
if (!design || design.kind !== 'slug')
|
|
2051
|
+
return design;
|
|
2052
|
+
return {
|
|
2053
|
+
...design,
|
|
2054
|
+
displayName: (0, designCatalog_1.getDesignSystemBySlug)(design.slug)?.name ?? design.slug,
|
|
2055
|
+
};
|
|
2056
|
+
};
|
|
2057
|
+
const toActiveProjectContext = (projectContext) => {
|
|
2058
|
+
if (projectContext.kind === 'existing') {
|
|
2059
|
+
return { kind: 'existing' };
|
|
2060
|
+
}
|
|
2061
|
+
return {
|
|
2062
|
+
kind: 'fresh',
|
|
2063
|
+
workspacePath: projectContext.workspacePath,
|
|
2064
|
+
framework: projectContext.framework,
|
|
2065
|
+
designContext: projectContext.designContext?.map((entry) => entry.kind === 'slug'
|
|
2066
|
+
? { kind: 'slug', slug: entry.slug }
|
|
2067
|
+
: { kind: 'markdown', label: entry.label }),
|
|
2068
|
+
...(projectContext.sourceContext
|
|
2069
|
+
? {
|
|
2070
|
+
sourceContext: {
|
|
2071
|
+
sourceCount: projectContext.sourceContext.sourceUrls?.length ?? 0,
|
|
2072
|
+
isSourceGrounded: Boolean(projectContext.sourceContext.artifact),
|
|
2073
|
+
},
|
|
2074
|
+
}
|
|
2075
|
+
: {}),
|
|
2076
|
+
...(projectContext.executionPlan
|
|
2077
|
+
? {
|
|
2078
|
+
executionPlan: {
|
|
2079
|
+
mode: projectContext.executionPlan.mode,
|
|
2080
|
+
confidence: projectContext.executionPlan.confidence,
|
|
2081
|
+
reason: projectContext.executionPlan.reason,
|
|
2082
|
+
assetCount: projectContext.executionPlan.assetPlan?.length ?? 0,
|
|
2083
|
+
runtimeRequirementCount: projectContext.executionPlan.runtimeRequirements?.length ?? 0,
|
|
2084
|
+
},
|
|
2085
|
+
}
|
|
2086
|
+
: {}),
|
|
2087
|
+
};
|
|
2088
|
+
};
|
|
2089
|
+
/**
|
|
2090
|
+
* Resolve the user-facing supporting artifacts for a session.
|
|
2091
|
+
*
|
|
2092
|
+
* For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
|
|
2093
|
+
* turned into a `design_context` artifact carrying the full DESIGN.md
|
|
2094
|
+
* markdown:
|
|
2095
|
+
* - `slug` entries resolve bundled catalog markdown via the design catalog.
|
|
2096
|
+
* - `markdown` entries (Agent Browser / inspiration extractor output) carry
|
|
2097
|
+
* their stored markdown verbatim.
|
|
2098
|
+
* Slots whose markdown can't be resolved are skipped so the UI never renders
|
|
2099
|
+
* a metadata-only DESIGN.md row.
|
|
2100
|
+
*/
|
|
2101
|
+
const buildSessionArtifacts = (projectContext) => {
|
|
2102
|
+
if (projectContext.kind !== 'fresh')
|
|
2103
|
+
return [];
|
|
2104
|
+
const designContext = projectContext.designContext;
|
|
2105
|
+
if (!designContext || designContext.length === 0)
|
|
2106
|
+
return [];
|
|
2107
|
+
const artifactsByContent = new Map();
|
|
2108
|
+
designContext.forEach((entry, slot) => {
|
|
2109
|
+
if (!entry)
|
|
2110
|
+
return;
|
|
2111
|
+
if (entry.kind === 'slug') {
|
|
2112
|
+
const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
|
|
2113
|
+
if (!markdown)
|
|
2114
|
+
return;
|
|
2115
|
+
const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
|
|
2116
|
+
addDesignContextArtifact(artifactsByContent, {
|
|
2117
|
+
id: `design_context:${slot}:${entry.slug}`,
|
|
2118
|
+
kind: 'design_context',
|
|
2119
|
+
label: catalogEntry?.name ?? entry.slug,
|
|
2120
|
+
...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
|
|
2121
|
+
status: 'ready',
|
|
2122
|
+
source: 'static',
|
|
2123
|
+
contentType: 'text/markdown',
|
|
2124
|
+
content: markdown,
|
|
2125
|
+
});
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (!entry.content)
|
|
2129
|
+
return;
|
|
2130
|
+
addDesignContextArtifact(artifactsByContent, {
|
|
2131
|
+
id: `design_context:${slot}:${entry.label}`,
|
|
2132
|
+
kind: 'design_context',
|
|
2133
|
+
label: entry.label,
|
|
2134
|
+
status: 'ready',
|
|
2135
|
+
source: 'agent_browser',
|
|
2136
|
+
contentType: 'text/markdown',
|
|
2137
|
+
content: entry.content,
|
|
2138
|
+
});
|
|
2139
|
+
});
|
|
2140
|
+
return [...artifactsByContent.values()].map((artifact) => {
|
|
2141
|
+
if (artifact.usedByVariantCount <= 1)
|
|
2142
|
+
return artifact;
|
|
2143
|
+
return {
|
|
2144
|
+
...artifact,
|
|
2145
|
+
summary: artifact.summary
|
|
2146
|
+
? `${artifact.summary} Used by ${artifact.usedByVariantCount} variants.`
|
|
2147
|
+
: `Used by ${artifact.usedByVariantCount} variants.`,
|
|
2148
|
+
};
|
|
2149
|
+
});
|
|
2150
|
+
};
|
|
2151
|
+
const addDesignContextArtifact = (artifactsByContent, artifact) => {
|
|
2152
|
+
if (!artifact.content)
|
|
2153
|
+
return;
|
|
2154
|
+
const existing = artifactsByContent.get(artifact.content);
|
|
2155
|
+
if (existing) {
|
|
2156
|
+
existing.usedByVariantCount += 1;
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
artifactsByContent.set(artifact.content, {
|
|
2160
|
+
...artifact,
|
|
2161
|
+
usedByVariantCount: 1,
|
|
2162
|
+
});
|
|
2163
|
+
};
|
|
2164
|
+
const summarizeDesignContext = (designContext) => {
|
|
2165
|
+
if (!designContext)
|
|
2166
|
+
return null;
|
|
2167
|
+
return designContext.reduce((entries, entry, slot) => {
|
|
2168
|
+
if (!entry)
|
|
2169
|
+
return entries;
|
|
2170
|
+
if (entry.kind === 'slug') {
|
|
2171
|
+
entries.push({ slot, kind: 'slug', slug: entry.slug });
|
|
2172
|
+
return entries;
|
|
2173
|
+
}
|
|
2174
|
+
entries.push({
|
|
2175
|
+
slot,
|
|
2176
|
+
kind: 'markdown',
|
|
2177
|
+
markdownLabel: entry.label,
|
|
2178
|
+
});
|
|
2179
|
+
return entries;
|
|
2180
|
+
}, []);
|
|
2181
|
+
};
|
|
2182
|
+
const trackScaffoldStarted = (telemetry, data) => {
|
|
2183
|
+
if (telemetry.trackAgentVariantsScaffoldStarted) {
|
|
2184
|
+
telemetry.trackAgentVariantsScaffoldStarted(data);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
telemetry.track('agent_variants.scaffold_started', {
|
|
2188
|
+
source: 'mcp',
|
|
2189
|
+
session_id: data.sessionId,
|
|
2190
|
+
variant_count: data.variantCount,
|
|
2191
|
+
design_context: data.designContext ?? null,
|
|
2192
|
+
});
|
|
2193
|
+
};
|
|
2194
|
+
const trackScaffoldCompleted = (telemetry, data) => {
|
|
2195
|
+
if (telemetry.trackAgentVariantsScaffoldCompleted) {
|
|
2196
|
+
telemetry.trackAgentVariantsScaffoldCompleted(data);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
telemetry.track('agent_variants.scaffold_completed', {
|
|
2200
|
+
source: 'mcp',
|
|
2201
|
+
session_id: data.sessionId,
|
|
2202
|
+
duration_ms: data.durationMs,
|
|
2203
|
+
install_duration_ms: data.installDurationMs,
|
|
2204
|
+
variant_count: data.variantCount,
|
|
2205
|
+
});
|
|
2206
|
+
};
|
|
2207
|
+
const trackScaffoldFailed = (telemetry, data) => {
|
|
2208
|
+
if (telemetry.trackAgentVariantsScaffoldFailed) {
|
|
2209
|
+
telemetry.trackAgentVariantsScaffoldFailed(data);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
telemetry.track('agent_variants.scaffold_failed', {
|
|
2213
|
+
source: 'mcp',
|
|
2214
|
+
session_id: data.sessionId,
|
|
2215
|
+
error_code: data.errorCode,
|
|
2216
|
+
duration_ms: data.durationMs,
|
|
2217
|
+
});
|
|
2218
|
+
};
|
|
2219
|
+
const trackFreshDevServerStarted = (telemetry, data) => {
|
|
2220
|
+
if (telemetry.trackAgentVariantsFreshDevServerStarted) {
|
|
2221
|
+
telemetry.trackAgentVariantsFreshDevServerStarted(data);
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
telemetry.track('agent_variants.fresh_dev_server_started', {
|
|
2225
|
+
source: 'mcp',
|
|
2226
|
+
session_id: data.sessionId,
|
|
2227
|
+
variant_id: data.variantId,
|
|
2228
|
+
port: data.port,
|
|
2229
|
+
});
|
|
2230
|
+
};
|
|
2231
|
+
const trackFreshDevServerFailed = (telemetry, data) => {
|
|
2232
|
+
if (telemetry.trackAgentVariantsFreshDevServerFailed) {
|
|
2233
|
+
telemetry.trackAgentVariantsFreshDevServerFailed(data);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
telemetry.track('agent_variants.fresh_dev_server_failed', {
|
|
2237
|
+
source: 'mcp',
|
|
2238
|
+
session_id: data.sessionId,
|
|
2239
|
+
variant_id: data.variantId,
|
|
2240
|
+
error_code: data.errorCode,
|
|
2241
|
+
});
|
|
2242
|
+
};
|
|
2243
|
+
const trackStaticPreviewCompleted = (telemetry, data) => {
|
|
2244
|
+
if (telemetry.trackAgentVariantsStaticPreviewCompleted) {
|
|
2245
|
+
telemetry.trackAgentVariantsStaticPreviewCompleted(data);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
telemetry.track('agent_variants.static_preview_completed', {
|
|
2249
|
+
source: 'mcp',
|
|
2250
|
+
session_id: data.sessionId,
|
|
2251
|
+
variant_id: data.variantId,
|
|
2252
|
+
duration_ms: data.durationMs,
|
|
2253
|
+
});
|
|
2254
|
+
};
|
|
2255
|
+
const MATERIALIZE_EXCLUDE = new Set([
|
|
2256
|
+
'node_modules',
|
|
2257
|
+
'.git',
|
|
2258
|
+
'dist',
|
|
2259
|
+
'.next',
|
|
2260
|
+
'.cache',
|
|
2261
|
+
'.vite',
|
|
2262
|
+
]);
|
|
2263
|
+
const defaultInstallDependencies = (worktreePath) => {
|
|
2264
|
+
return new Promise((resolve, reject) => {
|
|
2265
|
+
const proc = (0, child_process_1.spawn)('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], {
|
|
2266
|
+
cwd: worktreePath,
|
|
2267
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2268
|
+
});
|
|
2269
|
+
let stderr = '';
|
|
2270
|
+
proc.stderr.on('data', (chunk) => {
|
|
2271
|
+
stderr += chunk.toString();
|
|
2272
|
+
});
|
|
2273
|
+
proc.on('error', reject);
|
|
2274
|
+
proc.on('exit', (code) => {
|
|
2275
|
+
if (code === 0) {
|
|
2276
|
+
resolve();
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
reject(new Error(`npm install in ${worktreePath} failed (code ${code}): ${stderr.slice(-512)}`));
|
|
2280
|
+
});
|
|
2281
|
+
});
|
|
2282
|
+
};
|
|
2283
|
+
/**
|
|
2284
|
+
* True when `a` and `b` (or the closest existing ancestor of each) live on
|
|
2285
|
+
* the same filesystem volume. Used by commitVariant to decide between an
|
|
2286
|
+
* O(1) rename and a recursive copy-with-reinstall fallback.
|
|
2287
|
+
*
|
|
2288
|
+
* `destinationPath` for fresh sessions does not exist yet (we'd fail the
|
|
2289
|
+
* "destination must be empty" guard otherwise), so we compare against its
|
|
2290
|
+
* parent directory which is guaranteed to exist (createZeroToOneTool either
|
|
2291
|
+
* created it or validated it).
|
|
2292
|
+
*/
|
|
2293
|
+
function areSameVolume(a, b) {
|
|
2294
|
+
try {
|
|
2295
|
+
const statA = fs_1.default.statSync(a);
|
|
2296
|
+
const parentB = fs_1.default.existsSync(b) ? b : path_1.default.dirname(b);
|
|
2297
|
+
const statB = fs_1.default.statSync(parentB);
|
|
2298
|
+
return statA.dev === statB.dev;
|
|
2299
|
+
}
|
|
2300
|
+
catch {
|
|
2301
|
+
return false;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Materialize node_modules from symlink → real directory at the given path,
|
|
2306
|
+
* so a subsequent rename of that path leaves a self-contained tree.
|
|
2307
|
+
*
|
|
2308
|
+
* Fresh worktrees 1..N share variant 0's node_modules via a symlink (see
|
|
2309
|
+
* runBackgroundInstall). When the user picks variant K and we rename it
|
|
2310
|
+
* into the destination, the sibling worktrees get torn down — which would
|
|
2311
|
+
* otherwise leave a dangling symlink in the destination if variant K was
|
|
2312
|
+
* the one with the symlink (K > 0).
|
|
2313
|
+
*
|
|
2314
|
+
* Returns true when the destination must run a dependency install after the
|
|
2315
|
+
* rename because node_modules is missing or points nowhere.
|
|
2316
|
+
*/
|
|
2317
|
+
async function resolveNodeModulesSymlink(worktreePath) {
|
|
2318
|
+
const nodeModulesPath = path_1.default.join(worktreePath, 'node_modules');
|
|
2319
|
+
let lstat;
|
|
2320
|
+
try {
|
|
2321
|
+
lstat = await fs_1.default.promises.lstat(nodeModulesPath);
|
|
2322
|
+
}
|
|
2323
|
+
catch {
|
|
2324
|
+
return true;
|
|
2325
|
+
}
|
|
2326
|
+
if (!lstat.isSymbolicLink())
|
|
2327
|
+
return false;
|
|
2328
|
+
let target;
|
|
2329
|
+
try {
|
|
2330
|
+
target = await fs_1.default.promises.realpath(nodeModulesPath);
|
|
2331
|
+
}
|
|
2332
|
+
catch {
|
|
2333
|
+
await fs_1.default.promises.rm(nodeModulesPath, { force: true });
|
|
2334
|
+
return true;
|
|
2335
|
+
}
|
|
2336
|
+
// Stage the copy under a sibling temp name so a mid-copy failure (disk
|
|
2337
|
+
// full, EACCES, etc.) leaves the original symlink intact rather than
|
|
2338
|
+
// wiping it and leaving the worktree with no node_modules at all.
|
|
2339
|
+
// The final swap is then atomic on the same filesystem (rename + unlink).
|
|
2340
|
+
const stagingPath = `${nodeModulesPath}.materializing-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
2341
|
+
try {
|
|
2342
|
+
await fs_1.default.promises.cp(target, stagingPath, {
|
|
2343
|
+
recursive: true,
|
|
2344
|
+
dereference: false,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
catch (err) {
|
|
2348
|
+
// Best-effort cleanup of the partial staging dir; leave the symlink
|
|
2349
|
+
// intact so the caller can re-run materialization.
|
|
2350
|
+
await fs_1.default.promises
|
|
2351
|
+
.rm(stagingPath, { recursive: true, force: true })
|
|
2352
|
+
.catch(() => { });
|
|
2353
|
+
throw err;
|
|
2354
|
+
}
|
|
2355
|
+
// Swap: remove the symlink, then rename the staging dir into place.
|
|
2356
|
+
// Both operations are local fs ops on the same parent — fast and
|
|
2357
|
+
// unlikely to fail independently, but if the rename fails the symlink
|
|
2358
|
+
// is already gone, so prefer to leave the staging dir on disk over a
|
|
2359
|
+
// half-broken worktree (the caller will surface the throw).
|
|
2360
|
+
await fs_1.default.promises.rm(nodeModulesPath, { force: true });
|
|
2361
|
+
await fs_1.default.promises.rename(stagingPath, nodeModulesPath);
|
|
2362
|
+
return false;
|
|
2363
|
+
}
|
|
2364
|
+
const defaultMaterializeProject = async (sourceWorktreePath, destinationPath) => {
|
|
2365
|
+
await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
|
|
2366
|
+
await fs_1.default.promises.cp(sourceWorktreePath, destinationPath, {
|
|
2367
|
+
recursive: true,
|
|
2368
|
+
filter: (sourcePath) => {
|
|
2369
|
+
const rel = path_1.default.relative(sourceWorktreePath, sourcePath);
|
|
2370
|
+
if (!rel)
|
|
2371
|
+
return true;
|
|
2372
|
+
const top = rel.split(path_1.default.sep)[0];
|
|
2373
|
+
return !MATERIALIZE_EXCLUDE.has(top);
|
|
2374
|
+
},
|
|
2375
|
+
});
|
|
2376
|
+
const git = (0, simple_git_1.simpleGit)(destinationPath);
|
|
2377
|
+
await git.raw(['init']);
|
|
2378
|
+
await git.raw(['add', '-A']);
|
|
2379
|
+
await git
|
|
2380
|
+
.raw([
|
|
2381
|
+
'-c',
|
|
2382
|
+
'user.name=Rivet',
|
|
2383
|
+
'-c',
|
|
2384
|
+
'user.email=hello@tryrivet.design',
|
|
2385
|
+
'commit',
|
|
2386
|
+
'--no-gpg-sign',
|
|
2387
|
+
'-m',
|
|
2388
|
+
'Initial commit (created with Rivet)',
|
|
2389
|
+
])
|
|
2390
|
+
.catch(() => {
|
|
2391
|
+
// Best-effort: a missing user identity or empty tree leaves the
|
|
2392
|
+
// commit step un-applied. The destination still has the files.
|
|
2393
|
+
});
|
|
2394
|
+
};
|
|
2395
|
+
/**
|
|
2396
|
+
* Synthesize a placeholder brief body for a unified start_variants slot.
|
|
2397
|
+
* The body must fit `briefSchema.body` (≤ 200 chars, ≥ 1 char). The agent's
|
|
2398
|
+
* streamed first-line label replaces this in the UI once code_gen output
|
|
2399
|
+
* arrives. Index/total are unused for now but kept in the signature so
|
|
2400
|
+
* future diversity hints can be added without a call-site change.
|
|
2401
|
+
*/
|
|
2402
|
+
function synthesizeUnifiedBriefBody(prompt, _index, _total) {
|
|
2403
|
+
const trimmed = prompt.trim().replace(/\s+/g, ' ').slice(0, 200);
|
|
2404
|
+
return trimmed.length > 0 ? trimmed : 'variant';
|
|
2405
|
+
}
|
|
482
2406
|
//# sourceMappingURL=WorktreeOrchestrator.js.map
|