RedlineKnowledge base

B6 Standalone

Stage 1: Redline serves its own dashboard and knowledge base beside its API, on the tailnet, in a QA and a production environment, and is proven there against real GitLab. Modelled on the Markdown Renderer service (fio-design-markdown-renderer): one deployable, the docs rendered by the thing they describe, build once and promote, persistent QA, smoke evidence per environment, lifecycle records together. Design: standalone.md.


T6.1 Dashboard site

  • Wave: 7
  • Depends on: T3.4, T3.5, T3.6
  • Size: L
  • Design: standalone.md (the dashboard)
  • Touches: site/** (new), GitHistoryFooter.astro, GitTrackChanges.astro, GitComments.astro, GitRedline.astro, GitChangedSince.astro, client.js (resolveServiceUrl), index.js, index.d.ts, package.json (scripts), .gitignore, .dockerignore, test/dashboard.test.js (new)
  • Why: the host that proves Redline on its own, with this repository’s kb/ as content.

Steps

  1. client.js: export resolveServiceUrl(value, base = globalThis.location?.href) returning an absolute origin-plus-path without a trailing slash: absolute http(s): values unchanged, a value starting with / resolved against base, anything else ''. createRedlineClient and getRedlineStore resolve their serviceUrl through it.
  2. Each component’s frontmatter keeps an absolute serviceUrl as today and also accepts one that starts with /; the <script> resolves it with resolveServiceUrl. The memoisation key in getRedlineStore uses the resolved URL.
  3. site/: an Astro 7 project ("@fridai/fio-redline": "file:..", @astrojs/markdown-remark exact, astro exact, its own package-lock.json) with markdown: { processor: unified() } and gitHistory({ blocks: true, contentRoots: [docsDir] }) where docsDir is process.env.REDLINE_DOCS_DIR || 'kb' relative to the repository root.
    • src/pages/index.astro: the overview: title, one paragraph on what Redline is, the build identity from import.meta.env.PUBLIC_REDLINE_VERSION (fallback development), and a status line bound to the store (data-dashboard-status, text from store.message()).
    • src/pages/kb/[...slug].astro: one page per Markdown file under docsDir (index files map to their folder), with a navigation list of every page, the five components with serviceUrl="/", siteId={process.env.REDLINE_SITE_ID || 'fio-redline'} and documentPath the file’s repository path, or, when REDLINE_DOCUMENT_PREFIX is set, that prefix plus the file’s path inside docsDir (the e2e build maps the fixture onto the mock’s src/content), and <article data-redline-document> holding the content. The footer gets meta={getGitMeta(<repository path>)} and id="history".
    • Plain CSS only; no design-system dependency (the registry token cannot read packages, see BLOCKERS.md T0.6).
  4. Root package.json scripts: "dashboard:build": "npm --prefix site run build", "dev:dashboard": "npm --prefix site run dev". Ignore site/node_modules/, site/dist/, site/.astro/ in .gitignore; in .dockerignore keep site/ excluded except !site/dist/ and !site/dist/**.

Acceptance

test/dashboard.test.js:

  • service url resolves relative to the page (resolveServiceUrl('/', 'https://qa-yqa.fio.sh/kb/x/') is https://qa-yqa.fio.sh; absolute values unchanged; javascript:x gives '')
  • dashboard builds every kb page with block ids (spawns npm run dashboard:build; for every kb/**/*.md a page exists under site/dist/kb/; site/dist/kb/index.html contains data-redline-block= and data-git-redline)
  • dashboard pages carry no absolute service host (no file under site/dist contains localhost:8787 or yqa.fio.sh in a data-service-url)
npm test 2>&1 | grep -c "^✔ service url resolves\|^✔ dashboard"   # prints 3
npm run test:e2e 2>&1 | tail -1                                     # all passed (relative serviceUrl does not break the fixture)

T6.2 Service serves the dashboard on its own origin

  • Wave: 8
  • Depends on: T6.1
  • Size: M
  • Design: standalone.md (one deployable)
  • Touches: service/src/static.js (new), service/src/server.js, service/Dockerfile, .gitlab-ci.yml (job site, needs on the container job), test/static.test.js (new)
  • Why: one origin for dashboard and API removes CORS and cross-site cookies from the standalone path, and makes the image the single artifact.

Steps

  1. service/src/static.js: createStaticHandler(directory) returning async (request) => Response | null. It answers only GET and HEAD for paths outside /v1/; resolves / and /x/ to index.html; refuses any path that normalises outside directory (404); serves the built 404 page with status 404 when the file is missing; sets Content-Type from the extension, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: public, max-age=31536000, immutable under /_astro/ and no-cache elsewhere.
  2. server.js: when SITE_DIR is set (the image sets /app/site), try the static handler before the API for non-/v1 paths.
  3. Dockerfile: COPY --chown=node:node site/dist ./site when present (the build fails loudly if site/dist is missing in CI); ENV SITE_DIR=/app/site.
  4. .gitlab-ci.yml: a site job in stage build (image node:22-alpine, GIT_DEPTH: "0", apk add --no-cache git, npm ci, npm --prefix site ci, PUBLIC_REDLINE_VERSION=$CALVER_VERSION npm run dashboard:build, artifacts site/dist/, rules as the container job); the container job gains needs: [{ job: site, artifacts: true }]. Cite the job’s purpose in a comment in the file’s style. Confirm in the pipeline log that the image contains /app/site/index.html; if the catalog component cannot take the artifact, record a blocker rather than building the site inside Docker.

Acceptance

test/static.test.js:

  • static handler serves the dashboard index and assets
  • static handler refuses paths outside the site directory (/../package.json, /%2e%2e/package.json → 404)
  • static handler leaves the api to the service (/v1/health → null)
  • node server serves the dashboard and the api on one origin (createNodeServer with SITE_DIR pointing at a temp directory; GET / 200 HTML, GET /v1/health 200 JSON)
npm test 2>&1 | grep -c "^✔ static handler\|^✔ node server serves"   # prints 4
npm run dashboard:build && npm run build:container && docker run --rm -d --name redline-t62 -p 8787:8787 --env-file <a valid env file> fio-redline:local && sleep 3 && curl -sf http://127.0.0.1:8787/ | grep -c data-dashboard-status && curl -sf http://127.0.0.1:8787/v1/health && docker rm -f redline-t62   # 1, then health JSON

T6.3 QA environment configuration

  • Wave: 7
  • Depends on: T4.1
  • Size: S
  • Design: standalone.md (artifact and environments)
  • Touches: ops/docker-compose.yml, ops/service.env.example, ops/service-qa.env.example (new), ops/.env.example (new), kb/setup/index.md (the experiment section), test/hardening.test.js
  • Why: QA tracks :verified and production :released, on one host, from one compose file.

Steps

  1. ops/docker-compose.yml: add service-qa under profiles: ["qa"], the same hardening as service, image: "${FIO_REDLINE_QA_IMAGE:?…}", env_file: ./service-qa.env, container_name: fio-redline-qa, network alias fio-redline-qa on ingress and gitlab, the same secrets except session_secret_qa (its own file ./secrets/session-secret-qa).
  2. ops/.env.example: FIO_REDLINE_IMAGE=glab.fio.sh:5050/fridai/fio-registry/fio-redline:released, FIO_REDLINE_QA_IMAGE=glab.fio.sh:5050/fridai/fio-registry/fio-redline:verified, GLAB_DOCKER_NETWORK=glab.fio.sh-bridge.
  3. ops/service-qa.env.example: the experiment values with PUBLIC_SERVICE_URL=https://qa-yqa.fio.sh and a SITES_JSON registering fio-redline (gitlabProjectId of fridai/fio-dep/fio-redline, gitlabProjectPath: "fridai/fio-dep/fio-redline", contentRoots: ["kb"], allowedOrigins: ["https://qa-yqa.fio.sh"], features.editing=false). ops/service.env.example gains the same fio-redline site with https://yqa.fio.sh.
  4. kb/setup/index.md: extend the experiment section with the QA host, the two OAuth redirect URIs (one application, one URI per line) and docker compose --profile qa up -d.
  5. test/hardening.test.js:
    • compose qa service tracks the verified channel under its own profile
    • experiment env examples register the fio-redline site on their own origin (parse both examples; allowedOrigins equals the own PUBLIC_SERVICE_URL)

Acceptance

npm test 2>&1 | grep -c "^✔ compose qa service\|^✔ experiment env examples"   # prints 2
# with ops/.env and ops/service*.env copied from the examples:
docker compose -f ops/docker-compose.yml config --services                 # service
docker compose -f ops/docker-compose.yml --profile qa config --services    # service, service-qa

T6.4 Dashboard end to end

  • Wave: 9
  • Depends on: T6.1, T6.2, T0.3
  • Size: M
  • Touches: playwright.config.js (a second service on 8788 serving the dashboard), test/e2e/run-service.js (honour REDLINE_E2E_PORT, REDLINE_E2E_SITE_DIR, REDLINE_E2E_ALLOWED_ORIGIN), test/e2e/dashboard.spec.js (new), .gitlab-ci.yml (e2e installs site/)
  • Why: proves the same-origin path in a browser against the mock, before any deployment.

Steps

  1. The dashboard is built for the fixture repository: REDLINE_DOCS_DIR=test/fixtures/site/src/content REDLINE_SITE_ID=demo REDLINE_DOCUMENT_PREFIX=src/content npm run dashboard:build, so its one page’s document path is src/content/doc.md, which the mock serves.
  2. A fourth webServer entry: build that dashboard, then node test/e2e/run-service.js with REDLINE_E2E_PORT=8788, REDLINE_E2E_SITE_DIR=site/dist, REDLINE_E2E_ALLOWED_ORIGIN=http://localhost:8788 and PUBLIC_SERVICE_URL=http://localhost:8788; url: http://localhost:8788/v1/health.
  3. Spec.

Acceptance

test/e2e/dashboard.spec.js:

  • dashboard serves a kb page and the api from one origin (http://localhost:8788/kb/doc/ has [data-redline-block="title:paragraph:paragraph"]; every component root reaches live or connect-required; no request leaves localhost:8788 except the OAuth redirect)
  • dashboard shows the proposal inline (toggle !7; the block’s del is Revised, its ins is Proposed)
  • dashboard connects gitlab on its own origin (connect; comments state live; the session cookie’s domain is localhost and the comment form is visible)
npx playwright test test/e2e/dashboard.spec.js 2>&1 | tail -3   # "3 passed"
npm run test:e2e 2>&1 | tail -3                                  # "19 passed"

T6.5 Live smoke spec

  • Wave: 9
  • Depends on: T6.1, T6.2, T4.2
  • Size: S
  • Touches: playwright.live.config.js (new), test/live/live.spec.js (new), package.json (script test:live)
  • Why: the browser half of the per-environment evidence: the same checks against QA and production, read-only.

Steps

  1. playwright.live.config.js: testDir: 'test/live', no webServer, use.baseURL from REDLINE_LIVE_URL (required; the config throws a clear error when it is missing), one chromium project, workers: 1.
  2. test/live/live.spec.js (read-only; it never writes to GitLab):
    • live health reports gitlab reachable (GET /v1/health: ok, gitlab.reachable)
    • live dashboard renders the knowledge base with block ids (/kb/ has at least one [data-redline-block])
    • live components reach a live state (every component root on /kb/ reaches live or connect-required within 15 seconds)
    • live history lists commits (open the footer; at least one commit row)
  3. "test:live": "playwright test -c playwright.live.config.js".

Acceptance

npm run test:live 2>&1 | tail -2        # without REDLINE_LIVE_URL: fails with the message naming the variable
REDLINE_LIVE_URL=http://localhost:8788 npx playwright test -c playwright.live.config.js 2>&1 | tail -1   # against the e2e dashboard service started by hand: "4 passed"

T6.6 Lifecycle records

  • Wave: 7
  • Depends on: none
  • Size: M
  • Touches: kb/lifecycle/index.md, kb/lifecycle/roadmap.md, kb/lifecycle/tech-debt.md, kb/lifecycle/decisions/index.md, kb/lifecycle/decisions/ADR-*.md (all new), kb/index.md (link the section)
  • Why: the renderer keeps intent, evidence and the cost of change in one place (its ADR-0024). Redline’s decisions so far live only in commit bodies and STATUS.md notes.

Steps

  1. roadmap.md in the renderer’s shape: Available now (what CI proves, with links), Adoption in progress (Stage 1 standalone, Stage 2 YYZ), Next, in priority order as a table of outcome and acceptance evidence, Decisions still needed (kb-package compatibility, service ownership).
  2. tech-debt.md, one TD-NNN heading each, with status and evidence: TD-001 verify rulesets blocked on a package-read token (T0.6); TD-002 block ids for publish-time rendered kb-package pages (harvester); TD-003 history from kb-package history.bundle instead of the build checkout; TD-004 a service site spanning several source projects (page → project, path); TD-005 anonymous state responses are public, max-age=15 (the store bypasses the cache; the header still misleads other clients); TD-006 the e2e suite runs on one worker because specs share one mock; TD-007 package-npm-publish tags every candidate latest, so npm i @fridai/fio-redline bypasses the verified/released gate the container image has.
  3. decisions/ADR-0001… in the renderer’s format (Status, Context, Decision, Why, Consequences, Evidence with commit SHAs, Revisit when), one each for: the gitlab-oauth identity mode; one build-time history index; semantic block ids stamped from source; one shared client store; failure states named by edge; inline redline replaces the side list as the primary view; standalone dashboard and API on one origin (Stage 1 before YYZ); build once and promote, persistent QA. decisions/index.md lists them.
  4. No content is moved from kb/plans/; the plan stays where it is.

Acceptance

ls kb/lifecycle/decisions/ADR-*.md | wc -l                       # 8
grep -c '^## TD-' kb/lifecycle/tech-debt.md                       # 7
grep -c '^## Revisit when' kb/lifecycle/decisions/ADR-*.md | grep -c ':1$'   # 8
node ../fio-kb-harvester/bin/kb-harvester.mjs publish --version 0.0.0-check --out /tmp/kb-check && node ../fio-kb-harvester/bin/kb-harvester.mjs verify   # "document governance OK."

(The last command needs a sibling checkout of fio-kb-harvester; if absent, record that and rely on the kb-publish job after merge.)


T6.7 QA and production deployment and evidence (human)

  • Wave: 10
  • Depends on: T4.3, T6.3, T6.4, T6.5, and a :verified image containing every task up to wave 9; production additionally a :released image (the operator promotes it)
  • Size: human
  • Replaces: T4.4

The executor sets this task to human and lists in STATUS.md what the operator provides. The operator performs, beyond T4.3:

  1. DNS and a host nginx TLS vhost for qa-yqa.fio.sh → fio-redline-qa:8787, by the same method as yqa.fio.sh.
  2. Add https://qa-yqa.fio.sh/v1/auth/gitlab/callback as a second redirect URI of the OAuth application.
  3. ops/.env, ops/service-qa.env, ops/secrets/session-secret-qa from the examples; cd ops && docker compose --profile qa pull && docker compose --profile qa up -d.
  4. QA evidence, from a tailnet device in this repository: npm run smoke -- https://qa-yqa.fio.sh fio-redline kb/index.md (exit 0, live or connect-required), the same off the tailnet (exit 2, offline-network), and REDLINE_LIVE_URL=https://qa-yqa.fio.sh npm run test:live (4 passed).
  5. Stage 1 demonstration on QA: open a merge request on fio-redline that changes a paragraph under kb/; on its QA page press the toggle and see the change inline; connect GitLab and leave an anchored comment; see the note in the [Document Review] kb/… issue under your own account. Record the merge request and issue URLs and a screenshot path.
  6. Promote to released (manual job), then the same smoke and live checks against https://yqa.fio.sh.

Evidence for STATUS.md: the smoke and live outputs per environment, both image tags from docker compose ps, the merge request and issue URLs, the screenshot path, and the date. No secrets, no cookies.


T6.8 Timeline scrubber

  • Wave: 10
  • Depends on: T6.1, T6.2, T3.5
  • Size: L
  • Design: operator decision 2026-10-11 (STATUS.md): a timeline like a camera app’s event scrubber. Commits sit left of now, open merge requests right of it. The playhead shows the page as it was at that point, with that point’s own change redlined (struck old text, highlighted new text); past now, a merge request marker shows the page as that proposal would make it.
  • Touches: semantic.js (anchorBlockId on delete operations), remark-redline-blocks.js (data-redline-fingerprint), service/src/gitlab.js and service/src/index.js (GET /v1/sites/:site/documents/version), contracts/openapi.yaml, client.js (getDocumentVersion, renderVersion, restoreVersion), GitTimeline.astro (new), index.js, index.d.ts, package.json, site/src/pages/kb/[...slug].astro, tests below.
  • Why: the dashboard showed every capability as an empty box waiting for a click. Scrubbing through a page’s past and proposed future is the thesis made visible: changes reviewed in short bites on the rendered page.

Steps

  1. diffDocumentBlocks gives each delete operation anchorBlockId: the id, in the after document, of the nearest preceding block that survives (matched or replaced), or null at the top. Additive; existing fields unchanged.
  2. The remark plugin also stamps data-redline-fingerprint (the block’s fingerprint), so the browser can reuse rendered blocks that did not change.
  3. GET /v1/sites/:site/documents/version?path=&ref=<sha>&base=<sha?>: the document at ref as blocks (id, type, content, fingerprint) and the operations from base (empty document when absent) to ref. Both refs must be hexadecimal commit ids. Documents over the size limit answer 413.
  4. client.js: getDocumentVersion on the client; renderVersion(root, version) rebuilds the article from the version’s blocks (cloning rendered elements whose id and fingerprint match, rendering the rest from source text: headings by level, paragraphs, list items grouped in a list, code in pre), then redlines the version’s operations in place: replace → word diff, insert → whole block marked inserted, delete → struck block after its anchorBlockId. restoreVersion(root) puts the original article back.
  5. GitTimeline.astro: a bar fixed to the bottom of the viewport with markers for each commit (by date), a now marker, and one marker per open merge request; a playhead that snaps to markers and moves by drag, wheel, arrow keys or click; a label with the point’s date, title and author. Moving to a commit loads version(ref=sha, base=first parent), to a merge request version(ref=headSha, base=baseSha); now restores the page. Versions are cached in the page; loads while scrubbing are debounced.
  6. Dashboard pages carry GitTimeline instead of GitRedline, GitChangedSince and GitTrackChanges; comments and the history footer stay. Deliberately updates the T6.1 and T6.4 assertions that named the removed components.

Acceptance

Unit:

  • semantic delete operations carry the anchor block in the after document
  • remark plugin stamps block fingerprints
  • document version returns blocks at a commit and its change from the parent
  • document version rejects refs that are not commit ids
  • version payload validates against the DocumentVersion schema

test/e2e/timeline.spec.js (dashboard on :8788):

  • timeline lists commits now and proposals in order
  • scrubbing to a commit shows the page as it was with that change redlined
  • scrubbing to a proposal shows the page as it would become
  • returning to now restores the page
  • the playhead moves with the keyboard
npm test 2>&1 | grep -c "^✔ semantic delete operations\|^✔ remark plugin stamps block fingerprints\|^✔ document version\|^✔ version payload"   # prints 5
npx playwright test test/e2e/timeline.spec.js 2>&1 | tail -1                                                                                 # "5 passed"

T6.9 Comments in the text, Word-style

  • Wave: 10
  • Depends on: T6.8, T3.4
  • Size: L
  • Design: operator decision 2026-10-11 (STATUS.md): comments work as in a word processor. Selecting text opens a small menu beside the selection with Comment; the comment is written in a popover there. Each unresolved comment’s text gets a dotted underline, and hovering (or focusing) it shows the thread with reply, resolve and delete. There is no comment box below the article.
  • Touches: GitInlineComments.astro (new), client.js (deleted blocks after a list item go after the list), index.d.ts, package.json, scripts/check-components.js, site/src/pages/kb/[...slug].astro, test/e2e/inline-comments.spec.js (new), test/e2e/dashboard.spec.js, test/e2e/helpers.js.

Steps

  1. GitInlineComments.astro (props serviceUrl, siteId, documentPath, contentSelector) renders nothing in the page flow except a line naming comments on the whole page, when there are any.
  2. On a selection inside the content, a menu appears beside it: Comment when the reader can comment, Connect GitLab to comment (the store’s connect URL) when a session is needed, nothing when live review is unavailable. Comment opens a popover with a text field, Post and Cancel (Escape cancels). Posting creates a published comment anchored to the selection (block id, offsets, selected text, fingerprint, base sha) and reloads the store.
  3. Each unresolved anchored discussion wraps its text in <span class="agic__mark" data-redline-comment="<discussion id>" tabindex="0"> (dotted underline), across inline elements if needed. Marks are redrawn on every new payload and are not drawn while the timeline shows a past version.
  4. Hovering or focusing a mark shows a card with the thread (author, time, body) and, for a connected reader, Reply (inline field), Resolve and Delete (two steps, as in T3.2). The card stays while the pointer is on it.
  5. Dashboard pages carry GitInlineComments instead of GitComments.

Acceptance

test/e2e/inline-comments.spec.js (dashboard on :8788):

  • selecting text opens a comment menu beside the selection
  • a comment made from the menu is underlined in the text and attributed in gitlab
  • hovering a commented passage shows the thread
  • replying and deleting from the hover card
  • resolving from the hover card removes the underline
  • the page has no comment box
npx playwright test test/e2e/inline-comments.spec.js 2>&1 | tail -1   # "6 passed"
Git history

Loading the page's history…