indusagi-coding-agent 0.2.12 → 0.2.14

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 (81) hide show
  1. package/.env.review.example +25 -0
  2. package/README.md +30 -0
  3. package/REVIEW_AGENT.md +137 -0
  4. package/dist/entry.js +1861 -979
  5. package/dist/guardrails.js +212 -36
  6. package/dist/index.js +2829 -1057
  7. package/dist/review-agent.js +4461 -0
  8. package/dist/review-server.js +4633 -0
  9. package/package.json +24 -7
  10. package/src/boot/auth-vault.ts +4 -5
  11. package/src/boot/contract.ts +1 -1
  12. package/src/boot/runners/addon-wiring.ts +111 -3
  13. package/src/boot/runners/benchmark-integrity.ts +107 -0
  14. package/src/boot/runners/repl-runner.ts +2 -1
  15. package/src/boot/runners/session.ts +26 -12
  16. package/src/boot/server-token.ts +20 -11
  17. package/src/boot/stages.ts +3 -6
  18. package/src/briefing/context-docs.ts +144 -12
  19. package/src/channels/oneshot.ts +6 -0
  20. package/src/conductor/catalog/catalog.ts +3 -1
  21. package/src/conductor/conductor.ts +77 -17
  22. package/src/conductor/contract.ts +9 -7
  23. package/src/conductor/index.ts +1 -1
  24. package/src/conductor/transcript-store/store.ts +4 -8
  25. package/src/console/auth-status.ts +9 -4
  26. package/src/console/components/AgentsView.ts +2 -2
  27. package/src/console/components/BackgroundAgents.ts +15 -2
  28. package/src/console/components/Banner.ts +20 -9
  29. package/src/console/components/Composer.ts +57 -82
  30. package/src/console/components/ScrollViewport.ts +46 -0
  31. package/src/console/components/TerminalConsole.ts +165 -162
  32. package/src/console/components/WorkingIndicator.ts +6 -5
  33. package/src/console/components/banner-sweep.ts +5 -1
  34. package/src/console/components/welcome.ts +3 -3
  35. package/src/console/contract.ts +10 -5
  36. package/src/console/index.ts +7 -7
  37. package/src/console/input/complete.ts +2 -2
  38. package/src/console/input/dir-reader.ts +3 -1
  39. package/src/console/input/graphemes.ts +22 -0
  40. package/src/console/input/keymap.ts +6 -4
  41. package/src/console/mount.ts +45 -3
  42. package/src/console/overlays/auth.ts +28 -8
  43. package/src/console/overlays/boards.ts +13 -40
  44. package/src/console/overlays/host.ts +10 -0
  45. package/src/console/overlays/pickers.ts +35 -30
  46. package/src/console/reducer.ts +12 -5
  47. package/src/console/slash/builtins.ts +4 -3
  48. package/src/console/slash/commands/integrations.ts +13 -14
  49. package/src/console/slash/commands/transcript.ts +3 -8
  50. package/src/console/slash/commands/workbench.ts +4 -4
  51. package/src/console/test-support.ts +48 -0
  52. package/src/index.ts +1 -0
  53. package/src/integrations/sarvam/config.ts +15 -13
  54. package/src/kit/external-editor.ts +17 -1
  55. package/src/kit/run-shell.ts +31 -0
  56. package/src/launch/catalog.ts +4 -2
  57. package/src/launch/contract.ts +1 -0
  58. package/src/launch/invocation/flags.ts +13 -1
  59. package/src/launch/invocation/read.ts +4 -3
  60. package/src/launch/login.ts +23 -10
  61. package/src/launch/oauth.ts +3 -0
  62. package/src/models.ts +166 -0
  63. package/src/review-agent/config.ts +75 -0
  64. package/src/review-agent/contract.ts +59 -0
  65. package/src/review-agent/github.ts +242 -0
  66. package/src/review-agent/index.ts +8 -0
  67. package/src/review-agent/prompt.ts +132 -0
  68. package/src/review-agent/reviewer.ts +118 -0
  69. package/src/review-agent/server.ts +221 -0
  70. package/src/review-agent/service.ts +165 -0
  71. package/src/review-agent/supabase.ts +209 -0
  72. package/src/review-agent/webhook.ts +76 -0
  73. package/src/security/private-state.ts +122 -0
  74. package/src/window-budget/summarize/condense.ts +4 -1
  75. package/src/workspace/brand.ts +1 -1
  76. package/src/workspace/locator.ts +2 -2
  77. package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
  78. package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
  79. package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
  80. package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
  81. package/supabase/migrations/20260827042500_static_token_webhooks.sql +75 -0
@@ -0,0 +1,209 @@
1
+ import type { ReviewOutput, ReviewRequest } from "./contract.js";
2
+
3
+ export interface ReviewJob {
4
+ id: string;
5
+ installationId: number;
6
+ repository: string;
7
+ owner: string;
8
+ repo: string;
9
+ pullNumber: number;
10
+ headSha: string;
11
+ title: string;
12
+ attempts: number;
13
+ maxAttempts: number;
14
+ workerId: string;
15
+ leaseToken: string;
16
+ checkRunId?: number;
17
+ }
18
+
19
+ export interface CheckRunReconciliation {
20
+ id: string;
21
+ installationId: number;
22
+ owner: string;
23
+ repo: string;
24
+ checkRunId: number;
25
+ review: ReviewOutput;
26
+ }
27
+
28
+ function toJob(row: Record<string, any>): ReviewJob {
29
+ return {
30
+ id: String(row.id),
31
+ installationId: Number(row.installation_id),
32
+ repository: String(row.repository),
33
+ owner: String(row.owner),
34
+ repo: String(row.repo),
35
+ pullNumber: Number(row.pr_number),
36
+ headSha: String(row.head_sha),
37
+ title: String(row.title),
38
+ attempts: Number(row.attempts ?? 0),
39
+ maxAttempts: Number(row.max_attempts ?? 3),
40
+ workerId: String(row.worker_id ?? ""),
41
+ leaseToken: String(row.lease_token ?? ""),
42
+ ...(row.check_run_id != null ? { checkRunId: Number(row.check_run_id) } : {}),
43
+ };
44
+ }
45
+
46
+ export class SupabaseReviewStore {
47
+ private readonly baseUrl: string;
48
+
49
+ constructor(
50
+ url: string,
51
+ private readonly serviceKey: string,
52
+ private readonly fetcher: typeof fetch = fetch,
53
+ private readonly requestTimeoutMs = 30_000,
54
+ ) {
55
+ this.baseUrl = url.replace(/\/$/, "");
56
+ }
57
+
58
+ private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
59
+ const response = await this.fetcher(`${this.baseUrl}${path}`, {
60
+ ...init,
61
+ signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs),
62
+ headers: {
63
+ apikey: this.serviceKey,
64
+ Authorization: `Bearer ${this.serviceKey}`,
65
+ "Content-Type": "application/json",
66
+ ...(init.headers as Record<string, string> | undefined),
67
+ },
68
+ });
69
+ if (!response.ok) {
70
+ throw new Error(`Supabase ${init.method ?? "GET"} ${path} failed (${response.status}): ${(await response.text()).slice(0, 500)}`);
71
+ }
72
+ if (response.status === 204) return undefined as T;
73
+ return await response.json() as T;
74
+ }
75
+
76
+ async recordDelivery(deliveryId: string, eventName: string): Promise<boolean> {
77
+ const rows = await this.request<Array<Record<string, unknown>>>(
78
+ "/rest/v1/indusagi_review_deliveries?on_conflict=delivery_id",
79
+ {
80
+ method: "POST",
81
+ headers: { Prefer: "resolution=ignore-duplicates,return=representation" },
82
+ body: JSON.stringify({ delivery_id: deliveryId, event_name: eventName }),
83
+ },
84
+ );
85
+ return rows.length > 0;
86
+ }
87
+
88
+ async enqueueWebhook(request: ReviewRequest, eventName: string): Promise<{ jobId?: string; duplicate: boolean }> {
89
+ const rows = await this.request<Array<{ job_id?: unknown; duplicate?: unknown }>>(
90
+ "/rest/v1/rpc/enqueue_indusagi_review_job",
91
+ {
92
+ method: "POST",
93
+ body: JSON.stringify({
94
+ p_delivery_id: request.deliveryId,
95
+ p_event_name: eventName,
96
+ p_installation_id: request.installationId,
97
+ p_repository: request.repository,
98
+ p_owner: request.owner,
99
+ p_repo: request.repo,
100
+ p_pr_number: request.pullNumber,
101
+ p_head_sha: request.headSha,
102
+ p_title: request.title,
103
+ }),
104
+ },
105
+ );
106
+ const row = rows[0];
107
+ if (!row || typeof row.duplicate !== "boolean") throw new Error("Supabase returned no webhook enqueue result");
108
+ if (!row.duplicate && (typeof row.job_id !== "string" || !row.job_id)) {
109
+ throw new Error("Supabase returned no queued review job id");
110
+ }
111
+ return {
112
+ ...(typeof row.job_id === "string" && row.job_id ? { jobId: row.job_id } : {}),
113
+ duplicate: row.duplicate,
114
+ };
115
+ }
116
+
117
+ async claim(workerId: string): Promise<ReviewJob | null> {
118
+ const rows = await this.request<Array<Record<string, any>>>("/rest/v1/rpc/claim_indusagi_review_job", {
119
+ method: "POST",
120
+ body: JSON.stringify({ p_worker_id: workerId }),
121
+ });
122
+ return rows[0] ? toJob(rows[0]) : null;
123
+ }
124
+
125
+ async setCheckRun(job: ReviewJob, checkRunId: number): Promise<void> {
126
+ await this.ownedPatch(job, { check_run_id: checkRunId });
127
+ }
128
+
129
+ async complete(job: ReviewJob, review: ReviewOutput, githubReviewId?: number, commentId?: number): Promise<void> {
130
+ await this.ownedPatch(job, {
131
+ status: "completed",
132
+ review,
133
+ github_review_id: githubReviewId ?? null,
134
+ github_comment_id: commentId ?? null,
135
+ completed_at: new Date().toISOString(),
136
+ worker_id: null,
137
+ claimed_at: null,
138
+ lease_expires_at: null,
139
+ error: null,
140
+ });
141
+ }
142
+
143
+ async markCheckRunComplete(jobId: string): Promise<void> {
144
+ await this.patch(jobId, { check_completed_at: new Date().toISOString() });
145
+ }
146
+
147
+ async nextCheckRunReconciliation(): Promise<CheckRunReconciliation | null> {
148
+ const path = "/rest/v1/indusagi_review_jobs?status=eq.completed&check_run_id=not.is.null&check_completed_at=is.null&review=not.is.null&select=id,installation_id,owner,repo,check_run_id,review&order=completed_at.asc&limit=1";
149
+ const rows = await this.request<Array<Record<string, any>>>(path);
150
+ const row = rows[0];
151
+ if (!row) return null;
152
+ return {
153
+ id: String(row.id),
154
+ installationId: Number(row.installation_id),
155
+ owner: String(row.owner),
156
+ repo: String(row.repo),
157
+ checkRunId: Number(row.check_run_id),
158
+ review: row.review as ReviewOutput,
159
+ };
160
+ }
161
+
162
+ async fail(job: ReviewJob, message: string, retryable = true): Promise<void> {
163
+ const retry = retryable && job.attempts < job.maxAttempts;
164
+ const delaySeconds = Math.min(300, Math.max(5, 5 * 2 ** Math.max(0, job.attempts - 1)));
165
+ await this.ownedPatch(job, {
166
+ status: retry ? "pending" : "failed",
167
+ error: message.slice(0, 4_000),
168
+ worker_id: null,
169
+ claimed_at: null,
170
+ lease_expires_at: null,
171
+ ...(retry ? { available_at: new Date(Date.now() + delaySeconds * 1_000).toISOString() } : { completed_at: new Date().toISOString() }),
172
+ });
173
+ }
174
+
175
+ async recoverStaleJobs(staleMinutes = 15): Promise<void> {
176
+ await this.request("/rest/v1/rpc/recover_stale_indusagi_review_jobs", {
177
+ method: "POST",
178
+ body: JSON.stringify({ p_stale_minutes: staleMinutes }),
179
+ });
180
+ }
181
+
182
+ async renewLease(job: ReviewJob, leaseSeconds = 300): Promise<boolean> {
183
+ const rows = await this.ownedPatch(job, {
184
+ lease_expires_at: new Date(Date.now() + leaseSeconds * 1_000).toISOString(),
185
+ }, false);
186
+ return rows.length > 0;
187
+ }
188
+
189
+ private async ownedPatch(job: ReviewJob, values: Record<string, unknown>, required = true): Promise<Array<Record<string, unknown>>> {
190
+ if (!job.workerId || !job.leaseToken) throw new Error("review job has no lease ownership");
191
+ const leaseFloor = encodeURIComponent(new Date().toISOString());
192
+ const filters = `id=eq.${encodeURIComponent(job.id)}&status=eq.running&worker_id=eq.${encodeURIComponent(job.workerId)}&lease_token=eq.${encodeURIComponent(job.leaseToken)}&lease_expires_at=gt.${leaseFloor}`;
193
+ const rows = await this.request<Array<Record<string, unknown>>>(`/rest/v1/indusagi_review_jobs?${filters}`, {
194
+ method: "PATCH",
195
+ headers: { Prefer: "return=representation" },
196
+ body: JSON.stringify({ ...values, updated_at: new Date().toISOString() }),
197
+ });
198
+ if (required && rows.length === 0) throw new Error("review job lease ownership was lost");
199
+ return rows;
200
+ }
201
+
202
+ private async patch(jobId: string, values: Record<string, unknown>): Promise<void> {
203
+ await this.request(`/rest/v1/indusagi_review_jobs?id=eq.${encodeURIComponent(jobId)}`, {
204
+ method: "PATCH",
205
+ headers: { Prefer: "return=minimal" },
206
+ body: JSON.stringify({ ...values, updated_at: new Date().toISOString() }),
207
+ });
208
+ }
209
+ }
@@ -0,0 +1,76 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import type { ReviewRequest } from "./contract.js";
3
+
4
+ const REVIEW_ACTIONS = new Set(["opened", "reopened", "synchronize", "ready_for_review"]);
5
+
6
+ export function verifyGithubSignature(
7
+ payload: string,
8
+ signature: string | null | undefined,
9
+ secret: string,
10
+ ): boolean {
11
+ if (!secret || !signature?.startsWith("sha256=")) return false;
12
+ const expected = `sha256=${createHmac("sha256", secret).update(payload).digest("hex")}`;
13
+ const actualBuffer = Buffer.from(signature);
14
+ const expectedBuffer = Buffer.from(expected);
15
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
16
+ }
17
+
18
+ function object(value: unknown): Record<string, unknown> | undefined {
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as Record<string, unknown>
21
+ : undefined;
22
+ }
23
+
24
+ export function parsePullRequestWebhook(
25
+ payload: string,
26
+ deliveryId: string,
27
+ options: { allowMissingInstallation?: boolean } = {},
28
+ ): ReviewRequest | null {
29
+ let decoded: unknown;
30
+ try {
31
+ decoded = JSON.parse(payload);
32
+ } catch {
33
+ throw new Error("GitHub webhook body is not valid JSON");
34
+ }
35
+ const body = object(decoded);
36
+ const action = typeof body?.action === "string" ? body.action : "";
37
+ if (!REVIEW_ACTIONS.has(action)) return null;
38
+
39
+ const installation = object(body?.installation);
40
+ const repository = object(body?.repository);
41
+ const pull = object(body?.pull_request);
42
+ const head = object(pull?.head);
43
+ const fullName = typeof repository?.full_name === "string" ? repository.full_name : "";
44
+ const separator = fullName.indexOf("/");
45
+ const installationId = installation?.id == null && options.allowMissingInstallation
46
+ ? 0
47
+ : Number(installation?.id);
48
+ const pullNumber = Number(body?.number ?? pull?.number);
49
+ const headSha = typeof head?.sha === "string" ? head.sha : "";
50
+ const title = typeof pull?.title === "string" ? pull.title : "";
51
+ const draft = pull?.draft === true;
52
+
53
+ if (draft && action !== "ready_for_review") return null;
54
+ if (
55
+ !deliveryId || separator <= 0 || separator === fullName.length - 1 ||
56
+ !Number.isInteger(installationId) || installationId < 0 ||
57
+ (!options.allowMissingInstallation && installationId === 0) ||
58
+ !Number.isInteger(pullNumber) || pullNumber <= 0 ||
59
+ !headSha || !title
60
+ ) {
61
+ throw new Error("GitHub pull_request webhook is missing required fields");
62
+ }
63
+
64
+ return {
65
+ deliveryId,
66
+ action,
67
+ installationId,
68
+ repository: fullName,
69
+ owner: fullName.slice(0, separator),
70
+ repo: fullName.slice(separator + 1),
71
+ pullNumber,
72
+ headSha,
73
+ title,
74
+ draft,
75
+ };
76
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ constants,
5
+ fchmodSync,
6
+ fstatSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { dirname } from "node:path";
17
+
18
+ const PRIVATE_DIRECTORY_MODE = 0o700;
19
+ const PRIVATE_FILE_MODE = 0o600;
20
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
21
+
22
+ function isPosix(): boolean {
23
+ return process.platform !== "win32";
24
+ }
25
+
26
+ function assertOwned(uid: number, path: string): void {
27
+ if (!isPosix() || process.getuid === undefined) return;
28
+ if (uid !== process.getuid()) {
29
+ throw new Error(`Refusing sensitive path not owned by the current user: ${path}`);
30
+ }
31
+ }
32
+
33
+ export function ensurePrivateDirectorySync(path: string): void {
34
+ try {
35
+ const current = lstatSync(path);
36
+ if (current.isSymbolicLink() || !current.isDirectory()) {
37
+ throw new Error(`Refusing non-directory sensitive state path: ${path}`);
38
+ }
39
+ assertOwned(current.uid, path);
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
42
+ mkdirSync(path, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
43
+ }
44
+ if (isPosix()) chmodSync(path, PRIVATE_DIRECTORY_MODE);
45
+ }
46
+
47
+ function validateExistingFile(path: string): void {
48
+ try {
49
+ const current = lstatSync(path);
50
+ if (current.isSymbolicLink() || !current.isFile()) {
51
+ throw new Error(`Refusing non-regular sensitive file: ${path}`);
52
+ }
53
+ assertOwned(current.uid, path);
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export function readPrivateFileSync(path: string): string | null {
61
+ ensurePrivateDirectorySync(dirname(path));
62
+ try {
63
+ validateExistingFile(path);
64
+ const fd = openSync(path, constants.O_RDONLY | NO_FOLLOW);
65
+ try {
66
+ const current = fstatSync(fd);
67
+ if (!current.isFile()) throw new Error(`Refusing non-regular sensitive file: ${path}`);
68
+ assertOwned(current.uid, path);
69
+ if (isPosix()) fchmodSync(fd, PRIVATE_FILE_MODE);
70
+ return readFileSync(fd, "utf8");
71
+ } finally {
72
+ closeSync(fd);
73
+ }
74
+ } catch (error) {
75
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ export function atomicWritePrivateFileSync(path: string, content: string): void {
81
+ const parent = dirname(path);
82
+ ensurePrivateDirectorySync(parent);
83
+ validateExistingFile(path);
84
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
85
+ let fd: number | undefined;
86
+ try {
87
+ fd = openSync(
88
+ temporary,
89
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW,
90
+ PRIVATE_FILE_MODE,
91
+ );
92
+ writeFileSync(fd, content, "utf8");
93
+ if (isPosix()) fchmodSync(fd, PRIVATE_FILE_MODE);
94
+ closeSync(fd);
95
+ fd = undefined;
96
+ renameSync(temporary, path);
97
+ if (isPosix()) chmodSync(path, PRIVATE_FILE_MODE);
98
+ } catch (error) {
99
+ if (fd !== undefined) closeSync(fd);
100
+ try { rmSync(temporary, { force: true }); } catch { /* best effort cleanup */ }
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ export function appendPrivateFileSync(path: string, content: string): void {
106
+ ensurePrivateDirectorySync(dirname(path));
107
+ validateExistingFile(path);
108
+ const fd = openSync(
109
+ path,
110
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | NO_FOLLOW,
111
+ PRIVATE_FILE_MODE,
112
+ );
113
+ try {
114
+ const current = fstatSync(fd);
115
+ if (!current.isFile()) throw new Error(`Refusing non-regular sensitive file: ${path}`);
116
+ assertOwned(current.uid, path);
117
+ if (isPosix()) fchmodSync(fd, PRIVATE_FILE_MODE);
118
+ writeFileSync(fd, content, "utf8");
119
+ } finally {
120
+ closeSync(fd);
121
+ }
122
+ }
@@ -11,6 +11,8 @@ import { completeSimple } from "indusagi/ai";
11
11
  import { CONDENSER_BRIEF, buildSummaryPrompt, flattenTranscript } from "./prompt.js";
12
12
 
13
13
  export interface SummarizeDeps {
14
+ /** Literal user guidance; never interpreted as shell input. */
15
+ readonly instructions?: string;
14
16
  /** Injectable completer (default: framework `completeSimple`). */
15
17
  readonly complete?: CompleteFn;
16
18
  /** The summarization model. When omitted, a deterministic local digest is used. */
@@ -71,7 +73,8 @@ export async function summarize(
71
73
  const digest = localFallbackDigest(messages, deps.priorDigest);
72
74
  return { message: toSummaryMessage(digest, scope), coveredCount };
73
75
  }
74
- const prompt = buildSummaryPrompt(messages, scope, deps.priorDigest);
76
+ const prompt = buildSummaryPrompt(messages, scope, deps.priorDigest) +
77
+ (deps.instructions ? `\n\nUser compaction guidance:\n${deps.instructions}` : "");
75
78
  const context = {
76
79
  systemPrompt: CONDENSER_BRIEF,
77
80
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
@@ -26,4 +26,4 @@ export const BRAND: Brand = Object.freeze({
26
26
  /**
27
27
  * The product version — printed by `--version` and shown on the startup banner.
28
28
  */
29
- export const VERSION = "0.2.12";
29
+ export const VERSION = "0.2.14";
@@ -2,11 +2,11 @@
2
2
  * Workspace Locator — turns the active {@link Brand} plus the environment into a
3
3
  * single, fully-resolved {@link Workspace} record of absolute paths.
4
4
  */
5
- import { mkdirSync } from "node:fs";
6
5
  import { homedir } from "node:os";
7
6
  import { isAbsolute, join, resolve } from "node:path";
8
7
  import type { Brand, Workspace } from "../boot/contract.js";
9
8
  import { BRAND } from "./brand.js";
9
+ import { ensurePrivateDirectorySync } from "../security/private-state.js";
10
10
 
11
11
  export interface WorkspaceOverrides {
12
12
  /** User home directory; defaults to {@link homedir}. */
@@ -97,7 +97,7 @@ export function createWorkspace(overrides: WorkspaceOverrides = {}): Workspace {
97
97
  */
98
98
  export function ensureDirs(workspace: Workspace): Workspace {
99
99
  for (const key of DIRECTORY_KEYS) {
100
- mkdirSync(workspace[key], { recursive: true });
100
+ ensurePrivateDirectorySync(workspace[key]);
101
101
  }
102
102
  return workspace;
103
103
  }
@@ -0,0 +1,109 @@
1
+ create extension if not exists pgcrypto;
2
+
3
+ create table if not exists public.indusagi_review_deliveries (
4
+ delivery_id text primary key,
5
+ event_name text not null,
6
+ received_at timestamptz not null default now()
7
+ );
8
+
9
+ create table if not exists public.indusagi_review_jobs (
10
+ id uuid primary key default gen_random_uuid(),
11
+ delivery_id text references public.indusagi_review_deliveries(delivery_id) on delete set null,
12
+ installation_id bigint not null,
13
+ repository text not null,
14
+ owner text not null,
15
+ repo text not null,
16
+ pr_number integer not null check (pr_number > 0),
17
+ head_sha text not null,
18
+ title text not null,
19
+ status text not null default 'pending' check (status in ('pending', 'running', 'completed', 'failed')),
20
+ attempts integer not null default 0 check (attempts >= 0),
21
+ max_attempts integer not null default 3 check (max_attempts between 1 and 10),
22
+ available_at timestamptz not null default now(),
23
+ worker_id text,
24
+ claimed_at timestamptz,
25
+ completed_at timestamptz,
26
+ review jsonb,
27
+ github_review_id bigint,
28
+ github_comment_id bigint,
29
+ check_run_id bigint,
30
+ error text,
31
+ created_at timestamptz not null default now(),
32
+ updated_at timestamptz not null default now(),
33
+ unique (repository, pr_number, head_sha)
34
+ );
35
+
36
+ create index if not exists indusagi_review_jobs_claim_idx
37
+ on public.indusagi_review_jobs (status, available_at, created_at)
38
+ where status = 'pending';
39
+ create index if not exists indusagi_review_jobs_pr_idx
40
+ on public.indusagi_review_jobs (repository, pr_number, created_at desc);
41
+
42
+ alter table public.indusagi_review_deliveries enable row level security;
43
+ alter table public.indusagi_review_jobs enable row level security;
44
+
45
+ create or replace function public.claim_indusagi_review_job(p_worker_id text)
46
+ returns setof public.indusagi_review_jobs
47
+ language plpgsql
48
+ security definer
49
+ set search_path = public
50
+ as $$
51
+ declare
52
+ claimed public.indusagi_review_jobs;
53
+ begin
54
+ if nullif(trim(p_worker_id), '') is null then
55
+ raise exception 'worker id is required';
56
+ end if;
57
+
58
+ select * into claimed
59
+ from public.indusagi_review_jobs
60
+ where status = 'pending' and available_at <= now()
61
+ order by created_at
62
+ for update skip locked
63
+ limit 1;
64
+
65
+ if claimed.id is null then
66
+ return;
67
+ end if;
68
+
69
+ update public.indusagi_review_jobs
70
+ set status = 'running',
71
+ attempts = attempts + 1,
72
+ worker_id = p_worker_id,
73
+ claimed_at = now(),
74
+ updated_at = now()
75
+ where id = claimed.id
76
+ returning * into claimed;
77
+
78
+ return next claimed;
79
+ end;
80
+ $$;
81
+
82
+ create or replace function public.recover_stale_indusagi_review_jobs(p_stale_minutes integer default 15)
83
+ returns integer
84
+ language plpgsql
85
+ security definer
86
+ set search_path = public
87
+ as $$
88
+ declare
89
+ recovered integer;
90
+ begin
91
+ update public.indusagi_review_jobs
92
+ set status = case when attempts < max_attempts then 'pending' else 'failed' end,
93
+ available_at = now(),
94
+ worker_id = null,
95
+ claimed_at = null,
96
+ error = coalesce(error, 'worker lease expired'),
97
+ completed_at = case when attempts >= max_attempts then now() else completed_at end,
98
+ updated_at = now()
99
+ where status = 'running'
100
+ and claimed_at < now() - make_interval(mins => greatest(1, p_stale_minutes));
101
+ get diagnostics recovered = row_count;
102
+ return recovered;
103
+ end;
104
+ $$;
105
+
106
+ revoke all on function public.claim_indusagi_review_job(text) from public, anon, authenticated;
107
+ revoke all on function public.recover_stale_indusagi_review_jobs(integer) from public, anon, authenticated;
108
+ grant execute on function public.claim_indusagi_review_job(text) to service_role;
109
+ grant execute on function public.recover_stale_indusagi_review_jobs(integer) to service_role;
@@ -0,0 +1,75 @@
1
+ create or replace function public.enqueue_indusagi_review_job(
2
+ p_delivery_id text,
3
+ p_event_name text,
4
+ p_installation_id bigint,
5
+ p_repository text,
6
+ p_owner text,
7
+ p_repo text,
8
+ p_pr_number integer,
9
+ p_head_sha text,
10
+ p_title text
11
+ )
12
+ returns table(job_id uuid, duplicate boolean)
13
+ language plpgsql
14
+ security definer
15
+ set search_path = public
16
+ as $$
17
+ declare
18
+ queued_id uuid;
19
+ inserted_delivery integer;
20
+ begin
21
+ if nullif(trim(p_delivery_id), '') is null
22
+ or nullif(trim(p_event_name), '') is null
23
+ or p_installation_id <= 0
24
+ or nullif(trim(p_repository), '') is null
25
+ or nullif(trim(p_owner), '') is null
26
+ or nullif(trim(p_repo), '') is null
27
+ or p_pr_number <= 0
28
+ or nullif(trim(p_head_sha), '') is null
29
+ or nullif(trim(p_title), '') is null then
30
+ raise exception 'invalid review webhook job';
31
+ end if;
32
+
33
+ insert into public.indusagi_review_deliveries(delivery_id, event_name)
34
+ values (p_delivery_id, p_event_name)
35
+ on conflict (delivery_id) do nothing;
36
+ get diagnostics inserted_delivery = row_count;
37
+
38
+ if inserted_delivery = 0 then
39
+ return query select null::uuid, true;
40
+ return;
41
+ end if;
42
+
43
+ insert into public.indusagi_review_jobs(
44
+ delivery_id,
45
+ installation_id,
46
+ repository,
47
+ owner,
48
+ repo,
49
+ pr_number,
50
+ head_sha,
51
+ title
52
+ ) values (
53
+ p_delivery_id,
54
+ p_installation_id,
55
+ p_repository,
56
+ p_owner,
57
+ p_repo,
58
+ p_pr_number,
59
+ p_head_sha,
60
+ p_title
61
+ )
62
+ on conflict (repository, pr_number, head_sha) do update
63
+ set installation_id = excluded.installation_id,
64
+ title = excluded.title,
65
+ updated_at = now()
66
+ returning id into queued_id;
67
+
68
+ return query select queued_id, false;
69
+ end;
70
+ $$;
71
+
72
+ revoke all on function public.enqueue_indusagi_review_job(text, text, bigint, text, text, text, integer, text, text)
73
+ from public, anon, authenticated;
74
+ grant execute on function public.enqueue_indusagi_review_job(text, text, bigint, text, text, text, integer, text, text)
75
+ to service_role;
@@ -0,0 +1,8 @@
1
+ alter table public.indusagi_review_jobs
2
+ add column if not exists check_completed_at timestamptz;
3
+
4
+ create index if not exists indusagi_review_jobs_check_reconcile_idx
5
+ on public.indusagi_review_jobs (completed_at)
6
+ where status = 'completed'
7
+ and check_run_id is not null
8
+ and check_completed_at is null;