[{"data":1,"prerenderedAt":812},["ShallowReactive",2],{"profile":3,"projects:page:\u002Fprojects\u002Fforevermore\u002Fautopilot":22,"projects:docs:\u002Fprojects\u002Fforevermore":771,"structured-data:current-stack":798},{"id":4,"alias":5,"current":6,"email":10,"extension":11,"headline":12,"links":13,"location":17,"meta":18,"name":19,"stem":20,"__hash__":21},"profile\u002Fprofile.yml","readonly",{"role":7,"company":8,"companyUrl":9},"Fullstack Engineer — Tech Lead","DataCose","https:\u002F\u002Fdatacose.com","redonemini18@gmail.com","yml","Fullstack Engineer",{"github":14,"linkedin":15,"twitch":16},"https:\u002F\u002Fgithub.com\u002FRedoni18","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fredon-emini\u002F","https:\u002F\u002Fwww.twitch.tv\u002Fr3adonly","Pristina, Kosovo",{},"Redon Emini","profile","Fbb1IAp4nuUOOtgXdwpzcqSmG0CUKtZ4D0ifPqdK5QU",{"id":23,"title":24,"body":25,"description":756,"extension":757,"featured":758,"kind":759,"links":760,"meta":761,"navigation":762,"order":764,"path":765,"period":766,"preview":766,"seo":767,"stack":768,"stem":769,"year":766,"__hash__":770},"projects\u002Fprojects\u002Fforevermore\u002F3.autopilot.md","A marketing pipeline built as a state machine",{"type":26,"value":27,"toc":749},"minimark",[28,32,35,41,46,49,84,100,104,107,415,424,427,520,523,527,530,554,651,654,661,665,668,724,731,734,738,745],[29,30,31],"p",{},"Running a product alone means doing its marketing alone too, and social media wants a steady stream of posts. Autopilot is the tool I built to take over the production side. It plans a week of posts, writes the copy, renders the finished images and videos, runs a quality check, and writes a digest for me to review. My part shrinks to approving or rejecting a batch.",[29,33,34],{},"It lives in its own repository, with its own Postgres database, and its only connection to the main platform is a folder path, so it can read assets from a local checkout. A marketing tool has no business sharing a deploy or a database schema with the product it promotes. If Autopilot breaks, the product doesn't notice.",[36,37],"link-card",{"description":38,"title":39,"to":40},"forevermore-autopilot: pipeline core, CLI, and the stage state machine.","Source on GitHub","https:\u002F\u002Fgithub.com\u002FRedoni18\u002Fforevermore-autopilot",[42,43,45],"h2",{"id":44},"five-stages","Five stages",[29,47,48],{},"Every run moves content through the same five stages, in order:",[50,51,52,60,66,72,78],"ol",{},[53,54,55,59],"li",{},[56,57,58],"strong",{},"Plan."," Pick ideas for the coming week from an idea database, scored by quality and by how recently each one was used. This stage is deterministic: same inputs, same plan, no AI involved.",[53,61,62,65],{},[56,63,64],{},"Generate."," Write captions, hashtags and text overlays.",[53,67,68,71],{},[56,69,70],{},"Render."," Produce the finished image or video.",[53,73,74,77],{},[56,75,76],{},"QA."," Check the result against a set of rules.",[53,79,80,83],{},[56,81,82],{},"Digest."," Write an HTML summary of everything waiting for review.",[29,85,86,87,91,92,95,96,99],{},"Each stage works on a specific date, and a finished ",[88,89,90],"code",{},"(stage, date)"," pair counts as done. Running it again logs a skip and does nothing, unless you pass ",[88,93,94],{},"--force",". That makes it safe to trigger stages from a scheduler without worrying about duplicate work if something fires twice. In other words, every stage is ",[56,97,98],{},"idempotent",": running it once or five times ends in the same place.",[42,101,103],{"id":102},"the-status-is-a-state-machine-not-a-flag","The status is a state machine, not a flag",[29,105,106],{},"Every piece of content has a status, and the legal moves between statuses are written down in one table:",[108,109,115],"pre",{"className":110,"code":111,"filename":112,"language":113,"meta":114,"style":114},"language-js shiki shiki-themes vitesse-light vitesse-dark","export const TRANSITIONS = {\n  planned: ['drafting', 'skipped'],\n  drafting: ['drafted', 'skipped'],\n  drafted: ['rendering', 'skipped'],\n  rendering: ['rendered', 'skipped'],\n  rendered: ['pending_review', 'qa_failed'],\n  qa_failed: ['drafting', 'skipped'],\n  pending_review: ['approved', 'changes_requested', 'skipped'],\n  changes_requested: ['drafting', 'skipped'],\n  approved: ['scheduled', 'skipped'],\n  \u002F\u002F ... scheduling and publishing states\n}\n","src\u002Fstate\u002Fmachine.mjs","js","",[88,116,117,141,178,205,232,259,287,313,349,375,402,409],{"__ignoreMap":114},[118,119,122,126,130,134,138],"span",{"class":120,"line":121},"line",1,[118,123,125],{"class":124},"sTPum","export",[118,127,129],{"class":128},"s5TCs"," const",[118,131,133],{"class":132},"s9nN2"," TRANSITIONS",[118,135,137],{"class":136},"si6no"," =",[118,139,140],{"class":136}," {\n",[118,142,144,148,151,154,158,162,164,167,170,173,175],{"class":120,"line":143},2,[118,145,147],{"class":146},"sHLBJ","  planned",[118,149,150],{"class":136},":",[118,152,153],{"class":136}," [",[118,155,157],{"class":156},"scnC2","'",[118,159,161],{"class":160},"spP0B","drafting",[118,163,157],{"class":156},[118,165,166],{"class":136},",",[118,168,169],{"class":156}," '",[118,171,172],{"class":160},"skipped",[118,174,157],{"class":156},[118,176,177],{"class":136},"],\n",[118,179,181,184,186,188,190,193,195,197,199,201,203],{"class":120,"line":180},3,[118,182,183],{"class":146},"  drafting",[118,185,150],{"class":136},[118,187,153],{"class":136},[118,189,157],{"class":156},[118,191,192],{"class":160},"drafted",[118,194,157],{"class":156},[118,196,166],{"class":136},[118,198,169],{"class":156},[118,200,172],{"class":160},[118,202,157],{"class":156},[118,204,177],{"class":136},[118,206,208,211,213,215,217,220,222,224,226,228,230],{"class":120,"line":207},4,[118,209,210],{"class":146},"  drafted",[118,212,150],{"class":136},[118,214,153],{"class":136},[118,216,157],{"class":156},[118,218,219],{"class":160},"rendering",[118,221,157],{"class":156},[118,223,166],{"class":136},[118,225,169],{"class":156},[118,227,172],{"class":160},[118,229,157],{"class":156},[118,231,177],{"class":136},[118,233,235,238,240,242,244,247,249,251,253,255,257],{"class":120,"line":234},5,[118,236,237],{"class":146},"  rendering",[118,239,150],{"class":136},[118,241,153],{"class":136},[118,243,157],{"class":156},[118,245,246],{"class":160},"rendered",[118,248,157],{"class":156},[118,250,166],{"class":136},[118,252,169],{"class":156},[118,254,172],{"class":160},[118,256,157],{"class":156},[118,258,177],{"class":136},[118,260,262,265,267,269,271,274,276,278,280,283,285],{"class":120,"line":261},6,[118,263,264],{"class":146},"  rendered",[118,266,150],{"class":136},[118,268,153],{"class":136},[118,270,157],{"class":156},[118,272,273],{"class":160},"pending_review",[118,275,157],{"class":156},[118,277,166],{"class":136},[118,279,169],{"class":156},[118,281,282],{"class":160},"qa_failed",[118,284,157],{"class":156},[118,286,177],{"class":136},[118,288,290,293,295,297,299,301,303,305,307,309,311],{"class":120,"line":289},7,[118,291,292],{"class":146},"  qa_failed",[118,294,150],{"class":136},[118,296,153],{"class":136},[118,298,157],{"class":156},[118,300,161],{"class":160},[118,302,157],{"class":156},[118,304,166],{"class":136},[118,306,169],{"class":156},[118,308,172],{"class":160},[118,310,157],{"class":156},[118,312,177],{"class":136},[118,314,316,319,321,323,325,328,330,332,334,337,339,341,343,345,347],{"class":120,"line":315},8,[118,317,318],{"class":146},"  pending_review",[118,320,150],{"class":136},[118,322,153],{"class":136},[118,324,157],{"class":156},[118,326,327],{"class":160},"approved",[118,329,157],{"class":156},[118,331,166],{"class":136},[118,333,169],{"class":156},[118,335,336],{"class":160},"changes_requested",[118,338,157],{"class":156},[118,340,166],{"class":136},[118,342,169],{"class":156},[118,344,172],{"class":160},[118,346,157],{"class":156},[118,348,177],{"class":136},[118,350,352,355,357,359,361,363,365,367,369,371,373],{"class":120,"line":351},9,[118,353,354],{"class":146},"  changes_requested",[118,356,150],{"class":136},[118,358,153],{"class":136},[118,360,157],{"class":156},[118,362,161],{"class":160},[118,364,157],{"class":156},[118,366,166],{"class":136},[118,368,169],{"class":156},[118,370,172],{"class":160},[118,372,157],{"class":156},[118,374,177],{"class":136},[118,376,378,381,383,385,387,390,392,394,396,398,400],{"class":120,"line":377},10,[118,379,380],{"class":146},"  approved",[118,382,150],{"class":136},[118,384,153],{"class":136},[118,386,157],{"class":156},[118,388,389],{"class":160},"scheduled",[118,391,157],{"class":156},[118,393,166],{"class":136},[118,395,169],{"class":156},[118,397,172],{"class":160},[118,399,157],{"class":156},[118,401,177],{"class":136},[118,403,405],{"class":120,"line":404},11,[118,406,408],{"class":407},"snYqZ","  \u002F\u002F ... scheduling and publishing states\n",[118,410,412],{"class":120,"line":411},12,[118,413,414],{"class":136},"}\n",[29,416,417,418,420,421,423],{},"That table is the single source of truth. Every status change in the codebase goes through one function, which looks the move up in the table and throws if it isn't there. You can't accidentally jump a post from ",[88,419,192],{}," straight to ",[88,422,327],{},", because no code path allows it. It's also one of the first things the tests check: illegal transitions get tested as carefully as legal ones.",[29,425,426],{},"Here's the production and review part of that lifecycle as a diagram:",[428,429,430],"mermaid",{},[108,431,434],{"className":432,"code":433,"language":428,"meta":114,"style":114},"language-mermaid shiki shiki-themes vitesse-light vitesse-dark","stateDiagram-v2\n  [*] --> planned\n  planned --> drafting\n  drafting --> drafted\n  drafted --> rendering\n  rendering --> rendered\n  rendered --> pending_review: QA passes\n  rendered --> qa_failed: QA fails\n  pending_review --> approved\n  pending_review --> changes_requested\n  changes_requested --> drafting: retry (max 2)\n  qa_failed --> drafting: retry (max 3)\n  qa_failed --> skipped\n  pending_review --> skipped: reject\n  approved --> [*]\n  skipped --> [*]\n",[88,435,436,441,446,451,456,461,466,471,476,481,486,491,496,502,508,514],{"__ignoreMap":114},[118,437,438],{"class":120,"line":121},[118,439,440],{},"stateDiagram-v2\n",[118,442,443],{"class":120,"line":143},[118,444,445],{},"  [*] --> planned\n",[118,447,448],{"class":120,"line":180},[118,449,450],{},"  planned --> drafting\n",[118,452,453],{"class":120,"line":207},[118,454,455],{},"  drafting --> drafted\n",[118,457,458],{"class":120,"line":234},[118,459,460],{},"  drafted --> rendering\n",[118,462,463],{"class":120,"line":261},[118,464,465],{},"  rendering --> rendered\n",[118,467,468],{"class":120,"line":289},[118,469,470],{},"  rendered --> pending_review: QA passes\n",[118,472,473],{"class":120,"line":315},[118,474,475],{},"  rendered --> qa_failed: QA fails\n",[118,477,478],{"class":120,"line":351},[118,479,480],{},"  pending_review --> approved\n",[118,482,483],{"class":120,"line":377},[118,484,485],{},"  pending_review --> changes_requested\n",[118,487,488],{"class":120,"line":404},[118,489,490],{},"  changes_requested --> drafting: retry (max 2)\n",[118,492,493],{"class":120,"line":411},[118,494,495],{},"  qa_failed --> drafting: retry (max 3)\n",[118,497,499],{"class":120,"line":498},13,[118,500,501],{},"  qa_failed --> skipped\n",[118,503,505],{"class":120,"line":504},14,[118,506,507],{},"  pending_review --> skipped: reject\n",[118,509,511],{"class":120,"line":510},15,[118,512,513],{},"  approved --> [*]\n",[118,515,517],{"class":120,"line":516},16,[118,518,519],{},"  skipped --> [*]\n",[29,521,522],{},"Retries are capped. A post that fails QA goes back to drafting at most three times, and a post I send back with notes can be regenerated at most twice. After that, it's skipped. Without those caps, a bad prompt or a flaky renderer could loop all night, burning time and API credits while I'm asleep.",[42,524,526],{"id":525},"surviving-a-crash-halfway-through","Surviving a crash halfway through",[29,528,529],{},"Anything that runs unattended will eventually crash in the middle of a job, or get started twice at the same time. The real question is what state it leaves behind.",[29,531,532,533,536,537,539,540,542,543,549,550,553],{},"Autopilot's answer is a ",[56,534,535],{},"compare-and-swap"," (CAS) update. Instead of \"set this post's status to ",[88,538,219],{},"\", every transition says \"set this post's status to ",[88,541,219],{},", ",[544,545,546,547],"em",{},"but only if it's currently ",[88,548,192],{},"\". In SQL, that's just an extra condition in the ",[88,551,552],{},"WHERE"," clause:",[108,555,559],{"className":556,"code":557,"language":558,"meta":114,"style":114},"language-sql shiki shiki-themes vitesse-light vitesse-dark","update autopilot.content_items\n   set status = 'rendering', updated_at = now()\n where id = $1\n   and status = 'drafted'      -- the status we expect it to be in\nreturning *;\n","sql",[88,560,561,577,605,622,640],{"__ignoreMap":114},[118,562,563,566,570,574],{"class":120,"line":121},[118,564,565],{"class":124},"update",[118,567,569],{"class":568},"sfsYZ"," autopilot",[118,571,573],{"class":572},"s8w-G",".",[118,575,576],{"class":568},"content_items\n",[118,578,579,582,585,587,589,591,593,596,599,602],{"class":120,"line":143},[118,580,581],{"class":124},"   set",[118,583,584],{"class":124}," status",[118,586,137],{"class":128},[118,588,169],{"class":156},[118,590,219],{"class":160},[118,592,157],{"class":156},[118,594,595],{"class":572},", updated_at ",[118,597,598],{"class":128},"=",[118,600,601],{"class":124}," now",[118,603,604],{"class":136},"()\n",[118,606,607,610,613,615,618],{"class":120,"line":180},[118,608,609],{"class":124}," where",[118,611,612],{"class":572}," id ",[118,614,598],{"class":128},[118,616,617],{"class":572}," $",[118,619,621],{"class":620},"sqbOQ","1\n",[118,623,624,627,629,631,633,635,637],{"class":120,"line":207},[118,625,626],{"class":124},"   and",[118,628,584],{"class":124},[118,630,137],{"class":128},[118,632,169],{"class":156},[118,634,192],{"class":160},[118,636,157],{"class":156},[118,638,639],{"class":407},"      -- the status we expect it to be in\n",[118,641,642,645,648],{"class":120,"line":234},[118,643,644],{"class":572},"returning ",[118,646,647],{"class":128},"*",[118,649,650],{"class":572},";\n",[29,652,653],{},"If the update changes one row, the transition happened. If it changes zero, something else moved the post first (or it doesn't exist), and the code raises a conflict instead of carrying on with stale assumptions. Two processes can race to render the same post, and exactly one of them wins. Postgres makes the check and the write a single atomic step, so there's no gap between \"check the status\" and \"change it\" for another process to slip into.",[29,655,656,657,660],{},"This is also why crashes are boring. The code never assumes a post is wherever the process last left it. After a restart, it reads the real status from the database and picks up from there. If you've come across ",[56,658,659],{},"optimistic locking",", this is it: instead of locking a row while you work on it, you check at write time that nobody changed it in the meantime.",[42,662,664],{"id":663},"building-the-skeleton-before-the-expensive-parts","Building the skeleton before the expensive parts",[29,666,667],{},"The three least predictable parts of the system, where the copy gets written, how a post gets judged, and where state is stored, are injected rather than hard-coded. Each one had a simple default that shipped first:",[669,670,671,687],"table",{},[672,673,674],"thead",{},[675,676,677,681,684],"tr",{},[678,679,680],"th",{},"Seam",[678,682,683],{},"What shipped first",[678,685,686],{},"The real implementation",[688,689,690,702,713],"tbody",{},[675,691,692,696,699],{},[693,694,695],"td",{},"Store",[693,697,698],{},"A plain file store",[693,700,701],{},"A Postgres store, once the schema existed",[675,703,704,707,710],{},[693,705,706],{},"Copywriting (\"brain\")",[693,708,709],{},"A fixture that returns fixed sample copy",[693,711,712],{},"A driver backed by a real model",[675,714,715,718,721],{},[693,716,717],{},"QA",[693,719,720],{},"A check that always passes",[693,722,723],{},"A real lint engine",[29,725,726,727,730],{},"That order was deliberate. The state machine, the CLI, retries with backoff, and the CAS transitions were all written and tested against those stand-ins before a single API key was involved. ",[88,728,729],{},"node --test"," covers the state machine (illegal moves included), CAS races, the planner's determinism, retries, and a full end-to-end run of the pipeline with every expensive part stubbed out. When the real model driver arrived, it replaced one seam, and none of the orchestration code around it had to change.",[29,732,733],{},"The idea travels well beyond pipelines. If you can make the expensive or unpredictable part of a system swappable, you can build and test everything around it first, cheaply and deterministically, and plug the real thing in last.",[42,735,737],{"id":736},"very-few-dependencies","Very few dependencies",[29,739,740,741,744],{},"The core is written against the Node standard library. Argument parsing, the stage runner and the review web UI are all hand-rolled, and the only runtime dependency is the ",[88,742,743],{},"postgres"," driver that the Postgres store needs. That was a constraint I set going in. A tool that runs on a schedule while nobody's watching shouldn't break overnight because some transitive dependency shipped a bad patch release.",[746,747,748],"style",{},"html pre.shiki code .sTPum, html code.shiki .sTPum{--shiki-default:#1E754F;--shiki-dark:#4D9375}html pre.shiki code .s5TCs, html code.shiki .s5TCs{--shiki-default:#AB5959;--shiki-dark:#CB7676}html pre.shiki code .s9nN2, html code.shiki .s9nN2{--shiki-default:#B07D48;--shiki-dark:#BD976A}html pre.shiki code .si6no, html code.shiki .si6no{--shiki-default:#999999;--shiki-dark:#666666}html pre.shiki code .sHLBJ, html code.shiki .sHLBJ{--shiki-default:#998418;--shiki-dark:#B8A965}html pre.shiki code .scnC2, html code.shiki .scnC2{--shiki-default:#B5695977;--shiki-dark:#C98A7D77}html pre.shiki code .spP0B, html code.shiki .spP0B{--shiki-default:#B56959;--shiki-dark:#C98A7D}html pre.shiki code .snYqZ, html code.shiki .snYqZ{--shiki-default:#A0ADA0;--shiki-dark:#758575DD}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sfsYZ, html code.shiki .sfsYZ{--shiki-default:#A65E2B;--shiki-dark:#C99076}html pre.shiki code .s8w-G, html code.shiki .s8w-G{--shiki-default:#393A34;--shiki-dark:#DBD7CAEE}html pre.shiki code .sqbOQ, html code.shiki .sqbOQ{--shiki-default:#2F798A;--shiki-dark:#4C9A91}",{"title":114,"searchDepth":143,"depth":180,"links":750},[751,752,753,754,755],{"id":44,"depth":143,"text":45},{"id":102,"depth":143,"text":103},{"id":525,"depth":143,"text":526},{"id":663,"depth":143,"text":664},{"id":736,"depth":143,"text":737},"Autopilot plans, writes, renders and checks a week of social posts on its own. How a state machine, compare-and-swap updates and injected dependencies make it safe to run unattended.","md",false,"personal",{},{},{"title":763},"Autopilot",999,"\u002Fprojects\u002Fforevermore\u002Fautopilot",null,{"title":24,"description":756},[],"projects\u002Fforevermore\u002F3.autopilot","eM1Afs_0Ptr3kgZ85U0WH29G2-Vl30e9TRx3X78xgws",[772,782,789,796],{"path":773,"title":774,"stem":775,"navigation":776,"description":777,"preview":778},"\u002Fprojects\u002Fforevermore","Forevermore","projects\u002Fforevermore\u002Findex",true,"A platform for personalized animated gift worlds. Pick a scene, fill it with photos and a song, and send it like a message.",{"src":779,"alt":780,"og":781},"\u002Fprojects\u002Fforevermore\u002Fpreview.webp","Forevermore: 'Create a world for someone you love.' beside three animated gift worlds fanned out like postcards","\u002Fprojects\u002Fforevermore\u002Fog.png",{"path":783,"title":784,"stem":785,"navigation":786,"description":788,"preview":766},"\u002Fprojects\u002Fforevermore\u002Farchitecture","Subdomains and permissions","projects\u002Fforevermore\u002F1.architecture",{"title":787},"Architecture","How every gift gets its own address on a host that doesn't support wildcard domains, and how Postgres grants keep payment state out of the browser's reach.",{"path":790,"title":791,"stem":792,"navigation":793,"description":795,"preview":766},"\u002Fprojects\u002Fforevermore\u002Frendering-pipeline","One payload, many worlds","projects\u002Fforevermore\u002F2.rendering-pipeline",{"title":794},"Rendering pipeline","How dozens of very different worlds read one data contract, why a video is secretly a photo, and why most of the 3D assets are code instead of files.",{"path":765,"title":24,"stem":769,"navigation":797,"description":756,"preview":766},{"title":763},[799,800,801,802,803,804,805,806,807,808,809,810,811],"TypeScript","Vue 3","Nuxt","Pinia","Tailwind CSS","Python","FastAPI","SQLAlchemy","Pydantic","PostgreSQL","Supabase","GCP","Firebase",1790471332101]