rivet-design 0.9.3 → 0.9.5
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 +26 -10
- package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
- package/dist/mcp/agent-variants/SessionStore.js +189 -44
- package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +209 -13
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1489 -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 +1576 -64
- package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
- package/dist/mcp/agent-variants/contracts.js +202 -9
- 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 +67 -25
- package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
- package/dist/mcp/agent-variants/createZeroToOneTool.js +60 -126
- package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -1
- 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 +1 -0
- package/dist/mcp/agent-variants/errors.js.map +1 -1
- package/dist/mcp/agent-variants/index.d.ts +3 -4
- package/dist/mcp/agent-variants/index.d.ts.map +1 -1
- package/dist/mcp/agent-variants/index.js +2 -2
- package/dist/mcp/agent-variants/index.js.map +1 -1
- package/dist/mcp/agent-variants/inspirationDesignContext.d.ts +10 -0
- package/dist/mcp/agent-variants/inspirationDesignContext.d.ts.map +1 -1
- package/dist/mcp/agent-variants/inspirationDesignContext.js +104 -16
- package/dist/mcp/agent-variants/inspirationDesignContext.js.map +1 -1
- package/dist/mcp/agent-variants/pendingChangesAdapter.d.ts.map +1 -1
- package/dist/mcp/agent-variants/pendingChangesAdapter.js +11 -0
- 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 +1 -0
- package/dist/mcp/agent-variants/sourceContext.d.ts.map +1 -1
- package/dist/mcp/agent-variants/sourceContext.js +28 -3
- package/dist/mcp/agent-variants/sourceContext.js.map +1 -1
- package/dist/mcp/agent-variants/tools.d.ts +40 -3
- package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
- package/dist/mcp/agent-variants/tools.js +262 -38
- package/dist/mcp/agent-variants/tools.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +32 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/prompts/agentModPrompts.d.ts.map +1 -1
- package/dist/prompts/agentModPrompts.js +11 -10
- package/dist/prompts/agentModPrompts.js.map +1 -1
- package/dist/proxy-middleware/proxy-config.d.ts +2 -2
- package/dist/proxy-middleware/proxy-config.d.ts.map +1 -1
- package/dist/proxy-middleware/proxy-config.js +66 -22
- package/dist/proxy-middleware/proxy-config.js.map +1 -1
- package/dist/routes/agentVariants.d.ts +2 -13
- package/dist/routes/agentVariants.d.ts.map +1 -1
- package/dist/routes/agentVariants.js +156 -2
- package/dist/routes/agentVariants.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +79 -4
- package/dist/server.js.map +1 -1
- package/dist/services/ProjectDetectionService.d.ts.map +1 -1
- package/dist/services/ProjectDetectionService.js +21 -0
- package/dist/services/ProjectDetectionService.js.map +1 -1
- package/dist/services/VariantHistoryService.d.ts +117 -0
- package/dist/services/VariantHistoryService.d.ts.map +1 -0
- package/dist/services/VariantHistoryService.js +385 -0
- package/dist/services/VariantHistoryService.js.map +1 -0
- package/dist/services/WorktreeManager.d.ts +67 -9
- package/dist/services/WorktreeManager.d.ts.map +1 -1
- package/dist/services/WorktreeManager.js +209 -26
- package/dist/services/WorktreeManager.js.map +1 -1
- package/dist/services/agent/AgentCore.d.ts +1 -1
- package/dist/services/agent/AgentCore.d.ts.map +1 -1
- package/dist/services/agent/AgentCore.js +24 -1
- package/dist/services/agent/AgentCore.js.map +1 -1
- package/dist/services/agent/AgentModService.d.ts +1 -1
- package/dist/services/agent/AgentModService.js +7 -7
- package/dist/services/agent/AgentModService.js.map +1 -1
- package/dist/services/templates/viteReactTs.d.ts +6 -0
- package/dist/services/templates/viteReactTs.d.ts.map +1 -1
- package/dist/services/templates/viteReactTs.js +8 -1
- package/dist/services/templates/viteReactTs.js.map +1 -1
- package/dist/types/change-request-types.d.ts +14 -0
- package/dist/types/change-request-types.d.ts.map +1 -1
- package/dist/utils/skills/claude-skill.d.ts +1 -1
- package/dist/utils/skills/claude-skill.js +2 -2
- package/dist/utils/skills/cursor-rules.d.ts +1 -1
- package/dist/utils/skills/cursor-rules.js +1 -1
- package/dist/utils/skills/shared-variants-protocol.d.ts +1 -1
- package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
- package/dist/utils/skills/shared-variants-protocol.js +31 -32
- package/dist/utils/skills/shared-variants-protocol.js.map +1 -1
- package/package.json +4 -5
- package/src/ui/dist/assets/main-OdmwI8Od.css +1 -0
- package/src/ui/dist/assets/{main-DejhsBWR.js → main-SuZlKEi0.js} +115 -115
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/main-C9jfEp80.css +0 -1
|
@@ -4,18 +4,191 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AgentVariantsOrchestrator = void 0;
|
|
7
|
+
exports.buildStaticPreviewDocument = buildStaticPreviewDocument;
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
7
9
|
const events_1 = require("events");
|
|
8
10
|
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
9
12
|
const path_1 = __importDefault(require("path"));
|
|
10
13
|
const child_process_1 = require("child_process");
|
|
11
14
|
const simple_git_1 = require("simple-git");
|
|
12
15
|
const logger_1 = require("../../utils/logger");
|
|
13
16
|
const errors_1 = require("./errors");
|
|
17
|
+
const createProjectArtifacts_1 = require("./createProjectArtifacts");
|
|
14
18
|
const contracts_1 = require("./contracts");
|
|
15
19
|
const viteReactTs_1 = require("../../services/templates/viteReactTs");
|
|
16
20
|
const designCatalog_1 = require("../../services/templates/designCatalog");
|
|
21
|
+
const previewQa_1 = require("./previewQa");
|
|
22
|
+
const VariantHistoryService_1 = require("../../services/VariantHistoryService");
|
|
17
23
|
const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
|
|
18
24
|
const FRESH_DEV_SERVER_HOST = '127.0.0.1';
|
|
25
|
+
/**
|
|
26
|
+
* Allowlist of asset file extensions an agent-planned source may have.
|
|
27
|
+
* `assetPlan` is sized for large local *assets* (3D models, images,
|
|
28
|
+
* audio, video, fonts) — never code, dotfiles, secrets, or arbitrary
|
|
29
|
+
* configs. The check is defense-in-depth alongside the path / symlink
|
|
30
|
+
* filters in `copyAssetIntoWorktree`; without it, a prompt-injected
|
|
31
|
+
* source plan could copy readable host files such as SSH keys or
|
|
32
|
+
* project secrets into the generated app.
|
|
33
|
+
*
|
|
34
|
+
* Intentionally excludes `.json`, `.txt`, `.md`, `.csv`: these are
|
|
35
|
+
* common carriers for credentials (`credentials.json`, `.aws/config`,
|
|
36
|
+
* shell history `.txt`, README-shaped secrets) and have no legitimate
|
|
37
|
+
* use as binary preview assets. If a future flow needs structured data
|
|
38
|
+
* it should ship it through the manifest instead of file copy.
|
|
39
|
+
*/
|
|
40
|
+
const ALLOWED_ASSET_EXTENSIONS = new Set([
|
|
41
|
+
// 3D / models
|
|
42
|
+
'.glb', '.gltf', '.obj', '.fbx', '.usdz',
|
|
43
|
+
// images
|
|
44
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
|
|
45
|
+
// video
|
|
46
|
+
'.mp4', '.webm', '.mov',
|
|
47
|
+
// audio
|
|
48
|
+
'.mp3', '.wav', '.ogg', '.m4a',
|
|
49
|
+
// fonts
|
|
50
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
51
|
+
// PDFs
|
|
52
|
+
'.pdf',
|
|
53
|
+
]);
|
|
54
|
+
/**
|
|
55
|
+
* Reject paths that traverse through a "sensitive" directory anywhere
|
|
56
|
+
* in their ancestry — `.ssh`, `.aws`, `.config`, `.gnupg`, etc. Even if
|
|
57
|
+
* a copy passes the extension allowlist, an agent-controlled source
|
|
58
|
+
* resolving inside `~/.ssh/` is almost certainly an exfiltration
|
|
59
|
+
* attempt rather than a legitimate asset reference.
|
|
60
|
+
*/
|
|
61
|
+
const SENSITIVE_PATH_SEGMENTS = new Set([
|
|
62
|
+
'.ssh',
|
|
63
|
+
'.aws',
|
|
64
|
+
'.gnupg',
|
|
65
|
+
'.kube',
|
|
66
|
+
'.docker',
|
|
67
|
+
'.config',
|
|
68
|
+
'.npm',
|
|
69
|
+
'.cargo',
|
|
70
|
+
'.gem',
|
|
71
|
+
'.netrc',
|
|
72
|
+
'private',
|
|
73
|
+
'secrets',
|
|
74
|
+
'credentials',
|
|
75
|
+
]);
|
|
76
|
+
function hasSensitivePathSegment(absolutePath) {
|
|
77
|
+
// Split on any path separator; check each segment case-insensitively.
|
|
78
|
+
const segments = absolutePath.split(/[/\\]/).filter(Boolean);
|
|
79
|
+
return segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Returns true when `absolutePath` is the root itself or one of its
|
|
83
|
+
* descendants after both paths have been resolved.
|
|
84
|
+
*/
|
|
85
|
+
function isPathWithinRoot(absolutePath, root) {
|
|
86
|
+
const relative = path_1.default.relative(root, absolutePath);
|
|
87
|
+
return (relative === '' ||
|
|
88
|
+
(relative.length > 0 &&
|
|
89
|
+
!relative.startsWith('..') &&
|
|
90
|
+
!path_1.default.isAbsolute(relative)));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Copy one agent-planned asset file into a variant worktree at the
|
|
94
|
+
* project-relative `destination`. The agent decides routing
|
|
95
|
+
* (`destination` like `public/assets/avatar.glb`) so all variants share
|
|
96
|
+
* a consistent layout. Fails loudly when the source is missing or the
|
|
97
|
+
* destination tries to escape the worktree — silent fallbacks here
|
|
98
|
+
* would re-introduce the broken-asset failure mode this routing is
|
|
99
|
+
* supposed to prevent.
|
|
100
|
+
*
|
|
101
|
+
* Security: `entry.source` comes from `report_source_plan` and is
|
|
102
|
+
* agent-controlled. To prevent a prompt-injected source plan from
|
|
103
|
+
* exfiltrating arbitrary readable host files (SSH keys, dotfiles,
|
|
104
|
+
* cloud credentials, project secrets) into the generated app, the
|
|
105
|
+
* source must (1) be an absolute path that resolves via realpath
|
|
106
|
+
* (no traversal, no symlinks pointing outside the resolved location),
|
|
107
|
+
* (2) be a regular file (not a device, socket, fifo, or directory),
|
|
108
|
+
* (3) live under the approved asset root, (4) have an extension on the
|
|
109
|
+
* asset allowlist.
|
|
110
|
+
*/
|
|
111
|
+
function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
|
|
112
|
+
if (!assetSourceRoot) {
|
|
113
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source requires an approved asset root before copying '${entry.source}'`);
|
|
114
|
+
}
|
|
115
|
+
if (!path_1.default.isAbsolute(assetSourceRoot)) {
|
|
116
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot must be an absolute path, got '${assetSourceRoot}'`);
|
|
117
|
+
}
|
|
118
|
+
if (!path_1.default.isAbsolute(entry.source)) {
|
|
119
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be an absolute path, got '${entry.source}'`);
|
|
120
|
+
}
|
|
121
|
+
const ext = path_1.default.extname(entry.source).toLowerCase();
|
|
122
|
+
if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
|
|
123
|
+
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.`);
|
|
124
|
+
}
|
|
125
|
+
// lstat first: a symlink whose target lies outside the source path
|
|
126
|
+
// could otherwise smuggle in arbitrary files. Reject symlinks outright;
|
|
127
|
+
// realpath would resolve them but the user-visible intent of an
|
|
128
|
+
// assetPlan entry is to name a concrete file.
|
|
129
|
+
let lstat;
|
|
130
|
+
try {
|
|
131
|
+
lstat = fs_1.default.lstatSync(entry.source);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source not found on disk: ${entry.source}`);
|
|
135
|
+
}
|
|
136
|
+
if (lstat.isSymbolicLink()) {
|
|
137
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must not be a symlink: ${entry.source}`);
|
|
138
|
+
}
|
|
139
|
+
if (!lstat.isFile()) {
|
|
140
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be a regular file: ${entry.source}`);
|
|
141
|
+
}
|
|
142
|
+
// Resolve realpath as additional defense: even with no symlinks at the
|
|
143
|
+
// leaf, an intermediate directory could be a symlink. Cross-check the
|
|
144
|
+
// file kind on the resolved path.
|
|
145
|
+
let resolvedSource;
|
|
146
|
+
try {
|
|
147
|
+
resolvedSource = fs_1.default.realpathSync(entry.source);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source could not be resolved: ${entry.source}`);
|
|
151
|
+
}
|
|
152
|
+
let resolvedAssetSourceRoot;
|
|
153
|
+
try {
|
|
154
|
+
resolvedAssetSourceRoot = fs_1.default.realpathSync(assetSourceRoot);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot could not be resolved: ${assetSourceRoot}`);
|
|
158
|
+
}
|
|
159
|
+
const resolvedStat = fs_1.default.statSync(resolvedSource);
|
|
160
|
+
if (!resolvedStat.isFile()) {
|
|
161
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved target must be a regular file: ${entry.source}`);
|
|
162
|
+
}
|
|
163
|
+
// Symlinked parent directory defense: even though we rejected a symlink
|
|
164
|
+
// leaf and confirmed the resolved file is regular, an intermediate dir
|
|
165
|
+
// could have been a symlink that quietly forwards into a sensitive
|
|
166
|
+
// ancestor (e.g. `<asset-root>/avatar -> ~/.ssh`). Cross-check that
|
|
167
|
+
// NEITHER the user-supplied path NOR its realpath traverses a known
|
|
168
|
+
// sensitive segment such as `.ssh`, `.aws`, `credentials`, etc. Also
|
|
169
|
+
// re-verify the extension on the resolved path so a `.glb` symlink
|
|
170
|
+
// chain cannot smuggle in a `.json` realpath.
|
|
171
|
+
if (hasSensitivePathSegment(entry.source) ||
|
|
172
|
+
hasSensitivePathSegment(resolvedSource)) {
|
|
173
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source traverses a sensitive directory and is refused: ${entry.source}`);
|
|
174
|
+
}
|
|
175
|
+
if (!isPathWithinRoot(resolvedSource, resolvedAssetSourceRoot)) {
|
|
176
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must stay inside the approved asset root: ${entry.source}`);
|
|
177
|
+
}
|
|
178
|
+
const resolvedExt = path_1.default.extname(resolvedSource).toLowerCase();
|
|
179
|
+
if (!ALLOWED_ASSET_EXTENSIONS.has(resolvedExt)) {
|
|
180
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
|
|
181
|
+
}
|
|
182
|
+
const normalizedDest = path_1.default.normalize(entry.destination);
|
|
183
|
+
if (normalizedDest.startsWith('..') ||
|
|
184
|
+
path_1.default.isAbsolute(normalizedDest)) {
|
|
185
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
|
|
186
|
+
}
|
|
187
|
+
const absDest = path_1.default.join(worktreePath, normalizedDest);
|
|
188
|
+
fs_1.default.mkdirSync(path_1.default.dirname(absDest), { recursive: true });
|
|
189
|
+
fs_1.default.copyFileSync(resolvedSource, absDest);
|
|
190
|
+
}
|
|
191
|
+
const defaultPreviewQaRunner = ({ html }) => (0, previewQa_1.runPreviewQa)({ html });
|
|
19
192
|
const NOOP_TELEMETRY = { track: () => undefined };
|
|
20
193
|
/**
|
|
21
194
|
* Wraps SessionStore for the operations that have side effects: approve
|
|
@@ -37,7 +210,30 @@ class AgentVariantsOrchestrator {
|
|
|
37
210
|
telemetry;
|
|
38
211
|
installDependencies;
|
|
39
212
|
materializeProject;
|
|
213
|
+
previewQaRunner;
|
|
214
|
+
switchPreviewPort;
|
|
215
|
+
variantHistory;
|
|
40
216
|
resources = new Map();
|
|
217
|
+
/**
|
|
218
|
+
* Committed dev servers from prior sessions that survived teardown. The
|
|
219
|
+
* orchestrator outlives any single session — once a new session starts, the
|
|
220
|
+
* prior committed dev server is no longer needed (the next session opens
|
|
221
|
+
* its own preview chip), so stop it before the new session's first preview
|
|
222
|
+
* goes live. Keyed by sessionId for diagnostics; values are kept alive
|
|
223
|
+
* intentionally between commit and next `propose` / `shutdown`.
|
|
224
|
+
*/
|
|
225
|
+
lingeringCommittedDevServers = new Map();
|
|
226
|
+
/**
|
|
227
|
+
* Per-session in-flight commit chain. Two overlapping commitVariant calls
|
|
228
|
+
* would otherwise both pass the `getVariantPick` idempotency check (the
|
|
229
|
+
* pick isn't recorded until after `applyDiffToProject`) and double-invoke
|
|
230
|
+
* `git apply` — which corrupts the working tree on the second run. The
|
|
231
|
+
* chain serializes commits per session so the second caller waits for the
|
|
232
|
+
* first to finish and then hits the idempotency check (returning
|
|
233
|
+
* `duplicate: true` for the same variantId, or `PENDING_CHANGE_CONFLICT`
|
|
234
|
+
* for a different one).
|
|
235
|
+
*/
|
|
236
|
+
inflightCommits = new Map();
|
|
41
237
|
/** Most recent agent-variants sessionId — read by the iframe chip via
|
|
42
238
|
* GET /api/variants/active. Cleared on cancel or commit. */
|
|
43
239
|
activeSessionId = null;
|
|
@@ -54,9 +250,18 @@ class AgentVariantsOrchestrator {
|
|
|
54
250
|
deps.installDependencies ?? defaultInstallDependencies;
|
|
55
251
|
this.materializeProject =
|
|
56
252
|
deps.materializeProject ?? defaultMaterializeProject;
|
|
253
|
+
this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
|
|
254
|
+
this.switchPreviewPort = deps.switchPreviewPort;
|
|
255
|
+
this.variantHistory = deps.variantHistory ?? new VariantHistoryService_1.VariantHistoryService();
|
|
57
256
|
}
|
|
58
257
|
// --- Pure delegations (no side effects) ---------------------------------
|
|
59
258
|
propose(args) {
|
|
259
|
+
// A new session means any lingering committed dev server from a prior
|
|
260
|
+
// session is no longer needed — the next session opens its own preview.
|
|
261
|
+
// Best-effort stop; failures here don't block propose.
|
|
262
|
+
void this.stopLingeringCommittedDevServers().catch((err) => {
|
|
263
|
+
log.warn('Stopping lingering committed dev servers failed', err);
|
|
264
|
+
});
|
|
60
265
|
const result = this.store.propose(args);
|
|
61
266
|
this.activeSessionId = result.sessionId;
|
|
62
267
|
this.ensureResources(result.sessionId);
|
|
@@ -99,8 +304,10 @@ class AgentVariantsOrchestrator {
|
|
|
99
304
|
? this.store.getSummary(sessionId)
|
|
100
305
|
: null;
|
|
101
306
|
const variants = this.getVariants(sessionId);
|
|
102
|
-
const
|
|
307
|
+
const sessionProjectContext = this.store.getProjectContext(sessionId);
|
|
308
|
+
const projectContext = toActiveProjectContext(sessionProjectContext);
|
|
103
309
|
const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
|
|
310
|
+
const artifacts = buildSessionArtifacts(sessionProjectContext);
|
|
104
311
|
return {
|
|
105
312
|
active: true,
|
|
106
313
|
sessionId,
|
|
@@ -111,6 +318,7 @@ class AgentVariantsOrchestrator {
|
|
|
111
318
|
variants,
|
|
112
319
|
projectContext,
|
|
113
320
|
...(destinationPath ? { destinationPath } : {}),
|
|
321
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
114
322
|
};
|
|
115
323
|
}
|
|
116
324
|
emitChange() {
|
|
@@ -134,14 +342,14 @@ class AgentVariantsOrchestrator {
|
|
|
134
342
|
this.emitChange();
|
|
135
343
|
return result;
|
|
136
344
|
}
|
|
137
|
-
|
|
138
|
-
const result = this.store.
|
|
345
|
+
reportSourcePlan(args) {
|
|
346
|
+
const result = this.store.reportSourcePlan(args);
|
|
139
347
|
this.telemetry.trackAgentVariantsSourceContextQuality?.({
|
|
140
348
|
sessionId: args.sessionId,
|
|
141
|
-
sourceUrlCount: args.
|
|
142
|
-
artifactCount: args.sourceContext.sourceFindings.length,
|
|
143
|
-
hasScreenshotReferences: (args.sourceContext.screenshotReferences?.length ?? 0) > 0,
|
|
144
|
-
preserveBrand: args.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
|
|
349
|
+
sourceUrlCount: args.sourcePlan.sourceIntent.sources.length,
|
|
350
|
+
artifactCount: args.sourcePlan.sourceContext.sourceFindings.length,
|
|
351
|
+
hasScreenshotReferences: (args.sourcePlan.sourceContext.screenshotReferences?.length ?? 0) > 0,
|
|
352
|
+
preserveBrand: args.sourcePlan.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
|
|
145
353
|
});
|
|
146
354
|
this.emitChange();
|
|
147
355
|
return result;
|
|
@@ -226,14 +434,20 @@ class AgentVariantsOrchestrator {
|
|
|
226
434
|
if (!preview && port) {
|
|
227
435
|
preview = { kind: 'dev_server', port };
|
|
228
436
|
}
|
|
437
|
+
const qa = resources?.qaResults.get(variant.workItemId);
|
|
229
438
|
const isSucceeded = variant.status === 'succeeded';
|
|
439
|
+
const qaFailed = qa?.status === 'failed';
|
|
230
440
|
const canView = Boolean(preview) || (isSucceeded && Boolean(port));
|
|
231
|
-
const canCommit = isSucceeded;
|
|
441
|
+
const canCommit = isSucceeded && !qaFailed;
|
|
442
|
+
const commitDisabledReason = qaFailed
|
|
443
|
+
? qa?.summary ?? 'Variant failed QA'
|
|
444
|
+
: 'Wait for a successful variant';
|
|
232
445
|
return {
|
|
233
446
|
...variant,
|
|
234
447
|
design: enrichDesignSource(variant.design),
|
|
235
448
|
...(preview ? { preview } : {}),
|
|
236
449
|
port,
|
|
450
|
+
...(qa ? { qa } : {}),
|
|
237
451
|
actions: {
|
|
238
452
|
view: canView
|
|
239
453
|
? { enabled: true }
|
|
@@ -247,7 +461,7 @@ class AgentVariantsOrchestrator {
|
|
|
247
461
|
? { enabled: true }
|
|
248
462
|
: {
|
|
249
463
|
enabled: false,
|
|
250
|
-
reason:
|
|
464
|
+
reason: commitDisabledReason,
|
|
251
465
|
},
|
|
252
466
|
},
|
|
253
467
|
};
|
|
@@ -344,26 +558,40 @@ class AgentVariantsOrchestrator {
|
|
|
344
558
|
* work items via continue_variants(action="request_work").
|
|
345
559
|
*/
|
|
346
560
|
async startUnified(args) {
|
|
347
|
-
const count = args.count ?? 4;
|
|
561
|
+
const count = args.briefs?.length ?? args.count ?? 4;
|
|
348
562
|
const projectContext = args.projectContext ?? { kind: 'existing' };
|
|
563
|
+
const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
|
|
564
|
+
const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
|
|
565
|
+
Boolean(sourceContext?.sourceArtifacts?.length) ||
|
|
566
|
+
Boolean(sourceContext?.sourceIntent) ||
|
|
567
|
+
Boolean(sourceContext?.artifact);
|
|
568
|
+
if (isSourceGrounded) {
|
|
569
|
+
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.');
|
|
570
|
+
}
|
|
349
571
|
const proposeResult = this.propose({
|
|
350
572
|
prompt: args.prompt,
|
|
351
573
|
count,
|
|
352
574
|
target: args.target,
|
|
353
575
|
projectContext,
|
|
354
576
|
});
|
|
355
|
-
if (proposeResult.stage === 'awaiting_source_research') {
|
|
356
|
-
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-research sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
|
|
357
|
-
}
|
|
358
577
|
if (proposeResult.stage !== 'awaiting_briefs' ||
|
|
359
578
|
!proposeResult.briefWorkItem) {
|
|
360
579
|
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `start_variants expected awaiting_briefs from propose, got ${proposeResult.stage}`);
|
|
361
580
|
}
|
|
362
|
-
const briefs =
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
581
|
+
const briefs = args.briefs
|
|
582
|
+
? args.briefs.map((b, i) => ({
|
|
583
|
+
briefId: `v${i + 1}`,
|
|
584
|
+
label: b.label,
|
|
585
|
+
body: b.body,
|
|
586
|
+
...(b.visualReferenceUrl
|
|
587
|
+
? { visualReferenceUrl: b.visualReferenceUrl }
|
|
588
|
+
: {}),
|
|
589
|
+
}))
|
|
590
|
+
: Array.from({ length: count }, (_, i) => ({
|
|
591
|
+
briefId: `v${i + 1}`,
|
|
592
|
+
label: `Variant ${i + 1}`,
|
|
593
|
+
body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
|
|
594
|
+
}));
|
|
367
595
|
this.store.reportBriefs({
|
|
368
596
|
sessionId: proposeResult.sessionId,
|
|
369
597
|
workItemId: proposeResult.briefWorkItem.id,
|
|
@@ -420,14 +648,21 @@ class AgentVariantsOrchestrator {
|
|
|
420
648
|
};
|
|
421
649
|
}
|
|
422
650
|
async reportComplete(args) {
|
|
423
|
-
|
|
651
|
+
// QA gate: for `succeeded` static_preview reports, run preview QA
|
|
652
|
+
// synchronously before recording success in the store. A failed QA
|
|
653
|
+
// verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
|
|
654
|
+
// so the variant never reaches `ready` and `getVariants` can disable
|
|
655
|
+
// its commit action.
|
|
656
|
+
const qaOverride = await this.evaluateQaForReport(args);
|
|
657
|
+
const effectiveArgs = qaOverride?.overrideArgs ?? args;
|
|
658
|
+
const result = this.store.reportComplete(effectiveArgs);
|
|
424
659
|
this.emitChange();
|
|
425
660
|
// Per-variant telemetry on terminal item statuses (skip 'running'
|
|
426
661
|
// heartbeats and the brief work item — only code_gen / scaffold_base
|
|
427
662
|
// matter for the funnel).
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
663
|
+
if (effectiveArgs.status === 'succeeded' ||
|
|
664
|
+
effectiveArgs.status === 'failed' ||
|
|
665
|
+
effectiveArgs.status === 'cancelled') {
|
|
431
666
|
const resources = this.resources.get(args.sessionId);
|
|
432
667
|
const leasedAt = resources?.leasedAt.get(args.workItemId);
|
|
433
668
|
const durationMs = leasedAt !== undefined ? Date.now() - leasedAt : null;
|
|
@@ -440,12 +675,12 @@ class AgentVariantsOrchestrator {
|
|
|
440
675
|
source: 'mcp',
|
|
441
676
|
sessionId: args.sessionId,
|
|
442
677
|
workItemId: args.workItemId,
|
|
443
|
-
status:
|
|
678
|
+
status: effectiveArgs.status,
|
|
444
679
|
attempt: args.attempt,
|
|
445
680
|
durationMs,
|
|
446
681
|
approveToCompleteMs,
|
|
447
|
-
hasError: Boolean(
|
|
448
|
-
errorCode:
|
|
682
|
+
hasError: Boolean(effectiveArgs.error),
|
|
683
|
+
errorCode: effectiveArgs.error?.code ?? null,
|
|
449
684
|
isScaffold: resources?.scaffoldBaseWorkItemId === args.workItemId,
|
|
450
685
|
projectContextKind,
|
|
451
686
|
tokensIn: args.tokensIn ?? null,
|
|
@@ -476,7 +711,7 @@ class AgentVariantsOrchestrator {
|
|
|
476
711
|
});
|
|
477
712
|
}
|
|
478
713
|
}
|
|
479
|
-
if (
|
|
714
|
+
if (effectiveArgs.status === 'succeeded') {
|
|
480
715
|
void this.handleSucceededReport({
|
|
481
716
|
sessionId: args.sessionId,
|
|
482
717
|
workItemId: args.workItemId,
|
|
@@ -506,6 +741,9 @@ class AgentVariantsOrchestrator {
|
|
|
506
741
|
fromStage: stageBefore,
|
|
507
742
|
});
|
|
508
743
|
this.emitChange();
|
|
744
|
+
void this.markPersistedVariantsCancelled(args.sessionId).catch((err) => {
|
|
745
|
+
log.warn(`markPersistedVariantsCancelled failed for ${args.sessionId}`, err);
|
|
746
|
+
});
|
|
509
747
|
void this.teardownSession(args.sessionId, 'cancel').catch((err) => {
|
|
510
748
|
log.error(`teardownSession failed for ${args.sessionId}`, err);
|
|
511
749
|
});
|
|
@@ -533,6 +771,11 @@ class AgentVariantsOrchestrator {
|
|
|
533
771
|
alreadyTerminal: result.alreadyTerminal,
|
|
534
772
|
reason: args.reason ?? null,
|
|
535
773
|
});
|
|
774
|
+
if (!result.alreadyTerminal) {
|
|
775
|
+
void this.markPersistedVariantCancelled(args.sessionId, args.variantId).catch((err) => {
|
|
776
|
+
log.warn(`markPersistedVariantCancelled failed for ${args.sessionId}/${args.variantId}`, err);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
536
779
|
this.emitChange();
|
|
537
780
|
return {
|
|
538
781
|
sessionId: args.sessionId,
|
|
@@ -549,12 +792,67 @@ class AgentVariantsOrchestrator {
|
|
|
549
792
|
* the call returns duplicate=true without re-enqueueing.
|
|
550
793
|
*/
|
|
551
794
|
async commitVariant(args) {
|
|
795
|
+
// Serialize per-session so two overlapping commits can't both run
|
|
796
|
+
// `applyDiffToProject` (or the fresh rename) before the first records
|
|
797
|
+
// its pick. The second caller waits for the first to settle, then runs
|
|
798
|
+
// commitVariantImpl which hits the idempotency check in
|
|
799
|
+
// `getVariantPick` / `recordVariantPick`.
|
|
800
|
+
const prior = this.inflightCommits.get(args.sessionId);
|
|
801
|
+
const run = (async () => {
|
|
802
|
+
if (prior) {
|
|
803
|
+
await prior.catch(() => undefined);
|
|
804
|
+
}
|
|
805
|
+
return this.commitVariantImpl(args);
|
|
806
|
+
})();
|
|
807
|
+
this.inflightCommits.set(args.sessionId, run);
|
|
808
|
+
try {
|
|
809
|
+
return await run;
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
if (this.inflightCommits.get(args.sessionId) === run) {
|
|
813
|
+
this.inflightCommits.delete(args.sessionId);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async commitVariantImpl(args) {
|
|
552
818
|
// Idempotent path — SessionStore.recordVariantPick is the source of truth
|
|
553
819
|
// for which variant the user picked. If the same variant is being
|
|
554
820
|
// committed again, replay the previously stored envelope's metadata
|
|
555
821
|
// without going back through resources (which may have been torn down).
|
|
556
822
|
const existingPick = this.store.getVariantPick(args.sessionId);
|
|
557
823
|
if (existingPick && existingPick.variantId === args.variantId) {
|
|
824
|
+
// Retry-safe history flip. If the first commit attempt enqueued
|
|
825
|
+
// successfully but the history-persist task crashed (it's
|
|
826
|
+
// fire-and-forget — see persistVariantHistoryAtCommit call site),
|
|
827
|
+
// the chosen variant stays at `completed` instead of `committed`
|
|
828
|
+
// on disk. Re-run the flip on every duplicate attempt; the
|
|
829
|
+
// terminal-status guard in `markStatus` makes this safely
|
|
830
|
+
// idempotent.
|
|
831
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
832
|
+
let historyProjectPath;
|
|
833
|
+
if (projectContext.kind === 'fresh') {
|
|
834
|
+
historyProjectPath = projectContext.workspaceRoot;
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
try {
|
|
838
|
+
historyProjectPath =
|
|
839
|
+
(await this.resolveEnv(args.sessionId))?.projectPath;
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
historyProjectPath = undefined;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (historyProjectPath) {
|
|
846
|
+
void this.persistVariantHistoryAtCommit({
|
|
847
|
+
sessionId: args.sessionId,
|
|
848
|
+
chosenVariantId: args.variantId,
|
|
849
|
+
projectPath: historyProjectPath,
|
|
850
|
+
projectKind: projectContext.kind,
|
|
851
|
+
destinationPath: existingPick.destinationPath ?? historyProjectPath,
|
|
852
|
+
}).catch((err) => {
|
|
853
|
+
log.warn(`persistVariantHistoryAtCommit (duplicate retry) failed for session ${args.sessionId}`, err);
|
|
854
|
+
});
|
|
855
|
+
}
|
|
558
856
|
return {
|
|
559
857
|
enqueued: false,
|
|
560
858
|
duplicate: true,
|
|
@@ -573,39 +871,160 @@ class AgentVariantsOrchestrator {
|
|
|
573
871
|
if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
|
|
574
872
|
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
|
|
575
873
|
}
|
|
576
|
-
const env = await this.resolveEnv(args.sessionId);
|
|
577
874
|
const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
|
|
578
875
|
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
579
876
|
let payload;
|
|
580
877
|
let envelopeDestination;
|
|
581
878
|
let changedFilesCount;
|
|
582
879
|
if (projectContext.kind === 'fresh') {
|
|
583
|
-
// Fresh sessions: static preview HTML is the deliverable. Write index.html
|
|
584
|
-
// to the destination directory.
|
|
585
880
|
const destinationPath = projectContext.workspacePath;
|
|
586
881
|
this.assertDestinationAvailable(destinationPath);
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
882
|
+
const freshMode = projectContext.executionPlan?.mode === 'vite_app'
|
|
883
|
+
? 'vite_app'
|
|
884
|
+
: 'static_preview';
|
|
885
|
+
if (freshMode === 'vite_app') {
|
|
886
|
+
// Vite_app: the deliverable is the entire variant worktree, not a
|
|
887
|
+
// single HTML file. When the worktree lives on the same volume as
|
|
888
|
+
// the destination (the common case — provisionFreshWorktrees scaffolds
|
|
889
|
+
// under destinationParent/.rivet-variants/) we can rename it into
|
|
890
|
+
// place. That moves the entire tree — including the materialized
|
|
891
|
+
// node_modules — in O(1) with no copy, and the rename is atomic.
|
|
892
|
+
// For cross-volume cases (user passed a destinationParent on a
|
|
893
|
+
// different mount than the .rivet-variants dir, e.g. tests stubbing
|
|
894
|
+
// workspacePath to a tmp dir), fall back to the recursive-copy +
|
|
895
|
+
// background-install path so the feature still works.
|
|
896
|
+
const record = resources.worktrees.get(args.variantId);
|
|
897
|
+
if (!record) {
|
|
898
|
+
throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
|
|
899
|
+
}
|
|
900
|
+
let usedRename = false;
|
|
901
|
+
let isDependencyInstallRequired = false;
|
|
902
|
+
try {
|
|
903
|
+
if (areSameVolume(record.worktreePath, destinationPath)) {
|
|
904
|
+
// Vite's dev server writes its module cache to `<worktreePath>/.vite/`
|
|
905
|
+
// and recreates that directory on the fly whenever it's missing. If
|
|
906
|
+
// we rename a worktree out from under a running dev server, Vite
|
|
907
|
+
// will materialize an empty `.vite/` shell back at the OLD path —
|
|
908
|
+
// leaving behind orphan stub dirs in `.rivet-variants/` and
|
|
909
|
+
// confusing later cleanup. Stop every variant's dev server first
|
|
910
|
+
// (including the chosen variant, which is about to move) so no
|
|
911
|
+
// process is holding the old paths open during the rename.
|
|
912
|
+
//
|
|
913
|
+
// We stop them eagerly here rather than relying on teardownSession
|
|
914
|
+
// because teardown runs AFTER the rename — too late to prevent the
|
|
915
|
+
// stub-dir race.
|
|
916
|
+
await this.stopAllDevServersForSession(resources);
|
|
917
|
+
// node_modules in fresh variants 1..N is a symlink into variant 0
|
|
918
|
+
// (see runBackgroundInstall). After rename, variant 0's
|
|
919
|
+
// node_modules vanishes during cleanup of the sibling worktrees,
|
|
920
|
+
// leaving the chosen variant with a dangling link. Resolve the
|
|
921
|
+
// symlink to a real directory first so the destination has real
|
|
922
|
+
// deps and the rename is self-contained.
|
|
923
|
+
isDependencyInstallRequired = await resolveNodeModulesSymlink(record.worktreePath);
|
|
924
|
+
await this.renameWorktreeIntoDestination(record.worktreePath, destinationPath);
|
|
925
|
+
usedRename = true;
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
await this.materializeProject(record.worktreePath, destinationPath);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
933
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to materialize vite_app variant to ${destinationPath}: ${message}`);
|
|
934
|
+
}
|
|
935
|
+
changedFilesCount = countWorktreeFiles(destinationPath);
|
|
936
|
+
if (!usedRename) {
|
|
937
|
+
// Cross-volume fallback: materializeProject excludes node_modules
|
|
938
|
+
// so we have to re-install at the destination. Don't await the
|
|
939
|
+
// install — the agent's commit_variant call should return
|
|
940
|
+
// immediately — but chain the dev-server start to it so the iframe
|
|
941
|
+
// recovers without manual intervention once deps are ready.
|
|
942
|
+
void this.installDependencies(destinationPath)
|
|
943
|
+
.then(() => this.startCommittedDevServer({
|
|
944
|
+
resources,
|
|
945
|
+
destinationPath,
|
|
946
|
+
}))
|
|
947
|
+
.catch((err) => {
|
|
948
|
+
log.warn(`Background install + committed dev server start after vite_app commit failed for ${destinationPath}`, err);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
else if (isDependencyInstallRequired) {
|
|
952
|
+
await this.installDependencies(destinationPath);
|
|
953
|
+
}
|
|
954
|
+
if (usedRename) {
|
|
955
|
+
// Preserve the unchosen variants as runnable siblings of the
|
|
956
|
+
// chosen project. Best-effort: failures here log and continue so
|
|
957
|
+
// a partial history never blocks the commit handoff.
|
|
958
|
+
try {
|
|
959
|
+
this.preserveUnchosenVariants({
|
|
960
|
+
sessionId: args.sessionId,
|
|
961
|
+
chosenVariantId: args.variantId,
|
|
962
|
+
destinationPath,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
log.warn(`preserveUnchosenVariants failed for session ${args.sessionId}; continuing without history`, err);
|
|
967
|
+
}
|
|
968
|
+
// Stopped every dev server pre-rename to dodge the .vite/ stub-dir
|
|
969
|
+
// race. The iframe is still pointed at the (now-dead) chosen
|
|
970
|
+
// variant's port via the proxy, so without a fresh server at the
|
|
971
|
+
// destination the user would see "preview disconnected" the moment
|
|
972
|
+
// they commit. Spawn one and retarget the proxy. Best-effort: if
|
|
973
|
+
// it fails, the user can `npm run dev` themselves at destination.
|
|
974
|
+
await this.startCommittedDevServer({
|
|
975
|
+
resources,
|
|
976
|
+
destinationPath,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
payload = {
|
|
980
|
+
kind: 'project-created',
|
|
981
|
+
destinationPath,
|
|
982
|
+
changedFilesCount,
|
|
983
|
+
note: usedRename
|
|
984
|
+
? 'Vite app moved to destinationPath (rename); node_modules came along.'
|
|
985
|
+
: 'Vite app materialized at destinationPath; `npm install` running in the background.',
|
|
986
|
+
};
|
|
987
|
+
envelopeDestination = destinationPath;
|
|
594
988
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
989
|
+
else {
|
|
990
|
+
// Static_preview: HTML is the entire deliverable. Write index.html.
|
|
991
|
+
// Prefer the in-memory record; fall back to the persisted history at
|
|
992
|
+
// `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/files/index.html`
|
|
993
|
+
// so a process restart between report_variant_complete and commit
|
|
994
|
+
// doesn't strand the variant.
|
|
995
|
+
const staticPreview = resources.staticPreviews.get(args.variantId);
|
|
996
|
+
const htmlFromSnapshot = await this.variantHistory.readStaticPreview({
|
|
997
|
+
projectPath: projectContext.workspaceRoot,
|
|
998
|
+
sessionId: args.sessionId,
|
|
999
|
+
variantId: args.variantId,
|
|
1000
|
+
});
|
|
1001
|
+
if (!staticPreview && !htmlFromSnapshot) {
|
|
1002
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
fs_1.default.mkdirSync(destinationPath, { recursive: true });
|
|
1006
|
+
fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1010
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
|
|
1011
|
+
}
|
|
1012
|
+
changedFilesCount = 1;
|
|
1013
|
+
payload = {
|
|
1014
|
+
kind: 'project-created',
|
|
1015
|
+
destinationPath,
|
|
1016
|
+
changedFilesCount,
|
|
1017
|
+
note: 'Static preview written to index.html at destinationPath.',
|
|
1018
|
+
};
|
|
1019
|
+
envelopeDestination = destinationPath;
|
|
598
1020
|
}
|
|
599
|
-
changedFilesCount = 1;
|
|
600
|
-
payload = {
|
|
601
|
-
kind: 'project-created',
|
|
602
|
-
destinationPath,
|
|
603
|
-
changedFilesCount,
|
|
604
|
-
note: 'Static preview written to index.html at destinationPath.',
|
|
605
|
-
};
|
|
606
|
-
envelopeDestination = destinationPath;
|
|
607
1021
|
}
|
|
608
1022
|
else {
|
|
1023
|
+
// Existing project: diff against the user's repo. resolveEnv lives in
|
|
1024
|
+
// this branch only — fresh sessions don't have a user-supplied project
|
|
1025
|
+
// and the bridge.getProjectPath() it reads from would either be stale
|
|
1026
|
+
// or empty for them.
|
|
1027
|
+
const env = await this.resolveEnv(args.sessionId);
|
|
609
1028
|
const record = resources.worktrees.get(args.variantId);
|
|
610
1029
|
if (!record) {
|
|
611
1030
|
throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
|
|
@@ -614,11 +1033,26 @@ class AgentVariantsOrchestrator {
|
|
|
614
1033
|
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
|
|
615
1034
|
}
|
|
616
1035
|
changedFilesCount = countDiffFiles(record.diff);
|
|
1036
|
+
// Apply the diff to the user's working tree right now so the variant
|
|
1037
|
+
// lands on the current branch (uncommitted). Previously this just
|
|
1038
|
+
// enqueued the diff for an agent to apply; non-MCP sessions had no
|
|
1039
|
+
// agent and the variant was effectively lost.
|
|
1040
|
+
if (!this.worktrees.applyDiffToProject) {
|
|
1041
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'WorktreeManager does not support applying diffs to the project tree');
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
await this.worktrees.applyDiffToProject(record.diff, env.projectPath);
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1048
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to apply variant ${args.variantId} to ${env.projectPath}: ${message}`);
|
|
1049
|
+
}
|
|
617
1050
|
payload = {
|
|
618
|
-
kind: 'diff',
|
|
1051
|
+
kind: 'diff-applied',
|
|
619
1052
|
diff: record.diff,
|
|
620
1053
|
target: input.target,
|
|
621
1054
|
changedFilesCount,
|
|
1055
|
+
note: 'Variant diff applied to the user\'s working tree (uncommitted).',
|
|
622
1056
|
};
|
|
623
1057
|
envelopeDestination = env.projectPath;
|
|
624
1058
|
}
|
|
@@ -637,6 +1071,25 @@ class AgentVariantsOrchestrator {
|
|
|
637
1071
|
sessionId: args.sessionId,
|
|
638
1072
|
envelope,
|
|
639
1073
|
});
|
|
1074
|
+
// History: flip every persisted variant in this session to its terminal
|
|
1075
|
+
// status. Variants were persisted at code_gen success time; this only
|
|
1076
|
+
// patches the status (+ destinationPath on the chosen one). For fresh
|
|
1077
|
+
// sessions, history lives at `<workspaceRoot>/.rivet/variants/` — the
|
|
1078
|
+
// user's working dir — so all variants from any session in the workspace
|
|
1079
|
+
// accumulate in one place, not per-subproject. Best-effort, never blocks
|
|
1080
|
+
// the commit.
|
|
1081
|
+
const historyProjectPath = projectContext.kind === 'fresh'
|
|
1082
|
+
? projectContext.workspaceRoot
|
|
1083
|
+
: envelopeDestination;
|
|
1084
|
+
void this.persistVariantHistoryAtCommit({
|
|
1085
|
+
sessionId: args.sessionId,
|
|
1086
|
+
chosenVariantId: args.variantId,
|
|
1087
|
+
projectPath: historyProjectPath,
|
|
1088
|
+
projectKind: projectContext.kind,
|
|
1089
|
+
destinationPath: envelopeDestination,
|
|
1090
|
+
}).catch((err) => {
|
|
1091
|
+
log.warn(`persistVariantHistoryAtCommit failed for session ${args.sessionId}`, err);
|
|
1092
|
+
});
|
|
640
1093
|
const enqueueResult = this.adapter.enqueue(envelope);
|
|
641
1094
|
resources.committedVariantIds.add(args.variantId);
|
|
642
1095
|
if (this.activeSessionId === args.sessionId) {
|
|
@@ -678,14 +1131,20 @@ class AgentVariantsOrchestrator {
|
|
|
678
1131
|
/**
|
|
679
1132
|
* Ensure the user-facing destination path can receive the new project.
|
|
680
1133
|
* Rejects when the path exists and is non-empty.
|
|
1134
|
+
*
|
|
1135
|
+
* `.rivet/` (pre-commit snapshots + history manifests) and `.gitignore`
|
|
1136
|
+
* (written by VariantHistoryService.ensureGitignore when variants persist
|
|
1137
|
+
* at success time) are both tolerated — neither is user-authored content
|
|
1138
|
+
* that would be clobbered by the materialize step.
|
|
681
1139
|
*/
|
|
682
1140
|
assertDestinationAvailable(destinationPath) {
|
|
683
1141
|
if (!fs_1.default.existsSync(destinationPath))
|
|
684
1142
|
return;
|
|
685
1143
|
const entries = fs_1.default.readdirSync(destinationPath);
|
|
686
|
-
|
|
1144
|
+
const userVisibleEntries = entries.filter((entry) => entry !== '.rivet' && entry !== '.gitignore');
|
|
1145
|
+
if (userVisibleEntries.length === 0)
|
|
687
1146
|
return;
|
|
688
|
-
throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${
|
|
1147
|
+
throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
|
|
689
1148
|
}
|
|
690
1149
|
/** Read the captured diff for a code-gen variant, if available. */
|
|
691
1150
|
getVariantDiff(sessionId, variantId) {
|
|
@@ -695,8 +1154,13 @@ class AgentVariantsOrchestrator {
|
|
|
695
1154
|
async provisionWorktrees(sessionId, approveResult) {
|
|
696
1155
|
const projectContext = this.store.getProjectContext(sessionId);
|
|
697
1156
|
if (projectContext.kind === 'fresh') {
|
|
698
|
-
// Fresh
|
|
699
|
-
//
|
|
1157
|
+
// Fresh + static_preview: no worktrees, no scaffold, HTML is the
|
|
1158
|
+
// deliverable. Fresh + vite_app: provision a Vite skeleton per variant
|
|
1159
|
+
// and copy the agent-planned assetPlan files before the agent leases
|
|
1160
|
+
// its code_gen items.
|
|
1161
|
+
if (approveResult.scaffoldBaseWorkItemId) {
|
|
1162
|
+
await this.provisionFreshWorktrees(sessionId, approveResult, projectContext);
|
|
1163
|
+
}
|
|
700
1164
|
return;
|
|
701
1165
|
}
|
|
702
1166
|
await this.provisionExistingWorktrees(sessionId, approveResult);
|
|
@@ -769,8 +1233,19 @@ class AgentVariantsOrchestrator {
|
|
|
769
1233
|
designContext: summarizeDesignContext(designContext),
|
|
770
1234
|
});
|
|
771
1235
|
log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
|
|
772
|
-
|
|
1236
|
+
// destinationParent for fresh worktrees: the user's workspace root
|
|
1237
|
+
// (sibling to `.rivet/`). Keeping the worktree on the same volume as
|
|
1238
|
+
// the materialize destination turns commit into a directory rename
|
|
1239
|
+
// instead of a recursive copy. `path.dirname(workspacePath)` *used*
|
|
1240
|
+
// to equal `workspaceRoot`, but after nesting subprojects under
|
|
1241
|
+
// `<workspaceRoot>/.rivet/<slug>/` the dirname is now `.rivet/`,
|
|
1242
|
+
// which would stage worktrees inside `.rivet/.rivet-variants/`.
|
|
1243
|
+
// Use workspaceRoot directly so staging lives at
|
|
1244
|
+
// `<workspaceRoot>/.rivet-variants/` as originally intended.
|
|
1245
|
+
const destinationParent = projectContext.workspaceRoot;
|
|
1246
|
+
const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
|
|
773
1247
|
resources.scaffoldBaseWorkItemId = scaffoldId;
|
|
1248
|
+
resources.freshDestinationParent = destinationParent;
|
|
774
1249
|
// Each code_gen item maps 1:1 to a fresh worktree. The scaffold_base
|
|
775
1250
|
// work item is internal — no dedicated worktree.
|
|
776
1251
|
for (let i = 0; i < codeGenIds.length; i++) {
|
|
@@ -779,6 +1254,45 @@ class AgentVariantsOrchestrator {
|
|
|
779
1254
|
worktreePath: paths[i],
|
|
780
1255
|
});
|
|
781
1256
|
}
|
|
1257
|
+
// Copy agent-planned assets (e.g. avatar.glb) into each worktree so
|
|
1258
|
+
// generated code can reference them via plan.referenceAs. Done before
|
|
1259
|
+
// the background install so the assets are on disk by the time the
|
|
1260
|
+
// agent leases its code_gen item.
|
|
1261
|
+
// If copy throws (e.g. source asset deleted between planning and
|
|
1262
|
+
// provisioning), fail scaffold_base immediately so dependent code_gen
|
|
1263
|
+
// items cascade DEPENDENCY_FAILED instead of leaving the session
|
|
1264
|
+
// permanently stuck in `work_items_ready`.
|
|
1265
|
+
const assetPlan = projectContext.executionPlan?.assetPlan ?? [];
|
|
1266
|
+
if (assetPlan.length > 0) {
|
|
1267
|
+
try {
|
|
1268
|
+
for (const worktreePath of paths) {
|
|
1269
|
+
for (const entry of assetPlan) {
|
|
1270
|
+
copyAssetIntoWorktree(worktreePath, entry, projectContext.assetSourceRoot);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
catch (err) {
|
|
1275
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1276
|
+
log.warn(`asset copy failed for session ${sessionId}; marking scaffold_base failed`, err);
|
|
1277
|
+
trackScaffoldFailed(this.telemetry, {
|
|
1278
|
+
sessionId,
|
|
1279
|
+
errorCode: 'SCAFFOLD_FAILED',
|
|
1280
|
+
durationMs: Date.now() - scaffoldStartedAt,
|
|
1281
|
+
});
|
|
1282
|
+
try {
|
|
1283
|
+
this.store.failInternal({
|
|
1284
|
+
sessionId,
|
|
1285
|
+
workItemId: scaffoldId,
|
|
1286
|
+
error: { code: 'SCAFFOLD_FAILED', message },
|
|
1287
|
+
});
|
|
1288
|
+
this.emitChange();
|
|
1289
|
+
}
|
|
1290
|
+
catch (storeErr) {
|
|
1291
|
+
log.error(`failInternal failed for ${sessionId}/${scaffoldId}`, storeErr);
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
782
1296
|
// Background install: don't await. The orchestrator finishes the
|
|
783
1297
|
// approve→provision call immediately; the agent polls for scaffold
|
|
784
1298
|
// completion via continue_variants(action='check').
|
|
@@ -794,7 +1308,22 @@ class AgentVariantsOrchestrator {
|
|
|
794
1308
|
async runBackgroundInstall(sessionId, scaffoldWorkItemId, worktreePaths, scaffoldStartedAt) {
|
|
795
1309
|
const installStartedAt = Date.now();
|
|
796
1310
|
try {
|
|
797
|
-
|
|
1311
|
+
const [firstWorktree, ...rest] = worktreePaths;
|
|
1312
|
+
await this.installDependencies(firstWorktree);
|
|
1313
|
+
await Promise.all(rest.map(async (p) => {
|
|
1314
|
+
const target = path_1.default.join(firstWorktree, 'node_modules');
|
|
1315
|
+
const link = path_1.default.join(p, 'node_modules');
|
|
1316
|
+
// The fresh template has no node_modules until install runs, so
|
|
1317
|
+
// the link target is guaranteed absent in `p` — no need to remove
|
|
1318
|
+
// first.
|
|
1319
|
+
try {
|
|
1320
|
+
fs_1.default.symlinkSync(target, link, 'dir');
|
|
1321
|
+
}
|
|
1322
|
+
catch (symlinkErr) {
|
|
1323
|
+
log.warn(`Symlink node_modules failed for ${p}, falling back to install`, symlinkErr);
|
|
1324
|
+
await this.installDependencies(p);
|
|
1325
|
+
}
|
|
1326
|
+
}));
|
|
798
1327
|
}
|
|
799
1328
|
catch (err) {
|
|
800
1329
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -835,6 +1364,65 @@ class AgentVariantsOrchestrator {
|
|
|
835
1364
|
variantCount: worktreePaths.length,
|
|
836
1365
|
});
|
|
837
1366
|
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Gate `succeeded` reports for `static_preview` items through preview QA
|
|
1369
|
+
* before they reach the store. Returns either `null` (caller continues
|
|
1370
|
+
* with the original args — non-static-preview report, non-succeeded
|
|
1371
|
+
* status, no parseable HTML, or QA passed) or an override carrying a
|
|
1372
|
+
* `failed` rewrite of the report. The QA result is also memoized on the
|
|
1373
|
+
* session resources so `getVariants` can surface it.
|
|
1374
|
+
*
|
|
1375
|
+
* Only fresh-project sessions go through this gate today; existing-
|
|
1376
|
+
* project `code_gen` variants produce a diff with no preview document
|
|
1377
|
+
* to inspect, so they skip QA and behave exactly as before.
|
|
1378
|
+
*/
|
|
1379
|
+
async evaluateQaForReport(args) {
|
|
1380
|
+
if (args.status !== 'succeeded')
|
|
1381
|
+
return null;
|
|
1382
|
+
if (!this.store.hasSession(args.sessionId))
|
|
1383
|
+
return null;
|
|
1384
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1385
|
+
if (projectContext.kind !== 'fresh')
|
|
1386
|
+
return null;
|
|
1387
|
+
const parsed = parseStaticPreviewOutput(normalizeOutput(args.output));
|
|
1388
|
+
if (!parsed)
|
|
1389
|
+
return null;
|
|
1390
|
+
let qa;
|
|
1391
|
+
try {
|
|
1392
|
+
qa = await this.previewQaRunner({
|
|
1393
|
+
sessionId: args.sessionId,
|
|
1394
|
+
workItemId: args.workItemId,
|
|
1395
|
+
html: parsed.html,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
catch (err) {
|
|
1399
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1400
|
+
qa = {
|
|
1401
|
+
status: 'failed',
|
|
1402
|
+
issues: [
|
|
1403
|
+
{
|
|
1404
|
+
kind: 'preview_unavailable',
|
|
1405
|
+
detail: 'qa_runner_error',
|
|
1406
|
+
message,
|
|
1407
|
+
},
|
|
1408
|
+
],
|
|
1409
|
+
summary: `Preview QA runner threw: ${message}`,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
const resources = this.ensureResources(args.sessionId);
|
|
1413
|
+
resources.qaResults.set(args.workItemId, qa);
|
|
1414
|
+
if (qa.status === 'failed') {
|
|
1415
|
+
return {
|
|
1416
|
+
qa,
|
|
1417
|
+
overrideArgs: {
|
|
1418
|
+
...args,
|
|
1419
|
+
status: 'failed',
|
|
1420
|
+
error: { code: 'VARIANT_QA_FAILED', message: qa.summary },
|
|
1421
|
+
},
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
return { qa };
|
|
1425
|
+
}
|
|
838
1426
|
async handleSucceededReport(args) {
|
|
839
1427
|
const { sessionId, workItemId } = args;
|
|
840
1428
|
const resources = this.resources.get(sessionId);
|
|
@@ -850,6 +1438,20 @@ class AgentVariantsOrchestrator {
|
|
|
850
1438
|
html: staticPreview.html,
|
|
851
1439
|
};
|
|
852
1440
|
resources.staticPreviews.set(workItemId, record);
|
|
1441
|
+
if (this.store.getProjectContext(sessionId).kind === 'fresh') {
|
|
1442
|
+
// History at `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/`
|
|
1443
|
+
// is the sole on-disk record. The legacy per-subproject snapshot
|
|
1444
|
+
// tree (`<slug>/.rivet/<variantName>/`) is no longer written —
|
|
1445
|
+
// it duplicated this data in a parallel layout and cluttered
|
|
1446
|
+
// `.rivet/` with slug-named directories before the user ever
|
|
1447
|
+
// committed.
|
|
1448
|
+
this.persistCompletedFreshVariant({
|
|
1449
|
+
sessionId,
|
|
1450
|
+
workItemId,
|
|
1451
|
+
}).catch((err) => {
|
|
1452
|
+
log.warn(`persistCompletedFreshVariant failed for ${sessionId}/${workItemId}`, err);
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
853
1455
|
const leasedAt = resources.leasedAt.get(workItemId);
|
|
854
1456
|
trackStaticPreviewCompleted(this.telemetry, {
|
|
855
1457
|
sessionId,
|
|
@@ -881,6 +1483,31 @@ class AgentVariantsOrchestrator {
|
|
|
881
1483
|
catch (err) {
|
|
882
1484
|
log.warn(`getDiff failed for ${record.worktreePath}`, err);
|
|
883
1485
|
}
|
|
1486
|
+
// History: persist every completed variant immediately to
|
|
1487
|
+
// `<projectPath>/.rivet/variants/`. Existing projects pass the captured
|
|
1488
|
+
// diff (or an empty string when capture itself failed — the variant
|
|
1489
|
+
// still succeeded code-gen-wise, and the history row is the only
|
|
1490
|
+
// record the UI has). Fresh-project variants copy their worktree
|
|
1491
|
+
// (vite_app) or the staged HTML (static_preview). Best-effort — a
|
|
1492
|
+
// failure here must never block dev-server startup or the user's pick
|
|
1493
|
+
// flow.
|
|
1494
|
+
if (!isFresh) {
|
|
1495
|
+
this.persistCompletedExistingVariant({
|
|
1496
|
+
sessionId,
|
|
1497
|
+
workItemId,
|
|
1498
|
+
diff: record.diff ?? '',
|
|
1499
|
+
}).catch((err) => {
|
|
1500
|
+
log.warn(`persistCompletedExistingVariant failed for ${sessionId}/${workItemId}`, err);
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
this.persistCompletedFreshVariant({
|
|
1505
|
+
sessionId,
|
|
1506
|
+
workItemId,
|
|
1507
|
+
}).catch((err) => {
|
|
1508
|
+
log.warn(`persistCompletedFreshVariant failed for ${sessionId}/${workItemId}`, err);
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
884
1511
|
// Bring up a dev server in the variant's worktree so the user can cycle
|
|
885
1512
|
// through live variants in the iframe via the chip. Failures here are
|
|
886
1513
|
// logged but non-fatal — the user can still pick by reading the diff.
|
|
@@ -890,6 +1517,19 @@ class AgentVariantsOrchestrator {
|
|
|
890
1517
|
const proc = await this.worktrees.startDevServer(dev.cwd, port, dev.cmd, dev.args, dev.env);
|
|
891
1518
|
record.port = port;
|
|
892
1519
|
record.devServerProcess = proc;
|
|
1520
|
+
// If the dev server dies on its own (crash, OOM, Vite hard-fail),
|
|
1521
|
+
// clear the port so the iframe stops routing the chip onto a dead
|
|
1522
|
+
// socket. Without this the proxy retargets onto an unreachable port
|
|
1523
|
+
// and the chip shows "upstream_unreachable" instead of the accurate
|
|
1524
|
+
// "Preview is unavailable for this variant".
|
|
1525
|
+
proc.once('exit', (code, signal) => {
|
|
1526
|
+
if (record.devServerProcess !== proc)
|
|
1527
|
+
return;
|
|
1528
|
+
record.port = undefined;
|
|
1529
|
+
record.devServerProcess = undefined;
|
|
1530
|
+
log.warn(`Variant ${workItemId} dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}); preview disabled`);
|
|
1531
|
+
this.emitChange();
|
|
1532
|
+
});
|
|
893
1533
|
this.emitChange();
|
|
894
1534
|
trackFreshDevServerStarted(this.telemetry, {
|
|
895
1535
|
sessionId,
|
|
@@ -907,6 +1547,219 @@ class AgentVariantsOrchestrator {
|
|
|
907
1547
|
log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
|
|
908
1548
|
}
|
|
909
1549
|
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Persist a completed existing-project code_gen variant into
|
|
1552
|
+
* `<env.projectPath>/.rivet/variants/<sessionId>/<variantId>/`. Called from
|
|
1553
|
+
* `handleSucceededReport` once the worktree diff has been captured but
|
|
1554
|
+
* before the user picks. Status is `completed` until `commitVariant` or a
|
|
1555
|
+
* cancellation transitions it.
|
|
1556
|
+
*/
|
|
1557
|
+
async persistCompletedExistingVariant(args) {
|
|
1558
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1559
|
+
if (projectContext.kind !== 'existing')
|
|
1560
|
+
return;
|
|
1561
|
+
let projectPath;
|
|
1562
|
+
try {
|
|
1563
|
+
const env = await this.resolveEnv(args.sessionId);
|
|
1564
|
+
projectPath = env.projectPath;
|
|
1565
|
+
}
|
|
1566
|
+
catch (err) {
|
|
1567
|
+
log.warn(`persistCompletedExistingVariant: resolveEnv failed for ${args.sessionId}`, err);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
const input = this.store.getWorkItemInput(args.sessionId, args.workItemId);
|
|
1571
|
+
const sessionPrompt = this.store.getPrompt(args.sessionId);
|
|
1572
|
+
await this.variantHistory.persistVariant({
|
|
1573
|
+
projectPath,
|
|
1574
|
+
sessionId: args.sessionId,
|
|
1575
|
+
variantId: args.workItemId,
|
|
1576
|
+
label: input.briefLabel,
|
|
1577
|
+
brief: input.briefBody,
|
|
1578
|
+
sessionPrompt,
|
|
1579
|
+
kind: 'diff',
|
|
1580
|
+
diff: args.diff,
|
|
1581
|
+
changedFilesCount: countDiffFiles(args.diff),
|
|
1582
|
+
projectKind: 'existing',
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Persist a completed fresh-project variant into
|
|
1587
|
+
* `<projectContext.workspacePath>/.rivet/variants/<sessionId>/<variantId>/`.
|
|
1588
|
+
* Called from `handleSucceededReport` right after the per-variant snapshot
|
|
1589
|
+
* lands on disk — for static_preview that's the `.rivet/<slug>/` snapshot
|
|
1590
|
+
* dir; for vite_app fresh variants it's the worktree itself. Status starts
|
|
1591
|
+
* as 'completed' and transitions to 'committed' / 'rejected' / 'cancelled'
|
|
1592
|
+
* via `markStatus` at commit or teardown time.
|
|
1593
|
+
*
|
|
1594
|
+
* Running at success time (rather than commit time) means the history panel
|
|
1595
|
+
* populates live as variants generate — including when the user never picks
|
|
1596
|
+
* one. Mirrors the existing-project persistCompletedExistingVariant flow.
|
|
1597
|
+
*/
|
|
1598
|
+
async persistCompletedFreshVariant(args) {
|
|
1599
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1600
|
+
if (projectContext.kind !== 'fresh')
|
|
1601
|
+
return;
|
|
1602
|
+
// Variant history lives at the workspace root — the user's working
|
|
1603
|
+
// dir (e.g. `fable-eng-demo/`). All variants from any session in this
|
|
1604
|
+
// workspace accumulate at
|
|
1605
|
+
// `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/`.
|
|
1606
|
+
const historyProjectPath = projectContext.workspaceRoot;
|
|
1607
|
+
const input = this.store.getWorkItemInput(args.sessionId, args.workItemId);
|
|
1608
|
+
const sessionPrompt = this.store.getPrompt(args.sessionId);
|
|
1609
|
+
const designArtifact = resolveDesignArtifact(input.designContextEntry);
|
|
1610
|
+
const resources = this.resources.get(args.sessionId);
|
|
1611
|
+
const worktreeRecord = resources?.worktrees.get(args.workItemId);
|
|
1612
|
+
const staticPreview = resources?.staticPreviews.get(args.workItemId);
|
|
1613
|
+
// Vite_app deliverables are full scaffolded worktrees — pass the worktree
|
|
1614
|
+
// directory as sourceDir and let copyDirFiltered handle it (excludes
|
|
1615
|
+
// node_modules, .rivet, etc). Static_preview deliverables are inline HTML
|
|
1616
|
+
// captured in `resources.staticPreviews`; stage them in a tmp dir so the
|
|
1617
|
+
// existing copy path works without poking the user's workspace.
|
|
1618
|
+
let sourceDir = null;
|
|
1619
|
+
let tmpStagingDir = null;
|
|
1620
|
+
if (worktreeRecord && fs_1.default.existsSync(worktreeRecord.worktreePath)) {
|
|
1621
|
+
sourceDir = worktreeRecord.worktreePath;
|
|
1622
|
+
}
|
|
1623
|
+
else if (staticPreview) {
|
|
1624
|
+
tmpStagingDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), `rivet-variant-${args.workItemId}-`));
|
|
1625
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpStagingDir, 'index.html'), staticPreview.html, 'utf8');
|
|
1626
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpStagingDir, 'brief.md'), `# ${input.briefLabel}\n\n${input.briefBody}\n`, 'utf8');
|
|
1627
|
+
sourceDir = tmpStagingDir;
|
|
1628
|
+
}
|
|
1629
|
+
if (!sourceDir) {
|
|
1630
|
+
log.warn(`persistCompletedFreshVariant: no source for ${args.workItemId} (session ${args.sessionId}); skipping`);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
// For static_preview, only `index.html` is a real deliverable — `brief.md`
|
|
1634
|
+
// is implementation-detail staging that we copy alongside it. Hardcode
|
|
1635
|
+
// the count so the history row matches what `commit_variant` reports
|
|
1636
|
+
// (always 1) instead of double-counting the brief.
|
|
1637
|
+
const changedFilesCount = staticPreview
|
|
1638
|
+
? 1
|
|
1639
|
+
: countWorktreeFiles(sourceDir);
|
|
1640
|
+
try {
|
|
1641
|
+
await this.variantHistory.persistVariant({
|
|
1642
|
+
projectPath: historyProjectPath,
|
|
1643
|
+
sessionId: args.sessionId,
|
|
1644
|
+
variantId: args.workItemId,
|
|
1645
|
+
label: input.briefLabel,
|
|
1646
|
+
brief: input.briefBody,
|
|
1647
|
+
sessionPrompt,
|
|
1648
|
+
kind: 'project-created',
|
|
1649
|
+
sourceDir,
|
|
1650
|
+
changedFilesCount,
|
|
1651
|
+
projectKind: 'fresh',
|
|
1652
|
+
designMarkdown: designArtifact?.markdown,
|
|
1653
|
+
designSource: designArtifact?.source,
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
finally {
|
|
1657
|
+
if (tmpStagingDir) {
|
|
1658
|
+
try {
|
|
1659
|
+
fs_1.default.rmSync(tmpStagingDir, { recursive: true, force: true });
|
|
1660
|
+
}
|
|
1661
|
+
catch (err) {
|
|
1662
|
+
log.warn(`Failed to clean up variant staging dir ${tmpStagingDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* At commit time, flip persisted variant manifests to terminal statuses
|
|
1669
|
+
* ('committed' for the chosen, 'rejected' for the rest). Both existing- and
|
|
1670
|
+
* fresh-project variants have already been persisted at code_gen success
|
|
1671
|
+
* time, so this is a pure status update — no source-dir copy required.
|
|
1672
|
+
* Also records the destination path on the chosen variant so history can
|
|
1673
|
+
* surface "this variant became <path>".
|
|
1674
|
+
*/
|
|
1675
|
+
async persistVariantHistoryAtCommit(args) {
|
|
1676
|
+
const variants = this.store.getVariants(args.sessionId);
|
|
1677
|
+
for (const variant of variants) {
|
|
1678
|
+
if (variant.status !== 'succeeded')
|
|
1679
|
+
continue;
|
|
1680
|
+
const workItemId = variant.workItemId;
|
|
1681
|
+
const isChosen = workItemId === args.chosenVariantId;
|
|
1682
|
+
const status = isChosen
|
|
1683
|
+
? 'committed'
|
|
1684
|
+
: 'rejected';
|
|
1685
|
+
await this.variantHistory.markStatus({
|
|
1686
|
+
projectPath: args.projectPath,
|
|
1687
|
+
sessionId: args.sessionId,
|
|
1688
|
+
variantId: workItemId,
|
|
1689
|
+
status,
|
|
1690
|
+
destinationPath: isChosen ? args.destinationPath : undefined,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Flip persisted variant manifests to status='cancelled' for every variant
|
|
1696
|
+
* in the session that was already snapshotted to disk. Fresh- and existing-
|
|
1697
|
+
* project variants both persist at success time now, so both need their
|
|
1698
|
+
* manifests flipped here. Best-effort — missing manifests are silently
|
|
1699
|
+
* skipped by VariantHistoryService.markStatus.
|
|
1700
|
+
*/
|
|
1701
|
+
async markPersistedVariantsCancelled(sessionId) {
|
|
1702
|
+
if (!this.store.hasSession(sessionId))
|
|
1703
|
+
return;
|
|
1704
|
+
const projectPath = await this.resolveHistoryProjectPath(sessionId);
|
|
1705
|
+
if (!projectPath)
|
|
1706
|
+
return;
|
|
1707
|
+
const variants = this.store.getVariants(sessionId);
|
|
1708
|
+
for (const variant of variants) {
|
|
1709
|
+
await this.variantHistory.markStatus({
|
|
1710
|
+
projectPath,
|
|
1711
|
+
sessionId,
|
|
1712
|
+
variantId: variant.workItemId,
|
|
1713
|
+
status: 'cancelled',
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
async markPersistedVariantCancelled(sessionId, variantId) {
|
|
1718
|
+
if (!this.store.hasSession(sessionId))
|
|
1719
|
+
return;
|
|
1720
|
+
const projectPath = await this.resolveHistoryProjectPath(sessionId);
|
|
1721
|
+
if (!projectPath)
|
|
1722
|
+
return;
|
|
1723
|
+
await this.variantHistory.markStatus({
|
|
1724
|
+
projectPath,
|
|
1725
|
+
sessionId,
|
|
1726
|
+
variantId,
|
|
1727
|
+
status: 'cancelled',
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Resolve the project path that owns `.rivet/variants/` for a session.
|
|
1732
|
+
* Existing sessions: the user's project (via `resolveEnv`). Fresh sessions:
|
|
1733
|
+
* the *workspace root*, which is the parent of `workspacePath` — variants
|
|
1734
|
+
* accumulate there across sessions instead of being scattered under each
|
|
1735
|
+
* subproject. Must match what `persistCompletedFreshVariant` writes to.
|
|
1736
|
+
*/
|
|
1737
|
+
async resolveHistoryProjectPath(sessionId) {
|
|
1738
|
+
const projectContext = this.store.getProjectContext(sessionId);
|
|
1739
|
+
if (projectContext.kind === 'fresh') {
|
|
1740
|
+
return projectContext.workspaceRoot;
|
|
1741
|
+
}
|
|
1742
|
+
try {
|
|
1743
|
+
const env = await this.resolveEnv(sessionId);
|
|
1744
|
+
return env.projectPath;
|
|
1745
|
+
}
|
|
1746
|
+
catch (err) {
|
|
1747
|
+
log.warn(`resolveHistoryProjectPath: resolveEnv failed for ${sessionId}`, err);
|
|
1748
|
+
return null;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
readManifest(manifestPath) {
|
|
1752
|
+
if (!fs_1.default.existsSync(manifestPath)) {
|
|
1753
|
+
return undefined;
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
const raw = fs_1.default.readFileSync(manifestPath, 'utf8');
|
|
1757
|
+
return JSON.parse(raw);
|
|
1758
|
+
}
|
|
1759
|
+
catch {
|
|
1760
|
+
return undefined;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
910
1763
|
/**
|
|
911
1764
|
* Resolve dev server invocation for a worktree. Fresh-project worktrees
|
|
912
1765
|
* always use the Vite template's npm command at the worktree root; existing
|
|
@@ -941,6 +1794,301 @@ class AgentVariantsOrchestrator {
|
|
|
941
1794
|
env: { PORT: String(port) },
|
|
942
1795
|
};
|
|
943
1796
|
}
|
|
1797
|
+
/**
|
|
1798
|
+
* After the chosen variant has been renamed into `destinationPath`, move
|
|
1799
|
+
* each unchosen sibling worktree into a `<destinationParent>/<slug>-variants/NN-<label>/`
|
|
1800
|
+
* folder and repoint its `node_modules` symlink at the chosen project's
|
|
1801
|
+
* real install. Writes a manifest documenting all 5 variants (the chosen
|
|
1802
|
+
* one with `folder: null` and `chosenPath: '../<slug>'`).
|
|
1803
|
+
*
|
|
1804
|
+
* Sets `vitePreservedSiblings = true` on session resources so the
|
|
1805
|
+
* subsequent teardown skips `cleanupFreshWorktrees` (which would otherwise
|
|
1806
|
+
* delete the just-preserved history). Dev servers still get stopped.
|
|
1807
|
+
*/
|
|
1808
|
+
preserveUnchosenVariants(args) {
|
|
1809
|
+
const resources = this.resources.get(args.sessionId);
|
|
1810
|
+
if (!resources)
|
|
1811
|
+
return;
|
|
1812
|
+
const destinationParent = path_1.default.dirname(args.destinationPath);
|
|
1813
|
+
const projectSlug = path_1.default.basename(args.destinationPath);
|
|
1814
|
+
const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
|
|
1815
|
+
const sharedNodeModulesAbs = path_1.default.join(args.destinationPath, 'node_modules');
|
|
1816
|
+
const briefs = this.store.getBriefs(args.sessionId);
|
|
1817
|
+
const variants = this.store.getVariants(args.sessionId);
|
|
1818
|
+
const briefIndexById = new Map(briefs.map((b, i) => [b.briefId, i]));
|
|
1819
|
+
const manifestEntries = [];
|
|
1820
|
+
let chosenSlug = '';
|
|
1821
|
+
for (const variant of variants) {
|
|
1822
|
+
const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
|
|
1823
|
+
const label = input.briefLabel ?? variant.label ?? 'variant';
|
|
1824
|
+
const slug = (0, createProjectArtifacts_1.createProjectVariantSlug)(label);
|
|
1825
|
+
const briefIdx = briefIndexById.get(variant.briefId) ?? 0;
|
|
1826
|
+
const numericPrefix = String(briefIdx + 1).padStart(2, '0');
|
|
1827
|
+
const folderName = `${numericPrefix}-${slug}`;
|
|
1828
|
+
if (variant.workItemId === args.chosenVariantId) {
|
|
1829
|
+
chosenSlug = slug;
|
|
1830
|
+
manifestEntries.push({
|
|
1831
|
+
variantId: variant.workItemId,
|
|
1832
|
+
label,
|
|
1833
|
+
slug,
|
|
1834
|
+
folder: null,
|
|
1835
|
+
isChosen: true,
|
|
1836
|
+
chosenPath: `../${projectSlug}`,
|
|
1837
|
+
});
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
const record = resources.worktrees.get(variant.workItemId);
|
|
1841
|
+
if (!record) {
|
|
1842
|
+
// No worktree (e.g. failed variant) — still list it in manifest with
|
|
1843
|
+
// null folder so the user can see why it's absent.
|
|
1844
|
+
manifestEntries.push({
|
|
1845
|
+
variantId: variant.workItemId,
|
|
1846
|
+
label,
|
|
1847
|
+
slug,
|
|
1848
|
+
folder: null,
|
|
1849
|
+
isChosen: false,
|
|
1850
|
+
});
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
if (!fs_1.default.existsSync(record.worktreePath)) {
|
|
1854
|
+
manifestEntries.push({
|
|
1855
|
+
variantId: variant.workItemId,
|
|
1856
|
+
label,
|
|
1857
|
+
slug,
|
|
1858
|
+
folder: null,
|
|
1859
|
+
isChosen: false,
|
|
1860
|
+
});
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
try {
|
|
1864
|
+
const newPath = this.worktrees.moveWorktreeToHistory
|
|
1865
|
+
? this.worktrees.moveWorktreeToHistory(record.worktreePath, historyDir, folderName)
|
|
1866
|
+
: (() => {
|
|
1867
|
+
fs_1.default.mkdirSync(historyDir, { recursive: true });
|
|
1868
|
+
const dest = path_1.default.join(historyDir, folderName);
|
|
1869
|
+
fs_1.default.renameSync(record.worktreePath, dest);
|
|
1870
|
+
return dest;
|
|
1871
|
+
})();
|
|
1872
|
+
// Repoint node_modules at the chosen project's real install.
|
|
1873
|
+
try {
|
|
1874
|
+
if (this.worktrees.repointNodeModulesSymlink) {
|
|
1875
|
+
this.worktrees.repointNodeModulesSymlink(newPath, sharedNodeModulesAbs);
|
|
1876
|
+
}
|
|
1877
|
+
else {
|
|
1878
|
+
const link = path_1.default.join(newPath, 'node_modules');
|
|
1879
|
+
try {
|
|
1880
|
+
fs_1.default.rmSync(link, { force: true, recursive: true });
|
|
1881
|
+
}
|
|
1882
|
+
catch {
|
|
1883
|
+
/* may not exist */
|
|
1884
|
+
}
|
|
1885
|
+
const target = path_1.default.relative(path_1.default.dirname(link), sharedNodeModulesAbs);
|
|
1886
|
+
fs_1.default.symlinkSync(target, link, 'dir');
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
catch (err) {
|
|
1890
|
+
log.warn(`repointNodeModulesSymlink failed for ${newPath}; variant still runnable after npm install`, err);
|
|
1891
|
+
}
|
|
1892
|
+
// Update the in-memory record so teardown doesn't try to operate on
|
|
1893
|
+
// the stale path.
|
|
1894
|
+
record.worktreePath = newPath;
|
|
1895
|
+
manifestEntries.push({
|
|
1896
|
+
variantId: variant.workItemId,
|
|
1897
|
+
label,
|
|
1898
|
+
slug,
|
|
1899
|
+
folder: folderName,
|
|
1900
|
+
isChosen: false,
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
catch (err) {
|
|
1904
|
+
log.warn(`moveWorktreeToHistory failed for variant ${variant.workItemId}`, err);
|
|
1905
|
+
manifestEntries.push({
|
|
1906
|
+
variantId: variant.workItemId,
|
|
1907
|
+
label,
|
|
1908
|
+
slug,
|
|
1909
|
+
folder: null,
|
|
1910
|
+
isChosen: false,
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
const manifest = {
|
|
1915
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1916
|
+
createdAt: new Date().toISOString(),
|
|
1917
|
+
sessionId: args.sessionId,
|
|
1918
|
+
chosenVariantId: args.chosenVariantId,
|
|
1919
|
+
chosenSlug: chosenSlug || projectSlug,
|
|
1920
|
+
variants: manifestEntries,
|
|
1921
|
+
};
|
|
1922
|
+
try {
|
|
1923
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)((0, createProjectArtifacts_1.createVariantsHistoryManifestPath)(destinationParent, projectSlug), manifest);
|
|
1924
|
+
}
|
|
1925
|
+
catch (err) {
|
|
1926
|
+
log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
|
|
1927
|
+
}
|
|
1928
|
+
resources.vitePreservedSiblings = true;
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Rename `sourceWorktreePath` into `destinationPath`, then replace the
|
|
1932
|
+
* worktree's `.git` with a fresh-history init + initial commit. Matches
|
|
1933
|
+
* the historical materializeProject behavior of "user sees a fresh repo
|
|
1934
|
+
* at destinationPath" so we don't surprise them with a `rivet: variant
|
|
1935
|
+
* baseline` commit they didn't intend.
|
|
1936
|
+
*
|
|
1937
|
+
* The rename is the load-bearing operation: it moves the entire variant
|
|
1938
|
+
* tree (template files + materialized node_modules) in O(1) when the
|
|
1939
|
+
* source and destination share a volume. The git reset that follows is
|
|
1940
|
+
* best-effort housekeeping; a failure there leaves files in place.
|
|
1941
|
+
*/
|
|
1942
|
+
async renameWorktreeIntoDestination(sourceWorktreePath, destinationPath) {
|
|
1943
|
+
// destinationPath must not exist for fs.rename to behave as a move.
|
|
1944
|
+
// assertDestinationAvailable already enforced that any existing directory
|
|
1945
|
+
// contains only `.rivet`, which we preserve across the move.
|
|
1946
|
+
const destinationParent = path_1.default.dirname(destinationPath);
|
|
1947
|
+
await fs_1.default.promises.mkdir(destinationParent, { recursive: true });
|
|
1948
|
+
let preservedRivetParent;
|
|
1949
|
+
const existingRivetPath = path_1.default.join(destinationPath, '.rivet');
|
|
1950
|
+
if (fs_1.default.existsSync(destinationPath)) {
|
|
1951
|
+
preservedRivetParent = await fs_1.default.promises.mkdtemp(path_1.default.join(destinationParent, '.rivet-preserve-'));
|
|
1952
|
+
if (fs_1.default.existsSync(existingRivetPath)) {
|
|
1953
|
+
await fs_1.default.promises.rename(existingRivetPath, path_1.default.join(preservedRivetParent, '.rivet'));
|
|
1954
|
+
}
|
|
1955
|
+
await fs_1.default.promises.rm(destinationPath, { recursive: true, force: true });
|
|
1956
|
+
}
|
|
1957
|
+
try {
|
|
1958
|
+
await fs_1.default.promises.rename(sourceWorktreePath, destinationPath);
|
|
1959
|
+
}
|
|
1960
|
+
catch (err) {
|
|
1961
|
+
if (preservedRivetParent && !fs_1.default.existsSync(destinationPath)) {
|
|
1962
|
+
await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
|
|
1963
|
+
const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
|
|
1964
|
+
if (fs_1.default.existsSync(preservedRivetPath)) {
|
|
1965
|
+
await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
throw err;
|
|
1969
|
+
}
|
|
1970
|
+
if (preservedRivetParent) {
|
|
1971
|
+
const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
|
|
1972
|
+
if (fs_1.default.existsSync(preservedRivetPath)) {
|
|
1973
|
+
await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
|
|
1974
|
+
}
|
|
1975
|
+
await fs_1.default.promises.rm(preservedRivetParent, {
|
|
1976
|
+
recursive: true,
|
|
1977
|
+
force: true,
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
// Replace the variant-baseline git history with a fresh init so the
|
|
1981
|
+
// user doesn't inherit a `rivet: variant baseline` commit.
|
|
1982
|
+
const dotGit = path_1.default.join(destinationPath, '.git');
|
|
1983
|
+
try {
|
|
1984
|
+
fs_1.default.rmSync(dotGit, { recursive: true, force: true });
|
|
1985
|
+
}
|
|
1986
|
+
catch {
|
|
1987
|
+
// Best-effort — leaving the baseline .git is preferable to failing
|
|
1988
|
+
// the commit after we've moved the tree.
|
|
1989
|
+
}
|
|
1990
|
+
try {
|
|
1991
|
+
const git = (0, simple_git_1.simpleGit)(destinationPath);
|
|
1992
|
+
await git.raw(['init']);
|
|
1993
|
+
await git.raw(['add', '-A']);
|
|
1994
|
+
await git
|
|
1995
|
+
.raw([
|
|
1996
|
+
'-c',
|
|
1997
|
+
'user.name=Rivet',
|
|
1998
|
+
'-c',
|
|
1999
|
+
'user.email=hello@tryrivet.design',
|
|
2000
|
+
'commit',
|
|
2001
|
+
'--no-gpg-sign',
|
|
2002
|
+
'-m',
|
|
2003
|
+
'Initial commit (created with Rivet)',
|
|
2004
|
+
])
|
|
2005
|
+
.catch(() => {
|
|
2006
|
+
// Best-effort: missing user identity or empty tree leaves the
|
|
2007
|
+
// commit step un-applied. Files are still in place.
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
catch (err) {
|
|
2011
|
+
log.warn(`Initial git history setup at ${destinationPath} failed`, err);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Stop every variant dev server currently running for `resources`. Clears
|
|
2016
|
+
* the `devServerProcess` / `port` fields on each record so subsequent
|
|
2017
|
+
* teardown logic doesn't try to stop them a second time. Called from
|
|
2018
|
+
* `commitVariant` (vite_app rename path) BEFORE the worktree directory is
|
|
2019
|
+
* renamed — leaving Vite alive across the rename leaks empty `.vite/`
|
|
2020
|
+
* stub dirs at the old paths.
|
|
2021
|
+
*/
|
|
2022
|
+
async stopAllDevServersForSession(resources) {
|
|
2023
|
+
const stops = [];
|
|
2024
|
+
for (const record of resources.worktrees.values()) {
|
|
2025
|
+
if (!record.devServerProcess)
|
|
2026
|
+
continue;
|
|
2027
|
+
const proc = record.devServerProcess;
|
|
2028
|
+
stops.push(this.worktrees
|
|
2029
|
+
.stopDevServer(proc)
|
|
2030
|
+
.catch((err) => log.warn(`stopDevServer pre-commit failed for ${resources.sessionId}/${record.workItemId}`, err)));
|
|
2031
|
+
record.devServerProcess = undefined;
|
|
2032
|
+
record.port = undefined;
|
|
2033
|
+
}
|
|
2034
|
+
await Promise.all(stops);
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Spawn a fresh dev server at the user-facing destinationPath and retarget
|
|
2038
|
+
* the proxy so the iframe stays live across commit. Stores the process on
|
|
2039
|
+
* `resources.committedDevServer` so teardown can hand it off to the
|
|
2040
|
+
* lingering registry instead of killing it. Best-effort: a failure here is
|
|
2041
|
+
* non-fatal — the commit still succeeds; the user just has to run
|
|
2042
|
+
* `npm run dev` themselves to bring the preview back.
|
|
2043
|
+
*/
|
|
2044
|
+
async startCommittedDevServer(args) {
|
|
2045
|
+
try {
|
|
2046
|
+
const port = await this.worktrees.getFreePort();
|
|
2047
|
+
const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
|
|
2048
|
+
'run',
|
|
2049
|
+
'dev',
|
|
2050
|
+
'--',
|
|
2051
|
+
'--port',
|
|
2052
|
+
String(port),
|
|
2053
|
+
'--host',
|
|
2054
|
+
FRESH_DEV_SERVER_HOST,
|
|
2055
|
+
], { PORT: String(port) });
|
|
2056
|
+
args.resources.committedDevServer = {
|
|
2057
|
+
proc,
|
|
2058
|
+
port,
|
|
2059
|
+
path: args.destinationPath,
|
|
2060
|
+
};
|
|
2061
|
+
try {
|
|
2062
|
+
this.switchPreviewPort?.(port);
|
|
2063
|
+
}
|
|
2064
|
+
catch (err) {
|
|
2065
|
+
log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
|
|
2066
|
+
}
|
|
2067
|
+
log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
|
|
2068
|
+
}
|
|
2069
|
+
catch (err) {
|
|
2070
|
+
log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Stop any committed dev servers that were left alive past their session's
|
|
2075
|
+
* teardown. Called at the start of a new `propose` (so the next session's
|
|
2076
|
+
* preview doesn't compete with stale processes) and exposed for explicit
|
|
2077
|
+
* shutdown hooks. Best-effort: per-server failures are logged but don't
|
|
2078
|
+
* propagate.
|
|
2079
|
+
*/
|
|
2080
|
+
async stopLingeringCommittedDevServers() {
|
|
2081
|
+
const entries = [...this.lingeringCommittedDevServers.entries()];
|
|
2082
|
+
this.lingeringCommittedDevServers.clear();
|
|
2083
|
+
await Promise.all(entries.map(async ([sessionId, entry]) => {
|
|
2084
|
+
try {
|
|
2085
|
+
await this.worktrees.stopDevServer(entry.proc);
|
|
2086
|
+
}
|
|
2087
|
+
catch (err) {
|
|
2088
|
+
log.warn(`stopDevServer for lingering committed server (session ${sessionId}, port ${entry.port}) failed`, err);
|
|
2089
|
+
}
|
|
2090
|
+
}));
|
|
2091
|
+
}
|
|
944
2092
|
async teardownSession(sessionId, reason) {
|
|
945
2093
|
const resources = this.resources.get(sessionId);
|
|
946
2094
|
if (!resources)
|
|
@@ -950,17 +2098,46 @@ class AgentVariantsOrchestrator {
|
|
|
950
2098
|
resources.cleanupStarted = true;
|
|
951
2099
|
log.info(`Tearing down session ${sessionId} (reason: ${reason})`);
|
|
952
2100
|
// Stop dev servers in parallel; ignore individual failures.
|
|
2101
|
+
// NOTE: For the fresh vite_app rename path, commitVariant already stopped
|
|
2102
|
+
// these dev servers BEFORE the rename (so Vite couldn't recreate `.vite/`
|
|
2103
|
+
// stub dirs at the old worktree paths). In that case devServerProcess is
|
|
2104
|
+
// already cleared and this loop is a no-op — exactly the desired behavior.
|
|
953
2105
|
const stops = [...resources.worktrees.values()]
|
|
954
2106
|
.filter((r) => r.devServerProcess)
|
|
955
2107
|
.map((r) => this.worktrees
|
|
956
2108
|
.stopDevServer(r.devServerProcess)
|
|
957
2109
|
.catch((err) => log.warn(`stopDevServer failed for ${sessionId}/${r.workItemId}`, err)));
|
|
958
2110
|
await Promise.all(stops);
|
|
959
|
-
|
|
960
|
-
|
|
2111
|
+
// The committed dev server (spawned at destinationPath after a fresh
|
|
2112
|
+
// vite_app commit) intentionally outlives this teardown — the iframe is
|
|
2113
|
+
// pointed at it via the proxy and the user just chose this project. Hand
|
|
2114
|
+
// it off to the lingering registry; it'll be stopped on the next
|
|
2115
|
+
// `propose` or explicit shutdown.
|
|
2116
|
+
if (resources.committedDevServer && reason === 'committed') {
|
|
2117
|
+
this.lingeringCommittedDevServers.set(sessionId, resources.committedDevServer);
|
|
961
2118
|
}
|
|
962
|
-
|
|
963
|
-
|
|
2119
|
+
else if (resources.committedDevServer) {
|
|
2120
|
+
// Non-committed teardown (cancel/shutdown) with a committed dev server
|
|
2121
|
+
// shouldn't happen in current code paths, but stop it defensively so we
|
|
2122
|
+
// don't leak procs.
|
|
2123
|
+
try {
|
|
2124
|
+
await this.worktrees.stopDevServer(resources.committedDevServer.proc);
|
|
2125
|
+
}
|
|
2126
|
+
catch (err) {
|
|
2127
|
+
log.warn(`stopDevServer for committed dev server (session ${sessionId}) failed`, err);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
// When a vite_app commit already moved the unchosen worktrees into a
|
|
2131
|
+
// sibling history dir, calling cleanupSession would re-delete them. Skip
|
|
2132
|
+
// the worktree removal step in that case — the move already cleared the
|
|
2133
|
+
// .rivet-variants/ entries for the chosen + preserved variants.
|
|
2134
|
+
if (!resources.vitePreservedSiblings) {
|
|
2135
|
+
try {
|
|
2136
|
+
await this.worktrees.cleanupSession(sessionId, resources.freshDestinationParent);
|
|
2137
|
+
}
|
|
2138
|
+
catch (err) {
|
|
2139
|
+
log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
|
|
2140
|
+
}
|
|
964
2141
|
}
|
|
965
2142
|
this.resources.delete(sessionId);
|
|
966
2143
|
}
|
|
@@ -975,6 +2152,8 @@ class AgentVariantsOrchestrator {
|
|
|
975
2152
|
committedVariantIds: new Set(),
|
|
976
2153
|
startedAt: Date.now(),
|
|
977
2154
|
leasedAt: new Map(),
|
|
2155
|
+
qaResults: new Map(),
|
|
2156
|
+
vitePreservedSiblings: false,
|
|
978
2157
|
};
|
|
979
2158
|
this.resources.set(sessionId, r);
|
|
980
2159
|
}
|
|
@@ -1026,11 +2205,33 @@ function parseStaticPreviewOutput(output) {
|
|
|
1026
2205
|
};
|
|
1027
2206
|
}
|
|
1028
2207
|
function buildStaticPreviewDocument(input) {
|
|
1029
|
-
if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
|
|
1030
|
-
return input.html;
|
|
1031
|
-
}
|
|
1032
2208
|
const style = input.css ? `<style>\n${input.css}\n</style>` : '';
|
|
1033
2209
|
const script = input.js ? `<script>\n${input.js}\n</script>` : '';
|
|
2210
|
+
if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
|
|
2211
|
+
// Full document: inject css before </head> and js before </body>. The
|
|
2212
|
+
// agent often passes a complete `<!doctype html>...` blob with css/js
|
|
2213
|
+
// alongside; without this they're silently dropped and the variant ships
|
|
2214
|
+
// unstyled and non-interactive. Falls back to appending to the end if
|
|
2215
|
+
// the closing tag isn't found.
|
|
2216
|
+
let doc = input.html;
|
|
2217
|
+
if (style) {
|
|
2218
|
+
if (/<\/head>/i.test(doc)) {
|
|
2219
|
+
doc = doc.replace(/<\/head>/i, () => `${style}\n</head>`);
|
|
2220
|
+
}
|
|
2221
|
+
else {
|
|
2222
|
+
doc += `\n${style}`;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
if (script) {
|
|
2226
|
+
if (/<\/body>/i.test(doc)) {
|
|
2227
|
+
doc = doc.replace(/<\/body>/i, () => `${script}\n</body>`);
|
|
2228
|
+
}
|
|
2229
|
+
else {
|
|
2230
|
+
doc += `\n${script}`;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return doc;
|
|
2234
|
+
}
|
|
1034
2235
|
return `<!doctype html>
|
|
1035
2236
|
<html lang="en">
|
|
1036
2237
|
<head>
|
|
@@ -1048,6 +2249,35 @@ function countDiffFiles(diff) {
|
|
|
1048
2249
|
// Each file in a unified diff starts with "diff --git ".
|
|
1049
2250
|
return (diff.match(/^diff --git /gm) ?? []).length;
|
|
1050
2251
|
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Count source files under a materialized fresh-project destination,
|
|
2254
|
+
* skipping install artifacts (`node_modules`, `.git`, build outputs) so the
|
|
2255
|
+
* commit telemetry / chip count reflects the agent-visible surface area
|
|
2256
|
+
* rather than the install footprint.
|
|
2257
|
+
*/
|
|
2258
|
+
function countWorktreeFiles(destinationPath) {
|
|
2259
|
+
let count = 0;
|
|
2260
|
+
const walk = (dir) => {
|
|
2261
|
+
let entries;
|
|
2262
|
+
try {
|
|
2263
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
2264
|
+
}
|
|
2265
|
+
catch {
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
for (const entry of entries) {
|
|
2269
|
+
if (MATERIALIZE_EXCLUDE.has(entry.name))
|
|
2270
|
+
continue;
|
|
2271
|
+
const full = path_1.default.join(dir, entry.name);
|
|
2272
|
+
if (entry.isDirectory())
|
|
2273
|
+
walk(full);
|
|
2274
|
+
else if (entry.isFile())
|
|
2275
|
+
count += 1;
|
|
2276
|
+
}
|
|
2277
|
+
};
|
|
2278
|
+
walk(destinationPath);
|
|
2279
|
+
return count;
|
|
2280
|
+
}
|
|
1051
2281
|
const enrichDesignSource = (design) => {
|
|
1052
2282
|
if (!design || design.kind !== 'slug')
|
|
1053
2283
|
return design;
|
|
@@ -1063,6 +2293,7 @@ const toActiveProjectContext = (projectContext) => {
|
|
|
1063
2293
|
return {
|
|
1064
2294
|
kind: 'fresh',
|
|
1065
2295
|
workspacePath: projectContext.workspacePath,
|
|
2296
|
+
workspaceRoot: projectContext.workspaceRoot,
|
|
1066
2297
|
framework: projectContext.framework,
|
|
1067
2298
|
designContext: projectContext.designContext?.map((entry) => entry.kind === 'slug'
|
|
1068
2299
|
? { kind: 'slug', slug: entry.slug }
|
|
@@ -1075,6 +2306,122 @@ const toActiveProjectContext = (projectContext) => {
|
|
|
1075
2306
|
},
|
|
1076
2307
|
}
|
|
1077
2308
|
: {}),
|
|
2309
|
+
...(projectContext.executionPlan
|
|
2310
|
+
? {
|
|
2311
|
+
executionPlan: {
|
|
2312
|
+
mode: projectContext.executionPlan.mode,
|
|
2313
|
+
confidence: projectContext.executionPlan.confidence,
|
|
2314
|
+
reason: projectContext.executionPlan.reason,
|
|
2315
|
+
assetCount: projectContext.executionPlan.assetPlan?.length ?? 0,
|
|
2316
|
+
runtimeRequirementCount: projectContext.executionPlan.runtimeRequirements?.length ?? 0,
|
|
2317
|
+
},
|
|
2318
|
+
}
|
|
2319
|
+
: {}),
|
|
2320
|
+
};
|
|
2321
|
+
};
|
|
2322
|
+
/**
|
|
2323
|
+
* Resolve the user-facing supporting artifacts for a session.
|
|
2324
|
+
*
|
|
2325
|
+
* For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
|
|
2326
|
+
* turned into a `design_context` artifact carrying the full DESIGN.md
|
|
2327
|
+
* markdown:
|
|
2328
|
+
* - `slug` entries resolve bundled catalog markdown via the design catalog.
|
|
2329
|
+
* - `markdown` entries (Agent Browser / inspiration extractor output) carry
|
|
2330
|
+
* their stored markdown verbatim.
|
|
2331
|
+
* Slots whose markdown can't be resolved are skipped so the UI never renders
|
|
2332
|
+
* a metadata-only DESIGN.md row.
|
|
2333
|
+
*/
|
|
2334
|
+
const buildSessionArtifacts = (projectContext) => {
|
|
2335
|
+
if (projectContext.kind !== 'fresh')
|
|
2336
|
+
return [];
|
|
2337
|
+
const designContext = projectContext.designContext;
|
|
2338
|
+
if (!designContext || designContext.length === 0)
|
|
2339
|
+
return [];
|
|
2340
|
+
const artifactsByContent = new Map();
|
|
2341
|
+
designContext.forEach((entry, slot) => {
|
|
2342
|
+
if (!entry)
|
|
2343
|
+
return;
|
|
2344
|
+
if (entry.kind === 'slug') {
|
|
2345
|
+
const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
|
|
2346
|
+
if (!markdown)
|
|
2347
|
+
return;
|
|
2348
|
+
const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
|
|
2349
|
+
addDesignContextArtifact(artifactsByContent, {
|
|
2350
|
+
id: `design_context:${slot}:${entry.slug}`,
|
|
2351
|
+
kind: 'design_context',
|
|
2352
|
+
label: catalogEntry?.name ?? entry.slug,
|
|
2353
|
+
...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
|
|
2354
|
+
status: 'ready',
|
|
2355
|
+
source: 'static',
|
|
2356
|
+
contentType: 'text/markdown',
|
|
2357
|
+
content: markdown,
|
|
2358
|
+
});
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
if (!entry.content)
|
|
2362
|
+
return;
|
|
2363
|
+
addDesignContextArtifact(artifactsByContent, {
|
|
2364
|
+
id: `design_context:${slot}:${entry.label}`,
|
|
2365
|
+
kind: 'design_context',
|
|
2366
|
+
label: entry.label,
|
|
2367
|
+
status: 'ready',
|
|
2368
|
+
source: 'agent_browser',
|
|
2369
|
+
contentType: 'text/markdown',
|
|
2370
|
+
content: entry.content,
|
|
2371
|
+
});
|
|
2372
|
+
});
|
|
2373
|
+
return [...artifactsByContent.values()].map((artifact) => {
|
|
2374
|
+
if (artifact.usedByVariantCount <= 1)
|
|
2375
|
+
return artifact;
|
|
2376
|
+
return {
|
|
2377
|
+
...artifact,
|
|
2378
|
+
summary: artifact.summary
|
|
2379
|
+
? `${artifact.summary} Used by ${artifact.usedByVariantCount} variants.`
|
|
2380
|
+
: `Used by ${artifact.usedByVariantCount} variants.`,
|
|
2381
|
+
};
|
|
2382
|
+
});
|
|
2383
|
+
};
|
|
2384
|
+
const addDesignContextArtifact = (artifactsByContent, artifact) => {
|
|
2385
|
+
if (!artifact.content)
|
|
2386
|
+
return;
|
|
2387
|
+
const existing = artifactsByContent.get(artifact.content);
|
|
2388
|
+
if (existing) {
|
|
2389
|
+
existing.usedByVariantCount += 1;
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
artifactsByContent.set(artifact.content, {
|
|
2393
|
+
...artifact,
|
|
2394
|
+
usedByVariantCount: 1,
|
|
2395
|
+
});
|
|
2396
|
+
};
|
|
2397
|
+
/**
|
|
2398
|
+
* Resolve a per-variant design context entry into the raw DESIGN.md markdown
|
|
2399
|
+
* the worktree scaffolder writes, plus a small `designSource` descriptor for
|
|
2400
|
+
* the variant manifest. Slug entries resolve through the bundled catalog;
|
|
2401
|
+
* markdown entries (Agent Browser / inspiration extractor output) carry their
|
|
2402
|
+
* stored markdown verbatim. Returns null when the entry is missing or the
|
|
2403
|
+
* slug doesn't resolve to bundled markdown.
|
|
2404
|
+
*/
|
|
2405
|
+
const resolveDesignArtifact = (entry) => {
|
|
2406
|
+
if (!entry)
|
|
2407
|
+
return null;
|
|
2408
|
+
if (entry.kind === 'markdown') {
|
|
2409
|
+
return {
|
|
2410
|
+
markdown: entry.content,
|
|
2411
|
+
source: { kind: 'markdown', label: entry.label },
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
|
|
2415
|
+
if (!markdown)
|
|
2416
|
+
return null;
|
|
2417
|
+
const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
|
|
2418
|
+
return {
|
|
2419
|
+
markdown,
|
|
2420
|
+
source: {
|
|
2421
|
+
kind: 'slug',
|
|
2422
|
+
slug: entry.slug,
|
|
2423
|
+
label: catalogEntry?.name ?? entry.slug,
|
|
2424
|
+
},
|
|
1078
2425
|
};
|
|
1079
2426
|
};
|
|
1080
2427
|
const summarizeDesignContext = (designContext) => {
|
|
@@ -1196,6 +2543,87 @@ const defaultInstallDependencies = (worktreePath) => {
|
|
|
1196
2543
|
});
|
|
1197
2544
|
});
|
|
1198
2545
|
};
|
|
2546
|
+
/**
|
|
2547
|
+
* True when `a` and `b` (or the closest existing ancestor of each) live on
|
|
2548
|
+
* the same filesystem volume. Used by commitVariant to decide between an
|
|
2549
|
+
* O(1) rename and a recursive copy-with-reinstall fallback.
|
|
2550
|
+
*
|
|
2551
|
+
* `destinationPath` for fresh sessions does not exist yet (we'd fail the
|
|
2552
|
+
* "destination must be empty" guard otherwise), so we compare against its
|
|
2553
|
+
* parent directory which is guaranteed to exist (createZeroToOneTool either
|
|
2554
|
+
* created it or validated it).
|
|
2555
|
+
*/
|
|
2556
|
+
function areSameVolume(a, b) {
|
|
2557
|
+
try {
|
|
2558
|
+
const statA = fs_1.default.statSync(a);
|
|
2559
|
+
const parentB = fs_1.default.existsSync(b) ? b : path_1.default.dirname(b);
|
|
2560
|
+
const statB = fs_1.default.statSync(parentB);
|
|
2561
|
+
return statA.dev === statB.dev;
|
|
2562
|
+
}
|
|
2563
|
+
catch {
|
|
2564
|
+
return false;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Materialize node_modules from symlink → real directory at the given path,
|
|
2569
|
+
* so a subsequent rename of that path leaves a self-contained tree.
|
|
2570
|
+
*
|
|
2571
|
+
* Fresh worktrees 1..N share variant 0's node_modules via a symlink (see
|
|
2572
|
+
* runBackgroundInstall). When the user picks variant K and we rename it
|
|
2573
|
+
* into the destination, the sibling worktrees get torn down — which would
|
|
2574
|
+
* otherwise leave a dangling symlink in the destination if variant K was
|
|
2575
|
+
* the one with the symlink (K > 0).
|
|
2576
|
+
*
|
|
2577
|
+
* Returns true when the destination must run a dependency install after the
|
|
2578
|
+
* rename because node_modules is missing or points nowhere.
|
|
2579
|
+
*/
|
|
2580
|
+
async function resolveNodeModulesSymlink(worktreePath) {
|
|
2581
|
+
const nodeModulesPath = path_1.default.join(worktreePath, 'node_modules');
|
|
2582
|
+
let lstat;
|
|
2583
|
+
try {
|
|
2584
|
+
lstat = await fs_1.default.promises.lstat(nodeModulesPath);
|
|
2585
|
+
}
|
|
2586
|
+
catch {
|
|
2587
|
+
return true;
|
|
2588
|
+
}
|
|
2589
|
+
if (!lstat.isSymbolicLink())
|
|
2590
|
+
return false;
|
|
2591
|
+
let target;
|
|
2592
|
+
try {
|
|
2593
|
+
target = await fs_1.default.promises.realpath(nodeModulesPath);
|
|
2594
|
+
}
|
|
2595
|
+
catch {
|
|
2596
|
+
await fs_1.default.promises.rm(nodeModulesPath, { force: true });
|
|
2597
|
+
return true;
|
|
2598
|
+
}
|
|
2599
|
+
// Stage the copy under a sibling temp name so a mid-copy failure (disk
|
|
2600
|
+
// full, EACCES, etc.) leaves the original symlink intact rather than
|
|
2601
|
+
// wiping it and leaving the worktree with no node_modules at all.
|
|
2602
|
+
// The final swap is then atomic on the same filesystem (rename + unlink).
|
|
2603
|
+
const stagingPath = `${nodeModulesPath}.materializing-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
2604
|
+
try {
|
|
2605
|
+
await fs_1.default.promises.cp(target, stagingPath, {
|
|
2606
|
+
recursive: true,
|
|
2607
|
+
dereference: false,
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
catch (err) {
|
|
2611
|
+
// Best-effort cleanup of the partial staging dir; leave the symlink
|
|
2612
|
+
// intact so the caller can re-run materialization.
|
|
2613
|
+
await fs_1.default.promises
|
|
2614
|
+
.rm(stagingPath, { recursive: true, force: true })
|
|
2615
|
+
.catch(() => { });
|
|
2616
|
+
throw err;
|
|
2617
|
+
}
|
|
2618
|
+
// Swap: remove the symlink, then rename the staging dir into place.
|
|
2619
|
+
// Both operations are local fs ops on the same parent — fast and
|
|
2620
|
+
// unlikely to fail independently, but if the rename fails the symlink
|
|
2621
|
+
// is already gone, so prefer to leave the staging dir on disk over a
|
|
2622
|
+
// half-broken worktree (the caller will surface the throw).
|
|
2623
|
+
await fs_1.default.promises.rm(nodeModulesPath, { force: true });
|
|
2624
|
+
await fs_1.default.promises.rename(stagingPath, nodeModulesPath);
|
|
2625
|
+
return false;
|
|
2626
|
+
}
|
|
1199
2627
|
const defaultMaterializeProject = async (sourceWorktreePath, destinationPath) => {
|
|
1200
2628
|
await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
|
|
1201
2629
|
await fs_1.default.promises.cp(sourceWorktreePath, destinationPath, {
|