{
  "name": "WF-30 poller",
  "nodes": [
    {
      "id": "sched",
      "name": "Every 5 min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 5
            }
          ]
        }
      }
    },
    {
      "id": "getdue",
      "name": "Get candidates",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        200,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "select id, type, cron, label, extract(epoch from last_run) as last_run_e from schedules where active and ((type = 'onetime' and run_at <= now()) or (type = 'recurring' and cron is not null))",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "id": "eval",
      "name": "Evaluate due",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        400,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Minimal 5-field cron matcher for the WF-30 poller Code node.\n// No external deps (n8n task-runner sandbox has no require).\n// Timezone: Asia/Dhaka = fixed UTC+6, no DST \u2014 epoch shift, then UTC getters.\n\nfunction parseField(spec, min, max) {\n  const out = new Set();\n  for (const part of String(spec).split(',')) {\n    const m = part.match(/^(\\*|\\d+(?:-\\d+)?)(?:\\/(\\d+))?$/);\n    if (!m) return null;\n    const step = m[2] ? parseInt(m[2], 10) : 1;\n    if (step < 1) return null;\n    let lo = min, hi = max;\n    if (m[1] !== '*') {\n      const r = m[1].split('-');\n      lo = parseInt(r[0], 10);\n      hi = r.length > 1 ? parseInt(r[1], 10) : (m[2] ? max : lo);\n    }\n    if (lo < min || hi > max || lo > hi) return null;\n    for (let v = lo; v <= hi; v += step) out.add(v);\n  }\n  return out;\n}\n\nfunction compileCron(expr) {\n  const f = String(expr).trim().split(/\\s+/);\n  if (f.length !== 5) return null;\n  const minute = parseField(f[0], 0, 59);\n  const hour = parseField(f[1], 0, 23);\n  const dom = parseField(f[2], 1, 31);\n  const mon = parseField(f[3], 1, 12);\n  const dowRaw = parseField(f[4], 0, 7);\n  if (!minute || !hour || !dom || !mon || !dowRaw) return null;\n  const dow = new Set([...dowRaw].map(v => v === 7 ? 0 : v));\n  return { minute, hour, dom, mon, dow,\n    domStar: f[2] === '*', dowStar: f[4] === '*' };\n}\n\n// epochSec: UTC epoch seconds. Returns true if cron matches that minute in Dhaka.\nfunction cronMatchesAt(c, epochSec) {\n  const d = new Date((epochSec + 6 * 3600) * 1000);\n  if (!c.minute.has(d.getUTCMinutes())) return false;\n  if (!c.hour.has(d.getUTCHours())) return false;\n  if (!c.mon.has(d.getUTCMonth() + 1)) return false;\n  const domOk = c.dom.has(d.getUTCDate());\n  const dowOk = c.dow.has(d.getUTCDay());\n  // standard cron: if both dom and dow are restricted, either may match\n  if (!c.domStar && !c.dowStar) return domOk || dowOk;\n  return domOk && dowOk;\n}\n\n// Did expr have a fire tick in (fromEpoch, toEpoch]? Lookback capped by caller.\nfunction cronFiredInWindow(expr, fromEpoch, toEpoch) {\n  const c = compileCron(expr);\n  if (!c) return false;\n  let t = Math.floor(fromEpoch / 60) * 60 + 60; // first whole minute AFTER fromEpoch\n  for (; t <= toEpoch; t += 60) {\n    if (cronMatchesAt(c, t)) return true;\n  }\n  return false;\n}\n\n\nconst nowE = Math.floor(Date.now() / 1000);\nconst due = [];\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  if (!j.id) continue;\n  if (j.type === 'onetime') { due.push(j); continue; }\n  if (j.type === 'recurring' && j.cron) {\n    const lastE = j.last_run_e ? Number(j.last_run_e) : nowE - 330;\n    const from = Math.max(lastE, nowE - 6 * 3600);\n    if (cronFiredInWindow(j.cron, from, nowE)) due.push(j);\n  }\n}\nreturn due.map(j => ({ json: { id: j.id, type: j.type, label: j.label || '' } }));\n"
      }
    },
    {
      "id": "mark",
      "name": "Mark fired",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        600,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "update schedules set last_run = now(), active = case when type = 'onetime' then false else active end where id = $1::bigint returning id",
        "options": {
          "queryReplacement": "={{ $json.id }}"
        }
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "fire",
      "name": "Fire full sweep",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.2,
      "position": [
        800,
        0
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "value": "__WF40_ID__",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "query": "",
            "trigger": "schedule"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "query",
              "displayName": "query",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            },
            {
              "id": "trigger",
              "displayName": "trigger",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            }
          ]
        },
        "options": {
          "waitForSubWorkflow": false
        }
      }
    },
    {
      "id": "findbacklog",
      "name": "Find backlog",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        200,
        200
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "with backlog as (\n  select rd.id\n  from raw_docs rd\n  left join feed_items f on f.raw_doc_id = rd.id\n  group by rd.id\n  having count(f.id) <> 1\n)\nselect rd.id as raw_doc_id,\n       rd.canonical_url,\n       coalesce(rd.title, '') as title,\n       coalesce(to_char(rd.published_at, 'YYYY-MM-DD\"T\"HH24:MI:SSOF'), '') as published_at,\n       coalesce(rd.run_id, 0) as run_id,\n       -- raw_docs has no source column; seen_urls records it at ingest\n       coalesce((select su.source from seen_urls su where su.canonical_url = rd.canonical_url), '') as source,\n       left(rd.markdown, 8000) as markdown,\n       (select count(*) from backlog) as backlog_total\nfrom raw_docs rd\nwhere rd.id in (select id from backlog)\n  -- opt-in: does nothing at all unless the user switched it on (\"fix feed\")\n  and (select value from settings where key = 'drain_enabled') = 'true'\n  -- and never competes with a live run\n  and not exists (\n    select 1 from runs r\n    where r.status = 'running' and r.started_at > now() - interval '15 minutes'\n  )\norder by rd.id\nlimit 12",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "alwaysOutputData": true
    },
    {
      "id": "hasbacklog",
      "name": "Has backlog?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        400,
        200
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "b1",
              "leftValue": "={{ $json.raw_doc_id }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ]
        }
      }
    },
    {
      "id": "buildbody",
      "name": "Build triage body",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        140
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Triage system prompt + response schema, shared by the build scripts so WF-20\n// (live pipeline) and WF-30 (backlog drain) can never drift apart.\n//\n// v2 (2026-07-26): two judgement axes replace the single `relevance` score,\n// which collapsed \u2014 on a real query (\"AI infrastructure costs\") 10 of 10 items\n// scored 5, so min_relevance filtered nothing. `relevance` is still emitted\n// alongside for A/B comparison and will be dropped once the axes are trusted.\n//\n// Deliberately NOT an axis: novelty. exa and brave return a publication date on\n// ~100% of results, so recency is exact SQL arithmetic on published_at rather\n// than a 9B model's guess. The prompt still receives the dates so it does not\n// invent recency inside the summary.\n//\n// The beat \u2014 what the writer actually covers \u2014 is personal editorial\n// configuration and stays out of the repo. beat.md (gitignored; one paragraph\n// of plain prose, no quotes/backticks/backslashes) is substituted for\n// __BEAT__ by scripts/deploy.js. Copy beat.example.md to beat.md to start.\nconst BEAT = '__BEAT__';\n\nconst TRIAGE_SYSTEM = [\n  'You are a research triage assistant for a writer covering: ' + BEAT,\n  'The audience is practitioners \u2014 people who build and operate these systems,',\n  'not general readers. Score every article against that beat, not general',\n  'interest.',\n  '',\n  'You read one scraped article and return ONLY a JSON object with exactly these',\n  'fields, in this order:',\n  '',\n  'summary: 2-3 sentences on what the article actually says. Substance only \u2014 no',\n  'meta-commentary about the article itself.',\n  '',\n  'angle: one sentence naming the specific, publishable hook this gives the writer',\n  'for that audience. Use null if there is no defensible angle beyond restating',\n  'the article.',\n  '',\n  'relevance (integer 1-5): overall usefulness to that beat.',\n  '',\n  'specificity (integer 1-5): how well anchored the claims are.',\n  '  5 = concrete data points, named companies and people, quantified outcomes,',\n  '      verifiable claims.',\n  '  3 = a mix \u2014 some claims backed, some asserted.',\n  '  1 = entirely abstract commentary, opinion, or speculation with nothing to',\n  '      check. Vendor content marketing and SEO listicles that recycle public',\n  '      figures without sourcing belong at 1-2.',\n  '',\n  'angle_strength (integer 1-5): how good the angle you just wrote actually is.',\n  '  5 = supports a contrarian, non-obvious, or counterintuitive take that most',\n  '      commentators would miss.',\n  '  3 = a viable angle, but the obvious one most people would write.',\n  '  1 = no defensible angle. If angle is null, angle_strength is 1.',\n  '',\n  'tags: array of 1-5 short lowercase kebab-case topic tags (e.g. \"ai-agents\",',\n  '\"frontier-markets\", \"inference-cost\"). No spaces, no punctuation.',\n  '',\n  'Scoring rules:',\n  '- specificity and angle_strength are independent. Judge each on its own',\n  '  evidence and do not let one pull the other. An article often deserves a high',\n  '  score on one and a low score on the other; that spread is the point.',\n  '- Most articles are not 5s. A typical useful article lands at 2-4. Reserve 5',\n  '  for cases that clearly meet its anchor.',\n  '- If the content is unusable \u2014 a paywall stub, error page, login wall, or a',\n  '  navigation/index/listing page with no article body \u2014 set angle to null, set',\n  '  all scores to 1, and say so in the summary.',\n  '',\n  'Return nothing except the JSON object.'\n].join('\\n');\n\n// Property order is load-bearing: grammar-constrained decoding emits fields in\n// schema order, so summary forces a real read before any number is committed,\n// and angle_strength comes after the angle it rates.\nconst TRIAGE_SCHEMA = {\n  type: 'json_schema',\n  json_schema: {\n    name: 'triage', strict: true,\n    schema: {\n      type: 'object',\n      properties: {\n        summary: { type: 'string' },\n        angle: { type: ['string', 'null'] },\n        relevance: { type: 'integer', minimum: 1, maximum: 5 },\n        specificity: { type: 'integer', minimum: 1, maximum: 5 },\n        angle_strength: { type: 'integer', minimum: 1, maximum: 5 },\n        tags: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 5 }\n      },\n      required: ['summary', 'angle', 'relevance', 'specificity', 'angle_strength', 'tags'],\n      additionalProperties: false\n    }\n  }\n};\n\nconst TRIAGE_MODEL = 'qwen/qwen3.5-9b';\n\n// Per-source triage profiles.\n//\n// MEASURED, 2026-07-27 \u2014 read this before tuning, it is counter-intuitive:\n// triage time tracks the model's REASONING OUTPUT, not how much you feed it.\n// Same document at three read lengths: 2500 chars/2835 tokens/38.7s,\n// 6000/4113/47.4s, 8000/2779/32.3s. The longest read was the fastest.\n// Qwen spends ~3000 tokens thinking to produce ~120 tokens of JSON, and that\n// is where the ~33s goes.\n//\n// Suppressing the thinking does not work on this model: enable_thinking via\n// chat_template_kwargs, a /no_think suffix, and reasoning_effort:minimal were\n// all tried and all ignored (2900-3300 tokens, 32-38s, no change).\n//\n// So `content_chars` does NOT buy speed. It buys judgement quality and context\n// budget, which is still worth varying: a feed blurb has nothing after 2500\n// chars, a hand-picked URL deserves the full read.\n//\n// The one real speed/cost lever is `model`. LM Studio serves every loaded\n// model on the same endpoint with the same key, so pointing a source at a\n// different model needs no new credential or URL \u2014 just `lms load <id>` first.\n// If the named model is not loaded the request fails, retries, and falls back\n// to TRIAGE_FAILED, so a missing model degrades rather than breaks.\n//\n// max_tokens is a ceiling, not a target \u2014 the model stops on its own well\n// below it. Keep it above ~2500 or the reasoning strands the JSON.\nconst TRIAGE_PROFILES = {\n  // Feed items are short and mostly low-signal; a smaller read is plenty.\n  rss:     { model: TRIAGE_MODEL, content_chars: 2500, max_tokens: 3000 },\n  // Search hits on the standing queries are the material worth thinking about.\n  exa:     { model: TRIAGE_MODEL, content_chars: 6000, max_tokens: 6000 },\n  tavily:  { model: TRIAGE_MODEL, content_chars: 6000, max_tokens: 6000 },\n  brave:   { model: TRIAGE_MODEL, content_chars: 6000, max_tokens: 6000 },\n  // A URL pasted by hand was chosen deliberately \u2014 read it properly.\n  manual:  { model: TRIAGE_MODEL, content_chars: 8000, max_tokens: 6000 },\n  default: { model: TRIAGE_MODEL, content_chars: 6000, max_tokens: 6000 }\n};\n\nfunction profileFor(source) {\n  const key = String(source || '').toLowerCase().trim();\n  return TRIAGE_PROFILES[key] || TRIAGE_PROFILES.default;\n}\n\nreturn $input.all().map(i => {\n  const d = i.json;\n  const prof = profileFor(d.source);\n  const dhaka = new Date(Date.now() + 6 * 3600 * 1000).toISOString().slice(0, 10);\n  const pub = d.published_at ? String(d.published_at).slice(0, 10) : 'unknown';\n  const user = 'TODAY: ' + dhaka + '\\n' + 'PUBLISHED: ' + pub + '\\n' +\n    'TITLE: ' + (d.title || '') + '\\n' + 'URL: ' + d.canonical_url +\n    '\\n\\nCONTENT:\\n' + String(d.markdown || '').slice(0, prof.content_chars);\n  return { json: {\n    raw_doc_id: d.raw_doc_id,\n    canonical_url: d.canonical_url,\n    title: d.title || '',\n    published_at: d.published_at || '',\n    run_id: d.run_id,\n    triage_body: JSON.stringify({\n      model: prof.model, temperature: 0.2, max_tokens: prof.max_tokens,\n      response_format: TRIAGE_SCHEMA,\n      messages: [ { role: 'system', content: TRIAGE_SYSTEM }, { role: 'user', content: user } ]\n    })\n  } };\n});"
      }
    },
    {
      "id": "draintriage",
      "name": "Drain triage",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.2,
      "position": [
        800,
        140
      ],
      "parameters": {
        "mode": "each",
        "workflowId": {
          "__rl": true,
          "value": "HISagmvZYi6O7N5u",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "triage_body": "={{ $json.triage_body }}",
            "raw_doc_id": "={{ $json.raw_doc_id }}",
            "canonical_url": "={{ $json.canonical_url }}",
            "title": "={{ $json.title }}",
            "published_at": "={{ $json.published_at }}",
            "run_id": "={{ $json.run_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "triage_body",
              "displayName": "triage_body",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            },
            {
              "id": "raw_doc_id",
              "displayName": "raw_doc_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            },
            {
              "id": "canonical_url",
              "displayName": "canonical_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            },
            {
              "id": "published_at",
              "displayName": "published_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            },
            {
              "id": "run_id",
              "displayName": "run_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "canBeUsedToMatch": true,
              "type": "string"
            }
          ]
        },
        "options": {
          "waitForSubWorkflow": true
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "lmping",
      "name": "Ping LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        200,
        460
      ],
      "parameters": {
        "method": "GET",
        "url": "http://host.docker.internal:1234/v1/models",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "options": {
          "timeout": 4000
        }
      },
      "credentials": {
        "httpBearerAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "id": "lmstate",
      "name": "LM state",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        400,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const j = $input.first().json || {};\n// a loaded model list means it is genuinely servable, not merely listening\nconst up = !j.error && Array.isArray(j.data) && j.data.length > 0;\nreturn [{ json: { lm_state: up ? 'up' : 'down' } }];"
      }
    },
    {
      "id": "lmrecord",
      "name": "Record LM state",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        600,
        460
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "with prev as (select value from settings where key = 'lm_state'), upd as (insert into settings (key, value, updated_at) values ('lm_state', $1, now()) on conflict (key) do update set value = excluded.value, updated_at = now()), unmute as (update notices set last_sent = now() - interval '7 hours' where kind = 'feed_backlog' and $1 = 'up' and coalesce((select value from prev), 'up') = 'down') select coalesce((select value from prev), 'unknown') as was, $1 as now_state",
        "options": {
          "queryReplacement": "={{ $json.lm_state }}"
        }
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true
    },
    {
      "id": "idlecheck",
      "name": "Check idle backlog",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        200,
        320
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "select (select count(*) from (\n          select rd.id from raw_docs rd\n          left join feed_items f on f.raw_doc_id = rd.id\n          group by rd.id having count(f.id) <> 1\n        ) a) as backlog_total,\n       (select value from settings where key = 'drain_enabled') as drain_enabled,\n       coalesce(\n         (select last_sent < now() - interval '6 hours' from notices where kind = 'feed_backlog'),\n         true\n       ) as notice_due",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "id": "shouldask",
      "name": "Should ask?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        400,
        320
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "s1",
              "leftValue": "={{ Number($json.backlog_total) }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            },
            {
              "id": "s2",
              "leftValue": "={{ String($json.drain_enabled) }}",
              "rightValue": "true",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            },
            {
              "id": "s3",
              "leftValue": "={{ String($json.notice_due) }}",
              "rightValue": "true",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        }
      }
    },
    {
      "id": "noticegate",
      "name": "Stamp notice",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.4,
      "position": [
        600,
        320
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "insert into notices (kind, last_sent) values ('feed_backlog', now()) on conflict (kind) do update set last_sent = now()",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true
    },
    {
      "id": "sendnotice",
      "name": "Send notice",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        800,
        320
      ],
      "parameters": {
        "chatId": "__TG_CHAT__",
        "text": "={{ $('Check idle backlog').first().json.backlog_total }} saved pages never got summarized, so they're missing from your feed.\n\nReply \"fix feed\" and I'll work through them. Reply \"stop feed\" any time to stop.",
        "additionalFields": {
          "appendAttribution": false,
          "parse_mode": "HTML"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "Every 5 min": {
      "main": [
        [
          {
            "node": "Get candidates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Find backlog",
            "type": "main",
            "index": 0
          },
          {
            "node": "Check idle backlog",
            "type": "main",
            "index": 0
          },
          {
            "node": "Ping LM Studio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ping LM Studio": {
      "main": [
        [
          {
            "node": "LM state",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LM state": {
      "main": [
        [
          {
            "node": "Record LM state",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find backlog": {
      "main": [
        [
          {
            "node": "Has backlog?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has backlog?": {
      "main": [
        [
          {
            "node": "Build triage body",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Build triage body": {
      "main": [
        [
          {
            "node": "Drain triage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check idle backlog": {
      "main": [
        [
          {
            "node": "Should ask?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Should ask?": {
      "main": [
        [
          {
            "node": "Stamp notice",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Stamp notice": {
      "main": [
        [
          {
            "node": "Send notice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get candidates": {
      "main": [
        [
          {
            "node": "Evaluate due",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Evaluate due": {
      "main": [
        [
          {
            "node": "Mark fired",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark fired": {
      "main": [
        [
          {
            "node": "Fire full sweep",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "errorWorkflow": "PNJMA4NbQGmp1xKv"
  }
}