Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions README.ko.md

Large diffs are not rendered by default.

54 changes: 39 additions & 15 deletions README.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions deploy/nginx.conf.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Example production reverse-proxy for pixel-agent-lab (monitor.moss.land).
#
# The app is a static SPA (serve `dist/`) that calls three same-origin API
# prefixes at runtime. The Vite dev proxy only exists during `npm run dev`, so
# production MUST provide these three proxies itself. Path rewrites below mirror
# vite.config.ts. Adjust upstream host:port to match your deployment.
# The app is a static single-page site (serve `dist/`, with no SPA fallback)
# that calls three same-origin API prefixes at runtime. Vite's proxies for them
# exist only under `npm run dev` and `npm run preview`, so production MUST
# provide these three proxies itself. Path rewrites below mirror vite.config.ts.
# Adjust upstream host:port to match your deployment.
#
# This is a reference configuration for nginx 1.18: the routes, rewrites and
# headers monitor.moss.land needs, not a byte-for-byte copy of a running vhost.
Expand Down
30 changes: 20 additions & 10 deletions docs/mossland-services-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@

본 문서는 실제 화면 탐색(대시보드/세부 메뉴 확인) 기준으로, 세 서비스의 역할·입출력·연계 구조를 실무적으로 이해하기 쉽게 정리한다.

> **문서 상태 (2026-09 기준)**
>
> - 2026-03에 세 서비스의 화면을 직접 탐색하며 정리한 문서다. 2·3·4절의 화면 설명은 그 시점 기준이며, 이후 바뀌었을 수 있다.
> - 1·6·9절의 운영 루프와 8절의 데이터 핸드오프 계약, 그리고 각 서비스 I/O 가운데 다른 서비스와 주고받는 항목(`(개념)` 표시)은 **개념적 설계 스케치**이며 실제 동작이 아니다. 현재 세 서비스는 각자의 수집기로 독립적으로 운영되며, 서비스 간 데이터 전달은 구현되어 있지 않다. 이 모니터 상세 화면의 단계 이름도 각 서비스의 작업 흐름을 설명할 뿐이다. Algora 벨트에서는 AO·Bridge의 신호도 Algora의 단계를 따라 움직이지만, 이는 모니터가 세 서비스에서 각각 읽어 온 신호를 하나로 합친 것이며 서비스 간 전달이 아니다.
> - Algora는 [MIP-1 생태계 레지스트리](https://links.moss.land/ecosystem-registry.json)에서 `archive` 단계로 분류되어 있다. 정기 보고서 생성은 2026-09-02에 중단되었고, 도메인과 기록은 읽기 전용으로 보존된다.

---

## 1) 전체 구조: 3-레이어 운영 루프
## 1) 전체 구조: 3-레이어 운영 루프 (개념)

Mossland 3개 서비스는 기능이 중복되는 제품군이 아니라, 하나의 거버넌스 운영 루프를 분담한다.
Mossland 3개 서비스는 기능이 중복되는 제품군이 아니라, 개념적으로는 하나의 거버넌스 운영 루프를 나누어 맡는 구조로 볼 수 있다. 아래 루프는 세 서비스의 책임이 어떻게 나뉘는지를 설명하는 모델이며, 현재 이 루프를 잇는 서비스 간 데이터 전달은 구현되어 있지 않다.

1. **Algora (Sense & Detect)**
- 다중 소스 신호 수집
Expand All @@ -29,7 +35,7 @@ Mossland 3개 서비스는 기능이 중복되는 제품군이 아니라, 하나
- 결과 검증(Outcome/Proof)
- 실행 결과 환류

핵심 파이프라인:
개념 파이프라인:

`Signals → Issues → Debates/Plans → Execution/Delegation → Outcomes/Proof → Feedback`

Expand Down Expand Up @@ -88,7 +94,7 @@ Mossland 3개 서비스는 기능이 중복되는 제품군이 아니라, 하나

- 구조화된 signal 레코드
- issue 후보 및 우선순위
- AO에 전달 가능한 의사결정 재료(문제 컨텍스트)
- (개념) AO에 전달할 수 있는 의사결정 재료(문제 컨텍스트) — 현재 AO로 전달되지 않음

---

Expand Down Expand Up @@ -137,14 +143,14 @@ Projects 탭에서 계획의 산출물이 코드 프로젝트로 연결되는

**Input**

- Algora가 만든 신호/이슈 컨텍스트
- (개념) Algora가 만든 신호/이슈 컨텍스트 — 현재는 AO 자체 수집기로 신호를 모음
- 목표/정책/제약

**Output**

- Ideas / Debates 결과
- Plans / Projects / 실행 명세
- Bridge에 전달할 작업 단위(task graph)
- (개념) Bridge에 전달할 작업 단위(task graph) — 현재 Bridge로 전달되지 않음

---

Expand Down Expand Up @@ -187,14 +193,14 @@ Dashboard에서 확인되는 KPI:

**Input**

- AO에서 확정된 계획/태스크/우선순위
- (개념) AO에서 확정된 계획/태스크/우선순위 — 현재는 Bridge 자체 어댑터로 신호를 모음

**Output**

- 실행 기록(Execution Record)
- 검증/통과 여부(Verified, Passed)
- 신뢰 지표(Trust Scores)
- 상위 레이어 환류용 결과 이벤트
- (개념) 상위 레이어 환류용 결과 이벤트

---

Expand All @@ -215,7 +221,9 @@ Dashboard에서 확인되는 KPI:

---

## 6) 관계 모델: 실제 운영 시나리오
## 6) 관계 모델: 개념적 운영 시나리오 (설계 스케치)

아래 단계는 세 서비스가 서로 연결된다면 어떻게 이어질지를 보여 주는 설계 스케치다. 현재 각 단계는 서로 다른 서비스 안에서 독립적으로 일어나며, 한 단계의 결과가 다음 서비스로 전달되지 않는다.

### Step 1. 감지 (Algora)

Expand Down Expand Up @@ -267,6 +275,8 @@ Dashboard에서 확인되는 KPI:

## 8) 데이터 핸드오프 계약(개념)

아래 필드는 제안된 형태다. 현재 어느 서비스도 이 계약으로 데이터를 주고받지 않는다.

### Algora → AO

- `signal_id`, `issue_id`
Expand Down Expand Up @@ -297,4 +307,4 @@ Dashboard에서 확인되는 KPI:
- **AO**는 “그래서 무엇을 할지”를 토론과 계획으로 만든다.
- **Bridge**는 “실제로 무엇이 수행됐는지”를 검증 가능한 결과로 남긴다.

이 3개가 결합되어 Mossland의 에이전트 거버넌스 운영 루프를 구성한다.
개념적으로는 이 3개가 결합되어 Mossland의 에이전트 거버넌스 운영 루프를 이룬다. 현재는 세 서비스가 독립적으로 운영되며, 이 루프를 잇는 서비스 간 데이터 전달은 구현되어 있지 않다.
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<title>Mossland Space Hub — Governance Monitor</title>
<meta
name="description"
content="Live pixel-art map of the Mossland service ecosystem, with real-time belts for the governance services that stream data — Algora (Sense), AO (Plan), and Bridge (Execute)."
content="Pixel-art map of the Mossland service ecosystem and the health its services report, with detail views that illustrate the workflows of Algora (Sense), AO (Plan), and Bridge (Execute)."
/>

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Expand All @@ -23,7 +23,7 @@
<meta property="og:title" content="Mossland Space Hub — Governance Monitor" />
<meta
property="og:description"
content="Live pixel-art map of the Mossland service ecosystem, with real-time belts for Algora (Sense), AO (Plan), and Bridge (Execute)."
content="Pixel-art map of the Mossland service ecosystem and the health its services report, with detail views that illustrate the workflows of Algora (Sense), AO (Plan), and Bridge (Execute)."
/>
<meta property="og:url" content="https://monitor.moss.land/" />
<meta property="og:image" content="https://monitor.moss.land/og-image.png" />
Expand All @@ -39,7 +39,7 @@
<meta name="twitter:title" content="Mossland Space Hub — Governance Monitor" />
<meta
name="twitter:description"
content="Live pixel-art dashboard of Mossland's three independent governance services: Algora, AO, and Bridge."
content="Pixel-art map of the Mossland ecosystem and the health its services report, with workflow views for Algora, AO, and Bridge."
/>
<meta name="twitter:image" content="https://monitor.moss.land/og-image.png" />
</head>
Expand Down
Binary file modified public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions public/og-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions src/scenes/SpaceHubScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type ZoneKey = "hub" | "algora" | "ao" | "bridge";

// The map lives below the three service zones so the existing belts keep their
// coordinates. It is the default view: the ecosystem is the subject, and a belt
// is the detail you open for the two services that actually stream.
// is the detail you open for the three services whose data it reads.
// Separation only — the hub's own depth band (see HubMap) is what actually
// keeps the belt zones from painting over it, since Phaser depth is global
// and the hub overscans this rectangle by far more than any gap could cover.
Expand Down Expand Up @@ -83,7 +83,9 @@ export class SpaceHubScene extends Phaser.Scene {
this.drawSpaceBackground();

// header bar
this.add.text(W / 2, 16, "MOSSLAND SPACE HUB | ecosystem map \u00b7 live belts for the services that stream", {
// Polled, not streamed, and the belts illustrate workflows (README,
// "Reading the data correctly"): the header claims neither.
this.add.text(W / 2, 16, "MOSSLAND SPACE HUB | ecosystem map \u00b7 workflow views for the services we read", {
fontFamily: "monospace", fontSize: "10px", color: "#94a3b8",
backgroundColor: "#0b1226cc", padding: { x: 10, y: 4 },
}).setOrigin(0.5).setDepth(100);
Expand Down
12 changes: 12 additions & 0 deletions src/services/ecosystem-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import { getJSON, getJSONWithStatus, isRecord } from "./http.ts";

// Both of these are served with `Access-Control-Allow-Origin: *`, so they are
// fetched cross-origin directly and need no entry in the monitor's nginx proxy.
// They must stay inside the page's Content-Security-Policy `connect-src`
// (deploy/nginx.conf.example, `location /`), though, as must every registry
// `statusUrl` that fetchServiceHealth reads. Development and CI send no CSP, so
// an address moved to another host passes every check here and is blocked in
// production only, where the fetch throws. What that costs depends on which
// address moved. The registry: it never loads, so nothing is drawn, and the
// map and the sidebar say "Registry unreachable — retrying" on every retry.
// The aggregate: city reads as unmeasured, and no other service has city's
// reading to fall back on. A statusUrl: that service falls back to city's
// second-hand reading if city probes it, or else reads as unmeasured. None of
// these shows a service as down, and nothing on the page names the policy as
// the cause.
const REGISTRY_URL = "https://links.moss.land/ecosystem-registry.json";
/** city.moss.land's aggregate — which is also city's own registry `statusUrl`. */
export const HEALTH_URL = "https://city.moss.land/api/health";
Expand Down
5 changes: 3 additions & 2 deletions src/services/ecosystem-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,9 @@ export class EcosystemFeed {
// landed for STALE_AFTER_MS the readings are shown as stale — dimmed,
// still, dated — instead of as current for as long as the network is
// gone. That happens only when *every* request failed, which is the
// viewer's own connection: a service that failed on its own drops to
// unmeasured at once, in a sweep that did land.
// viewer's own connection: a service that failed on its own falls back
// at once, in a sweep that did land, to city's second-hand reading if
// city probes it, or else to unmeasured.
if (firstHand.length === 0 && !aggregate) return false;

const entries = new Map<string, HealthEntry>();
Expand Down
4 changes: 2 additions & 2 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,8 @@ h1 { margin: 0; font-size: 1rem; color: #eaf2ff; letter-spacing: 0.2px; }

.eco-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Streaming services are the two with real telemetry — the only ones allowed
to look fully "on". */
/* Streaming services are the three whose data this monitor reads (Algora, AO,
Bridge) — the only ones allowed to look fully "on". */
.eco-row.streaming .eco-name { color: #f1f5f9; font-weight: 600; }

/* Archived: still on the map, visibly not alive. */
Expand Down
10 changes: 7 additions & 3 deletions src/ui/Sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,11 @@ export function initSidebar(): void {
}

export function setConnectionStatus(state: ConnState): void {
setHTML(connEl, connMarkup(state, "Real-time service data"));
// What LIVE means, in the About text's words. It used to say "Real-time
// service data", which this is not: the data is polled, and the belts
// only illustrate workflows. The next 500 ms refresh replaces the suffix
// with the queue size (updateSidebar).
setHTML(connEl, connMarkup(state, "a data API responded"));
}

/** Single source of truth for the status line. "connecting" is a real state:
Expand Down Expand Up @@ -199,8 +203,8 @@ function renderEcosystem(markup: string): void {
*
* The rule: a row may only look as alive as the data behind it. `stream`
* services are ones this monitor actually polls, `health` ones have a reading —
* first-hand from their own /api/health, or city.moss.land's aggregate as the
* fallback — and `listed` ones we know nothing about beyond their registry
* first-hand from their own registry `statusUrl`, or city.moss.land's aggregate
* as the fallback — and `listed` ones we know nothing about beyond their registry
* entry, so those get a neutral dot, never a reassuring green.
*
* First, above the instrumentation counts, what the services report: the same
Expand Down
10 changes: 6 additions & 4 deletions src/ui/belt-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import { lapsed, liveBadge } from "./live-badge.ts";
* Each card also says whether its service answered this poll, with the
* sidebar's own badge (live-badge.ts): on a phone the sidebar is a closed
* drawer and the map's heading is hidden on a belt view, so nothing else on
* screen would. The lists are what the service last sent, and a failed read
* keeps them, so once a poll goes unanswered they are called what they then
* are — the last received, not the latest. The caches carry no times of
* their own, so the card gives no ages it would have to invent.
* screen would. The AO and Bridge lists are what the service last sent, and a
* failed read keeps them, so once a poll goes unanswered they are called what
* they then are — the last received, not the latest. The Algora belt holds
* all three services' signals, so its card only says Algora did not answer
* (see algoraHeadHtml). The caches carry no times of their own, so the card
* gives no ages it would have to invent.
*
* Every string comes from another service's JSON behind an unchecked cast,
* so each is checked for its type and escaped here, the sink.
Expand Down
63 changes: 63 additions & 0 deletions tests/page-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

/**
* What the page says about itself before anyone has opened it: the search and
* link-preview text in index.html, the install description in the manifest,
* and the words on the preview image.
*
* Previews show public/og-image.png, and no test reads it: what is checked is
* its source, public/og-image.svg. After changing that text, re-render the PNG
* from the repository root at 1200x630, e.g.
*
* "<Chrome>" --headless=new --hide-scrollbars --window-size=1200,630 \
* --screenshot=public/og-image.png public/og-image.svg
*
* (Chrome may not exit on its own once the file is written), and look at the
* result before committing. A render is not byte-for-byte reproducible, so
* nothing here can tell a stale PNG from a fresh one.
*
* These are the most-read words the monitor has, and the one place nothing
* at runtime keeps honest. They called the belts "real-time" for months after
* the README and the in-app About text said the data is polled and the belts
* only illustrate workflows. The rule is the map's own: say no more than is
* measured. Reads that repeat every 15 s to 10 min are not a stream, and a
* Bridge proposal drawn on a timer is not a live one.
*/

const read = (rel: string): string =>
readFileSync(fileURLToPath(new URL(`../${rel}`, import.meta.url)), "utf8");

/** Claims the data behind the page does not back. */
const OVERCLAIM = /real[\s-]?time|\bstream/i;

/** A <meta> tag's content, by its name or property; the tags span lines. */
function meta(html: string, key: string): string {
const m = new RegExp(`<meta\\s+(?:name|property)="${key}"\\s+content="([^"]*)"`).exec(html);
expect(m, `index.html has no ${key}`).not.toBeNull();
return m![1];
}

describe("the page's own copy", () => {
const html = read("index.html");

it.each(["description", "og:description", "twitter:description", "og:image:alt"])(
"index.html %s claims no real-time stream", key => {
const text = meta(html, key);
expect(text.length).toBeGreaterThan(0);
expect(text).not.toMatch(OVERCLAIM);
});

it("the manifest's description claims none either", () => {
const { description } = JSON.parse(read("public/manifest.webmanifest")) as { description: string };
expect(description.length).toBeGreaterThan(0);
expect(description).not.toMatch(OVERCLAIM);
});

it("nor does the preview image's source, og-image.svg", () => {
const words = [...read("public/og-image.svg").matchAll(/<text\b[^>]*>([^<]*)<\/text>/g)].map(m => m[1]);
expect(words.length).toBeGreaterThan(0);
for (const w of words) expect(w).not.toMatch(OVERCLAIM);
});
});
4 changes: 3 additions & 1 deletion tests/sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ describe("panel rewrites", () => {
// own last markup memoised, skip, and leave this one-off text up.
updateSidebar(bridgeWith(LIVE));
setConnectionStatus("live");
expect(textOf(els["#connStatus"].innerHTML)).toBe("LIVE — Real-time service data");
// Polled, not streamed: the line says what LIVE rests on, never
// "real-time" (README, "Reading the data correctly").
expect(textOf(els["#connStatus"].innerHTML)).toBe("LIVE — a data API responded");
updateSidebar(bridgeWith(LIVE));
expect(textOf(els["#connStatus"].innerHTML)).toBe("LIVE — queue: 3");
});
Expand Down
8 changes: 4 additions & 4 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ function healthEndpoint(): Plugin {
* Phaser is ~95% of the shipped JavaScript and changes only when the lockfile
* does, so it is built as a chunk of its own (`build.rollupOptions` below).
* /assets/ is served `immutable`, and a separate chunk keeps its content hash
* across app-only deploys: a returning visitor re-downloads ~18 kB gzip of app
* code instead of ~350 kB. A Phaser, Rollup or esbuild bump still changes that
* chunk, as it should. Vite preloads it with <link rel="modulepreload">, so a
* first visit is no slower for it.
* across app-only deploys: a returning visitor re-downloads under 30 kB gzip
* of app code instead of ~360 kB. A Phaser, Rollup or esbuild bump still
* changes that chunk, as it should. Vite preloads it with
* <link rel="modulepreload">, so a first visit is no slower for it.
*
* That split trips Vite's chunk-size warning on every build, and Vite's limit
* is global: raising it far enough for Phaser (~1.2 MB minified) would also
Expand Down
Loading