@payconductor/houndsman 0.7.0 → 0.8.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.
Files changed (54) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/worktree-command.ts +118 -0
  8. package/src/cli/help.ts +12 -2
  9. package/src/cli/index.ts +14 -4
  10. package/src/core/ast/service.ts +7 -2
  11. package/src/core/brief.ts +1 -0
  12. package/src/core/commands/context.ts +9 -1
  13. package/src/core/commands/findings.ts +11 -5
  14. package/src/core/commands/review.ts +13 -0
  15. package/src/core/commands/scaffold.ts +4 -2
  16. package/src/core/config.ts +13 -0
  17. package/src/core/defaults.ts +7 -0
  18. package/src/core/engine/houndsman-engine.ts +26 -3
  19. package/src/core/engine/review-scope.ts +17 -22
  20. package/src/core/engine/review-session.ts +25 -2
  21. package/src/core/engine/review-workspace.ts +204 -0
  22. package/src/core/jev/jev-detector.ts +10 -2
  23. package/src/core/orchestrator.ts +62 -13
  24. package/src/core/project-config.ts +12 -0
  25. package/src/core/services/git-repository.ts +46 -0
  26. package/src/core/services/pr-resolver.ts +147 -0
  27. package/src/core/services/worktree-manager.ts +269 -0
  28. package/src/core/storage/file/file-storage.ts +14 -1
  29. package/src/core/storage/paths.ts +30 -1
  30. package/src/core/storage/sqlite/mappers.ts +4 -0
  31. package/src/core/storage/sqlite/schema.ts +13 -3
  32. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  33. package/src/core/storage/types.ts +12 -0
  34. package/src/core/storage/work-area.ts +20 -1
  35. package/src/core/types.ts +2 -0
  36. package/src/harness/hunt/hunt-options.ts +17 -0
  37. package/src/harness/hunt/hunt-session.ts +7 -20
  38. package/src/index.ts +31 -0
  39. package/src/server/client/App.tsx +49 -11
  40. package/src/server/client/DeleteRunModal.tsx +163 -0
  41. package/src/server/client/ResumePanel.tsx +8 -8
  42. package/src/server/client/Sidebar.tsx +56 -1
  43. package/src/server/client/icons.tsx +10 -0
  44. package/src/server/client/utils/types.ts +26 -0
  45. package/src/server/index.ts +97 -4
  46. package/src/server/pr-inbox.ts +15 -4
  47. package/src/server/run-repository.ts +103 -2
  48. package/src/skill/README.md +11 -3
  49. package/src/skill/SKILL.md +44 -10
  50. package/src/skill/hunt.md +33 -7
  51. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  52. package/src/tests/cli.test.ts +2 -1
  53. package/src/tests/server.test.ts +38 -0
  54. package/src/tests/worktree.test.ts +369 -0
@@ -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" | "resetHeartbeat">,
36
+ Exclude<keyof ReviewPatch, "resetStartedAt" | "resetHeartbeat" | "resetWorkspace">,
37
37
  string
38
38
  > = {
39
39
  branch: "branch",
@@ -43,6 +43,8 @@ const REVIEW_PATCH_COLUMNS: Record<
43
43
  headSha: "head_sha",
44
44
  reviewedSha: "reviewed_sha",
45
45
  reviewedAt: "reviewed_at",
46
+ worktreePath: "worktree_path",
47
+ prNumber: "pr_number",
46
48
  status: "status",
47
49
  startedAt: "started_at",
48
50
  heartbeatAt: "heartbeat_at",
@@ -70,13 +72,24 @@ export class SQLiteStorageProvider implements StorageProvider {
70
72
  const existing = this.findReview(input.repo.key, input.branch);
71
73
  if (existing) {
72
74
  // O repo pode ter sido movido; a identidade continua a mesma.
73
- if (
75
+ const moved =
74
76
  existing.repoRoot !== input.repo.root ||
75
- existing.repoRemote !== input.repo.remote
76
- ) {
77
+ existing.repoRemote !== input.repo.remote;
78
+ const workspace =
79
+ (input.worktreePath !== undefined &&
80
+ existing.worktreePath !== input.worktreePath) ||
81
+ (input.prNumber !== undefined && existing.prNumber !== input.prNumber);
82
+ if (moved || workspace) {
77
83
  this.db.run(
78
- "UPDATE reviews SET repo_root = ?, repo_remote = ? WHERE id = ?",
79
- [input.repo.root, input.repo.remote ?? null, existing.id],
84
+ `UPDATE reviews SET repo_root = ?, repo_remote = ?, worktree_path = ?,
85
+ pr_number = ? WHERE id = ?`,
86
+ [
87
+ input.repo.root,
88
+ input.repo.remote ?? null,
89
+ input.worktreePath ?? existing.worktreePath ?? null,
90
+ input.prNumber ?? existing.prNumber ?? null,
91
+ existing.id,
92
+ ],
80
93
  );
81
94
  }
82
95
  return this.requireReview(existing.id);
@@ -92,6 +105,8 @@ export class SQLiteStorageProvider implements StorageProvider {
92
105
  slug: input.slug,
93
106
  base: input.base,
94
107
  head: input.head,
108
+ worktreePath: input.worktreePath,
109
+ prNumber: input.prNumber,
95
110
  status: null,
96
111
  createdAt: now,
97
112
  updatedAt: now,
@@ -99,8 +114,8 @@ export class SQLiteStorageProvider implements StorageProvider {
99
114
  this.db.run(
100
115
  `INSERT INTO reviews
101
116
  (id, repo_key, repo_root, repo_remote, branch, slug, base, head,
102
- created_at, updated_at)
103
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
117
+ worktree_path, pr_number, created_at, updated_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
104
119
  [
105
120
  record.id,
106
121
  record.repoKey,
@@ -110,6 +125,8 @@ export class SQLiteStorageProvider implements StorageProvider {
110
125
  record.slug,
111
126
  record.base ?? null,
112
127
  record.head ?? null,
128
+ record.worktreePath ?? null,
129
+ record.prNumber ?? null,
113
130
  record.createdAt,
114
131
  record.updatedAt,
115
132
  ],
@@ -163,6 +180,9 @@ export class SQLiteStorageProvider implements StorageProvider {
163
180
  if (patch.resetHeartbeat) {
164
181
  sets.push("heartbeat_at = NULL");
165
182
  }
183
+ if (patch.resetWorkspace) {
184
+ sets.push("worktree_path = NULL", "pr_number = NULL");
185
+ }
166
186
  if (patch.updatedAt === undefined) {
167
187
  sets.push("updated_at = ?");
168
188
  params.push(now);
@@ -34,6 +34,10 @@ export type ReviewRecord = {
34
34
  headSha?: string;
35
35
  reviewedSha?: string;
36
36
  reviewedAt?: string;
37
+ /** Worktree onde a revisão roda (ausente = clone principal). */
38
+ worktreePath?: string;
39
+ /** Número da PR revisada (quando iniciada via `--pr`). */
40
+ prNumber?: number;
37
41
  status: ReviewStatus | null;
38
42
  startedAt?: string;
39
43
  /**
@@ -56,6 +60,8 @@ export type ReviewPatch = Partial<
56
60
  | "headSha"
57
61
  | "reviewedSha"
58
62
  | "reviewedAt"
63
+ | "worktreePath"
64
+ | "prNumber"
59
65
  | "status"
60
66
  | "startedAt"
61
67
  | "heartbeatAt"
@@ -66,6 +72,8 @@ export type ReviewPatch = Partial<
66
72
  resetStartedAt?: boolean;
67
73
  /** Limpa o heartbeat (execução terminou). */
68
74
  resetHeartbeat?: boolean;
75
+ /** Limpa `worktreePath`/`prNumber` (review voltou ao clone principal). */
76
+ resetWorkspace?: boolean;
69
77
  };
70
78
 
71
79
  /** Entrada de `getOrCreateReview`. */
@@ -75,6 +83,10 @@ export type CreateReviewInput = {
75
83
  slug: string;
76
84
  base?: string;
77
85
  head?: string;
86
+ /** Worktree onde a revisão roda (ausente = clone principal). */
87
+ worktreePath?: string;
88
+ /** Número da PR revisada (quando iniciada via `--pr`). */
89
+ prNumber?: number;
78
90
  now?: string;
79
91
  };
80
92
 
@@ -14,6 +14,7 @@ import {
14
14
  CONTEXT_FILE,
15
15
  CONTEXT_SUMMARY_FILE,
16
16
  displayPath,
17
+ PROJECT_DIR,
17
18
  } from "../paths";
18
19
  import type { ContextPaths } from "../pr-context";
19
20
  import {
@@ -35,12 +36,26 @@ export type FindingsIngest = {
35
36
  warnings: string[];
36
37
  };
37
38
 
39
+ /**
40
+ * Raiz do staging da review.
41
+ *
42
+ * Com worktree, tudo (briefs/findings/contexto) fica dentro dele — assim os
43
+ * agentes leem e escrevem dentro do workspace aberto no editor, sem prompts de
44
+ * permissão. Sem worktree, mantém o diretório global do houndsman.
45
+ */
46
+ export function reviewWorkBase(review: ReviewRecord): string {
47
+ const worktree = review.worktreePath;
48
+ return worktree && existsSync(worktree)
49
+ ? join(worktree, PROJECT_DIR, "work")
50
+ : workRoot();
51
+ }
52
+
38
53
  /** Staging de uma review (briefs/findings/contexto). */
39
54
  export class ReviewWorkArea {
40
55
  readonly review: ReviewRecord;
41
56
  readonly root: string;
42
57
 
43
- constructor(review: ReviewRecord, base: string = workRoot()) {
58
+ constructor(review: ReviewRecord, base: string = reviewWorkBase(review)) {
44
59
  this.review = review;
45
60
  this.root = join(base, review.id);
46
61
  }
@@ -86,6 +101,10 @@ export class ReviewWorkArea {
86
101
  /** Garante os diretórios; `clean` recria a área do zero. */
87
102
  ensure(clean = false): void {
88
103
  if (clean) this.clean();
104
+ mkdirSync(this.root, { recursive: true });
105
+ // Staging nunca deve sujar `git status` (especialmente dentro do worktree).
106
+ const ignore = join(this.root, ".gitignore");
107
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
89
108
  mkdirSync(this.briefsDir, { recursive: true });
90
109
  mkdirSync(this.findingsDir, { recursive: true });
91
110
  }
package/src/core/types.ts CHANGED
@@ -135,6 +135,8 @@ export type WatchdogRenderArgs = {
135
135
  branch: string;
136
136
  outputPath: string;
137
137
  outputFormatPath: string;
138
+ /** Worktree onde a review roda (ausente = clone principal). */
139
+ worktree?: string;
138
140
  docs: readonly string[];
139
141
  context?: WatchdogRenderContext | null;
140
142
  candidates?: readonly WatchdogRenderCandidate[];
@@ -24,6 +24,12 @@ export type HuntOptions = {
24
24
  concurrency?: number;
25
25
  provider?: AiProviderKind;
26
26
  model?: string;
27
+ /** Número da PR (`--pr`) — o comando resolve/cria o worktree. */
28
+ pr?: number;
29
+ /** Worktree informado via flag (relativo ao cwd ou absoluto). */
30
+ worktree?: string;
31
+ /** Worktree resolvido (absoluto) para persistir na review. */
32
+ worktreePath?: string;
27
33
  };
28
34
 
29
35
  /** Defaults do hunt. */
@@ -54,6 +60,17 @@ export function parseHuntArgs(args: string[]): HuntOptions {
54
60
  const model = parser.value("--model");
55
61
  const maxCalls = parser.value("--max-calls");
56
62
  const concurrency = parser.value("--concurrency");
63
+ const pr = parser.value("--pr");
64
+ const worktree = parser.value("--worktree");
65
+ if (pr) {
66
+ const number = Number.parseInt(pr, 10);
67
+ if (Number.isFinite(number) && number > 0) options.pr = number;
68
+ else throw new Error(`--pr inválido: '${pr}'.`);
69
+ }
70
+ if (worktree) options.worktree = worktree;
71
+ if (options.pr !== undefined && options.worktree !== undefined) {
72
+ throw new Error("Use --pr ou --worktree, não os dois.");
73
+ }
57
74
  if (base) options.base = base;
58
75
  if (head) options.head = head;
59
76
  if (branch) options.branch = branch;
@@ -14,11 +14,9 @@ import {
14
14
  type ModelProvider,
15
15
  } from "../../core/ai";
16
16
  import type { AstAnalysis } from "../../core/ast/types";
17
- import { collectDiff } from "../../core/diff-source";
18
17
  import type { HoundsmanEngine } from "../../core/engine/houndsman-engine";
19
18
  import type { RunClassification } from "../../core/engine/review-scope";
20
19
  import { parseEntries } from "../../core/file-classifier";
21
- import { runGit, tryGit } from "../../core/git-runner";
22
20
  import type { LoggerLike } from "../../core/logging";
23
21
  import type { PrepareResult } from "../../core/orchestrator";
24
22
  import { defaultOptions, type ReviewOptions } from "../../core/orchestrator";
@@ -67,6 +65,8 @@ export class HuntSession {
67
65
  clean: options.clean,
68
66
  noAi: true,
69
67
  finalize: false,
68
+ pr: options.pr,
69
+ worktreePath: options.worktreePath,
70
70
  };
71
71
 
72
72
  const session = engine.session(reviewOptions);
@@ -137,7 +137,7 @@ export class HuntSession {
137
137
  reporter,
138
138
  config: this.deps.config,
139
139
  ast: engine.ast,
140
- readFile: defaultFileReader(fileRoot()),
140
+ readFile: defaultFileReader(engine.root),
141
141
  });
142
142
  const runner = new AgentRunner(factory);
143
143
  const scheduler = new AgentScheduler(
@@ -265,11 +265,11 @@ export class HuntSession {
265
265
  work: import("../../core/storage/work-area").ReviewWorkArea,
266
266
  ) {
267
267
  const { engine, options } = this.deps;
268
- const diff = collectDiff(options.base, options.head);
268
+ const diff = engine.git.collectDiff(options.base, options.head);
269
269
  const entries = parseEntries(
270
- runGit(["diff", `${options.base}...${options.head}`, "--name-status"]),
270
+ engine.git.diffNameStatus(options.base, options.head),
271
271
  );
272
- const baseRef = mergeBase(options.base, options.head) ?? options.base;
272
+ const baseRef = engine.git.mergeBase(options.base, options.head) ?? options.base;
273
273
  const ast: Map<string, AstAnalysis> = options.noAst
274
274
  ? new Map()
275
275
  : await engine.ast.analyze(entries, diff, baseRef);
@@ -283,7 +283,7 @@ export class HuntSession {
283
283
  context,
284
284
  ast,
285
285
  impact,
286
- repoRoot: process.cwd(),
286
+ repoRoot: engine.root,
287
287
  diff,
288
288
  branch: review.branch,
289
289
  watchdog: options.watchdog,
@@ -357,16 +357,3 @@ function readContextSummary(
357
357
  return null;
358
358
  }
359
359
  }
360
-
361
- /** Merge-base best effort. */
362
- function mergeBase(base: string, head: string): string | null {
363
- const result = tryGit(["merge-base", base, head]);
364
- if (!result.ok) return null;
365
- const sha = result.stdout.trim();
366
- return sha.length > 0 ? sha : null;
367
- }
368
-
369
- /** Raiz do repositório para leitura das tools. */
370
- function fileRoot(): string {
371
- return process.cwd();
372
- }
package/src/index.ts CHANGED
@@ -89,6 +89,14 @@ export { buildCarryover, mergeCarryover } from "./core/engine/carryover";
89
89
  export { HoundsmanEngine } from "./core/engine/houndsman-engine";
90
90
  export { ReviewScopeResolver } from "./core/engine/review-scope";
91
91
  export { ReviewSession } from "./core/engine/review-session";
92
+ export type {
93
+ ResolvedWorkspace,
94
+ WorkspaceRequest,
95
+ } from "./core/engine/review-workspace";
96
+ export {
97
+ mainRootOf,
98
+ resolveReviewWorkspace,
99
+ } from "./core/engine/review-workspace";
92
100
  export type { HoundsmanEnv } from "./core/env";
93
101
  export { ENV_NAMES, env, readEnv } from "./core/env";
94
102
  export {
@@ -171,8 +179,26 @@ export { FixBatchBuilder } from "./core/services/fix-batch-builder";
171
179
  export type { GhResult, GhRunner } from "./core/services/gh-client";
172
180
  export { GhClient } from "./core/services/gh-client";
173
181
  export { GitRepository } from "./core/services/git-repository";
182
+ export type {
183
+ PrRefs,
184
+ PrResolveResult,
185
+ PullRequestInfo,
186
+ } from "./core/services/pr-resolver";
187
+ export { PrResolver } from "./core/services/pr-resolver";
174
188
  export { ProjectFiles } from "./core/services/project-files";
175
189
  export { Verifier } from "./core/services/verifier";
190
+ export type {
191
+ EnsureWorktreeInput,
192
+ EnsureWorktreeResult,
193
+ RemoveWorktreeResult,
194
+ WorktreeEntry,
195
+ WorktreeManagerOptions,
196
+ } from "./core/services/worktree-manager";
197
+ export {
198
+ parseWorktreeList,
199
+ repoSlug,
200
+ WorktreeManager,
201
+ } from "./core/services/worktree-manager";
176
202
  export {
177
203
  createMemoryStorage,
178
204
  createStorage,
@@ -226,4 +252,9 @@ export {
226
252
  } from "./core/watchdog-registry";
227
253
  export type { ServerOptions } from "./server";
228
254
  export { createRequestHandler, HoundsmanServer, startServer } from "./server";
255
+ export type {
256
+ DeleteRunResult,
257
+ RunDetail,
258
+ RunListItem,
259
+ } from "./server/run-repository";
229
260
  export { RunRepository } from "./server/run-repository";
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
8
8
  import { ArtifactsPanel } from "./ArtifactsPanel";
9
9
  import { AutoRefresh } from "./AutoRefresh";
10
10
  import { CommentsPanel } from "./CommentsPanel";
11
+ import { DeleteRunModal } from "./DeleteRunModal";
11
12
  import { Filters, type FiltersState } from "./Filters";
12
13
  import { FindingsList } from "./FindingsList";
13
14
  import { FileText, Layers, ListIcon, MessageSquare, Refresh, Send } from "./icons";
@@ -17,7 +18,13 @@ import { SubmitModal } from "./SubmitModal";
17
18
  import { SummaryBar } from "./SummaryBar";
18
19
  import { api, computeSummary } from "./utils/api";
19
20
  import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./utils/constants";
20
- import type { EditorContext, Finding, FindingsResponse, RunSummary } from "./utils/types";
21
+ import type {
22
+ DeleteRunResponse,
23
+ EditorContext,
24
+ Finding,
25
+ FindingsResponse,
26
+ RunSummary,
27
+ } from "./utils/types";
21
28
 
22
29
  type Tab = "resume" | "findings" | "artifacts" | "comments";
23
30
 
@@ -67,6 +74,7 @@ export function App() {
67
74
  const [refreshing, setRefreshing] = useState(false);
68
75
  const [syncing, setSyncing] = useState(false);
69
76
  const [submitOpen, setSubmitOpen] = useState(false);
77
+ const [deleteOpen, setDeleteOpen] = useState(false);
70
78
  /** Editor detectado pelo servidor — base dos links de código. */
71
79
  const [editor, setEditor] = useState<EditorContext>({ kind: "vscode" });
72
80
  /** Refs espelhando estado volátil — o polling não deve recriar o intervalo. */
@@ -212,8 +220,28 @@ export function App() {
212
220
 
213
221
  const summary = useMemo(() => computeSummary(data?.findings ?? []), [data]);
214
222
 
215
- const branch =
216
- data?.branch ?? runs.find((run) => run.slug === slug)?.branch ?? slug ?? "";
223
+ const selectedRun = runs.find((run) => run.slug === slug) ?? null;
224
+ const branch = data?.branch ?? selectedRun?.branch ?? slug ?? "";
225
+
226
+ /** Após excluir: limpa a seleção e escolhe a próxima run da lista. */
227
+ const afterDelete = useCallback((result: DeleteRunResponse) => {
228
+ setDeleteOpen(false);
229
+ setData(null);
230
+ setSlug(null);
231
+ if (result.warning) {
232
+ setError(
233
+ `Execução excluída, mas o worktree não foi removido: ${result.warning}`,
234
+ );
235
+ } else {
236
+ setError("");
237
+ }
238
+ api<{ runs: RunSummary[] }>("/api/runs")
239
+ .then(({ runs: list }) => {
240
+ setRuns(list);
241
+ setSlug(list[0]?.slug ?? null);
242
+ })
243
+ .catch((err) => setError(String(err)));
244
+ }, []);
217
245
 
218
246
  return (
219
247
  <div class="app">
@@ -290,7 +318,7 @@ export function App() {
290
318
  branch={branch}
291
319
  findings={data.findings}
292
320
  summary={summary}
293
- onOpenArtifacts={() => setTab("artifacts")}
321
+ onRequestDelete={() => setDeleteOpen(true)}
294
322
  />
295
323
  )}
296
324
  {slug && tab === "findings" && data && (
@@ -312,13 +340,23 @@ export function App() {
312
340
  {!slug && !error && <p class="empty">Nenhuma execução para triar.</p>}
313
341
  </div>
314
342
  {slug && (
315
- <SubmitModal
316
- slug={slug}
317
- branch={branch}
318
- open={submitOpen}
319
- onClose={() => setSubmitOpen(false)}
320
- onSubmitted={afterSubmit}
321
- />
343
+ <>
344
+ <SubmitModal
345
+ slug={slug}
346
+ branch={branch}
347
+ open={submitOpen}
348
+ onClose={() => setSubmitOpen(false)}
349
+ onSubmitted={afterSubmit}
350
+ />
351
+ <DeleteRunModal
352
+ slug={slug}
353
+ branch={branch}
354
+ worktreePath={selectedRun?.worktreePath}
355
+ open={deleteOpen}
356
+ onClose={() => setDeleteOpen(false)}
357
+ onDeleted={afterDelete}
358
+ />
359
+ </>
322
360
  )}
323
361
  </main>
324
362
  </div>
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Modal "Excluir execução": confirma a remoção definitiva da run.
3
+ *
4
+ * Apaga a review, findings, histórico de triagem, artefatos e o staging local.
5
+ * Quando a run tem worktree, oferece removê-lo junto (alterações não commitadas
6
+ * nele são descartadas).
7
+ */
8
+
9
+ import { useEffect, useState } from "preact/hooks";
10
+ import { Spinner, Trash } from "./icons";
11
+ import { api } from "./utils/api";
12
+ import type { DeleteRunResponse } from "./utils/types";
13
+
14
+ export function DeleteRunModal({
15
+ slug,
16
+ branch,
17
+ worktreePath,
18
+ open,
19
+ onClose,
20
+ onDeleted,
21
+ }: {
22
+ slug: string;
23
+ branch: string;
24
+ worktreePath?: string;
25
+ open: boolean;
26
+ onClose: () => void;
27
+ onDeleted: (result: DeleteRunResponse) => void;
28
+ }) {
29
+ const [removeWorktree, setRemoveWorktree] = useState(Boolean(worktreePath));
30
+ const [deleting, setDeleting] = useState(false);
31
+ const [error, setError] = useState("");
32
+
33
+ useEffect(() => {
34
+ if (!open) return;
35
+ setRemoveWorktree(Boolean(worktreePath));
36
+ setDeleting(false);
37
+ setError("");
38
+ }, [open, worktreePath]);
39
+
40
+ useEffect(() => {
41
+ if (!open) return;
42
+ const onKey = (event: KeyboardEvent) => {
43
+ if (event.key === "Escape" && !deleting) onClose();
44
+ };
45
+ document.addEventListener("keydown", onKey);
46
+ return () => document.removeEventListener("keydown", onKey);
47
+ }, [open, deleting, onClose]);
48
+
49
+ if (!open) return null;
50
+
51
+ const confirm = async () => {
52
+ setDeleting(true);
53
+ setError("");
54
+ try {
55
+ const suffix = removeWorktree && worktreePath ? "?worktree=1" : "";
56
+ const result = await api<DeleteRunResponse>(`/api/runs/${slug}${suffix}`, {
57
+ method: "DELETE",
58
+ });
59
+ if (!result.deleted) throw new Error("A execução não foi encontrada.");
60
+ onDeleted(result);
61
+ } catch (err) {
62
+ setError(err instanceof Error ? err.message : String(err));
63
+ } finally {
64
+ setDeleting(false);
65
+ }
66
+ };
67
+
68
+ return (
69
+ <div class="modal-layer">
70
+ <button
71
+ type="button"
72
+ class="modal-backdrop"
73
+ aria-label="Fechar"
74
+ title="Fechar"
75
+ onClick={() => {
76
+ if (!deleting) onClose();
77
+ }}
78
+ />
79
+ <div
80
+ class="modal"
81
+ role="dialog"
82
+ aria-modal="true"
83
+ aria-label="Excluir execução"
84
+ >
85
+ <header class="modal-head">
86
+ <div>
87
+ <h2 class="modal-title">Excluir execução</h2>
88
+ <p class="modal-sub">
89
+ Esta ação é permanente e apaga a revisão de{" "}
90
+ <code>{branch}</code>.
91
+ </p>
92
+ </div>
93
+ <button
94
+ type="button"
95
+ class="modal-close"
96
+ title="Fechar"
97
+ disabled={deleting}
98
+ onClick={onClose}
99
+ >
100
+ ×
101
+ </button>
102
+ </header>
103
+
104
+ <div class="modal-body">
105
+ <p class="modal-warn">
106
+ Serão removidos os findings, o histórico de triagem, todos os
107
+ artefatos (briefs, classificação, manifesto, relatório, verify) e
108
+ o staging local desta run. O envio já feito ao GitHub não é
109
+ desfeito.
110
+ </p>
111
+
112
+ {worktreePath && (
113
+ <label class="modal-check">
114
+ <input
115
+ type="checkbox"
116
+ checked={removeWorktree}
117
+ disabled={deleting}
118
+ onChange={(event) =>
119
+ setRemoveWorktree(event.currentTarget.checked)
120
+ }
121
+ />
122
+ <span>
123
+ Excluir também o worktree desta execução. Alterações não
124
+ commitadas nele serão perdidas.
125
+ <br />
126
+ <code>{worktreePath}</code>
127
+ </span>
128
+ </label>
129
+ )}
130
+
131
+ {error && <p class="error">{error}</p>}
132
+ </div>
133
+
134
+ <footer class="modal-foot">
135
+ <span class="submit-label">Run: {branch}</span>
136
+ <div class="submit-actions">
137
+ <button
138
+ type="button"
139
+ class="btn"
140
+ disabled={deleting}
141
+ onClick={onClose}
142
+ >
143
+ Cancelar
144
+ </button>
145
+ <button
146
+ type="button"
147
+ class="btn danger"
148
+ disabled={deleting}
149
+ onClick={() => void confirm()}
150
+ >
151
+ {deleting ? (
152
+ <Spinner size={15} class="icon spin" />
153
+ ) : (
154
+ <Trash size={15} />
155
+ )}
156
+ {deleting ? "Excluindo…" : "Excluir execução"}
157
+ </button>
158
+ </div>
159
+ </footer>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ComponentChildren } from "preact";
12
12
  import { useEffect, useMemo, useState } from "preact/hooks";
13
- import { ExternalLink, Layers } from "./icons";
13
+ import { ExternalLink, Layers, Trash } from "./icons";
14
14
  import { MarkdownBlocks } from "./MarkdownText";
15
15
  import { api } from "./utils/api";
16
16
  import { SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
@@ -79,13 +79,13 @@ export function ResumePanel({
79
79
  branch,
80
80
  findings,
81
81
  summary,
82
- onOpenArtifacts,
82
+ onRequestDelete,
83
83
  }: {
84
84
  slug: string;
85
85
  branch: string;
86
86
  findings: Finding[];
87
87
  summary: Summary;
88
- onOpenArtifacts: () => void;
88
+ onRequestDelete: () => void;
89
89
  }) {
90
90
  const [resume, setResume] = useState("");
91
91
  const [detail, setDetail] = useState<RunDetail | null>(null);
@@ -241,12 +241,12 @@ export function ResumePanel({
241
241
  <span class={`resume-status ${status.tone}`}>{status.label}</span>
242
242
  <button
243
243
  type="button"
244
- class="btn"
245
- title="Ver os artefatos brutos da revisão"
246
- onClick={onOpenArtifacts}
244
+ class="btn danger"
245
+ title="Excluir esta execução, seus findings e artefatos locais"
246
+ onClick={onRequestDelete}
247
247
  >
248
- <Layers size={14} />
249
- Ver artefatos
248
+ <Trash size={14} />
249
+ Excluir
250
250
  </button>
251
251
  </div>
252
252
  </section>