Compare commits
13 Commits
e682bbccd1
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904f7b4303 | ||
|
|
cac84dec9b | ||
|
|
90dd8f1481 | ||
| 39113c9dbf | |||
|
|
1367070468 | ||
|
|
767ac9e7e2 | ||
|
|
2a4ef9267e | ||
|
|
309719abc6 | ||
|
|
3511301331 | ||
|
|
b65ca6d7dd | ||
| 4a3819373d | |||
|
|
c64d7f315e | ||
|
|
7a7aa79eab |
11
.env.example
11
.env.example
@@ -132,11 +132,12 @@ MCP_DOCMOST_PASSWORD=
|
||||
# NEVER set is_agent on a human or shared account — every action by that account
|
||||
# (including normal human edits) would then be mis-attributed as AI.
|
||||
|
||||
# Agent-roles catalog source: an http(s):// base URL => the catalog is fetched
|
||||
# remotely (e.g. the raw GitHub base URL of the catalog repo); any other value
|
||||
# => a local filesystem directory. Empty (default) => the in-repo
|
||||
# ./agent-roles-catalog folder (dev). Used by the admin "import role from
|
||||
# catalog" feature only.
|
||||
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
|
||||
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
|
||||
# baked into the Docker image at build time per branch (see the Dockerfile ARG
|
||||
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
|
||||
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
|
||||
# admin feature is unavailable. Local-filesystem sources are no longer supported.
|
||||
# AI_AGENT_ROLES_CATALOG_URL=
|
||||
|
||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||
|
||||
1
.github/workflows/develop.yml
vendored
1
.github/workflows/develop.yml
vendored
@@ -52,6 +52,7 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -17,6 +17,7 @@ permissions:
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
@@ -57,6 +58,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
|
||||
@@ -85,6 +87,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
push: false
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -37,10 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
|
||||
`/catalog/bundle` (read one bundle's roles), `/import`, and
|
||||
`/update-from-catalog` — and a new `source` column linking a role to its
|
||||
catalog slug/language/version. The catalog source is configurable via the new
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var (an `http(s)://` base URL fetches it
|
||||
remotely; otherwise a local directory; empty defaults to the in-repo
|
||||
`agent-roles-catalog/` folder — see `.env.example`). (#222)
|
||||
catalog slug/language/version. The catalog source is configured via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
|
||||
catalog's raw files; the image ships a per-branch default baked in CI, and it
|
||||
can be overridden at runtime via the env var (see `.env.example`). (#222)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -55,6 +55,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
page), so any previously-live duplicate `/l/<old>` link begins returning the
|
||||
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
|
||||
#227)
|
||||
- **Typing a custom address already used by another page no longer looks like a
|
||||
dead end.** The share modal previously flagged such a name with a red "This
|
||||
address is already in use" error, hiding the fact that saving offers to MOVE
|
||||
the address to the current page. The field now shows an informational hint —
|
||||
"This address is in use. Saving will move it to this page." — and keeps Save
|
||||
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` →
|
||||
"Move custom address?") is discoverable instead of reading as terminal. (#227)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
@@ -134,6 +141,13 @@ per-workspace rolling-day token budget.
|
||||
applies it through the existing `/pages/update` route — reflecting it in the
|
||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||
flag and throttled per user. (#199)
|
||||
- **AI chat: header button auto-opens the chat bound to the current document.**
|
||||
Clicking the AI-chat button in the header while viewing a page now reopens the
|
||||
latest chat tied to that document instead of whatever chat was last active,
|
||||
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
|
||||
chat you created on the page wins; with no bound chat — or off a page, or if
|
||||
the lookup fails — it falls soft to a fresh chat and keeps the current
|
||||
selection otherwise. (#191)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Agent-roles catalog base URL: per-branch default set at build time (CI);
|
||||
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
|
||||
ARG AI_AGENT_ROLES_CATALOG_URL=""
|
||||
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
|
||||
|
||||
# Copy apps
|
||||
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
||||
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
|
||||
|
||||
@@ -16,6 +16,7 @@ agent-roles-catalog/
|
||||
<lang>.json # one file per declared language (e.g. ru.json, en.json)
|
||||
scripts/
|
||||
check.mjs # validates the catalog (no dependencies)
|
||||
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
|
||||
package.json # defines the `check` script
|
||||
README.md
|
||||
```
|
||||
@@ -23,27 +24,27 @@ agent-roles-catalog/
|
||||
Currently shipped bundles:
|
||||
|
||||
- `editorial` — the editorial suite (structural-editor, line-editor,
|
||||
copy-editor, fact-checker, proofreader, narrator), languages `ru`, `en`.
|
||||
fact-checker, proofreader, narrator), languages `ru`, `en`.
|
||||
- `research` — a single `researcher` role, languages `ru`, `en`.
|
||||
|
||||
## How it's served
|
||||
|
||||
The server does not bundle this data; it reads it at request time from a single
|
||||
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
|
||||
(`EnvironmentService.getAiAgentRolesCatalogSource()`). The value selects one of
|
||||
three sources:
|
||||
(`EnvironmentService.getAiAgentRolesCatalogSource()`), an `http(s)://` base URL
|
||||
to the catalog's raw files. The server fetches `<base>/index.json` for the
|
||||
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
|
||||
file (REMOTE only).
|
||||
|
||||
- **`http(s)://…`** — a REMOTE base URL. The server fetches `<base>/index.json`
|
||||
for the manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened
|
||||
bundle file (e.g. the raw GitHub base of the catalog repo in production).
|
||||
- **any other non-empty value** — a LOCAL filesystem directory; the same
|
||||
`index.json` / `bundles/<id>/<lang>.json` paths are read from disk.
|
||||
- **empty / unset** (the default) — the in-repo `agent-roles-catalog/` folder
|
||||
(this directory), i.e. local dev reads these files directly.
|
||||
That base URL is provided as a per-branch default in the Docker image (set in
|
||||
CI: a `develop` build points at the `develop` raw URL, a release build at the
|
||||
`main` raw URL) and can be overridden at runtime via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
|
||||
supported; if the value is unset the catalog is unavailable.
|
||||
|
||||
In every case the layout below is what the server expects, and the fetched JSON
|
||||
is re-validated server-side (the catalog is treated as untrusted input). See
|
||||
`.env.example` for the variable and the CHANGELOG for the rollout.
|
||||
The fetched JSON is re-validated server-side (the catalog is treated as
|
||||
untrusted input). See `.env.example` for the variable and the CHANGELOG for the
|
||||
rollout.
|
||||
|
||||
## `index.json` schema
|
||||
|
||||
@@ -133,7 +134,10 @@ bundle. A slug appears once per language file of its bundle (same slug in
|
||||
### Change a role's content
|
||||
|
||||
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
|
||||
`version`** in `index.json`.
|
||||
`version`** in `index.json`. Then run `node scripts/check.mjs --update-hashes`
|
||||
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
|
||||
now **fails if a role's content changed but its `version` was not bumped**, so
|
||||
this step is mandatory — the lock can only be refreshed after the bump.
|
||||
|
||||
## Validating
|
||||
|
||||
@@ -147,3 +151,43 @@ It fails (exit code 1) if any slug is duplicated across the catalog, if a
|
||||
bundle's index `roles[]` don't match the slugs present in each language file, if
|
||||
a declared language file is missing, or if any role is missing a required field
|
||||
(`slug`, `name`, `instructions`). It prints `OK` on success.
|
||||
|
||||
### Content-hash guard
|
||||
|
||||
`check.mjs` also guards against changing a role's content without bumping its
|
||||
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
|
||||
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
|
||||
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
|
||||
`launchMessage`) across all of its language files, in a deterministic canonical
|
||||
form. This lockfile is a **check artifact only** — the server fetches only
|
||||
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
|
||||
effect on the served catalog or its schema.
|
||||
|
||||
On a normal run, for every role the check recomputes the hash and compares it
|
||||
against the lock:
|
||||
|
||||
- content unchanged and versions agree → OK;
|
||||
- content changed but `version` not bumped above the lock → **error** asking you
|
||||
to bump and refresh;
|
||||
- content changed and `version` bumped → **error** asking you to record it by
|
||||
refreshing the lock;
|
||||
- role missing from the lock, or a lock entry for a role that no longer exists →
|
||||
**error** asking you to refresh.
|
||||
|
||||
Refresh the lock with:
|
||||
|
||||
```sh
|
||||
node scripts/check.mjs --update-hashes # alias: --fix
|
||||
```
|
||||
|
||||
This recomputes the lock from the current catalog, prunes entries for removed
|
||||
roles, and prints what changed — but it **refuses to write** (exit 1) if any
|
||||
role's content changed while its `index.json` version was not bumped, so the
|
||||
version bump is always enforced first. The check also requires every
|
||||
`index.json` role to carry a finite numeric `version` (the server requires the
|
||||
same).
|
||||
|
||||
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
|
||||
role and run `--update-hashes`, then re-add it with changed content at the same
|
||||
version) is **not** caught, because a brand-new slug has no lock baseline to
|
||||
enforce a bump against.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,16 +5,15 @@
|
||||
"id": "editorial",
|
||||
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
|
||||
"description": {
|
||||
"ru": "Полный цикл редактуры статьи: структура, стиль, грамматика, факты, корректура и нарратив.",
|
||||
"en": "The full article-editing cycle: structure, style, grammar, facts, proofreading, and narrative."
|
||||
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
|
||||
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 1 },
|
||||
{ "slug": "line-editor", "version": 1 },
|
||||
{ "slug": "copy-editor", "version": 1 },
|
||||
{ "slug": "fact-checker", "version": 1 },
|
||||
{ "slug": "proofreader", "version": 1 },
|
||||
{ "slug": "structural-editor", "version": 2 },
|
||||
{ "slug": "line-editor", "version": 2 },
|
||||
{ "slug": "fact-checker", "version": 2 },
|
||||
{ "slug": "proofreader", "version": 3 },
|
||||
{ "slug": "narrator", "version": 1 }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4,13 +4,23 @@
|
||||
// between a bundle's index roles[] and the slugs present in each language
|
||||
// file, a missing declared language file, or a role missing required fields.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const catalogDir = join(__dirname, "..");
|
||||
|
||||
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
|
||||
// the current catalog instead of just validating against it.
|
||||
const updateHashes =
|
||||
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
|
||||
|
||||
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
|
||||
// the server never fetches it, so it has zero impact on the served schema.
|
||||
const lockPath = join(__dirname, "content-hashes.json");
|
||||
|
||||
const errors = [];
|
||||
|
||||
function readJson(path) {
|
||||
@@ -56,6 +66,17 @@ for (const bundle of bundles) {
|
||||
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
|
||||
}
|
||||
|
||||
// Each index role must carry a finite numeric "version". The server requires
|
||||
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
|
||||
// below relies on it for the bump comparison, so enforce it here too.
|
||||
for (const r of bundle.roles || []) {
|
||||
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
if (languages.length === 0) {
|
||||
errors.push(`Bundle "${bundleId}" declares no languages`);
|
||||
@@ -121,6 +142,208 @@ for (const bundle of bundles) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-hash guard: detect "content changed without a version bump".
|
||||
//
|
||||
// check.mjs cannot use git history, so we maintain a lockfile
|
||||
// (scripts/content-hashes.json) mapping each role slug to its recorded
|
||||
// { version, hash }. On every run we recompute each role's content hash and
|
||||
// compare it against the lock; a content change is only allowed once the role's
|
||||
// version in index.json has been bumped and the lock refreshed.
|
||||
//
|
||||
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
|
||||
// the role and run --update-hashes, then re-add it with changed content at the
|
||||
// same version) is NOT caught, because a brand-new slug has no lock baseline to
|
||||
// enforce a bump against. We document this rather than building tombstones.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Content fields hashed for each role, in a fixed canonical order. `slug` is
|
||||
// identity (not content) and `version` lives in index.json, so neither is here.
|
||||
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
|
||||
// EXCLUDED: no shipped role uses it today, and being an object it would need a
|
||||
// deterministic deep canonicalization (recursive key sort) before hashing —
|
||||
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
|
||||
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
|
||||
// change to it is still caught by the bump guard.
|
||||
const CONTENT_FIELDS = [
|
||||
"emoji",
|
||||
"autoStart",
|
||||
"name",
|
||||
"description",
|
||||
"instructions",
|
||||
"launchMessage",
|
||||
];
|
||||
|
||||
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
|
||||
// current catalog so we can compute hashes and read index versions.
|
||||
function collectCatalogRoles() {
|
||||
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
|
||||
for (const bundle of bundles) {
|
||||
const bundleId = bundle.id;
|
||||
if (!bundleId) continue;
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
for (const r of bundle.roles || []) {
|
||||
if (!r || !r.slug) continue;
|
||||
if (!out.has(r.slug)) {
|
||||
out.set(r.slug, { version: r.version, langRoles: new Map() });
|
||||
} else {
|
||||
// Same slug declared twice in index.json roles[]; already flagged above.
|
||||
out.get(r.slug).version = r.version;
|
||||
}
|
||||
}
|
||||
for (const lang of languages) {
|
||||
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
||||
if (!existsSync(langPath)) continue;
|
||||
const langFile = readJson(langPath);
|
||||
if (!langFile) continue;
|
||||
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
||||
for (const role of roles) {
|
||||
if (!role || !role.slug) continue;
|
||||
const entry = out.get(role.slug);
|
||||
if (!entry) continue; // role not declared in index.json; flagged above.
|
||||
entry.langRoles.set(lang, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Deterministic content hash for a role: languages sorted ascending, each
|
||||
// language's content fields taken in CONTENT_FIELDS order (null when absent).
|
||||
function contentHash(langRoles) {
|
||||
const langs = [...langRoles.keys()].sort();
|
||||
const canonical = langs.map((lang) => {
|
||||
const role = langRoles.get(lang);
|
||||
const fields = {};
|
||||
for (const field of CONTENT_FIELDS) {
|
||||
fields[field] = role && role[field] != null ? role[field] : null;
|
||||
}
|
||||
return [lang, fields];
|
||||
});
|
||||
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
||||
}
|
||||
|
||||
// Compute current { version, hash } for every catalog role.
|
||||
const catalogRoles = collectCatalogRoles();
|
||||
const current = new Map(); // slug -> { version, hash }
|
||||
for (const [slug, entry] of catalogRoles) {
|
||||
current.set(slug, {
|
||||
version: entry.version,
|
||||
hash: contentHash(entry.langRoles),
|
||||
});
|
||||
}
|
||||
|
||||
// Load the existing lock (may be absent on first run).
|
||||
let lock = {};
|
||||
if (existsSync(lockPath)) {
|
||||
const parsed = readJson(lockPath);
|
||||
if (parsed && typeof parsed === "object") lock = parsed;
|
||||
}
|
||||
|
||||
if (updateHashes) {
|
||||
// Refresh the lock from the current catalog, but refuse to write if any role's
|
||||
// content changed without its version being bumped above the existing lock.
|
||||
const blockers = [];
|
||||
for (const [slug, cur] of current) {
|
||||
const prev = lock[slug];
|
||||
if (!prev) continue; // new role; nothing to enforce a bump against.
|
||||
if (cur.hash === prev.hash) continue; // content unchanged.
|
||||
// Defense-in-depth: a non-numeric version must never pass the bump check via
|
||||
// `undefined <= N` (which is false). The standard checks already flag a
|
||||
// missing numeric version, but guard here too before comparing.
|
||||
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
|
||||
blockers.push(
|
||||
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
|
||||
);
|
||||
} else if (cur.version <= prev.version) {
|
||||
blockers.push(
|
||||
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Still honor the standard checks before allowing a write.
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
console.error("Refusing to update content-hash lock:");
|
||||
for (const b of blockers) console.error(` - ${b}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Compute the change summary relative to the old lock, pruning removed slugs.
|
||||
const newLock = {};
|
||||
const added = [];
|
||||
const changed = [];
|
||||
const removed = [];
|
||||
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
newLock[slug] = { version: cur.version, hash: cur.hash };
|
||||
const prev = lock[slug];
|
||||
if (!prev) added.push(slug);
|
||||
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
|
||||
}
|
||||
for (const slug of Object.keys(lock)) {
|
||||
if (!current.has(slug)) removed.push(slug);
|
||||
}
|
||||
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
|
||||
console.log(`Wrote ${lockPath}`);
|
||||
if (added.length) console.log(` added: ${added.join(", ")}`);
|
||||
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
|
||||
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
|
||||
if (!added.length && !changed.length && !removed.length) {
|
||||
console.log(" (no changes; lock already up to date)");
|
||||
}
|
||||
console.log("OK");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Normal run: validate current content against the lock.
|
||||
for (const [slug, cur] of current) {
|
||||
const prev = lock[slug];
|
||||
if (!prev) {
|
||||
errors.push(
|
||||
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (cur.hash === prev.hash) {
|
||||
// Content unchanged; the lock version must still agree with index.json.
|
||||
if (cur.version !== prev.version) {
|
||||
errors.push(
|
||||
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Content changed.
|
||||
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
|
||||
// comparison, so a missing version can never silently pass the bump check
|
||||
// (and we avoid a misleading "version bumped to undefined" message).
|
||||
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
|
||||
errors.push(
|
||||
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
} else if (cur.version <= prev.version) {
|
||||
errors.push(
|
||||
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
} else {
|
||||
errors.push(
|
||||
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Lock entries for slugs that no longer exist in the catalog.
|
||||
for (const slug of Object.keys(lock)) {
|
||||
if (!current.has(slug)) {
|
||||
errors.push(
|
||||
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
|
||||
26
agent-roles-catalog/scripts/content-hashes.json
Normal file
26
agent-roles-catalog/scripts/content-hashes.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 2,
|
||||
"hash": "d7ad1dae07d6f4321e7d40c5b36259dbf930264d748834809c4fb77294bf72e3"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
}
|
||||
}
|
||||
@@ -1333,6 +1333,7 @@
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
|
||||
@@ -1190,6 +1190,7 @@
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
|
||||
@@ -10,12 +10,12 @@ import classes from "./app-header.module.css";
|
||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
@@ -38,7 +38,9 @@ export function AppHeader() {
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
// Opening from the header auto-opens the document's bound chat (last chat
|
||||
// created on the current page); off a page it keeps the current selection.
|
||||
const openAiChat = useOpenAiChatForCurrentPage();
|
||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
@@ -105,7 +107,7 @@ export function AppHeader() {
|
||||
color="dark"
|
||||
size="sm"
|
||||
aria-label={t("AI chat")}
|
||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||
onClick={openAiChat}
|
||||
>
|
||||
<IconMessage size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
135
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.test.tsx
Normal file
135
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// useMatch is the only react-router-dom export the hook uses; drive its return
|
||||
// per test to simulate "on a page" vs "off a page".
|
||||
const useMatchMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useMatch: () => useMatchMock(),
|
||||
}));
|
||||
|
||||
// The bound-chat resolver is the network boundary; stub it per test.
|
||||
const getBoundChatMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
|
||||
}));
|
||||
|
||||
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
|
||||
// tests override useMatch to go off-page.
|
||||
function onPage(pageSlug = "doc-p1") {
|
||||
useMatchMock.mockReturnValue({ params: { pageSlug } });
|
||||
}
|
||||
function offPage() {
|
||||
useMatchMock.mockReturnValue(null);
|
||||
}
|
||||
|
||||
// Render the hook inside an explicit jotai store so atom side effects are
|
||||
// assertable; the store is returned for setup + assertions.
|
||||
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
|
||||
const store = createStore();
|
||||
seed?.(store);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
);
|
||||
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
|
||||
return { store, open: () => act(() => result.current()) };
|
||||
}
|
||||
|
||||
describe("useOpenAiChatForCurrentPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onPage();
|
||||
});
|
||||
|
||||
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
|
||||
getBoundChatMock.mockResolvedValue("bound-chat-1");
|
||||
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
|
||||
});
|
||||
|
||||
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
|
||||
getBoundChatMock.mockResolvedValue(null);
|
||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("off a page: keeps the current selection and does NOT resolve", async () => {
|
||||
offPage();
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(activeAiChatIdAtom, "keep-me");
|
||||
s.set(aiChatDraftAtom, "untouched");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
|
||||
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
|
||||
getBoundChatMock.mockResolvedValue("would-switch");
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(aiChatWindowOpenAtom, true);
|
||||
s.set(activeAiChatIdAtom, "current");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("current");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
|
||||
getBoundChatMock.mockResolvedValue("same");
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(activeAiChatIdAtom, "same");
|
||||
s.set(aiChatDraftAtom, "in-progress");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
|
||||
getBoundChatMock.mockRejectedValue(new Error("network"));
|
||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the picked role on a real switch", async () => {
|
||||
getBoundChatMock.mockResolvedValue("bound");
|
||||
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
|
||||
});
|
||||
});
|
||||
67
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts
Normal file
67
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import {
|
||||
aiChatWindowOpenAtom,
|
||||
activeAiChatIdAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
/**
|
||||
* The generic "open the AI chat" action, WITH document binding: when invoked
|
||||
* while viewing a page, it resolves that page's bound chat and selects it before
|
||||
* opening — so the last chat for this document re-opens by itself. With no bound
|
||||
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
|
||||
* by the app-header entry point; NOT by the provenance badge (which deep-links).
|
||||
*/
|
||||
export function useOpenAiChatForCurrentPage() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// Same route-match trick the window uses: read :pageSlug from the pathname.
|
||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
||||
// see :pageSlug — match the full path against the authenticated page route.
|
||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
||||
// re-resolve and yank the user to another chat: resolve only on a genuine
|
||||
// closed -> open transition. (`windowOpen` is already true here, so there
|
||||
// is nothing to set — just bail.)
|
||||
if (windowOpen) return;
|
||||
// Open the window FIRST so the control feels instant: the bound-chat
|
||||
// round-trip below must never gate the window appearing, or on a slow
|
||||
// connection the first click reads as a hung control until the POST returns.
|
||||
setWindowOpen(true);
|
||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||
if (pageId) {
|
||||
try {
|
||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
||||
} catch {
|
||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||
}
|
||||
}
|
||||
// Clear the composer draft / picked role ONLY on an actual switch, so
|
||||
// reopening the same chat does not wipe an in-progress draft. Applied after
|
||||
// the resolve so the window is already visible while the switch settles.
|
||||
if (resolved !== activeChatId) {
|
||||
setActiveChatId(resolved);
|
||||
setDraft("");
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
}, [
|
||||
windowOpen,
|
||||
activeChatId,
|
||||
pageId,
|
||||
setWindowOpen,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
}
|
||||
@@ -42,6 +42,17 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
*/
|
||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||
pageId,
|
||||
});
|
||||
return req.data.chatId;
|
||||
}
|
||||
|
||||
/** Rename a chat. */
|
||||
export async function renameAiChat(data: {
|
||||
chatId: string;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { IShareAlias } from "@/features/share/types/share.types";
|
||||
|
||||
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The mutation + query hooks reach react-query/network; the availability probe
|
||||
// hits the API. Stub them so the section renders in isolation and we can drive
|
||||
// the exact branches (taken name -> hint, 409 -> reassign modal).
|
||||
const setMutateAsync = vi.fn();
|
||||
let currentAlias: IShareAlias | null = null;
|
||||
let availabilityResult: {
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} = { valid: true, available: true, currentPageId: null };
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||
useSetShareAliasMutation: () => ({
|
||||
mutateAsync: setMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useRemoveShareAliasMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/share/services/share-service.ts", () => ({
|
||||
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
|
||||
}));
|
||||
|
||||
import ShareAliasSection from "./share-alias-section";
|
||||
|
||||
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
|
||||
id: `alias-${alias}`,
|
||||
workspaceId: "ws-1",
|
||||
alias,
|
||||
pageId,
|
||||
creatorId: "user-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
function renderSection(pageId = "page-Y") {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ShareAliasSection pageId={pageId} readOnly={false} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
|
||||
beforeEach(() => {
|
||||
setMutateAsync.mockReset();
|
||||
currentAlias = null;
|
||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||
});
|
||||
|
||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||
// Page Y already owns "bee"; the user retypes a name owned by page X.
|
||||
currentAlias = aliasRow("bee", "page-Y");
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
|
||||
renderSection("page-Y");
|
||||
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "test2" } });
|
||||
|
||||
// The reassign hint replaces the old dead-end red error.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This address is in use. Saving will move it to this page.",
|
||||
),
|
||||
).toBeDefined(),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
// The old terminal "already in use" error must NOT be shown.
|
||||
expect(screen.queryByText("This address is already in use")).toBeNull();
|
||||
|
||||
// Save stays enabled so the confirm-reassign flow can run.
|
||||
const saveBtn = screen.getByRole("button", {
|
||||
name: "Save",
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
|
||||
currentAlias = aliasRow("bee", "page-Y");
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
// The server rejects the un-confirmed save asking the client to confirm.
|
||||
setMutateAsync.mockRejectedValueOnce({
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
code: "ALIAS_REASSIGN_REQUIRED",
|
||||
currentPageId: "page-X",
|
||||
currentPageTitle: "Alias Test Page X",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderSection("page-Y");
|
||||
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "test2" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", {
|
||||
name: "Save",
|
||||
}) as HTMLButtonElement;
|
||||
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
|
||||
timeout: 2000,
|
||||
});
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
// First save sent WITHOUT confirmReassign.
|
||||
await waitFor(() =>
|
||||
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alias: "test2", confirmReassign: false }),
|
||||
),
|
||||
);
|
||||
|
||||
// The "Move custom address?" confirm modal must appear (the path forward).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Move custom address?")).toBeDefined(),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
|
||||
|
||||
// Confirming retries WITH confirmReassign: true.
|
||||
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
|
||||
await waitFor(() =>
|
||||
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alias: "test2", confirmReassign: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -120,8 +120,13 @@ export default function ShareAliasSection({
|
||||
};
|
||||
|
||||
const showInvalid = normalized.length > 0 && !isValid;
|
||||
const showTaken =
|
||||
isValid && !unchanged && availability && !availability.available;
|
||||
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
|
||||
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
|
||||
// the "Move custom address?" confirm modal that retargets the address here.
|
||||
// So surface it as an informational hint (not a terminal red error) and keep
|
||||
// Save enabled, instead of looking like the address is unusable.
|
||||
const reassignable =
|
||||
isValid && !unchanged && !!availability && !availability.available;
|
||||
|
||||
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
||||
const prefixLabel = aliasPrefixLabel();
|
||||
@@ -198,9 +203,12 @@ export default function ShareAliasSection({
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: showTaken
|
||||
? t("This address is already in use")
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
description={
|
||||
reassignable
|
||||
? t("This address is in use. Saving will move it to this page.")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward
|
||||
* the requesting user + workspace + pageId to findLatestByPage and return the
|
||||
* matched chat's id, or `{ chatId: null }` when there is none. The repo already
|
||||
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match
|
||||
* (null) — no extra page-access check is needed. Exercised with hand-rolled
|
||||
* mocks, no Nest graph and no DB.
|
||||
*/
|
||||
describe('AiChatController.boundChat', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeController(chat: unknown) {
|
||||
const aiChatRepo = {
|
||||
findLatestByPage: jest.fn().mockResolvedValue(chat),
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
aiChatRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { controller, aiChatRepo };
|
||||
}
|
||||
|
||||
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => {
|
||||
const { controller, aiChatRepo } = makeController({
|
||||
id: 'c1',
|
||||
creatorId: 'u1',
|
||||
});
|
||||
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace);
|
||||
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1');
|
||||
expect(res).toEqual({ chatId: 'c1' });
|
||||
});
|
||||
|
||||
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => {
|
||||
const { controller } = makeController(undefined);
|
||||
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace);
|
||||
expect(res).toEqual({ chatId: null });
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import {
|
||||
BoundChatDto,
|
||||
ChatIdDto,
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
@@ -67,6 +68,28 @@ export class AiChatController {
|
||||
return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document for the requesting user: the most-recent
|
||||
* non-deleted chat created on that page (ai_chats.page_id). Returns
|
||||
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page
|
||||
* access check needed: only the caller's OWN chats are matched, so a foreign
|
||||
* pageId reveals nothing.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('bound-chat')
|
||||
async boundChat(
|
||||
@Body() dto: BoundChatDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ chatId: string | null }> {
|
||||
const chat = await this.aiChatRepo.findLatestByPage(
|
||||
user.id,
|
||||
workspace.id,
|
||||
dto.pageId,
|
||||
);
|
||||
return { chatId: chat?.id ?? null };
|
||||
}
|
||||
|
||||
/** Fetch the messages of a chat (oldest first, paginated). */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('messages')
|
||||
|
||||
@@ -37,6 +37,12 @@ export class GetChatMessagesDto {
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/** Resolve the chat bound to a document (the page's most-recent owned chat). */
|
||||
export class BoundChatDto {
|
||||
@IsString()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||
* role/tool-action labels; defaults to English server-side. */
|
||||
export class ExportChatDto {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { BadGatewayException, BadRequestException } from '@nestjs/common';
|
||||
import { AiAgentRolesCatalogProvider } from './ai-agent-roles-catalog.provider';
|
||||
|
||||
/**
|
||||
* Provider tests against a LOCAL fixture directory (no network). They cover the
|
||||
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection, a
|
||||
* missing file => unavailable, and — most importantly — the `^[a-z0-9-]+$`
|
||||
* path-traversal guard that runs BEFORE any path is built.
|
||||
* Provider tests against a mocked remote source (no network). They cover the
|
||||
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection,
|
||||
* rejection of non-http(s) sources (local sources are gone), and — most
|
||||
* importantly — the `^[a-z0-9-]+$` path-traversal guard that runs BEFORE any
|
||||
* path/URL is built.
|
||||
*/
|
||||
describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
|
||||
let dir: string;
|
||||
|
||||
describe('AiAgentRolesCatalogProvider', () => {
|
||||
function makeProvider(source: string) {
|
||||
const env = {
|
||||
getAiAgentRolesCatalogSource: () => source,
|
||||
@@ -20,96 +16,13 @@ describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
|
||||
return new AiAgentRolesCatalogProvider(env as never);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-roles-catalog-'));
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'index.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General', ru: 'Общие' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
await fs.mkdir(path.join(dir, 'bundles', 'general'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'bundles', 'general', 'en.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: [
|
||||
{
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
instructions: 'be a researcher',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
// A malformed bundle (a role missing `instructions`) to test rejection.
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'bundles', 'general', 'fr.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
language: 'fr',
|
||||
roles: [{ slug: 'researcher', name: 'Chercheur' }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fetchIndex reads + validates index.json', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
const index = await provider.fetchIndex();
|
||||
expect(index.schemaVersion).toBe(1);
|
||||
expect(index.bundles[0].id).toBe('general');
|
||||
expect(index.bundles[0].roles[0]).toEqual({
|
||||
slug: 'researcher',
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('fetchBundle reads + validates a language file', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
const bundle = await provider.fetchBundle('general', 'en');
|
||||
expect(bundle.language).toBe('en');
|
||||
expect(bundle.roles[0].slug).toBe('researcher');
|
||||
expect(bundle.roles[0].instructions).toBe('be a researcher');
|
||||
});
|
||||
|
||||
it('malformed bundle (missing instructions) => BadGateway', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('missing file => BadGateway (unavailable)', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
await expect(
|
||||
provider.fetchBundle('general', 'de'),
|
||||
).rejects.toBeInstanceOf(BadGatewayException);
|
||||
});
|
||||
|
||||
it('empty source resolves to the in-repo folder (no throw building the path)', async () => {
|
||||
// With an empty source the provider targets ./agent-roles-catalog under the
|
||||
// cwd; that folder is created by a separate task, so a read here surfaces as
|
||||
// BadGateway (unavailable) rather than a path-build error.
|
||||
const provider = makeProvider('');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
it('non-http(s) source => BadGateway (local sources removed)', async () => {
|
||||
for (const source of ['', '/var/lib/agent-roles-catalog', './agent-roles-catalog']) {
|
||||
const provider = makeProvider(source);
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('remote fetch streaming size cap', () => {
|
||||
@@ -157,6 +70,43 @@ describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
it('fetchBundle remote happy path => parses + validates', async () => {
|
||||
const json = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: [
|
||||
{
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
instructions: 'be a researcher',
|
||||
},
|
||||
],
|
||||
});
|
||||
const body = streamOf([new TextEncoder().encode(json)]);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
const bundle = await provider.fetchBundle('general', 'en');
|
||||
expect(bundle.roles[0].slug).toBe('researcher');
|
||||
});
|
||||
|
||||
it('fetchBundle remote malformed (role missing instructions) => BadGateway', async () => {
|
||||
const json = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
language: 'fr',
|
||||
roles: [{ slug: 'researcher', name: 'Chercheur' }],
|
||||
});
|
||||
const body = streamOf([new TextEncoder().encode(json)]);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('declared Content-Length over the cap => BadGateway before reading the body', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
mockResponse({
|
||||
@@ -340,14 +290,14 @@ describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
|
||||
|
||||
for (const value of bad) {
|
||||
it(`rejects bundleId="${value}" with BadRequest`, async () => {
|
||||
const provider = makeProvider(dir);
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(
|
||||
provider.fetchBundle(value, 'en'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it(`rejects language="${value}" with BadRequest`, async () => {
|
||||
const provider = makeProvider(dir);
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(
|
||||
provider.fetchBundle('general', value),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
@@ -26,9 +24,9 @@ const MAX_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* Fetches + validates the agent-roles catalog from its configured source. The
|
||||
* source location (EnvironmentService.getAiAgentRolesCatalogSource()) is either
|
||||
* an http(s):// base URL (REMOTE) or a local filesystem directory (LOCAL; the
|
||||
* empty default resolves to the in-repo `agent-roles-catalog/` folder).
|
||||
* source (EnvironmentService.getAiAgentRolesCatalogSource()) is an http(s)://
|
||||
* base URL — REMOTE only; local-filesystem sources are no longer supported. The
|
||||
* value is baked into the Docker image at build time (set per-branch in CI).
|
||||
*
|
||||
* The catalog is UNTRUSTED input: every file is JSON-parsed and run through a
|
||||
* hand-written type guard before any field is exposed, and every dynamic path
|
||||
@@ -91,31 +89,20 @@ export class AiAgentRolesCatalogProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a relative catalog path as text from the configured source. */
|
||||
/** Read a relative catalog path as text from the configured remote source. */
|
||||
private async readRelative(rel: string): Promise<string> {
|
||||
const source = this.environmentService
|
||||
.getAiAgentRolesCatalogSource()
|
||||
.trim();
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
return this.fetchRemote(source, rel);
|
||||
}
|
||||
const dir = source || path.join(process.cwd(), 'agent-roles-catalog');
|
||||
return this.readLocal(dir, rel);
|
||||
}
|
||||
|
||||
/** Read a local catalog file. Missing => the catalog is unavailable. */
|
||||
private async readLocal(dir: string, rel: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(path.join(dir, rel), 'utf8');
|
||||
} catch (err) {
|
||||
const reason = shortError(err);
|
||||
if (!/^https?:\/\//i.test(source)) {
|
||||
this.logger.error(
|
||||
`Agent roles catalog local read failed (${path.join(dir, rel)}): ${reason}`,
|
||||
'Agent roles catalog source is not configured (expected an http(s):// base URL)',
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog is unavailable: ${reason}`,
|
||||
'Agent roles catalog is unavailable: source is not configured',
|
||||
);
|
||||
}
|
||||
return this.fetchRemote(source, rel);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { NoResultError } from 'kysely';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
|
||||
/**
|
||||
@@ -355,6 +356,68 @@ describe('ShareAliasService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a concurrent-delete race in the SWAP branch to a retryable 409 (not a 200-without-alias)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// Name points at another page; reassign confirmed -> swap branch.
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-other',
|
||||
});
|
||||
// A concurrent removeAlias deleted the row between read and UPDATE, so the
|
||||
// repo's executeTakeFirstOrThrow finds 0 rows and throws NoResultError.
|
||||
shareAliasRepo.updatePageId.mockRejectedValue(
|
||||
new NoResultError({} as any),
|
||||
);
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
confirmReassign: true,
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
// Crucially NOT a resolved 200 carrying `undefined` as the alias.
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a concurrent-delete race in the RENAME branch to a retryable 409 (not a generic 400)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// New slug is free, but the page already owns an alias we rename in place.
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.findByPageId.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'te',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
// The row vanished before the UPDATE; repo throws NoResultError rather
|
||||
// than returning undefined (which would dereference undefined.id -> 400).
|
||||
shareAliasRepo.updateAlias.mockRejectedValue(new NoResultError({} as any));
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'ted',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect(err).not.toBeInstanceOf(BadRequestException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a non-unique-violation db error to BadRequest (Failed to set alias)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
|
||||
@@ -11,21 +11,21 @@ import { Page, ShareAlias } from '@docmost/db/types/entity.types';
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
|
||||
/** Postgres unique_violation. Two unique indexes can raise it on this table. */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
import {
|
||||
executeTx,
|
||||
isUniqueViolation,
|
||||
violatedConstraint,
|
||||
} from '@docmost/db/utils';
|
||||
import { NoResultError } from 'kysely';
|
||||
|
||||
/**
|
||||
* Unique index names from the share_aliases migrations. The `postgres@3.x`
|
||||
* driver (kysely-postgres-js) surfaces the violated constraint as
|
||||
* `err.constraint_name` (NOT `.constraint`); we keep `.constraint` only as a
|
||||
* defensive fallback for other drivers.
|
||||
* - ALIAS: `(workspace_id, alias)` -> the vanity NAME is taken.
|
||||
* Unique index name from the share_aliases migrations whose violation we map to
|
||||
* a DISTINCT, non-misleading outcome:
|
||||
* - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL`
|
||||
* -> a concurrent writer already gave THIS page an alias.
|
||||
* The `(workspace_id, alias)` index (the vanity NAME being taken) needs no
|
||||
* constant: it is the default "Alias already taken" mapping.
|
||||
*/
|
||||
const UNIQUE_ALIAS_INDEX = 'share_aliases_workspace_id_alias_unique';
|
||||
const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique';
|
||||
|
||||
export interface ResolvedAliasTarget {
|
||||
@@ -171,11 +171,23 @@ export class ShareAliasService {
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// The row we read was deleted (concurrent `removeAlias`) before our UPDATE
|
||||
// matched it, so `executeTakeFirstOrThrow` found no row. Surface a
|
||||
// retryable conflict instead of a 200-without-alias (swap branch) or a
|
||||
// generic 400 from dereferencing `undefined.id` (rename branch).
|
||||
if (err instanceof NoResultError) {
|
||||
this.logger.warn(
|
||||
'share alias update matched no row (concurrent-delete race)',
|
||||
);
|
||||
throw new ConflictException({
|
||||
message: 'The address changed concurrently, please retry',
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
// A unique index fired. Which one decides the message — always log the
|
||||
// constraint so the race is diagnosable.
|
||||
if (err?.code === PG_UNIQUE_VIOLATION) {
|
||||
const constraint: string | undefined =
|
||||
err?.constraint_name ?? err?.constraint;
|
||||
if (isUniqueViolation(err)) {
|
||||
const constraint = violatedConstraint(err);
|
||||
this.logger.warn(
|
||||
`share alias unique violation on ${constraint ?? '<unknown>'}`,
|
||||
);
|
||||
@@ -189,13 +201,8 @@ export class ShareAliasService {
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
// `(workspace_id, alias)` (UNIQUE_ALIAS_INDEX) or any other/unknown
|
||||
// unique index: treat as the vanity name being claimed first.
|
||||
if (constraint && constraint !== UNIQUE_ALIAS_INDEX) {
|
||||
this.logger.warn(
|
||||
`unexpected unique index ${constraint} mapped to "Alias already taken"`,
|
||||
);
|
||||
}
|
||||
// `(workspace_id, alias)` or any other/unknown unique index: treat as
|
||||
// the vanity name being claimed first.
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
}
|
||||
this.logger.error(err);
|
||||
|
||||
85
apps/server/src/database/repos/ai-chat/ai-chat.repo.spec.ts
Normal file
85
apps/server/src/database/repos/ai-chat/ai-chat.repo.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AiChatRepo } from './ai-chat.repo';
|
||||
import type { KyselyDB } from '../../types/kysely.types';
|
||||
|
||||
/**
|
||||
* Unit test for AiChatRepo.findLatestByPage — the "bound chat" resolver behind
|
||||
* #191 (auto-open the last chat created on a document). It builds the scoping
|
||||
* query, so we assert the EXACT predicates/ordering the spec mandates over a
|
||||
* chainable builder mock (no live DB): user + workspace + page scope, the
|
||||
* deletedAt filter, newest-by-createdAt with an id tiebreaker, limit 1. A
|
||||
* live-Postgres ordering test is out of scope for this pure unit test.
|
||||
*/
|
||||
describe('AiChatRepo.findLatestByPage', () => {
|
||||
type Recorded = {
|
||||
table?: string;
|
||||
wheres: Array<[string, string, unknown]>;
|
||||
orderBys: Array<[string, string]>;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
function makeDb(result: unknown): { db: KyselyDB; rec: Recorded } {
|
||||
const rec: Recorded = { wheres: [], orderBys: [] };
|
||||
const builder: Record<string, unknown> = {};
|
||||
const chain = () => builder;
|
||||
builder.selectAll = chain;
|
||||
builder.where = (col: string, op: string, val: unknown) => {
|
||||
rec.wheres.push([col, op, val]);
|
||||
return builder;
|
||||
};
|
||||
builder.orderBy = (col: string, dir: string) => {
|
||||
rec.orderBys.push([col, dir]);
|
||||
return builder;
|
||||
};
|
||||
builder.limit = (n: number) => {
|
||||
rec.limit = n;
|
||||
return builder;
|
||||
};
|
||||
builder.executeTakeFirst = () => Promise.resolve(result);
|
||||
const db = {
|
||||
selectFrom: (table: string) => {
|
||||
rec.table = table;
|
||||
return builder;
|
||||
},
|
||||
} as unknown as KyselyDB;
|
||||
return { db, rec };
|
||||
}
|
||||
|
||||
it('returns the matched chat and scopes by user + workspace + page (deletedAt null)', async () => {
|
||||
const chat = { id: 'c1', creatorId: 'u1', workspaceId: 'ws1', pageId: 'p1' };
|
||||
const { db, rec } = makeDb(chat);
|
||||
const repo = new AiChatRepo(db);
|
||||
|
||||
const res = await repo.findLatestByPage('u1', 'ws1', 'p1');
|
||||
|
||||
expect(res).toBe(chat);
|
||||
expect(rec.table).toBe('aiChats');
|
||||
expect(rec.wheres).toEqual(
|
||||
expect.arrayContaining([
|
||||
['creatorId', '=', 'u1'],
|
||||
['workspaceId', '=', 'ws1'],
|
||||
['pageId', '=', 'p1'],
|
||||
['deletedAt', 'is', null],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('orders newest-first by createdAt then id, limit 1', async () => {
|
||||
const { db, rec } = makeDb(undefined);
|
||||
const repo = new AiChatRepo(db);
|
||||
|
||||
await repo.findLatestByPage('u1', 'ws1', 'p1');
|
||||
|
||||
expect(rec.orderBys).toEqual([
|
||||
['createdAt', 'desc'],
|
||||
['id', 'desc'],
|
||||
]);
|
||||
expect(rec.limit).toBe(1);
|
||||
});
|
||||
|
||||
it('returns undefined when the page has no owned chat', async () => {
|
||||
const { db } = makeDb(undefined);
|
||||
const repo = new AiChatRepo(db);
|
||||
|
||||
await expect(repo.findLatestByPage('u1', 'ws1', 'p1')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,32 @@ export class AiChatRepo {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The "bound chat" for a document: the requesting user's most recently
|
||||
* created, non-deleted chat whose origin page is `pageId`. Auto-opened when
|
||||
* the AI chat window is opened on that page. Newest-by-createdAt wins, so a
|
||||
* chat created later on the same page supersedes earlier ones — exactly how
|
||||
* "new chat -> becomes the bound one" falls out for free. Scoped to the user +
|
||||
* workspace, so a foreign pageId can only ever match the caller's own chats.
|
||||
*/
|
||||
async findLatestByPage(
|
||||
creatorId: string,
|
||||
workspaceId: string,
|
||||
pageId: string,
|
||||
): Promise<AiChat | undefined> {
|
||||
return this.db
|
||||
.selectFrom('aiChats')
|
||||
.selectAll('aiChats')
|
||||
.where('creatorId', '=', creatorId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('pageId', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.orderBy('id', 'desc') // stable tiebreaker, mirrors findByCreator's cursor
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insert(
|
||||
insertable: InsertableAiChat,
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { dbOrTx, isUniqueViolation } from '@docmost/db/utils';
|
||||
|
||||
export const FavoriteType = {
|
||||
PAGE: 'page',
|
||||
@@ -29,7 +29,8 @@ export class FavoriteRepo {
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
} catch (err: any) {
|
||||
if (err?.code === '23505') return undefined;
|
||||
// Idempotent favorite: a duplicate (already-favorited) is not an error.
|
||||
if (isUniqueViolation(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,9 @@ describe('ShareAliasRepo', () => {
|
||||
return builder;
|
||||
}),
|
||||
returning: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }),
|
||||
// Retarget uses executeTakeFirstOrThrow so a row reaped by a concurrent
|
||||
// delete (0 rows matched) raises NoResultError instead of returning undefined.
|
||||
executeTakeFirstOrThrow: jest.fn().mockResolvedValue({ id: 'a-1' }),
|
||||
};
|
||||
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
@@ -121,7 +123,11 @@ describe('ShareAliasRepo', () => {
|
||||
return builder;
|
||||
}),
|
||||
returning: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1', alias: 'ted' }),
|
||||
// Rename uses executeTakeFirstOrThrow so a row reaped by a concurrent
|
||||
// delete (0 rows matched) raises NoResultError instead of returning undefined.
|
||||
executeTakeFirstOrThrow: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'a-1', alias: 'ted' }),
|
||||
};
|
||||
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
|
||||
const repo = new ShareAliasRepo(db);
|
||||
|
||||
@@ -92,6 +92,12 @@ export class ShareAliasRepo {
|
||||
* Rename an existing alias row in place (the vanity-slug edit, e.g.
|
||||
* `te` -> `ted`). Keeps the row's id/page_id/creator so the page's single
|
||||
* alias pointer is preserved — only the human-readable name changes.
|
||||
*
|
||||
* Uses `executeTakeFirstOrThrow`: if a concurrent `delete` reaps this row
|
||||
* between the service's read and this UPDATE (READ COMMITTED), the UPDATE
|
||||
* matches 0 rows and kysely throws `NoResultError` rather than returning
|
||||
* `undefined` for a `Promise<ShareAlias>`. The service maps that to a
|
||||
* retryable conflict instead of dereferencing `undefined.id`.
|
||||
*/
|
||||
async updateAlias(
|
||||
id: string,
|
||||
@@ -105,7 +111,7 @@ export class ShareAliasRepo {
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +133,15 @@ export class ShareAliasRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Retarget an existing alias to a new page (the "swap" operation). */
|
||||
/**
|
||||
* Retarget an existing alias to a new page (the "swap" operation).
|
||||
*
|
||||
* Uses `executeTakeFirstOrThrow`: if a concurrent `delete` reaps this row
|
||||
* between the service's read and this UPDATE, the UPDATE matches 0 rows and
|
||||
* kysely throws `NoResultError` instead of returning `undefined` into the 200
|
||||
* response (a "success" with no alias). The service maps that to a retryable
|
||||
* conflict.
|
||||
*/
|
||||
async updatePageId(
|
||||
id: string,
|
||||
pageId: string,
|
||||
@@ -140,7 +154,7 @@ export class ShareAliasRepo {
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async delete(
|
||||
|
||||
51
apps/server/src/database/unique-violation.spec.ts
Normal file
51
apps/server/src/database/unique-violation.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { isUniqueViolation, violatedConstraint } from './utils';
|
||||
|
||||
/**
|
||||
* Unit tests for the driver-bound Postgres unique-violation helpers extracted
|
||||
* from the share-alias service (and now shared with favorite.repo). They encode
|
||||
* two `kysely-postgres-js` / `postgres@3.x` quirks: the SQLSTATE is the string
|
||||
* `'23505'`, and the violated index name arrives as `constraint_name` (with
|
||||
* `constraint` only a fallback for other drivers).
|
||||
*/
|
||||
describe('isUniqueViolation', () => {
|
||||
it('is true for a 23505 error', () => {
|
||||
expect(isUniqueViolation({ code: '23505' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for any other code', () => {
|
||||
expect(isUniqueViolation({ code: '08006' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when there is no code / not an object', () => {
|
||||
expect(isUniqueViolation({})).toBe(false);
|
||||
expect(isUniqueViolation(null)).toBe(false);
|
||||
expect(isUniqueViolation(undefined)).toBe(false);
|
||||
expect(isUniqueViolation(new Error('boom'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('violatedConstraint', () => {
|
||||
it('reads the postgres@3.x `constraint_name` field', () => {
|
||||
expect(
|
||||
violatedConstraint({ code: '23505', constraint_name: 'idx_a' }),
|
||||
).toBe('idx_a');
|
||||
});
|
||||
|
||||
it('falls back to `constraint` when `constraint_name` is absent', () => {
|
||||
expect(violatedConstraint({ code: '23505', constraint: 'idx_b' })).toBe(
|
||||
'idx_b',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers `constraint_name` over `constraint` when both are present', () => {
|
||||
expect(
|
||||
violatedConstraint({ constraint_name: 'idx_a', constraint: 'idx_b' }),
|
||||
).toBe('idx_a');
|
||||
});
|
||||
|
||||
it('is undefined when neither field is present', () => {
|
||||
expect(violatedConstraint({ code: '23505' })).toBeUndefined();
|
||||
expect(violatedConstraint(null)).toBeUndefined();
|
||||
expect(violatedConstraint(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,35 @@ export function dbOrTx(
|
||||
}
|
||||
}
|
||||
|
||||
/** Postgres `unique_violation` SQLSTATE — raised when a write hits a UNIQUE index. */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
/**
|
||||
* Whether `err` is a Postgres unique-violation (SQLSTATE `23505`). THE single
|
||||
* check so repos/services stop re-hardcoding the magic code.
|
||||
*
|
||||
* NOTE (#222): `core/ai-chat/roles/ai-agent-roles.service.ts` still carries its
|
||||
* own inline `23505` check on a separate, unmerged branch; it should adopt this
|
||||
* helper (and {@link violatedConstraint}) after #227 lands.
|
||||
*/
|
||||
export function isUniqueViolation(err: unknown): boolean {
|
||||
return (err as { code?: unknown } | null | undefined)?.code === PG_UNIQUE_VIOLATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the UNIQUE index/constraint a `23505` error violated, or
|
||||
* undefined. The `kysely-postgres-js` / `postgres@3.x` driver surfaces it as
|
||||
* `err.constraint_name` (NOT `.constraint`); `.constraint` is kept only as a
|
||||
* defensive fallback for other drivers.
|
||||
*/
|
||||
export function violatedConstraint(err: unknown): string | undefined {
|
||||
const e = err as
|
||||
| { constraint_name?: string; constraint?: string }
|
||||
| null
|
||||
| undefined;
|
||||
return e?.constraint_name ?? e?.constraint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a JS array/object as a `jsonb` column value, working around a postgres
|
||||
* driver double-encoding quirk. THE single implementation — repos that persist
|
||||
|
||||
@@ -290,11 +290,14 @@ export class EnvironmentService {
|
||||
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
|
||||
|
||||
getAiAgentRolesCatalogSource(): string {
|
||||
// Catalog location. http(s):// URL => fetched remotely; anything else => a
|
||||
// local filesystem directory. Defaults to the in-repo folder (dev). In prod
|
||||
// set this to the raw GitHub base URL of the catalog repo. Unlike the AI_*
|
||||
// getters above this is INFRA config (where the catalog lives), not
|
||||
// provider/model config — so an env var here is appropriate.
|
||||
// Catalog location: an http(s):// base URL the catalog is fetched from.
|
||||
// The image ships a per-branch default for this baked in at build time
|
||||
// (Dockerfile ARG AI_AGENT_ROLES_CATALOG_URL, set per-branch in CI), but it
|
||||
// is overridable at runtime via the env var (this getter returns that
|
||||
// runtime value). Local-filesystem sources are no longer supported.
|
||||
// Empty/unset => the catalog is unavailable (the provider returns 502).
|
||||
// This is INFRA config (where the catalog lives), not provider/model
|
||||
// config, so an env var is appropriate.
|
||||
return this.configService.get<string>('AI_AGENT_ROLES_CATALOG_URL', '');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user