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를 그대로 싣는다
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 하나
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 가 메서드로 가른다 - 이 파일에서 그 두 줄 말고 아무것도 안 만진다
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도 임포트하지 않는다 — 반대 방향이라 순환이 된다
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/로 넘긴다 — 리다이렉트를 손으로 안 짠다
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
Step 8 — assets/app.css
- 그림의 어두운 팔레트. 색은 상태별 토큰으로 이름을 준다 —
임대 없음 · 임대 중 · draining · 만료 임박, 단계 여섯 - 카드 격자 · Run 목록 · 그래프 · 상세 패널의 배치
- 흐려짐(만료 임박)과 멎음(S1b)의 클래스를 따로 둔다
- 폰트는 system-ui 스택. 외부 폰트를 안 받는다 (CSP)
Step 9 — assets/api.js
- 토큰을
sessionStorage에서 읽고 쓴다.localStorage를 안 쓴다 —
S0 의 「토큰은 이 브라우저에만 남는다」가 탭을 닫으면 끝나는 것을 뜻한다 -
get(path)—Authorization: Bearer <token>을 붙여 부른다 - 401 이면 즉시 토큰을 지우고 S0b 로 가는 신호를 낸다
- 연속 실패를 센다. 성공하면 0 으로 되돌린다. 3 이면 stale 신호
- 마지막 성공 시각과 마지막
observed_at을 든다 -
console로 나가는 문구는 영어다
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 로 간다
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절)
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.md3절 ·requirements.md7.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 라우트를 조인한다