git-coco 0.54.0 → 0.54.1
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/index.esm.mjs +285 -48
- package/dist/index.js +285 -48
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.54.
|
|
64
|
+
const BUILD_VERSION = "0.54.1";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -20220,9 +20220,16 @@ function applyCommitComposeAction(state, action) {
|
|
|
20220
20220
|
field: state.field === 'summary' ? 'body' : 'summary',
|
|
20221
20221
|
};
|
|
20222
20222
|
case 'setEditing':
|
|
20223
|
+
// Audit finding #12: defensively clear `streamingPreview` when
|
|
20224
|
+
// editing toggles off AND no draft is in flight. The current
|
|
20225
|
+
// input pipeline never triggers this combination, but the
|
|
20226
|
+
// reducer is the source of truth — if a future code path
|
|
20227
|
+
// toggles editing off mid-stream, the preview shouldn't linger
|
|
20228
|
+
// below an idle compose panel.
|
|
20223
20229
|
return {
|
|
20224
20230
|
...state,
|
|
20225
20231
|
editing: action.value,
|
|
20232
|
+
streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
|
|
20226
20233
|
};
|
|
20227
20234
|
case 'setLoading':
|
|
20228
20235
|
// Clearing loading also clears any in-flight streaming preview;
|
|
@@ -20234,6 +20241,22 @@ function applyCommitComposeAction(state, action) {
|
|
|
20234
20241
|
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20235
20242
|
};
|
|
20236
20243
|
case 'setDraft':
|
|
20244
|
+
// Audit finding #7: if the user has typed content in summary or
|
|
20245
|
+
// body, the AI draft would silently clobber their work with no
|
|
20246
|
+
// undo. Route the result to `pendingAiDraft` instead and surface
|
|
20247
|
+
// a confirmation message; the user accepts with `R` (replace)
|
|
20248
|
+
// or dismisses with Esc. Empty fields = safe to replace as
|
|
20249
|
+
// before, since there's nothing to lose.
|
|
20250
|
+
if (state.summary.trim() || state.body.trim()) {
|
|
20251
|
+
return {
|
|
20252
|
+
...state,
|
|
20253
|
+
loading: false,
|
|
20254
|
+
streamingPreview: undefined,
|
|
20255
|
+
pendingAiDraft: action.value,
|
|
20256
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20257
|
+
details: undefined,
|
|
20258
|
+
};
|
|
20259
|
+
}
|
|
20237
20260
|
// No `message` here — the loader → filled fields are the confirmation
|
|
20238
20261
|
// that the AI generated something. A lingering "AI draft ready for
|
|
20239
20262
|
// editing" line in the panel reads as stale state. The runtime still
|
|
@@ -20248,6 +20271,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20248
20271
|
message: undefined,
|
|
20249
20272
|
details: undefined,
|
|
20250
20273
|
streamingPreview: undefined,
|
|
20274
|
+
pendingAiDraft: undefined,
|
|
20251
20275
|
};
|
|
20252
20276
|
case 'setResult':
|
|
20253
20277
|
return {
|
|
@@ -20267,6 +20291,46 @@ function applyCommitComposeAction(state, action) {
|
|
|
20267
20291
|
...state,
|
|
20268
20292
|
streamingPreview: action.value,
|
|
20269
20293
|
};
|
|
20294
|
+
case 'setPendingAiDraft':
|
|
20295
|
+
// Audit finding #7: route the AI draft here (instead of straight
|
|
20296
|
+
// to summary/body via `setDraft`) when the user has unsaved
|
|
20297
|
+
// typing the draft would clobber. The dispatcher does the
|
|
20298
|
+
// user-content check; this reducer just stashes the draft and
|
|
20299
|
+
// surfaces a message inviting the user to accept or dismiss.
|
|
20300
|
+
return {
|
|
20301
|
+
...state,
|
|
20302
|
+
loading: false,
|
|
20303
|
+
streamingPreview: undefined,
|
|
20304
|
+
pendingAiDraft: action.value,
|
|
20305
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20306
|
+
details: undefined,
|
|
20307
|
+
};
|
|
20308
|
+
case 'acceptPendingAiDraft':
|
|
20309
|
+
// Swap the pending draft into the editable fields and clear it.
|
|
20310
|
+
// Mirrors `setDraft`'s field positioning (focus on summary,
|
|
20311
|
+
// editing on) so the user lands in the same place whether they
|
|
20312
|
+
// accepted immediately or after deliberation.
|
|
20313
|
+
if (!state.pendingAiDraft)
|
|
20314
|
+
return state;
|
|
20315
|
+
return {
|
|
20316
|
+
...state,
|
|
20317
|
+
...splitCommitDraft(state.pendingAiDraft),
|
|
20318
|
+
field: 'summary',
|
|
20319
|
+
editing: true,
|
|
20320
|
+
loading: false,
|
|
20321
|
+
message: undefined,
|
|
20322
|
+
details: undefined,
|
|
20323
|
+
streamingPreview: undefined,
|
|
20324
|
+
pendingAiDraft: undefined,
|
|
20325
|
+
};
|
|
20326
|
+
case 'dismissPendingAiDraft':
|
|
20327
|
+
// User chose to keep their typing; drop the AI draft.
|
|
20328
|
+
return {
|
|
20329
|
+
...state,
|
|
20330
|
+
pendingAiDraft: undefined,
|
|
20331
|
+
message: undefined,
|
|
20332
|
+
details: undefined,
|
|
20333
|
+
};
|
|
20270
20334
|
case 'reset':
|
|
20271
20335
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
20272
20336
|
// notification is already on the runtime status line (footer); a
|
|
@@ -20454,6 +20518,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20454
20518
|
// classify below.
|
|
20455
20519
|
const stream = await chain.stream(variables, signal ? { signal } : undefined);
|
|
20456
20520
|
let chunkCount = 0;
|
|
20521
|
+
let callbackFailureCount = 0;
|
|
20522
|
+
// Audit finding #13: cap consecutive callback failures so a
|
|
20523
|
+
// genuinely broken render handler can't tie up the LLM call
|
|
20524
|
+
// silently for the user's entire wait. Five strikes (out of an
|
|
20525
|
+
// expected ~50-500 chunks for a normal commit message) is enough
|
|
20526
|
+
// to ride out a transient blip but small enough to bail before
|
|
20527
|
+
// the user finishes waiting on a useless stream.
|
|
20528
|
+
const MAX_CALLBACK_FAILURES = 5;
|
|
20457
20529
|
for await (const messageChunk of stream) {
|
|
20458
20530
|
const text = coerceChunkText(messageChunk);
|
|
20459
20531
|
if (!text)
|
|
@@ -20462,12 +20534,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20462
20534
|
chunkCount += 1;
|
|
20463
20535
|
try {
|
|
20464
20536
|
onChunk({ text, accumulated });
|
|
20537
|
+
// Successful callback resets the consecutive-failure counter —
|
|
20538
|
+
// we only bail on a STREAK of failures, not on isolated ones.
|
|
20539
|
+
callbackFailureCount = 0;
|
|
20465
20540
|
}
|
|
20466
20541
|
catch (callbackError) {
|
|
20467
20542
|
// Deliberately swallow callback errors so a bad render handler
|
|
20468
20543
|
// can't tank the entire LLM call. Log at verbose so users with
|
|
20469
20544
|
// verbose mode on can still see what happened.
|
|
20470
|
-
|
|
20545
|
+
callbackFailureCount += 1;
|
|
20546
|
+
logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
|
|
20547
|
+
if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
|
|
20548
|
+
logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
|
|
20549
|
+
throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
|
|
20550
|
+
}
|
|
20471
20551
|
}
|
|
20472
20552
|
}
|
|
20473
20553
|
if (!accumulated) {
|
|
@@ -20501,15 +20581,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20501
20581
|
}
|
|
20502
20582
|
catch (error) {
|
|
20503
20583
|
// Cancellation classifier (#881 phase 3). Three signals: an
|
|
20504
|
-
// explicitly aborted user signal (post-throw check)
|
|
20505
|
-
// standard DOM
|
|
20506
|
-
//
|
|
20507
|
-
//
|
|
20508
|
-
//
|
|
20509
|
-
//
|
|
20510
|
-
//
|
|
20584
|
+
// explicitly aborted user signal (post-throw check) or a thrown
|
|
20585
|
+
// `AbortError` from the standard DOM API. Either means "user
|
|
20586
|
+
// wanted out," not "the call failed." Wrap the raw error so
|
|
20587
|
+
// callers can pattern-match on `LangChainCancelledError` and
|
|
20588
|
+
// carry the partial accumulated text in case the caller wants
|
|
20589
|
+
// to salvage anything.
|
|
20590
|
+
//
|
|
20591
|
+
// Audit finding #8: an earlier implementation also fell back to
|
|
20592
|
+
// `error.message.includes('aborted')` as a third signal. That
|
|
20593
|
+
// substring heuristic is footgun-shaped — legitimate provider
|
|
20594
|
+
// errors ("model not aborted properly", future API copy) would
|
|
20595
|
+
// misclassify as user cancels. Dropped; rely on the structured
|
|
20596
|
+
// signal (`signal.aborted`) and the standard error class
|
|
20597
|
+
// (`name === 'AbortError'`).
|
|
20511
20598
|
const aborted = signal?.aborted ||
|
|
20512
|
-
(error instanceof Error &&
|
|
20599
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
20513
20600
|
if (aborted) {
|
|
20514
20601
|
throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
|
|
20515
20602
|
provider: effectiveProvider,
|
|
@@ -20768,6 +20855,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20768
20855
|
// schema-validated retry — paying for a second LLM call only
|
|
20769
20856
|
// on the edge case where the streamed output is unsalvageable.
|
|
20770
20857
|
const streamingParser = createSchemaParser(schema, llm);
|
|
20858
|
+
// Capture the final accumulated text out-of-band so we can
|
|
20859
|
+
// attempt salvage if the parser throws on completion (audit
|
|
20860
|
+
// finding #1). Updated on every chunk; the last value is
|
|
20861
|
+
// whatever the stream produced before the parser ran. Empty
|
|
20862
|
+
// string when streaming throws before any chunks arrived.
|
|
20863
|
+
let streamedAccumulated = '';
|
|
20771
20864
|
let salvaged;
|
|
20772
20865
|
try {
|
|
20773
20866
|
// `executeChainStreaming` runs the parser on the accumulated
|
|
@@ -20781,6 +20874,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20781
20874
|
variables: budgetedPrompt.variables,
|
|
20782
20875
|
parser: streamingParser,
|
|
20783
20876
|
onChunk: ({ text, accumulated }) => {
|
|
20877
|
+
streamedAccumulated = accumulated;
|
|
20784
20878
|
onStreamChunk(text, accumulated);
|
|
20785
20879
|
},
|
|
20786
20880
|
signal,
|
|
@@ -20810,13 +20904,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20810
20904
|
cancelled: true,
|
|
20811
20905
|
};
|
|
20812
20906
|
}
|
|
20813
|
-
//
|
|
20814
|
-
//
|
|
20815
|
-
//
|
|
20816
|
-
// to
|
|
20817
|
-
//
|
|
20818
|
-
|
|
20819
|
-
|
|
20907
|
+
// Audit finding #1: try the lossy salvager on the accumulated
|
|
20908
|
+
// text before paying for a second LLM call. The salvager
|
|
20909
|
+
// strips code fences, attempts strict JSON parse, and falls
|
|
20910
|
+
// back to "first line is title, rest is body." We only accept
|
|
20911
|
+
// its output when it produced a real title — the placeholder
|
|
20912
|
+
// title ("Auto-generated commit") means the salvager
|
|
20913
|
+
// couldn't extract anything meaningful and the non-streaming
|
|
20914
|
+
// retry is the better choice.
|
|
20915
|
+
if (streamedAccumulated) {
|
|
20916
|
+
const candidate = salvageCommitMessageFromText(streamedAccumulated);
|
|
20917
|
+
if (candidate.title && candidate.title !== 'Auto-generated commit') {
|
|
20918
|
+
salvaged = candidate;
|
|
20919
|
+
logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
|
|
20920
|
+
}
|
|
20921
|
+
}
|
|
20922
|
+
if (!salvaged) {
|
|
20923
|
+
logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
|
|
20924
|
+
}
|
|
20820
20925
|
}
|
|
20821
20926
|
// Type-narrow: commitMsg is set inside try{}, but TS doesn't
|
|
20822
20927
|
// see that across the catch. Re-init through the salvage path
|
|
@@ -20825,10 +20930,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20825
20930
|
commitMsg = salvaged;
|
|
20826
20931
|
}
|
|
20827
20932
|
else if (!(commitMsg)) {
|
|
20828
|
-
// Streaming threw
|
|
20829
|
-
//
|
|
20830
|
-
//
|
|
20831
|
-
//
|
|
20933
|
+
// Streaming threw AND the salvager couldn't recover anything
|
|
20934
|
+
// useful; fall back to the standard non-streaming flow.
|
|
20935
|
+
// Documented trade-off from the issue: streaming gives us a
|
|
20936
|
+
// preview but the validated result still comes from the
|
|
20937
|
+
// schema-aware retry path when both streaming AND salvage
|
|
20938
|
+
// fail.
|
|
20832
20939
|
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20833
20940
|
logger,
|
|
20834
20941
|
tokenizer,
|
|
@@ -24271,10 +24378,14 @@ function applyLogInkAction(state, action) {
|
|
|
24271
24378
|
// Cache the result so re-entry (or `c` to PR) reuses it instead of
|
|
24272
24379
|
// re-running the LLM. Keyed by branch so a checkout naturally
|
|
24273
24380
|
// produces a fresh generation.
|
|
24381
|
+
// Audit finding #9: `generatedAt` arrives on the action payload
|
|
24382
|
+
// instead of being read from `Date.now()` here, so the reducer
|
|
24383
|
+
// stays pure. Dispatchers (currently `runChangelogView` in
|
|
24384
|
+
// app.ts) call `Date.now()` at dispatch time.
|
|
24274
24385
|
const cached = {
|
|
24275
24386
|
text: action.text,
|
|
24276
24387
|
baseLabel: action.baseLabel,
|
|
24277
|
-
generatedAt:
|
|
24388
|
+
generatedAt: action.generatedAt,
|
|
24278
24389
|
};
|
|
24279
24390
|
return {
|
|
24280
24391
|
...state,
|
|
@@ -24328,7 +24439,8 @@ function applyLogInkAction(state, action) {
|
|
|
24328
24439
|
// Updated-at timestamp reflects the edit. Not the original
|
|
24329
24440
|
// generation time — `r` (regenerate) is the explicit knob
|
|
24330
24441
|
// for "I want fresh LLM output, not my edits".
|
|
24331
|
-
|
|
24442
|
+
// Audit finding #9: timestamp arrives on the action.
|
|
24443
|
+
generatedAt: action.generatedAt,
|
|
24332
24444
|
},
|
|
24333
24445
|
},
|
|
24334
24446
|
pendingKey: undefined,
|
|
@@ -24364,7 +24476,9 @@ function applyLogInkAction(state, action) {
|
|
|
24364
24476
|
}
|
|
24365
24477
|
return {
|
|
24366
24478
|
...state,
|
|
24367
|
-
|
|
24479
|
+
// Audit finding #9: timestamp arrives on the action payload
|
|
24480
|
+
// instead of being read from `Date.now()` here.
|
|
24481
|
+
recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
|
|
24368
24482
|
pendingKey: undefined,
|
|
24369
24483
|
};
|
|
24370
24484
|
case 'clearRecentCommits':
|
|
@@ -25164,16 +25278,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25164
25278
|
return [];
|
|
25165
25279
|
}
|
|
25166
25280
|
// Cancel in-flight AI commit draft (#881 phase 3). When the compose
|
|
25167
|
-
//
|
|
25168
|
-
// and the runtime handler cleans up (clear loading, clear
|
|
25169
|
-
// status line shows "AI draft cancelled.").
|
|
25170
|
-
//
|
|
25171
|
-
//
|
|
25281
|
+
// state has a draft in flight (loading === true), Esc aborts the
|
|
25282
|
+
// LLM call and the runtime handler cleans up (clear loading, clear
|
|
25283
|
+
// preview, status line shows "AI draft cancelled.").
|
|
25284
|
+
//
|
|
25285
|
+
// Audit finding #5: the `activeView === 'compose'` gate from the
|
|
25286
|
+
// original phase 3 implementation made the cancel keystroke
|
|
25287
|
+
// unreachable after the user chord-navigated away from compose
|
|
25288
|
+
// mid-stream (Esc would fall through to popView etc., consuming
|
|
25289
|
+
// the navigation intent while the LLM call silently ran to
|
|
25290
|
+
// completion). Cancel should work wherever the user is — they
|
|
25291
|
+
// can always navigate back to compose afterwards.
|
|
25172
25292
|
//
|
|
25173
|
-
//
|
|
25174
|
-
// can't
|
|
25175
|
-
//
|
|
25176
|
-
|
|
25293
|
+
// Sits above the editing / view handlers so the cancel keystroke
|
|
25294
|
+
// can't fall through to "leave compose" or anything else. Loading
|
|
25295
|
+
// and editing are mutually exclusive in practice (the user can't
|
|
25296
|
+
// type while the AI is generating), but the order here makes the
|
|
25297
|
+
// precedence explicit if that ever changes.
|
|
25298
|
+
if (state.commitCompose.loading && key.escape) {
|
|
25177
25299
|
return [{ type: 'cancelAiCommitDraft' }];
|
|
25178
25300
|
}
|
|
25179
25301
|
// Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
|
|
@@ -25193,6 +25315,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25193
25315
|
if (state.pendingPullRequestBodyDraft && key.escape) {
|
|
25194
25316
|
return [{ type: 'cancelPullRequestBodyDraft' }];
|
|
25195
25317
|
}
|
|
25318
|
+
// Pending AI draft confirmation (audit finding #7). When the AI
|
|
25319
|
+
// draft completes against a non-empty compose surface, it lands in
|
|
25320
|
+
// `pendingAiDraft` instead of overwriting the user's typing. `R`
|
|
25321
|
+
// accepts the swap (user's typing is lost, AI draft becomes the
|
|
25322
|
+
// new content). `Esc` dismisses the AI draft (typing is preserved,
|
|
25323
|
+
// AI draft is lost — the user paid for the tokens but explicitly
|
|
25324
|
+
// chose not to use them).
|
|
25325
|
+
//
|
|
25326
|
+
// Gated on `activeView === 'compose'` because the pending draft is
|
|
25327
|
+
// only meaningful on the compose surface (where the message line
|
|
25328
|
+
// surfaces the prompt). A user who chord-navigated away while the
|
|
25329
|
+
// draft was pending should see the original `R` / Esc semantics of
|
|
25330
|
+
// wherever they are now.
|
|
25331
|
+
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
25332
|
+
if (inputValue === 'R' && !key.ctrl && !key.meta) {
|
|
25333
|
+
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
25334
|
+
}
|
|
25335
|
+
if (key.escape) {
|
|
25336
|
+
return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
|
|
25337
|
+
}
|
|
25338
|
+
}
|
|
25196
25339
|
if (state.commitCompose.editing) {
|
|
25197
25340
|
if (key.escape) {
|
|
25198
25341
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -36251,6 +36394,14 @@ function LogInkApp(deps) {
|
|
|
36251
36394
|
// workdirs for submodule paths recorded in `.gitmodules` (which
|
|
36252
36395
|
// are repo-relative). Undefined during the brief moment between
|
|
36253
36396
|
// git swap and the revparse callback resolving.
|
|
36397
|
+
//
|
|
36398
|
+
// Audit finding #10: rapid frame push/pop races are prevented by
|
|
36399
|
+
// the per-effect `cancelled` flag — React fires the cleanup
|
|
36400
|
+
// synchronously BEFORE running the next effect body, so any
|
|
36401
|
+
// pending revparse from the old `git` sees `cancelled === true`
|
|
36402
|
+
// and skips its write. The `git` reference itself is captured by
|
|
36403
|
+
// closure, so each effect run resolves against the right binding.
|
|
36404
|
+
// No additional depth tagging is needed.
|
|
36254
36405
|
const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
|
|
36255
36406
|
React.useEffect(() => {
|
|
36256
36407
|
let cancelled = false;
|
|
@@ -36681,18 +36832,50 @@ function LogInkApp(deps) {
|
|
|
36681
36832
|
})();
|
|
36682
36833
|
return () => { cancelled = true; };
|
|
36683
36834
|
}, [git, dispatch]);
|
|
36835
|
+
// Audit finding #2: re-resolve the repo root inline on every save
|
|
36836
|
+
// and key the deps off `git` + the saved value. The original
|
|
36837
|
+
// implementation read from `repoRootRef.current`, which is async-
|
|
36838
|
+
// populated by the resolver effect above and can lag behind a git
|
|
36839
|
+
// swap. After #995's synchronous pop-restore, the parent's freshly
|
|
36840
|
+
// restored sidebar tab was being written into the submodule's
|
|
36841
|
+
// cache because the ref still held the submodule root during the
|
|
36842
|
+
// brief window before the resolver settled.
|
|
36843
|
+
//
|
|
36844
|
+
// The extra `revparse` cost per save is negligible (saves fire
|
|
36845
|
+
// once per user-initiated tab change, not per render) and the
|
|
36846
|
+
// cancellation flag prevents a stale resolution from racing a
|
|
36847
|
+
// newer one in flight.
|
|
36684
36848
|
React.useEffect(() => {
|
|
36685
|
-
|
|
36686
|
-
|
|
36687
|
-
|
|
36688
|
-
|
|
36689
|
-
|
|
36849
|
+
let cancelled = false;
|
|
36850
|
+
void (async () => {
|
|
36851
|
+
try {
|
|
36852
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
36853
|
+
if (cancelled || !root)
|
|
36854
|
+
return;
|
|
36855
|
+
saveSidebarTab(root, state.userSidebarTab);
|
|
36856
|
+
}
|
|
36857
|
+
catch {
|
|
36858
|
+
// Not in a worktree, or revparse failed — silently skip.
|
|
36859
|
+
// The next save attempt will retry.
|
|
36860
|
+
}
|
|
36861
|
+
})();
|
|
36862
|
+
return () => { cancelled = true; };
|
|
36863
|
+
}, [state.userSidebarTab, git]);
|
|
36690
36864
|
React.useEffect(() => {
|
|
36691
|
-
|
|
36692
|
-
|
|
36693
|
-
|
|
36694
|
-
|
|
36695
|
-
|
|
36865
|
+
let cancelled = false;
|
|
36866
|
+
void (async () => {
|
|
36867
|
+
try {
|
|
36868
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
36869
|
+
if (cancelled || !root)
|
|
36870
|
+
return;
|
|
36871
|
+
saveDiffViewMode(root, state.diffViewMode);
|
|
36872
|
+
}
|
|
36873
|
+
catch {
|
|
36874
|
+
// Same as above.
|
|
36875
|
+
}
|
|
36876
|
+
})();
|
|
36877
|
+
return () => { cancelled = true; };
|
|
36878
|
+
}, [state.diffViewMode, git]);
|
|
36696
36879
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
36697
36880
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
36698
36881
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -37326,6 +37509,12 @@ function LogInkApp(deps) {
|
|
|
37326
37509
|
git,
|
|
37327
37510
|
signal: controller.signal,
|
|
37328
37511
|
onStreamChunk: (_text, accumulated) => {
|
|
37512
|
+
// Audit finding #4: skip dispatching into a torn-down
|
|
37513
|
+
// tree. If the user quit (or otherwise unmounted the
|
|
37514
|
+
// workstation) mid-stream, React warns about updates on
|
|
37515
|
+
// an unmounted component. Drop the chunk silently.
|
|
37516
|
+
if (!mountedRef.current)
|
|
37517
|
+
return;
|
|
37329
37518
|
// Dispatch the full accumulated text — the preview chrome
|
|
37330
37519
|
// helper does the last-N-lines slicing at render time, so
|
|
37331
37520
|
// re-doing the slice here would be wasted work. Per-chunk
|
|
@@ -37337,6 +37526,11 @@ function LogInkApp(deps) {
|
|
|
37337
37526
|
});
|
|
37338
37527
|
},
|
|
37339
37528
|
});
|
|
37529
|
+
// Audit finding #4 (unmount race): bail out before any
|
|
37530
|
+
// post-await dispatch if the user quit while the LLM call was
|
|
37531
|
+
// in flight. Same pattern as `refreshHistoryRows` upstream.
|
|
37532
|
+
if (!mountedRef.current)
|
|
37533
|
+
return;
|
|
37340
37534
|
// Cancel path (#881 phase 3). User pressed Esc during the
|
|
37341
37535
|
// stream; reducer drops loading + preview, status line shows
|
|
37342
37536
|
// a neutral "cancelled" message. Skip the result / failure
|
|
@@ -37357,6 +37551,23 @@ function LogInkApp(deps) {
|
|
|
37357
37551
|
});
|
|
37358
37552
|
dispatch({ type: 'setStatus', value: result.message });
|
|
37359
37553
|
}
|
|
37554
|
+
catch (error) {
|
|
37555
|
+
// Audit finding #3: defensive recovery for unexpected throws
|
|
37556
|
+
// from the workflow. The workflow catches its own errors
|
|
37557
|
+
// today, so this catch is latent — but any future refactor
|
|
37558
|
+
// that lets an error escape would otherwise strand the
|
|
37559
|
+
// spinner permanently with no user-facing recovery short of
|
|
37560
|
+
// quitting. Surface a generic failure and clear the loading
|
|
37561
|
+
// state so the user can re-try.
|
|
37562
|
+
if (mountedRef.current) {
|
|
37563
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37564
|
+
dispatch({
|
|
37565
|
+
type: 'setStatus',
|
|
37566
|
+
value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
37567
|
+
kind: 'error',
|
|
37568
|
+
});
|
|
37569
|
+
}
|
|
37570
|
+
}
|
|
37360
37571
|
finally {
|
|
37361
37572
|
// Clear the ref only if it still points at OUR controller — a
|
|
37362
37573
|
// rapid second invocation could have already replaced it, in
|
|
@@ -37448,9 +37659,14 @@ function LogInkApp(deps) {
|
|
|
37448
37659
|
const cancelHandle = { cancelled: false };
|
|
37449
37660
|
pullRequestBodyCancelRef.current = cancelHandle;
|
|
37450
37661
|
dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
|
|
37662
|
+
// Audit finding #6: soft cancel today — Esc skips opening the
|
|
37663
|
+
// follow-up prompt, but the LLM call itself keeps running to
|
|
37664
|
+
// completion (no AbortSignal threaded through the changelog CLI
|
|
37665
|
+
// chain). Status copy reflects that honestly so the user isn't
|
|
37666
|
+
// misled into thinking they're saving tokens.
|
|
37451
37667
|
dispatch({
|
|
37452
37668
|
type: 'setStatus',
|
|
37453
|
-
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to
|
|
37669
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
|
|
37454
37670
|
loading: true,
|
|
37455
37671
|
});
|
|
37456
37672
|
try {
|
|
@@ -37477,6 +37693,15 @@ function LogInkApp(deps) {
|
|
|
37477
37693
|
else {
|
|
37478
37694
|
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
37479
37695
|
}
|
|
37696
|
+
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
37697
|
+
// prompt. If a future refactor adds an `await` between the flag
|
|
37698
|
+
// clear (currently in `finally`) and the `openInputPrompt`
|
|
37699
|
+
// dispatch, an Esc keystroke in the gap would dispatch
|
|
37700
|
+
// `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
|
|
37701
|
+
// the prompt visible with a stale "cancelled" message. Clearing
|
|
37702
|
+
// here moves the flag teardown into the same React batch as the
|
|
37703
|
+
// prompt open, eliminating the race.
|
|
37704
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37480
37705
|
dispatch({
|
|
37481
37706
|
type: 'openInputPrompt',
|
|
37482
37707
|
kind: 'create-pr',
|
|
@@ -37486,11 +37711,14 @@ function LogInkApp(deps) {
|
|
|
37486
37711
|
});
|
|
37487
37712
|
}
|
|
37488
37713
|
finally {
|
|
37489
|
-
//
|
|
37714
|
+
// Belt-and-suspenders: the `try` block clears the flag on the
|
|
37715
|
+
// success path (audit finding #11). This duplicate clear handles
|
|
37716
|
+
// the error / cancel paths where the early-returns skip the
|
|
37717
|
+
// success-path dispatch. Safe to no-op when already false.
|
|
37718
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37490
37719
|
// Only clear the ref if we still own it — a second invocation
|
|
37491
37720
|
// would have already taken ownership in which case the cancel
|
|
37492
37721
|
// duty has rolled over.
|
|
37493
|
-
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37494
37722
|
if (pullRequestBodyCancelRef.current === cancelHandle) {
|
|
37495
37723
|
pullRequestBodyCancelRef.current = null;
|
|
37496
37724
|
}
|
|
@@ -37591,6 +37819,11 @@ function LogInkApp(deps) {
|
|
|
37591
37819
|
branch: head,
|
|
37592
37820
|
baseLabel: cached.baseLabel,
|
|
37593
37821
|
text: cached.text,
|
|
37822
|
+
// Audit finding #9: cache-hit path preserves the original
|
|
37823
|
+
// generation timestamp rather than minting a fresh one — the
|
|
37824
|
+
// "X ago" header should reflect when the LLM ran, not when
|
|
37825
|
+
// the cached entry was re-displayed.
|
|
37826
|
+
generatedAt: cached.generatedAt,
|
|
37594
37827
|
});
|
|
37595
37828
|
dispatch({
|
|
37596
37829
|
type: 'setStatus',
|
|
@@ -37619,6 +37852,9 @@ function LogInkApp(deps) {
|
|
|
37619
37852
|
branch: head,
|
|
37620
37853
|
baseLabel,
|
|
37621
37854
|
text: result.text,
|
|
37855
|
+
// Audit finding #9: timestamp captured at dispatch time, not
|
|
37856
|
+
// inside the reducer.
|
|
37857
|
+
generatedAt: Date.now(),
|
|
37622
37858
|
});
|
|
37623
37859
|
dispatch({
|
|
37624
37860
|
type: 'setStatus',
|
|
@@ -37721,7 +37957,7 @@ function LogInkApp(deps) {
|
|
|
37721
37957
|
if (editorOk) {
|
|
37722
37958
|
try {
|
|
37723
37959
|
const content = readFileSync$1(file, 'utf8');
|
|
37724
|
-
dispatch({ type: 'setChangelogText', text: content });
|
|
37960
|
+
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
37725
37961
|
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
37726
37962
|
}
|
|
37727
37963
|
catch (error) {
|
|
@@ -38076,7 +38312,8 @@ function LogInkApp(deps) {
|
|
|
38076
38312
|
// that could disagree with reality on partial-apply.
|
|
38077
38313
|
const commitHashes = result.commitHashes || [];
|
|
38078
38314
|
if (commitHashes.length > 0) {
|
|
38079
|
-
|
|
38315
|
+
// Audit finding #9: timestamp captured at dispatch time.
|
|
38316
|
+
dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
|
|
38080
38317
|
// DevSkim: ignore DS172411 — function literal, fixed delay,
|
|
38081
38318
|
// no caller-supplied data flowing through.
|
|
38082
38319
|
setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
|
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.54.
|
|
81
|
+
const BUILD_VERSION = "0.54.1";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -20237,9 +20237,16 @@ function applyCommitComposeAction(state, action) {
|
|
|
20237
20237
|
field: state.field === 'summary' ? 'body' : 'summary',
|
|
20238
20238
|
};
|
|
20239
20239
|
case 'setEditing':
|
|
20240
|
+
// Audit finding #12: defensively clear `streamingPreview` when
|
|
20241
|
+
// editing toggles off AND no draft is in flight. The current
|
|
20242
|
+
// input pipeline never triggers this combination, but the
|
|
20243
|
+
// reducer is the source of truth — if a future code path
|
|
20244
|
+
// toggles editing off mid-stream, the preview shouldn't linger
|
|
20245
|
+
// below an idle compose panel.
|
|
20240
20246
|
return {
|
|
20241
20247
|
...state,
|
|
20242
20248
|
editing: action.value,
|
|
20249
|
+
streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
|
|
20243
20250
|
};
|
|
20244
20251
|
case 'setLoading':
|
|
20245
20252
|
// Clearing loading also clears any in-flight streaming preview;
|
|
@@ -20251,6 +20258,22 @@ function applyCommitComposeAction(state, action) {
|
|
|
20251
20258
|
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20252
20259
|
};
|
|
20253
20260
|
case 'setDraft':
|
|
20261
|
+
// Audit finding #7: if the user has typed content in summary or
|
|
20262
|
+
// body, the AI draft would silently clobber their work with no
|
|
20263
|
+
// undo. Route the result to `pendingAiDraft` instead and surface
|
|
20264
|
+
// a confirmation message; the user accepts with `R` (replace)
|
|
20265
|
+
// or dismisses with Esc. Empty fields = safe to replace as
|
|
20266
|
+
// before, since there's nothing to lose.
|
|
20267
|
+
if (state.summary.trim() || state.body.trim()) {
|
|
20268
|
+
return {
|
|
20269
|
+
...state,
|
|
20270
|
+
loading: false,
|
|
20271
|
+
streamingPreview: undefined,
|
|
20272
|
+
pendingAiDraft: action.value,
|
|
20273
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20274
|
+
details: undefined,
|
|
20275
|
+
};
|
|
20276
|
+
}
|
|
20254
20277
|
// No `message` here — the loader → filled fields are the confirmation
|
|
20255
20278
|
// that the AI generated something. A lingering "AI draft ready for
|
|
20256
20279
|
// editing" line in the panel reads as stale state. The runtime still
|
|
@@ -20265,6 +20288,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20265
20288
|
message: undefined,
|
|
20266
20289
|
details: undefined,
|
|
20267
20290
|
streamingPreview: undefined,
|
|
20291
|
+
pendingAiDraft: undefined,
|
|
20268
20292
|
};
|
|
20269
20293
|
case 'setResult':
|
|
20270
20294
|
return {
|
|
@@ -20284,6 +20308,46 @@ function applyCommitComposeAction(state, action) {
|
|
|
20284
20308
|
...state,
|
|
20285
20309
|
streamingPreview: action.value,
|
|
20286
20310
|
};
|
|
20311
|
+
case 'setPendingAiDraft':
|
|
20312
|
+
// Audit finding #7: route the AI draft here (instead of straight
|
|
20313
|
+
// to summary/body via `setDraft`) when the user has unsaved
|
|
20314
|
+
// typing the draft would clobber. The dispatcher does the
|
|
20315
|
+
// user-content check; this reducer just stashes the draft and
|
|
20316
|
+
// surfaces a message inviting the user to accept or dismiss.
|
|
20317
|
+
return {
|
|
20318
|
+
...state,
|
|
20319
|
+
loading: false,
|
|
20320
|
+
streamingPreview: undefined,
|
|
20321
|
+
pendingAiDraft: action.value,
|
|
20322
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20323
|
+
details: undefined,
|
|
20324
|
+
};
|
|
20325
|
+
case 'acceptPendingAiDraft':
|
|
20326
|
+
// Swap the pending draft into the editable fields and clear it.
|
|
20327
|
+
// Mirrors `setDraft`'s field positioning (focus on summary,
|
|
20328
|
+
// editing on) so the user lands in the same place whether they
|
|
20329
|
+
// accepted immediately or after deliberation.
|
|
20330
|
+
if (!state.pendingAiDraft)
|
|
20331
|
+
return state;
|
|
20332
|
+
return {
|
|
20333
|
+
...state,
|
|
20334
|
+
...splitCommitDraft(state.pendingAiDraft),
|
|
20335
|
+
field: 'summary',
|
|
20336
|
+
editing: true,
|
|
20337
|
+
loading: false,
|
|
20338
|
+
message: undefined,
|
|
20339
|
+
details: undefined,
|
|
20340
|
+
streamingPreview: undefined,
|
|
20341
|
+
pendingAiDraft: undefined,
|
|
20342
|
+
};
|
|
20343
|
+
case 'dismissPendingAiDraft':
|
|
20344
|
+
// User chose to keep their typing; drop the AI draft.
|
|
20345
|
+
return {
|
|
20346
|
+
...state,
|
|
20347
|
+
pendingAiDraft: undefined,
|
|
20348
|
+
message: undefined,
|
|
20349
|
+
details: undefined,
|
|
20350
|
+
};
|
|
20287
20351
|
case 'reset':
|
|
20288
20352
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
20289
20353
|
// notification is already on the runtime status line (footer); a
|
|
@@ -20471,6 +20535,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20471
20535
|
// classify below.
|
|
20472
20536
|
const stream = await chain.stream(variables, signal ? { signal } : undefined);
|
|
20473
20537
|
let chunkCount = 0;
|
|
20538
|
+
let callbackFailureCount = 0;
|
|
20539
|
+
// Audit finding #13: cap consecutive callback failures so a
|
|
20540
|
+
// genuinely broken render handler can't tie up the LLM call
|
|
20541
|
+
// silently for the user's entire wait. Five strikes (out of an
|
|
20542
|
+
// expected ~50-500 chunks for a normal commit message) is enough
|
|
20543
|
+
// to ride out a transient blip but small enough to bail before
|
|
20544
|
+
// the user finishes waiting on a useless stream.
|
|
20545
|
+
const MAX_CALLBACK_FAILURES = 5;
|
|
20474
20546
|
for await (const messageChunk of stream) {
|
|
20475
20547
|
const text = coerceChunkText(messageChunk);
|
|
20476
20548
|
if (!text)
|
|
@@ -20479,12 +20551,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20479
20551
|
chunkCount += 1;
|
|
20480
20552
|
try {
|
|
20481
20553
|
onChunk({ text, accumulated });
|
|
20554
|
+
// Successful callback resets the consecutive-failure counter —
|
|
20555
|
+
// we only bail on a STREAK of failures, not on isolated ones.
|
|
20556
|
+
callbackFailureCount = 0;
|
|
20482
20557
|
}
|
|
20483
20558
|
catch (callbackError) {
|
|
20484
20559
|
// Deliberately swallow callback errors so a bad render handler
|
|
20485
20560
|
// can't tank the entire LLM call. Log at verbose so users with
|
|
20486
20561
|
// verbose mode on can still see what happened.
|
|
20487
|
-
|
|
20562
|
+
callbackFailureCount += 1;
|
|
20563
|
+
logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
|
|
20564
|
+
if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
|
|
20565
|
+
logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
|
|
20566
|
+
throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
|
|
20567
|
+
}
|
|
20488
20568
|
}
|
|
20489
20569
|
}
|
|
20490
20570
|
if (!accumulated) {
|
|
@@ -20518,15 +20598,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20518
20598
|
}
|
|
20519
20599
|
catch (error) {
|
|
20520
20600
|
// Cancellation classifier (#881 phase 3). Three signals: an
|
|
20521
|
-
// explicitly aborted user signal (post-throw check)
|
|
20522
|
-
// standard DOM
|
|
20523
|
-
//
|
|
20524
|
-
//
|
|
20525
|
-
//
|
|
20526
|
-
//
|
|
20527
|
-
//
|
|
20601
|
+
// explicitly aborted user signal (post-throw check) or a thrown
|
|
20602
|
+
// `AbortError` from the standard DOM API. Either means "user
|
|
20603
|
+
// wanted out," not "the call failed." Wrap the raw error so
|
|
20604
|
+
// callers can pattern-match on `LangChainCancelledError` and
|
|
20605
|
+
// carry the partial accumulated text in case the caller wants
|
|
20606
|
+
// to salvage anything.
|
|
20607
|
+
//
|
|
20608
|
+
// Audit finding #8: an earlier implementation also fell back to
|
|
20609
|
+
// `error.message.includes('aborted')` as a third signal. That
|
|
20610
|
+
// substring heuristic is footgun-shaped — legitimate provider
|
|
20611
|
+
// errors ("model not aborted properly", future API copy) would
|
|
20612
|
+
// misclassify as user cancels. Dropped; rely on the structured
|
|
20613
|
+
// signal (`signal.aborted`) and the standard error class
|
|
20614
|
+
// (`name === 'AbortError'`).
|
|
20528
20615
|
const aborted = signal?.aborted ||
|
|
20529
|
-
(error instanceof Error &&
|
|
20616
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
20530
20617
|
if (aborted) {
|
|
20531
20618
|
throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
|
|
20532
20619
|
provider: effectiveProvider,
|
|
@@ -20785,6 +20872,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20785
20872
|
// schema-validated retry — paying for a second LLM call only
|
|
20786
20873
|
// on the edge case where the streamed output is unsalvageable.
|
|
20787
20874
|
const streamingParser = createSchemaParser(schema, llm);
|
|
20875
|
+
// Capture the final accumulated text out-of-band so we can
|
|
20876
|
+
// attempt salvage if the parser throws on completion (audit
|
|
20877
|
+
// finding #1). Updated on every chunk; the last value is
|
|
20878
|
+
// whatever the stream produced before the parser ran. Empty
|
|
20879
|
+
// string when streaming throws before any chunks arrived.
|
|
20880
|
+
let streamedAccumulated = '';
|
|
20788
20881
|
let salvaged;
|
|
20789
20882
|
try {
|
|
20790
20883
|
// `executeChainStreaming` runs the parser on the accumulated
|
|
@@ -20798,6 +20891,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20798
20891
|
variables: budgetedPrompt.variables,
|
|
20799
20892
|
parser: streamingParser,
|
|
20800
20893
|
onChunk: ({ text, accumulated }) => {
|
|
20894
|
+
streamedAccumulated = accumulated;
|
|
20801
20895
|
onStreamChunk(text, accumulated);
|
|
20802
20896
|
},
|
|
20803
20897
|
signal,
|
|
@@ -20827,13 +20921,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20827
20921
|
cancelled: true,
|
|
20828
20922
|
};
|
|
20829
20923
|
}
|
|
20830
|
-
//
|
|
20831
|
-
//
|
|
20832
|
-
//
|
|
20833
|
-
// to
|
|
20834
|
-
//
|
|
20835
|
-
|
|
20836
|
-
|
|
20924
|
+
// Audit finding #1: try the lossy salvager on the accumulated
|
|
20925
|
+
// text before paying for a second LLM call. The salvager
|
|
20926
|
+
// strips code fences, attempts strict JSON parse, and falls
|
|
20927
|
+
// back to "first line is title, rest is body." We only accept
|
|
20928
|
+
// its output when it produced a real title — the placeholder
|
|
20929
|
+
// title ("Auto-generated commit") means the salvager
|
|
20930
|
+
// couldn't extract anything meaningful and the non-streaming
|
|
20931
|
+
// retry is the better choice.
|
|
20932
|
+
if (streamedAccumulated) {
|
|
20933
|
+
const candidate = salvageCommitMessageFromText(streamedAccumulated);
|
|
20934
|
+
if (candidate.title && candidate.title !== 'Auto-generated commit') {
|
|
20935
|
+
salvaged = candidate;
|
|
20936
|
+
logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
|
|
20937
|
+
}
|
|
20938
|
+
}
|
|
20939
|
+
if (!salvaged) {
|
|
20940
|
+
logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
|
|
20941
|
+
}
|
|
20837
20942
|
}
|
|
20838
20943
|
// Type-narrow: commitMsg is set inside try{}, but TS doesn't
|
|
20839
20944
|
// see that across the catch. Re-init through the salvage path
|
|
@@ -20842,10 +20947,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20842
20947
|
commitMsg = salvaged;
|
|
20843
20948
|
}
|
|
20844
20949
|
else if (!(commitMsg)) {
|
|
20845
|
-
// Streaming threw
|
|
20846
|
-
//
|
|
20847
|
-
//
|
|
20848
|
-
//
|
|
20950
|
+
// Streaming threw AND the salvager couldn't recover anything
|
|
20951
|
+
// useful; fall back to the standard non-streaming flow.
|
|
20952
|
+
// Documented trade-off from the issue: streaming gives us a
|
|
20953
|
+
// preview but the validated result still comes from the
|
|
20954
|
+
// schema-aware retry path when both streaming AND salvage
|
|
20955
|
+
// fail.
|
|
20849
20956
|
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20850
20957
|
logger,
|
|
20851
20958
|
tokenizer,
|
|
@@ -24288,10 +24395,14 @@ function applyLogInkAction(state, action) {
|
|
|
24288
24395
|
// Cache the result so re-entry (or `c` to PR) reuses it instead of
|
|
24289
24396
|
// re-running the LLM. Keyed by branch so a checkout naturally
|
|
24290
24397
|
// produces a fresh generation.
|
|
24398
|
+
// Audit finding #9: `generatedAt` arrives on the action payload
|
|
24399
|
+
// instead of being read from `Date.now()` here, so the reducer
|
|
24400
|
+
// stays pure. Dispatchers (currently `runChangelogView` in
|
|
24401
|
+
// app.ts) call `Date.now()` at dispatch time.
|
|
24291
24402
|
const cached = {
|
|
24292
24403
|
text: action.text,
|
|
24293
24404
|
baseLabel: action.baseLabel,
|
|
24294
|
-
generatedAt:
|
|
24405
|
+
generatedAt: action.generatedAt,
|
|
24295
24406
|
};
|
|
24296
24407
|
return {
|
|
24297
24408
|
...state,
|
|
@@ -24345,7 +24456,8 @@ function applyLogInkAction(state, action) {
|
|
|
24345
24456
|
// Updated-at timestamp reflects the edit. Not the original
|
|
24346
24457
|
// generation time — `r` (regenerate) is the explicit knob
|
|
24347
24458
|
// for "I want fresh LLM output, not my edits".
|
|
24348
|
-
|
|
24459
|
+
// Audit finding #9: timestamp arrives on the action.
|
|
24460
|
+
generatedAt: action.generatedAt,
|
|
24349
24461
|
},
|
|
24350
24462
|
},
|
|
24351
24463
|
pendingKey: undefined,
|
|
@@ -24381,7 +24493,9 @@ function applyLogInkAction(state, action) {
|
|
|
24381
24493
|
}
|
|
24382
24494
|
return {
|
|
24383
24495
|
...state,
|
|
24384
|
-
|
|
24496
|
+
// Audit finding #9: timestamp arrives on the action payload
|
|
24497
|
+
// instead of being read from `Date.now()` here.
|
|
24498
|
+
recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
|
|
24385
24499
|
pendingKey: undefined,
|
|
24386
24500
|
};
|
|
24387
24501
|
case 'clearRecentCommits':
|
|
@@ -25181,16 +25295,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25181
25295
|
return [];
|
|
25182
25296
|
}
|
|
25183
25297
|
// Cancel in-flight AI commit draft (#881 phase 3). When the compose
|
|
25184
|
-
//
|
|
25185
|
-
// and the runtime handler cleans up (clear loading, clear
|
|
25186
|
-
// status line shows "AI draft cancelled.").
|
|
25187
|
-
//
|
|
25188
|
-
//
|
|
25298
|
+
// state has a draft in flight (loading === true), Esc aborts the
|
|
25299
|
+
// LLM call and the runtime handler cleans up (clear loading, clear
|
|
25300
|
+
// preview, status line shows "AI draft cancelled.").
|
|
25301
|
+
//
|
|
25302
|
+
// Audit finding #5: the `activeView === 'compose'` gate from the
|
|
25303
|
+
// original phase 3 implementation made the cancel keystroke
|
|
25304
|
+
// unreachable after the user chord-navigated away from compose
|
|
25305
|
+
// mid-stream (Esc would fall through to popView etc., consuming
|
|
25306
|
+
// the navigation intent while the LLM call silently ran to
|
|
25307
|
+
// completion). Cancel should work wherever the user is — they
|
|
25308
|
+
// can always navigate back to compose afterwards.
|
|
25189
25309
|
//
|
|
25190
|
-
//
|
|
25191
|
-
// can't
|
|
25192
|
-
//
|
|
25193
|
-
|
|
25310
|
+
// Sits above the editing / view handlers so the cancel keystroke
|
|
25311
|
+
// can't fall through to "leave compose" or anything else. Loading
|
|
25312
|
+
// and editing are mutually exclusive in practice (the user can't
|
|
25313
|
+
// type while the AI is generating), but the order here makes the
|
|
25314
|
+
// precedence explicit if that ever changes.
|
|
25315
|
+
if (state.commitCompose.loading && key.escape) {
|
|
25194
25316
|
return [{ type: 'cancelAiCommitDraft' }];
|
|
25195
25317
|
}
|
|
25196
25318
|
// Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
|
|
@@ -25210,6 +25332,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25210
25332
|
if (state.pendingPullRequestBodyDraft && key.escape) {
|
|
25211
25333
|
return [{ type: 'cancelPullRequestBodyDraft' }];
|
|
25212
25334
|
}
|
|
25335
|
+
// Pending AI draft confirmation (audit finding #7). When the AI
|
|
25336
|
+
// draft completes against a non-empty compose surface, it lands in
|
|
25337
|
+
// `pendingAiDraft` instead of overwriting the user's typing. `R`
|
|
25338
|
+
// accepts the swap (user's typing is lost, AI draft becomes the
|
|
25339
|
+
// new content). `Esc` dismisses the AI draft (typing is preserved,
|
|
25340
|
+
// AI draft is lost — the user paid for the tokens but explicitly
|
|
25341
|
+
// chose not to use them).
|
|
25342
|
+
//
|
|
25343
|
+
// Gated on `activeView === 'compose'` because the pending draft is
|
|
25344
|
+
// only meaningful on the compose surface (where the message line
|
|
25345
|
+
// surfaces the prompt). A user who chord-navigated away while the
|
|
25346
|
+
// draft was pending should see the original `R` / Esc semantics of
|
|
25347
|
+
// wherever they are now.
|
|
25348
|
+
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
25349
|
+
if (inputValue === 'R' && !key.ctrl && !key.meta) {
|
|
25350
|
+
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
25351
|
+
}
|
|
25352
|
+
if (key.escape) {
|
|
25353
|
+
return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
|
|
25354
|
+
}
|
|
25355
|
+
}
|
|
25213
25356
|
if (state.commitCompose.editing) {
|
|
25214
25357
|
if (key.escape) {
|
|
25215
25358
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -36268,6 +36411,14 @@ function LogInkApp(deps) {
|
|
|
36268
36411
|
// workdirs for submodule paths recorded in `.gitmodules` (which
|
|
36269
36412
|
// are repo-relative). Undefined during the brief moment between
|
|
36270
36413
|
// git swap and the revparse callback resolving.
|
|
36414
|
+
//
|
|
36415
|
+
// Audit finding #10: rapid frame push/pop races are prevented by
|
|
36416
|
+
// the per-effect `cancelled` flag — React fires the cleanup
|
|
36417
|
+
// synchronously BEFORE running the next effect body, so any
|
|
36418
|
+
// pending revparse from the old `git` sees `cancelled === true`
|
|
36419
|
+
// and skips its write. The `git` reference itself is captured by
|
|
36420
|
+
// closure, so each effect run resolves against the right binding.
|
|
36421
|
+
// No additional depth tagging is needed.
|
|
36271
36422
|
const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
|
|
36272
36423
|
React.useEffect(() => {
|
|
36273
36424
|
let cancelled = false;
|
|
@@ -36698,18 +36849,50 @@ function LogInkApp(deps) {
|
|
|
36698
36849
|
})();
|
|
36699
36850
|
return () => { cancelled = true; };
|
|
36700
36851
|
}, [git, dispatch]);
|
|
36852
|
+
// Audit finding #2: re-resolve the repo root inline on every save
|
|
36853
|
+
// and key the deps off `git` + the saved value. The original
|
|
36854
|
+
// implementation read from `repoRootRef.current`, which is async-
|
|
36855
|
+
// populated by the resolver effect above and can lag behind a git
|
|
36856
|
+
// swap. After #995's synchronous pop-restore, the parent's freshly
|
|
36857
|
+
// restored sidebar tab was being written into the submodule's
|
|
36858
|
+
// cache because the ref still held the submodule root during the
|
|
36859
|
+
// brief window before the resolver settled.
|
|
36860
|
+
//
|
|
36861
|
+
// The extra `revparse` cost per save is negligible (saves fire
|
|
36862
|
+
// once per user-initiated tab change, not per render) and the
|
|
36863
|
+
// cancellation flag prevents a stale resolution from racing a
|
|
36864
|
+
// newer one in flight.
|
|
36701
36865
|
React.useEffect(() => {
|
|
36702
|
-
|
|
36703
|
-
|
|
36704
|
-
|
|
36705
|
-
|
|
36706
|
-
|
|
36866
|
+
let cancelled = false;
|
|
36867
|
+
void (async () => {
|
|
36868
|
+
try {
|
|
36869
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
36870
|
+
if (cancelled || !root)
|
|
36871
|
+
return;
|
|
36872
|
+
saveSidebarTab(root, state.userSidebarTab);
|
|
36873
|
+
}
|
|
36874
|
+
catch {
|
|
36875
|
+
// Not in a worktree, or revparse failed — silently skip.
|
|
36876
|
+
// The next save attempt will retry.
|
|
36877
|
+
}
|
|
36878
|
+
})();
|
|
36879
|
+
return () => { cancelled = true; };
|
|
36880
|
+
}, [state.userSidebarTab, git]);
|
|
36707
36881
|
React.useEffect(() => {
|
|
36708
|
-
|
|
36709
|
-
|
|
36710
|
-
|
|
36711
|
-
|
|
36712
|
-
|
|
36882
|
+
let cancelled = false;
|
|
36883
|
+
void (async () => {
|
|
36884
|
+
try {
|
|
36885
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
36886
|
+
if (cancelled || !root)
|
|
36887
|
+
return;
|
|
36888
|
+
saveDiffViewMode(root, state.diffViewMode);
|
|
36889
|
+
}
|
|
36890
|
+
catch {
|
|
36891
|
+
// Same as above.
|
|
36892
|
+
}
|
|
36893
|
+
})();
|
|
36894
|
+
return () => { cancelled = true; };
|
|
36895
|
+
}, [state.diffViewMode, git]);
|
|
36713
36896
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
36714
36897
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
36715
36898
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -37343,6 +37526,12 @@ function LogInkApp(deps) {
|
|
|
37343
37526
|
git,
|
|
37344
37527
|
signal: controller.signal,
|
|
37345
37528
|
onStreamChunk: (_text, accumulated) => {
|
|
37529
|
+
// Audit finding #4: skip dispatching into a torn-down
|
|
37530
|
+
// tree. If the user quit (or otherwise unmounted the
|
|
37531
|
+
// workstation) mid-stream, React warns about updates on
|
|
37532
|
+
// an unmounted component. Drop the chunk silently.
|
|
37533
|
+
if (!mountedRef.current)
|
|
37534
|
+
return;
|
|
37346
37535
|
// Dispatch the full accumulated text — the preview chrome
|
|
37347
37536
|
// helper does the last-N-lines slicing at render time, so
|
|
37348
37537
|
// re-doing the slice here would be wasted work. Per-chunk
|
|
@@ -37354,6 +37543,11 @@ function LogInkApp(deps) {
|
|
|
37354
37543
|
});
|
|
37355
37544
|
},
|
|
37356
37545
|
});
|
|
37546
|
+
// Audit finding #4 (unmount race): bail out before any
|
|
37547
|
+
// post-await dispatch if the user quit while the LLM call was
|
|
37548
|
+
// in flight. Same pattern as `refreshHistoryRows` upstream.
|
|
37549
|
+
if (!mountedRef.current)
|
|
37550
|
+
return;
|
|
37357
37551
|
// Cancel path (#881 phase 3). User pressed Esc during the
|
|
37358
37552
|
// stream; reducer drops loading + preview, status line shows
|
|
37359
37553
|
// a neutral "cancelled" message. Skip the result / failure
|
|
@@ -37374,6 +37568,23 @@ function LogInkApp(deps) {
|
|
|
37374
37568
|
});
|
|
37375
37569
|
dispatch({ type: 'setStatus', value: result.message });
|
|
37376
37570
|
}
|
|
37571
|
+
catch (error) {
|
|
37572
|
+
// Audit finding #3: defensive recovery for unexpected throws
|
|
37573
|
+
// from the workflow. The workflow catches its own errors
|
|
37574
|
+
// today, so this catch is latent — but any future refactor
|
|
37575
|
+
// that lets an error escape would otherwise strand the
|
|
37576
|
+
// spinner permanently with no user-facing recovery short of
|
|
37577
|
+
// quitting. Surface a generic failure and clear the loading
|
|
37578
|
+
// state so the user can re-try.
|
|
37579
|
+
if (mountedRef.current) {
|
|
37580
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37581
|
+
dispatch({
|
|
37582
|
+
type: 'setStatus',
|
|
37583
|
+
value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
37584
|
+
kind: 'error',
|
|
37585
|
+
});
|
|
37586
|
+
}
|
|
37587
|
+
}
|
|
37377
37588
|
finally {
|
|
37378
37589
|
// Clear the ref only if it still points at OUR controller — a
|
|
37379
37590
|
// rapid second invocation could have already replaced it, in
|
|
@@ -37465,9 +37676,14 @@ function LogInkApp(deps) {
|
|
|
37465
37676
|
const cancelHandle = { cancelled: false };
|
|
37466
37677
|
pullRequestBodyCancelRef.current = cancelHandle;
|
|
37467
37678
|
dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
|
|
37679
|
+
// Audit finding #6: soft cancel today — Esc skips opening the
|
|
37680
|
+
// follow-up prompt, but the LLM call itself keeps running to
|
|
37681
|
+
// completion (no AbortSignal threaded through the changelog CLI
|
|
37682
|
+
// chain). Status copy reflects that honestly so the user isn't
|
|
37683
|
+
// misled into thinking they're saving tokens.
|
|
37468
37684
|
dispatch({
|
|
37469
37685
|
type: 'setStatus',
|
|
37470
|
-
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to
|
|
37686
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
|
|
37471
37687
|
loading: true,
|
|
37472
37688
|
});
|
|
37473
37689
|
try {
|
|
@@ -37494,6 +37710,15 @@ function LogInkApp(deps) {
|
|
|
37494
37710
|
else {
|
|
37495
37711
|
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
37496
37712
|
}
|
|
37713
|
+
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
37714
|
+
// prompt. If a future refactor adds an `await` between the flag
|
|
37715
|
+
// clear (currently in `finally`) and the `openInputPrompt`
|
|
37716
|
+
// dispatch, an Esc keystroke in the gap would dispatch
|
|
37717
|
+
// `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
|
|
37718
|
+
// the prompt visible with a stale "cancelled" message. Clearing
|
|
37719
|
+
// here moves the flag teardown into the same React batch as the
|
|
37720
|
+
// prompt open, eliminating the race.
|
|
37721
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37497
37722
|
dispatch({
|
|
37498
37723
|
type: 'openInputPrompt',
|
|
37499
37724
|
kind: 'create-pr',
|
|
@@ -37503,11 +37728,14 @@ function LogInkApp(deps) {
|
|
|
37503
37728
|
});
|
|
37504
37729
|
}
|
|
37505
37730
|
finally {
|
|
37506
|
-
//
|
|
37731
|
+
// Belt-and-suspenders: the `try` block clears the flag on the
|
|
37732
|
+
// success path (audit finding #11). This duplicate clear handles
|
|
37733
|
+
// the error / cancel paths where the early-returns skip the
|
|
37734
|
+
// success-path dispatch. Safe to no-op when already false.
|
|
37735
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37507
37736
|
// Only clear the ref if we still own it — a second invocation
|
|
37508
37737
|
// would have already taken ownership in which case the cancel
|
|
37509
37738
|
// duty has rolled over.
|
|
37510
|
-
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37511
37739
|
if (pullRequestBodyCancelRef.current === cancelHandle) {
|
|
37512
37740
|
pullRequestBodyCancelRef.current = null;
|
|
37513
37741
|
}
|
|
@@ -37608,6 +37836,11 @@ function LogInkApp(deps) {
|
|
|
37608
37836
|
branch: head,
|
|
37609
37837
|
baseLabel: cached.baseLabel,
|
|
37610
37838
|
text: cached.text,
|
|
37839
|
+
// Audit finding #9: cache-hit path preserves the original
|
|
37840
|
+
// generation timestamp rather than minting a fresh one — the
|
|
37841
|
+
// "X ago" header should reflect when the LLM ran, not when
|
|
37842
|
+
// the cached entry was re-displayed.
|
|
37843
|
+
generatedAt: cached.generatedAt,
|
|
37611
37844
|
});
|
|
37612
37845
|
dispatch({
|
|
37613
37846
|
type: 'setStatus',
|
|
@@ -37636,6 +37869,9 @@ function LogInkApp(deps) {
|
|
|
37636
37869
|
branch: head,
|
|
37637
37870
|
baseLabel,
|
|
37638
37871
|
text: result.text,
|
|
37872
|
+
// Audit finding #9: timestamp captured at dispatch time, not
|
|
37873
|
+
// inside the reducer.
|
|
37874
|
+
generatedAt: Date.now(),
|
|
37639
37875
|
});
|
|
37640
37876
|
dispatch({
|
|
37641
37877
|
type: 'setStatus',
|
|
@@ -37738,7 +37974,7 @@ function LogInkApp(deps) {
|
|
|
37738
37974
|
if (editorOk) {
|
|
37739
37975
|
try {
|
|
37740
37976
|
const content = fs$1.readFileSync(file, 'utf8');
|
|
37741
|
-
dispatch({ type: 'setChangelogText', text: content });
|
|
37977
|
+
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
37742
37978
|
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
37743
37979
|
}
|
|
37744
37980
|
catch (error) {
|
|
@@ -38093,7 +38329,8 @@ function LogInkApp(deps) {
|
|
|
38093
38329
|
// that could disagree with reality on partial-apply.
|
|
38094
38330
|
const commitHashes = result.commitHashes || [];
|
|
38095
38331
|
if (commitHashes.length > 0) {
|
|
38096
|
-
|
|
38332
|
+
// Audit finding #9: timestamp captured at dispatch time.
|
|
38333
|
+
dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
|
|
38097
38334
|
// DevSkim: ignore DS172411 — function literal, fixed delay,
|
|
38098
38335
|
// no caller-supplied data flowing through.
|
|
38099
38336
|
setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
|