@delorenj/pjangler 1.4.5 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -520
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +5747 -13483
- package/dist/mcp-server.js +6853 -14059
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -513
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/project/registryService.ts
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { Pool } from "pg";
|
|
6
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
// src/project/executionBinding.ts
|
|
9
|
+
var EXECUTION_LANES = ["Backlog", "Needs Re-evaluation", "Todo", "In Progress", "E2E Testing & QA", "Ready for Documentation", "Done", "Needs Attention", "Cancelled"];
|
|
10
|
+
function executionReadiness(manifest) {
|
|
11
|
+
const execution = manifest.execution;
|
|
12
|
+
if (!execution || execution.mode === "legacy") return [];
|
|
13
|
+
const errors = [];
|
|
14
|
+
if (!["shadow", "managed"].includes(execution.mode)) errors.push("execution.mode must be legacy, shadow or managed");
|
|
15
|
+
if (!manifest.project_id) errors.push("canonical project_id missing");
|
|
16
|
+
if (manifest.ticket_provider?.type !== "plane" || !manifest.ticket_provider.workspace || !manifest.ticket_provider.board_id) errors.push("exact Plane binding missing");
|
|
17
|
+
if (execution.policy_version !== 2 || !execution.skill_version) errors.push("execution policy or skill pin missing");
|
|
18
|
+
for (const lane of EXECUTION_LANES) if (!execution.states?.[lane]) errors.push(`lane binding missing: ${lane}`);
|
|
19
|
+
for (const pin of ["pilot_bundle_sha256", "momo_bundle_sha256"]) if (!/^[a-f0-9]{64}$/.test(execution[pin] ?? "")) errors.push(`${pin} missing`);
|
|
20
|
+
if (!execution.working_label) errors.push("working label binding missing");
|
|
21
|
+
if (!execution.actors?.[execution.pm_actor] || execution.actors[execution.pm_actor].role !== "pm") errors.push("PM actor enrollment missing");
|
|
22
|
+
if (!execution.actors?.[execution.controller_actor] || execution.actors[execution.controller_actor].role !== "operator") errors.push("controller repair actor enrollment missing");
|
|
23
|
+
const ids = /* @__PURE__ */ new Set();
|
|
24
|
+
for (const [name, actor] of Object.entries(execution.actors ?? {})) {
|
|
25
|
+
if (!actor.native_user_id || !actor.key_ref?.startsWith("op://") || !actor.runtime_id) errors.push(`actor ${name}: native identity, op reference and runtime required`);
|
|
26
|
+
if (ids.has(actor.native_user_id)) errors.push(`actor ${name}: duplicate native identity`);
|
|
27
|
+
ids.add(actor.native_user_id);
|
|
28
|
+
if (!["pm", "operator", "reviewer", "interactive"].includes(actor.role)) errors.push(`actor ${name}: invalid role`);
|
|
29
|
+
if (name === execution.pm_actor && (!Array.isArray(actor.runtime?.planner_argv) || !actor.runtime.planner_argv.length || actor.runtime.planner_argv.some((x) => typeof x !== "string" || !x || x.includes("\0")))) errors.push("PM planner argv missing");
|
|
30
|
+
if (actor.runtime?.adapter !== "systemd" || !/^[a-zA-Z0-9_-]+$/.test(actor.runtime?.unit_prefix ?? "")) errors.push(`actor ${name}: supervised runtime missing`);
|
|
31
|
+
}
|
|
32
|
+
if (execution.legacy_writers_fenced !== true) errors.push("legacy writers not fenced");
|
|
33
|
+
return errors;
|
|
34
|
+
}
|
|
35
|
+
function validateExecutionBinding(manifest) {
|
|
36
|
+
if (!manifest.execution) return;
|
|
37
|
+
if (typeof manifest.execution !== "object" || Array.isArray(manifest.execution)) throw new Error("execution must be an object");
|
|
38
|
+
if (manifest.execution.mode === "managed") {
|
|
39
|
+
const errors = executionReadiness(manifest);
|
|
40
|
+
if (errors.length) throw new Error(`Managed execution not ready: ${errors.join("; ")}`);
|
|
41
|
+
} else if (!["legacy", "shadow"].includes(manifest.execution.mode)) throw new Error("invalid execution mode");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/project/manifestIndex.ts
|
|
45
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
46
|
+
import { constants, lstatSync, readFileSync, realpathSync, renameSync } from "node:fs";
|
|
47
|
+
import { lstat, open, readFile, realpath, unlink } from "node:fs/promises";
|
|
48
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
49
|
+
|
|
50
|
+
// src/project/registryClient.ts
|
|
51
|
+
function normalizeProjectId(value) {
|
|
52
|
+
if (typeof value !== "string") throw new Error("project_id is required");
|
|
53
|
+
const id = value.trim().toLowerCase();
|
|
54
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || id.endsWith("-") || id.length > 128) {
|
|
55
|
+
throw new Error(`Invalid project_id: ${value}; use letters, digits and internal hyphens`);
|
|
56
|
+
}
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/project/notebookSettingsValidation.ts
|
|
61
|
+
import { isIP } from "node:net";
|
|
62
|
+
import { isAbsolute } from "node:path";
|
|
63
|
+
|
|
64
|
+
// src/notebook/types.ts
|
|
65
|
+
var CREDENTIAL_KEY = /(?:^|[_-])(?:password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|credential|authorization)(?:$|[_-])/iu;
|
|
66
|
+
var SECRET_SHAPED_VALUE = /(?:-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9])|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|(?:password|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*[^\s]{8,})/iu;
|
|
67
|
+
function notebookCredentialMaterialPath(value) {
|
|
68
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
69
|
+
let visited = 0;
|
|
70
|
+
const walk = (candidate, path, depth) => {
|
|
71
|
+
if (++visited > 2e3 || depth > 20) return `${path}.[structure-limit]`;
|
|
72
|
+
if (typeof candidate === "string") return SECRET_SHAPED_VALUE.test(candidate) ? path : null;
|
|
73
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
74
|
+
if (seen.has(candidate)) return `${path}.[cycle]`;
|
|
75
|
+
seen.add(candidate);
|
|
76
|
+
if (Array.isArray(candidate)) {
|
|
77
|
+
for (let index = 0; index < candidate.length; index++) {
|
|
78
|
+
const found = walk(candidate[index], `${path}[${index}]`, depth + 1);
|
|
79
|
+
if (found) return found;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
for (const [key, child] of Object.entries(candidate)) {
|
|
84
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
85
|
+
if (CREDENTIAL_KEY.test(key)) return childPath;
|
|
86
|
+
const found = walk(child, childPath, depth + 1);
|
|
87
|
+
if (found) return found;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
return walk(value, "notebook", 0);
|
|
92
|
+
}
|
|
93
|
+
var DEFAULT_NOTEBOOK_LIMITS = Object.freeze({
|
|
94
|
+
schema_version: 1,
|
|
95
|
+
overview_max_chars: 4e3,
|
|
96
|
+
request_max_bytes: 1048576,
|
|
97
|
+
response_max_bytes: 4194304,
|
|
98
|
+
note_max_bytes: 1048576,
|
|
99
|
+
source_file_max_bytes: 524288,
|
|
100
|
+
list_max_items: 1e3,
|
|
101
|
+
note_detail_fetch_concurrency: 8,
|
|
102
|
+
excerpt_max_chars: 320,
|
|
103
|
+
diagnostic_max_chars: 512,
|
|
104
|
+
overall_timeout_ms: 5e3,
|
|
105
|
+
hook_session_start_timeout_ms: 4e3,
|
|
106
|
+
hook_session_end_timeout_ms: 250,
|
|
107
|
+
hook_payload_max_bytes: 1048576,
|
|
108
|
+
receipt_succeeded_retention_days: 30,
|
|
109
|
+
receiptless_session_retention_seconds: 86400,
|
|
110
|
+
unresolved_receipt_max_count: 100,
|
|
111
|
+
unresolved_receipt_max_bytes: 8388608,
|
|
112
|
+
receipt_max_bytes: 131072,
|
|
113
|
+
automatic_attempt_limit: 2,
|
|
114
|
+
lease_seconds: 300,
|
|
115
|
+
integrity_max_entries: 20,
|
|
116
|
+
refusal_max_entries: 100
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// src/project/notebookSettingsValidation.ts
|
|
120
|
+
function isRecord(value) {
|
|
121
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
function validateGlobalNotebookConfig(value) {
|
|
124
|
+
if (value === void 0) return;
|
|
125
|
+
if (!isRecord(value)) throw new Error("Project registry notebook must be a mapping");
|
|
126
|
+
const credentialPath = notebookCredentialMaterialPath(value);
|
|
127
|
+
if (credentialPath) throw new Error(`Project registry Notebook configuration contains forbidden credential material at ${credentialPath}`);
|
|
128
|
+
if (value.base_url !== void 0) {
|
|
129
|
+
if (typeof value.base_url !== "string" || !value.base_url.trim()) throw new Error("Project registry notebook.base_url must be a nonempty URL");
|
|
130
|
+
let url;
|
|
131
|
+
try {
|
|
132
|
+
url = new URL(value.base_url);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error("Project registry notebook.base_url must be an absolute URL");
|
|
135
|
+
}
|
|
136
|
+
if (url.username || url.password || url.search || url.hash) throw new Error("Project registry notebook.base_url may not contain credentials, query, or fragment");
|
|
137
|
+
const hostname = url.hostname.toLowerCase();
|
|
138
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
139
|
+
const loopback = hostname === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/u.test(host);
|
|
140
|
+
if (isIP(host) !== 0 && !loopback) throw new Error("Project registry notebook.base_url may not use a numeric non-loopback host");
|
|
141
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new Error("Project registry notebook.base_url must use HTTPS or loopback HTTP");
|
|
142
|
+
}
|
|
143
|
+
if (value.auth !== void 0) {
|
|
144
|
+
if (!isRecord(value.auth) || value.auth.mode !== "none" && value.auth.mode !== "environment") throw new Error("Project registry notebook.auth is invalid");
|
|
145
|
+
if (value.auth.mode === "environment" && value.auth.env_var !== "OPEN_NOTEBOOK_PASSWORD") throw new Error("Project registry notebook.auth.env_var must be OPEN_NOTEBOOK_PASSWORD");
|
|
146
|
+
if (value.auth.mode === "none" && value.auth.env_var !== void 0) throw new Error("Project registry notebook.auth none mode may not name a credential variable");
|
|
147
|
+
}
|
|
148
|
+
const boundedList = (candidate, name) => {
|
|
149
|
+
if (!Array.isArray(candidate) || candidate.length > 100 || candidate.some((entry) => typeof entry !== "string" || !entry || Buffer.byteLength(entry, "utf8") > 512)) throw new Error(`Project registry notebook.defaults.${name} must be a bounded string list`);
|
|
150
|
+
};
|
|
151
|
+
if (value.defaults !== void 0) {
|
|
152
|
+
if (!isRecord(value.defaults)) throw new Error("Project registry notebook.defaults must be a mapping");
|
|
153
|
+
for (const key of ["enabled", "session_start_enabled", "session_capture_enabled"]) {
|
|
154
|
+
if (value.defaults[key] !== void 0 && typeof value.defaults[key] !== "boolean") throw new Error(`Project registry notebook.defaults.${key} must be boolean`);
|
|
155
|
+
}
|
|
156
|
+
if (value.defaults.overview_max_chars !== void 0 && (!Number.isSafeInteger(value.defaults.overview_max_chars) || Number(value.defaults.overview_max_chars) <= 0)) throw new Error("Project registry notebook.defaults.overview_max_chars must be a positive integer");
|
|
157
|
+
for (const key of ["documentation_globs", "overview_references", "excluded_globs"]) if (value.defaults[key] !== void 0) boundedList(value.defaults[key], key);
|
|
158
|
+
}
|
|
159
|
+
const limits = { ...DEFAULT_NOTEBOOK_LIMITS };
|
|
160
|
+
if (value.limits !== void 0) {
|
|
161
|
+
if (!isRecord(value.limits)) throw new Error("Project registry notebook.limits must be a mapping");
|
|
162
|
+
for (const key of Object.keys(DEFAULT_NOTEBOOK_LIMITS)) {
|
|
163
|
+
const configured = value.limits[key];
|
|
164
|
+
if (configured === void 0) continue;
|
|
165
|
+
if (!Number.isSafeInteger(configured) || Number(configured) <= 0) throw new Error(`Project registry notebook.limits.${key} must be a positive integer`);
|
|
166
|
+
limits[key] = Number(configured);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (limits.schema_version !== 1) throw new Error("Project registry notebook.limits.schema_version must be 1");
|
|
170
|
+
if (limits.receipt_max_bytes > limits.unresolved_receipt_max_bytes) throw new Error("Project registry notebook receipt_max_bytes may not exceed unresolved_receipt_max_bytes");
|
|
171
|
+
if (limits.hook_payload_max_bytes > DEFAULT_NOTEBOOK_LIMITS.hook_payload_max_bytes) throw new Error("Project registry notebook hook_payload_max_bytes exceeds the packaged ceiling");
|
|
172
|
+
if (limits.note_detail_fetch_concurrency > DEFAULT_NOTEBOOK_LIMITS.note_detail_fetch_concurrency) throw new Error("Project registry notebook note_detail_fetch_concurrency exceeds the packaged ceiling");
|
|
173
|
+
if (limits.lease_seconds * 1e3 <= limits.overall_timeout_ms) throw new Error("Project registry notebook lease_seconds must exceed one request timeout");
|
|
174
|
+
if (isRecord(value.defaults) && value.defaults.overview_max_chars !== void 0 && Number(value.defaults.overview_max_chars) > limits.note_max_bytes) throw new Error("Project registry notebook overview_max_chars exceeds note_max_bytes");
|
|
175
|
+
if (value.summarizer !== void 0) {
|
|
176
|
+
if (!isRecord(value.summarizer) || typeof value.summarizer.executable !== "string" || !isAbsolute(value.summarizer.executable) || value.summarizer.executable.includes("\0") || Buffer.byteLength(value.summarizer.executable, "utf8") > 1024) throw new Error("Project registry notebook.summarizer executable must be a bounded absolute path");
|
|
177
|
+
if (value.summarizer.args !== void 0 && (!Array.isArray(value.summarizer.args) || value.summarizer.args.length > 32 || value.summarizer.args.some((entry) => typeof entry !== "string" || entry.includes("\0") || Buffer.byteLength(entry, "utf8") > 1024))) throw new Error("Project registry notebook.summarizer args must be bounded strings");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/project/manifestIndex.ts
|
|
182
|
+
var RegistryError = class extends Error {
|
|
183
|
+
constructor(message, status = 422, code = "invalid_manifest") {
|
|
184
|
+
super(message);
|
|
185
|
+
this.status = status;
|
|
186
|
+
this.code = code;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
function normalizeProjectId2(value) {
|
|
190
|
+
try {
|
|
191
|
+
return normalizeProjectId(value);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new RegistryError(error instanceof Error ? error.message : String(error));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function object(value) {
|
|
197
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
198
|
+
}
|
|
199
|
+
function stable(value) {
|
|
200
|
+
return JSON.stringify(value, (_key, v) => object(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v);
|
|
201
|
+
}
|
|
202
|
+
function equal(a, b) {
|
|
203
|
+
return stable(a) === stable(b);
|
|
204
|
+
}
|
|
205
|
+
function mergeMissing(current, fallback) {
|
|
206
|
+
if (current === void 0) return fallback;
|
|
207
|
+
if (!object(current) || !object(fallback)) return current;
|
|
208
|
+
return Object.fromEntries([.../* @__PURE__ */ new Set([...Object.keys(current), ...Object.keys(fallback)])].map((key) => [key, mergeMissing(current[key], fallback[key])]));
|
|
209
|
+
}
|
|
210
|
+
function hash(data) {
|
|
211
|
+
return createHash("sha256").update(data).digest("hex");
|
|
212
|
+
}
|
|
213
|
+
function validateManifestFields(manifest, path) {
|
|
214
|
+
validateExecutionBinding(manifest);
|
|
215
|
+
const fail = (field, shape) => {
|
|
216
|
+
throw new RegistryError(`Manifest ${path}: ${field} must be ${shape}`);
|
|
217
|
+
};
|
|
218
|
+
const strings = (value, names, prefix = "") => {
|
|
219
|
+
for (const name of names) if (value[name] !== void 0 && typeof value[name] !== "string") fail(`${prefix}${name}`, "a string");
|
|
220
|
+
};
|
|
221
|
+
strings(manifest, ["project_name", "name", "project_description", "description", "status", "repo_path", "created_at", "updated_at"]);
|
|
222
|
+
for (const field of ["ticket_provider", "agents", "template", "notebook", "automation"]) if (manifest[field] !== void 0 && !object(manifest[field])) fail(field, "an object");
|
|
223
|
+
if (manifest.source_artifacts !== void 0 && !Array.isArray(manifest.source_artifacts)) fail("source_artifacts", "an array");
|
|
224
|
+
for (const [index, artifact] of (manifest.source_artifacts ?? []).entries()) {
|
|
225
|
+
if (!object(artifact)) fail(`source_artifacts.${index}`, "an object");
|
|
226
|
+
strings(artifact, ["kind", "path", "package_name"], `source_artifacts.${index}.`);
|
|
227
|
+
}
|
|
228
|
+
if (manifest.ticket_provider) strings(manifest.ticket_provider, ["type", "workspace", "identifier", "identifier_source", "identifier_fetched_at", "board_id", "board_confirmed_at", "board_url", "state"], "ticket_provider.");
|
|
229
|
+
for (const [name, agent] of Object.entries(manifest.agents ?? {})) {
|
|
230
|
+
if (!object(agent)) fail(`agents.${name}`, "an object");
|
|
231
|
+
strings(agent, ["role", "role_dir", "provisioning_state"], `agents.${name}.`);
|
|
232
|
+
}
|
|
233
|
+
if (manifest.template?.commonproject !== void 0) {
|
|
234
|
+
if (!object(manifest.template.commonproject)) fail("template.commonproject", "an object");
|
|
235
|
+
const template = manifest.template.commonproject;
|
|
236
|
+
if (template.enabled !== void 0 && typeof template.enabled !== "boolean") fail("template.commonproject.enabled", "boolean");
|
|
237
|
+
strings(template, ["primary_language"], "template.commonproject.");
|
|
238
|
+
}
|
|
239
|
+
for (const field of ["binding", "policy"]) if (manifest.notebook?.[field] !== void 0 && !object(manifest.notebook[field])) fail(`notebook.${field}`, "an object");
|
|
240
|
+
if (manifest.notebook?.binding) strings(manifest.notebook.binding, ["provider", "state", "notebook_name", "blocked_reason", "notebook_id", "overview_note_id", "created_at", "updated_at"], "notebook.binding.");
|
|
241
|
+
if (manifest.notebook?.policy) {
|
|
242
|
+
try {
|
|
243
|
+
validateGlobalNotebookConfig({ defaults: manifest.notebook.policy });
|
|
244
|
+
} catch (error) {
|
|
245
|
+
throw new RegistryError(`Manifest ${path}: invalid notebook policy: ${String(error)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function serializedManifest(manifest) {
|
|
250
|
+
const serialized = `${JSON.stringify(manifest, null, 2)}
|
|
251
|
+
`;
|
|
252
|
+
if (Buffer.byteLength(serialized) > 2 * 1024 * 1024) throw new RegistryError("Serialized manifest exceeds 2 MiB; no manifest was written", 413, "manifest_too_large");
|
|
253
|
+
return serialized;
|
|
254
|
+
}
|
|
255
|
+
async function readManifest(path) {
|
|
256
|
+
path = resolve(path);
|
|
257
|
+
const stat = await lstat(path);
|
|
258
|
+
if (!stat.isFile() || stat.isSymbolicLink() || basename(path) !== ".project.json") {
|
|
259
|
+
throw new RegistryError(`Expected a regular .project.json file: ${path}`);
|
|
260
|
+
}
|
|
261
|
+
path = await realpath(path);
|
|
262
|
+
const raw = await readFile(path, "utf8");
|
|
263
|
+
if (Buffer.byteLength(raw) > 2 * 1024 * 1024) throw new RegistryError(`Manifest exceeds 2 MiB: ${path}`);
|
|
264
|
+
let manifest;
|
|
265
|
+
try {
|
|
266
|
+
manifest = JSON.parse(raw);
|
|
267
|
+
} catch {
|
|
268
|
+
throw new RegistryError(`Malformed JSON: ${path}`);
|
|
269
|
+
}
|
|
270
|
+
if (!object(manifest)) throw new RegistryError(`Expected a JSON object: ${path}`);
|
|
271
|
+
validateManifestFields(manifest, path);
|
|
272
|
+
const id = normalizeProjectId2(manifest.project_id ?? manifest.project_slug);
|
|
273
|
+
if (manifest.project_id && manifest.project_slug && normalizeProjectId2(manifest.project_slug) !== id) {
|
|
274
|
+
throw new RegistryError(`Conflicting project_id and project_slug: ${path}`, 409, "identity_conflict");
|
|
275
|
+
}
|
|
276
|
+
return { path, raw, manifest, id, hash: hash(raw), mode: stat.mode & 511 };
|
|
277
|
+
}
|
|
278
|
+
function recordFromManifest(manifest, path) {
|
|
279
|
+
const id = normalizeProjectId2(manifest.project_id ?? manifest.project_slug);
|
|
280
|
+
const { project_slug: _old, project_name: _name, project_description: _description, notebook, ...rest } = manifest;
|
|
281
|
+
return {
|
|
282
|
+
...rest,
|
|
283
|
+
project_id: id,
|
|
284
|
+
slug: id,
|
|
285
|
+
name: manifest.project_name ?? manifest.name ?? id,
|
|
286
|
+
description: manifest.project_description ?? manifest.description ?? "",
|
|
287
|
+
repo_path: dirname(path),
|
|
288
|
+
status: manifest.status ?? "active",
|
|
289
|
+
source_artifacts: manifest.source_artifacts ?? [],
|
|
290
|
+
template: manifest.template ?? { commonproject: { enabled: true, primary_language: "typescript" } },
|
|
291
|
+
ticket_provider: manifest.ticket_provider ?? { type: "none", state: "skipped" },
|
|
292
|
+
agents: manifest.agents ?? {},
|
|
293
|
+
...notebook?.binding ? { notebook: notebook.binding } : {},
|
|
294
|
+
...notebook?.policy ? { notebook_policy: notebook.policy } : {},
|
|
295
|
+
created_at: manifest.created_at ?? "",
|
|
296
|
+
updated_at: manifest.updated_at ?? ""
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
async function writeManifest(original, manifest) {
|
|
300
|
+
validateManifestFields(manifest, original.path);
|
|
301
|
+
const serialized = serializedManifest(manifest);
|
|
302
|
+
const temp = join(dirname(original.path), `.project.json.${randomUUID()}.tmp`);
|
|
303
|
+
let committed = false;
|
|
304
|
+
try {
|
|
305
|
+
const handle = await open(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, original.mode);
|
|
306
|
+
try {
|
|
307
|
+
await handle.writeFile(serialized);
|
|
308
|
+
await handle.sync();
|
|
309
|
+
} finally {
|
|
310
|
+
await handle.close();
|
|
311
|
+
}
|
|
312
|
+
const stat = lstatSync(original.path);
|
|
313
|
+
if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(original.path) !== original.path) throw new RegistryError(`Manifest path changed: ${original.path}`, 409, "stale_manifest");
|
|
314
|
+
const currentHash = hash(readFileSync(original.path, "utf8"));
|
|
315
|
+
if (currentHash !== original.hash) throw new RegistryError(`Manifest changed while saving ${original.path}; reload and retry`, 409, "stale_manifest");
|
|
316
|
+
renameSync(temp, original.path);
|
|
317
|
+
committed = true;
|
|
318
|
+
const dir = await open(dirname(original.path), constants.O_RDONLY);
|
|
319
|
+
try {
|
|
320
|
+
await dir.sync();
|
|
321
|
+
} finally {
|
|
322
|
+
await dir.close();
|
|
323
|
+
}
|
|
324
|
+
return await readManifest(original.path);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (committed) throw new RegistryError(`Manifest committed at ${original.path}, but indexing is stale. Run pj reindex --all. ${String(error)}`, 503, "index_stale");
|
|
327
|
+
throw error;
|
|
328
|
+
} finally {
|
|
329
|
+
await unlink(temp).catch(() => {
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function mergeChanges(base, desired, current, path) {
|
|
334
|
+
if (equal(base, desired) || equal(current, desired)) return current;
|
|
335
|
+
if (equal(current, base)) return desired;
|
|
336
|
+
if (object(base) && object(desired) && object(current)) {
|
|
337
|
+
const merged = { ...current };
|
|
338
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(desired)])) {
|
|
339
|
+
const value = mergeChanges(base[key], desired[key], current[key], `${path}.${key}`);
|
|
340
|
+
if (value === void 0) delete merged[key];
|
|
341
|
+
else Object.defineProperty(merged, key, { value, writable: true, enumerable: true, configurable: true });
|
|
342
|
+
}
|
|
343
|
+
return merged;
|
|
344
|
+
}
|
|
345
|
+
throw new RegistryError(`Concurrent manifest change conflicts at ${path}; reload the registry and retry`, 409, "stale_manifest");
|
|
346
|
+
}
|
|
347
|
+
function applyRecord(manifest, current, next) {
|
|
348
|
+
const result = structuredClone(manifest);
|
|
349
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(current), ...Object.keys(next)])) {
|
|
350
|
+
if (["slug", "project_id", "repo_path", "index_status", "index_error"].includes(key) || key.startsWith("__registry_") || equal(current[key], next[key])) continue;
|
|
351
|
+
const target = key === "name" ? "project_name" : key === "description" ? "project_description" : key;
|
|
352
|
+
if (key === "notebook" || key === "notebook_policy") {
|
|
353
|
+
const part = key === "notebook" ? "binding" : "policy";
|
|
354
|
+
result.notebook = { ...object(result.notebook) ? result.notebook : {} };
|
|
355
|
+
if (next[key] === void 0) delete result.notebook[part];
|
|
356
|
+
else result.notebook[part] = next[key];
|
|
357
|
+
} else if (next[key] === void 0) delete result[target];
|
|
358
|
+
else Object.defineProperty(result, target, { value: next[key], enumerable: true, writable: true, configurable: true });
|
|
359
|
+
}
|
|
360
|
+
result.project_id = normalizeProjectId2(next.project_id ?? next.slug);
|
|
361
|
+
delete result.project_slug;
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
var ManifestIndex = class {
|
|
365
|
+
constructor(pool) {
|
|
366
|
+
this.pool = pool;
|
|
367
|
+
}
|
|
368
|
+
queue = Promise.resolve();
|
|
369
|
+
committed = /* @__PURE__ */ new WeakMap();
|
|
370
|
+
async initialize() {
|
|
371
|
+
const client = await this.pool.connect();
|
|
372
|
+
try {
|
|
373
|
+
await client.query("BEGIN");
|
|
374
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtext('pjangler_index.manifests'))");
|
|
375
|
+
await client.query(`CREATE EXTENSION IF NOT EXISTS citext;
|
|
376
|
+
CREATE SCHEMA IF NOT EXISTS pjangler_index;
|
|
377
|
+
CREATE TABLE IF NOT EXISTS pjangler_index.projects (
|
|
378
|
+
project_id citext PRIMARY KEY,
|
|
379
|
+
manifest_path text UNIQUE NOT NULL, snapshot jsonb NOT NULL,
|
|
380
|
+
content_hash text NOT NULL, indexed_at timestamptz NOT NULL DEFAULT now(),
|
|
381
|
+
status text NOT NULL DEFAULT 'ok', error text
|
|
382
|
+
);
|
|
383
|
+
ALTER TABLE pjangler_index.projects DROP CONSTRAINT IF EXISTS projects_project_id_check;
|
|
384
|
+
ALTER TABLE pjangler_index.projects DROP CONSTRAINT IF EXISTS project_id_canonical;
|
|
385
|
+
DO $$ BEGIN
|
|
386
|
+
IF EXISTS (SELECT 1 FROM pg_attribute
|
|
387
|
+
WHERE attrelid='pjangler_index.projects'::regclass AND attname='project_id'
|
|
388
|
+
AND atttypid <> 'citext'::regtype) THEN
|
|
389
|
+
ALTER TABLE pjangler_index.projects ALTER COLUMN project_id TYPE citext USING project_id::citext;
|
|
390
|
+
END IF;
|
|
391
|
+
END $$;
|
|
392
|
+
ALTER TABLE pjangler_index.projects ADD CONSTRAINT project_id_canonical
|
|
393
|
+
CHECK (project_id::text ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' AND length(project_id::text) <= 128);
|
|
394
|
+
CREATE TABLE IF NOT EXISTS pjangler_index.settings (key text PRIMARY KEY, value jsonb NOT NULL);`);
|
|
395
|
+
await client.query("COMMIT");
|
|
396
|
+
} catch (error) {
|
|
397
|
+
await client.query("ROLLBACK").catch(() => {
|
|
398
|
+
});
|
|
399
|
+
throw error;
|
|
400
|
+
} finally {
|
|
401
|
+
client.release();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
serialized(fn) {
|
|
405
|
+
const run = this.queue.then(async () => {
|
|
406
|
+
const client = await this.pool.connect();
|
|
407
|
+
try {
|
|
408
|
+
await client.query("BEGIN");
|
|
409
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtext('pjangler_index.manifests'))");
|
|
410
|
+
const result = await fn(client);
|
|
411
|
+
await client.query("COMMIT");
|
|
412
|
+
return result;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
await client.query("ROLLBACK").catch(() => {
|
|
415
|
+
});
|
|
416
|
+
const path = this.committed.get(client);
|
|
417
|
+
if (path && !(error instanceof RegistryError && error.code === "index_stale")) throw new RegistryError(`Manifest committed at ${path}; index is stale. Retry the same request or reindex. ${String(error)}`, 503, "index_stale");
|
|
418
|
+
throw error;
|
|
419
|
+
} finally {
|
|
420
|
+
this.committed.delete(client);
|
|
421
|
+
client.release();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
this.queue = run.catch(() => {
|
|
425
|
+
});
|
|
426
|
+
return run;
|
|
427
|
+
}
|
|
428
|
+
async registrationRows(client, read) {
|
|
429
|
+
const result = await client.query("SELECT project_id,manifest_path FROM pjangler_index.projects WHERE project_id=$1 OR manifest_path=$2", [read.id, read.path]);
|
|
430
|
+
for (const row of result.rows) {
|
|
431
|
+
if (row.manifest_path === read.path) continue;
|
|
432
|
+
let missing = false;
|
|
433
|
+
try {
|
|
434
|
+
await lstat(row.manifest_path);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
missing = ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
|
|
437
|
+
}
|
|
438
|
+
if (!missing) throw new RegistryError(`project_id ${read.id} is already registered at ${row.manifest_path}; its canonical manifest must be missing before relocation`, 409, "identity_collision");
|
|
439
|
+
}
|
|
440
|
+
return result.rows;
|
|
441
|
+
}
|
|
442
|
+
async persist(client, read, oldId) {
|
|
443
|
+
await this.registrationRows(client, read);
|
|
444
|
+
if (oldId && oldId !== read.id) await client.query("DELETE FROM pjangler_index.projects WHERE project_id=$1 AND manifest_path=$2", [oldId, read.path]);
|
|
445
|
+
await client.query(`INSERT INTO pjangler_index.projects(project_id,manifest_path,snapshot,content_hash) VALUES($1,$2,$3,$4)
|
|
446
|
+
ON CONFLICT(project_id) DO UPDATE SET manifest_path=excluded.manifest_path,snapshot=excluded.snapshot,
|
|
447
|
+
content_hash=excluded.content_hash,indexed_at=now(),status='ok',error=NULL`, [read.id, read.path, JSON.stringify(recordFromManifest(read.manifest, read.path)), read.hash]);
|
|
448
|
+
}
|
|
449
|
+
async register(client, path) {
|
|
450
|
+
let read = await readManifest(path);
|
|
451
|
+
const collision = await this.registrationRows(client, read);
|
|
452
|
+
const manifest = { ...read.manifest, project_id: read.id };
|
|
453
|
+
delete manifest.project_slug;
|
|
454
|
+
let committed = false;
|
|
455
|
+
try {
|
|
456
|
+
if (!equal(manifest, read.manifest)) {
|
|
457
|
+
read = await writeManifest(read, manifest);
|
|
458
|
+
committed = true;
|
|
459
|
+
this.committed.set(client, read.path);
|
|
460
|
+
}
|
|
461
|
+
await this.persist(client, read, collision.find((r) => r.manifest_path === read.path)?.project_id);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (committed) throw new RegistryError(`Manifest saved at ${read.path}; index is stale. Retry registration or reindex. ${String(error)}`, 503, "index_stale");
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async refresh(client) {
|
|
468
|
+
const rows = (await client.query("SELECT * FROM pjangler_index.projects ORDER BY project_id")).rows;
|
|
469
|
+
for (const row of rows) {
|
|
470
|
+
try {
|
|
471
|
+
const read = await readManifest(row.manifest_path);
|
|
472
|
+
if (read.hash !== row.content_hash || row.status !== "ok" || !equal(row.snapshot, recordFromManifest(read.manifest, read.path))) await this.persist(client, read, row.project_id);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (!(error instanceof RegistryError) && !["ENOENT", "ENOTDIR", "ELOOP", "EACCES", "EPERM"].includes(error.code ?? "")) throw error;
|
|
475
|
+
await client.query("UPDATE pjangler_index.projects SET status=$2,error=$3 WHERE project_id=$1", [row.project_id, ["ENOENT", "ENOTDIR"].includes(error.code ?? "") ? "missing" : "invalid", String(error)]);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async snapshot(client) {
|
|
480
|
+
const rows = (await client.query("SELECT * FROM pjangler_index.projects ORDER BY project_id")).rows;
|
|
481
|
+
const settings = (await client.query("SELECT key,value FROM pjangler_index.settings")).rows;
|
|
482
|
+
const projects = /* @__PURE__ */ Object.create(null), hashes = /* @__PURE__ */ Object.create(null), statuses = /* @__PURE__ */ Object.create(null), baseline = /* @__PURE__ */ Object.create(null);
|
|
483
|
+
for (const row of rows) {
|
|
484
|
+
projects[row.project_id] = row.snapshot;
|
|
485
|
+
hashes[row.project_id] = row.content_hash;
|
|
486
|
+
baseline[row.project_id] = structuredClone(row.snapshot);
|
|
487
|
+
statuses[row.project_id] = { status: row.status, error: row.error, manifest_path: row.manifest_path, content_hash: row.content_hash, indexed_at: row.indexed_at };
|
|
488
|
+
}
|
|
489
|
+
const global = Object.fromEntries(settings.map((row) => [row.key, row.value]));
|
|
490
|
+
return { ...global, schema_version: 1, projects, __registry_hashes: hashes, __registry_baseline: baseline, __registry_status: statuses, __registry_settings_baseline: structuredClone(global) };
|
|
491
|
+
}
|
|
492
|
+
load() {
|
|
493
|
+
return this.serialized(async (client) => {
|
|
494
|
+
await this.refresh(client);
|
|
495
|
+
return this.snapshot(client);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
index(path) {
|
|
499
|
+
return this.serialized(async (client) => {
|
|
500
|
+
await this.register(client, path);
|
|
501
|
+
await this.refresh(client);
|
|
502
|
+
return this.snapshot(client);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
rebuild(paths) {
|
|
506
|
+
return this.serialized(async (client) => {
|
|
507
|
+
const discovered = /* @__PURE__ */ new Map();
|
|
508
|
+
for (const path of paths) {
|
|
509
|
+
const read = await readManifest(path);
|
|
510
|
+
if (discovered.has(read.id) && discovered.get(read.id) !== read.path) throw new RegistryError(`Duplicate project_id ${read.id} in rebuild paths`, 409, "identity_collision");
|
|
511
|
+
discovered.set(read.id, read.path);
|
|
512
|
+
await this.registrationRows(client, read);
|
|
513
|
+
}
|
|
514
|
+
for (const path of discovered.values()) await this.register(client, path);
|
|
515
|
+
await this.refresh(client);
|
|
516
|
+
return this.snapshot(client);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
remove(id) {
|
|
520
|
+
return this.serialized(async (client) => {
|
|
521
|
+
await client.query("DELETE FROM pjangler_index.projects WHERE project_id=$1", [normalizeProjectId2(id)]);
|
|
522
|
+
return this.snapshot(client);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
settings(settings) {
|
|
526
|
+
return this.serialized(async (client) => {
|
|
527
|
+
await this.saveSettings(client, settings);
|
|
528
|
+
return this.snapshot(client);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
validateSettings(settings) {
|
|
532
|
+
try {
|
|
533
|
+
validateGlobalNotebookConfig(settings.notebook);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
throw new RegistryError(error instanceof Error ? error.message : String(error), 422, "invalid_settings");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async saveSettings(client, settings) {
|
|
539
|
+
this.validateSettings(settings);
|
|
540
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
541
|
+
if (["schema_version", "projects"].includes(key) || key.startsWith("__registry_")) continue;
|
|
542
|
+
if (value === void 0) {
|
|
543
|
+
await client.query("DELETE FROM pjangler_index.settings WHERE key=$1", [key]);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
await client.query("INSERT INTO pjangler_index.settings(key,value) VALUES($1,$2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [key, JSON.stringify(value)]);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
save(registry) {
|
|
550
|
+
if (!object(registry.projects)) return Promise.reject(new RegistryError("Expected registry.projects object"));
|
|
551
|
+
return this.serialized(async (client) => {
|
|
552
|
+
await this.refresh(client);
|
|
553
|
+
const rows = (await client.query("SELECT * FROM pjangler_index.projects")).rows;
|
|
554
|
+
const writes = [];
|
|
555
|
+
const ids = /* @__PURE__ */ new Set();
|
|
556
|
+
for (const [key, desiredInput] of Object.entries(registry.projects)) {
|
|
557
|
+
if (!object(desiredInput)) throw new RegistryError(`Invalid project record ${key}`);
|
|
558
|
+
const id = normalizeProjectId2(desiredInput.project_id ?? desiredInput.slug ?? key);
|
|
559
|
+
if (normalizeProjectId2(key) !== id || desiredInput.slug && normalizeProjectId2(desiredInput.slug) !== id) throw new RegistryError(`Identity aliases disagree for ${key}`, 409, "identity_conflict");
|
|
560
|
+
if (ids.has(id)) throw new RegistryError(`Duplicate normalized project_id ${id}`, 409, "identity_collision");
|
|
561
|
+
ids.add(id);
|
|
562
|
+
const desired = { ...desiredInput, project_id: id, slug: id };
|
|
563
|
+
const row = rows.find((r) => r.project_id === id);
|
|
564
|
+
if (object(registry.__registry_baseline?.[id])) {
|
|
565
|
+
if (equal(registry.__registry_baseline[id], desired)) continue;
|
|
566
|
+
if (!row) throw new RegistryError(`Project ${id} was removed after this snapshot; reload before explicitly registering it`, 409, "stale_manifest");
|
|
567
|
+
}
|
|
568
|
+
const path = row?.manifest_path ?? join(String(desired.repo_path ?? ""), ".project.json");
|
|
569
|
+
const read = await readManifest(path);
|
|
570
|
+
if (read.id !== id) throw new RegistryError(`Manifest at ${path} declares ${read.id}, not ${id}`, 409, "identity_conflict");
|
|
571
|
+
const current = recordFromManifest(read.manifest, read.path);
|
|
572
|
+
const suppliedBase = registry.__registry_baseline?.[id];
|
|
573
|
+
const base = object(suppliedBase) ? suppliedBase : row && registry.__registry_hashes?.[id] === row.content_hash ? row.snapshot : void 0;
|
|
574
|
+
let next;
|
|
575
|
+
if (!row) {
|
|
576
|
+
next = { ...desired, ...current };
|
|
577
|
+
for (const [field, value] of Object.entries(desired)) {
|
|
578
|
+
const manifestKey = field === "name" ? "project_name" : field === "description" ? "project_description" : field;
|
|
579
|
+
if (field === "notebook" ? read.manifest.notebook?.binding === void 0 : field === "notebook_policy" ? read.manifest.notebook?.policy === void 0 : read.manifest[manifestKey] === void 0) next[field] = value;
|
|
580
|
+
else next[field] = mergeMissing(current[field], value);
|
|
581
|
+
}
|
|
582
|
+
} else if (base) next = mergeChanges(base, desired, current, id);
|
|
583
|
+
else if (equal(current, desired)) next = current;
|
|
584
|
+
else throw new RegistryError(`Missing concurrency baseline for ${id}; reload the registry and retry`, 409, "stale_manifest");
|
|
585
|
+
if (next.repo_path !== current.repo_path) throw new RegistryError(`Cannot move ${id} through a registry snapshot; register its canonical manifest path`, 409, "identity_conflict");
|
|
586
|
+
const manifest = applyRecord(read.manifest, current, next);
|
|
587
|
+
validateManifestFields(manifest, path);
|
|
588
|
+
serializedManifest(manifest);
|
|
589
|
+
writes.push({ read, manifest, oldId: row?.project_id });
|
|
590
|
+
}
|
|
591
|
+
let settings = Object.fromEntries(Object.entries(registry).filter(([key]) => !["schema_version", "projects"].includes(key) && !key.startsWith("__registry_")));
|
|
592
|
+
if (object(registry.__registry_settings_baseline)) {
|
|
593
|
+
const currentSettings = Object.fromEntries((await client.query("SELECT key,value FROM pjangler_index.settings")).rows.map((row) => [row.key, row.value]));
|
|
594
|
+
settings = Object.fromEntries([.../* @__PURE__ */ new Set([...Object.keys(registry.__registry_settings_baseline), ...Object.keys(settings)])].map((key) => [key, mergeChanges(registry.__registry_settings_baseline[key], settings[key], currentSettings[key], `settings.${key}`)]));
|
|
595
|
+
}
|
|
596
|
+
this.validateSettings(settings);
|
|
597
|
+
let committedPath;
|
|
598
|
+
try {
|
|
599
|
+
for (const write of writes) {
|
|
600
|
+
let read = write.read;
|
|
601
|
+
if (!equal(read.manifest, write.manifest)) {
|
|
602
|
+
read = await writeManifest(read, write.manifest);
|
|
603
|
+
committedPath = read.path;
|
|
604
|
+
this.committed.set(client, read.path);
|
|
605
|
+
}
|
|
606
|
+
await this.persist(client, read, write.oldId);
|
|
607
|
+
}
|
|
608
|
+
await this.saveSettings(client, settings);
|
|
609
|
+
return await this.snapshot(client);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (committedPath) throw new RegistryError(`Manifest saved at ${committedPath}; index is stale. Retry the same save or reindex. ${String(error)}`, 503, "index_stale");
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// src/project/registryService.ts
|
|
619
|
+
var MAX_BODY = 8 * 1024 * 1024;
|
|
620
|
+
async function body(request) {
|
|
621
|
+
const chunks = [];
|
|
622
|
+
let size = 0;
|
|
623
|
+
for await (const chunk of request) {
|
|
624
|
+
size += chunk.length;
|
|
625
|
+
if (size > MAX_BODY) throw new RegistryError("Request exceeds 8 MiB", 413, "request_too_large");
|
|
626
|
+
chunks.push(chunk);
|
|
627
|
+
}
|
|
628
|
+
let parsed;
|
|
629
|
+
try {
|
|
630
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
631
|
+
} catch {
|
|
632
|
+
throw new RegistryError("Expected JSON request body", 400, "invalid_request");
|
|
633
|
+
}
|
|
634
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new RegistryError("Expected JSON object", 400, "invalid_request");
|
|
635
|
+
return parsed;
|
|
636
|
+
}
|
|
637
|
+
async function createRegistryService(options = {}) {
|
|
638
|
+
const pool = options.pool ?? new Pool({ database: process.env.PGDATABASE ?? "33god", connectionTimeoutMillis: 5e3, statement_timeout: 15e3 });
|
|
639
|
+
const index = new ManifestIndex(pool);
|
|
640
|
+
try {
|
|
641
|
+
await index.initialize();
|
|
642
|
+
} catch (error) {
|
|
643
|
+
if (!options.pool) await pool.end();
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
const server = createServer(async (request, response) => {
|
|
647
|
+
response.setHeader("content-type", "application/json");
|
|
648
|
+
response.setHeader("cache-control", "no-store");
|
|
649
|
+
try {
|
|
650
|
+
const route = `${request.method} ${new URL(request.url ?? "/", "http://localhost").pathname}`;
|
|
651
|
+
let result;
|
|
652
|
+
switch (route) {
|
|
653
|
+
case "GET /health":
|
|
654
|
+
await pool.query("SELECT 1");
|
|
655
|
+
result = { ok: true, service: "pjangler-project-registry", schema_version: 1 };
|
|
656
|
+
break;
|
|
657
|
+
case "GET /v1/registry":
|
|
658
|
+
result = await index.load();
|
|
659
|
+
break;
|
|
660
|
+
case "POST /v1/reindex":
|
|
661
|
+
result = await index.load();
|
|
662
|
+
break;
|
|
663
|
+
case "POST /v1/rebuild": {
|
|
664
|
+
const payload = await body(request);
|
|
665
|
+
let paths = payload.manifest_paths;
|
|
666
|
+
if (payload.receipt_path !== void 0) {
|
|
667
|
+
if (typeof payload.receipt_path !== "string" || paths !== void 0) throw new RegistryError("Supply either manifest_paths or receipt_path", 400, "invalid_request");
|
|
668
|
+
const raw = await readFile2(payload.receipt_path, "utf8");
|
|
669
|
+
if (Buffer.byteLength(raw) > MAX_BODY) throw new RegistryError("Discovery receipt exceeds 8 MiB", 413, "request_too_large");
|
|
670
|
+
let receipt;
|
|
671
|
+
try {
|
|
672
|
+
receipt = JSON.parse(raw);
|
|
673
|
+
} catch {
|
|
674
|
+
throw new RegistryError("Malformed discovery receipt", 422, "invalid_request");
|
|
675
|
+
}
|
|
676
|
+
if (!receipt || !Array.isArray(receipt.projects)) throw new RegistryError("Discovery receipt must contain projects array", 422, "invalid_request");
|
|
677
|
+
paths = receipt.projects.map((project) => project?.manifest_path);
|
|
678
|
+
}
|
|
679
|
+
if (!Array.isArray(paths) || paths.length > 1e4 || paths.some((path) => typeof path !== "string")) throw new RegistryError("manifest_paths must be an array of at most 10000 paths", 400, "invalid_request");
|
|
680
|
+
result = await index.rebuild(paths);
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
case "POST /v1/index": {
|
|
684
|
+
const payload = await body(request);
|
|
685
|
+
if (typeof payload.manifest_path !== "string") throw new RegistryError("manifest_path is required", 400, "invalid_request");
|
|
686
|
+
result = await index.index(payload.manifest_path);
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case "PUT /v1/registry":
|
|
690
|
+
result = await index.save(await body(request));
|
|
691
|
+
break;
|
|
692
|
+
case "POST /v1/remove":
|
|
693
|
+
result = await index.remove((await body(request)).project_id);
|
|
694
|
+
break;
|
|
695
|
+
case "POST /v1/settings":
|
|
696
|
+
result = await index.settings(await body(request));
|
|
697
|
+
break;
|
|
698
|
+
default:
|
|
699
|
+
throw new RegistryError("Unknown registry endpoint", 404, "not_found");
|
|
700
|
+
}
|
|
701
|
+
response.end(JSON.stringify(result));
|
|
702
|
+
} catch (error) {
|
|
703
|
+
response.statusCode = error instanceof RegistryError ? error.status : 503;
|
|
704
|
+
response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error), code: error instanceof RegistryError ? error.code : "registry_unavailable" }));
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
server.requestTimeout = 3e4;
|
|
708
|
+
server.headersTimeout = 1e4;
|
|
709
|
+
server.keepAliveTimeout = 1e3;
|
|
710
|
+
await new Promise((resolve2, reject) => {
|
|
711
|
+
server.once("error", reject);
|
|
712
|
+
server.listen(options.port ?? Number(process.env.PJ_REGISTRY_PORT ?? 8764), options.host ?? "127.0.0.1", () => {
|
|
713
|
+
server.off("error", reject);
|
|
714
|
+
resolve2();
|
|
715
|
+
});
|
|
716
|
+
}).catch(async (error) => {
|
|
717
|
+
if (!options.pool) await pool.end();
|
|
718
|
+
throw error;
|
|
719
|
+
});
|
|
720
|
+
const address = server.address();
|
|
721
|
+
return {
|
|
722
|
+
server,
|
|
723
|
+
pool,
|
|
724
|
+
index,
|
|
725
|
+
url: `http://127.0.0.1:${typeof address === "object" ? address?.port : options.port}`,
|
|
726
|
+
async close() {
|
|
727
|
+
await new Promise((resolve2, reject) => server.close((error) => error ? reject(error) : resolve2()));
|
|
728
|
+
if (!options.pool) await pool.end();
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
async function startRegistryService() {
|
|
733
|
+
const service = await createRegistryService();
|
|
734
|
+
console.log(`PJangler project registry listening at ${service.url}`);
|
|
735
|
+
const stop = () => {
|
|
736
|
+
void service.close().then(() => process.exit(0), (error) => {
|
|
737
|
+
console.error(error);
|
|
738
|
+
process.exit(1);
|
|
739
|
+
});
|
|
740
|
+
};
|
|
741
|
+
process.once("SIGTERM", stop);
|
|
742
|
+
process.once("SIGINT", stop);
|
|
743
|
+
return service;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// src/project-registry-service.ts
|
|
747
|
+
await startRegistryService();
|
|
748
|
+
//# sourceMappingURL=project-registry-service.js.map
|