rivet-design 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp/agent-variants/SessionStore.d.ts +25 -10
- package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
- package/dist/mcp/agent-variants/SessionStore.js +186 -44
- package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +146 -13
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1223 -58
- 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 +1470 -64
- package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
- package/dist/mcp/agent-variants/contracts.js +173 -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 +60 -24
- package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
- package/dist/mcp/agent-variants/createZeroToOneTool.js +38 -120
- 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 +23 -1
- package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
- package/dist/mcp/agent-variants/tools.js +144 -27
- package/dist/mcp/agent-variants/tools.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +25 -6
- package/dist/mcp/server.js.map +1 -1
- package/dist/prompts/agentModPrompts.js +4 -4
- package/dist/prompts/agentModPrompts.js.map +1 -1
- package/dist/routes/agentVariants.d.ts.map +1 -1
- package/dist/routes/agentVariants.js +8 -1
- package/dist/routes/agentVariants.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -3
- package/dist/server.js.map +1 -1
- package/dist/services/ProjectDetectionService.d.ts.map +1 -1
- package/dist/services/ProjectDetectionService.js +9 -0
- package/dist/services/ProjectDetectionService.js.map +1 -1
- package/dist/services/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/AgentModService.js +6 -6
- 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 +3 -4
- package/src/ui/dist/assets/{main-DejhsBWR.js → main-CpX7fB64.js} +113 -113
- package/src/ui/dist/assets/main-Qqe2_oMT.css +1 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/main-C9jfEp80.css +0 -1
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AgentVariantsOrchestrator = void 0;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
7
8
|
const events_1 = require("events");
|
|
8
9
|
const fs_1 = __importDefault(require("fs"));
|
|
9
10
|
const path_1 = __importDefault(require("path"));
|
|
@@ -11,11 +12,180 @@ const child_process_1 = require("child_process");
|
|
|
11
12
|
const simple_git_1 = require("simple-git");
|
|
12
13
|
const logger_1 = require("../../utils/logger");
|
|
13
14
|
const errors_1 = require("./errors");
|
|
15
|
+
const createProjectArtifacts_1 = require("./createProjectArtifacts");
|
|
14
16
|
const contracts_1 = require("./contracts");
|
|
15
17
|
const viteReactTs_1 = require("../../services/templates/viteReactTs");
|
|
16
18
|
const designCatalog_1 = require("../../services/templates/designCatalog");
|
|
19
|
+
const previewQa_1 = require("./previewQa");
|
|
17
20
|
const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
|
|
18
21
|
const FRESH_DEV_SERVER_HOST = '127.0.0.1';
|
|
22
|
+
/**
|
|
23
|
+
* Allowlist of asset file extensions an agent-planned source may have.
|
|
24
|
+
* `assetPlan` is sized for large local *assets* (3D models, images,
|
|
25
|
+
* audio, video, fonts) — never code, dotfiles, secrets, or arbitrary
|
|
26
|
+
* configs. The check is defense-in-depth alongside the path / symlink
|
|
27
|
+
* filters in `copyAssetIntoWorktree`; without it, a prompt-injected
|
|
28
|
+
* source plan could copy readable host files such as SSH keys or
|
|
29
|
+
* project secrets into the generated app.
|
|
30
|
+
*
|
|
31
|
+
* Intentionally excludes `.json`, `.txt`, `.md`, `.csv`: these are
|
|
32
|
+
* common carriers for credentials (`credentials.json`, `.aws/config`,
|
|
33
|
+
* shell history `.txt`, README-shaped secrets) and have no legitimate
|
|
34
|
+
* use as binary preview assets. If a future flow needs structured data
|
|
35
|
+
* it should ship it through the manifest instead of file copy.
|
|
36
|
+
*/
|
|
37
|
+
const ALLOWED_ASSET_EXTENSIONS = new Set([
|
|
38
|
+
// 3D / models
|
|
39
|
+
'.glb', '.gltf', '.obj', '.fbx', '.usdz',
|
|
40
|
+
// images
|
|
41
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
|
|
42
|
+
// video
|
|
43
|
+
'.mp4', '.webm', '.mov',
|
|
44
|
+
// audio
|
|
45
|
+
'.mp3', '.wav', '.ogg', '.m4a',
|
|
46
|
+
// fonts
|
|
47
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
48
|
+
// PDFs
|
|
49
|
+
'.pdf',
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Reject paths that traverse through a "sensitive" directory anywhere
|
|
53
|
+
* in their ancestry — `.ssh`, `.aws`, `.config`, `.gnupg`, etc. Even if
|
|
54
|
+
* a copy passes the extension allowlist, an agent-controlled source
|
|
55
|
+
* resolving inside `~/.ssh/` is almost certainly an exfiltration
|
|
56
|
+
* attempt rather than a legitimate asset reference.
|
|
57
|
+
*/
|
|
58
|
+
const SENSITIVE_PATH_SEGMENTS = new Set([
|
|
59
|
+
'.ssh',
|
|
60
|
+
'.aws',
|
|
61
|
+
'.gnupg',
|
|
62
|
+
'.kube',
|
|
63
|
+
'.docker',
|
|
64
|
+
'.config',
|
|
65
|
+
'.npm',
|
|
66
|
+
'.cargo',
|
|
67
|
+
'.gem',
|
|
68
|
+
'.netrc',
|
|
69
|
+
'private',
|
|
70
|
+
'secrets',
|
|
71
|
+
'credentials',
|
|
72
|
+
]);
|
|
73
|
+
function hasSensitivePathSegment(absolutePath) {
|
|
74
|
+
// Split on any path separator; check each segment case-insensitively.
|
|
75
|
+
const segments = absolutePath.split(/[/\\]/).filter(Boolean);
|
|
76
|
+
return segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Returns true when `absolutePath` is the root itself or one of its
|
|
80
|
+
* descendants after both paths have been resolved.
|
|
81
|
+
*/
|
|
82
|
+
function isPathWithinRoot(absolutePath, root) {
|
|
83
|
+
const relative = path_1.default.relative(root, absolutePath);
|
|
84
|
+
return (relative === '' ||
|
|
85
|
+
(relative.length > 0 &&
|
|
86
|
+
!relative.startsWith('..') &&
|
|
87
|
+
!path_1.default.isAbsolute(relative)));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Copy one agent-planned asset file into a variant worktree at the
|
|
91
|
+
* project-relative `destination`. The agent decides routing
|
|
92
|
+
* (`destination` like `public/assets/avatar.glb`) so all variants share
|
|
93
|
+
* a consistent layout. Fails loudly when the source is missing or the
|
|
94
|
+
* destination tries to escape the worktree — silent fallbacks here
|
|
95
|
+
* would re-introduce the broken-asset failure mode this routing is
|
|
96
|
+
* supposed to prevent.
|
|
97
|
+
*
|
|
98
|
+
* Security: `entry.source` comes from `report_source_plan` and is
|
|
99
|
+
* agent-controlled. To prevent a prompt-injected source plan from
|
|
100
|
+
* exfiltrating arbitrary readable host files (SSH keys, dotfiles,
|
|
101
|
+
* cloud credentials, project secrets) into the generated app, the
|
|
102
|
+
* source must (1) be an absolute path that resolves via realpath
|
|
103
|
+
* (no traversal, no symlinks pointing outside the resolved location),
|
|
104
|
+
* (2) be a regular file (not a device, socket, fifo, or directory),
|
|
105
|
+
* (3) live under the approved asset root, (4) have an extension on the
|
|
106
|
+
* asset allowlist.
|
|
107
|
+
*/
|
|
108
|
+
function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
|
|
109
|
+
if (!assetSourceRoot) {
|
|
110
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source requires an approved asset root before copying '${entry.source}'`);
|
|
111
|
+
}
|
|
112
|
+
if (!path_1.default.isAbsolute(assetSourceRoot)) {
|
|
113
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot must be an absolute path, got '${assetSourceRoot}'`);
|
|
114
|
+
}
|
|
115
|
+
if (!path_1.default.isAbsolute(entry.source)) {
|
|
116
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be an absolute path, got '${entry.source}'`);
|
|
117
|
+
}
|
|
118
|
+
const ext = path_1.default.extname(entry.source).toLowerCase();
|
|
119
|
+
if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
|
|
120
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source extension '${ext || '(none)'}' is not on the allowlist (got '${entry.source}'). Only inert media/font/document assets may be copied.`);
|
|
121
|
+
}
|
|
122
|
+
// lstat first: a symlink whose target lies outside the source path
|
|
123
|
+
// could otherwise smuggle in arbitrary files. Reject symlinks outright;
|
|
124
|
+
// realpath would resolve them but the user-visible intent of an
|
|
125
|
+
// assetPlan entry is to name a concrete file.
|
|
126
|
+
let lstat;
|
|
127
|
+
try {
|
|
128
|
+
lstat = fs_1.default.lstatSync(entry.source);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source not found on disk: ${entry.source}`);
|
|
132
|
+
}
|
|
133
|
+
if (lstat.isSymbolicLink()) {
|
|
134
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must not be a symlink: ${entry.source}`);
|
|
135
|
+
}
|
|
136
|
+
if (!lstat.isFile()) {
|
|
137
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be a regular file: ${entry.source}`);
|
|
138
|
+
}
|
|
139
|
+
// Resolve realpath as additional defense: even with no symlinks at the
|
|
140
|
+
// leaf, an intermediate directory could be a symlink. Cross-check the
|
|
141
|
+
// file kind on the resolved path.
|
|
142
|
+
let resolvedSource;
|
|
143
|
+
try {
|
|
144
|
+
resolvedSource = fs_1.default.realpathSync(entry.source);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source could not be resolved: ${entry.source}`);
|
|
148
|
+
}
|
|
149
|
+
let resolvedAssetSourceRoot;
|
|
150
|
+
try {
|
|
151
|
+
resolvedAssetSourceRoot = fs_1.default.realpathSync(assetSourceRoot);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot could not be resolved: ${assetSourceRoot}`);
|
|
155
|
+
}
|
|
156
|
+
const resolvedStat = fs_1.default.statSync(resolvedSource);
|
|
157
|
+
if (!resolvedStat.isFile()) {
|
|
158
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved target must be a regular file: ${entry.source}`);
|
|
159
|
+
}
|
|
160
|
+
// Symlinked parent directory defense: even though we rejected a symlink
|
|
161
|
+
// leaf and confirmed the resolved file is regular, an intermediate dir
|
|
162
|
+
// could have been a symlink that quietly forwards into a sensitive
|
|
163
|
+
// ancestor (e.g. `<asset-root>/avatar -> ~/.ssh`). Cross-check that
|
|
164
|
+
// NEITHER the user-supplied path NOR its realpath traverses a known
|
|
165
|
+
// sensitive segment such as `.ssh`, `.aws`, `credentials`, etc. Also
|
|
166
|
+
// re-verify the extension on the resolved path so a `.glb` symlink
|
|
167
|
+
// chain cannot smuggle in a `.json` realpath.
|
|
168
|
+
if (hasSensitivePathSegment(entry.source) ||
|
|
169
|
+
hasSensitivePathSegment(resolvedSource)) {
|
|
170
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source traverses a sensitive directory and is refused: ${entry.source}`);
|
|
171
|
+
}
|
|
172
|
+
if (!isPathWithinRoot(resolvedSource, resolvedAssetSourceRoot)) {
|
|
173
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must stay inside the approved asset root: ${entry.source}`);
|
|
174
|
+
}
|
|
175
|
+
const resolvedExt = path_1.default.extname(resolvedSource).toLowerCase();
|
|
176
|
+
if (!ALLOWED_ASSET_EXTENSIONS.has(resolvedExt)) {
|
|
177
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
|
|
178
|
+
}
|
|
179
|
+
const normalizedDest = path_1.default.normalize(entry.destination);
|
|
180
|
+
if (normalizedDest.startsWith('..') ||
|
|
181
|
+
path_1.default.isAbsolute(normalizedDest)) {
|
|
182
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
|
|
183
|
+
}
|
|
184
|
+
const absDest = path_1.default.join(worktreePath, normalizedDest);
|
|
185
|
+
fs_1.default.mkdirSync(path_1.default.dirname(absDest), { recursive: true });
|
|
186
|
+
fs_1.default.copyFileSync(resolvedSource, absDest);
|
|
187
|
+
}
|
|
188
|
+
const defaultPreviewQaRunner = ({ html }) => (0, previewQa_1.runPreviewQa)({ html });
|
|
19
189
|
const NOOP_TELEMETRY = { track: () => undefined };
|
|
20
190
|
/**
|
|
21
191
|
* Wraps SessionStore for the operations that have side effects: approve
|
|
@@ -37,7 +207,29 @@ class AgentVariantsOrchestrator {
|
|
|
37
207
|
telemetry;
|
|
38
208
|
installDependencies;
|
|
39
209
|
materializeProject;
|
|
210
|
+
previewQaRunner;
|
|
211
|
+
switchPreviewPort;
|
|
40
212
|
resources = new Map();
|
|
213
|
+
/**
|
|
214
|
+
* Committed dev servers from prior sessions that survived teardown. The
|
|
215
|
+
* orchestrator outlives any single session — once a new session starts, the
|
|
216
|
+
* prior committed dev server is no longer needed (the next session opens
|
|
217
|
+
* its own preview chip), so stop it before the new session's first preview
|
|
218
|
+
* goes live. Keyed by sessionId for diagnostics; values are kept alive
|
|
219
|
+
* intentionally between commit and next `propose` / `shutdown`.
|
|
220
|
+
*/
|
|
221
|
+
lingeringCommittedDevServers = new Map();
|
|
222
|
+
/**
|
|
223
|
+
* Per-session in-flight commit chain. Two overlapping commitVariant calls
|
|
224
|
+
* would otherwise both pass the `getVariantPick` idempotency check (the
|
|
225
|
+
* pick isn't recorded until after `applyDiffToProject`) and double-invoke
|
|
226
|
+
* `git apply` — which corrupts the working tree on the second run. The
|
|
227
|
+
* chain serializes commits per session so the second caller waits for the
|
|
228
|
+
* first to finish and then hits the idempotency check (returning
|
|
229
|
+
* `duplicate: true` for the same variantId, or `PENDING_CHANGE_CONFLICT`
|
|
230
|
+
* for a different one).
|
|
231
|
+
*/
|
|
232
|
+
inflightCommits = new Map();
|
|
41
233
|
/** Most recent agent-variants sessionId — read by the iframe chip via
|
|
42
234
|
* GET /api/variants/active. Cleared on cancel or commit. */
|
|
43
235
|
activeSessionId = null;
|
|
@@ -54,9 +246,17 @@ class AgentVariantsOrchestrator {
|
|
|
54
246
|
deps.installDependencies ?? defaultInstallDependencies;
|
|
55
247
|
this.materializeProject =
|
|
56
248
|
deps.materializeProject ?? defaultMaterializeProject;
|
|
249
|
+
this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
|
|
250
|
+
this.switchPreviewPort = deps.switchPreviewPort;
|
|
57
251
|
}
|
|
58
252
|
// --- Pure delegations (no side effects) ---------------------------------
|
|
59
253
|
propose(args) {
|
|
254
|
+
// A new session means any lingering committed dev server from a prior
|
|
255
|
+
// session is no longer needed — the next session opens its own preview.
|
|
256
|
+
// Best-effort stop; failures here don't block propose.
|
|
257
|
+
void this.stopLingeringCommittedDevServers().catch((err) => {
|
|
258
|
+
log.warn('Stopping lingering committed dev servers failed', err);
|
|
259
|
+
});
|
|
60
260
|
const result = this.store.propose(args);
|
|
61
261
|
this.activeSessionId = result.sessionId;
|
|
62
262
|
this.ensureResources(result.sessionId);
|
|
@@ -99,8 +299,10 @@ class AgentVariantsOrchestrator {
|
|
|
99
299
|
? this.store.getSummary(sessionId)
|
|
100
300
|
: null;
|
|
101
301
|
const variants = this.getVariants(sessionId);
|
|
102
|
-
const
|
|
302
|
+
const sessionProjectContext = this.store.getProjectContext(sessionId);
|
|
303
|
+
const projectContext = toActiveProjectContext(sessionProjectContext);
|
|
103
304
|
const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
|
|
305
|
+
const artifacts = buildSessionArtifacts(sessionProjectContext);
|
|
104
306
|
return {
|
|
105
307
|
active: true,
|
|
106
308
|
sessionId,
|
|
@@ -111,6 +313,7 @@ class AgentVariantsOrchestrator {
|
|
|
111
313
|
variants,
|
|
112
314
|
projectContext,
|
|
113
315
|
...(destinationPath ? { destinationPath } : {}),
|
|
316
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
114
317
|
};
|
|
115
318
|
}
|
|
116
319
|
emitChange() {
|
|
@@ -134,14 +337,14 @@ class AgentVariantsOrchestrator {
|
|
|
134
337
|
this.emitChange();
|
|
135
338
|
return result;
|
|
136
339
|
}
|
|
137
|
-
|
|
138
|
-
const result = this.store.
|
|
340
|
+
reportSourcePlan(args) {
|
|
341
|
+
const result = this.store.reportSourcePlan(args);
|
|
139
342
|
this.telemetry.trackAgentVariantsSourceContextQuality?.({
|
|
140
343
|
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'),
|
|
344
|
+
sourceUrlCount: args.sourcePlan.sourceIntent.sources.length,
|
|
345
|
+
artifactCount: args.sourcePlan.sourceContext.sourceFindings.length,
|
|
346
|
+
hasScreenshotReferences: (args.sourcePlan.sourceContext.screenshotReferences?.length ?? 0) > 0,
|
|
347
|
+
preserveBrand: args.sourcePlan.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
|
|
145
348
|
});
|
|
146
349
|
this.emitChange();
|
|
147
350
|
return result;
|
|
@@ -226,14 +429,20 @@ class AgentVariantsOrchestrator {
|
|
|
226
429
|
if (!preview && port) {
|
|
227
430
|
preview = { kind: 'dev_server', port };
|
|
228
431
|
}
|
|
432
|
+
const qa = resources?.qaResults.get(variant.workItemId);
|
|
229
433
|
const isSucceeded = variant.status === 'succeeded';
|
|
434
|
+
const qaFailed = qa?.status === 'failed';
|
|
230
435
|
const canView = Boolean(preview) || (isSucceeded && Boolean(port));
|
|
231
|
-
const canCommit = isSucceeded;
|
|
436
|
+
const canCommit = isSucceeded && !qaFailed;
|
|
437
|
+
const commitDisabledReason = qaFailed
|
|
438
|
+
? qa?.summary ?? 'Variant failed QA'
|
|
439
|
+
: 'Wait for a successful variant';
|
|
232
440
|
return {
|
|
233
441
|
...variant,
|
|
234
442
|
design: enrichDesignSource(variant.design),
|
|
235
443
|
...(preview ? { preview } : {}),
|
|
236
444
|
port,
|
|
445
|
+
...(qa ? { qa } : {}),
|
|
237
446
|
actions: {
|
|
238
447
|
view: canView
|
|
239
448
|
? { enabled: true }
|
|
@@ -247,7 +456,7 @@ class AgentVariantsOrchestrator {
|
|
|
247
456
|
? { enabled: true }
|
|
248
457
|
: {
|
|
249
458
|
enabled: false,
|
|
250
|
-
reason:
|
|
459
|
+
reason: commitDisabledReason,
|
|
251
460
|
},
|
|
252
461
|
},
|
|
253
462
|
};
|
|
@@ -344,26 +553,40 @@ class AgentVariantsOrchestrator {
|
|
|
344
553
|
* work items via continue_variants(action="request_work").
|
|
345
554
|
*/
|
|
346
555
|
async startUnified(args) {
|
|
347
|
-
const count = args.count ?? 4;
|
|
556
|
+
const count = args.briefs?.length ?? args.count ?? 4;
|
|
348
557
|
const projectContext = args.projectContext ?? { kind: 'existing' };
|
|
558
|
+
const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
|
|
559
|
+
const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
|
|
560
|
+
Boolean(sourceContext?.sourceArtifacts?.length) ||
|
|
561
|
+
Boolean(sourceContext?.sourceIntent) ||
|
|
562
|
+
Boolean(sourceContext?.artifact);
|
|
563
|
+
if (isSourceGrounded) {
|
|
564
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-grounded sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
|
|
565
|
+
}
|
|
349
566
|
const proposeResult = this.propose({
|
|
350
567
|
prompt: args.prompt,
|
|
351
568
|
count,
|
|
352
569
|
target: args.target,
|
|
353
570
|
projectContext,
|
|
354
571
|
});
|
|
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
572
|
if (proposeResult.stage !== 'awaiting_briefs' ||
|
|
359
573
|
!proposeResult.briefWorkItem) {
|
|
360
574
|
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `start_variants expected awaiting_briefs from propose, got ${proposeResult.stage}`);
|
|
361
575
|
}
|
|
362
|
-
const briefs =
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
576
|
+
const briefs = args.briefs
|
|
577
|
+
? args.briefs.map((b, i) => ({
|
|
578
|
+
briefId: `v${i + 1}`,
|
|
579
|
+
label: b.label,
|
|
580
|
+
body: b.body,
|
|
581
|
+
...(b.visualReferenceUrl
|
|
582
|
+
? { visualReferenceUrl: b.visualReferenceUrl }
|
|
583
|
+
: {}),
|
|
584
|
+
}))
|
|
585
|
+
: Array.from({ length: count }, (_, i) => ({
|
|
586
|
+
briefId: `v${i + 1}`,
|
|
587
|
+
label: `Variant ${i + 1}`,
|
|
588
|
+
body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
|
|
589
|
+
}));
|
|
367
590
|
this.store.reportBriefs({
|
|
368
591
|
sessionId: proposeResult.sessionId,
|
|
369
592
|
workItemId: proposeResult.briefWorkItem.id,
|
|
@@ -420,14 +643,21 @@ class AgentVariantsOrchestrator {
|
|
|
420
643
|
};
|
|
421
644
|
}
|
|
422
645
|
async reportComplete(args) {
|
|
423
|
-
|
|
646
|
+
// QA gate: for `succeeded` static_preview reports, run preview QA
|
|
647
|
+
// synchronously before recording success in the store. A failed QA
|
|
648
|
+
// verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
|
|
649
|
+
// so the variant never reaches `ready` and `getVariants` can disable
|
|
650
|
+
// its commit action.
|
|
651
|
+
const qaOverride = await this.evaluateQaForReport(args);
|
|
652
|
+
const effectiveArgs = qaOverride?.overrideArgs ?? args;
|
|
653
|
+
const result = this.store.reportComplete(effectiveArgs);
|
|
424
654
|
this.emitChange();
|
|
425
655
|
// Per-variant telemetry on terminal item statuses (skip 'running'
|
|
426
656
|
// heartbeats and the brief work item — only code_gen / scaffold_base
|
|
427
657
|
// matter for the funnel).
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
658
|
+
if (effectiveArgs.status === 'succeeded' ||
|
|
659
|
+
effectiveArgs.status === 'failed' ||
|
|
660
|
+
effectiveArgs.status === 'cancelled') {
|
|
431
661
|
const resources = this.resources.get(args.sessionId);
|
|
432
662
|
const leasedAt = resources?.leasedAt.get(args.workItemId);
|
|
433
663
|
const durationMs = leasedAt !== undefined ? Date.now() - leasedAt : null;
|
|
@@ -440,12 +670,12 @@ class AgentVariantsOrchestrator {
|
|
|
440
670
|
source: 'mcp',
|
|
441
671
|
sessionId: args.sessionId,
|
|
442
672
|
workItemId: args.workItemId,
|
|
443
|
-
status:
|
|
673
|
+
status: effectiveArgs.status,
|
|
444
674
|
attempt: args.attempt,
|
|
445
675
|
durationMs,
|
|
446
676
|
approveToCompleteMs,
|
|
447
|
-
hasError: Boolean(
|
|
448
|
-
errorCode:
|
|
677
|
+
hasError: Boolean(effectiveArgs.error),
|
|
678
|
+
errorCode: effectiveArgs.error?.code ?? null,
|
|
449
679
|
isScaffold: resources?.scaffoldBaseWorkItemId === args.workItemId,
|
|
450
680
|
projectContextKind,
|
|
451
681
|
tokensIn: args.tokensIn ?? null,
|
|
@@ -476,7 +706,7 @@ class AgentVariantsOrchestrator {
|
|
|
476
706
|
});
|
|
477
707
|
}
|
|
478
708
|
}
|
|
479
|
-
if (
|
|
709
|
+
if (effectiveArgs.status === 'succeeded') {
|
|
480
710
|
void this.handleSucceededReport({
|
|
481
711
|
sessionId: args.sessionId,
|
|
482
712
|
workItemId: args.workItemId,
|
|
@@ -549,6 +779,29 @@ class AgentVariantsOrchestrator {
|
|
|
549
779
|
* the call returns duplicate=true without re-enqueueing.
|
|
550
780
|
*/
|
|
551
781
|
async commitVariant(args) {
|
|
782
|
+
// Serialize per-session so two overlapping commits can't both run
|
|
783
|
+
// `applyDiffToProject` (or the fresh rename) before the first records
|
|
784
|
+
// its pick. The second caller waits for the first to settle, then runs
|
|
785
|
+
// commitVariantImpl which hits the idempotency check in
|
|
786
|
+
// `getVariantPick` / `recordVariantPick`.
|
|
787
|
+
const prior = this.inflightCommits.get(args.sessionId);
|
|
788
|
+
const run = (async () => {
|
|
789
|
+
if (prior) {
|
|
790
|
+
await prior.catch(() => undefined);
|
|
791
|
+
}
|
|
792
|
+
return this.commitVariantImpl(args);
|
|
793
|
+
})();
|
|
794
|
+
this.inflightCommits.set(args.sessionId, run);
|
|
795
|
+
try {
|
|
796
|
+
return await run;
|
|
797
|
+
}
|
|
798
|
+
finally {
|
|
799
|
+
if (this.inflightCommits.get(args.sessionId) === run) {
|
|
800
|
+
this.inflightCommits.delete(args.sessionId);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async commitVariantImpl(args) {
|
|
552
805
|
// Idempotent path — SessionStore.recordVariantPick is the source of truth
|
|
553
806
|
// for which variant the user picked. If the same variant is being
|
|
554
807
|
// committed again, replay the previously stored envelope's metadata
|
|
@@ -573,39 +826,162 @@ class AgentVariantsOrchestrator {
|
|
|
573
826
|
if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
|
|
574
827
|
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
|
|
575
828
|
}
|
|
576
|
-
const env = await this.resolveEnv(args.sessionId);
|
|
577
829
|
const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
|
|
578
830
|
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
579
831
|
let payload;
|
|
580
832
|
let envelopeDestination;
|
|
581
833
|
let changedFilesCount;
|
|
834
|
+
let freshVariantFolderName;
|
|
582
835
|
if (projectContext.kind === 'fresh') {
|
|
583
|
-
// Fresh sessions: static preview HTML is the deliverable. Write index.html
|
|
584
|
-
// to the destination directory.
|
|
585
836
|
const destinationPath = projectContext.workspacePath;
|
|
586
837
|
this.assertDestinationAvailable(destinationPath);
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
838
|
+
const variantFolderName = this.getFreshVariantFolderName({
|
|
839
|
+
sessionId: args.sessionId,
|
|
840
|
+
variantId: args.variantId,
|
|
841
|
+
variantName: input.briefLabel,
|
|
842
|
+
});
|
|
843
|
+
freshVariantFolderName = variantFolderName;
|
|
844
|
+
const freshMode = projectContext.executionPlan?.mode === 'vite_app'
|
|
845
|
+
? 'vite_app'
|
|
846
|
+
: 'static_preview';
|
|
847
|
+
if (freshMode === 'vite_app') {
|
|
848
|
+
// Vite_app: the deliverable is the entire variant worktree, not a
|
|
849
|
+
// single HTML file. When the worktree lives on the same volume as
|
|
850
|
+
// the destination (the common case — provisionFreshWorktrees scaffolds
|
|
851
|
+
// under destinationParent/.rivet-variants/) we can rename it into
|
|
852
|
+
// place. That moves the entire tree — including the materialized
|
|
853
|
+
// node_modules — in O(1) with no copy, and the rename is atomic.
|
|
854
|
+
// For cross-volume cases (user passed a destinationParent on a
|
|
855
|
+
// different mount than the .rivet-variants dir, e.g. tests stubbing
|
|
856
|
+
// workspacePath to a tmp dir), fall back to the recursive-copy +
|
|
857
|
+
// background-install path so the feature still works.
|
|
858
|
+
const record = resources.worktrees.get(args.variantId);
|
|
859
|
+
if (!record) {
|
|
860
|
+
throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
|
|
861
|
+
}
|
|
862
|
+
let usedRename = false;
|
|
863
|
+
let isDependencyInstallRequired = false;
|
|
864
|
+
try {
|
|
865
|
+
if (areSameVolume(record.worktreePath, destinationPath)) {
|
|
866
|
+
// Vite's dev server writes its module cache to `<worktreePath>/.vite/`
|
|
867
|
+
// and recreates that directory on the fly whenever it's missing. If
|
|
868
|
+
// we rename a worktree out from under a running dev server, Vite
|
|
869
|
+
// will materialize an empty `.vite/` shell back at the OLD path —
|
|
870
|
+
// leaving behind orphan stub dirs in `.rivet-variants/` and
|
|
871
|
+
// confusing later cleanup. Stop every variant's dev server first
|
|
872
|
+
// (including the chosen variant, which is about to move) so no
|
|
873
|
+
// process is holding the old paths open during the rename.
|
|
874
|
+
//
|
|
875
|
+
// We stop them eagerly here rather than relying on teardownSession
|
|
876
|
+
// because teardown runs AFTER the rename — too late to prevent the
|
|
877
|
+
// stub-dir race.
|
|
878
|
+
await this.stopAllDevServersForSession(resources);
|
|
879
|
+
// node_modules in fresh variants 1..N is a symlink into variant 0
|
|
880
|
+
// (see runBackgroundInstall). After rename, variant 0's
|
|
881
|
+
// node_modules vanishes during cleanup of the sibling worktrees,
|
|
882
|
+
// leaving the chosen variant with a dangling link. Resolve the
|
|
883
|
+
// symlink to a real directory first so the destination has real
|
|
884
|
+
// deps and the rename is self-contained.
|
|
885
|
+
isDependencyInstallRequired = await resolveNodeModulesSymlink(record.worktreePath);
|
|
886
|
+
await this.renameWorktreeIntoDestination(record.worktreePath, destinationPath);
|
|
887
|
+
usedRename = true;
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
await this.materializeProject(record.worktreePath, destinationPath);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
895
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to materialize vite_app variant to ${destinationPath}: ${message}`);
|
|
896
|
+
}
|
|
897
|
+
changedFilesCount = countWorktreeFiles(destinationPath);
|
|
898
|
+
if (!usedRename) {
|
|
899
|
+
// Cross-volume fallback: materializeProject excludes node_modules
|
|
900
|
+
// so we have to re-install at the destination. Don't await the
|
|
901
|
+
// install — the agent's commit_variant call should return
|
|
902
|
+
// immediately — but chain the dev-server start to it so the iframe
|
|
903
|
+
// recovers without manual intervention once deps are ready.
|
|
904
|
+
void this.installDependencies(destinationPath)
|
|
905
|
+
.then(() => this.startCommittedDevServer({
|
|
906
|
+
resources,
|
|
907
|
+
destinationPath,
|
|
908
|
+
}))
|
|
909
|
+
.catch((err) => {
|
|
910
|
+
log.warn(`Background install + committed dev server start after vite_app commit failed for ${destinationPath}`, err);
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
else if (isDependencyInstallRequired) {
|
|
914
|
+
await this.installDependencies(destinationPath);
|
|
915
|
+
}
|
|
916
|
+
if (usedRename) {
|
|
917
|
+
// Preserve the unchosen variants as runnable siblings of the
|
|
918
|
+
// chosen project. Best-effort: failures here log and continue so
|
|
919
|
+
// a partial history never blocks the commit handoff.
|
|
920
|
+
try {
|
|
921
|
+
this.preserveUnchosenVariants({
|
|
922
|
+
sessionId: args.sessionId,
|
|
923
|
+
chosenVariantId: args.variantId,
|
|
924
|
+
destinationPath,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
log.warn(`preserveUnchosenVariants failed for session ${args.sessionId}; continuing without history`, err);
|
|
929
|
+
}
|
|
930
|
+
// Stopped every dev server pre-rename to dodge the .vite/ stub-dir
|
|
931
|
+
// race. The iframe is still pointed at the (now-dead) chosen
|
|
932
|
+
// variant's port via the proxy, so without a fresh server at the
|
|
933
|
+
// destination the user would see "preview disconnected" the moment
|
|
934
|
+
// they commit. Spawn one and retarget the proxy. Best-effort: if
|
|
935
|
+
// it fails, the user can `npm run dev` themselves at destination.
|
|
936
|
+
await this.startCommittedDevServer({
|
|
937
|
+
resources,
|
|
938
|
+
destinationPath,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
payload = {
|
|
942
|
+
kind: 'project-created',
|
|
943
|
+
destinationPath,
|
|
944
|
+
changedFilesCount,
|
|
945
|
+
note: usedRename
|
|
946
|
+
? 'Vite app moved to destinationPath (rename); node_modules came along.'
|
|
947
|
+
: 'Vite app materialized at destinationPath; `npm install` running in the background.',
|
|
948
|
+
};
|
|
949
|
+
envelopeDestination = destinationPath;
|
|
594
950
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
951
|
+
else {
|
|
952
|
+
// Static_preview: HTML is the entire deliverable. Write index.html.
|
|
953
|
+
const staticPreview = resources.staticPreviews.get(args.variantId);
|
|
954
|
+
const persistedSnapshotIndexPath = (0, createProjectArtifacts_1.createProjectVariantIndexPath)(destinationPath, variantFolderName);
|
|
955
|
+
const htmlFromSnapshot = fs_1.default.existsSync(persistedSnapshotIndexPath)
|
|
956
|
+
? fs_1.default.readFileSync(persistedSnapshotIndexPath, 'utf8')
|
|
957
|
+
: null;
|
|
958
|
+
if (!staticPreview && !htmlFromSnapshot) {
|
|
959
|
+
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
fs_1.default.mkdirSync(destinationPath, { recursive: true });
|
|
963
|
+
fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
967
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
|
|
968
|
+
}
|
|
969
|
+
changedFilesCount = 1;
|
|
970
|
+
payload = {
|
|
971
|
+
kind: 'project-created',
|
|
972
|
+
destinationPath,
|
|
973
|
+
changedFilesCount,
|
|
974
|
+
note: 'Static preview written to index.html at destinationPath.',
|
|
975
|
+
};
|
|
976
|
+
envelopeDestination = destinationPath;
|
|
598
977
|
}
|
|
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
978
|
}
|
|
608
979
|
else {
|
|
980
|
+
// Existing project: diff against the user's repo. resolveEnv lives in
|
|
981
|
+
// this branch only — fresh sessions don't have a user-supplied project
|
|
982
|
+
// and the bridge.getProjectPath() it reads from would either be stale
|
|
983
|
+
// or empty for them.
|
|
984
|
+
const env = await this.resolveEnv(args.sessionId);
|
|
609
985
|
const record = resources.worktrees.get(args.variantId);
|
|
610
986
|
if (!record) {
|
|
611
987
|
throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
|
|
@@ -614,11 +990,26 @@ class AgentVariantsOrchestrator {
|
|
|
614
990
|
throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
|
|
615
991
|
}
|
|
616
992
|
changedFilesCount = countDiffFiles(record.diff);
|
|
993
|
+
// Apply the diff to the user's working tree right now so the variant
|
|
994
|
+
// lands on the current branch (uncommitted). Previously this just
|
|
995
|
+
// enqueued the diff for an agent to apply; non-MCP sessions had no
|
|
996
|
+
// agent and the variant was effectively lost.
|
|
997
|
+
if (!this.worktrees.applyDiffToProject) {
|
|
998
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'WorktreeManager does not support applying diffs to the project tree');
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
await this.worktrees.applyDiffToProject(record.diff, env.projectPath);
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to apply variant ${args.variantId} to ${env.projectPath}: ${message}`);
|
|
1006
|
+
}
|
|
617
1007
|
payload = {
|
|
618
|
-
kind: 'diff',
|
|
1008
|
+
kind: 'diff-applied',
|
|
619
1009
|
diff: record.diff,
|
|
620
1010
|
target: input.target,
|
|
621
1011
|
changedFilesCount,
|
|
1012
|
+
note: 'Variant diff applied to the user\'s working tree (uncommitted).',
|
|
622
1013
|
};
|
|
623
1014
|
envelopeDestination = env.projectPath;
|
|
624
1015
|
}
|
|
@@ -637,6 +1028,27 @@ class AgentVariantsOrchestrator {
|
|
|
637
1028
|
sessionId: args.sessionId,
|
|
638
1029
|
envelope,
|
|
639
1030
|
});
|
|
1031
|
+
if (projectContext.kind === 'fresh') {
|
|
1032
|
+
// Manifest persistence is bookkeeping for the .rivet/ artifacts and must
|
|
1033
|
+
// never strand the session — `recordVariantPick` above has already marked
|
|
1034
|
+
// this variant as the selection, so a retry would short-circuit via the
|
|
1035
|
+
// `duplicate: true` path and skip the enqueue below. Catch and log
|
|
1036
|
+
// instead of letting a filesystem hiccup block the handoff to pending-
|
|
1037
|
+
// changes.
|
|
1038
|
+
try {
|
|
1039
|
+
this.persistFreshSelectionMetadata({
|
|
1040
|
+
sessionId: args.sessionId,
|
|
1041
|
+
variantId: args.variantId,
|
|
1042
|
+
variantName: input.briefLabel,
|
|
1043
|
+
projectPath: projectContext.workspacePath,
|
|
1044
|
+
variantFolderName: freshVariantFolderName ?? (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel),
|
|
1045
|
+
changedFilesCount,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
log.warn(`persistFreshSelectionMetadata failed for session ${args.sessionId} variant ${args.variantId} — continuing with enqueue`, err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
640
1052
|
const enqueueResult = this.adapter.enqueue(envelope);
|
|
641
1053
|
resources.committedVariantIds.add(args.variantId);
|
|
642
1054
|
if (this.activeSessionId === args.sessionId) {
|
|
@@ -683,9 +1095,10 @@ class AgentVariantsOrchestrator {
|
|
|
683
1095
|
if (!fs_1.default.existsSync(destinationPath))
|
|
684
1096
|
return;
|
|
685
1097
|
const entries = fs_1.default.readdirSync(destinationPath);
|
|
686
|
-
|
|
1098
|
+
const userVisibleEntries = entries.filter((entry) => entry !== '.rivet');
|
|
1099
|
+
if (userVisibleEntries.length === 0)
|
|
687
1100
|
return;
|
|
688
|
-
throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${
|
|
1101
|
+
throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
|
|
689
1102
|
}
|
|
690
1103
|
/** Read the captured diff for a code-gen variant, if available. */
|
|
691
1104
|
getVariantDiff(sessionId, variantId) {
|
|
@@ -695,8 +1108,13 @@ class AgentVariantsOrchestrator {
|
|
|
695
1108
|
async provisionWorktrees(sessionId, approveResult) {
|
|
696
1109
|
const projectContext = this.store.getProjectContext(sessionId);
|
|
697
1110
|
if (projectContext.kind === 'fresh') {
|
|
698
|
-
// Fresh
|
|
699
|
-
//
|
|
1111
|
+
// Fresh + static_preview: no worktrees, no scaffold, HTML is the
|
|
1112
|
+
// deliverable. Fresh + vite_app: provision a Vite skeleton per variant
|
|
1113
|
+
// and copy the agent-planned assetPlan files before the agent leases
|
|
1114
|
+
// its code_gen items.
|
|
1115
|
+
if (approveResult.scaffoldBaseWorkItemId) {
|
|
1116
|
+
await this.provisionFreshWorktrees(sessionId, approveResult, projectContext);
|
|
1117
|
+
}
|
|
700
1118
|
return;
|
|
701
1119
|
}
|
|
702
1120
|
await this.provisionExistingWorktrees(sessionId, approveResult);
|
|
@@ -769,8 +1187,13 @@ class AgentVariantsOrchestrator {
|
|
|
769
1187
|
designContext: summarizeDesignContext(designContext),
|
|
770
1188
|
});
|
|
771
1189
|
log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
|
|
772
|
-
|
|
1190
|
+
// destinationParent for fresh worktrees: same parent the materialized
|
|
1191
|
+
// project will land in. Keeping the worktree on the same volume turns
|
|
1192
|
+
// commit into a directory rename instead of a recursive copy.
|
|
1193
|
+
const destinationParent = path_1.default.dirname(projectContext.workspacePath);
|
|
1194
|
+
const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
|
|
773
1195
|
resources.scaffoldBaseWorkItemId = scaffoldId;
|
|
1196
|
+
resources.freshDestinationParent = destinationParent;
|
|
774
1197
|
// Each code_gen item maps 1:1 to a fresh worktree. The scaffold_base
|
|
775
1198
|
// work item is internal — no dedicated worktree.
|
|
776
1199
|
for (let i = 0; i < codeGenIds.length; i++) {
|
|
@@ -779,6 +1202,45 @@ class AgentVariantsOrchestrator {
|
|
|
779
1202
|
worktreePath: paths[i],
|
|
780
1203
|
});
|
|
781
1204
|
}
|
|
1205
|
+
// Copy agent-planned assets (e.g. avatar.glb) into each worktree so
|
|
1206
|
+
// generated code can reference them via plan.referenceAs. Done before
|
|
1207
|
+
// the background install so the assets are on disk by the time the
|
|
1208
|
+
// agent leases its code_gen item.
|
|
1209
|
+
// If copy throws (e.g. source asset deleted between planning and
|
|
1210
|
+
// provisioning), fail scaffold_base immediately so dependent code_gen
|
|
1211
|
+
// items cascade DEPENDENCY_FAILED instead of leaving the session
|
|
1212
|
+
// permanently stuck in `work_items_ready`.
|
|
1213
|
+
const assetPlan = projectContext.executionPlan?.assetPlan ?? [];
|
|
1214
|
+
if (assetPlan.length > 0) {
|
|
1215
|
+
try {
|
|
1216
|
+
for (const worktreePath of paths) {
|
|
1217
|
+
for (const entry of assetPlan) {
|
|
1218
|
+
copyAssetIntoWorktree(worktreePath, entry, projectContext.assetSourceRoot);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
catch (err) {
|
|
1223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1224
|
+
log.warn(`asset copy failed for session ${sessionId}; marking scaffold_base failed`, err);
|
|
1225
|
+
trackScaffoldFailed(this.telemetry, {
|
|
1226
|
+
sessionId,
|
|
1227
|
+
errorCode: 'SCAFFOLD_FAILED',
|
|
1228
|
+
durationMs: Date.now() - scaffoldStartedAt,
|
|
1229
|
+
});
|
|
1230
|
+
try {
|
|
1231
|
+
this.store.failInternal({
|
|
1232
|
+
sessionId,
|
|
1233
|
+
workItemId: scaffoldId,
|
|
1234
|
+
error: { code: 'SCAFFOLD_FAILED', message },
|
|
1235
|
+
});
|
|
1236
|
+
this.emitChange();
|
|
1237
|
+
}
|
|
1238
|
+
catch (storeErr) {
|
|
1239
|
+
log.error(`failInternal failed for ${sessionId}/${scaffoldId}`, storeErr);
|
|
1240
|
+
}
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
782
1244
|
// Background install: don't await. The orchestrator finishes the
|
|
783
1245
|
// approve→provision call immediately; the agent polls for scaffold
|
|
784
1246
|
// completion via continue_variants(action='check').
|
|
@@ -794,7 +1256,22 @@ class AgentVariantsOrchestrator {
|
|
|
794
1256
|
async runBackgroundInstall(sessionId, scaffoldWorkItemId, worktreePaths, scaffoldStartedAt) {
|
|
795
1257
|
const installStartedAt = Date.now();
|
|
796
1258
|
try {
|
|
797
|
-
|
|
1259
|
+
const [firstWorktree, ...rest] = worktreePaths;
|
|
1260
|
+
await this.installDependencies(firstWorktree);
|
|
1261
|
+
await Promise.all(rest.map(async (p) => {
|
|
1262
|
+
const target = path_1.default.join(firstWorktree, 'node_modules');
|
|
1263
|
+
const link = path_1.default.join(p, 'node_modules');
|
|
1264
|
+
// The fresh template has no node_modules until install runs, so
|
|
1265
|
+
// the link target is guaranteed absent in `p` — no need to remove
|
|
1266
|
+
// first.
|
|
1267
|
+
try {
|
|
1268
|
+
fs_1.default.symlinkSync(target, link, 'dir');
|
|
1269
|
+
}
|
|
1270
|
+
catch (symlinkErr) {
|
|
1271
|
+
log.warn(`Symlink node_modules failed for ${p}, falling back to install`, symlinkErr);
|
|
1272
|
+
await this.installDependencies(p);
|
|
1273
|
+
}
|
|
1274
|
+
}));
|
|
798
1275
|
}
|
|
799
1276
|
catch (err) {
|
|
800
1277
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -835,6 +1312,65 @@ class AgentVariantsOrchestrator {
|
|
|
835
1312
|
variantCount: worktreePaths.length,
|
|
836
1313
|
});
|
|
837
1314
|
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Gate `succeeded` reports for `static_preview` items through preview QA
|
|
1317
|
+
* before they reach the store. Returns either `null` (caller continues
|
|
1318
|
+
* with the original args — non-static-preview report, non-succeeded
|
|
1319
|
+
* status, no parseable HTML, or QA passed) or an override carrying a
|
|
1320
|
+
* `failed` rewrite of the report. The QA result is also memoized on the
|
|
1321
|
+
* session resources so `getVariants` can surface it.
|
|
1322
|
+
*
|
|
1323
|
+
* Only fresh-project sessions go through this gate today; existing-
|
|
1324
|
+
* project `code_gen` variants produce a diff with no preview document
|
|
1325
|
+
* to inspect, so they skip QA and behave exactly as before.
|
|
1326
|
+
*/
|
|
1327
|
+
async evaluateQaForReport(args) {
|
|
1328
|
+
if (args.status !== 'succeeded')
|
|
1329
|
+
return null;
|
|
1330
|
+
if (!this.store.hasSession(args.sessionId))
|
|
1331
|
+
return null;
|
|
1332
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1333
|
+
if (projectContext.kind !== 'fresh')
|
|
1334
|
+
return null;
|
|
1335
|
+
const parsed = parseStaticPreviewOutput(normalizeOutput(args.output));
|
|
1336
|
+
if (!parsed)
|
|
1337
|
+
return null;
|
|
1338
|
+
let qa;
|
|
1339
|
+
try {
|
|
1340
|
+
qa = await this.previewQaRunner({
|
|
1341
|
+
sessionId: args.sessionId,
|
|
1342
|
+
workItemId: args.workItemId,
|
|
1343
|
+
html: parsed.html,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
catch (err) {
|
|
1347
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1348
|
+
qa = {
|
|
1349
|
+
status: 'failed',
|
|
1350
|
+
issues: [
|
|
1351
|
+
{
|
|
1352
|
+
kind: 'preview_unavailable',
|
|
1353
|
+
detail: 'qa_runner_error',
|
|
1354
|
+
message,
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
summary: `Preview QA runner threw: ${message}`,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
const resources = this.ensureResources(args.sessionId);
|
|
1361
|
+
resources.qaResults.set(args.workItemId, qa);
|
|
1362
|
+
if (qa.status === 'failed') {
|
|
1363
|
+
return {
|
|
1364
|
+
qa,
|
|
1365
|
+
overrideArgs: {
|
|
1366
|
+
...args,
|
|
1367
|
+
status: 'failed',
|
|
1368
|
+
error: { code: 'VARIANT_QA_FAILED', message: qa.summary },
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
return { qa };
|
|
1373
|
+
}
|
|
838
1374
|
async handleSucceededReport(args) {
|
|
839
1375
|
const { sessionId, workItemId } = args;
|
|
840
1376
|
const resources = this.resources.get(sessionId);
|
|
@@ -850,6 +1386,13 @@ class AgentVariantsOrchestrator {
|
|
|
850
1386
|
html: staticPreview.html,
|
|
851
1387
|
};
|
|
852
1388
|
resources.staticPreviews.set(workItemId, record);
|
|
1389
|
+
if (this.store.getProjectContext(sessionId).kind === 'fresh') {
|
|
1390
|
+
this.persistFreshVariantArtifacts({
|
|
1391
|
+
sessionId,
|
|
1392
|
+
workItemId,
|
|
1393
|
+
html: staticPreview.html,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
853
1396
|
const leasedAt = resources.leasedAt.get(workItemId);
|
|
854
1397
|
trackStaticPreviewCompleted(this.telemetry, {
|
|
855
1398
|
sessionId,
|
|
@@ -890,6 +1433,19 @@ class AgentVariantsOrchestrator {
|
|
|
890
1433
|
const proc = await this.worktrees.startDevServer(dev.cwd, port, dev.cmd, dev.args, dev.env);
|
|
891
1434
|
record.port = port;
|
|
892
1435
|
record.devServerProcess = proc;
|
|
1436
|
+
// If the dev server dies on its own (crash, OOM, Vite hard-fail),
|
|
1437
|
+
// clear the port so the iframe stops routing the chip onto a dead
|
|
1438
|
+
// socket. Without this the proxy retargets onto an unreachable port
|
|
1439
|
+
// and the chip shows "upstream_unreachable" instead of the accurate
|
|
1440
|
+
// "Preview is unavailable for this variant".
|
|
1441
|
+
proc.once('exit', (code, signal) => {
|
|
1442
|
+
if (record.devServerProcess !== proc)
|
|
1443
|
+
return;
|
|
1444
|
+
record.port = undefined;
|
|
1445
|
+
record.devServerProcess = undefined;
|
|
1446
|
+
log.warn(`Variant ${workItemId} dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}); preview disabled`);
|
|
1447
|
+
this.emitChange();
|
|
1448
|
+
});
|
|
893
1449
|
this.emitChange();
|
|
894
1450
|
trackFreshDevServerStarted(this.telemetry, {
|
|
895
1451
|
sessionId,
|
|
@@ -907,6 +1463,93 @@ class AgentVariantsOrchestrator {
|
|
|
907
1463
|
log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
|
|
908
1464
|
}
|
|
909
1465
|
}
|
|
1466
|
+
persistFreshVariantArtifacts(args) {
|
|
1467
|
+
const projectContext = this.store.getProjectContext(args.sessionId);
|
|
1468
|
+
if (projectContext.kind !== 'fresh') {
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const projectPath = projectContext.workspacePath;
|
|
1472
|
+
const now = new Date().toISOString();
|
|
1473
|
+
const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(projectPath);
|
|
1474
|
+
const existingProjectManifest = this.readManifest(projectManifestPath);
|
|
1475
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
|
|
1476
|
+
createdAt: existingProjectManifest?.createdAt ?? now,
|
|
1477
|
+
selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
|
|
1478
|
+
latestVariantSessionId: args.sessionId,
|
|
1479
|
+
selectedVariantId: existingProjectManifest?.selectedVariantId,
|
|
1480
|
+
}));
|
|
1481
|
+
const briefInput = this.store.getWorkItemInput(args.sessionId, args.workItemId);
|
|
1482
|
+
const variantFolderName = this.getFreshVariantFolderName({
|
|
1483
|
+
sessionId: args.sessionId,
|
|
1484
|
+
variantId: args.workItemId,
|
|
1485
|
+
variantName: briefInput.briefLabel,
|
|
1486
|
+
});
|
|
1487
|
+
const snapshotPath = (0, createProjectArtifacts_1.createProjectVariantSnapshotPath)(projectPath, variantFolderName);
|
|
1488
|
+
fs_1.default.rmSync(snapshotPath, { recursive: true, force: true });
|
|
1489
|
+
fs_1.default.mkdirSync(snapshotPath, { recursive: true });
|
|
1490
|
+
const briefPath = (0, createProjectArtifacts_1.createProjectVariantBriefPath)(projectPath, variantFolderName);
|
|
1491
|
+
fs_1.default.writeFileSync(briefPath, `# ${briefInput.briefLabel}\n\n${briefInput.briefBody}\n`, 'utf8');
|
|
1492
|
+
const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(projectPath, variantFolderName);
|
|
1493
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
|
|
1494
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1495
|
+
createdAt: now,
|
|
1496
|
+
sessionId: args.sessionId,
|
|
1497
|
+
variantId: args.workItemId,
|
|
1498
|
+
variantName: briefInput.briefLabel,
|
|
1499
|
+
variantFolderName,
|
|
1500
|
+
changedFilesCount: 1,
|
|
1501
|
+
});
|
|
1502
|
+
fs_1.default.writeFileSync((0, createProjectArtifacts_1.createProjectVariantIndexPath)(projectPath, variantFolderName), args.html, 'utf8');
|
|
1503
|
+
}
|
|
1504
|
+
persistFreshSelectionMetadata(args) {
|
|
1505
|
+
const now = new Date().toISOString();
|
|
1506
|
+
const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(args.projectPath);
|
|
1507
|
+
const existingProjectManifest = this.readManifest(projectManifestPath);
|
|
1508
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
|
|
1509
|
+
createdAt: existingProjectManifest?.createdAt ?? now,
|
|
1510
|
+
selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
|
|
1511
|
+
latestVariantSessionId: args.sessionId,
|
|
1512
|
+
selectedVariantId: args.variantId,
|
|
1513
|
+
}));
|
|
1514
|
+
const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(args.projectPath, args.variantFolderName);
|
|
1515
|
+
const existingVariantManifest = this.readManifest(variantManifestPath);
|
|
1516
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
|
|
1517
|
+
...existingVariantManifest,
|
|
1518
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1519
|
+
createdAt: existingVariantManifest?.createdAt ?? now,
|
|
1520
|
+
sessionId: args.sessionId,
|
|
1521
|
+
variantId: args.variantId,
|
|
1522
|
+
variantName: args.variantName,
|
|
1523
|
+
variantFolderName: args.variantFolderName,
|
|
1524
|
+
changedFilesCount: args.changedFilesCount,
|
|
1525
|
+
selectedVariantId: args.variantId,
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
getFreshVariantFolderName(args) {
|
|
1529
|
+
const baseSlug = (0, createProjectArtifacts_1.createProjectVariantSlug)(args.variantName);
|
|
1530
|
+
const variants = this.store.getVariants(args.sessionId);
|
|
1531
|
+
const matchingVariants = variants.filter((variant) => {
|
|
1532
|
+
const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
|
|
1533
|
+
return (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel ?? '') === baseSlug;
|
|
1534
|
+
});
|
|
1535
|
+
const index = matchingVariants.findIndex((variant) => variant.workItemId === args.variantId);
|
|
1536
|
+
if (index <= 0) {
|
|
1537
|
+
return baseSlug;
|
|
1538
|
+
}
|
|
1539
|
+
return `${baseSlug}-${index + 1}`;
|
|
1540
|
+
}
|
|
1541
|
+
readManifest(manifestPath) {
|
|
1542
|
+
if (!fs_1.default.existsSync(manifestPath)) {
|
|
1543
|
+
return undefined;
|
|
1544
|
+
}
|
|
1545
|
+
try {
|
|
1546
|
+
const raw = fs_1.default.readFileSync(manifestPath, 'utf8');
|
|
1547
|
+
return JSON.parse(raw);
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
return undefined;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
910
1553
|
/**
|
|
911
1554
|
* Resolve dev server invocation for a worktree. Fresh-project worktrees
|
|
912
1555
|
* always use the Vite template's npm command at the worktree root; existing
|
|
@@ -941,6 +1584,301 @@ class AgentVariantsOrchestrator {
|
|
|
941
1584
|
env: { PORT: String(port) },
|
|
942
1585
|
};
|
|
943
1586
|
}
|
|
1587
|
+
/**
|
|
1588
|
+
* After the chosen variant has been renamed into `destinationPath`, move
|
|
1589
|
+
* each unchosen sibling worktree into a `<destinationParent>/<slug>-variants/NN-<label>/`
|
|
1590
|
+
* folder and repoint its `node_modules` symlink at the chosen project's
|
|
1591
|
+
* real install. Writes a manifest documenting all 5 variants (the chosen
|
|
1592
|
+
* one with `folder: null` and `chosenPath: '../<slug>'`).
|
|
1593
|
+
*
|
|
1594
|
+
* Sets `vitePreservedSiblings = true` on session resources so the
|
|
1595
|
+
* subsequent teardown skips `cleanupFreshWorktrees` (which would otherwise
|
|
1596
|
+
* delete the just-preserved history). Dev servers still get stopped.
|
|
1597
|
+
*/
|
|
1598
|
+
preserveUnchosenVariants(args) {
|
|
1599
|
+
const resources = this.resources.get(args.sessionId);
|
|
1600
|
+
if (!resources)
|
|
1601
|
+
return;
|
|
1602
|
+
const destinationParent = path_1.default.dirname(args.destinationPath);
|
|
1603
|
+
const projectSlug = path_1.default.basename(args.destinationPath);
|
|
1604
|
+
const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
|
|
1605
|
+
const sharedNodeModulesAbs = path_1.default.join(args.destinationPath, 'node_modules');
|
|
1606
|
+
const briefs = this.store.getBriefs(args.sessionId);
|
|
1607
|
+
const variants = this.store.getVariants(args.sessionId);
|
|
1608
|
+
const briefIndexById = new Map(briefs.map((b, i) => [b.briefId, i]));
|
|
1609
|
+
const manifestEntries = [];
|
|
1610
|
+
let chosenSlug = '';
|
|
1611
|
+
for (const variant of variants) {
|
|
1612
|
+
const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
|
|
1613
|
+
const label = input.briefLabel ?? variant.label ?? 'variant';
|
|
1614
|
+
const slug = (0, createProjectArtifacts_1.createProjectVariantSlug)(label);
|
|
1615
|
+
const briefIdx = briefIndexById.get(variant.briefId) ?? 0;
|
|
1616
|
+
const numericPrefix = String(briefIdx + 1).padStart(2, '0');
|
|
1617
|
+
const folderName = `${numericPrefix}-${slug}`;
|
|
1618
|
+
if (variant.workItemId === args.chosenVariantId) {
|
|
1619
|
+
chosenSlug = slug;
|
|
1620
|
+
manifestEntries.push({
|
|
1621
|
+
variantId: variant.workItemId,
|
|
1622
|
+
label,
|
|
1623
|
+
slug,
|
|
1624
|
+
folder: null,
|
|
1625
|
+
isChosen: true,
|
|
1626
|
+
chosenPath: `../${projectSlug}`,
|
|
1627
|
+
});
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
const record = resources.worktrees.get(variant.workItemId);
|
|
1631
|
+
if (!record) {
|
|
1632
|
+
// No worktree (e.g. failed variant) — still list it in manifest with
|
|
1633
|
+
// null folder so the user can see why it's absent.
|
|
1634
|
+
manifestEntries.push({
|
|
1635
|
+
variantId: variant.workItemId,
|
|
1636
|
+
label,
|
|
1637
|
+
slug,
|
|
1638
|
+
folder: null,
|
|
1639
|
+
isChosen: false,
|
|
1640
|
+
});
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (!fs_1.default.existsSync(record.worktreePath)) {
|
|
1644
|
+
manifestEntries.push({
|
|
1645
|
+
variantId: variant.workItemId,
|
|
1646
|
+
label,
|
|
1647
|
+
slug,
|
|
1648
|
+
folder: null,
|
|
1649
|
+
isChosen: false,
|
|
1650
|
+
});
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
try {
|
|
1654
|
+
const newPath = this.worktrees.moveWorktreeToHistory
|
|
1655
|
+
? this.worktrees.moveWorktreeToHistory(record.worktreePath, historyDir, folderName)
|
|
1656
|
+
: (() => {
|
|
1657
|
+
fs_1.default.mkdirSync(historyDir, { recursive: true });
|
|
1658
|
+
const dest = path_1.default.join(historyDir, folderName);
|
|
1659
|
+
fs_1.default.renameSync(record.worktreePath, dest);
|
|
1660
|
+
return dest;
|
|
1661
|
+
})();
|
|
1662
|
+
// Repoint node_modules at the chosen project's real install.
|
|
1663
|
+
try {
|
|
1664
|
+
if (this.worktrees.repointNodeModulesSymlink) {
|
|
1665
|
+
this.worktrees.repointNodeModulesSymlink(newPath, sharedNodeModulesAbs);
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
const link = path_1.default.join(newPath, 'node_modules');
|
|
1669
|
+
try {
|
|
1670
|
+
fs_1.default.rmSync(link, { force: true, recursive: true });
|
|
1671
|
+
}
|
|
1672
|
+
catch {
|
|
1673
|
+
/* may not exist */
|
|
1674
|
+
}
|
|
1675
|
+
const target = path_1.default.relative(path_1.default.dirname(link), sharedNodeModulesAbs);
|
|
1676
|
+
fs_1.default.symlinkSync(target, link, 'dir');
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
catch (err) {
|
|
1680
|
+
log.warn(`repointNodeModulesSymlink failed for ${newPath}; variant still runnable after npm install`, err);
|
|
1681
|
+
}
|
|
1682
|
+
// Update the in-memory record so teardown doesn't try to operate on
|
|
1683
|
+
// the stale path.
|
|
1684
|
+
record.worktreePath = newPath;
|
|
1685
|
+
manifestEntries.push({
|
|
1686
|
+
variantId: variant.workItemId,
|
|
1687
|
+
label,
|
|
1688
|
+
slug,
|
|
1689
|
+
folder: folderName,
|
|
1690
|
+
isChosen: false,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
catch (err) {
|
|
1694
|
+
log.warn(`moveWorktreeToHistory failed for variant ${variant.workItemId}`, err);
|
|
1695
|
+
manifestEntries.push({
|
|
1696
|
+
variantId: variant.workItemId,
|
|
1697
|
+
label,
|
|
1698
|
+
slug,
|
|
1699
|
+
folder: null,
|
|
1700
|
+
isChosen: false,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const manifest = {
|
|
1705
|
+
schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
|
|
1706
|
+
createdAt: new Date().toISOString(),
|
|
1707
|
+
sessionId: args.sessionId,
|
|
1708
|
+
chosenVariantId: args.chosenVariantId,
|
|
1709
|
+
chosenSlug: chosenSlug || projectSlug,
|
|
1710
|
+
variants: manifestEntries,
|
|
1711
|
+
};
|
|
1712
|
+
try {
|
|
1713
|
+
(0, createProjectArtifacts_1.writeCreateProjectManifestFile)((0, createProjectArtifacts_1.createVariantsHistoryManifestPath)(destinationParent, projectSlug), manifest);
|
|
1714
|
+
}
|
|
1715
|
+
catch (err) {
|
|
1716
|
+
log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
|
|
1717
|
+
}
|
|
1718
|
+
resources.vitePreservedSiblings = true;
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Rename `sourceWorktreePath` into `destinationPath`, then replace the
|
|
1722
|
+
* worktree's `.git` with a fresh-history init + initial commit. Matches
|
|
1723
|
+
* the historical materializeProject behavior of "user sees a fresh repo
|
|
1724
|
+
* at destinationPath" so we don't surprise them with a `rivet: variant
|
|
1725
|
+
* baseline` commit they didn't intend.
|
|
1726
|
+
*
|
|
1727
|
+
* The rename is the load-bearing operation: it moves the entire variant
|
|
1728
|
+
* tree (template files + materialized node_modules) in O(1) when the
|
|
1729
|
+
* source and destination share a volume. The git reset that follows is
|
|
1730
|
+
* best-effort housekeeping; a failure there leaves files in place.
|
|
1731
|
+
*/
|
|
1732
|
+
async renameWorktreeIntoDestination(sourceWorktreePath, destinationPath) {
|
|
1733
|
+
// destinationPath must not exist for fs.rename to behave as a move.
|
|
1734
|
+
// assertDestinationAvailable already enforced that any existing directory
|
|
1735
|
+
// contains only `.rivet`, which we preserve across the move.
|
|
1736
|
+
const destinationParent = path_1.default.dirname(destinationPath);
|
|
1737
|
+
await fs_1.default.promises.mkdir(destinationParent, { recursive: true });
|
|
1738
|
+
let preservedRivetParent;
|
|
1739
|
+
const existingRivetPath = path_1.default.join(destinationPath, '.rivet');
|
|
1740
|
+
if (fs_1.default.existsSync(destinationPath)) {
|
|
1741
|
+
preservedRivetParent = await fs_1.default.promises.mkdtemp(path_1.default.join(destinationParent, '.rivet-preserve-'));
|
|
1742
|
+
if (fs_1.default.existsSync(existingRivetPath)) {
|
|
1743
|
+
await fs_1.default.promises.rename(existingRivetPath, path_1.default.join(preservedRivetParent, '.rivet'));
|
|
1744
|
+
}
|
|
1745
|
+
await fs_1.default.promises.rm(destinationPath, { recursive: true, force: true });
|
|
1746
|
+
}
|
|
1747
|
+
try {
|
|
1748
|
+
await fs_1.default.promises.rename(sourceWorktreePath, destinationPath);
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
if (preservedRivetParent && !fs_1.default.existsSync(destinationPath)) {
|
|
1752
|
+
await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
|
|
1753
|
+
const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
|
|
1754
|
+
if (fs_1.default.existsSync(preservedRivetPath)) {
|
|
1755
|
+
await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
throw err;
|
|
1759
|
+
}
|
|
1760
|
+
if (preservedRivetParent) {
|
|
1761
|
+
const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
|
|
1762
|
+
if (fs_1.default.existsSync(preservedRivetPath)) {
|
|
1763
|
+
await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
|
|
1764
|
+
}
|
|
1765
|
+
await fs_1.default.promises.rm(preservedRivetParent, {
|
|
1766
|
+
recursive: true,
|
|
1767
|
+
force: true,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
// Replace the variant-baseline git history with a fresh init so the
|
|
1771
|
+
// user doesn't inherit a `rivet: variant baseline` commit.
|
|
1772
|
+
const dotGit = path_1.default.join(destinationPath, '.git');
|
|
1773
|
+
try {
|
|
1774
|
+
fs_1.default.rmSync(dotGit, { recursive: true, force: true });
|
|
1775
|
+
}
|
|
1776
|
+
catch {
|
|
1777
|
+
// Best-effort — leaving the baseline .git is preferable to failing
|
|
1778
|
+
// the commit after we've moved the tree.
|
|
1779
|
+
}
|
|
1780
|
+
try {
|
|
1781
|
+
const git = (0, simple_git_1.simpleGit)(destinationPath);
|
|
1782
|
+
await git.raw(['init']);
|
|
1783
|
+
await git.raw(['add', '-A']);
|
|
1784
|
+
await git
|
|
1785
|
+
.raw([
|
|
1786
|
+
'-c',
|
|
1787
|
+
'user.name=Rivet',
|
|
1788
|
+
'-c',
|
|
1789
|
+
'user.email=hello@tryrivet.design',
|
|
1790
|
+
'commit',
|
|
1791
|
+
'--no-gpg-sign',
|
|
1792
|
+
'-m',
|
|
1793
|
+
'Initial commit (created with Rivet)',
|
|
1794
|
+
])
|
|
1795
|
+
.catch(() => {
|
|
1796
|
+
// Best-effort: missing user identity or empty tree leaves the
|
|
1797
|
+
// commit step un-applied. Files are still in place.
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
catch (err) {
|
|
1801
|
+
log.warn(`Initial git history setup at ${destinationPath} failed`, err);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Stop every variant dev server currently running for `resources`. Clears
|
|
1806
|
+
* the `devServerProcess` / `port` fields on each record so subsequent
|
|
1807
|
+
* teardown logic doesn't try to stop them a second time. Called from
|
|
1808
|
+
* `commitVariant` (vite_app rename path) BEFORE the worktree directory is
|
|
1809
|
+
* renamed — leaving Vite alive across the rename leaks empty `.vite/`
|
|
1810
|
+
* stub dirs at the old paths.
|
|
1811
|
+
*/
|
|
1812
|
+
async stopAllDevServersForSession(resources) {
|
|
1813
|
+
const stops = [];
|
|
1814
|
+
for (const record of resources.worktrees.values()) {
|
|
1815
|
+
if (!record.devServerProcess)
|
|
1816
|
+
continue;
|
|
1817
|
+
const proc = record.devServerProcess;
|
|
1818
|
+
stops.push(this.worktrees
|
|
1819
|
+
.stopDevServer(proc)
|
|
1820
|
+
.catch((err) => log.warn(`stopDevServer pre-commit failed for ${resources.sessionId}/${record.workItemId}`, err)));
|
|
1821
|
+
record.devServerProcess = undefined;
|
|
1822
|
+
record.port = undefined;
|
|
1823
|
+
}
|
|
1824
|
+
await Promise.all(stops);
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Spawn a fresh dev server at the user-facing destinationPath and retarget
|
|
1828
|
+
* the proxy so the iframe stays live across commit. Stores the process on
|
|
1829
|
+
* `resources.committedDevServer` so teardown can hand it off to the
|
|
1830
|
+
* lingering registry instead of killing it. Best-effort: a failure here is
|
|
1831
|
+
* non-fatal — the commit still succeeds; the user just has to run
|
|
1832
|
+
* `npm run dev` themselves to bring the preview back.
|
|
1833
|
+
*/
|
|
1834
|
+
async startCommittedDevServer(args) {
|
|
1835
|
+
try {
|
|
1836
|
+
const port = await this.worktrees.getFreePort();
|
|
1837
|
+
const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
|
|
1838
|
+
'run',
|
|
1839
|
+
'dev',
|
|
1840
|
+
'--',
|
|
1841
|
+
'--port',
|
|
1842
|
+
String(port),
|
|
1843
|
+
'--host',
|
|
1844
|
+
FRESH_DEV_SERVER_HOST,
|
|
1845
|
+
], { PORT: String(port) });
|
|
1846
|
+
args.resources.committedDevServer = {
|
|
1847
|
+
proc,
|
|
1848
|
+
port,
|
|
1849
|
+
path: args.destinationPath,
|
|
1850
|
+
};
|
|
1851
|
+
try {
|
|
1852
|
+
this.switchPreviewPort?.(port);
|
|
1853
|
+
}
|
|
1854
|
+
catch (err) {
|
|
1855
|
+
log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
|
|
1856
|
+
}
|
|
1857
|
+
log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
|
|
1858
|
+
}
|
|
1859
|
+
catch (err) {
|
|
1860
|
+
log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Stop any committed dev servers that were left alive past their session's
|
|
1865
|
+
* teardown. Called at the start of a new `propose` (so the next session's
|
|
1866
|
+
* preview doesn't compete with stale processes) and exposed for explicit
|
|
1867
|
+
* shutdown hooks. Best-effort: per-server failures are logged but don't
|
|
1868
|
+
* propagate.
|
|
1869
|
+
*/
|
|
1870
|
+
async stopLingeringCommittedDevServers() {
|
|
1871
|
+
const entries = [...this.lingeringCommittedDevServers.entries()];
|
|
1872
|
+
this.lingeringCommittedDevServers.clear();
|
|
1873
|
+
await Promise.all(entries.map(async ([sessionId, entry]) => {
|
|
1874
|
+
try {
|
|
1875
|
+
await this.worktrees.stopDevServer(entry.proc);
|
|
1876
|
+
}
|
|
1877
|
+
catch (err) {
|
|
1878
|
+
log.warn(`stopDevServer for lingering committed server (session ${sessionId}, port ${entry.port}) failed`, err);
|
|
1879
|
+
}
|
|
1880
|
+
}));
|
|
1881
|
+
}
|
|
944
1882
|
async teardownSession(sessionId, reason) {
|
|
945
1883
|
const resources = this.resources.get(sessionId);
|
|
946
1884
|
if (!resources)
|
|
@@ -950,17 +1888,46 @@ class AgentVariantsOrchestrator {
|
|
|
950
1888
|
resources.cleanupStarted = true;
|
|
951
1889
|
log.info(`Tearing down session ${sessionId} (reason: ${reason})`);
|
|
952
1890
|
// Stop dev servers in parallel; ignore individual failures.
|
|
1891
|
+
// NOTE: For the fresh vite_app rename path, commitVariant already stopped
|
|
1892
|
+
// these dev servers BEFORE the rename (so Vite couldn't recreate `.vite/`
|
|
1893
|
+
// stub dirs at the old worktree paths). In that case devServerProcess is
|
|
1894
|
+
// already cleared and this loop is a no-op — exactly the desired behavior.
|
|
953
1895
|
const stops = [...resources.worktrees.values()]
|
|
954
1896
|
.filter((r) => r.devServerProcess)
|
|
955
1897
|
.map((r) => this.worktrees
|
|
956
1898
|
.stopDevServer(r.devServerProcess)
|
|
957
1899
|
.catch((err) => log.warn(`stopDevServer failed for ${sessionId}/${r.workItemId}`, err)));
|
|
958
1900
|
await Promise.all(stops);
|
|
959
|
-
|
|
960
|
-
|
|
1901
|
+
// The committed dev server (spawned at destinationPath after a fresh
|
|
1902
|
+
// vite_app commit) intentionally outlives this teardown — the iframe is
|
|
1903
|
+
// pointed at it via the proxy and the user just chose this project. Hand
|
|
1904
|
+
// it off to the lingering registry; it'll be stopped on the next
|
|
1905
|
+
// `propose` or explicit shutdown.
|
|
1906
|
+
if (resources.committedDevServer && reason === 'committed') {
|
|
1907
|
+
this.lingeringCommittedDevServers.set(sessionId, resources.committedDevServer);
|
|
961
1908
|
}
|
|
962
|
-
|
|
963
|
-
|
|
1909
|
+
else if (resources.committedDevServer) {
|
|
1910
|
+
// Non-committed teardown (cancel/shutdown) with a committed dev server
|
|
1911
|
+
// shouldn't happen in current code paths, but stop it defensively so we
|
|
1912
|
+
// don't leak procs.
|
|
1913
|
+
try {
|
|
1914
|
+
await this.worktrees.stopDevServer(resources.committedDevServer.proc);
|
|
1915
|
+
}
|
|
1916
|
+
catch (err) {
|
|
1917
|
+
log.warn(`stopDevServer for committed dev server (session ${sessionId}) failed`, err);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
// When a vite_app commit already moved the unchosen worktrees into a
|
|
1921
|
+
// sibling history dir, calling cleanupSession would re-delete them. Skip
|
|
1922
|
+
// the worktree removal step in that case — the move already cleared the
|
|
1923
|
+
// .rivet-variants/ entries for the chosen + preserved variants.
|
|
1924
|
+
if (!resources.vitePreservedSiblings) {
|
|
1925
|
+
try {
|
|
1926
|
+
await this.worktrees.cleanupSession(sessionId, resources.freshDestinationParent);
|
|
1927
|
+
}
|
|
1928
|
+
catch (err) {
|
|
1929
|
+
log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
|
|
1930
|
+
}
|
|
964
1931
|
}
|
|
965
1932
|
this.resources.delete(sessionId);
|
|
966
1933
|
}
|
|
@@ -975,6 +1942,8 @@ class AgentVariantsOrchestrator {
|
|
|
975
1942
|
committedVariantIds: new Set(),
|
|
976
1943
|
startedAt: Date.now(),
|
|
977
1944
|
leasedAt: new Map(),
|
|
1945
|
+
qaResults: new Map(),
|
|
1946
|
+
vitePreservedSiblings: false,
|
|
978
1947
|
};
|
|
979
1948
|
this.resources.set(sessionId, r);
|
|
980
1949
|
}
|
|
@@ -1048,6 +2017,35 @@ function countDiffFiles(diff) {
|
|
|
1048
2017
|
// Each file in a unified diff starts with "diff --git ".
|
|
1049
2018
|
return (diff.match(/^diff --git /gm) ?? []).length;
|
|
1050
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Count source files under a materialized fresh-project destination,
|
|
2022
|
+
* skipping install artifacts (`node_modules`, `.git`, build outputs) so the
|
|
2023
|
+
* commit telemetry / chip count reflects the agent-visible surface area
|
|
2024
|
+
* rather than the install footprint.
|
|
2025
|
+
*/
|
|
2026
|
+
function countWorktreeFiles(destinationPath) {
|
|
2027
|
+
let count = 0;
|
|
2028
|
+
const walk = (dir) => {
|
|
2029
|
+
let entries;
|
|
2030
|
+
try {
|
|
2031
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
2032
|
+
}
|
|
2033
|
+
catch {
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
for (const entry of entries) {
|
|
2037
|
+
if (MATERIALIZE_EXCLUDE.has(entry.name))
|
|
2038
|
+
continue;
|
|
2039
|
+
const full = path_1.default.join(dir, entry.name);
|
|
2040
|
+
if (entry.isDirectory())
|
|
2041
|
+
walk(full);
|
|
2042
|
+
else if (entry.isFile())
|
|
2043
|
+
count += 1;
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
walk(destinationPath);
|
|
2047
|
+
return count;
|
|
2048
|
+
}
|
|
1051
2049
|
const enrichDesignSource = (design) => {
|
|
1052
2050
|
if (!design || design.kind !== 'slug')
|
|
1053
2051
|
return design;
|
|
@@ -1075,8 +2073,94 @@ const toActiveProjectContext = (projectContext) => {
|
|
|
1075
2073
|
},
|
|
1076
2074
|
}
|
|
1077
2075
|
: {}),
|
|
2076
|
+
...(projectContext.executionPlan
|
|
2077
|
+
? {
|
|
2078
|
+
executionPlan: {
|
|
2079
|
+
mode: projectContext.executionPlan.mode,
|
|
2080
|
+
confidence: projectContext.executionPlan.confidence,
|
|
2081
|
+
reason: projectContext.executionPlan.reason,
|
|
2082
|
+
assetCount: projectContext.executionPlan.assetPlan?.length ?? 0,
|
|
2083
|
+
runtimeRequirementCount: projectContext.executionPlan.runtimeRequirements?.length ?? 0,
|
|
2084
|
+
},
|
|
2085
|
+
}
|
|
2086
|
+
: {}),
|
|
1078
2087
|
};
|
|
1079
2088
|
};
|
|
2089
|
+
/**
|
|
2090
|
+
* Resolve the user-facing supporting artifacts for a session.
|
|
2091
|
+
*
|
|
2092
|
+
* For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
|
|
2093
|
+
* turned into a `design_context` artifact carrying the full DESIGN.md
|
|
2094
|
+
* markdown:
|
|
2095
|
+
* - `slug` entries resolve bundled catalog markdown via the design catalog.
|
|
2096
|
+
* - `markdown` entries (Agent Browser / inspiration extractor output) carry
|
|
2097
|
+
* their stored markdown verbatim.
|
|
2098
|
+
* Slots whose markdown can't be resolved are skipped so the UI never renders
|
|
2099
|
+
* a metadata-only DESIGN.md row.
|
|
2100
|
+
*/
|
|
2101
|
+
const buildSessionArtifacts = (projectContext) => {
|
|
2102
|
+
if (projectContext.kind !== 'fresh')
|
|
2103
|
+
return [];
|
|
2104
|
+
const designContext = projectContext.designContext;
|
|
2105
|
+
if (!designContext || designContext.length === 0)
|
|
2106
|
+
return [];
|
|
2107
|
+
const artifactsByContent = new Map();
|
|
2108
|
+
designContext.forEach((entry, slot) => {
|
|
2109
|
+
if (!entry)
|
|
2110
|
+
return;
|
|
2111
|
+
if (entry.kind === 'slug') {
|
|
2112
|
+
const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
|
|
2113
|
+
if (!markdown)
|
|
2114
|
+
return;
|
|
2115
|
+
const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
|
|
2116
|
+
addDesignContextArtifact(artifactsByContent, {
|
|
2117
|
+
id: `design_context:${slot}:${entry.slug}`,
|
|
2118
|
+
kind: 'design_context',
|
|
2119
|
+
label: catalogEntry?.name ?? entry.slug,
|
|
2120
|
+
...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
|
|
2121
|
+
status: 'ready',
|
|
2122
|
+
source: 'static',
|
|
2123
|
+
contentType: 'text/markdown',
|
|
2124
|
+
content: markdown,
|
|
2125
|
+
});
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (!entry.content)
|
|
2129
|
+
return;
|
|
2130
|
+
addDesignContextArtifact(artifactsByContent, {
|
|
2131
|
+
id: `design_context:${slot}:${entry.label}`,
|
|
2132
|
+
kind: 'design_context',
|
|
2133
|
+
label: entry.label,
|
|
2134
|
+
status: 'ready',
|
|
2135
|
+
source: 'agent_browser',
|
|
2136
|
+
contentType: 'text/markdown',
|
|
2137
|
+
content: entry.content,
|
|
2138
|
+
});
|
|
2139
|
+
});
|
|
2140
|
+
return [...artifactsByContent.values()].map((artifact) => {
|
|
2141
|
+
if (artifact.usedByVariantCount <= 1)
|
|
2142
|
+
return artifact;
|
|
2143
|
+
return {
|
|
2144
|
+
...artifact,
|
|
2145
|
+
summary: artifact.summary
|
|
2146
|
+
? `${artifact.summary} Used by ${artifact.usedByVariantCount} variants.`
|
|
2147
|
+
: `Used by ${artifact.usedByVariantCount} variants.`,
|
|
2148
|
+
};
|
|
2149
|
+
});
|
|
2150
|
+
};
|
|
2151
|
+
const addDesignContextArtifact = (artifactsByContent, artifact) => {
|
|
2152
|
+
if (!artifact.content)
|
|
2153
|
+
return;
|
|
2154
|
+
const existing = artifactsByContent.get(artifact.content);
|
|
2155
|
+
if (existing) {
|
|
2156
|
+
existing.usedByVariantCount += 1;
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
artifactsByContent.set(artifact.content, {
|
|
2160
|
+
...artifact,
|
|
2161
|
+
usedByVariantCount: 1,
|
|
2162
|
+
});
|
|
2163
|
+
};
|
|
1080
2164
|
const summarizeDesignContext = (designContext) => {
|
|
1081
2165
|
if (!designContext)
|
|
1082
2166
|
return null;
|
|
@@ -1196,6 +2280,87 @@ const defaultInstallDependencies = (worktreePath) => {
|
|
|
1196
2280
|
});
|
|
1197
2281
|
});
|
|
1198
2282
|
};
|
|
2283
|
+
/**
|
|
2284
|
+
* True when `a` and `b` (or the closest existing ancestor of each) live on
|
|
2285
|
+
* the same filesystem volume. Used by commitVariant to decide between an
|
|
2286
|
+
* O(1) rename and a recursive copy-with-reinstall fallback.
|
|
2287
|
+
*
|
|
2288
|
+
* `destinationPath` for fresh sessions does not exist yet (we'd fail the
|
|
2289
|
+
* "destination must be empty" guard otherwise), so we compare against its
|
|
2290
|
+
* parent directory which is guaranteed to exist (createZeroToOneTool either
|
|
2291
|
+
* created it or validated it).
|
|
2292
|
+
*/
|
|
2293
|
+
function areSameVolume(a, b) {
|
|
2294
|
+
try {
|
|
2295
|
+
const statA = fs_1.default.statSync(a);
|
|
2296
|
+
const parentB = fs_1.default.existsSync(b) ? b : path_1.default.dirname(b);
|
|
2297
|
+
const statB = fs_1.default.statSync(parentB);
|
|
2298
|
+
return statA.dev === statB.dev;
|
|
2299
|
+
}
|
|
2300
|
+
catch {
|
|
2301
|
+
return false;
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Materialize node_modules from symlink → real directory at the given path,
|
|
2306
|
+
* so a subsequent rename of that path leaves a self-contained tree.
|
|
2307
|
+
*
|
|
2308
|
+
* Fresh worktrees 1..N share variant 0's node_modules via a symlink (see
|
|
2309
|
+
* runBackgroundInstall). When the user picks variant K and we rename it
|
|
2310
|
+
* into the destination, the sibling worktrees get torn down — which would
|
|
2311
|
+
* otherwise leave a dangling symlink in the destination if variant K was
|
|
2312
|
+
* the one with the symlink (K > 0).
|
|
2313
|
+
*
|
|
2314
|
+
* Returns true when the destination must run a dependency install after the
|
|
2315
|
+
* rename because node_modules is missing or points nowhere.
|
|
2316
|
+
*/
|
|
2317
|
+
async function resolveNodeModulesSymlink(worktreePath) {
|
|
2318
|
+
const nodeModulesPath = path_1.default.join(worktreePath, 'node_modules');
|
|
2319
|
+
let lstat;
|
|
2320
|
+
try {
|
|
2321
|
+
lstat = await fs_1.default.promises.lstat(nodeModulesPath);
|
|
2322
|
+
}
|
|
2323
|
+
catch {
|
|
2324
|
+
return true;
|
|
2325
|
+
}
|
|
2326
|
+
if (!lstat.isSymbolicLink())
|
|
2327
|
+
return false;
|
|
2328
|
+
let target;
|
|
2329
|
+
try {
|
|
2330
|
+
target = await fs_1.default.promises.realpath(nodeModulesPath);
|
|
2331
|
+
}
|
|
2332
|
+
catch {
|
|
2333
|
+
await fs_1.default.promises.rm(nodeModulesPath, { force: true });
|
|
2334
|
+
return true;
|
|
2335
|
+
}
|
|
2336
|
+
// Stage the copy under a sibling temp name so a mid-copy failure (disk
|
|
2337
|
+
// full, EACCES, etc.) leaves the original symlink intact rather than
|
|
2338
|
+
// wiping it and leaving the worktree with no node_modules at all.
|
|
2339
|
+
// The final swap is then atomic on the same filesystem (rename + unlink).
|
|
2340
|
+
const stagingPath = `${nodeModulesPath}.materializing-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
2341
|
+
try {
|
|
2342
|
+
await fs_1.default.promises.cp(target, stagingPath, {
|
|
2343
|
+
recursive: true,
|
|
2344
|
+
dereference: false,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
catch (err) {
|
|
2348
|
+
// Best-effort cleanup of the partial staging dir; leave the symlink
|
|
2349
|
+
// intact so the caller can re-run materialization.
|
|
2350
|
+
await fs_1.default.promises
|
|
2351
|
+
.rm(stagingPath, { recursive: true, force: true })
|
|
2352
|
+
.catch(() => { });
|
|
2353
|
+
throw err;
|
|
2354
|
+
}
|
|
2355
|
+
// Swap: remove the symlink, then rename the staging dir into place.
|
|
2356
|
+
// Both operations are local fs ops on the same parent — fast and
|
|
2357
|
+
// unlikely to fail independently, but if the rename fails the symlink
|
|
2358
|
+
// is already gone, so prefer to leave the staging dir on disk over a
|
|
2359
|
+
// half-broken worktree (the caller will surface the throw).
|
|
2360
|
+
await fs_1.default.promises.rm(nodeModulesPath, { force: true });
|
|
2361
|
+
await fs_1.default.promises.rename(stagingPath, nodeModulesPath);
|
|
2362
|
+
return false;
|
|
2363
|
+
}
|
|
1199
2364
|
const defaultMaterializeProject = async (sourceWorktreePath, destinationPath) => {
|
|
1200
2365
|
await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
|
|
1201
2366
|
await fs_1.default.promises.cp(sourceWorktreePath, destinationPath, {
|