records: the record is read where it is written
A module keeping a checkout of a repository of decisions, designs and issues from the git seat, current on every announced merge and on a timer, answering records_search / records_read / records_list / records_status / records_sync at the commit it read (novox/hq ADR 0025, ADR 0153). The repository is a setting; it names no mesh.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# records' runtime: the tool runtime, carrying this module's compiled reader and its tools.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
|
||||
# nothing is copied out of a neighbouring checkout (novox/hq ADR 0069).
|
||||
#
|
||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in
|
||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/records
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc records.ts index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
# **A module may need something the base image does not carry.** The reader keeps a checkout of the
|
||||
# repository it reads (novox/hq ADR 0153) — a git working copy, kept current, not a derived copy — and
|
||||
# the base image has no git. Certificates too, because the origin may be reached over TLS.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/modules/records/dist /app/modules/records/dist
|
||||
# Both entrypoints, loaded in serve mode: the consumer that pulls on a merge, and the tools.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/records/dist/index.js,/app/modules/records/dist/tools/index.js
|
||||
@@ -0,0 +1,41 @@
|
||||
# records
|
||||
|
||||
The record, read where it is written (novox/hq [ADR 0025](https://git.novox.be/novox/hq), ADR 0153).
|
||||
|
||||
A module that keeps a checkout of a repository of decisions, designs and issues — the mesh's own
|
||||
`hq`, or any repository of markdown on the forge — and answers questions about it over the bus, so
|
||||
whoever holds the console sees `records_search` beside every other tool and a symptom can be looked up
|
||||
in the design record without knowing it is there.
|
||||
|
||||
**A checkout, not a copy.** The same bytes the repository holds, at a commit every answer names,
|
||||
brought up to date on every merge the forge announces (`gitea.pull.merged`) and every ten minutes
|
||||
besides. Nothing is indexed, transformed or summarised, so nothing can drift from the source except by
|
||||
lagging behind it, and the lag is in `records_status`.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | answers |
|
||||
|---|---|
|
||||
| `records_search` `{query, limit?}` | where a phrase appears, as written: document, line, nearest heading, and the commit read |
|
||||
| `records_read` `{path}` | one document, whole |
|
||||
| `records_list` `{folder?}` | what a folder holds |
|
||||
| `records_status` | repository, forge, commit and its date, last sync, document count, last error |
|
||||
| `records_sync` | bring the checkout up to date now |
|
||||
|
||||
## Configuring it
|
||||
|
||||
The module names no mesh (ADR 0112). It requires the `git` provision — the forge — and reads the
|
||||
repository its **settings** name:
|
||||
|
||||
```
|
||||
settings set records repository.json # {"repository": "<owner>/<name>"}
|
||||
```
|
||||
|
||||
Public repositories only: it asks for no credential. Until a repository is set, it serves no tools and
|
||||
says so in its log.
|
||||
|
||||
## The check ADR 0025 names
|
||||
|
||||
Search the mesh, through the console, for a phrase that appears only in one design document here, and
|
||||
get it back. `records_search {"query": "…"}` is that search; its test does the same against a
|
||||
repository it makes.
|
||||
@@ -0,0 +1,34 @@
|
||||
// records' consumer: keep the checkout current (novox/hq ADR 0153).
|
||||
//
|
||||
// Synced when the runtime binds the broker, on every merge the forge announces, and on a timer for
|
||||
// the merges it did not hear about — a restart during a merge, a repository the forge does not emit
|
||||
// for. The timer is unhurried: the record changes when thinking changes, not by the minute.
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { recordsFromEnv, type Records } from "./records.js";
|
||||
|
||||
let records: Records | null = null;
|
||||
try {
|
||||
records = await recordsFromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[records] not reading — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (records) {
|
||||
const reader = records;
|
||||
void reader.sync().then(async () => {
|
||||
const s = await reader.standing();
|
||||
console.log(`[records] ${s.repository} at ${s.commit.slice(0, 8) || "(no commit)"}, ${s.documents} document(s)${s.lastError ? ` — ${s.lastError}` : ""}`);
|
||||
});
|
||||
setInterval(() => void reader.sync(), 10 * 60 * 1000).unref();
|
||||
|
||||
// A merge on the forge into the repository this reads: pull now. The event names the repository
|
||||
// by owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
|
||||
await on<{ owner?: string; repo?: string; base?: string }>("gitea.pull.merged", async (event) => {
|
||||
const merged = `${event.body.owner ?? ""}/${event.body.repo ?? ""}`;
|
||||
if (merged !== reader.repository) return;
|
||||
console.log(`[records] ${merged} merged; syncing`);
|
||||
await reader.sync();
|
||||
});
|
||||
}
|
||||
|
||||
export { records };
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"module": "records",
|
||||
"version": "1",
|
||||
"slug": "records",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"git"
|
||||
],
|
||||
"binds": {
|
||||
"git": "/var/lib/mesh/records/git.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/records/broker"
|
||||
},
|
||||
"consumes": [
|
||||
"gitea.pull.merged"
|
||||
],
|
||||
"tools": [
|
||||
"records_search",
|
||||
"records_read",
|
||||
"records_list",
|
||||
"records_status",
|
||||
"records_sync"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/records",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "checkout",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/records",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/records/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "origin",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/records/origin",
|
||||
"mode": "0600",
|
||||
"content": "${bound:git:scheme}://${bound:git:at}:${bound:git:port}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "records",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/records/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/records/config.json:/run/config/config.json:ro",
|
||||
"/var/lib/mesh/records/origin:/run/config/origin:ro",
|
||||
"/var/lib/records:/var/lib/records"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECORDS_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_RECORDS_ORIGIN_FILE": "/run/config/origin",
|
||||
"MESH_RECORDS_DIR": "/var/lib/records"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"config",
|
||||
"origin"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@novox/module-records",
|
||||
"version": "0.1.0",
|
||||
"description": "records — reads a repository of decisions, designs and issues where it is written, and answers questions about it (novox/hq ADR 0025, ADR 0153).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc records.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* The record, read where it is written (novox/hq ADR 0025, ADR 0153).
|
||||
*
|
||||
* A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own
|
||||
* forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit
|
||||
* every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
|
||||
* transformed, indexed or summarised on the way, so there is nothing that can drift from the source
|
||||
* except by lagging behind it, and the lag is a number in every answer.
|
||||
*
|
||||
* What it answers: a search for a phrase, a document by path, what a folder holds, and where the
|
||||
* checkout stands. The reasoning is in the documents; this only finds them.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { join, normalize, relative, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/** One place a phrase was found. */
|
||||
export interface Hit {
|
||||
/** The document, relative to the repository's root. */
|
||||
path: string;
|
||||
/** The line it was found on, from 1. */
|
||||
line: number;
|
||||
/** The nearest heading above it, so a hit reads as where in the document it is. */
|
||||
heading: string;
|
||||
/** The line itself, trimmed. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Standing {
|
||||
repository: string;
|
||||
origin: string;
|
||||
/** The commit the checkout is at, or empty before the first clone. */
|
||||
commit: string;
|
||||
/** When that commit was made, as the repository says. */
|
||||
committed: string;
|
||||
/** When this reader last brought the checkout up to date. */
|
||||
fetched: string;
|
||||
/** How many markdown documents the checkout holds. */
|
||||
documents: number;
|
||||
/** Why the last sync failed, if it did; the checkout stands where it was. */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */
|
||||
export const MOST_HITS = 50;
|
||||
/** A document longer than this is answered in part, and says so. */
|
||||
export const MOST_BYTES = 200_000;
|
||||
|
||||
export class Records {
|
||||
/** Where the checkout lives; the mesh gives the module the directory. */
|
||||
readonly dir: string;
|
||||
/** The forge's address, `scheme://host:port`, from the git provision's binding. */
|
||||
readonly origin: string;
|
||||
/** The repository's path on it, `owner/name`, from this module's settings. */
|
||||
readonly repository: string;
|
||||
|
||||
private syncing: Promise<void> | undefined;
|
||||
private fetched = "";
|
||||
private lastError: string | undefined;
|
||||
|
||||
constructor(dir: string, origin: string, repository: string) {
|
||||
this.dir = dir;
|
||||
this.origin = origin;
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** The clone URL: the forge, the repository. Public repositories only; a credential would be a
|
||||
* secret this module has not asked for. */
|
||||
get url(): string {
|
||||
return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`;
|
||||
}
|
||||
|
||||
/** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call
|
||||
* while one runs joins it rather than racing it. Never throws — a failed sync is recorded in
|
||||
* `standing()` and the checkout stands where it was, which is still an answer. */
|
||||
sync(): Promise<void> {
|
||||
if (!this.syncing) {
|
||||
this.syncing = this.doSync().finally(() => {
|
||||
this.syncing = undefined;
|
||||
});
|
||||
}
|
||||
return this.syncing;
|
||||
}
|
||||
|
||||
private async doSync(): Promise<void> {
|
||||
try {
|
||||
const cloned = await exists(join(this.dir, ".git"));
|
||||
if (!cloned) {
|
||||
await fs.mkdir(this.dir, { recursive: true });
|
||||
await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]);
|
||||
} else {
|
||||
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has,
|
||||
// rather than merging into something a hand may have touched.
|
||||
await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]);
|
||||
await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]);
|
||||
}
|
||||
this.fetched = new Date().toISOString();
|
||||
this.lastError = undefined;
|
||||
} catch (e) {
|
||||
this.lastError = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[records] could not sync ${this.url}: ${this.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
async standing(): Promise<Standing> {
|
||||
let commit = "";
|
||||
let committed = "";
|
||||
if (await exists(join(this.dir, ".git"))) {
|
||||
try {
|
||||
commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim();
|
||||
committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim();
|
||||
} catch {
|
||||
// A checkout without a commit yet: said as empty rather than thrown.
|
||||
}
|
||||
}
|
||||
const documents = commit ? (await this.documents()).length : 0;
|
||||
return {
|
||||
repository: this.repository,
|
||||
origin: this.origin,
|
||||
commit,
|
||||
committed,
|
||||
fetched: this.fetched,
|
||||
documents,
|
||||
...(this.lastError ? { lastError: this.lastError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every markdown document, relative to the root, in a stable order. */
|
||||
async documents(): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const walk = async (at: string): Promise<void> => {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(at, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name === ".git" || e.name === "node_modules") continue;
|
||||
const full = join(at, e.name);
|
||||
if (e.isDirectory()) await walk(full);
|
||||
else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/"));
|
||||
}
|
||||
};
|
||||
await walk(this.dir);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
|
||||
* design record is found by its own words and a reader deciding which words matter would be a
|
||||
* second opinion about somebody else's document. Bounded, and says when it was.
|
||||
*/
|
||||
async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document");
|
||||
const cap = Math.max(1, Math.min(limit, MOST_HITS));
|
||||
const hits: Hit[] = [];
|
||||
let more = false;
|
||||
for (const path of await this.documents()) {
|
||||
const text = await fs.readFile(join(this.dir, path), "utf8");
|
||||
let heading = "";
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim();
|
||||
if (line.toLowerCase().includes(needle)) {
|
||||
if (hits.length >= cap) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
hits.push({ path, line: i + 1, heading, text: line.trim() });
|
||||
}
|
||||
}
|
||||
if (more) break;
|
||||
}
|
||||
const commit = (await this.standing()).commit;
|
||||
return { hits, more, commit };
|
||||
}
|
||||
|
||||
/** One document, whole, or its first part with a note when it is very long. The path is kept
|
||||
* inside the checkout: `..` and absolute paths are refused, not resolved. */
|
||||
async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> {
|
||||
const clean = normalize(path).split(sep).join("/");
|
||||
if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) {
|
||||
throw new Error(`"${path}" is not a path inside the repository`);
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(join(this.dir, clean), "utf8");
|
||||
} catch {
|
||||
throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`);
|
||||
}
|
||||
const truncated = content.length > MOST_BYTES;
|
||||
return {
|
||||
path: clean,
|
||||
content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content,
|
||||
truncated,
|
||||
commit: (await this.standing()).commit,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a folder holds: its sub-folders and its documents, one level. */
|
||||
async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> {
|
||||
const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, "");
|
||||
if (clean.startsWith("..") || clean.startsWith("/")) {
|
||||
throw new Error(`"${folder}" is not a folder inside the repository`);
|
||||
}
|
||||
const at = clean ? join(this.dir, clean) : this.dir;
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(at, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new Error(`the repository holds no folder ${clean || "/"}`);
|
||||
}
|
||||
const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort();
|
||||
const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
|
||||
return { folder: clean, folders, documents };
|
||||
}
|
||||
}
|
||||
|
||||
/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and
|
||||
* the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */
|
||||
export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<Records> {
|
||||
const dir = env.MESH_RECORDS_DIR;
|
||||
if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in");
|
||||
const originFile = env.MESH_RECORDS_ORIGIN_FILE;
|
||||
if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding");
|
||||
const origin = (await fs.readFile(originFile, "utf8")).trim();
|
||||
if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`);
|
||||
const configFile = env.MESH_RECORDS_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset");
|
||||
let config: { repository?: unknown } = {};
|
||||
try {
|
||||
config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown };
|
||||
} catch (e) {
|
||||
throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
const repository = typeof config.repository === "string" ? config.repository.trim() : "";
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
|
||||
throw new Error(
|
||||
"this module reads the repository its settings name, and none is set: " +
|
||||
'`settings set records <file>` with {"repository": "<owner>/<name>"} — a module names no mesh (novox/hq ADR 0112)',
|
||||
);
|
||||
}
|
||||
return new Records(dir, origin, repository);
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The reader against a real repository: a checkout, a phrase found where it is written, a document
|
||||
* read whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears
|
||||
* only in one design document, and get it back.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Records } from "../records.ts";
|
||||
|
||||
function aRepository(): string {
|
||||
const dir = mkdtempSync("/tmp/records-origin-");
|
||||
const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" });
|
||||
git("init", "--quiet", "--initial-branch=main");
|
||||
git("config", "user.email", "t@example.invalid");
|
||||
git("config", "user.name", "t");
|
||||
mkdirSync(join(dir, "02-DECISIONS"));
|
||||
mkdirSync(join(dir, "03-DESIGN"));
|
||||
writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n");
|
||||
writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n");
|
||||
writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n");
|
||||
git("add", "-A");
|
||||
git("commit", "--quiet", "-m", "first");
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("a phrase that appears in one design document comes back from where it is written", async () => {
|
||||
const origin = aRepository();
|
||||
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "file://" + origin.replace(/\/[^/]+$/, ""), origin.split("/").pop()!);
|
||||
// A file:// origin has no `.git` suffix; point the clone at the directory itself.
|
||||
Object.defineProperty(records, "url", { get: () => origin });
|
||||
await records.sync();
|
||||
const found = await records.search("never look alike");
|
||||
assert.equal(found.hits.length, 1);
|
||||
assert.equal(found.hits[0]!.path, "03-DESIGN/07-knowledge.md");
|
||||
assert.equal(found.hits[0]!.heading, "The stores");
|
||||
assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at");
|
||||
|
||||
const doc = await records.read("02-DECISIONS/0001-a-decision.md");
|
||||
assert.match(doc.content, /We decided the thing/);
|
||||
assert.equal(doc.truncated, false);
|
||||
|
||||
const root = await records.list();
|
||||
assert.deepEqual(root.folders, ["02-DECISIONS", "03-DESIGN"]);
|
||||
assert.deepEqual(root.documents, ["README.md"]);
|
||||
|
||||
const standing = await records.standing();
|
||||
assert.equal(standing.documents, 3);
|
||||
assert.equal(standing.lastError, undefined);
|
||||
|
||||
// A merge on the origin, pulled: the checkout follows the source and names the new commit.
|
||||
writeFileSync(join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n");
|
||||
execFileSync("git", ["-C", origin, "add", "-A"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", origin, "commit", "--quiet", "-m", "second"], { stdio: "pipe" });
|
||||
await records.sync();
|
||||
const after = await records.search("nobody wrote before");
|
||||
assert.equal(after.hits.length, 1);
|
||||
assert.notEqual(after.commit, found.commit);
|
||||
});
|
||||
|
||||
test("a path outside the repository is refused, and an empty search is too", async () => {
|
||||
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://forge.invalid:3000", "novox/hq");
|
||||
await assert.rejects(() => records.read("../etc/passwd"), /not a path inside/);
|
||||
await assert.rejects(() => records.read("/etc/passwd"), /not a path inside/);
|
||||
await assert.rejects(() => records.search(" "), /empty one/);
|
||||
assert.equal(records.url, "http://forge.invalid:3000/novox/hq.git");
|
||||
});
|
||||
|
||||
test("a sync that fails leaves the checkout standing and says why", async () => {
|
||||
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://127.0.0.1:1", "novox/hq");
|
||||
await records.sync();
|
||||
const standing = await records.standing();
|
||||
assert.equal(standing.commit, "");
|
||||
assert.ok(standing.lastError, "a failed sync is said, not swallowed");
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// records' tools — how the record is asked (novox/hq ADR 0025, ADR 0153).
|
||||
//
|
||||
// Five questions, each answered from the checkout at the commit it names: where does a phrase
|
||||
// appear, what does one document say, what does a folder hold, where does the checkout stand, and
|
||||
// bring it up to date now. The reasoning stays in the documents; the tools only find them.
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { recordsFromEnv, type Records } from "../records.js";
|
||||
|
||||
export function getRecordsTools(records: Records): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "records_search",
|
||||
description:
|
||||
"Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
|
||||
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
|
||||
input: {
|
||||
query: { type: "string", description: "the phrase, matched case-insensitively as written" },
|
||||
limit: { type: "number", description: "at most this many places (default 50)" },
|
||||
},
|
||||
run: async (args) => records.search(String(args.query ?? ""), args.limit ? Number(args.limit) : undefined),
|
||||
},
|
||||
{
|
||||
name: "records_read",
|
||||
description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
|
||||
input: { path: { type: "string", description: "the document's path, e.g. 02-DECISIONS/0025-....md" } },
|
||||
run: async (args) => records.read(String(args.path ?? "")),
|
||||
},
|
||||
{
|
||||
name: "records_list",
|
||||
description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
|
||||
input: { folder: { type: "string", description: "a folder inside the repository (optional)" } },
|
||||
run: async (args) => records.list(args.folder ? String(args.folder) : ""),
|
||||
},
|
||||
{
|
||||
name: "records_status",
|
||||
description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
|
||||
input: {},
|
||||
run: async () => records.standing(),
|
||||
},
|
||||
{
|
||||
name: "records_sync",
|
||||
description: "Bring the checkout up to date now, and say where it stands.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
await records.sync();
|
||||
return records.standing();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The reader is made once, at load, from the environment the runtime resolves; the contributor is
|
||||
// synchronous and is called at every collection. Without a repository to read there is nothing to
|
||||
// answer, and the module exposes no tools rather than five that fail — the sdk's contract: a
|
||||
// contributor returning [] is normal.
|
||||
let reader: Records | null = null;
|
||||
try {
|
||||
reader = await recordsFromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[records] no tools — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
registerModuleTools("records", () => (reader ? getRecordsTools(reader) : []));
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["records.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user