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.
- package/.env.review.example +25 -0
- package/README.md +30 -0
- package/REVIEW_AGENT.md +137 -0
- package/dist/entry.js +1861 -979
- package/dist/guardrails.js +212 -36
- package/dist/index.js +2829 -1057
- package/dist/review-agent.js +4461 -0
- package/dist/review-server.js +4633 -0
- package/package.json +24 -7
- package/src/boot/auth-vault.ts +4 -5
- package/src/boot/contract.ts +1 -1
- package/src/boot/runners/addon-wiring.ts +111 -3
- package/src/boot/runners/benchmark-integrity.ts +107 -0
- package/src/boot/runners/repl-runner.ts +2 -1
- package/src/boot/runners/session.ts +26 -12
- package/src/boot/server-token.ts +20 -11
- package/src/boot/stages.ts +3 -6
- package/src/briefing/context-docs.ts +144 -12
- package/src/channels/oneshot.ts +6 -0
- package/src/conductor/catalog/catalog.ts +3 -1
- package/src/conductor/conductor.ts +77 -17
- package/src/conductor/contract.ts +9 -7
- package/src/conductor/index.ts +1 -1
- package/src/conductor/transcript-store/store.ts +4 -8
- package/src/console/auth-status.ts +9 -4
- package/src/console/components/AgentsView.ts +2 -2
- package/src/console/components/BackgroundAgents.ts +15 -2
- package/src/console/components/Banner.ts +20 -9
- package/src/console/components/Composer.ts +57 -82
- package/src/console/components/ScrollViewport.ts +46 -0
- package/src/console/components/TerminalConsole.ts +165 -162
- package/src/console/components/WorkingIndicator.ts +6 -5
- package/src/console/components/banner-sweep.ts +5 -1
- package/src/console/components/welcome.ts +3 -3
- package/src/console/contract.ts +10 -5
- package/src/console/index.ts +7 -7
- package/src/console/input/complete.ts +2 -2
- package/src/console/input/dir-reader.ts +3 -1
- package/src/console/input/graphemes.ts +22 -0
- package/src/console/input/keymap.ts +6 -4
- package/src/console/mount.ts +45 -3
- package/src/console/overlays/auth.ts +28 -8
- package/src/console/overlays/boards.ts +13 -40
- package/src/console/overlays/host.ts +10 -0
- package/src/console/overlays/pickers.ts +35 -30
- package/src/console/reducer.ts +12 -5
- package/src/console/slash/builtins.ts +4 -3
- package/src/console/slash/commands/integrations.ts +13 -14
- package/src/console/slash/commands/transcript.ts +3 -8
- package/src/console/slash/commands/workbench.ts +4 -4
- package/src/console/test-support.ts +48 -0
- package/src/index.ts +1 -0
- package/src/integrations/sarvam/config.ts +15 -13
- package/src/kit/external-editor.ts +17 -1
- package/src/kit/run-shell.ts +31 -0
- package/src/launch/catalog.ts +4 -2
- package/src/launch/contract.ts +1 -0
- package/src/launch/invocation/flags.ts +13 -1
- package/src/launch/invocation/read.ts +4 -3
- package/src/launch/login.ts +23 -10
- package/src/launch/oauth.ts +3 -0
- package/src/models.ts +166 -0
- package/src/review-agent/config.ts +75 -0
- package/src/review-agent/contract.ts +59 -0
- package/src/review-agent/github.ts +242 -0
- package/src/review-agent/index.ts +8 -0
- package/src/review-agent/prompt.ts +132 -0
- package/src/review-agent/reviewer.ts +118 -0
- package/src/review-agent/server.ts +221 -0
- package/src/review-agent/service.ts +165 -0
- package/src/review-agent/supabase.ts +209 -0
- package/src/review-agent/webhook.ts +76 -0
- package/src/security/private-state.ts +122 -0
- package/src/window-budget/summarize/condense.ts +4 -1
- package/src/workspace/brand.ts +1 -1
- package/src/workspace/locator.ts +2 -2
- package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
- package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
- package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
- package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
- 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() }],
|
package/src/workspace/brand.ts
CHANGED
package/src/workspace/locator.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|