ai-foreman 1.5.0 → 1.6.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/dist/agentRun.d.ts +3 -2
- package/dist/agentRun.js +27 -8
- package/dist/branch/git.d.ts +3 -0
- package/dist/branch/git.js +31 -5
- package/dist/branch/github.d.ts +2 -2
- package/dist/branch/github.js +2 -2
- package/dist/branch/gitlab.d.ts +3 -2
- package/dist/branch/gitlab.js +10 -4
- package/dist/branch/planner.d.ts +3 -1
- package/dist/branch/planner.js +24 -2
- package/dist/branch/runner.d.ts +10 -1
- package/dist/branch/runner.js +41 -13
- package/dist/branch/types.d.ts +4 -0
- package/dist/buildRuns.d.ts +35 -0
- package/dist/buildRuns.js +149 -0
- package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
- package/dist/cli/runtimeAuthPrompt.js +2 -2
- package/dist/cli/start.js +153 -16
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +71 -55
- package/dist/cli/tickets.js +57 -10
- package/dist/foreman.d.ts +3 -1
- package/dist/foreman.js +46 -4
- package/dist/index.js +0 -0
- package/dist/interviews.d.ts +26 -4
- package/dist/interviews.js +71 -4
- package/dist/log.d.ts +1 -1
- package/dist/roles.d.ts +11 -0
- package/dist/roles.js +8 -0
- package/dist/runtimeAuth.d.ts +1 -1
- package/dist/runtimeAuth.js +6 -25
- package/dist/runtimeReadiness.d.ts +14 -0
- package/dist/runtimeReadiness.js +122 -0
- package/dist/ticketPlanning.d.ts +83 -0
- package/dist/ticketPlanning.js +287 -0
- package/dist/tickets/commands.js +16 -0
- package/dist/tickets/delivery.d.ts +38 -0
- package/dist/tickets/delivery.js +152 -0
- package/dist/tickets/importer.js +4 -0
- package/dist/tickets/renderMarkdown.js +9 -0
- package/dist/tickets/setupConfig.d.ts +8 -0
- package/dist/tickets/setupConfig.js +47 -2
- package/dist/tickets/sourceFetch.d.ts +24 -0
- package/dist/tickets/sourceFetch.js +246 -0
- package/dist/tickets/stateDb.d.ts +10 -0
- package/dist/tickets/stateDb.js +24 -0
- package/dist/tickets/ticketLoader.js +18 -0
- package/dist/tickets/ticketSchema.d.ts +93 -0
- package/dist/tickets/ticketSchema.js +17 -0
- package/dist/tickets/validate.js +12 -0
- package/package.json +29 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TicketDef } from "./ticketSchema.js";
|
|
2
|
+
import type { TicketState } from "./stateDb.js";
|
|
3
|
+
import type { TicketBuildCompletionMode, TicketBuildMergeMethod, TicketBuildProvider } from "./setupConfig.js";
|
|
4
|
+
export declare const DELIVERY_FILE = ".tickets/delivery.yaml";
|
|
5
|
+
export type DeliveryBranchMode = "current" | "per-ticket" | "shared";
|
|
6
|
+
export type DeliveryDependencyMode = "combine" | "wait" | "stack";
|
|
7
|
+
export interface DeliveryUnit {
|
|
8
|
+
id: string;
|
|
9
|
+
tickets: string[];
|
|
10
|
+
branch_mode: DeliveryBranchMode;
|
|
11
|
+
completion?: TicketBuildCompletionMode;
|
|
12
|
+
provider?: TicketBuildProvider;
|
|
13
|
+
pr_ready?: boolean;
|
|
14
|
+
merge_method?: TicketBuildMergeMethod;
|
|
15
|
+
cleanup?: boolean;
|
|
16
|
+
depends_on?: string[];
|
|
17
|
+
dependency_mode?: DeliveryDependencyMode;
|
|
18
|
+
}
|
|
19
|
+
export interface DeliveryConfig {
|
|
20
|
+
version: 1;
|
|
21
|
+
units: DeliveryUnit[];
|
|
22
|
+
}
|
|
23
|
+
export interface DeliveryIssue {
|
|
24
|
+
path: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function loadDeliveryConfig(projectDir: string): DeliveryConfig | undefined;
|
|
28
|
+
export declare function normalizeDeliveryConfig(raw: unknown): DeliveryConfig;
|
|
29
|
+
export declare function validateDeliveryConfig(raw: unknown, ticketDefs?: TicketDef[]): DeliveryIssue[];
|
|
30
|
+
export interface DeliveryUnitProgress {
|
|
31
|
+
unit: DeliveryUnit;
|
|
32
|
+
completed: number;
|
|
33
|
+
remaining: string[];
|
|
34
|
+
state: "complete" | "ready" | "waiting" | "resume";
|
|
35
|
+
}
|
|
36
|
+
export declare function deliveryProgress(config: DeliveryConfig, states: Map<string, TicketState>): DeliveryUnitProgress[];
|
|
37
|
+
export declare function formatDeliverySummary(config: DeliveryConfig, states: Map<string, TicketState>): string[];
|
|
38
|
+
export declare function selectDeliveryUnitForRun(config: DeliveryConfig, states: Map<string, TicketState>, skipUnitIds?: readonly string[]): DeliveryUnitProgress | undefined;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
export const DELIVERY_FILE = ".tickets/delivery.yaml";
|
|
5
|
+
export function loadDeliveryConfig(projectDir) {
|
|
6
|
+
const path = join(projectDir, DELIVERY_FILE);
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return undefined;
|
|
9
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
10
|
+
const issues = validateDeliveryConfig(raw);
|
|
11
|
+
if (issues.length > 0) {
|
|
12
|
+
throw new Error(`${DELIVERY_FILE}:\n${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`);
|
|
13
|
+
}
|
|
14
|
+
return normalizeDeliveryConfig(raw);
|
|
15
|
+
}
|
|
16
|
+
export function normalizeDeliveryConfig(raw) {
|
|
17
|
+
const value = raw;
|
|
18
|
+
return {
|
|
19
|
+
version: 1,
|
|
20
|
+
units: value.units.map((unit) => ({
|
|
21
|
+
id: String(unit.id),
|
|
22
|
+
tickets: [...unit.tickets],
|
|
23
|
+
branch_mode: unit.branch_mode,
|
|
24
|
+
...(unit.completion ? { completion: unit.completion } : {}),
|
|
25
|
+
...(unit.provider ? { provider: unit.provider } : {}),
|
|
26
|
+
...(typeof unit.pr_ready === "boolean" ? { pr_ready: unit.pr_ready } : {}),
|
|
27
|
+
...(unit.merge_method ? { merge_method: unit.merge_method } : {}),
|
|
28
|
+
...(typeof unit.cleanup === "boolean" ? { cleanup: unit.cleanup } : {}),
|
|
29
|
+
...(Array.isArray(unit.depends_on) ? { depends_on: [...unit.depends_on] } : {}),
|
|
30
|
+
...(unit.dependency_mode ? { dependency_mode: unit.dependency_mode } : {}),
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function validateDeliveryConfig(raw, ticketDefs) {
|
|
35
|
+
const issues = [];
|
|
36
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
37
|
+
return [{ path: "$", message: "expected an object" }];
|
|
38
|
+
const value = raw;
|
|
39
|
+
if (value.version !== undefined && value.version !== 1)
|
|
40
|
+
issues.push({ path: "version", message: "expected 1" });
|
|
41
|
+
if (!Array.isArray(value.units))
|
|
42
|
+
return [...issues, { path: "units", message: "expected an array" }];
|
|
43
|
+
const unitIds = new Set();
|
|
44
|
+
const assigned = new Map();
|
|
45
|
+
const knownTickets = ticketDefs ? new Set(ticketDefs.map((ticket) => ticket.id)) : undefined;
|
|
46
|
+
for (let index = 0; index < value.units.length; index++) {
|
|
47
|
+
const path = `units[${index}]`;
|
|
48
|
+
const unit = value.units[index];
|
|
49
|
+
if (!unit || typeof unit !== "object" || Array.isArray(unit)) {
|
|
50
|
+
issues.push({ path, message: "expected an object" });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const u = unit;
|
|
54
|
+
if (typeof u.id !== "string" || !/^[A-Za-z][A-Za-z0-9-_]*$/.test(u.id))
|
|
55
|
+
issues.push({ path: `${path}.id`, message: "expected a stable identifier" });
|
|
56
|
+
else if (unitIds.has(u.id))
|
|
57
|
+
issues.push({ path: `${path}.id`, message: `duplicate unit ID: ${u.id}` });
|
|
58
|
+
else
|
|
59
|
+
unitIds.add(u.id);
|
|
60
|
+
if (!Array.isArray(u.tickets) || u.tickets.length === 0 || !u.tickets.every((id) => typeof id === "string" && id.length > 0)) {
|
|
61
|
+
issues.push({ path: `${path}.tickets`, message: "expected a non-empty ticket ID array" });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
for (const id of u.tickets) {
|
|
65
|
+
if (knownTickets && !knownTickets.has(id))
|
|
66
|
+
issues.push({ path: `${path}.tickets`, message: `unknown ticket: ${id}` });
|
|
67
|
+
const prior = assigned.get(id);
|
|
68
|
+
if (prior)
|
|
69
|
+
issues.push({ path: `${path}.tickets`, message: `${id} is already assigned to ${prior}` });
|
|
70
|
+
else
|
|
71
|
+
assigned.set(id, typeof u.id === "string" ? u.id : path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
enumValue(issues, `${path}.branch_mode`, u.branch_mode, ["current", "per-ticket", "shared"], true);
|
|
75
|
+
enumValue(issues, `${path}.completion`, u.completion, ["pr", "auto-merge", "direct-merge", "none"]);
|
|
76
|
+
enumValue(issues, `${path}.provider`, u.provider, ["auto", "github", "gitlab", "local"]);
|
|
77
|
+
enumValue(issues, `${path}.merge_method`, u.merge_method, ["squash", "merge", "rebase"]);
|
|
78
|
+
enumValue(issues, `${path}.dependency_mode`, u.dependency_mode, ["combine", "wait", "stack"]);
|
|
79
|
+
for (const field of ["pr_ready", "cleanup"]) {
|
|
80
|
+
if (u[field] !== undefined && typeof u[field] !== "boolean")
|
|
81
|
+
issues.push({ path: `${path}.${field}`, message: "expected a boolean" });
|
|
82
|
+
}
|
|
83
|
+
if (u.depends_on !== undefined && (!Array.isArray(u.depends_on) || !u.depends_on.every((id) => typeof id === "string"))) {
|
|
84
|
+
issues.push({ path: `${path}.depends_on`, message: "expected a unit ID array" });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const units = value.units.filter((unit) => Boolean(unit && typeof unit === "object" && !Array.isArray(unit)));
|
|
88
|
+
for (const unit of units) {
|
|
89
|
+
for (const dep of Array.isArray(unit.depends_on) ? unit.depends_on : []) {
|
|
90
|
+
if (!unitIds.has(String(dep)))
|
|
91
|
+
issues.push({ path: `units[${String(unit.id)}].depends_on`, message: `unknown unit: ${String(dep)}` });
|
|
92
|
+
if (dep === unit.id)
|
|
93
|
+
issues.push({ path: `units[${String(unit.id)}].depends_on`, message: "unit cannot depend on itself" });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const cycle of deliveryCycles(units))
|
|
97
|
+
issues.push({ path: "units", message: `dependency cycle: ${cycle}` });
|
|
98
|
+
return issues;
|
|
99
|
+
}
|
|
100
|
+
function enumValue(issues, path, value, allowed, required = false) {
|
|
101
|
+
if (value === undefined && !required)
|
|
102
|
+
return;
|
|
103
|
+
if (typeof value !== "string" || !allowed.includes(value))
|
|
104
|
+
issues.push({ path, message: `expected one of: ${allowed.join(", ")}` });
|
|
105
|
+
}
|
|
106
|
+
function deliveryCycles(units) {
|
|
107
|
+
const deps = new Map(units.map((unit) => [String(unit.id), (Array.isArray(unit.depends_on) ? unit.depends_on : []).map(String)]));
|
|
108
|
+
const visited = new Set();
|
|
109
|
+
const stack = new Set();
|
|
110
|
+
const cycles = [];
|
|
111
|
+
const walk = (id, path) => {
|
|
112
|
+
if (stack.has(id)) {
|
|
113
|
+
cycles.push([...path.slice(path.indexOf(id)), id].join(" -> "));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (visited.has(id))
|
|
117
|
+
return;
|
|
118
|
+
visited.add(id);
|
|
119
|
+
stack.add(id);
|
|
120
|
+
for (const dep of deps.get(id) ?? [])
|
|
121
|
+
if (deps.has(dep))
|
|
122
|
+
walk(dep, [...path, id]);
|
|
123
|
+
stack.delete(id);
|
|
124
|
+
};
|
|
125
|
+
for (const id of deps.keys())
|
|
126
|
+
walk(id, []);
|
|
127
|
+
return cycles;
|
|
128
|
+
}
|
|
129
|
+
export function deliveryProgress(config, states) {
|
|
130
|
+
const completedUnits = new Set(config.units.filter((unit) => unit.tickets.every((id) => ["done", "canceled"].includes(states.get(id)?.status ?? "planned"))).map((unit) => unit.id));
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const unit of config.units) {
|
|
133
|
+
const remaining = unit.tickets.filter((id) => !["done", "canceled"].includes(states.get(id)?.status ?? "planned"));
|
|
134
|
+
const started = unit.tickets.some((id) => ["in_progress", "blocked", "done"].includes(states.get(id)?.status ?? "planned"));
|
|
135
|
+
const waiting = (unit.depends_on ?? []).some((id) => !completedUnits.has(id));
|
|
136
|
+
out.push({
|
|
137
|
+
unit,
|
|
138
|
+
completed: unit.tickets.length - remaining.length,
|
|
139
|
+
remaining,
|
|
140
|
+
state: remaining.length === 0 ? "complete" : waiting ? "waiting" : started ? "resume" : "ready",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
export function formatDeliverySummary(config, states) {
|
|
146
|
+
return deliveryProgress(config, states).map(({ unit, completed, remaining, state }) => `${unit.id}: ${completed}/${unit.tickets.length} complete; ${remaining.length} remaining; ${unit.branch_mode}; ${unit.completion ?? "repository default"}; ${state}${(unit.depends_on ?? []).length ? `; depends on ${(unit.depends_on ?? []).join(", ")}` : ""}`);
|
|
147
|
+
}
|
|
148
|
+
export function selectDeliveryUnitForRun(config, states, skipUnitIds = []) {
|
|
149
|
+
const skipped = new Set(skipUnitIds);
|
|
150
|
+
const progress = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
|
|
151
|
+
return progress.find((item) => item.state === "resume") ?? progress.find((item) => item.state === "ready");
|
|
152
|
+
}
|
package/dist/tickets/importer.js
CHANGED
|
@@ -348,6 +348,10 @@ function importedItemToTicket(item, id, order, existing) {
|
|
|
348
348
|
key: item.key ?? null,
|
|
349
349
|
url: item.url ?? null,
|
|
350
350
|
}),
|
|
351
|
+
source_refs: [
|
|
352
|
+
...(existing?.source_refs ?? []).filter((ref) => !(ref.source === item.provider && ref.item === item.providerId)),
|
|
353
|
+
{ source: item.provider, item: item.providerId, url: item.url ?? null },
|
|
354
|
+
],
|
|
351
355
|
};
|
|
352
356
|
}
|
|
353
357
|
function buildImportedNotes(item) {
|
|
@@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from "
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { DateTime } from "luxon";
|
|
4
4
|
import { buildNextQueue, buildActiveStatusRows } from "./queue.js";
|
|
5
|
+
import { formatDeliverySummary, loadDeliveryConfig } from "./delivery.js";
|
|
5
6
|
// ── Table helpers ─────────────────────────────────────────────────────────────
|
|
6
7
|
function esc(v) {
|
|
7
8
|
return v.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
@@ -90,6 +91,14 @@ export function renderProgressDoc(input) {
|
|
|
90
91
|
["Remaining Tickets", String(queueRows.length > 0 ? "≥" + queueRows.length : "0")],
|
|
91
92
|
])));
|
|
92
93
|
lines.push("");
|
|
94
|
+
// Delivery units are optional; repositories without delivery.yaml retain legacy defaults.
|
|
95
|
+
const delivery = loadDeliveryConfig(projectDir);
|
|
96
|
+
if (delivery) {
|
|
97
|
+
lines.push("## Delivery Units");
|
|
98
|
+
const deliveryRows = formatDeliverySummary(delivery, states);
|
|
99
|
+
lines.push(section("DELIVERY_UNITS", deliveryRows.length > 0 ? deliveryRows.map((row) => `- ${row}`).join("\n") : "_No delivery units configured._"));
|
|
100
|
+
lines.push("");
|
|
101
|
+
}
|
|
93
102
|
// Rules
|
|
94
103
|
if (config.rendering.includeRulesInProgressDoc) {
|
|
95
104
|
lines.push(rulesContent);
|
|
@@ -20,6 +20,9 @@ export type TicketSourceConfig = {
|
|
|
20
20
|
email_env: string;
|
|
21
21
|
token_env: string;
|
|
22
22
|
jql: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: "url";
|
|
25
|
+
url: string;
|
|
23
26
|
};
|
|
24
27
|
export interface TicketPopulateSetupConfig {
|
|
25
28
|
source_handling: "saved" | "prompt" | "manual";
|
|
@@ -74,6 +77,11 @@ export declare function configuredPlanningSources(projectDir: string): string[];
|
|
|
74
77
|
export declare function externalSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
|
|
75
78
|
type: "linear" | "jira";
|
|
76
79
|
}>[];
|
|
80
|
+
export declare function urlSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
|
|
81
|
+
type: "url";
|
|
82
|
+
}>[];
|
|
83
|
+
export declare function normalizedSourceKey(source: TicketSourceConfig): string;
|
|
84
|
+
export declare function dedupeTicketSources(sources: TicketSourceConfig[]): TicketSourceConfig[];
|
|
77
85
|
export declare function configuredDocsRoot(projectDir: string): string | undefined;
|
|
78
86
|
export declare function configuredAppName(projectDir: string): string | undefined;
|
|
79
87
|
export declare function configuredHarnessTargets(projectDir: string): HarnessTarget[] | undefined;
|
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
import { loadDefaults } from "special-agents";
|
|
6
6
|
export const RAFI_CONFIG_FILE = "rafi-config.yaml";
|
|
7
|
-
const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker"];
|
|
7
|
+
const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
|
|
8
8
|
const SKILL_NAMES = [
|
|
9
9
|
"better-sqlite3-rebuild",
|
|
10
10
|
"grill-me",
|
|
@@ -121,6 +121,34 @@ export function configuredPlanningSources(projectDir) {
|
|
|
121
121
|
export function externalSources(setup) {
|
|
122
122
|
return (setup?.sources ?? []).filter((source) => source.type === "linear" || source.type === "jira");
|
|
123
123
|
}
|
|
124
|
+
export function urlSources(setup) {
|
|
125
|
+
return (setup?.sources ?? []).filter((source) => source.type === "url");
|
|
126
|
+
}
|
|
127
|
+
export function normalizedSourceKey(source) {
|
|
128
|
+
if (source.type === "local")
|
|
129
|
+
return `local:${unique(source.paths.map((path) => path.trim())).sort().join("|")}`;
|
|
130
|
+
if (source.type === "url") {
|
|
131
|
+
const url = new URL(source.url);
|
|
132
|
+
url.hash = "";
|
|
133
|
+
url.hostname = url.hostname.toLowerCase();
|
|
134
|
+
if ((url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80"))
|
|
135
|
+
url.port = "";
|
|
136
|
+
return `url:${url.toString()}`;
|
|
137
|
+
}
|
|
138
|
+
if (source.type === "linear")
|
|
139
|
+
return `linear:${source.api_key_env}:${source.team_key ?? ""}:${source.filter ?? ""}`;
|
|
140
|
+
return `jira:${source.site.replace(/\/+$/, "").toLowerCase()}:${source.jql}`;
|
|
141
|
+
}
|
|
142
|
+
export function dedupeTicketSources(sources) {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
return sources.filter((source) => {
|
|
145
|
+
const key = normalizedSourceKey(source);
|
|
146
|
+
if (seen.has(key))
|
|
147
|
+
return false;
|
|
148
|
+
seen.add(key);
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
124
152
|
export function configuredDocsRoot(projectDir) {
|
|
125
153
|
const raw = loadRafiConfigObject(projectDir);
|
|
126
154
|
const docs = raw?.docs;
|
|
@@ -271,7 +299,24 @@ function normalizeSource(value, label) {
|
|
|
271
299
|
jql,
|
|
272
300
|
};
|
|
273
301
|
}
|
|
274
|
-
|
|
302
|
+
if (raw.type === "url") {
|
|
303
|
+
const url = stringField(raw.url);
|
|
304
|
+
if (!url)
|
|
305
|
+
throw new Error(`${label}.url: expected a public HTTP(S) URL`);
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = new URL(url);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new Error(`${label}.url: expected a valid URL`);
|
|
312
|
+
}
|
|
313
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password) {
|
|
314
|
+
throw new Error(`${label}.url: expected HTTP(S) without embedded credentials`);
|
|
315
|
+
}
|
|
316
|
+
parsed.hash = "";
|
|
317
|
+
return { type: "url", url: parsed.toString() };
|
|
318
|
+
}
|
|
319
|
+
throw new Error(`${label}.type: expected local, linear, jira, or url`);
|
|
275
320
|
}
|
|
276
321
|
function normalizePopulate(value, label) {
|
|
277
322
|
const raw = objectOrEmpty(value, label);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SourceFetchLimits {
|
|
2
|
+
timeoutMs: number;
|
|
3
|
+
maxBytes: number;
|
|
4
|
+
maxRedirects: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_SOURCE_FETCH_LIMITS: SourceFetchLimits;
|
|
7
|
+
export interface FetchedSource {
|
|
8
|
+
requestedUrl: string;
|
|
9
|
+
finalUrl: string;
|
|
10
|
+
contentType: "html" | "text" | "markdown" | "pdf";
|
|
11
|
+
text: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
snapshotPath: string;
|
|
15
|
+
metadataPath: string;
|
|
16
|
+
fetchedAt: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function fetchAndSnapshotUrl(projectDir: string, input: string, limits?: Partial<SourceFetchLimits>): Promise<FetchedSource>;
|
|
19
|
+
export declare function normalizePublicHttpUrl(input: string): string;
|
|
20
|
+
export declare function assertPublicDestination(input: string): Promise<void>;
|
|
21
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
22
|
+
export declare function htmlToText(html: string): string;
|
|
23
|
+
/** Snapshot a local file outside the repository so absolute paths are never persisted. */
|
|
24
|
+
export declare function snapshotExternalLocalFile(projectDir: string, inputPath: string): string;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFileSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { readdirSync } from "node:fs";
|
|
7
|
+
import { parse as parseHtml } from "parse5";
|
|
8
|
+
export const DEFAULT_SOURCE_FETCH_LIMITS = {
|
|
9
|
+
timeoutMs: 15_000,
|
|
10
|
+
maxBytes: 10 * 1024 * 1024,
|
|
11
|
+
maxRedirects: 5,
|
|
12
|
+
};
|
|
13
|
+
export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
|
|
14
|
+
const effective = { ...DEFAULT_SOURCE_FETCH_LIMITS, ...limits };
|
|
15
|
+
const requested = normalizePublicHttpUrl(input);
|
|
16
|
+
let current = requested;
|
|
17
|
+
let response;
|
|
18
|
+
for (let redirects = 0; redirects <= effective.maxRedirects; redirects++) {
|
|
19
|
+
await assertPublicDestination(current);
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => controller.abort(), effective.timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
response = await fetch(current, {
|
|
24
|
+
redirect: "manual",
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
headers: { Accept: "text/html,text/plain,text/markdown,application/pdf;q=0.9" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
if (response.status >= 300 && response.status < 400) {
|
|
33
|
+
const location = response.headers.get("location");
|
|
34
|
+
if (!location)
|
|
35
|
+
throw new Error(`URL redirect ${response.status} did not include Location`);
|
|
36
|
+
if (redirects === effective.maxRedirects)
|
|
37
|
+
throw new Error(`URL exceeded ${effective.maxRedirects} redirects`);
|
|
38
|
+
current = normalizePublicHttpUrl(new URL(location, current).toString());
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
if (!response)
|
|
44
|
+
throw new Error("URL fetch did not produce a response");
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new Error(`URL fetch failed with HTTP ${response.status}`);
|
|
47
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
48
|
+
if (Number.isFinite(declaredLength) && declaredLength > effective.maxBytes)
|
|
49
|
+
throw new Error(`URL content exceeds ${effective.maxBytes} byte limit`);
|
|
50
|
+
const bytes = await readLimitedBody(response, effective.maxBytes);
|
|
51
|
+
const kind = detectContentType(response.headers.get("content-type"), bytes, current);
|
|
52
|
+
const text = await extractSourceText(kind, bytes);
|
|
53
|
+
const fetchedAt = new Date().toISOString();
|
|
54
|
+
const fingerprint = createHash("sha256").update(bytes).digest("hex");
|
|
55
|
+
const importsDir = join(resolve(projectDir), ".tickets", "imports");
|
|
56
|
+
mkdirSync(importsDir, { recursive: true });
|
|
57
|
+
const stamp = fetchedAt.replace(/[:.]/g, "-");
|
|
58
|
+
const stem = `url-${stamp}-${fingerprint.slice(0, 12)}`;
|
|
59
|
+
const snapshotPath = join(importsDir, `${stem}.md`);
|
|
60
|
+
const metadataPath = join(importsDir, `${stem}.json`);
|
|
61
|
+
writeFileSync(snapshotPath, `${text.trimEnd()}\n`, "utf8");
|
|
62
|
+
writeFileSync(metadataPath, `${JSON.stringify({ requestedUrl: requested, finalUrl: current, fetchedAt, bytes: bytes.length, contentType: kind, fingerprint }, null, 2)}\n`, "utf8");
|
|
63
|
+
return { requestedUrl: requested, finalUrl: current, contentType: kind, text, bytes: bytes.length, fingerprint, snapshotPath: relative(resolve(projectDir), snapshotPath), metadataPath: relative(resolve(projectDir), metadataPath), fetchedAt };
|
|
64
|
+
}
|
|
65
|
+
export function normalizePublicHttpUrl(input) {
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(input);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error(`invalid URL: ${input}`);
|
|
72
|
+
}
|
|
73
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
74
|
+
throw new Error("URL source must use HTTP or HTTPS");
|
|
75
|
+
if (url.username || url.password)
|
|
76
|
+
throw new Error("URL source must not contain credentials");
|
|
77
|
+
if (!url.hostname)
|
|
78
|
+
throw new Error("URL source must include a hostname");
|
|
79
|
+
url.hash = "";
|
|
80
|
+
return url.toString();
|
|
81
|
+
}
|
|
82
|
+
export async function assertPublicDestination(input) {
|
|
83
|
+
const url = new URL(normalizePublicHttpUrl(input));
|
|
84
|
+
const addresses = isIP(url.hostname)
|
|
85
|
+
? [{ address: url.hostname }]
|
|
86
|
+
: await dnsLookup(url.hostname, { all: true, verbatim: true });
|
|
87
|
+
if (addresses.length === 0)
|
|
88
|
+
throw new Error(`URL hostname did not resolve: ${url.hostname}`);
|
|
89
|
+
for (const { address } of addresses) {
|
|
90
|
+
if (!isPublicAddress(address))
|
|
91
|
+
throw new Error(`URL destination is not public: ${url.hostname} resolved to ${address}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function isPublicAddress(address) {
|
|
95
|
+
const normalized = address.toLowerCase().split("%")[0];
|
|
96
|
+
if (normalized === "::1" || normalized === "::" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd"))
|
|
97
|
+
return false;
|
|
98
|
+
if (normalized.startsWith("::ffff:"))
|
|
99
|
+
return isPublicAddress(normalized.slice(7));
|
|
100
|
+
if (isIP(normalized) === 6) {
|
|
101
|
+
return !(normalized.startsWith("ff") ||
|
|
102
|
+
normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb") ||
|
|
103
|
+
normalized.startsWith("fc") || normalized.startsWith("fd") ||
|
|
104
|
+
normalized.startsWith("fec") || normalized.startsWith("fed") || normalized.startsWith("fee") || normalized.startsWith("fef") ||
|
|
105
|
+
normalized.startsWith("2001:db8:") || normalized.startsWith("2001:10:") || normalized.startsWith("2001:2:") ||
|
|
106
|
+
normalized.startsWith("100:") || normalized.startsWith("64:ff9b:1:"));
|
|
107
|
+
}
|
|
108
|
+
const parts = normalized.split(".").map(Number);
|
|
109
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
110
|
+
return false;
|
|
111
|
+
const [a, b] = parts;
|
|
112
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224 ||
|
|
113
|
+
(a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
|
|
114
|
+
(a === 192 && (b === 0 || b === 88 || b === 168)) ||
|
|
115
|
+
(a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
|
116
|
+
(a === 203 && b === 0));
|
|
117
|
+
}
|
|
118
|
+
async function readLimitedBody(response, maxBytes) {
|
|
119
|
+
if (!response.body)
|
|
120
|
+
return new Uint8Array();
|
|
121
|
+
const reader = response.body.getReader();
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let total = 0;
|
|
124
|
+
while (true) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done)
|
|
127
|
+
break;
|
|
128
|
+
total += value.length;
|
|
129
|
+
if (total > maxBytes) {
|
|
130
|
+
await reader.cancel();
|
|
131
|
+
throw new Error(`URL content exceeds ${maxBytes} byte limit`);
|
|
132
|
+
}
|
|
133
|
+
chunks.push(value);
|
|
134
|
+
}
|
|
135
|
+
const out = new Uint8Array(total);
|
|
136
|
+
let offset = 0;
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
out.set(chunk, offset);
|
|
139
|
+
offset += chunk.length;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function detectContentType(header, bytes, url) {
|
|
144
|
+
const mime = (header ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
145
|
+
const magic = Buffer.from(bytes.subarray(0, 8)).toString("latin1");
|
|
146
|
+
if (magic.startsWith("%PDF-")) {
|
|
147
|
+
if (mime && mime !== "application/pdf" && mime !== "application/octet-stream")
|
|
148
|
+
throw new Error(`content-type ${mime} conflicts with PDF magic bytes`);
|
|
149
|
+
return "pdf";
|
|
150
|
+
}
|
|
151
|
+
if (["application/pdf"].includes(mime))
|
|
152
|
+
throw new Error("response claimed PDF but PDF magic bytes were missing");
|
|
153
|
+
if (["text/html", "application/xhtml+xml"].includes(mime))
|
|
154
|
+
return "html";
|
|
155
|
+
if (["text/markdown", "text/x-markdown"].includes(mime) || /\.md(?:own)?$/i.test(new URL(url).pathname))
|
|
156
|
+
return "markdown";
|
|
157
|
+
if (!mime || mime.startsWith("text/") || mime === "application/octet-stream") {
|
|
158
|
+
if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
|
|
159
|
+
throw new Error("binary URL content is not supported");
|
|
160
|
+
if ((!mime || mime === "application/octet-stream") && /^\s*(?:<!doctype\s+html|<html\b)/i.test(Buffer.from(bytes.subarray(0, 1024)).toString("utf8")))
|
|
161
|
+
return "html";
|
|
162
|
+
return "text";
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`unsupported URL content-type: ${mime}`);
|
|
165
|
+
}
|
|
166
|
+
async function extractSourceText(kind, bytes) {
|
|
167
|
+
if (kind === "pdf") {
|
|
168
|
+
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
169
|
+
const document = await pdfjs.getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false }).promise;
|
|
170
|
+
const pages = [];
|
|
171
|
+
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) {
|
|
172
|
+
const page = await document.getPage(pageNumber);
|
|
173
|
+
const content = await page.getTextContent();
|
|
174
|
+
pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
|
|
175
|
+
page.cleanup();
|
|
176
|
+
}
|
|
177
|
+
await document.destroy();
|
|
178
|
+
return pages.join("\n\n");
|
|
179
|
+
}
|
|
180
|
+
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
181
|
+
return kind === "html" ? htmlToText(decoded) : decoded;
|
|
182
|
+
}
|
|
183
|
+
export function htmlToText(html) {
|
|
184
|
+
const doc = parseHtml(html);
|
|
185
|
+
const blocks = new Set(["address", "article", "aside", "blockquote", "br", "div", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "li", "main", "p", "section", "table", "tr"]);
|
|
186
|
+
const skip = new Set(["script", "style", "noscript", "svg"]);
|
|
187
|
+
const out = [];
|
|
188
|
+
const walk = (node) => {
|
|
189
|
+
const named = node;
|
|
190
|
+
const name = named.nodeName?.toLowerCase();
|
|
191
|
+
if (name && skip.has(name))
|
|
192
|
+
return;
|
|
193
|
+
if (name === "#text" && named.value)
|
|
194
|
+
out.push(named.value);
|
|
195
|
+
for (const child of named.childNodes ?? [])
|
|
196
|
+
walk(child);
|
|
197
|
+
if (name && blocks.has(name))
|
|
198
|
+
out.push("\n");
|
|
199
|
+
};
|
|
200
|
+
for (const child of doc.childNodes)
|
|
201
|
+
walk(child);
|
|
202
|
+
return out.join(" ").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
203
|
+
}
|
|
204
|
+
/** Snapshot a local file outside the repository so absolute paths are never persisted. */
|
|
205
|
+
export function snapshotExternalLocalFile(projectDir, inputPath) {
|
|
206
|
+
const root = realpathSync(resolve(projectDir));
|
|
207
|
+
const file = realpathSync(resolve(inputPath));
|
|
208
|
+
const rel = relative(root, file);
|
|
209
|
+
if (!rel.startsWith("..") && !isAbsolute(rel))
|
|
210
|
+
return rel;
|
|
211
|
+
if (statSync(file).isDirectory()) {
|
|
212
|
+
const hash = createHash("sha256").update(file).digest("hex").slice(0, 12);
|
|
213
|
+
const targetRoot = join(root, ".tickets", "imports", `local-${hash}-${basename(file).replace(/[^A-Za-z0-9._-]/g, "-")}`);
|
|
214
|
+
let count = 0;
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
const copyDirectory = (source, target) => {
|
|
217
|
+
mkdirSync(target, { recursive: true });
|
|
218
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
219
|
+
if (entry.isSymbolicLink())
|
|
220
|
+
continue;
|
|
221
|
+
const from = join(source, entry.name);
|
|
222
|
+
const to = join(target, entry.name);
|
|
223
|
+
if (entry.isDirectory())
|
|
224
|
+
copyDirectory(from, to);
|
|
225
|
+
else if (entry.isFile()) {
|
|
226
|
+
count++;
|
|
227
|
+
bytes += statSync(from).size;
|
|
228
|
+
if (count > 1000 || bytes > DEFAULT_SOURCE_FETCH_LIMITS.maxBytes)
|
|
229
|
+
throw new Error("external local source directory exceeds snapshot limits");
|
|
230
|
+
copyFileSync(from, to);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
copyDirectory(file, targetRoot);
|
|
235
|
+
return relative(root, targetRoot);
|
|
236
|
+
}
|
|
237
|
+
if (!statSync(file).isFile())
|
|
238
|
+
throw new Error(`local source is not a file or directory: ${inputPath}`);
|
|
239
|
+
const bytes = readFileSync(file);
|
|
240
|
+
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
|
|
241
|
+
const dir = join(root, ".tickets", "imports");
|
|
242
|
+
mkdirSync(dir, { recursive: true });
|
|
243
|
+
const target = join(dir, `local-${hash}-${basename(file).replace(/[^A-Za-z0-9._-]/g, "-")}${extname(file) ? "" : ".txt"}`);
|
|
244
|
+
copyFileSync(file, target);
|
|
245
|
+
return relative(root, target);
|
|
246
|
+
}
|
|
@@ -81,6 +81,14 @@ export interface ReviewRecommendation {
|
|
|
81
81
|
source_json: string;
|
|
82
82
|
updated_at: string | null;
|
|
83
83
|
}
|
|
84
|
+
export interface OperationReceipt {
|
|
85
|
+
operation_id: string;
|
|
86
|
+
operation_type: string;
|
|
87
|
+
ticket_id: string | null;
|
|
88
|
+
run_id: string;
|
|
89
|
+
completed_at: string;
|
|
90
|
+
payload_json: string;
|
|
91
|
+
}
|
|
84
92
|
export declare class StateDb {
|
|
85
93
|
private db;
|
|
86
94
|
constructor(dbPath: string);
|
|
@@ -103,5 +111,7 @@ export declare class StateDb {
|
|
|
103
111
|
getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
|
|
104
112
|
getReviewRecommendation(id: number): ReviewRecommendation | undefined;
|
|
105
113
|
updateReviewRecommendationStatus(id: number, status: ReviewRecommendationStatus, updatedAt: string): void;
|
|
114
|
+
getOperationReceipt(operationId: string): OperationReceipt | undefined;
|
|
115
|
+
recordOperationReceipt(receipt: OperationReceipt): boolean;
|
|
106
116
|
transaction<T>(fn: () => T): T;
|
|
107
117
|
}
|
package/dist/tickets/stateDb.js
CHANGED
|
@@ -96,12 +96,22 @@ CREATE TABLE IF NOT EXISTS review_recommendations (
|
|
|
96
96
|
CHECK (status IN ('pending','accepted','deferred','dismissed'))
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
+
CREATE TABLE IF NOT EXISTS operation_receipts (
|
|
100
|
+
operation_id TEXT PRIMARY KEY,
|
|
101
|
+
operation_type TEXT NOT NULL,
|
|
102
|
+
ticket_id TEXT,
|
|
103
|
+
run_id TEXT NOT NULL,
|
|
104
|
+
completed_at TEXT NOT NULL,
|
|
105
|
+
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
106
|
+
);
|
|
107
|
+
|
|
99
108
|
CREATE INDEX IF NOT EXISTS idx_ticket_state_status ON ticket_state(status);
|
|
100
109
|
CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket_id ON ticket_events(ticket_id);
|
|
101
110
|
CREATE INDEX IF NOT EXISTS idx_ticket_events_timestamp ON ticket_events(timestamp);
|
|
102
111
|
CREATE INDEX IF NOT EXISTS idx_validation_snapshots_timestamp ON validation_snapshots(timestamp);
|
|
103
112
|
CREATE INDEX IF NOT EXISTS idx_future_work_disposition ON future_work(disposition);
|
|
104
113
|
CREATE INDEX IF NOT EXISTS idx_review_recommendations_status ON review_recommendations(status);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_operation_receipts_run ON operation_receipts(run_id);
|
|
105
115
|
`;
|
|
106
116
|
export class StateDb {
|
|
107
117
|
db;
|
|
@@ -291,6 +301,20 @@ export class StateDb {
|
|
|
291
301
|
.prepare("UPDATE review_recommendations SET status = ?, updated_at = ? WHERE id = ?")
|
|
292
302
|
.run(status, updatedAt, id);
|
|
293
303
|
}
|
|
304
|
+
// ── operation_receipts ───────────────────────────────────────────────────
|
|
305
|
+
getOperationReceipt(operationId) {
|
|
306
|
+
return this.db.prepare("SELECT * FROM operation_receipts WHERE operation_id = ?").get(operationId);
|
|
307
|
+
}
|
|
308
|
+
recordOperationReceipt(receipt) {
|
|
309
|
+
const result = this.db.prepare(`
|
|
310
|
+
INSERT OR IGNORE INTO operation_receipts (
|
|
311
|
+
operation_id,operation_type,ticket_id,run_id,completed_at,payload_json
|
|
312
|
+
) VALUES (
|
|
313
|
+
@operation_id,@operation_type,@ticket_id,@run_id,@completed_at,@payload_json
|
|
314
|
+
)
|
|
315
|
+
`).run(receipt);
|
|
316
|
+
return result.changes === 1;
|
|
317
|
+
}
|
|
294
318
|
// ── transactions ──────────────────────────────────────────────────────────
|
|
295
319
|
transaction(fn) {
|
|
296
320
|
return this.db.transaction(fn)();
|