@payconductor/houndsman 0.4.0 → 0.5.0
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 +12 -38
- package/package.json +1 -1
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/core/commands/findings.ts +61 -5
- package/src/core/commands/status.ts +63 -0
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/findings-state.ts +21 -4
- package/src/core/github-review.ts +620 -57
- package/src/core/orchestrator.ts +98 -8
- package/src/core/paths.ts +3 -0
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-status.ts +39 -5
- package/src/core/storage/file/file-storage.ts +2 -0
- package/src/core/storage/sqlite/mappers.ts +2 -0
- package/src/core/storage/sqlite/schema.ts +9 -1
- package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
- package/src/core/storage/types.ts +9 -0
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +12 -5
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +14 -10
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +92 -12
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +49 -6
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/{constants.ts → utils/constants.ts} +34 -5
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +58 -0
- package/src/server/index.ts +63 -5
- package/src/server/run-repository.ts +9 -0
- package/src/skill/example-review.md +22 -14
- package/src/skill/hunt.md +31 -17
- package/src/skill/verify.md +2 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +32 -30
- package/src/tests/baseline.test.ts +3 -0
- package/src/tests/code-highlight.test.ts +1 -1
- package/src/tests/findings-state.test.ts +50 -2
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/markdown.test.ts +61 -1
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +73 -0
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +53 -0
- package/src/tests/server.test.ts +76 -21
- package/src/tests/storage.test.ts +63 -0
- package/src/server/client/markdown.ts +0 -153
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
- /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
package/src/core/orchestrator.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { ReviewScopeResolver, type RunClassification } from "./engine/review-sco
|
|
|
30
30
|
import type { BudgetSelection, ClassificationResult } from "./file-classifier";
|
|
31
31
|
import { buildStateFromFindings, fileReader } from "./findings-state";
|
|
32
32
|
import { resolveSha } from "./git-revision";
|
|
33
|
+
import { syncResolvedThreads } from "./github-review";
|
|
33
34
|
import {
|
|
34
35
|
type JevCandidate,
|
|
35
36
|
type JevCandidatesFile,
|
|
@@ -39,12 +40,18 @@ import {
|
|
|
39
40
|
parseCandidates,
|
|
40
41
|
serializeCandidates,
|
|
41
42
|
} from "./jev";
|
|
42
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
displayPath,
|
|
45
|
+
OUTPUT_FORMAT_PATH,
|
|
46
|
+
REVIEW_FILE,
|
|
47
|
+
REVIEW_RESUME_FILE,
|
|
48
|
+
} from "./paths";
|
|
43
49
|
import { currentBranch } from "./pr-context";
|
|
44
50
|
import { ProjectConfig } from "./project-config";
|
|
45
51
|
import { BriefRenderer } from "./renderers/brief-renderer";
|
|
46
52
|
import { ManifestWriter } from "./renderers/manifest-writer";
|
|
47
53
|
import { ReportRenderer } from "./renderers/report-renderer";
|
|
54
|
+
import { renderReviewResume } from "./renderers/resume-renderer";
|
|
48
55
|
import {
|
|
49
56
|
type ContextSummary,
|
|
50
57
|
type DomainReport,
|
|
@@ -312,19 +319,36 @@ export async function prepare(
|
|
|
312
319
|
if (opts.clean) ctx.work.cleanFindings();
|
|
313
320
|
ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
|
|
314
321
|
|
|
322
|
+
// Marca o commit revisado e decide entre completo, incremental ou no-op.
|
|
323
|
+
const headSha = resolveSha(opts.head);
|
|
324
|
+
const previous = ctx.storage.readFindings(ctx.review.id);
|
|
325
|
+
const scope = new ReviewScopeResolver(opts, config).resolve(previous, headSha);
|
|
326
|
+
|
|
327
|
+
// Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
|
|
328
|
+
if (scope.kind === "noop") {
|
|
329
|
+
const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
|
|
330
|
+
// Ciclo em andamento continua válido (os findings já despachados devem
|
|
331
|
+
// ser finalizados); fora isso, o relatório anterior é a resposta final.
|
|
332
|
+
const pendingCycle = status === "reviewing";
|
|
333
|
+
if (!pendingCycle) {
|
|
334
|
+
writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
|
|
335
|
+
}
|
|
336
|
+
messages.push(
|
|
337
|
+
`Nenhuma alteração desde a última revisão (${shortSha(scope.reviewedFrom)}).`,
|
|
338
|
+
pendingCycle
|
|
339
|
+
? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
|
|
340
|
+
: "Nada a revisar — o relatório anterior foi mantido. Use `--full` para re-revisar.",
|
|
341
|
+
);
|
|
342
|
+
return { messages };
|
|
343
|
+
}
|
|
344
|
+
|
|
315
345
|
// Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
|
|
316
346
|
writeRunStatus(ctx.storage, ctx.review.id, {
|
|
317
347
|
status: "reviewing",
|
|
318
348
|
resetStartedAt: true,
|
|
319
349
|
});
|
|
320
350
|
|
|
321
|
-
|
|
322
|
-
const headSha = resolveSha(opts.head);
|
|
323
|
-
const previous = ctx.storage.readFindings(ctx.review.id);
|
|
324
|
-
const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
|
|
325
|
-
previous,
|
|
326
|
-
headSha,
|
|
327
|
-
);
|
|
351
|
+
const { classification, diff } = scope;
|
|
328
352
|
ctx.storage.writeArtifact(ctx.review.id, {
|
|
329
353
|
name: "classification.json",
|
|
330
354
|
kind: "classification",
|
|
@@ -510,6 +534,27 @@ export function finalize(
|
|
|
510
534
|
config: HoundsmanConfig,
|
|
511
535
|
ctx: ReviewContext,
|
|
512
536
|
): FinalizeResult {
|
|
537
|
+
// `--finalize` só consolida um ciclo preparado (status `reviewing`). Sem esta
|
|
538
|
+
// guarda, um prepare no-op seguido de `--finalize` reescreveria o relatório
|
|
539
|
+
// anterior com a classificação antiga (perdendo os relatórios por domínio).
|
|
540
|
+
// O `ctx.review` é um snapshot: relê o status atual do storage.
|
|
541
|
+
const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
|
|
542
|
+
if (status !== "reviewing") {
|
|
543
|
+
const state = ctx.storage.readFindings(ctx.review.id);
|
|
544
|
+
const present = Object.values(state?.findings ?? {}).filter(
|
|
545
|
+
(finding) => finding.present,
|
|
546
|
+
);
|
|
547
|
+
return {
|
|
548
|
+
reportPath: ctx.storage.location(ctx.review.id),
|
|
549
|
+
blockers: present.filter((f) => f.severity === "blocking").length,
|
|
550
|
+
attention: present.filter((f) => f.severity === "attention").length,
|
|
551
|
+
messages: [
|
|
552
|
+
`Nada a finalizar: nenhum ciclo de revisão pendente (status: ${status ?? "sem status"}).`,
|
|
553
|
+
"Se nada mudou desde a última revisão, o relatório anterior foi mantido; use `--full` para re-revisar.",
|
|
554
|
+
],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
513
558
|
const classification = readClassification(ctx);
|
|
514
559
|
const activeIds = Object.keys(classification.watchdogs);
|
|
515
560
|
|
|
@@ -546,12 +591,44 @@ export function finalize(
|
|
|
546
591
|
branch,
|
|
547
592
|
readFile: fileReader(process.cwd()),
|
|
548
593
|
previous: ctx.storage.readFindings(ctx.review.id),
|
|
594
|
+
reviewedPaths: new Set([
|
|
595
|
+
...reports.flatMap((report) => report.reviewedFiles),
|
|
596
|
+
// Arquivo deletado: o achado deixou de existir junto com ele.
|
|
597
|
+
...classification.deleted,
|
|
598
|
+
]),
|
|
549
599
|
now,
|
|
550
600
|
headSha: classification.headSha,
|
|
551
601
|
reviewed: classification.headSha
|
|
552
602
|
? { headSha: classification.headSha, at: now }
|
|
553
603
|
: undefined,
|
|
554
604
|
});
|
|
605
|
+
|
|
606
|
+
// Findings corrigidos entre revisões (ausentes + done) têm a conversa do PR
|
|
607
|
+
// resolvida automaticamente; as já resolvidas no GitHub são puladas. Só
|
|
608
|
+
// toca o `gh` quando há vínculo remoto pendente (fail-open).
|
|
609
|
+
const threadIds = Object.values(state.findings)
|
|
610
|
+
.filter(
|
|
611
|
+
(finding) =>
|
|
612
|
+
!finding.present &&
|
|
613
|
+
finding.execState === "done" &&
|
|
614
|
+
finding.github &&
|
|
615
|
+
finding.github.kind !== "comment" &&
|
|
616
|
+
!finding.github.resolvedAt,
|
|
617
|
+
)
|
|
618
|
+
.map((finding) => finding.id);
|
|
619
|
+
if (threadIds.length > 0) {
|
|
620
|
+
const sync = syncResolvedThreads({ state, ids: threadIds, now });
|
|
621
|
+
if (!sync.available) {
|
|
622
|
+
messages.push(`GitHub: threads não sincronizadas (${sync.reason}).`);
|
|
623
|
+
} else if (sync.checked > 0) {
|
|
624
|
+
const failures =
|
|
625
|
+
sync.failed.length > 0 ? `, ${sync.failed.length} falha(s)` : "";
|
|
626
|
+
messages.push(
|
|
627
|
+
`GitHub: ${sync.resolved.length} thread(s) resolvida(s), ${sync.skipped.length} já resolvida(s)/pulada(s)${failures}.`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
555
632
|
ctx.storage.writeFindings(ctx.review.id, state);
|
|
556
633
|
ctx.storage.updateReview(ctx.review.id, {
|
|
557
634
|
base: opts.base,
|
|
@@ -585,6 +662,19 @@ export function finalize(
|
|
|
585
662
|
kind: "review",
|
|
586
663
|
content: `${incrementalNote(classification)}${markdown}\n`,
|
|
587
664
|
});
|
|
665
|
+
// Resumo final para leitura humana — não participa de nenhuma etapa seguinte.
|
|
666
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
667
|
+
name: REVIEW_RESUME_FILE,
|
|
668
|
+
kind: "resume",
|
|
669
|
+
content: renderReviewResume({
|
|
670
|
+
branch,
|
|
671
|
+
date: now.slice(0, 10),
|
|
672
|
+
context,
|
|
673
|
+
classification,
|
|
674
|
+
findings: merged,
|
|
675
|
+
nameOf,
|
|
676
|
+
}),
|
|
677
|
+
});
|
|
588
678
|
|
|
589
679
|
const location = ctx.storage.location(ctx.review.id);
|
|
590
680
|
messages.push(
|
package/src/core/paths.ts
CHANGED
|
@@ -52,3 +52,6 @@ export const VERIFY_FILE = "verify.md";
|
|
|
52
52
|
|
|
53
53
|
/** Relatório consolidado da review (artefato `review.md`). */
|
|
54
54
|
export const REVIEW_FILE = "review.md";
|
|
55
|
+
|
|
56
|
+
/** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
|
|
57
|
+
export const REVIEW_RESUME_FILE = "review-resume.md";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resumo final da revisão (`review-resume.md`).
|
|
3
|
+
*
|
|
4
|
+
* Artefato somente-leitura para humanos: não entra em briefs, não é lido pelos
|
|
5
|
+
* watchdogs e não participa de nenhuma etapa seguinte. Une o contexto de
|
|
6
|
+
* negócio da PR aos números técnicos diretos da revisão, com limite de
|
|
7
|
+
* `RESUME_MAX_CHARS`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ContextSummary,
|
|
12
|
+
MergedFinding,
|
|
13
|
+
WatchdogNameResolver,
|
|
14
|
+
} from "../report-builder";
|
|
15
|
+
import { systemClock } from "../services/clock";
|
|
16
|
+
|
|
17
|
+
/** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
|
|
18
|
+
export const RESUME_MAX_CHARS = 2500;
|
|
19
|
+
|
|
20
|
+
/** Dados de classificação usados no resumo. */
|
|
21
|
+
export type ResumeClassification = {
|
|
22
|
+
files: Record<string, { status: string }>;
|
|
23
|
+
watchdogs: Record<string, string[]>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ReviewResumeInput = {
|
|
27
|
+
branch: string;
|
|
28
|
+
/** `YYYY-MM-DD`; default: data corrente do sistema. */
|
|
29
|
+
date?: string;
|
|
30
|
+
context?: ContextSummary | null;
|
|
31
|
+
classification: ResumeClassification;
|
|
32
|
+
findings: readonly MergedFinding[];
|
|
33
|
+
nameOf?: WatchdogNameResolver;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const identityName: WatchdogNameResolver = (id) => id;
|
|
37
|
+
|
|
38
|
+
const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
|
|
39
|
+
blocking: "❌ Mudanças necessárias",
|
|
40
|
+
attention: "⚠️ Aprovado com ressalvas",
|
|
41
|
+
clean: "✅ Aprovado",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Corta o texto em `max` caracteres, sinalizando com reticências. */
|
|
45
|
+
function truncate(value: string, max: number): string {
|
|
46
|
+
const text = value.trim().replace(/\s+/g, " ");
|
|
47
|
+
return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function plural(count: number, singular: string, plural: string): string {
|
|
51
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resumo de negócio: contexto da PR; sem ele, o alcance da branch. */
|
|
55
|
+
function businessSection(input: ReviewResumeInput): string[] {
|
|
56
|
+
const context = input.context;
|
|
57
|
+
const hasContext =
|
|
58
|
+
context?.available === true &&
|
|
59
|
+
Boolean(
|
|
60
|
+
context.objective ||
|
|
61
|
+
context.motivation ||
|
|
62
|
+
(context.changeSummary && context.changeSummary.length > 0),
|
|
63
|
+
);
|
|
64
|
+
const lines = ["## Resumo de negócio", ""];
|
|
65
|
+
|
|
66
|
+
if (!hasContext) {
|
|
67
|
+
const domainCount = Object.keys(input.classification.watchdogs).length;
|
|
68
|
+
lines.push(
|
|
69
|
+
`A branch \`${input.branch}\` altera ${plural(
|
|
70
|
+
Object.keys(input.classification.files).length,
|
|
71
|
+
"arquivo",
|
|
72
|
+
"arquivos",
|
|
73
|
+
)} em ${plural(domainCount, "domínio", "domínios")} de revisão.`,
|
|
74
|
+
"Sem resumo de contexto da PR disponível.",
|
|
75
|
+
"",
|
|
76
|
+
);
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (context.objective) lines.push(context.objective, "");
|
|
81
|
+
if (context.motivation) lines.push(`**Por quê:** ${context.motivation}`, "");
|
|
82
|
+
if (context.changeSummary && context.changeSummary.length > 0) {
|
|
83
|
+
for (const item of context.changeSummary.slice(0, 4)) {
|
|
84
|
+
lines.push(`- ${truncate(item, 180)}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push("");
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Achados que exigem ação, em formato direto (máximo 5). */
|
|
92
|
+
function technicalSection(
|
|
93
|
+
findings: readonly MergedFinding[],
|
|
94
|
+
nameOf: WatchdogNameResolver,
|
|
95
|
+
): string[] {
|
|
96
|
+
const relevant = findings
|
|
97
|
+
.filter(
|
|
98
|
+
(finding) =>
|
|
99
|
+
finding.severity === "blocking" || finding.severity === "attention",
|
|
100
|
+
)
|
|
101
|
+
.slice(0, 5);
|
|
102
|
+
const lines = ["## Pontos técnicos", ""];
|
|
103
|
+
if (relevant.length === 0) {
|
|
104
|
+
lines.push("- Nenhum bloqueio ou ponto de atenção.", "");
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
for (const finding of relevant) {
|
|
108
|
+
const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
|
109
|
+
const watchdogs = finding.watchdogs.map((id) => nameOf(id)).join(", ");
|
|
110
|
+
lines.push(
|
|
111
|
+
`- \`${location}\` — ${truncate(finding.message, 130)}${watchdogs ? ` _(${watchdogs})_` : ""}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
lines.push("");
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Garante o teto de caracteres cortando em uma quebra de linha. */
|
|
119
|
+
function clamp(markdown: string, max: number): string {
|
|
120
|
+
if (markdown.length <= max) return markdown;
|
|
121
|
+
const head = markdown.slice(0, max - 2);
|
|
122
|
+
const cut = head.lastIndexOf("\n");
|
|
123
|
+
return `${(cut > 0 ? head.slice(0, cut) : head).trimEnd()}\n…`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
|
|
128
|
+
* nasce do contexto já disponível e dos findings consolidados.
|
|
129
|
+
*/
|
|
130
|
+
export function renderReviewResume(input: ReviewResumeInput): string {
|
|
131
|
+
const nameOf = input.nameOf ?? identityName;
|
|
132
|
+
const blockers = input.findings.filter((f) => f.severity === "blocking").length;
|
|
133
|
+
const attention = input.findings.filter((f) => f.severity === "attention").length;
|
|
134
|
+
const status =
|
|
135
|
+
blockers > 0
|
|
136
|
+
? STATUS_BY_SEVERITY.blocking
|
|
137
|
+
: attention > 0
|
|
138
|
+
? STATUS_BY_SEVERITY.attention
|
|
139
|
+
: STATUS_BY_SEVERITY.clean;
|
|
140
|
+
|
|
141
|
+
const lines = [
|
|
142
|
+
`# Resumo da revisão — ${input.branch}`,
|
|
143
|
+
"",
|
|
144
|
+
`**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
|
|
145
|
+
"",
|
|
146
|
+
...businessSection(input),
|
|
147
|
+
...technicalSection(input.findings, nameOf),
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
|
|
151
|
+
}
|
|
@@ -294,6 +294,7 @@ function renderConclusion(counts: Record<Severity, number>): string[] {
|
|
|
294
294
|
"",
|
|
295
295
|
`**Status:** ${status}`,
|
|
296
296
|
`**Resumo:** ${counts.blocking} violações bloqueantes, ${counts.attention} pontos de atenção, ${counts.suggestion} sugestões e ${counts.nit} nits.`,
|
|
297
|
+
"",
|
|
297
298
|
];
|
|
298
299
|
}
|
|
299
300
|
|
|
@@ -453,6 +454,12 @@ export function renderReport(
|
|
|
453
454
|
|
|
454
455
|
if (context) lines.push(...renderContext(context));
|
|
455
456
|
|
|
457
|
+
// Sumário primeiro: quem lê o review.md vê domínios, cobertura e conclusão
|
|
458
|
+
// antes dos pontos específicos.
|
|
459
|
+
lines.push(...renderDomainIndex(domains));
|
|
460
|
+
lines.push(...renderCoverage(reports, coverage, nameOf));
|
|
461
|
+
lines.push(...renderConclusion(counts));
|
|
462
|
+
|
|
456
463
|
lines.push(...renderFindingsSections(merged, nameOf));
|
|
457
464
|
|
|
458
465
|
if (merged.length === 0) {
|
|
@@ -463,9 +470,5 @@ export function renderReport(
|
|
|
463
470
|
lines.push(...renderUnconfirmedCandidates(candidates, merged));
|
|
464
471
|
}
|
|
465
472
|
|
|
466
|
-
lines.push(...renderDomainIndex(domains));
|
|
467
|
-
lines.push(...renderCoverage(reports, coverage, nameOf));
|
|
468
|
-
lines.push(...renderConclusion(counts));
|
|
469
|
-
|
|
470
473
|
return lines.join("\n");
|
|
471
474
|
}
|
package/src/core/run-status.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
|
|
5
5
|
* (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
|
|
6
6
|
*
|
|
7
|
-
* Toda ação que muda o estado
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Toda ação que muda o estado pinga o `updatedAt`. Execuções ativas têm um
|
|
8
|
+
* `heartbeatAt` próprio (renovado por `status ping` ou pela correção); a UI usa
|
|
9
|
+
* esse campo — não o `updatedAt` — para detectar timeout (display-only).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { FindingsStateFile } from "./findings-state";
|
|
@@ -24,6 +24,11 @@ export const RUN_STATUSES: readonly RunStatus[] = [
|
|
|
24
24
|
"fix_done",
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
/** `true` para status de execução (recebem heartbeat e podem dar timeout). */
|
|
28
|
+
export function isActiveRunStatus(status: RunStatus | null): boolean {
|
|
29
|
+
return status === "reviewing" || status === "fixing";
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
/** Lê o status atual (null se a review não existir). */
|
|
28
33
|
export function readRunStatus(
|
|
29
34
|
storage: StorageProvider,
|
|
@@ -36,6 +41,11 @@ export type WriteRunStatusOptions = {
|
|
|
36
41
|
status: RunStatus;
|
|
37
42
|
/** Reinicia o `startedAt` — usado no início de uma nova revisão. */
|
|
38
43
|
resetStartedAt?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Marca a execução como viva agora. Implícito no início de um ciclo
|
|
46
|
+
* (`resetStartedAt`); pings explícitos usam `touchRunStatus`.
|
|
47
|
+
*/
|
|
48
|
+
touch?: boolean;
|
|
39
49
|
now?: string;
|
|
40
50
|
};
|
|
41
51
|
|
|
@@ -45,15 +55,35 @@ export function writeRunStatus(
|
|
|
45
55
|
reviewId: string,
|
|
46
56
|
options: WriteRunStatusOptions,
|
|
47
57
|
): ReviewRecord {
|
|
58
|
+
const now = options.now ?? systemClock();
|
|
59
|
+
const active = isActiveRunStatus(options.status);
|
|
60
|
+
const touch = options.touch ?? options.resetStartedAt ?? false;
|
|
48
61
|
const updated = storage.updateReview(reviewId, {
|
|
49
62
|
status: options.status,
|
|
50
63
|
resetStartedAt: options.resetStartedAt,
|
|
51
|
-
|
|
64
|
+
heartbeatAt: active && touch ? now : undefined,
|
|
65
|
+
resetHeartbeat: !active,
|
|
66
|
+
updatedAt: now,
|
|
52
67
|
});
|
|
53
68
|
if (!updated) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
54
69
|
return updated;
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Heartbeat explícito de uma execução em andamento (skill viva).
|
|
74
|
+
* Só age em `reviewing`/`fixing`; status terminal é devolvido sem alteração.
|
|
75
|
+
*/
|
|
76
|
+
export function touchRunStatus(
|
|
77
|
+
storage: StorageProvider,
|
|
78
|
+
reviewId: string,
|
|
79
|
+
now: string = systemClock(),
|
|
80
|
+
): ReviewRecord {
|
|
81
|
+
const review = storage.getReview(reviewId);
|
|
82
|
+
if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
|
|
83
|
+
if (!isActiveRunStatus(review.status)) return review;
|
|
84
|
+
return storage.updateReview(reviewId, { heartbeatAt: now, updatedAt: now }) ?? review;
|
|
85
|
+
}
|
|
86
|
+
|
|
57
87
|
/**
|
|
58
88
|
* Deriva o status após uma mudança nos findings (triagem/correção).
|
|
59
89
|
* `fixing` exige correção em andamento (`in_progress`) ou retomada de um fix;
|
|
@@ -87,7 +117,11 @@ export function pingFindingsStatus(
|
|
|
87
117
|
state: FindingsStateFile,
|
|
88
118
|
): ReviewRecord {
|
|
89
119
|
const previous = storage.getReview(reviewId)?.status ?? null;
|
|
120
|
+
const status = statusAfterFindingsChange(previous, state);
|
|
90
121
|
return writeRunStatus(storage, reviewId, {
|
|
91
|
-
status
|
|
122
|
+
status,
|
|
123
|
+
// Só a correção em andamento é uma execução: triagem/reviewing não
|
|
124
|
+
// devem renovar o heartbeat (era a origem do "timeout falso").
|
|
125
|
+
touch: status === "fixing",
|
|
92
126
|
});
|
|
93
127
|
}
|
|
@@ -102,6 +102,8 @@ export class FileStorageProvider implements StorageProvider {
|
|
|
102
102
|
if (patch.status !== undefined) updated.status = patch.status;
|
|
103
103
|
if (patch.resetStartedAt) updated.startedAt = now;
|
|
104
104
|
else if (patch.startedAt !== undefined) updated.startedAt = patch.startedAt;
|
|
105
|
+
if (patch.resetHeartbeat) updated.heartbeatAt = undefined;
|
|
106
|
+
else if (patch.heartbeatAt !== undefined) updated.heartbeatAt = patch.heartbeatAt;
|
|
105
107
|
updated.updatedAt = patch.updatedAt ?? now;
|
|
106
108
|
return this.writeReview(updated);
|
|
107
109
|
}
|
|
@@ -30,6 +30,7 @@ export type ReviewRow = {
|
|
|
30
30
|
reviewed_at: string | null;
|
|
31
31
|
status: string | null;
|
|
32
32
|
started_at: string | null;
|
|
33
|
+
heartbeat_at: string | null;
|
|
33
34
|
created_at: string;
|
|
34
35
|
updated_at: string;
|
|
35
36
|
};
|
|
@@ -96,6 +97,7 @@ export function reviewFromRow(row: ReviewRow): ReviewRecord {
|
|
|
96
97
|
reviewedAt: row.reviewed_at ?? undefined,
|
|
97
98
|
status: (row.status as ReviewRecord["status"]) ?? null,
|
|
98
99
|
startedAt: row.started_at ?? undefined,
|
|
100
|
+
heartbeatAt: row.heartbeat_at ?? undefined,
|
|
99
101
|
createdAt: row.created_at,
|
|
100
102
|
updatedAt: row.updated_at,
|
|
101
103
|
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Database } from "bun:sqlite";
|
|
9
9
|
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
const DDL = `
|
|
13
13
|
CREATE TABLE IF NOT EXISTS reviews (
|
|
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS reviews (
|
|
|
24
24
|
reviewed_at TEXT,
|
|
25
25
|
status TEXT,
|
|
26
26
|
started_at TEXT,
|
|
27
|
+
heartbeat_at TEXT,
|
|
27
28
|
created_at TEXT NOT NULL,
|
|
28
29
|
updated_at TEXT NOT NULL,
|
|
29
30
|
UNIQUE(repo_key, branch)
|
|
@@ -103,6 +104,13 @@ export function migrate(db: Database, path: string): void {
|
|
|
103
104
|
|
|
104
105
|
db.transaction(() => {
|
|
105
106
|
db.exec(DDL);
|
|
107
|
+
// v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
|
|
108
|
+
const columns = db.query("PRAGMA table_info(reviews)").all() as {
|
|
109
|
+
name: string;
|
|
110
|
+
}[];
|
|
111
|
+
if (!columns.some((column) => column.name === "heartbeat_at")) {
|
|
112
|
+
db.exec("ALTER TABLE reviews ADD COLUMN heartbeat_at TEXT");
|
|
113
|
+
}
|
|
106
114
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
107
115
|
})();
|
|
108
116
|
}
|
|
@@ -33,7 +33,7 @@ import { migrate } from "./schema";
|
|
|
33
33
|
|
|
34
34
|
/** Colunas mutáveis de review (camel → snake). */
|
|
35
35
|
const REVIEW_PATCH_COLUMNS: Record<
|
|
36
|
-
Exclude<keyof ReviewPatch, "resetStartedAt">,
|
|
36
|
+
Exclude<keyof ReviewPatch, "resetStartedAt" | "resetHeartbeat">,
|
|
37
37
|
string
|
|
38
38
|
> = {
|
|
39
39
|
branch: "branch",
|
|
@@ -45,6 +45,7 @@ const REVIEW_PATCH_COLUMNS: Record<
|
|
|
45
45
|
reviewedAt: "reviewed_at",
|
|
46
46
|
status: "status",
|
|
47
47
|
startedAt: "started_at",
|
|
48
|
+
heartbeatAt: "heartbeat_at",
|
|
48
49
|
updatedAt: "updated_at",
|
|
49
50
|
};
|
|
50
51
|
|
|
@@ -159,6 +160,9 @@ export class SQLiteStorageProvider implements StorageProvider {
|
|
|
159
160
|
sets.push("started_at = ?");
|
|
160
161
|
params.push(now);
|
|
161
162
|
}
|
|
163
|
+
if (patch.resetHeartbeat) {
|
|
164
|
+
sets.push("heartbeat_at = NULL");
|
|
165
|
+
}
|
|
162
166
|
if (patch.updatedAt === undefined) {
|
|
163
167
|
sets.push("updated_at = ?");
|
|
164
168
|
params.push(now);
|
|
@@ -36,6 +36,11 @@ export type ReviewRecord = {
|
|
|
36
36
|
reviewedAt?: string;
|
|
37
37
|
status: ReviewStatus | null;
|
|
38
38
|
startedAt?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Último sinal de vida de uma execução ativa (`reviewing`/`fixing`).
|
|
41
|
+
* Diferente de `updatedAt`: não muda com triagem/sync — só com a execução.
|
|
42
|
+
*/
|
|
43
|
+
heartbeatAt?: string;
|
|
39
44
|
createdAt: string;
|
|
40
45
|
updatedAt: string;
|
|
41
46
|
};
|
|
@@ -53,11 +58,14 @@ export type ReviewPatch = Partial<
|
|
|
53
58
|
| "reviewedAt"
|
|
54
59
|
| "status"
|
|
55
60
|
| "startedAt"
|
|
61
|
+
| "heartbeatAt"
|
|
56
62
|
| "updatedAt"
|
|
57
63
|
>
|
|
58
64
|
> & {
|
|
59
65
|
/** Reinicia o `startedAt` no início de um novo ciclo de revisão. */
|
|
60
66
|
resetStartedAt?: boolean;
|
|
67
|
+
/** Limpa o heartbeat (execução terminou). */
|
|
68
|
+
resetHeartbeat?: boolean;
|
|
61
69
|
};
|
|
62
70
|
|
|
63
71
|
/** Entrada de `getOrCreateReview`. */
|
|
@@ -81,6 +89,7 @@ export type ArtifactKind =
|
|
|
81
89
|
| "context-summary"
|
|
82
90
|
| "finding-report"
|
|
83
91
|
| "review"
|
|
92
|
+
| "resume"
|
|
84
93
|
| "domain-review"
|
|
85
94
|
| "verify"
|
|
86
95
|
| "other";
|
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* App: layout do servidor — sidebar de execuções, header fixo (abas segmentadas,
|
|
3
|
-
* auto-refresh e reancoragem), KPIs, filtros e os painéis de findings/
|
|
4
|
-
* Concentra o estado e as chamadas à API.
|
|
3
|
+
* auto-refresh e reancoragem), KPIs, filtros e os painéis de resumo/findings/
|
|
4
|
+
* artefatos. Concentra o estado e as chamadas à API.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
8
8
|
import { ArtifactsPanel } from "./ArtifactsPanel";
|
|
9
9
|
import { AutoRefresh } from "./AutoRefresh";
|
|
10
|
-
import { api, computeSummary } from "./api";
|
|
11
|
-
import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./constants";
|
|
12
10
|
import { Filters, type FiltersState } from "./Filters";
|
|
13
11
|
import { FindingsList } from "./FindingsList";
|
|
14
|
-
import { Layers, ListIcon, Refresh, Send } from "./icons";
|
|
12
|
+
import { FileText, Layers, ListIcon, Refresh, Send } from "./icons";
|
|
13
|
+
import { ResumePanel } from "./ResumePanel";
|
|
15
14
|
import { RunList } from "./RunList";
|
|
16
15
|
import { SubmitModal } from "./SubmitModal";
|
|
17
16
|
import { SummaryBar } from "./SummaryBar";
|
|
18
|
-
import
|
|
17
|
+
import { api, computeSummary } from "./utils/api";
|
|
18
|
+
import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./utils/constants";
|
|
19
|
+
import type { Finding, FindingsResponse, RunSummary } from "./utils/types";
|
|
19
20
|
|
|
20
|
-
type Tab = "findings" | "artifacts";
|
|
21
|
+
type Tab = "resume" | "findings" | "artifacts";
|
|
21
22
|
|
|
22
23
|
const TABS: { id: Tab; label: string; hint: string }[] = [
|
|
24
|
+
{
|
|
25
|
+
id: "resume",
|
|
26
|
+
label: "Resumo",
|
|
27
|
+
hint: "Visão principal da revisão: negócio, números e pontos técnicos",
|
|
28
|
+
},
|
|
23
29
|
{
|
|
24
30
|
id: "findings",
|
|
25
31
|
label: "Findings",
|
|
@@ -36,7 +42,7 @@ export function App() {
|
|
|
36
42
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
37
43
|
const [slug, setSlug] = useState<string | null>(null);
|
|
38
44
|
const [data, setData] = useState<FindingsResponse | null>(null);
|
|
39
|
-
const [tab, setTab] = useState<Tab>("
|
|
45
|
+
const [tab, setTab] = useState<Tab>("resume");
|
|
40
46
|
const [filters, setFilters] = useState<FiltersState>({
|
|
41
47
|
query: "",
|
|
42
48
|
decision: "all",
|
|
@@ -200,7 +206,9 @@ export function App() {
|
|
|
200
206
|
class={`seg-btn${tab === item.id ? " active" : ""}`}
|
|
201
207
|
onClick={() => setTab(item.id)}
|
|
202
208
|
>
|
|
203
|
-
{item.id === "
|
|
209
|
+
{item.id === "resume" ? (
|
|
210
|
+
<FileText size={15} />
|
|
211
|
+
) : item.id === "findings" ? (
|
|
204
212
|
<ListIcon size={15} />
|
|
205
213
|
) : (
|
|
206
214
|
<Layers size={15} />
|
|
@@ -254,6 +262,15 @@ export function App() {
|
|
|
254
262
|
</div>
|
|
255
263
|
{error && <p class="error">{error}</p>}
|
|
256
264
|
|
|
265
|
+
{slug && tab === "resume" && data && (
|
|
266
|
+
<ResumePanel
|
|
267
|
+
slug={slug}
|
|
268
|
+
branch={branch}
|
|
269
|
+
findings={data.findings}
|
|
270
|
+
summary={summary}
|
|
271
|
+
onOpenArtifacts={() => setTab("artifacts")}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
257
274
|
{slug && tab === "findings" && data && (
|
|
258
275
|
<>
|
|
259
276
|
<SummaryBar summary={summary} />
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Painel de artefatos: lista todos os artefatos persistidos da run e renderiza
|
|
3
|
-
* o conteúdo bruto. JSON ganha destaque de sintaxe;
|
|
3
|
+
* o conteúdo bruto. JSON ganha destaque de sintaxe; markdown é renderizado com
|
|
4
|
+
* o mesmo parser/highlight do restante da UI. Briefs e relatórios ficam
|
|
4
5
|
* auditáveis pela mesma navegação.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
8
|
-
import {
|
|
9
|
+
import { MarkdownText } from "./MarkdownText";
|
|
10
|
+
import { api } from "./utils/api";
|
|
9
11
|
import {
|
|
10
12
|
ARTIFACT_KIND_LABELS,
|
|
11
13
|
describeArtifact,
|
|
12
14
|
displayArtifactName,
|
|
13
15
|
groupArtifacts,
|
|
14
16
|
isJsonArtifact,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import
|
|
17
|
+
isMarkdownArtifact,
|
|
18
|
+
} from "./utils/constants";
|
|
19
|
+
import { formatJson, highlightJson } from "./utils/json-highlight";
|
|
20
|
+
import type { ArtifactMeta } from "./utils/types";
|
|
18
21
|
|
|
19
22
|
/** Prefere o relatório consolidado; senão, o primeiro artefato disponível. */
|
|
20
23
|
function pickDefault(items: ArtifactMeta[]): string {
|
|
@@ -149,6 +152,10 @@ export function ArtifactsPanel({ slug }: { slug: string }) {
|
|
|
149
152
|
) : selected ? (
|
|
150
153
|
isJsonArtifact(selected) ? (
|
|
151
154
|
<JsonView content={content} />
|
|
155
|
+
) : isMarkdownArtifact(selected) ? (
|
|
156
|
+
<div class="artifact-view artifact-markdown">
|
|
157
|
+
<MarkdownText text={content} />
|
|
158
|
+
</div>
|
|
152
159
|
) : (
|
|
153
160
|
<pre class="artifact-view">{content || "—"}</pre>
|
|
154
161
|
)
|