문서와 심볼

aidlc-docs 를 그대로 읽는다 · 읽기 전용
/home/sunny/enode-fixup-workshop2-visualizer연결 중
construction/plans/u-observe-api-code-generation-plan.md697 줄

U-관측API — Code Generation 계획

이 파일이 U-관측API Code Generation 의 정본이다. 여기 적힌 단계만
실행하고, 여기 없는 것은 만들지 않는다. 단계를 끝낼 때마다 그 자리에서
[x] 로 바꾼다.

이 유닛은 Functional Design 을 건너뛴다. 그래서 이 계획이 계획인 동시에
설계다 — U-바닥이 domain-entities.md · business-logic-model.md ·
business-rules.md 셋에 기대어 짧을 수 있었던 자리를, 여기서는 3절(응답
계약)과 4절(화면)이 진다.

건너뛰는 근거는 둘이다.

   API 쪽    응답 모양이 이미 값이다 — decisions.md 2절 · ADR-065 ·
             requirements.md 4.1절 · 8.4절 ① ①-a ①-b.  기존 행의 투영이고
             새 열이 0 이다 (aidlc-state.md 실행 계획 3절)

   화면 쪽    design/README.md 7절이 「화면이 있는 유닛은 이 문서와 pen 을
             읽는다」를 Functional Design 에 걸었는데, 그 읽기가 이미
             끝나 있다 — requirements.md 8절이 pen 을 실제로 열고 나온
             결과다.  8.3절이 버튼 전부, 8.5절이 C3 · C4 · C5 의 문구,
             8.6절이 어휘 정정 둘을 이미 값으로 갖고 있다.
             frontend-components.md 를 새로 내면 같은 것의 두 번째 정본이다

읽는 순서 — 3절이 밖으로 나가는 JSON, 4절이 화면, 5절이 단계다.


1. 유닛 맥락

1.1 이 유닛이 지는 FR

unit-of-work-story-map.md 의 U-관측API 블록 그대로다.

   FR-1   GET /v1/nodes 라우트
   FR-2   GET /v1/runs 라우트 + current[] + reject
   FR-4   임대는 GET /v1/nodes 에 내장.  별도 표면 없음
   FR-5   중앙 현황판 정적 파일 — S0 · S0b · S1 · S1b · S2
   FR-16  화면 몫 — draining 배지 두 국면

밖에서 보이는 변화가 처음 생긴다. 라우트가 15개에서 18개가 되고,
Mediator 가 HTML 을 내는 첫 자리가 선다.

1.2 의존

   U-관측API  ->  U-바닥      store.ListNodes · store.ListRuns
                              StepView.Chosen · contract.Policy
                              match.Reject 의 json 태그

U-바닥은 CP0 초록으로 닫혔다. 딛는 것이 전부 이미 있다 — 이 유닛은
store 에 함수를 더하지 않는다.

없는 의존도 값이다 (unit-of-work-dependency.md 6절).

   U-관측API  ->  U-대기열     없다.  QUEUED 는 그냥 또 하나의 state 문자열이다
   U-관측API  ->  U-drain      없다.  draining 은 nodes 열의 투영이다

1.3 뒤 유닛에 내주는 접점

unit-of-work-dependency.md 5절의 7 · 8 · 9 번이다. HTTP 접점이라
패키지 임포트가 아니다
— U-제어판이 이것을 부르는 것이 NFR-P1 · NFR-P2 가
요구하는 모양이다.

   GET /v1/nodes 의 노드 항목     U-제어판 · 화면    ADR-065 모양 + draining
   GET /v1/runs 의 행             화면              decisions.md 2절 + current[] + reject
   current[] 의 항목              화면              {node, step, attempt, started_at}

U-제어판은 GET /v1/nodes 의 자기 행과 GET /v1/runs/{id} 로 「현재 작업」과
「마지막 관측」을 조립한다 (FR-21 · FR-25). GET /v1/runs 목록은 안
부른다
— 제어판은 자기 노드 하나를 본다.

1.4 이 유닛이 소유하는 DB 실체

없다. 스키마를 안 건드리고 열을 안 더한다. current[] 는 steps 조인이고
reject 와 verdict 는 runs 에 이미 있다 (requirements.md 8.4절 ① —
「새 열 0」).


2. 파일 행렬

브라운필드다. 있는 파일은 제자리에서 고친다. _new · _modified 같은
사본을 만들지 않는다.

   신설    internal/api/list.go                    라우트 둘의 핸들러
           internal/api/list_test.go               API 테스트
           internal/api/ui/ui.go                   embed + Handler + 헤더 넷
           internal/api/ui/ui_test.go              DB 없이 도는 테스트
           internal/api/ui/assets/index.html       S0 · S1 · S2 의 뼈대 한 장
           internal/api/ui/assets/app.css
           internal/api/ui/assets/api.js           토큰 · fetch · 실패 카운트
           internal/api/ui/assets/fleet.js         카드 · 배지 · 카운트다운
           internal/api/ui/assets/runs.js          Run 목록 · 필터 칩
           internal/api/ui/assets/graph.js         S2 그래프 · 단계 상세
           internal/api/ui/assets/app.js           진입 · 화면 전환 · 5초 폴링

   수정    internal/api/api.go                     임포트 하나 + 등록 줄 셋

   부르기만 internal/store/list.go                 ListNodes · ListRuns
           internal/store/observe.go               Steps · Ledger

   안 건드림 internal/store 의 나머지 · internal/match · internal/contract
           cmd/ 다섯 · packaging/ · internal/config · internal/enode
           internal/record · internal/runctl · internal/schema
           design/ 전부 (START.md 5절 — 진행자만 고친다)
           enode-design/ 서브모듈 (canon.md — 정본 개정은 이 저장소의 일이 아니다)

internal/api/api.go 는 등록 줄 셋과 임포트 하나만 만진다. 핸들러 본체는
list.go 에 있고 정적 파일은 ui/ 에 있다 — unit-of-work-dependency.md
4절이 이 파일을 셋이 만지는 자리로 두었고, 관측이 먼저 등록 줄을 넣는다.

새 Go 의존이 0 이다. embed · io/fs · net/http · strconv ·
time 은 전부 표준 라이브러리이고 go.mod 를 안 건드린다.

프런트엔드 빌드 도구가 0 이다. npm 도 번들러도 프레임워크도 없다.
브라우저가 그대로 읽는 ES 모듈과 CSS 다.


3. 응답 계약 — 이 유닛이 고정하는 JSON

모양을 여기서 새로 정하지 않는다. requirements.md 4.1절이 이미
「Functional Design 이 내부를 다듬을 수 있으나 이 모양은 못 바꾼다」로
못 박은 것을 그대로 옮긴다.

3.1 GET /v1/nodes

store.NodePage 를 그대로 직렬화한다. 핸들러가 모양을 다시 짓지 않는다 —
지으면 같은 구조체가 두 벌이 되고 두 벌이 되면 갈린다.

   {observed_at, nodes:[{node_id, label, instance?, capabilities,
                         seen_at, expires_at, lease?{run_id, not_after},
                         draining?}]}

   필터 없다              FR-1.  질의 인자를 안 받는다
   만료 안 된 광고만       store 의 WHERE expires_at > now() 가 이미 한다
   principal 안 낸다      SELECT 목록에 없다 (U-바닥 R15)
   nonce 안 낸다          LeaseView 가 LeaseRow 가 아니다 (U-바닥 R14)
   observed_at            DB 의 now().  카운트다운의 기준점이다

3.2 GET /v1/runs

store.RunPage 를 그대로 직렬화한다.

   {observed_at, runs:[{run_id, state, verdict?, work_id?, created_at,
                        ended_at?, assigned?, current?, reject?}]}

   current   [{node, step, attempt, started_at}].  CLAIMED 단계마다 하나
             도는 것이 없으면 키가 없다 (빈 배열도 안 낸다)
   reject    {code, as?, reason}.  GET /v1/runs/{id} 와 같은 글자다
   정렬       created_at DESC, run_id DESC.  동점이 없다
   본문       runs.contract 를 안 싣는다

3.3 질의 인자와 검증 — NFR-S5

조이는 자리는 store 이고 거절하는 자리는 핸들러다. 둘을 가르는 기준은
「호출자가 값을 고쳐 다시 부를 수 있는가」다. limit 은 고칠 값이 하나뿐이라
조이는 것이 친절이고, state 오타는 조일 방향이 없어 거절해야 뜻이 산다.

   state    없으면 전체.  있으면 store.ValidState 의 다섯 —
            RUNNING · VERIFYING · SUCCEEDED · FAILED · QUEUED.  밖이면 400
   since    RFC3339.  못 읽으면 400.  created_at > since 배타 경계
   work     정확 일치.  검증 없음 — 문자열 그대로 넘긴다
   limit    십진 정수.  아니면 400
            값 조이기는 store 가 한다 — <=0 이면 100 · >1000 이면 1000
   그 밖의 인자  무시한다.  400 을 안 낸다 (기존 표면과 같은 태도)

필터 칩 다섯과 어휘 다섯이 어긋난다. 화면의 칩은 전체 · RUNNING ·
QUEUED · SUCCEEDED · FAILED 인데 어휘에는 VERIFYING 이 더 있다. 칩을
여섯으로 늘리지 않는다 — requirements.md 8.3절이 다섯으로 고정했고,
VERIFYING 행은 「전체」에만 보인다. 라우트는 그 값을 받는다.

3.4 오류

   400  질의 인자가 어휘 밖이거나 형식이 아니다
        문구 — "unknown state" · "since must be RFC3339" · "limit must be an integer"
        받은 값을 되비추지 않는다.  SQL · 경로 · 스택도 없다 (NFR-S9 · SECURITY-09)
   401  기존 auth 미들웨어.  두 라우트 다 s.auth 로 감싼다 (NFR-S4)
   503  store 실패.  "query failed" 하나.  자세한 것은 s.log.Error 로만
        기존 getRun · getCapabilities 와 같은 모양이다

3.5 GET /ui/

   무인증        FR-5.  토큰은 화면이 브라우저에서 받는다
                 이 화면을 위한 인증 예외 경로를 만드는 것이 아니다 —
                 /v1/* 은 그대로 토큰을 요구하고 화면이 그 토큰을 붙인다
   헤더 넷        Content-Security-Policy: default-src 'self'
                 X-Content-Type-Options: nosniff
                 X-Frame-Options: DENY
                 Referrer-Policy: strict-origin-when-cross-origin
   HSTS          안 건다.  N/A 근거는 requirements.md 3.3절

4. 화면 — 구조와 버튼 전부

「누르면 걸린다」만으로는 빠진 버튼이 통과된다 (scene-gates.md 2.1절 ·
design/README.md 7절). 그래서 버튼을 전부 센다. 재료는
requirements.md 8.3절이고 여기서 늘리지 않는다.

4.1 한 장짜리다

화면 다섯은 페이지 다섯이 아니다. index.html 한 장이고 뷰가 셋이다 —
S0b 는 S0 의 오류 상태이고 S1b 는 S1 의 갱신 실패 상태다.

   view-token   S0 · S0b
   view-fleet   S1 · S1b
   view-run     S2

4.2 CSP 가 파일 구성을 정한다

default-src 'self' 는 인라인을 전부 막는다. unsafe-inline 을 안 쓰기로
한 것이 requirements.md 3.3절이고, 그것이 아래를 강제한다.

   인라인 <script> 금지        JS 는 전부 별도 파일.  type="module" 로 싣는다
   인라인 <style> · style= 금지 CSS 는 app.css 하나
   onclick= 같은 속성 금지      전부 addEventListener
   외부 도메인 금지             폰트도 CDN 도 없다.  system-ui 스택을 쓴다

어기면 조용히 깨진다. 브라우저가 콘솔에만 적고 화면은 그냥 안 움직인다.
그래서 이것을 사람 눈이 아니라 테스트가 센다 (Step 14).

4.3 장식 문자를 안 쓴다 — 아이콘은 인라인 SVG 다

CONVENTIONS.md 1.2 가 코드에 장식 문자를 금한다. 그림에는 화살표와 경고
표시와 눈 아이콘과 갈매기표가 있는데 그것을 글자로 넣지 않고 인라인 SVG 로
그린다.
인라인 SVG 는 CSP 의 대상이 아니다 (스크립트도 스타일도 아니다).

   눈 아이콘 (S0 토큰 숨김)     SVG
   갈매기표 (Run 행 열기)        SVG
   화살표 (함대 격자의 이 노드로) SVG
   경고 표시 (S1b 배너)          SVG
   가운뎃점 · 과 줄표 —          글자로 쓴다.  구두점이고 glyphscan 도 뺀다

scripts/glyphscan.go 는 Go 파일만 본다. 이 규칙을 HTML 과 JS 와 CSS 에서
지키는 것은 Step 14 의 테스트다 — 도구를 두 벌로 안 만들고 검사만 옮긴다.

4.4 버튼 전부 — 완료 조건

   S0    토큰 표시 · 숨김 토글 (눈 아이콘)
         현황판 열기
         토큰은 브라우저 세션 저장소에만 둔다.  localStorage 를 안 쓴다

   S0b   위 둘이 그대로 산다
         입력 테두리가 붉다
         「401 — Mediator 가 이 토큰을 받지 않았다」가 인라인으로 붙는다

   S1    필터 칩 다섯 — 전체 · RUNNING · QUEUED · SUCCEEDED · FAILED
         Run 행의 열기  -> S2
         카드에는 조작이 없다.  중앙은 읽기 전용이다

   S1b   갱신이 연속 3회 실패하면 카운트다운이 멎고 실패 표시가 뜬다
         배너 — 「이 화면은 <observed_at> 의 함대다. 지금의 함대가 아니다」

   S2    닫기  -> S1
         단계 노드 고르기 -> 오른쪽 상세
         「함대 격자의 이 노드로」  -> S1 의 그 카드

자동화 친화 — 위 조작 자리마다 data-testid 를 단다. 이름은
{화면}-{역할} 이고 렌더마다 안 바뀐다.

   token-input · token-reveal-toggle · token-submit · token-error
   fleet-filter-{all|RUNNING|QUEUED|SUCCEEDED|FAILED}
   fleet-card-{node_id} · fleet-stale-banner · fleet-updated-at
   run-row-{run_id} · run-close · run-step-{step_id} · run-goto-node

4.5 파생 규칙 — 새 필드 0

requirements.md 8.2절이 「파생이다」로 닫은 것들의 값이다.

   카드 배지 우선순위    draining  >  만료 임박  >  임대 중  >  임대 없음
                        draining 이 먼저인 것은 그림이 그렇다 (ws-c 가 임대를
                        쥔 채 draining 배지다).  만료 임박이 임대 중보다 앞인
                        것은 그것이 더 급한 사실이기 때문이다 — 곧 격자에서 빠진다

   draining 두 국면      draining != "" 이고 lease != null    draining · 진행 중      C4
                        draining != "" 이고 lease == null    draining · 대기 중(owner) C5
                        (owner) 를 붙이는 것은 원본의 글자다 (8.5절)

   만료 임박            expires_at - 지금 <= 30초.  카드가 흐려진다 (NFR-O3)

   카운트다운           lease.not_after - 지금.  기준점은 observed_at 이고
                        그 뒤로는 브라우저가 센다
   at-boundary 카드      카운트다운 대신 「경계에서 · 최대 <not_after>」
                        경계가 언제인지는 아무도 모르므로 세면 거짓말이 된다

   짧은 이름            label 의 마지막 ':' 뒤.  taeels@thinkpad:ws-a -> ws-a
                        Run 행이 이것을 쓴다.  카드 제목은 label 전체다
   node_id -> label     assigned[].nodes[] 에서 찾고, 없으면 함대 목록에서 찾고,
                        그것도 없으면 node_id 를 그대로 적는다 (광고가 만료된 노드)

   produced             GET /v1/runs/{id}/ledger 의 by == "step:<id>" 를 모은다
                        없으면 「—」.  StepView 에 필드를 안 더한다 (8.2절)

4.6 어휘 — 원본이 코드와 어긋난 자리 셋

design/README.md 6절이 「API 값을 보이는 자리는 원문 enum 그대로」로
판정한다. 그림을 따르지 않는 자리가 셋이고 전부 requirements.md 8.6절의
값이다.

   S1b 필터 칩 · 배지의 SEALED       ->  SUCCEEDED.  SEALED 는 Run 상태가 아니다
   S1b Run 행 부제의 「봉인」          ->  완료 시각으로 적는다
   S2 단계 배지 · 범례의 RUNNING     ->  CLAIMED.  RUNNING 은 Run 의 상태다

S2 범례를 여섯으로 늘린다. 그림은 넷(DONE · RUNNING · PENDING ·
SKIPPED)인데 steps.state 어휘는 여섯이다. 안 늘리면 실패한 단계와 되묻는
단계가 색 없이 뜬다 (8.6절).

   완료 DONE · 실행 중 CLAIMED · 대기 PENDING
   건너뜀 SKIPPED · 실패 FAILED · 되물음 ASKED

4.7 갱신

   폴링        5초.  SSE · WebSocket 없음 (NFR-O1)
   요청 둘      GET /v1/nodes 와 GET /v1/runs.  화면 하나당 둘로 유지된다 (8.4절 ①)
   S2 가 열리면 GET /v1/runs/{id} 와 GET /v1/runs/{id}/ledger 를 더 부른다
   실패 셋      연속 3회 실패하면 카운트다운을 멈추고 S1b 로 간다 (NFR-O4)
                한 번은 흔들림, 셋은 끊김 (decisions.md 2절)
   401          몇 번째든 즉시 S0b 로 간다.  토큰이 틀린 것은 흔들림이 아니다
   마지막 갱신   화면이 값 대신 「마지막 갱신 시각」을 보인다 (NFR-O2)
   observed_at  카드에 expires_at 과 함께 얹는다 (NFR-O5)

4.8 화면 언어

한국어다. CONVENTIONS.md 2.1 이 영어로 못 박은 자리는 에러 문자열 ·
로그 · CLI 출력 · 테스트 이름 · 픽스처 · 에이전트 프롬프트인데 웹 화면은
그중 어디도 아니다. 가르는 기준이 2.3절의 「밖으로 나가느냐」 — 하네스로
실려 나가 에이전트에게 되주입되느냐 — 이고 화면 글자는 사람 눈에서 끝난다.
그리고 그림의 글자가 이미 한국어라 옮기면 원본과 갈린다.

코드는 영어다 — 식별자 · console 로 나가는 문구 · 테스트 이름.


5. 단계

Step 1 — internal/api/list.go 신설

  • getNodes — s.st.ListNodes(r.Context()) 를 부르고 write(w, 200, page)
  • 질의 인자를 안 읽는다 (FR-1 「필터 없음」)
  • store 오류는 s.log.Error + fail(w, 503, "query failed")
  • 응답 구조체를 새로 안 만든다 — store.NodePage 를 그대로 싣는다

FR-1 · FR-4

Step 2 — 같은 파일에 getRuns

  • 질의 인자 넷을 읽어 store.RunFilter 를 만든다
  • state — 비었으면 안 넣고, 있으면 store.ValidState 로 보고 밖이면
    fail(w, 400, "unknown state")
  • since — 비었으면 제로값, 있으면 time.Parse(time.RFC3339, …) 로 읽고
    실패하면 fail(w, 400, "since must be RFC3339")
  • work — 문자열 그대로
  • limit — 비었으면 0, 있으면 strconv.Atoi 로 읽고 실패하면
    fail(w, 400, "limit must be an integer"). 범위는 안 본다 —
    조이는 것은 store 의 일이다 (U-바닥 R6)
  • 오류 문구에 받은 값을 안 되비춘다. SQL · 경로 · 스택도 없다 (NFR-S9)
  • store 오류는 503 하나

FR-2

Step 3 — 등록 줄 둘

  • internal/api/api.go 의 Handler() 에 두 줄을 더한다 —
    mux.HandleFunc("GET /v1/nodes", s.auth(s.getNodes)) ·
    mux.HandleFunc("GET /v1/runs", s.auth(s.getRuns))
  • s.auth 로 감싼다 (NFR-S4). 기존 열다섯과 같은 미들웨어다
  • 같은 경로의 POST 와 공존한다 — ServeMux 가 메서드로 가른다
  • 이 파일에서 그 두 줄 말고 아무것도 안 만진다

FR-1 · FR-2

Step 4 — API 테스트 (DB 필요)

  • internal/api/list_test.go 신설. 기존 newServer · newServerFast ·
    do · advert 헬퍼를 재사용한다 — 두 벌로 안 만든다
  • ENODE_TEST_DATABASE_URL 부재 처리는 이 패키지의 기존 규칙을
    따른다
    — newServer 의 t.Skip 이다. store 패키지의 t.Fatal 과
    다른 것은 알고 두는 것이고, 새 규칙을 이 파일이 만들지 않는다
  • GET /v1/nodes — 광고 하나 올린 뒤 200 · observed_at 있음 ·
    노드 하나
  • 임대가 있으면 lease{run_id, not_after} 가 실린다
  • 응답 본문에 nonce 도 principal 도 없다 — 글자로 센다
  • draining 이 실린다 (광고에 policy.drain 을 실어 올린다)
  • 토큰 없이 부르면 401
  • GET /v1/runs — 시간 역순 · limit · state · work · since
  • current[] 가 CLAIMED 단계마다 하나이고 도는 것이 없으면 키가 없다
  • reject 가 {code, as, reason} 로 나온다
  • 400 셋 — 어휘 밖 state · RFC3339 아닌 since · 정수 아닌 limit
  • limit=0 과 limit=5000 이 400 이 아니다 (조여서 200)
  • 503 — newServerFast 가 준 store 를 닫고 부른다. 문구가
    query failed 뿐이고 경로도 SQL 도 없다
  • 기존 POST /v1/nodes 와 POST /v1/runs 가 그대로 돈다 (405 회귀 없음)

FR-1 · FR-2 · FR-4 · NFR-S4 · NFR-S5 · NFR-S9

Step 5 — internal/api/ui/ui.go 신설

  • 패키지 주석에 이 패키지가 무엇이고 왜 store 를 안 부르는지 적는다
  • //go:embed assets 로 assets/ 를 통째로 싣는다
  • Handler() http.Handler — fs.Sub 로 assets 를 뿌리로 만들고
    http.FileServerFS 에 얹는다
  • 헤더 넷을 거는 래퍼를 그 앞에 둔다. 404 에도 걸린다 — 래퍼가
    먼저 쓴다
  • internal/store 를 임포트하지 않는다 (NFR-P3). 경계 검사기가
    이것을 기계로 센다
  • internal/api 도 임포트하지 않는다 — 반대 방향이라 순환이 된다

FR-5 · NFR-S6 · NFR-P3

Step 6 — 등록 줄 하나

  • internal/api/api.go 에 ui 임포트를 더하고
    mux.Handle("GET /ui/", http.StripPrefix("/ui", ui.Handler())) 를 건다
  • "/ui/" 가 아니라 "/ui" 를 벗긴다 — "/ui/" 를 벗기면
    /ui/app.js 가 앞 슬래시 없는 app.js 가 되어 FileServer 가 못 찾는다
  • s.auth 로 감싸지 않는다 (FR-5). 화면은 무인증이고 자료는
    토큰이 필요한 /v1/* 에서 온다
  • /ui 는 ServeMux 가 /ui/ 로 넘긴다 — 리다이렉트를 손으로 안 짠다

FR-5

Step 7 — assets/index.html

  • 뷰 셋의 뼈대 — view-token · view-fleet · view-run
  • 4.4절의 조작 자리마다 data-testid
  • 인라인 <script> 도 <style> 도 style= 도 on*= 도 없다 (4.2절)
  • <link rel="stylesheet" href="./app.css"> 와
    <script type="module" src="./app.js"></script>. 상대 경로라 마운트
    지점이 박히지 않는다
  • 아이콘 넷은 인라인 SVG (4.3절)
  • lang="ko" · <meta charset="utf-8"> · viewport

FR-5

Step 8 — assets/app.css

  • 그림의 어두운 팔레트. 색은 상태별 토큰으로 이름을 준다 —
    임대 없음 · 임대 중 · draining · 만료 임박, 단계 여섯
  • 카드 격자 · Run 목록 · 그래프 · 상세 패널의 배치
  • 흐려짐(만료 임박)과 멎음(S1b)의 클래스를 따로 둔다
  • 폰트는 system-ui 스택. 외부 폰트를 안 받는다 (CSP)

FR-5

Step 9 — assets/api.js

  • 토큰을 sessionStorage 에서 읽고 쓴다. localStorage 를 안 쓴다 —
    S0 의 「토큰은 이 브라우저에만 남는다」가 탭을 닫으면 끝나는 것을 뜻한다
  • get(path) — Authorization: Bearer <token> 을 붙여 부른다
  • 401 이면 즉시 토큰을 지우고 S0b 로 가는 신호를 낸다
  • 연속 실패를 센다. 성공하면 0 으로 되돌린다. 3 이면 stale 신호
  • 마지막 성공 시각과 마지막 observed_at 을 든다
  • console 로 나가는 문구는 영어다

FR-5 · NFR-O4

Step 10 — assets/fleet.js

  • 카드 하나를 그린다 — 제목은 label 전체, 그 아래 node_id,
    capability 칩, 임대 블록, 바닥에 seen 과 expires
  • 배지 우선순위 넷 (4.5절)
  • draining 두 국면 C4 · C5 (FR-16)
  • 카운트다운. at-boundary 면 「경계에서 · 최대 <not_after>」
  • 만료 임박 30초 흐려짐 (NFR-O3)
  • 격자 위에 범례 넷과 노드 수
  • 카드에 조작을 안 넣는다 — 중앙은 읽기 전용이다

FR-1 · FR-4 · FR-16 · NFR-O3 · NFR-O5

Step 11 — assets/runs.js

  • 필터 칩 다섯. 고른 칩만 state 인자를 붙인다 (「전체」는 안 붙인다)
  • Run 행 — run_id · 상태 배지 · 부제 · 열기
  • 부제 넷 (4.5절의 파생 규칙 그대로)
  • SEALED 라는 글자가 어디에도 없다 (4.6절)
  • QUEUED 안내 문구와 페이지네이션 자리를 그림대로 둔다 — 자리만이고
    기능이 없다는 것을 글자가 말한다
  • 행 열기가 S2 로 간다

FR-2 · FR-5

Step 12 — assets/graph.js

  • steps[] 와 needs[] 로 층을 나눈다 — 위상 깊이가 열, 같은 깊이가 행
  • 단계 노드 — 이름 · 상태 배지 · 부제(node · 회차 · 시작 시각,
    없으면 「노드 미배정」) · chosen 칩
  • 범례 여섯 (4.6절)
  • 오른쪽 상세 — state · node · attempt · started_at · needs[] ·
    produced · chosen
  • produced 는 GET /v1/runs/{id}/ledger 를 조인한다. 없으면 「—」
  • 「함대 격자의 이 노드로」가 S1 으로 돌아가 그 카드를 밝힌다
  • 닫기가 S1 으로 간다
  • 트랜스크립트가 아니다 — 단계 상세다 (8.2절)

FR-2 · FR-3 · FR-5

Step 13 — assets/app.js

  • 진입 — 토큰이 있으면 S1, 없으면 S0
  • 「현황판 열기」가 한 번 불러 보고 401 이면 S0b 에 머문다
  • 눈 아이콘이 input type 을 password 와 text 사이로 바꾼다
  • 5초 폴링 — GET /v1/nodes 와 GET /v1/runs 둘
  • S2 가 열려 있으면 GET /v1/runs/{id} 와 ledger 를 더 부른다
  • stale 신호를 받으면 카운트다운을 멈추고 배너를 띄운다 (S1b)
  • 「마지막 갱신 N초 전」을 머리에 적는다 (NFR-O2)
  • 화면이 안 보일 때 폴링을 멈추지 않는다 — 5초짜리에 가시성 처리를
    더하면 「멈춘 것」과 「끊긴 것」이 화면에서 같아진다

FR-5 · NFR-O1 · NFR-O2 · NFR-O4

Step 14 — internal/api/ui/ui_test.go (DB 불필요)

internal/api/ui 는 store 를 안 부르므로 ENODE_TEST_DATABASE_URL 이
필요 없다 (NFR-Q2). 커버리지 하한 80% 를 이 테스트가 채운다 (NFR-Q1).

  • GET /ui/ 가 200 이고 index.html 의 글자가 실려 온다
  • 헤더 넷이 전부 있다. 값까지 글자로 센다 (NFR-S6)
  • 없는 파일이 404 이고 404 에도 헤더 넷이 있다
  • .js 와 .css 의 Content-Type 이 맞다
  • CSP 자체 검사 — index.html 에 인라인 <script> 도 <style> 도
    style= 도 on*= 속성도 없다. 있으면 브라우저가 조용히 안 움직인다
  • index.html 이 참조하는 파일이 embed 안에 전부 있다
  • 장식 문자 검사 — assets/ 의 모든 파일에 glyphscan 의 구간
    문자가 없다. glyphscan 이 Go 만 보므로 이 자리를 테스트가 메운다
  • 어휘 검사 — assets/ 어디에도 SEALED 가 없고, 단계 범례에
    RUNNING 이 없으며, 여섯이 다 있다 (4.6절)
  • 테스트 이름과 실패 메시지는 영어다

FR-5 · NFR-S6 · NFR-Q1 · NFR-Q2

Step 15 — CP1 을 실제로 돌린다

이 단계가 이 계획의 게이트다. 명령을 적는 것이 아니라 돌려서 초록을
확인한다. 값은 scene-gates.md 3절의 CP1 이고 CP0 의 일곱 줄을 함께 다시
돌린다 — 회귀를 여기서 잡는다.

  • go build ./...
  • go test ./... && go vet ./...
  • go run ./scripts/glyphscan.go
  • golangci-lint run ./... — 새 파일과 고친 파일에 0건
  • 커버리지 게이트 — .coverage-contract.yml 의 명령 그대로.
    패키지가 16개가 된다 (internal/api/ui 신설). 전부 하한 80%
  • go test -v ./internal/boundary — 2 checked, 2 skipped, 4 total
    이어야 한다. internal/api/ui 가 생겼으므로 규칙 하나가 「없음」에서
    「검사함」으로 넘어간다. 숫자가 안 바뀌면 검사기가 새 패키지를 못 본
    것이다
  • curl -H "Authorization: Bearer $T" $M/v1/nodes — 실데이터 JSON.
    lease.not_after 와 draining 이 실려 있다
  • curl -H "Authorization: Bearer $T" "$M/v1/runs?limit=5" — 실데이터 JSON
  • curl $M/ui/ — 200 · HTML · 헤더 넷. 토큰 없이도 온다
  • curl $M/v1/capabilities (토큰 있음) — 200. CP0 의 그 줄이 그대로 산다
  • 라우트 18개 회귀 0 — 기존 열다섯에 셋이 늘었다. mux 의
    404 page not found 가 하나도 없어야 한다

빨간 것이 남으면 다음 유닛에 안 들어간다 (scene-gates.md 4절).

Step 16 — 화면 검증 S1 · S1b

scene-gates.md 2.1절이 CP1 에 건 것이다. API 검증과 따로 적는다 —
API 로 통과되는 어휘는 화면 없이도 초록이 된다.

  • S1 — 카드가 노드 수만큼 있다
  • S1 — 임대된 카드에 not_after 카운트다운이 돈다
  • S1 — draining 카드에 배지가 있고 두 국면이 갈린다
  • S1 — 카드에 observed_at 과 expires_at 이 함께 얹혀 있다
  • S1 — 마지막 갱신 시각이 있다
  • S1 — 만료 30초 이내 카드가 흐려진다
  • S1b — Mediator 를 멈추면 카운트다운이 멎고 실패 표시가 뜬다
  • S0b — 틀린 토큰이 401 인라인 오류를 낸다
  • 4.4절의 버튼이 전부 있고 전부 걸린다
  • 브라우저 콘솔에 CSP 위반이 하나도 없다

Step 17 — 문서

  • aidlc-docs/construction/u-observe-api/code/ 에 요약 하나를 낸다 —
    만든 파일 · 고친 파일 · CP1 결과 · 화면 검증 결과 · 뒤 유닛에 내준 접점
  • enode-design/ 을 안 고친다. ADR-065 에 draining 을 더하는
    개정과 mediator-api.md 에 라우트 셋을 더하는 것은 정본 쪽 선행
    조건이다 (canon.md 3절 · requirements.md 7.1절). 요약에 「정본에
    아직 없다」를 한 줄 적는다
  • README.md 를 안 고친다 — 워크숍 스캐폴드 설명이고 enode 사용자
    문서가 아니다

Step 18 — 해당 없음을 적어 둔다

규칙이 요구하는 단계 중 이 유닛에 없는 것을 이름으로 남긴다. 없다고 조용히
빠지면 다음에 읽는 사람이 빠뜨린 것인지 없는 것인지 모른다.

  • Project Structure Setup — 해당 없음 (브라운필드. 구조가 이미 있다)
  • Business Logic Generation — 해당 없음. 이 유닛에 업무 규칙이 없다.
    투영과 표시뿐이고 판정은 전부 앞 유닛과 기존 코드에 있다
  • Repository Layer — 해당 없음. ListNodes · ListRuns 는 U-바닥이
    이미 냈고 이 유닛은 부르기만 한다
  • Database Migration Scripts — 해당 없음 (1.4절 — 소유하는 DB 실체가 없다)
  • Deployment Artifacts — 해당 없음 (새 실행파일 없음 · packaging/
    안 건드림 · 화면이 기존 Mediator 포트에 얹힌다)

6. 실행 규칙

   계획대로만          여기 없는 파일을 안 만든다.  여기 없는 함수를 안 더한다
   제자리에서 고친다    사본을 안 만든다.  _new · _modified 금지
   체크박스는 즉시      단계를 끝낸 그 응답에서 [x] 로 바꾼다
   상태 파일도 같이     aidlc-state.md 의 Current Status 를 함께 갱신한다
   코드는 저장소 뿌리   aidlc-docs/ 아래에 코드 파일을 안 만든다
   design/ 무변경       진행자만 고친다 (START.md 5절)
   enode-design/ 무변경 정본 개정은 이 저장소의 일이 아니다 (canon.md)

표기와 언어 — CONVENTIONS.md 다. 주석은 한국어, 에러 문자열과 로그와
테스트 이름과 실패 메시지는 영어, 화면 글자는 한국어(4.8절). 코드 어디에도
장식 문자를 안 넣는다.


7. 보안 확장 대조 — 이 유닛에서

security-baseline 이 켜져 있다. 이 유닛이 새 표면 셋을 연다 — 확장이
실제로 걸리는 첫 유닛이다.

   SECURITY-04  전송 헤더    새 HTML 표면에 헤더 넷 (3.5절 · NFR-S6)
                            HSTS 는 N/A, 근거는 requirements.md 3.3절
   SECURITY-05  입력 검증    state 어휘 · since RFC3339 · limit 정수 (3.3절)
                            limit 상한은 store 가 조인다
   SECURITY-08  접근 제어    /v1/nodes · /v1/runs 가 기존 auth 미들웨어로 보호된다
                            /ui/ 는 무인증이지만 자료를 안 낸다 — 정적 파일뿐이다
                            객체 수준 권한은 해당 없음 (ADR-015 §1)
   SECURITY-09  오류 노출    400 · 503 문구에 SQL · 경로 · 스택 · 받은 값이 없다
   SECURITY-12  자격증명     토큰을 소스에 안 박는다.  화면은 sessionStorage 에만 둔다
                            응답에 nonce 도 principal 도 안 싣는다 (테스트가 센다)
   SECURITY-01 · 03 · 07     기존 코드 사실.  이 유닛에서 수정하지 않는다

8. 안 만드는 것

   store 함수        하나도.  U-바닥이 낸 것을 부르기만 한다
   새 열 · 새 표      0.  current[] 는 steps 조인이고 reject 는 runs 열이다
   페이지네이션       limit 뿐.  화면에 자리만 둔다
   필터 (nodes)      GET /v1/nodes 는 질의 인자를 안 받는다
   VERIFYING 칩       필터 칩은 다섯이다.  라우트는 그 값을 받는다
   SSE · WebSocket   폴링 5초뿐 (constraints.md 5절)
   프런트엔드 의존    npm · 번들러 · 프레임워크 · 외부 폰트 · CDN 전부 없다
   Go 의존           go.mod 를 안 건드린다
   S3 · S4 · S5      U-제어판.  이 유닛은 중앙 화면 다섯만 진다
   202 · QUEUED 분기  U-대기열.  submit 을 안 건드린다
   drain 라우트       없다 (NFR-I3).  draining 은 nodes 열의 투영일 뿐이다
   /ui/ 인증         안 건다.  이 화면을 위한 인증 예외 경로도 안 만든다
   StepView 새 필드   produced 를 안 더한다.  ledger 라우트를 조인한다