docs(15): create phase plan — 2 plans for infrastructure foundation

This commit is contained in:
Lucas Berger
2026-02-09 08:42:39 -05:00
parent 62eaa4b1ec
commit a352b15954
3 changed files with 539 additions and 4 deletions
+5 -4
View File
@@ -66,10 +66,11 @@
3. GraphQL response normalization transforms Unraid API shape to workflow contract 3. GraphQL response normalization transforms Unraid API shape to workflow contract
4. GraphQL error handling standardized (checks response.errors[], handles HTTP 304) 4. GraphQL error handling standardized (checks response.errors[], handles HTTP 304)
5. Timeout configuration accounts for myunraid.net cloud relay latency (200-500ms) 5. Timeout configuration accounts for myunraid.net cloud relay latency (200-500ms)
**Plans**: TBD **Plans**: 2 plans
Plans: Plans:
- [ ] 15-01: TBD - [ ] 15-01-PLAN.md — Container ID Registry and Callback Token Encoding/Decoding
- [ ] 15-02-PLAN.md — GraphQL Response Normalizer, Error Handler, and HTTP Template
#### Phase 16: API Migration #### Phase 16: API Migration
**Goal**: All container operations work via Unraid GraphQL API **Goal**: All container operations work via Unraid GraphQL API
@@ -132,7 +133,7 @@ Phases execute in numeric order: 1-14 (complete) → 15 → 16 → 17 → 18
| 12 | Polish & Audit | v1.2 | 2/2 | Complete | 2026-02-08 | | 12 | Polish & Audit | v1.2 | 2/2 | Complete | 2026-02-08 |
| 13 | Documentation Overhaul | v1.2 | 1/1 | Complete | 2026-02-08 | | 13 | Documentation Overhaul | v1.2 | 1/1 | Complete | 2026-02-08 |
| 14 | Unraid API Access | v1.3 | 2/2 | Complete | 2026-02-08 | | 14 | Unraid API Access | v1.3 | 2/2 | Complete | 2026-02-08 |
| 15 | Infrastructure Foundation | v1.4 | 0/? | Not started | - | | 15 | Infrastructure Foundation | v1.4 | 0/2 | Not started | - |
| 16 | API Migration | v1.4 | 0/? | Not started | - | | 16 | API Migration | v1.4 | 0/? | Not started | - |
| 17 | Cleanup | v1.4 | 0/? | Not started | - | | 17 | Cleanup | v1.4 | 0/? | Not started | - |
| 18 | Documentation | v1.4 | 0/? | Not started | - | | 18 | Documentation | v1.4 | 0/? | Not started | - |
@@ -140,4 +141,4 @@ Phases execute in numeric order: 1-14 (complete) → 15 → 16 → 17 → 18
**Total: 4 milestones shipped (14 phases, 50 plans), v1.4 in progress (4 phases)** **Total: 4 milestones shipped (14 phases, 50 plans), v1.4 in progress (4 phases)**
--- ---
*Updated: 2026-02-09 — v1.4 Unraid API Native roadmap created* *Updated: 2026-02-09 — Phase 15 planned (2 plans)*
@@ -0,0 +1,243 @@
---
phase: 15-infrastructure-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- n8n-workflow.json
autonomous: true
must_haves:
truths:
- "Container ID Registry maps container names to Unraid PrefixedID format"
- "Callback token encoder produces 8-char tokens from PrefixedIDs"
- "Callback token decoder resolves 8-char tokens back to PrefixedIDs"
- "Token collisions are detected and handled"
- "Registry and token store persist across workflow executions via static data JSON serialization"
artifacts:
- path: "n8n-workflow.json"
provides: "Container ID Registry, Callback Token Encoder, Callback Token Decoder utility nodes"
contains: "_containerIdMap"
key_links:
- from: "Container ID Registry node"
to: "static data _containerIdMap"
via: "JSON.parse/JSON.stringify pattern"
pattern: "JSON\\.parse\\(.*_containerIdMap"
- from: "Callback Token Encoder node"
to: "static data _callbackTokens"
via: "SHA-256 hash + JSON serialization"
pattern: "crypto\\.subtle\\.digest"
- from: "Callback Token Decoder node"
to: "static data _callbackTokens"
via: "JSON.parse lookup"
pattern: "_callbackTokens"
---
<objective>
Build the Container ID Registry and Callback Token Encoding system as utility Code nodes in the main workflow.
Purpose: Phase 16 (API Migration) needs to translate between container names and Unraid PrefixedIDs (129-char format like `server_hash:container_hash`). The registry provides centralized ID translation, and the token system compresses PrefixedIDs to 8-char tokens for Telegram callback_data (64-byte limit). These nodes are not wired into the active flow yet -- they are standalone utilities that Phase 16 will connect.
Output: Three new Code nodes in n8n-workflow.json: Container ID Registry, Callback Token Encoder, Callback Token Decoder.
</objective>
<execution_context>
@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
@/home/luc/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@ARCHITECTURE.md
@.planning/phases/15-infrastructure-foundation/15-RESEARCH.md
@.planning/phases/14-unraid-api-access/14-02-SUMMARY.md
@.planning/phases/11-update-all-callback-limits/11-01-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add Container ID Registry utility node</name>
<files>n8n-workflow.json</files>
<action>
Add a new Code node named "Container ID Registry" to n8n-workflow.json. This is a **standalone utility node** -- do NOT connect it to any existing nodes. Place it at position [200, 2400] (below the active workflow area, in an "infrastructure utilities" zone).
The node must implement:
1. **Registry initialization** using static data with JSON serialization pattern (CRITICAL: must use top-level assignment per CLAUDE.md):
```javascript
const registry = $getWorkflowStaticData('global');
if (!registry._containerIdMap) {
registry._containerIdMap = JSON.stringify({});
}
const containerMap = JSON.parse(registry._containerIdMap);
```
2. **updateRegistry(containers)** function that:
- Takes an array of Unraid GraphQL container objects (format: `{ id, names[], state }`)
- Maps each container: name (from `names[0]`, strip leading `/`, lowercase) -> `{ name, unraidId: id }`
- The Unraid `id` field IS the PrefixedID (129-char `server_hash:container_hash` format per ARCHITECTURE.md)
- Stores timestamp: `registry._lastRefresh = Date.now()`
- Serializes: `registry._containerIdMap = JSON.stringify(newMap)`
- Returns the new map
3. **getUnraidId(containerName)** function that:
- Strips leading `/`, lowercases input
- Looks up in containerMap
- If not found AND registry older than 60 seconds: throws `Container "${name}" not found. Registry may be stale - try "status" to refresh.`
- If not found AND registry fresh: throws `Container "${name}" not found. Check spelling or run "status" to see all containers.`
- Returns `entry.unraidId`
4. **getContainerByName(containerName)** function that:
- Same lookup as getUnraidId but returns the full entry object
- Returns `{ name, unraidId }`
5. **Node input/output contract:**
- Input: `$input.item.json.containers` (array) for registry updates, OR `$input.item.json.containerName` (string) for lookups
- Detect mode: if `containers` array exists, update registry. If `containerName` exists, do lookup.
- Output: Updated map (for updates) or `{ containerName, unraidId }` (for lookups)
Node properties:
- id: "code-container-id-registry"
- name: "Container ID Registry"
- type: "n8n-nodes-base.code"
- typeVersion: 2
- Do NOT add any connections for this node.
- Add `notesDisplayMode: "show"` and `notes: "UTILITY: Container name <-> Unraid PrefixedID translation. Not connected - Phase 16 will wire this in."` to parameters.
</action>
<verify>
Run: `python3 -c "import json; wf=json.load(open('n8n-workflow.json')); nodes={n['name']:n for n in wf['nodes']}; r=nodes.get('Container ID Registry'); print('EXISTS' if r else 'MISSING'); print('ID:', r['id'] if r else 'N/A'); print('Has _containerIdMap:', '_containerIdMap' in r['parameters'].get('jsCode','') if r else False); print('Has JSON.parse:', 'JSON.parse' in r['parameters'].get('jsCode','') if r else False); print('Has JSON.stringify:', 'JSON.stringify' in r['parameters'].get('jsCode','') if r else False)"`
</verify>
<done>Container ID Registry Code node exists in n8n-workflow.json with updateRegistry() and getUnraidId() functions, uses JSON serialization for static data persistence, is not connected to any other nodes.</done>
</task>
<task type="auto">
<name>Task 2: Add Callback Token Encoder and Decoder utility nodes</name>
<files>n8n-workflow.json</files>
<action>
Add two new Code nodes to n8n-workflow.json for callback token encoding/decoding. These are **standalone utility nodes** -- do NOT connect them to any existing nodes. Place them at positions [600, 2400] and [1000, 2400].
**Node 1: Callback Token Encoder**
id: "code-callback-token-encoder"
name: "Callback Token Encoder"
Position: [600, 2400]
Implement:
1. **Token store initialization** using static data JSON serialization:
```javascript
const staticData = $getWorkflowStaticData('global');
if (!staticData._callbackTokens) {
staticData._callbackTokens = JSON.stringify({});
}
const tokenStore = JSON.parse(staticData._callbackTokens);
```
2. **encodeToken(unraidId)** async function that:
- Generates SHA-256 hash of the unraidId string using `crypto.subtle.digest('SHA-256', new TextEncoder().encode(unraidId))`
- Takes first 8 hex characters as the token
- **Collision detection**: if `tokenStore[token]` exists AND `tokenStore[token] !== unraidId`, try next 8 chars from the hash (offset by 8). Repeat up to 7 times (56 chars of SHA-256 = 7 non-overlapping 8-char windows). Throw if all windows collide.
- Stores mapping: `tokenStore[token] = unraidId`
- Persists: `staticData._callbackTokens = JSON.stringify(tokenStore)`
- Returns the 8-char token
3. **Input/output contract:**
- Input: `$input.item.json.unraidId` (string, the 129-char PrefixedID)
- Also accepts optional `$input.item.json.action` (string) for convenience
- Output: `{ token, unraidId, callbackData: "action:{action}:{token}" (if action provided), byteSize }`
- Include byte size validation: if callbackData > 64 bytes, include `warning: "Callback data exceeds 64-byte limit"`
4. Add notes: "UTILITY: Encode Unraid PrefixedID to 8-char callback token. Not connected - Phase 16 will wire this in."
**Node 2: Callback Token Decoder**
id: "code-callback-token-decoder"
name: "Callback Token Decoder"
Position: [1000, 2400]
Implement:
1. **Token store read** (same JSON parse pattern):
```javascript
const staticData = $getWorkflowStaticData('global');
const tokenStore = JSON.parse(staticData._callbackTokens || '{}');
```
2. **decodeToken(token)** function that:
- Looks up token in tokenStore
- If not found: throws `Token not found: ${token}. Token store may have been cleared.`
- Returns the full unraidId (129-char PrefixedID)
3. **Input/output contract:**
- Input: `$input.item.json.token` (string, 8-char token) OR `$input.item.json.callbackData` (string like "action:start:a1b2c3d4")
- If callbackData provided, parse it: split by `:`, extract token from last segment
- Output: `{ token, unraidId, action (if parsed from callbackData) }`
4. Add notes: "UTILITY: Decode 8-char callback token to Unraid PrefixedID. Not connected - Phase 16 will wire this in."
**For both nodes:** Do NOT add any connections. These are standalone utilities.
After adding all nodes, push the updated workflow to n8n using the push recipe from CLAUDE.md.
</action>
<verify>
Run: `python3 -c "
import json
wf = json.load(open('n8n-workflow.json'))
nodes = {n['name']: n for n in wf['nodes']}
enc = nodes.get('Callback Token Encoder')
dec = nodes.get('Callback Token Decoder')
print('Encoder:', 'EXISTS' if enc else 'MISSING')
print('Decoder:', 'EXISTS' if dec else 'MISSING')
if enc:
code = enc['parameters'].get('jsCode', '')
print('Has crypto.subtle:', 'crypto.subtle' in code)
print('Has collision detection:', 'tokenStore[token]' in code)
print('Has _callbackTokens:', '_callbackTokens' in code)
if dec:
code = dec['parameters'].get('jsCode', '')
print('Has decodeToken:', 'decodeToken' in code)
print('Has _callbackTokens:', '_callbackTokens' in code)
# Verify no connections to/from utility nodes
conn = wf.get('connections', {})
for name in ['Container ID Registry', 'Callback Token Encoder', 'Callback Token Decoder']:
has_outgoing = name in conn
has_incoming = any(
any(any(c.get('node') == name for c in group) for group in outputs if group)
for outputs in conn.values()
for outputs in [list(outputs.values()) if isinstance(outputs, dict) else []]
)
print(f'{name} connections: outgoing={has_outgoing}')
print('Total nodes:', len(wf['nodes']))
"`
</verify>
<done>Callback Token Encoder and Decoder Code nodes exist in n8n-workflow.json. Encoder uses SHA-256 hashing with collision detection, produces 8-char tokens. Decoder resolves tokens back to PrefixedIDs. Both use JSON serialization for static data persistence. Neither node is connected to any other nodes. Workflow pushed to n8n.</done>
</task>
</tasks>
<verification>
1. Three new utility Code nodes exist in n8n-workflow.json: Container ID Registry, Callback Token Encoder, Callback Token Decoder
2. All three nodes use `$getWorkflowStaticData('global')` with JSON.parse/JSON.stringify pattern (not deep mutation)
3. None of the three nodes have connections to/from other nodes (standalone utilities)
4. Container ID Registry handles: updateRegistry(containers), getUnraidId(name), getContainerByName(name)
5. Token Encoder handles: encodeToken(unraidId) with SHA-256 + collision detection
6. Token Decoder handles: decodeToken(token) with error handling for missing tokens
7. Workflow JSON is valid and pushed to n8n
</verification>
<success_criteria>
- Container ID Registry node maps container names to Unraid PrefixedID format (129-char)
- Callback token encoding produces 8-char hex tokens that fit within Telegram's 64-byte callback_data limit
- Token collision detection prevents wrong-container scenarios
- All static data uses JSON serialization (top-level assignment) per CLAUDE.md convention
- Three standalone utility nodes ready for Phase 16 to wire in
</success_criteria>
<output>
After completion, create `.planning/phases/15-infrastructure-foundation/15-01-SUMMARY.md`
</output>
@@ -0,0 +1,291 @@
---
phase: 15-infrastructure-foundation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- n8n-workflow.json
autonomous: true
must_haves:
truths:
- "GraphQL response normalizer transforms Unraid API shape to Docker API contract"
- "Normalized containers have Id, Names (with leading slash), State (lowercase) fields"
- "GraphQL error handler checks response.errors[] array and maps error codes"
- "ALREADY_IN_STATE error code maps to HTTP 304 equivalent"
- "HTTP Request template node has 15-second timeout configured"
artifacts:
- path: "n8n-workflow.json"
provides: "GraphQL Response Normalizer, GraphQL Error Handler, Unraid API HTTP Template utility nodes"
contains: "normalizeContainers"
key_links:
- from: "GraphQL Response Normalizer node"
to: "Docker API contract"
via: "Field mapping (id->Id, names->Names with slash, state->State lowercase)"
pattern: "normalizeContainers"
- from: "GraphQL Error Handler node"
to: "HTTP 304 pattern"
via: "ALREADY_IN_STATE error code mapping"
pattern: "ALREADY_IN_STATE"
- from: "Unraid API HTTP Template node"
to: "myunraid.net cloud relay"
via: "15-second timeout configuration"
pattern: "timeout.*15000"
---
<objective>
Build the GraphQL Response Normalizer, Error Handler, and HTTP Request template as utility nodes in the main workflow.
Purpose: Phase 16 (API Migration) needs to transform Unraid GraphQL responses into the Docker API contract that 60+ existing Code nodes expect. The normalizer ensures zero changes to downstream nodes. The error handler standardizes GraphQL error checking (response.errors[] array) with equivalent HTTP status codes. The HTTP template provides correctly-configured timeout and retry settings for the myunraid.net cloud relay.
Output: Three new utility nodes in n8n-workflow.json: GraphQL Response Normalizer, GraphQL Error Handler, Unraid API HTTP Template.
</objective>
<execution_context>
@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
@/home/luc/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@ARCHITECTURE.md
@.planning/phases/15-infrastructure-foundation/15-RESEARCH.md
@.planning/phases/14-unraid-api-access/14-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Add GraphQL Response Normalizer utility node</name>
<files>n8n-workflow.json</files>
<action>
Add a new Code node named "GraphQL Response Normalizer" to n8n-workflow.json. This is a **standalone utility node** -- do NOT connect it to any existing nodes. Place it at position [200, 2600] (below the registry nodes from Plan 01).
The node must implement a `normalizeContainers(graphqlResponse)` function that transforms Unraid GraphQL API responses to match the Docker API contract used by existing workflow nodes.
**CRITICAL field mappings based on ARCHITECTURE.md verified schema:**
Unraid GraphQL format (from Phase 14 verification):
```json
{
"data": {
"docker": {
"containers": [
{ "id": "server_hash:container_hash", "names": ["/n8n"], "state": "RUNNING" }
]
}
}
}
```
Docker API format (expected by existing nodes):
```json
{ "Id": "abc123...", "Names": ["/n8n"], "State": "running", "Status": "running" }
```
Normalization rules:
1. `Id`: Copy from Unraid `id` (keep the full PrefixedID -- downstream will use Container ID Registry for translation)
2. `Names`: Copy from Unraid `names` array as-is (Phase 14 verified they already have `/` prefix)
3. `State`: Convert Unraid UPPERCASE to lowercase (`"RUNNING"` -> `"running"`, `"STOPPED"` -> `"exited"` to match Docker convention)
4. `Status`: Set to same as State (Docker has separate Status field, set to normalized state value)
5. `Image`: Set to `""` (not available in basic Unraid query, add when schema is extended)
6. `_unraidId`: Preserve original Unraid PrefixedID for debugging
**State mapping** (Unraid -> Docker equivalents):
- `RUNNING` -> `running`
- `STOPPED` -> `exited`
- `PAUSED` -> `paused`
- Default: lowercase the value
**Validation:**
- If `graphqlResponse.errors` exists, throw with error messages joined
- If `graphqlResponse.data?.docker?.containers` is missing, throw "Invalid GraphQL response structure"
**Input/output contract:**
- Input: `$input.item.json` is the raw GraphQL response object
- Output: Array of items, each `{ json: normalizedContainer }` (n8n multi-item output format)
Add notes: "UTILITY: Transform Unraid GraphQL response to Docker API contract. Not connected - Phase 16 will wire this in."
Node properties:
- id: "code-graphql-normalizer"
- name: "GraphQL Response Normalizer"
- type: "n8n-nodes-base.code"
- typeVersion: 2
</action>
<verify>
Run: `python3 -c "
import json
wf = json.load(open('n8n-workflow.json'))
nodes = {n['name']: n for n in wf['nodes']}
norm = nodes.get('GraphQL Response Normalizer')
print('EXISTS' if norm else 'MISSING')
if norm:
code = norm['parameters'].get('jsCode', '')
print('Has normalizeContainers:', 'normalizeContainers' in code)
print('Has State mapping:', 'RUNNING' in code and 'running' in code)
print('Has STOPPED->exited:', 'STOPPED' in code and 'exited' in code)
print('Has Names pass-through:', 'Names' in code)
print('Has error check:', 'errors' in code)
print('Has _unraidId:', '_unraidId' in code)
"`
</verify>
<done>GraphQL Response Normalizer Code node exists with normalizeContainers() function that maps Unraid fields (id, names, state) to Docker fields (Id, Names, State), handles UPPERCASE->lowercase state conversion, validates response structure, and is not connected to any other nodes.</done>
</task>
<task type="auto">
<name>Task 2: Add GraphQL Error Handler and Unraid API HTTP Template nodes</name>
<files>n8n-workflow.json</files>
<action>
Add two new utility nodes to n8n-workflow.json. These are **standalone utility nodes** -- do NOT connect them to any existing nodes.
**Node 1: GraphQL Error Handler (Code node)**
id: "code-graphql-error-handler"
name: "GraphQL Error Handler"
Position: [600, 2600]
Implement a `checkGraphQLErrors(response)` function that:
1. **Check GraphQL errors array:**
- If `response.errors` exists and has entries, examine first error
- Extract `error.extensions?.code` and `error.message`
- Map error codes to HTTP equivalents:
- `ALREADY_IN_STATE` -> `{ statusCode: 304, alreadyInState: true, message }` (matches Docker API `statusCode === 304` pattern used in n8n-actions.json)
- `NOT_FOUND` -> throw `Container not found: ${message}`
- `FORBIDDEN` / `UNAUTHORIZED` -> throw `Permission denied: ${message}. Check API key permissions.`
- Any other code -> throw `Unraid API error: ${message}`
2. **Check HTTP-level errors:**
- If `response.statusCode >= 400`: throw `HTTP ${statusCode}: ${statusMessage}`
3. **Check missing data:**
- If no `response.data`: throw `GraphQL response missing data field`
4. **Success return:**
- `{ statusCode: 200, alreadyInState: false, data: response.data }`
5. **Input/output contract:**
- Input: `$input.item.json` is the raw response from HTTP Request node
- Output: `{ json: { success, statusCode, alreadyInState, message, data } }`
- On `alreadyInState: true`: `success: true, message: "Container already in desired state"`
- On error: throw (n8n error handling catches it)
- On success: `success: true, data: response.data`
Add notes: "UTILITY: Standardized GraphQL error checking. Maps ALREADY_IN_STATE to HTTP 304. Not connected - Phase 16 will wire this in."
**Node 2: Unraid API HTTP Template (HTTP Request node)**
id: "http-unraid-api-template"
name: "Unraid API HTTP Template"
Position: [1000, 2600]
This is an n8n HTTP Request node pre-configured for Unraid GraphQL API calls. It serves as a **copy-paste template** for Phase 16 -- when migrating each API call, duplicate this node and update the query body.
Configuration:
```json
{
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "genericCredentialType",
"genericAuthType": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "x-api-key", "value": "={{ $env.UNRAID_API_KEY }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\"query\": \"query { docker { containers { id names state } } }\"}",
"options": {
"timeout": 15000,
"allowUnauthorizedCerts": true,
"response": {
"response": {
"fullResponse": true
}
}
},
"onError": "continueRegularOutput"
}
```
Key settings rationale:
- **15000ms timeout**: myunraid.net cloud relay adds 200-500ms latency (Phase 14 research). 15s provides safety margin for slow operations (container start/stop can take 3-5s + relay overhead).
- **allowUnauthorizedCerts: true**: Matches Phase 14 verified configuration.
- **genericAuthType: none + manual x-api-key header**: Phase 14 decision -- environment variables more reliable than n8n Header Auth credentials.
- **onError: continueRegularOutput**: Allows downstream Code node (GraphQL Error Handler) to check errors instead of n8n catching them.
- **fullResponse: true**: Returns statusCode, statusMessage alongside body for HTTP-level error checking.
Add notes: "TEMPLATE: Pre-configured HTTP Request for Unraid GraphQL API. 15s timeout for cloud relay. Duplicate and modify query for Phase 16 migration."
After adding all nodes, push the updated workflow to n8n using the push recipe from CLAUDE.md. Verify total node count is previous + 3 (from Plan 01) + 3 (from this plan) = original + 6.
</action>
<verify>
Run: `python3 -c "
import json
wf = json.load(open('n8n-workflow.json'))
nodes = {n['name']: n for n in wf['nodes']}
# Check Error Handler
eh = nodes.get('GraphQL Error Handler')
print('Error Handler:', 'EXISTS' if eh else 'MISSING')
if eh:
code = eh['parameters'].get('jsCode', '')
print(' Has checkGraphQLErrors:', 'checkGraphQLErrors' in code)
print(' Has ALREADY_IN_STATE:', 'ALREADY_IN_STATE' in code)
print(' Has 304:', '304' in code)
print(' Has NOT_FOUND:', 'NOT_FOUND' in code)
print(' Has FORBIDDEN:', 'FORBIDDEN' in code)
# Check HTTP Template
ht = nodes.get('Unraid API HTTP Template')
print('HTTP Template:', 'EXISTS' if ht else 'MISSING')
if ht:
params = ht.get('parameters', {})
print(' Has timeout 15000:', '15000' in json.dumps(params))
print(' Has UNRAID_HOST:', 'UNRAID_HOST' in json.dumps(params))
print(' Has UNRAID_API_KEY:', 'UNRAID_API_KEY' in json.dumps(params))
print(' Has continueRegularOutput:', ht.get('onError') == 'continueRegularOutput' or 'continueRegularOutput' in json.dumps(params))
print('Total nodes:', len(wf['nodes']))
# Verify no connections for new utility nodes
conn = wf.get('connections', {})
for name in ['GraphQL Response Normalizer', 'GraphQL Error Handler', 'Unraid API HTTP Template']:
has_outgoing = name in conn
print(f'{name}: outgoing connections = {has_outgoing}')
"`
</verify>
<done>GraphQL Error Handler Code node exists with checkGraphQLErrors() function that maps ALREADY_IN_STATE to HTTP 304 equivalent, handles NOT_FOUND/FORBIDDEN/UNAUTHORIZED errors. Unraid API HTTP Template node exists with 15-second timeout, correct auth headers, and continueRegularOutput error handling. Neither node is connected. Workflow pushed to n8n.</done>
</task>
</tasks>
<verification>
1. Three new utility nodes exist: GraphQL Response Normalizer, GraphQL Error Handler, Unraid API HTTP Template
2. Normalizer correctly maps Unraid fields to Docker API contract (id->Id, UPPERCASE->lowercase state, names preserved)
3. Error Handler maps ALREADY_IN_STATE to 304 (matches Docker API pattern in n8n-actions.json)
4. HTTP Template has 15-second timeout, x-api-key header via env var, continueRegularOutput
5. None of the three nodes have connections to/from other nodes (standalone utilities)
6. Workflow JSON is valid and pushed to n8n
7. Combined with Plan 01, total of 6 new utility nodes added to workflow
</verification>
<success_criteria>
- GraphQL response normalization transforms Unraid API shape to match Docker API contract exactly
- State values properly converted (RUNNING->running, STOPPED->exited)
- GraphQL error handling checks response.errors[] and maps to HTTP status codes
- ALREADY_IN_STATE maps to 304 (same pattern as Docker API's HTTP 304 for "already started/stopped")
- HTTP template has 15-second timeout for myunraid.net cloud relay latency
- Six standalone utility nodes total (3 from Plan 01 + 3 from Plan 02) ready for Phase 16
</success_criteria>
<output>
After completion, create `.planning/phases/15-infrastructure-foundation/15-02-SUMMARY.md`
</output>