ralph-review 0.2.2 → 0.2.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/README.md +123 -16
- package/package.json +7 -5
- package/src/cli-core.ts +51 -88
- package/src/cli-rrr.ts +1 -4
- package/src/cli.ts +1 -2
- package/src/commands/apply.ts +35 -20
- package/src/commands/config-handlers.ts +68 -69
- package/src/commands/config-model.ts +147 -125
- package/src/commands/doctor.ts +2 -4
- package/src/commands/fix.ts +73 -51
- package/src/commands/handoff-selection.ts +6 -8
- package/src/commands/interactive-deps.ts +43 -0
- package/src/commands/list.ts +24 -7
- package/src/commands/log.ts +12 -12
- package/src/commands/run.ts +32 -33
- package/src/commands/status.ts +25 -4
- package/src/commands/stop.ts +99 -62
- package/src/commands/update.ts +2 -4
- package/src/lib/agents/claude.ts +4 -16
- package/src/lib/agents/core.ts +16 -0
- package/src/lib/agents/droid.ts +4 -15
- package/src/lib/agents/models.ts +9 -0
- package/src/lib/cli-parser.ts +19 -14
- package/src/lib/handoff.ts +16 -7
- package/src/lib/logging/session-log.ts +2 -1
- package/src/lib/prompts/defaults/review.md +1 -1
- package/src/lib/prompts/protocol.ts +2 -1
- package/src/lib/review-workflow/findings/artifact.ts +3 -1
- package/src/lib/review-workflow/findings/types.ts +1 -1
- package/src/lib/review-workflow/remediation/prompt.ts +7 -7
- package/src/lib/review-workflow/remediation/run-batch-fix-phase.ts +30 -20
- package/src/lib/review-workflow/remediation/run-fix-session.ts +70 -68
- package/src/lib/review-workflow/results/finalize-result.ts +20 -3
- package/src/lib/review-workflow/run-review-cycle.ts +1 -12
- package/src/lib/review-workflow/session-status.ts +13 -0
- package/src/lib/review-workflow/shared/framed-json.ts +2 -47
- package/src/lib/session/state.ts +50 -38
- package/src/lib/structured-output.ts +24 -9
- package/src/lib/tui/dashboard/HelpOverlay.tsx +13 -57
- package/src/lib/tui/dashboard/ReviewModeOverlay.tsx +2 -2
- package/src/lib/tui/dashboard/StatusBar.tsx +12 -50
- package/src/lib/tui/dashboard/StopSessionPickerOverlay.tsx +4 -22
- package/src/lib/tui/sessions/detail/DetailPane.tsx +6 -64
- package/src/lib/tui/sessions/detail/IdleStateView.tsx +10 -12
- package/src/lib/tui/sessions/detail/SessionDetailView.tsx +1 -1
- package/src/lib/tui/sessions/detail/session-detail-parts.tsx +66 -87
- package/src/lib/tui/sessions/history/SessionListOverlay.tsx +17 -75
- package/src/lib/tui/sessions/review-summary-parser.ts +2 -68
- package/src/lib/tui/shared/CenteredModal.tsx +44 -0
- package/src/lib/tui/shared/KeyboardShortcutsModal.tsx +14 -0
- package/src/lib/tui/shared/ShortcutHint.tsx +33 -0
- package/src/lib/tui/workspace/Workspace.tsx +6 -91
- package/src/lib/tui/workspace/use-workspace-state.ts +113 -61
- package/src/lib/types/fix.ts +15 -48
- package/src/lib/types/guards.ts +47 -0
- package/src/lib/types/review.ts +5 -39
|
@@ -19,7 +19,7 @@ import type { Config } from "@/lib/types";
|
|
|
19
19
|
import type { FixDecision } from "@/lib/types/domain";
|
|
20
20
|
|
|
21
21
|
interface BatchFixerResultEntry {
|
|
22
|
-
status: "resolved" | "unresolved";
|
|
22
|
+
status: "resolved" | "skipped" | "unresolved";
|
|
23
23
|
summary: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -66,7 +66,9 @@ function isBatchFixerResultEntry(value: unknown): value is BatchFixerResultEntry
|
|
|
66
66
|
|
|
67
67
|
const candidate = value as Record<string, unknown>;
|
|
68
68
|
return (
|
|
69
|
-
(candidate.status === "resolved" ||
|
|
69
|
+
(candidate.status === "resolved" ||
|
|
70
|
+
candidate.status === "skipped" ||
|
|
71
|
+
candidate.status === "unresolved") &&
|
|
70
72
|
typeof candidate.summary === "string"
|
|
71
73
|
);
|
|
72
74
|
}
|
|
@@ -124,6 +126,30 @@ function toFixResults(
|
|
|
124
126
|
});
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
async function appendBatchFixLog(
|
|
130
|
+
deps: RunBatchFixPhaseDependencies,
|
|
131
|
+
options: RunBatchFixPhaseOptions,
|
|
132
|
+
startedAt: number,
|
|
133
|
+
fixResults: FindingFixResult[],
|
|
134
|
+
error?: unknown
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await deps.appendLog(options.artifact.logPath, {
|
|
137
|
+
type: "batch_fix",
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
duration: Date.now() - startedAt,
|
|
140
|
+
selectedFindingIds: options.selection.selectedFindingIds,
|
|
141
|
+
fixResults,
|
|
142
|
+
...(error === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: {
|
|
145
|
+
error: {
|
|
146
|
+
phase: "fixer" as const,
|
|
147
|
+
message: error instanceof Error ? error.message : String(error),
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
export async function runBatchFixPhase(
|
|
128
154
|
options: RunBatchFixPhaseOptions,
|
|
129
155
|
deps: RunBatchFixPhaseDependencies = DEFAULT_RUN_BATCH_FIX_PHASE_DEPENDENCIES
|
|
@@ -168,13 +194,7 @@ export async function runBatchFixPhase(
|
|
|
168
194
|
const fixResults = toFixResults(options.selection.selectedFindingIds, parsed);
|
|
169
195
|
|
|
170
196
|
deps.discardCheckpoint(options.worktree.worktreeProjectPath, checkpoint);
|
|
171
|
-
await deps
|
|
172
|
-
type: "batch_fix",
|
|
173
|
-
timestamp: Date.now(),
|
|
174
|
-
duration: Date.now() - startedAt,
|
|
175
|
-
selectedFindingIds: options.selection.selectedFindingIds,
|
|
176
|
-
fixResults,
|
|
177
|
-
});
|
|
197
|
+
await appendBatchFixLog(deps, options, startedAt, fixResults);
|
|
178
198
|
|
|
179
199
|
return {
|
|
180
200
|
phase: "batch-fix",
|
|
@@ -183,17 +203,7 @@ export async function runBatchFixPhase(
|
|
|
183
203
|
};
|
|
184
204
|
} catch (error) {
|
|
185
205
|
deps.rollbackToCheckpoint(options.worktree.worktreeProjectPath, checkpoint);
|
|
186
|
-
await deps
|
|
187
|
-
type: "batch_fix",
|
|
188
|
-
timestamp: Date.now(),
|
|
189
|
-
duration: Date.now() - startedAt,
|
|
190
|
-
selectedFindingIds: options.selection.selectedFindingIds,
|
|
191
|
-
fixResults: [],
|
|
192
|
-
error: {
|
|
193
|
-
phase: "fixer",
|
|
194
|
-
message: error instanceof Error ? error.message : String(error),
|
|
195
|
-
},
|
|
196
|
-
});
|
|
206
|
+
await appendBatchFixLog(deps, options, startedAt, [], error);
|
|
197
207
|
throw error;
|
|
198
208
|
}
|
|
199
209
|
}
|
|
@@ -56,7 +56,9 @@ export interface RunFixSessionDependencies {
|
|
|
56
56
|
discardSessionWorktree: typeof discardSessionWorktree;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
async function
|
|
59
|
+
export async function promptForFixSelection(
|
|
60
|
+
artifact: FindingsArtifact
|
|
61
|
+
): Promise<FindingId[] | null> {
|
|
60
62
|
const selection = await p.multiselect({
|
|
61
63
|
message: "Choose findings to fix",
|
|
62
64
|
options: artifact.findings.map((finding) => ({
|
|
@@ -80,7 +82,7 @@ const DEFAULT_RUN_FIX_SESSION_DEPENDENCIES: RunFixSessionDependencies = {
|
|
|
80
82
|
createSessionWorktreeAt,
|
|
81
83
|
updateSelection,
|
|
82
84
|
appendLog,
|
|
83
|
-
promptForSelection:
|
|
85
|
+
promptForSelection: promptForFixSelection,
|
|
84
86
|
runBatchFixPhase,
|
|
85
87
|
appendFixResults,
|
|
86
88
|
updateRetainedWorktree,
|
|
@@ -123,6 +125,26 @@ function buildRetainedCleanupWorktree(
|
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
async function discardRetainedWorktree(
|
|
129
|
+
deps: RunFixSessionDependencies,
|
|
130
|
+
artifact: FindingsArtifact,
|
|
131
|
+
worktree: GitSessionWorktree,
|
|
132
|
+
retainedWorktree: RetainedSessionWorktree
|
|
133
|
+
): Promise<FindingsArtifact> {
|
|
134
|
+
deps.discardSessionWorktree(buildRetainedCleanupWorktree(worktree, retainedWorktree));
|
|
135
|
+
try {
|
|
136
|
+
await deps.updateRetainedWorktree(
|
|
137
|
+
CONFIG_DIR,
|
|
138
|
+
artifact.projectPath,
|
|
139
|
+
artifact.sessionId,
|
|
140
|
+
undefined
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
// Best-effort cleanup; the remediation result is still returned.
|
|
144
|
+
}
|
|
145
|
+
return artifact;
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
function resolveSelectionMode(selector: FixSessionSelector | undefined): {
|
|
127
149
|
mode: "all" | "priority" | "id" | "interactive";
|
|
128
150
|
count: number;
|
|
@@ -232,6 +254,26 @@ async function emitProgress(
|
|
|
232
254
|
await onProgress?.(updates);
|
|
233
255
|
}
|
|
234
256
|
|
|
257
|
+
async function emitResultProgress(
|
|
258
|
+
onProgress: RunFixSessionOptions["onProgress"],
|
|
259
|
+
result: FixSessionResult,
|
|
260
|
+
updates: Partial<SessionState> = {}
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
await emitProgress(onProgress, {
|
|
263
|
+
currentPhase: result.phase,
|
|
264
|
+
phase: result.phase,
|
|
265
|
+
sessionStatus: result.sessionStatus,
|
|
266
|
+
currentAgent: null,
|
|
267
|
+
selectedFindingIds: result.selection.selectedFindingIds,
|
|
268
|
+
reviewOutcome: result.reviewOutcome,
|
|
269
|
+
handoffStatus: result.handoffStatus,
|
|
270
|
+
handoffId: result.handoffId,
|
|
271
|
+
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
272
|
+
commitSha: result.commitSha,
|
|
273
|
+
...updates,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
235
277
|
export async function runFixSession(
|
|
236
278
|
config: Config,
|
|
237
279
|
options: RunFixSessionOptions,
|
|
@@ -263,18 +305,7 @@ export async function runFixSession(
|
|
|
263
305
|
unselectedFindings: [...artifact.findings],
|
|
264
306
|
reason: resolvedSelection.error,
|
|
265
307
|
});
|
|
266
|
-
await
|
|
267
|
-
currentPhase: result.phase,
|
|
268
|
-
phase: result.phase,
|
|
269
|
-
sessionStatus: result.sessionStatus,
|
|
270
|
-
currentAgent: null,
|
|
271
|
-
selectedFindingIds: result.selection.selectedFindingIds,
|
|
272
|
-
reviewOutcome: result.reviewOutcome,
|
|
273
|
-
handoffStatus: result.handoffStatus,
|
|
274
|
-
handoffId: result.handoffId,
|
|
275
|
-
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
276
|
-
commitSha: result.commitSha,
|
|
277
|
-
});
|
|
308
|
+
await emitResultProgress(options.onProgress, result);
|
|
278
309
|
return result;
|
|
279
310
|
}
|
|
280
311
|
|
|
@@ -287,18 +318,7 @@ export async function runFixSession(
|
|
|
287
318
|
reason: "Selection cancelled. Findings remain pending.",
|
|
288
319
|
unselectedFindings: [...artifact.findings],
|
|
289
320
|
});
|
|
290
|
-
await
|
|
291
|
-
currentPhase: result.phase,
|
|
292
|
-
phase: result.phase,
|
|
293
|
-
sessionStatus: result.sessionStatus,
|
|
294
|
-
currentAgent: null,
|
|
295
|
-
selectedFindingIds: result.selection.selectedFindingIds,
|
|
296
|
-
reviewOutcome: result.reviewOutcome,
|
|
297
|
-
handoffStatus: result.handoffStatus,
|
|
298
|
-
handoffId: result.handoffId,
|
|
299
|
-
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
300
|
-
commitSha: result.commitSha,
|
|
301
|
-
});
|
|
321
|
+
await emitResultProgress(options.onProgress, result);
|
|
302
322
|
return result;
|
|
303
323
|
}
|
|
304
324
|
|
|
@@ -334,18 +354,7 @@ export async function runFixSession(
|
|
|
334
354
|
selection: resolvedSelection.selection,
|
|
335
355
|
unselectedFindings: [...artifact.findings],
|
|
336
356
|
});
|
|
337
|
-
await
|
|
338
|
-
currentPhase: result.phase,
|
|
339
|
-
phase: result.phase,
|
|
340
|
-
sessionStatus: result.sessionStatus,
|
|
341
|
-
currentAgent: null,
|
|
342
|
-
selectedFindingIds: result.selection.selectedFindingIds,
|
|
343
|
-
reviewOutcome: result.reviewOutcome,
|
|
344
|
-
handoffStatus: result.handoffStatus,
|
|
345
|
-
handoffId: result.handoffId,
|
|
346
|
-
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
347
|
-
commitSha: result.commitSha,
|
|
348
|
-
});
|
|
357
|
+
await emitResultProgress(options.onProgress, result);
|
|
349
358
|
return result;
|
|
350
359
|
}
|
|
351
360
|
|
|
@@ -412,7 +421,21 @@ export async function runFixSession(
|
|
|
412
421
|
worktree,
|
|
413
422
|
});
|
|
414
423
|
|
|
415
|
-
if (result.
|
|
424
|
+
if (result.handoffStatus) {
|
|
425
|
+
if (artifactWithFixResults.retainedWorktree) {
|
|
426
|
+
const artifactWithoutRetainedWorktree = await discardRetainedWorktree(
|
|
427
|
+
deps,
|
|
428
|
+
artifact,
|
|
429
|
+
worktree,
|
|
430
|
+
artifactWithFixResults.retainedWorktree
|
|
431
|
+
);
|
|
432
|
+
artifactForResult = artifactWithoutRetainedWorktree;
|
|
433
|
+
result = {
|
|
434
|
+
...result,
|
|
435
|
+
artifact: artifactWithoutRetainedWorktree,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
} else if (result.reviewOutcome === "incomplete") {
|
|
416
439
|
try {
|
|
417
440
|
retainedWorktree = deps.finalizeSessionWorktree(worktree) ?? undefined;
|
|
418
441
|
} catch {
|
|
@@ -435,14 +458,11 @@ export async function runFixSession(
|
|
|
435
458
|
};
|
|
436
459
|
}
|
|
437
460
|
} else if (artifactWithFixResults.retainedWorktree) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
artifact.projectPath,
|
|
444
|
-
artifact.sessionId,
|
|
445
|
-
undefined
|
|
461
|
+
const artifactWithoutRetainedWorktree = await discardRetainedWorktree(
|
|
462
|
+
deps,
|
|
463
|
+
artifact,
|
|
464
|
+
worktree,
|
|
465
|
+
artifactWithFixResults.retainedWorktree
|
|
446
466
|
);
|
|
447
467
|
artifactForResult = artifactWithoutRetainedWorktree;
|
|
448
468
|
result = {
|
|
@@ -451,17 +471,7 @@ export async function runFixSession(
|
|
|
451
471
|
};
|
|
452
472
|
}
|
|
453
473
|
|
|
454
|
-
await
|
|
455
|
-
currentPhase: result.phase,
|
|
456
|
-
phase: result.phase,
|
|
457
|
-
sessionStatus: result.sessionStatus,
|
|
458
|
-
currentAgent: null,
|
|
459
|
-
selectedFindingIds: result.selection.selectedFindingIds,
|
|
460
|
-
reviewOutcome: result.reviewOutcome,
|
|
461
|
-
handoffStatus: result.handoffStatus,
|
|
462
|
-
handoffId: result.handoffId,
|
|
463
|
-
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
464
|
-
commitSha: result.commitSha,
|
|
474
|
+
await emitResultProgress(options.onProgress, result, {
|
|
465
475
|
worktreeProjectPath: result.retainedWorktree?.worktreeProjectPath,
|
|
466
476
|
worktreeBranch: result.retainedWorktree?.worktreeBranch,
|
|
467
477
|
worktreeMergeReady: result.retainedWorktree?.mergeReady,
|
|
@@ -491,17 +501,9 @@ export async function runFixSession(
|
|
|
491
501
|
(finding) => !selection.selectedFindingIds.includes(finding.id)
|
|
492
502
|
) ?? [],
|
|
493
503
|
});
|
|
494
|
-
await
|
|
504
|
+
await emitResultProgress(options.onProgress, result, {
|
|
495
505
|
currentPhase: phase,
|
|
496
|
-
phase
|
|
497
|
-
sessionStatus: result.sessionStatus,
|
|
498
|
-
currentAgent: null,
|
|
499
|
-
selectedFindingIds: result.selection.selectedFindingIds,
|
|
500
|
-
reviewOutcome: result.reviewOutcome,
|
|
501
|
-
handoffStatus: result.handoffStatus,
|
|
502
|
-
handoffId: result.handoffId,
|
|
503
|
-
handoffUpdatedAt: result.handoffUpdatedAt,
|
|
504
|
-
commitSha: result.commitSha,
|
|
506
|
+
phase,
|
|
505
507
|
worktreeProjectPath: retainedWorktree?.worktreeProjectPath,
|
|
506
508
|
worktreeBranch: retainedWorktree?.worktreeBranch,
|
|
507
509
|
worktreeMergeReady: retainedWorktree?.mergeReady,
|
|
@@ -24,6 +24,9 @@ const DEFAULT_FINALIZE_RESULT_DEPENDENCIES: FinalizeResultDependencies = {
|
|
|
24
24
|
appendLog,
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
const MIXED_UNRESOLVED_REASON =
|
|
28
|
+
"Some selected findings were resolved, but others remain unresolved. Ralph retained the remediation worktree instead of creating a handoff because the partial edits may be unsafe to apply automatically.";
|
|
29
|
+
|
|
27
30
|
export async function finalizeResult(
|
|
28
31
|
input: FinalizeResultInput,
|
|
29
32
|
deps: FinalizeResultDependencies = DEFAULT_FINALIZE_RESULT_DEPENDENCIES
|
|
@@ -39,13 +42,27 @@ export async function finalizeResult(
|
|
|
39
42
|
const unselectedFindings = input.artifact.findings.filter(
|
|
40
43
|
(finding) => !input.selection.selectedFindingIds.includes(finding.id)
|
|
41
44
|
);
|
|
45
|
+
const hasResolvedSelectedFindings = input.fixResults.some(
|
|
46
|
+
(result) => result.status === "resolved"
|
|
47
|
+
);
|
|
48
|
+
const hasUnresolvedSelectedFindings = unresolvedSelectedFindings.length > 0;
|
|
49
|
+
const hasSkippedSelectedFindings = input.fixResults.some((result) => result.status === "skipped");
|
|
50
|
+
const shouldCreateHandoff = hasResolvedSelectedFindings && !hasUnresolvedSelectedFindings;
|
|
42
51
|
|
|
43
52
|
let reviewOutcome: FixSessionResult["reviewOutcome"];
|
|
44
53
|
let reason: string;
|
|
45
54
|
|
|
46
|
-
if (
|
|
55
|
+
if (hasUnresolvedSelectedFindings) {
|
|
47
56
|
reviewOutcome = "incomplete";
|
|
48
|
-
reason =
|
|
57
|
+
reason = hasResolvedSelectedFindings
|
|
58
|
+
? MIXED_UNRESOLVED_REASON
|
|
59
|
+
: "Some selected findings remain unresolved after remediation.";
|
|
60
|
+
} else if (hasResolvedSelectedFindings) {
|
|
61
|
+
reviewOutcome = "fixed-selected";
|
|
62
|
+
reason = "Selected findings were resolved by remediation.";
|
|
63
|
+
} else if (hasSkippedSelectedFindings) {
|
|
64
|
+
reviewOutcome = "fixed-selected";
|
|
65
|
+
reason = "Selected findings were skipped after verification.";
|
|
49
66
|
} else {
|
|
50
67
|
reviewOutcome = "fixed-selected";
|
|
51
68
|
reason = "Selected findings were resolved by remediation.";
|
|
@@ -56,7 +73,7 @@ export async function finalizeResult(
|
|
|
56
73
|
let handoffUpdatedAt: number | undefined;
|
|
57
74
|
let commitSha: string | undefined;
|
|
58
75
|
|
|
59
|
-
if (
|
|
76
|
+
if (shouldCreateHandoff) {
|
|
60
77
|
const handoff = await deps.createOrAutoApplyHandoff(undefined, {
|
|
61
78
|
sessionId: input.artifact.sessionId,
|
|
62
79
|
projectPath: input.artifact.projectPath,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type RunReviewSessionDependencies,
|
|
25
25
|
runReviewSession,
|
|
26
26
|
} from "@/lib/review-workflow/review/run-review-session";
|
|
27
|
+
import { mapSessionStatusToFinalStatus } from "@/lib/review-workflow/session-status";
|
|
27
28
|
import { updateSessionState } from "@/lib/session";
|
|
28
29
|
import { parseFixSummaryOutput, parseReviewSummaryOutput } from "@/lib/structured-output";
|
|
29
30
|
import type {
|
|
@@ -203,18 +204,6 @@ function resetInterrupt(): void {
|
|
|
203
204
|
interrupted = false;
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
function mapSessionStatusToFinalStatus(status: SessionStatus): CycleResult["finalStatus"] {
|
|
207
|
-
if (status === "failed") {
|
|
208
|
-
return "failed";
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (status === "interrupted") {
|
|
212
|
-
return "interrupted";
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return "completed";
|
|
216
|
-
}
|
|
217
|
-
|
|
218
207
|
function createSessionEndEntry(
|
|
219
208
|
result: CycleResult | undefined,
|
|
220
209
|
error: unknown,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SessionEndEntry, SessionStatus } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
export function mapSessionStatusToFinalStatus(status: SessionStatus): SessionEndEntry["status"] {
|
|
4
|
+
if (status === "failed") {
|
|
5
|
+
return "failed";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (status === "interrupted") {
|
|
9
|
+
return "interrupted";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return "completed";
|
|
13
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { removeTrailingCommas } from "@/lib/structured-output";
|
|
2
|
+
|
|
1
3
|
interface ParseFramedJsonOptions<T> {
|
|
2
4
|
extractedText: string | null;
|
|
3
5
|
rawOutput: string;
|
|
@@ -10,53 +12,6 @@ function normalizeCandidate(candidate: string): string {
|
|
|
10
12
|
return candidate.replace(/\r\n?/g, "\n").trim();
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
function removeTrailingCommas(candidate: string): string {
|
|
14
|
-
let output = "";
|
|
15
|
-
let inString = false;
|
|
16
|
-
let escaped = false;
|
|
17
|
-
|
|
18
|
-
for (let index = 0; index < candidate.length; index += 1) {
|
|
19
|
-
const char = candidate[index];
|
|
20
|
-
|
|
21
|
-
if (escaped) {
|
|
22
|
-
output += char;
|
|
23
|
-
escaped = false;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (char === "\\") {
|
|
28
|
-
output += char;
|
|
29
|
-
escaped = true;
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (char === '"') {
|
|
34
|
-
output += char;
|
|
35
|
-
inString = !inString;
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (inString || char !== ",") {
|
|
40
|
-
output += char;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let lookahead = index + 1;
|
|
45
|
-
while (lookahead < candidate.length && /\s/u.test(candidate[lookahead] ?? "")) {
|
|
46
|
-
lookahead += 1;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const nextChar = candidate[lookahead];
|
|
50
|
-
if (nextChar === "}" || nextChar === "]") {
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
output += char;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return output;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
15
|
function extractFramedPayload(output: string, startToken: string, endToken: string): string | null {
|
|
61
16
|
const normalized = normalizeCandidate(output);
|
|
62
17
|
const startIndex = normalized.indexOf(startToken);
|
package/src/lib/session/state.ts
CHANGED
|
@@ -45,34 +45,39 @@ interface SessionStateGuardOptions {
|
|
|
45
45
|
expectedSessionId?: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
interface CreateSessionStateOptions
|
|
49
|
-
|
|
48
|
+
interface CreateSessionStateOptions
|
|
49
|
+
extends Partial<
|
|
50
|
+
Pick<
|
|
51
|
+
SessionState,
|
|
52
|
+
| "branch"
|
|
53
|
+
| "state"
|
|
54
|
+
| "mode"
|
|
55
|
+
| "pid"
|
|
56
|
+
| "startTime"
|
|
57
|
+
| "lastHeartbeat"
|
|
58
|
+
| "sessionPath"
|
|
59
|
+
| "worktreeProjectPath"
|
|
60
|
+
| "worktreeBranch"
|
|
61
|
+
| "worktreeMergeReady"
|
|
62
|
+
| "worktreeCommitSha"
|
|
63
|
+
| "endTime"
|
|
64
|
+
| "reason"
|
|
65
|
+
| "currentPhase"
|
|
66
|
+
| "phase"
|
|
67
|
+
| "sessionStatus"
|
|
68
|
+
| "reviewOutcome"
|
|
69
|
+
| "handoffStatus"
|
|
70
|
+
| "handoffId"
|
|
71
|
+
| "handoffUpdatedAt"
|
|
72
|
+
| "commitSha"
|
|
73
|
+
| "artifactPath"
|
|
74
|
+
| "baselineCommitSha"
|
|
75
|
+
| "sourceBaselineFingerprint"
|
|
76
|
+
| "accumulatedFindings"
|
|
77
|
+
| "selectedFindingIds"
|
|
78
|
+
>
|
|
79
|
+
> {
|
|
50
80
|
sessionId: string;
|
|
51
|
-
state?: SessionStatus;
|
|
52
|
-
mode?: SessionMode;
|
|
53
|
-
pid?: number;
|
|
54
|
-
startTime?: number;
|
|
55
|
-
lastHeartbeat?: number;
|
|
56
|
-
sessionPath?: string;
|
|
57
|
-
worktreeProjectPath?: string;
|
|
58
|
-
worktreeBranch?: string;
|
|
59
|
-
worktreeMergeReady?: boolean;
|
|
60
|
-
worktreeCommitSha?: string;
|
|
61
|
-
endTime?: number;
|
|
62
|
-
reason?: string;
|
|
63
|
-
currentPhase?: ReviewPhase;
|
|
64
|
-
phase?: ReviewPhase;
|
|
65
|
-
sessionStatus?: WorkflowSessionStatus;
|
|
66
|
-
reviewOutcome?: ReviewOutcome;
|
|
67
|
-
handoffStatus?: HandoffStatus;
|
|
68
|
-
handoffId?: string;
|
|
69
|
-
handoffUpdatedAt?: number;
|
|
70
|
-
commitSha?: string;
|
|
71
|
-
artifactPath?: string;
|
|
72
|
-
baselineCommitSha?: string;
|
|
73
|
-
sourceBaselineFingerprint?: string;
|
|
74
|
-
accumulatedFindings?: StoredFinding[];
|
|
75
|
-
selectedFindingIds?: FindingId[];
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
export interface SessionState {
|
|
@@ -201,17 +206,28 @@ async function readSessionStateByPath(sessionStatePath: string): Promise<Session
|
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
async function readExpectedSessionStateByPath(
|
|
210
|
+
sessionStatePath: string,
|
|
211
|
+
options: SessionStateGuardOptions
|
|
212
|
+
): Promise<SessionState | null> {
|
|
213
|
+
const existing = await readSessionStateByPath(sessionStatePath);
|
|
214
|
+
if (!existing) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return existing;
|
|
223
|
+
}
|
|
224
|
+
|
|
204
225
|
async function removeSessionStateByPath(
|
|
205
226
|
sessionStatePath: string,
|
|
206
227
|
options: SessionStateGuardOptions = {}
|
|
207
228
|
): Promise<boolean> {
|
|
208
229
|
return queueSessionStateWrite(sessionStatePath, async () => {
|
|
209
|
-
|
|
210
|
-
if (!existing) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
|
|
230
|
+
if (!(await readExpectedSessionStateByPath(sessionStatePath, options))) {
|
|
215
231
|
return false;
|
|
216
232
|
}
|
|
217
233
|
|
|
@@ -351,15 +367,11 @@ export async function updateSessionState(
|
|
|
351
367
|
const sessionStatePath = getSessionStatePath(storageRoot, projectPath, sessionId);
|
|
352
368
|
|
|
353
369
|
return await queueSessionStateWrite(sessionStatePath, async () => {
|
|
354
|
-
const existing = await
|
|
370
|
+
const existing = await readExpectedSessionStateByPath(sessionStatePath, options);
|
|
355
371
|
if (!existing) {
|
|
356
372
|
return false;
|
|
357
373
|
}
|
|
358
374
|
|
|
359
|
-
if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
|
|
360
|
-
return false;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
375
|
const merged = { ...existing } as Record<string, unknown>;
|
|
364
376
|
for (const [key, value] of Object.entries(updates)) {
|
|
365
377
|
if (value === undefined) {
|
|
@@ -42,6 +42,12 @@ interface RepairedCandidate {
|
|
|
42
42
|
changed: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface JsonObjectSlice {
|
|
46
|
+
start: number;
|
|
47
|
+
end: number;
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
function normalizeCandidateText(candidate: string): string {
|
|
46
52
|
return candidate
|
|
47
53
|
.replace(/^\uFEFF/, "")
|
|
@@ -72,8 +78,8 @@ function extractJsonBlock(output: string): string | null {
|
|
|
72
78
|
return match[1].trim();
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
function
|
|
76
|
-
const objects:
|
|
81
|
+
export function extractBalancedJsonObjectSlices(text: string): JsonObjectSlice[] {
|
|
82
|
+
const objects: JsonObjectSlice[] = [];
|
|
77
83
|
let depth = 0;
|
|
78
84
|
let startIndex = -1;
|
|
79
85
|
let inString = false;
|
|
@@ -87,12 +93,12 @@ function extractBalancedJsonObjects(text: string): string[] {
|
|
|
87
93
|
continue;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
if (char === "\\") {
|
|
91
|
-
escaped = true;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
96
|
if (inString) {
|
|
97
|
+
if (char === "\\") {
|
|
98
|
+
escaped = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
96
102
|
if (char === '"') {
|
|
97
103
|
inString = false;
|
|
98
104
|
}
|
|
@@ -122,7 +128,12 @@ function extractBalancedJsonObjects(text: string): string[] {
|
|
|
122
128
|
|
|
123
129
|
depth -= 1;
|
|
124
130
|
if (depth === 0 && startIndex >= 0) {
|
|
125
|
-
|
|
131
|
+
const endIndex = index + 1;
|
|
132
|
+
objects.push({
|
|
133
|
+
start: startIndex,
|
|
134
|
+
end: endIndex,
|
|
135
|
+
value: text.slice(startIndex, endIndex),
|
|
136
|
+
});
|
|
126
137
|
startIndex = -1;
|
|
127
138
|
}
|
|
128
139
|
}
|
|
@@ -130,6 +141,10 @@ function extractBalancedJsonObjects(text: string): string[] {
|
|
|
130
141
|
return objects;
|
|
131
142
|
}
|
|
132
143
|
|
|
144
|
+
function extractBalancedJsonObjects(text: string): string[] {
|
|
145
|
+
return extractBalancedJsonObjectSlices(text).map((object) => object.value);
|
|
146
|
+
}
|
|
147
|
+
|
|
133
148
|
function isolateJsonObject(candidate: string): string {
|
|
134
149
|
const objects = extractBalancedJsonObjects(candidate);
|
|
135
150
|
if (objects.length === 0) {
|
|
@@ -140,7 +155,7 @@ function isolateJsonObject(candidate: string): string {
|
|
|
140
155
|
return lastObject?.trim() || candidate;
|
|
141
156
|
}
|
|
142
157
|
|
|
143
|
-
function removeTrailingCommas(candidate: string): string {
|
|
158
|
+
export function removeTrailingCommas(candidate: string): string {
|
|
144
159
|
let output = "";
|
|
145
160
|
let inString = false;
|
|
146
161
|
let escaped = false;
|