@north-light/cli 0.3.453 → 0.3.455

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/api/contract.d.ts +1 -1
  2. package/dist/api/contract.js +1 -1
  3. package/dist/api/error-codes.d.ts +25 -0
  4. package/dist/api/error-codes.js +1 -1
  5. package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +1 -1
  6. package/dist/builtin-memory/crouter-sdk/release-notes.md +6 -0
  7. package/dist/clients/attach/viewer.js +413 -413
  8. package/dist/commands/pkg/browse/catalog.js +1 -1
  9. package/dist/commands/pkg/plugin-manage.js +1 -1
  10. package/dist/core/command-manifests/registry.d.ts +2 -1
  11. package/dist/core/command-plugins/bundle.d.ts +3 -10
  12. package/dist/core/command-plugins/bundle.js +1 -1
  13. package/dist/core/command-plugins/compose.js +1 -1
  14. package/dist/core/command-plugins/discovery.js +1 -1
  15. package/dist/core/command-plugins/endpoint.d.ts +12 -0
  16. package/dist/core/command-plugins/endpoint.js +1 -1
  17. package/dist/core/command.d.ts +13 -0
  18. package/dist/core/command.js +9 -9
  19. package/dist/core/peer/client.d.ts +4 -2
  20. package/dist/core/peer/client.js +1 -1
  21. package/dist/core/peer/directory.d.ts +7 -0
  22. package/dist/core/peer/directory.js +1 -1
  23. package/dist/core/peer/envelope.d.ts +4 -0
  24. package/dist/core/peer/envelope.js +1 -1
  25. package/dist/core/peer/home.js +1 -1
  26. package/dist/core/providers/bundles.d.ts +2 -0
  27. package/dist/core/providers/bundles.js +2 -2
  28. package/dist/core/providers/call.d.ts +2 -0
  29. package/dist/core/providers/call.js +1 -1
  30. package/dist/core/providers/device.d.ts +39 -0
  31. package/dist/core/providers/device.js +1 -0
  32. package/dist/core/providers/errors.js +1 -1
  33. package/dist/core/runtime/output-schema-refs.d.ts +3 -0
  34. package/dist/core/runtime/output-schema-refs.js +1 -0
  35. package/dist/core/runtime/structured-output.d.ts +16 -5
  36. package/dist/core/runtime/structured-output.js +8 -6
  37. package/dist/daemon/api/handlers/messages.js +2 -2
  38. package/dist/daemon/api/handlers/nodes.js +1 -1
  39. package/dist/daemon/api/handlers/objects.js +2 -2
  40. package/dist/daemon/api/handlers/providers.d.ts +2 -0
  41. package/dist/daemon/api/handlers/providers.js +1 -1
  42. package/dist/daemon/api/handlers/runs.js +1 -1
  43. package/dist/daemon/api/handlers/structured-requests.js +1 -1
  44. package/dist/daemon/peer/outbox.js +1 -1
  45. package/dist/daemon/peer/relay-client.js +1 -1
  46. package/dist/daemon/peer/relay-protocol.d.ts +1 -1
  47. package/dist/daemon/peer/relay-protocol.js +1 -1
  48. package/dist/types.d.ts +4 -0
  49. package/docs/ci-runner.md +50 -4
  50. package/docs/sdk/guides/apps-on-northlight.md +1 -1
  51. package/docs/sdk/release-notes.md +6 -0
  52. package/package.json +3 -3
  53. package/packages/crouter-identity/package.json +1 -1
  54. package/runtime.lock.json +12 -12
@@ -1 +1 @@
1
- var O=Object.defineProperty;var i=(t,e)=>O(t,"name",{value:e,configurable:!0});import{isSafeNodeId as h}from"../../../core/canvas/paths.js";import{admitStructuredRequest as T,bindStructuredRequest as N,getPendingStructuredRequestForNode as P,getStructuredRequest as c,settleStructuredRequest as l,toStructuredRequestDTO as _}from"../../../core/canvas/structured-requests.js";import{getNode as S}from"../../../core/canvas/canvas.js";import{ApiError as D}from"../../../api/errors.js";import{handleCreate as j}from"./nodes.js";import{handleMessage as E}from"./messages.js";import{writeStructuredRequestSidecar as k}from"../../../core/runtime/structured-output.js";import{requestBrokerTeardown as x}from"../../../core/runtime/host.js";import{transition as $}from"../../../core/runtime/lifecycle.js";import{subscribeStructuredRequest as C}from"../../structured-requests/notifier.js";import{SYNC_WORK_BUDGET_MS as R}from"../../../api/index.js";import{waitSeconds as I}from"../long-poll-wait.js";function d(t,e,n){throw new D(t,e,n)}i(d,"fail");function M(t){(typeof t!="object"||t===null||Array.isArray(t))&&d(400,"invalid_request","a structured request requires a JSON object");const e=t;(typeof e.request_id!="string"||!h(e.request_id))&&d(400,"request_id_invalid","request_id must use the node-id character rule"),e.mode!=="sync"&&e.mode!=="async"&&d(400,"invalid_request","mode must be sync or async"),typeof e.prompt!="string"&&d(400,"invalid_request","prompt must be a string"),(typeof e.output_schema!="object"||e.output_schema===null||Array.isArray(e.output_schema))&&d(400,"invalid_request","output_schema must be a JSON Schema object");const n=e.target;(typeof n!="object"||n===null)&&d(400,"invalid_request","target is required");const r=n;if(r.kind==="node")(typeof r.node_id!="string"||!h(r.node_id))&&d(400,"invalid_request","target.node_id must use the node-id character rule");else if(r.kind==="new"){(typeof r.create!="object"||r.create===null||Array.isArray(r.create))&&d(400,"invalid_request","target.create must be an object");for(const u of["prompt","output_schema","deadline","root_lifecycle","no_kickoff"])Object.hasOwn(r.create,u)&&d(400,"create_field_forbidden",`target.create may not carry ${u}`)}else d(400,"invalid_request","target.kind must be new or node");return e.work_budget_ms!==void 0&&(!Number.isInteger(e.work_budget_ms)||e.work_budget_ms<=0)&&d(400,"work_budget_invalid","work_budget_ms must be a positive integer"),e.deadline_at!==void 0&&(typeof e.deadline_at!="string"||Number.isNaN(Date.parse(e.deadline_at)))&&d(400,"invalid_request","deadline_at must be an ISO timestamp"),e}i(M,"parseCreate");function F(t,e){const n=t.deadline_at===void 0?null:Date.parse(t.deadline_at),r=t.work_budget_ms===void 0?null:e+t.work_budget_ms,u=n===null?r:r===null?n:Math.min(n,r);return t.mode==="sync"?Math.min(u??e+R,e+R):u}i(F,"deadlineFor");function v(t,e,n=""){return{...t,method:"POST",path:"",params:n===""?{}:{id:n},query:new URLSearchParams,body:e}}i(v,"context");function q(t){return{status:200,body:{admitted:!0,request:_(t)}}}i(q,"terminalAdmission");function f(t){return t!==null&&Date.now()>=t}i(f,"deadlinePassed");function w(t){return q(l(t,{intended:"expired"}).row)}i(w,"expireAdmission");async function A(t,e){let n;const r=new Promise(m=>{n=m}),u=C(t,n);try{return c(t).state==="pending"&&e>0&&await Promise.race([r,new Promise(p=>setTimeout(p,e))]),_(c(t))}finally{u()}}i(A,"waitForRequest");async function B(t,e){const n=await A(t,Math.max(0,e-Date.now()));return n.state==="pending"?_(l(t,{intended:"expired"}).row):n}i(B,"waitForTerminal");async function L(t){const e=M(t.body);e.mode==="sync"&&e.target.kind!=="new"&&d(400,"sync_requires_dedicated_node","sync requests require target.kind=new");const n=Date.now(),r=F(e,n);if(f(r))return{status:200,body:{admitted:!1,reason:"work_budget_exhausted"}};let u=null,m="terminal",p=!0;if(e.target.kind==="node"){u=e.target.node_id;const s=S(u);s===null&&d(404,"node_not_found",`unknown node: ${u}`),(s.final_report!==null||s.status==="done"||s.status==="canceled"||s.status==="dead")&&d(409,"node_terminal",`node ${u} is terminal`),P(u)!==null&&d(409,"structured_request_conflict",`node ${u} already has a pending structured request`),m="oneoff",p=!1}if(f(r))return{status:200,body:{admitted:!1,reason:"work_budget_exhausted"}};let y;try{y=T({requestId:e.request_id,mode:e.mode,submission:m,dedicated:p,schema:e.output_schema,deadlineAt:r,nodeId:u,now:Date.now()})}catch(s){const a=String(s);throw(s.code==="ERR_SQLITE_CONSTRAINT_PRIMARYKEY"||a.includes("structured_requests.request_id"))&&d(409,"request_id_in_use",`request_id ${e.request_id} is already in use`),(a.includes("idx_structured_requests_one_pending_node")||a.includes("structured_requests.node_id"))&&d(409,"structured_request_conflict","the target node already has a pending structured request"),s}if(f(r))return w(e.request_id);if(e.target.kind==="new")try{u=(await j(v(t,{...e.target.create,root:!0,root_lifecycle:"terminal",prompt:""}),{},{...r===null?{}:{deadlineAt:new Date(r).toISOString()},beforeBrokerLaunch:i(a=>{if(u=a,f(r))throw new Error("structured request deadline expired before binding");const o=N(e.request_id,a);if(!o.changed||o.row===null||o.row.state!=="pending")throw new Error("structured request bind did not remain pending");u=a,k(o.row)},"beforeBrokerLaunch")})).body.node_id}catch{const a=c(e.request_id),o=a.state==="pending"?l(e.request_id,{intended:"failed",failureReason:a.node_id===null?"node_create_failed":"bind_failed"}).row:a;if(u!==null&&o.node_id===null){const b=S(u);(b?.status==="active"||b?.status==="idle")&&$(u,"cancel",{reason:"stranded"}),await x(u)}return q(o)}else{if(f(r))return w(e.request_id);try{k(y)}catch{return q(l(e.request_id,{intended:"failed",failureReason:"bind_failed"}).row)}}const g=c(e.request_id);if(g.state!=="pending")return q(g);if(f(r))return w(e.request_id);try{await E(v(t,{body:e.prompt,tier:"normal"},u))}catch{return q(l(e.request_id,{intended:"failed",failureReason:"prompt_delivery_failed"}).row)}return e.mode==="async"?{status:202,body:{admitted:!0,request:_(c(e.request_id))}}:{status:200,body:{admitted:!0,request:await B(e.request_id,r)}}}i(L,"handleCreateStructuredRequest");async function Y(t){const e=t.params.request_id,n=I("structured-request.get",t.query),r=c(e);return r===null&&d(404,"structured_request_not_found",`unknown structured request: ${e}`),{status:200,body:n===0?_(r):await A(e,n*1e3)}}i(Y,"handleReadStructuredRequest");async function G(t){const e=l(t.params.request_id,{intended:"canceled"});return e.row===null&&d(404,"structured_request_not_found",`unknown structured request: ${t.params.request_id}`),{status:200,body:_(e.row)}}i(G,"handleCancelStructuredRequest");const ne=[{method:"POST",pattern:"/v1/structured-requests",handler:L},{method:"GET",pattern:"/v1/structured-requests/:request_id",handler:Y},{method:"POST",pattern:"/v1/structured-requests/:request_id/cancel",handler:G}];export{ne as structuredRequestRoutes};
1
+ var P=Object.defineProperty;var i=(t,e)=>P(t,"name",{value:e,configurable:!0});import{isSafeNodeId as S}from"../../../core/canvas/paths.js";import{admitStructuredRequest as T,bindStructuredRequest as N,getPendingStructuredRequestForNode as D,getStructuredRequest as l,settleStructuredRequest as _,toStructuredRequestDTO as f}from"../../../core/canvas/structured-requests.js";import{getNode as k}from"../../../core/canvas/canvas.js";import{ApiError as j}from"../../../api/errors.js";import{handleCreate as E}from"./nodes.js";import{handleMessage as $}from"./messages.js";import{outputSchemaProblem as x,writeStructuredRequestSidecar as R}from"../../../core/runtime/structured-output.js";import{requestBrokerTeardown as C}from"../../../core/runtime/host.js";import{transition as I}from"../../../core/runtime/lifecycle.js";import{subscribeStructuredRequest as M}from"../../structured-requests/notifier.js";import{SYNC_WORK_BUDGET_MS as v}from"../../../api/index.js";import{waitSeconds as F}from"../long-poll-wait.js";function n(t,e,r){throw new j(t,e,r)}i(n,"fail");function B(t){(typeof t!="object"||t===null||Array.isArray(t))&&n(400,"invalid_request","a structured request requires a JSON object");const e=t;(typeof e.request_id!="string"||!S(e.request_id))&&n(400,"request_id_invalid","request_id must use the node-id character rule"),e.mode!=="sync"&&e.mode!=="async"&&n(400,"invalid_request","mode must be sync or async"),typeof e.prompt!="string"&&n(400,"invalid_request","prompt must be a string"),(typeof e.output_schema!="object"||e.output_schema===null||Array.isArray(e.output_schema))&&n(400,"invalid_request","output_schema must be a JSON Schema object");const r=e.target;(typeof r!="object"||r===null)&&n(400,"invalid_request","target is required");const u=r;if(u.kind==="node")(typeof u.node_id!="string"||!S(u.node_id))&&n(400,"invalid_request","target.node_id must use the node-id character rule");else if(u.kind==="new"){(typeof u.create!="object"||u.create===null||Array.isArray(u.create))&&n(400,"invalid_request","target.create must be an object");for(const s of["prompt","output_schema","deadline","root_lifecycle","no_kickoff"])Object.hasOwn(u.create,s)&&n(400,"create_field_forbidden",`target.create may not carry ${s}`)}else n(400,"invalid_request","target.kind must be new or node");return e.work_budget_ms!==void 0&&(!Number.isInteger(e.work_budget_ms)||e.work_budget_ms<=0)&&n(400,"work_budget_invalid","work_budget_ms must be a positive integer"),e.deadline_at!==void 0&&(typeof e.deadline_at!="string"||Number.isNaN(Date.parse(e.deadline_at)))&&n(400,"invalid_request","deadline_at must be an ISO timestamp"),e}i(B,"parseCreate");function L(t,e){const r=t.deadline_at===void 0?null:Date.parse(t.deadline_at),u=t.work_budget_ms===void 0?null:e+t.work_budget_ms,s=r===null?u:u===null?r:Math.min(r,u);return t.mode==="sync"?Math.min(s??e+v,e+v):s}i(L,"deadlineFor");function A(t,e,r=""){return{...t,method:"POST",path:"",params:r===""?{}:{id:r},query:new URLSearchParams,body:e}}i(A,"context");function q(t){return{status:200,body:{admitted:!0,request:f(t)}}}i(q,"terminalAdmission");function m(t){return t!==null&&Date.now()>=t}i(m,"deadlinePassed");function w(t){return q(_(t,{intended:"expired"}).row)}i(w,"expireAdmission");async function O(t,e){let r;const u=new Promise(d=>{r=d}),s=M(t,r);try{return l(t).state==="pending"&&e>0&&await Promise.race([u,new Promise(p=>setTimeout(p,e))]),f(l(t))}finally{s()}}i(O,"waitForRequest");async function Y(t,e){const r=await O(t,Math.max(0,e-Date.now()));return r.state==="pending"?f(_(t,{intended:"expired"}).row):r}i(Y,"waitForTerminal");async function G(t){const e=B(t.body),r=await x(e.output_schema);r!==null&&n(400,"invalid_request",`output_schema is not usable: ${r}`),e.mode==="sync"&&e.target.kind!=="new"&&n(400,"sync_requires_dedicated_node","sync requests require target.kind=new");const u=Date.now(),s=L(e,u);if(m(s))return{status:200,body:{admitted:!1,reason:"work_budget_exhausted"}};let d=null,p="terminal",y=!0;if(e.target.kind==="node"){d=e.target.node_id;const a=k(d);a===null&&n(404,"node_not_found",`unknown node: ${d}`),(a.final_report!==null||a.status==="done"||a.status==="canceled"||a.status==="dead")&&n(409,"node_terminal",`node ${d} is terminal`),D(d)!==null&&n(409,"structured_request_conflict",`node ${d} already has a pending structured request`),p="oneoff",y=!1}if(m(s))return{status:200,body:{admitted:!1,reason:"work_budget_exhausted"}};let b;try{b=T({requestId:e.request_id,mode:e.mode,submission:p,dedicated:y,schema:e.output_schema,deadlineAt:s,nodeId:d,now:Date.now()})}catch(a){const o=String(a);throw(a.code==="ERR_SQLITE_CONSTRAINT_PRIMARYKEY"||o.includes("structured_requests.request_id"))&&n(409,"request_id_in_use",`request_id ${e.request_id} is already in use`),(o.includes("idx_structured_requests_one_pending_node")||o.includes("structured_requests.node_id"))&&n(409,"structured_request_conflict","the target node already has a pending structured request"),a}if(m(s))return w(e.request_id);if(e.target.kind==="new")try{d=(await E(A(t,{...e.target.create,root:!0,root_lifecycle:"terminal",prompt:""}),{},{...s===null?{}:{deadlineAt:new Date(s).toISOString()},beforeBrokerLaunch:i(o=>{if(d=o,m(s))throw new Error("structured request deadline expired before binding");const c=N(e.request_id,o);if(!c.changed||c.row===null||c.row.state!=="pending")throw new Error("structured request bind did not remain pending");d=o,R(c.row)},"beforeBrokerLaunch")})).body.node_id}catch{const o=l(e.request_id),c=o.state==="pending"?_(e.request_id,{intended:"failed",failureReason:o.node_id===null?"node_create_failed":"bind_failed"}).row:o;if(d!==null&&c.node_id===null){const g=k(d);(g?.status==="active"||g?.status==="idle")&&I(d,"cancel",{reason:"stranded"}),await C(d)}return q(c)}else{if(m(s))return w(e.request_id);try{R(b)}catch{return q(_(e.request_id,{intended:"failed",failureReason:"bind_failed"}).row)}}const h=l(e.request_id);if(h.state!=="pending")return q(h);if(m(s))return w(e.request_id);try{await $(A(t,{body:e.prompt,tier:"normal"},d))}catch{return q(_(e.request_id,{intended:"failed",failureReason:"prompt_delivery_failed"}).row)}return e.mode==="async"?{status:202,body:{admitted:!0,request:f(l(e.request_id))}}:{status:200,body:{admitted:!0,request:await Y(e.request_id,s)}}}i(G,"handleCreateStructuredRequest");async function J(t){const e=t.params.request_id,r=F("structured-request.get",t.query),u=l(e);return u===null&&n(404,"structured_request_not_found",`unknown structured request: ${e}`),{status:200,body:r===0?f(u):await O(e,r*1e3)}}i(J,"handleReadStructuredRequest");async function K(t){const e=_(t.params.request_id,{intended:"canceled"});return e.row===null&&n(404,"structured_request_not_found",`unknown structured request: ${t.params.request_id}`),{status:200,body:f(e.row)}}i(K,"handleCancelStructuredRequest");const se=[{method:"POST",pattern:"/v1/structured-requests",handler:G},{method:"GET",pattern:"/v1/structured-requests/:request_id",handler:J},{method:"POST",pattern:"/v1/structured-requests/:request_id/cancel",handler:K}];export{se as structuredRequestRoutes};
@@ -1 +1 @@
1
- var T=Object.defineProperty;var f=(e,d)=>T(e,"name",{value:d,configurable:!0});import{openDb as I,withCanvasWrite as L}from"../../core/canvas/db.js";import{emitEvent as R}from"../../core/events/emit.js";import{isUnreachable as A,peerCall as j}from"../../core/peer/client.js";import{listMachines as B,machineReach as C,normalizeMachineId as k,ownMachineId as D}from"../../core/peer/directory.js";import{relayConnected as U}from"../../core/peer/events.js";import{ingestPeerEvents as F}from"../../core/peer/ingest.js";import{dueRows as N,expireOld as W,markDone as q,markExpired as O,markRetry as x,markWaiting as S,onPeerEnqueued as X,pendingFor as Y,pruneOutbox as z,OUTBOX_MAX_AGE_MS as G}from"../../core/peer/outbox-store.js";const g={first:1e3,max:10*6e4},E=5*6e4,H=5*6e4,P=50,J=20,K={db:I,call:f((e,d)=>j(e,d),"call"),reach:C,machines:B,self:D,now:Date.now},M=f(e=>Math.min(g.first*2**Math.max(0,e-1),g.max),"stableBackoffMs");function V(e){const d=new Set;async function p(s,a,o){const t=e.db(),n=e.now(),l=await e.call(s,{method:"POST",path:"/v1/peer/events",body:{events:a.map(r=>JSON.parse(r.payload_json))}}),m=f(r=>{for(const c of a){const h=c.attempts+1,_=c.first_failed_at??n;o&&r?S(t,c.id,h):o?n-_>=E?S(t,c.id,h):x(t,c.id,h,n+Math.min(M(h),E-(n-_)),_):n-_>=G?O(t,c.id):x(t,c.id,h,n+M(h),_)}},"fail");if(A(l)){m(l.unreachable.reason==="machine_offline");return}const i=l.body;if(l.status!==200||i===null||!Array.isArray(i.accepted)){m(!1);return}const y=new Set(i.accepted),b=new Set((Array.isArray(i.rejected)?i.rejected:[]).map(r=>r.key));for(const r of a)y.has(r.idempotency_key)?q(t,r.id):b.has(r.idempotency_key)?O(t,r.id):x(t,r.id,r.attempts+1,n+M(r.attempts+1),r.first_failed_at??n)}f(p,"sendBatch");async function w(s){const a=s.filter(o=>!d.has(o.id));for(const o of a)d.add(o.id);try{const o=new Map;for(const t of a)o.set(t.peer,[...o.get(t.peer)??[],t]);await Promise.all([...o].map(async([t,n])=>{const m=(await e.reach(t).catch(()=>null))?.reach.kind==="relay";for(let i=0;i<n.length;i+=P)await p(t,n.slice(i,i+P),m)}))}finally{for(const o of a)d.delete(o.id)}}f(w,"pushRows");let u=0;return{async run(){const s=e.db(),a=e.now();a-u>=60*6e4&&(u=a,W(s,a),z(s,a)),await w(N(s,a))},flush:f(s=>w(Y(e.db(),s,e.now())),"flush"),async pull(){const s=e.self(),a=(await e.machines()).filter(t=>k(t.machine_id)!==s&&t.reach.kind==="stable");let o=0;for(const t of a){const n=k(t.machine_id);for(let l=0;l<J;l++){const m=await e.call(n,{method:"POST",path:"/v1/peer/outbox/claim",body:{limit:100}}),i=!A(m)&&m.status===200?m.body?.rows:void 0;if(!Array.isArray(i)||i.length===0)break;const y=L(c=>F(c,n,i.map(h=>h.payload)));o+=y.accepted.length;const b=new Set([...y.accepted,...y.rejected.map(c=>c.key)]),r=i.filter(c=>b.has(c.key)).map(c=>({key:c.key,claim_token:c.claim_token}));if(r.length>0&&await e.call(n,{method:"POST",path:"/v1/peer/outbox/ack",body:{acks:r}}),i.length<100)break}}return o}}}f(V,"createOutboxLane");function re(e){if(!e.joined)return()=>{};const d=e.deps??K,p=V(d),w=f(n=>l=>{R({level:"warn",event:`daemon.peer.${n}_failed`,error:l})},"quiet"),u=f((n,l)=>e.register(n().then(()=>{},w(l))),"guarded"),v=X(n=>u(()=>p.flush(n),"outbox_flush")),s=setInterval(()=>u(()=>p.run(),"outbox_run"),e.tickMs??5e3);s.unref();const a=f(()=>u(async()=>{(await d.machines()).find(l=>k(l.machine_id)===d.self())?.reach.kind==="relay"&&await p.pull()},"outbox_pull"),"pull"),o=U.on(a),t=setInterval(a,H);return t.unref(),a(),()=>{v(),o(),clearInterval(s),clearInterval(t)}}f(re,"startPeerOutbox");export{H as PULL_INTERVAL_MS,E as RELAY_RETRY_WINDOW_MS,g as STABLE_BACKOFF_MS,V as createOutboxLane,K as liveOutboxDeps,M as stableBackoffMs,re as startPeerOutbox};
1
+ var T=Object.defineProperty;var f=(t,d)=>T(t,"name",{value:d,configurable:!0});import{openDb as I,withCanvasWrite as L}from"../../core/canvas/db.js";import{emitEvent as R}from"../../core/events/emit.js";import{isUnreachable as A,peerCall as j}from"../../core/peer/client.js";import{listMachines as B,machineReach as C,normalizeMachineId as k,ownMachineId as D}from"../../core/peer/directory.js";import{relayConnected as U}from"../../core/peer/events.js";import{ingestPeerEvents as F}from"../../core/peer/ingest.js";import{dueRows as N,expireOld as W,markDone as q,markExpired as O,markRetry as x,markWaiting as S,onPeerEnqueued as X,pendingFor as Y,pruneOutbox as z,OUTBOX_MAX_AGE_MS as G}from"../../core/peer/outbox-store.js";const g={first:1e3,max:10*6e4},E=5*6e4,H=5*6e4,P=50,J=20,K={db:I,call:f((t,d)=>j(t,d),"call"),reach:C,machines:B,self:D,now:Date.now},v=f(t=>Math.min(g.first*2**Math.max(0,t-1),g.max),"stableBackoffMs");function V(t){const d=new Set;async function p(s,a,o){const e=t.db(),n=t.now(),l=await t.call(s,{method:"POST",path:"/v1/peer/events",body:{events:a.map(r=>JSON.parse(r.payload_json))}}),m=f(r=>{for(const c of a){const h=c.attempts+1,_=c.first_failed_at??n;o&&r?S(e,c.id,h):o?n-_>=E?S(e,c.id,h):x(e,c.id,h,n+Math.min(v(h),E-(n-_)),_):n-_>=G?O(e,c.id):x(e,c.id,h,n+v(h),_)}},"fail");if(A(l)){m(l.unreachable.reason==="machine_offline");return}const i=l.body;if(l.status!==200||i===null||!Array.isArray(i.accepted)){m(!1);return}const y=new Set(i.accepted),b=new Set((Array.isArray(i.rejected)?i.rejected:[]).map(r=>r.key));for(const r of a)y.has(r.idempotency_key)?q(e,r.id):b.has(r.idempotency_key)?O(e,r.id):x(e,r.id,r.attempts+1,n+v(r.attempts+1),r.first_failed_at??n)}f(p,"sendBatch");async function w(s){const a=s.filter(o=>!d.has(o.id));for(const o of a)d.add(o.id);try{const o=new Map;for(const e of a)o.set(e.peer,[...o.get(e.peer)??[],e]);await Promise.all([...o].map(async([e,n])=>{const m=(await t.reach(e).catch(()=>null))?.reach.kind==="relay";for(let i=0;i<n.length;i+=P)await p(e,n.slice(i,i+P),m)}))}finally{for(const o of a)d.delete(o.id)}}f(w,"pushRows");let u=0;return{async run(){const s=t.db(),a=t.now();a-u>=60*6e4&&(u=a,W(s,a),z(s,a)),await w(N(s,a))},flush:f(s=>w(Y(t.db(),s,t.now())),"flush"),async pull(){const s=t.self(),a=(await t.machines()).filter(e=>k(e.machine_id)!==s&&e.kind!=="device"&&e.reach.kind==="stable");let o=0;for(const e of a){const n=k(e.machine_id);for(let l=0;l<J;l++){const m=await t.call(n,{method:"POST",path:"/v1/peer/outbox/claim",body:{limit:100}}),i=!A(m)&&m.status===200?m.body?.rows:void 0;if(!Array.isArray(i)||i.length===0)break;const y=L(c=>F(c,n,i.map(h=>h.payload)));o+=y.accepted.length;const b=new Set([...y.accepted,...y.rejected.map(c=>c.key)]),r=i.filter(c=>b.has(c.key)).map(c=>({key:c.key,claim_token:c.claim_token}));if(r.length>0&&await t.call(n,{method:"POST",path:"/v1/peer/outbox/ack",body:{acks:r}}),i.length<100)break}}return o}}}f(V,"createOutboxLane");function re(t){if(!t.joined)return()=>{};const d=t.deps??K,p=V(d),w=f(n=>l=>{R({level:"warn",event:`daemon.peer.${n}_failed`,error:l})},"quiet"),u=f((n,l)=>t.register(n().then(()=>{},w(l))),"guarded"),M=X(n=>u(()=>p.flush(n),"outbox_flush")),s=setInterval(()=>u(()=>p.run(),"outbox_run"),t.tickMs??5e3);s.unref();const a=f(()=>u(async()=>{(await d.machines()).find(l=>k(l.machine_id)===d.self())?.reach.kind==="relay"&&await p.pull()},"outbox_pull"),"pull"),o=U.on(a),e=setInterval(a,H);return e.unref(),a(),()=>{M(),o(),clearInterval(s),clearInterval(e)}}f(re,"startPeerOutbox");export{H as PULL_INTERVAL_MS,E as RELAY_RETRY_WINDOW_MS,g as STABLE_BACKOFF_MS,V as createOutboxLane,K as liveOutboxDeps,v as stableBackoffMs,re as startPeerOutbox};
@@ -1 +1 @@
1
- var I=Object.defineProperty;var o=(_,e)=>I(_,"name",{value:e,configurable:!0});import{request as Y}from"node:http";import{decodeJwt as D}from"jose";import{WebSocket as R}from"ws";import{emitEvent as b}from"../../core/events/emit.js";import{relayConnected as q}from"../../core/peer/events.js";import{peerTokens as j}from"../../core/peer/token.js";import{parseServerFrame as z,RELAY_CLOSE as B,RELAY_MAX_BODY_BYTES as C,RELAY_PROTOCOL_VERSION as H}from"./relay-protocol.js";const J=1e3,K=6e4,U=6e4,X=6e4,V=1e4,W=["authorization","crtr-envelope","crtr-version","content-type"];function G(_){const e=new URL("/v1/connect",_);return e.protocol=e.protocol==="http:"?"ws:":"wss:",e.toString()}o(G,"relayConnectUrl");const Q=o(_=>{const e={host:"peer.local"};for(const E of W){const p=_[E];p!==void 0&&(e[E]=p)}return e},"pick");function se(_){const e={backoffMinMs:J,backoffMaxMs:K,refreshLeadMs:U,silenceLimitMs:X,helloTimeoutMs:V,tokens:j,connect:o(r=>new R(r,{maxPayload:Math.ceil(C*4/3)+65536}),"connect"),..._},E=G(e.relayOrigin);let p=!1,T,O=!1,k=e.backoffMinMs,m,v=0,L=!1;const h=new Map;function A(){if(m=void 0,p)return;const r=++v;(async()=>{let i;try{i=await e.tokens.get(e.issuer,e.relayOrigin)}catch(n){return b({level:"warn",event:"daemon.peer.relay_token_failed",error:n instanceof Error?n:new Error(String(n))}),x(r)}p||r!==v||N(i,r)})()}o(A,"dial");function x(r,i){if(p||r!==v||m!==void 0)return;const n=i??k;k=Math.min(k*2,e.backoffMaxMs),m=setTimeout(A,n),m.unref()}o(x,"schedule");function N(r,i){const n=e.connect(E);T=n;let l,a,f,u=!1,g=!1;const M=o(t=>{n.readyState===R.OPEN&&n.send(JSON.stringify(t))},"send"),d=o(()=>{clearTimeout(f),f=setTimeout(()=>{b({level:"warn",event:"daemon.peer.relay_silent"}),n.terminate()},e.silenceLimitMs),f.unref()},"heard"),w=o((t,c)=>{if(g)return;g=!0,clearTimeout(l),clearTimeout(a),clearTimeout(f);for(const[,S]of h)S.abort();h.clear(),T===n&&(T=void 0),O=!1,u?b({level:"info",event:"daemon.peer.relay_disconnected",fields:{code:t,reason:c}}):b({level:"warn",event:"daemon.peer.relay_connect_failed",fields:{code:t,reason:c}}),t===B.unauthorized&&e.tokens.drop(e.issuer,e.relayOrigin);const s=L;L=!1,x(i,s?0:t===B.replaced?e.backoffMaxMs:void 0)},"finish"),y=o(t=>{clearTimeout(a);const c=Math.max(0,t*1e3-e.refreshLeadMs-Date.now());a=setTimeout(()=>{e.tokens.drop(e.issuer,e.relayOrigin),e.tokens.get(e.issuer,e.relayOrigin).then(s=>{if(g)return;M({type:"token",token:s});const S=D(s).exp;y(typeof S=="number"&&S>t?S:t+1)},s=>{b({level:"warn",event:"daemon.peer.relay_refresh_failed",error:s instanceof Error?s:new Error(String(s))}),g||(clearTimeout(a),a=setTimeout(()=>y(t),5e3),a.unref())})},c),a.unref()},"planRefresh");n.on("open",()=>{M({type:"hello",protocol:H,token:r}),l=setTimeout(()=>n.terminate(),e.helloTimeoutMs),l.unref(),d()}),n.on("message",(t,c)=>{if(d(),c)return;const s=z(Array.isArray(t)?Buffer.concat(t).toString("utf8"):Buffer.from(t).toString("utf8"));s!==null&&F(s)}),n.on("close",(t,c)=>w(t,c.toString("utf8"))),n.on("error",t=>{b({level:"debug",event:"daemon.peer.relay_socket_error",error:t}),w(1006,"error");try{n.terminate()}catch{}});function F(t){switch(t.type){case"hello_ok":{clearTimeout(l),u=!0,O=!0,k=e.backoffMinMs,y(t.expires_at),b({level:"info",event:"daemon.peer.relay_connected",fields:{relay:e.relayOrigin,machine_id:t.machine_id}}),q.emit();return}case"ping":M({type:"pong"});return;case"pong":return;case"cancel":h.get(t.id)?.abort(),h.delete(t.id);return;case"request":{if(!u)return;const c=new AbortController;h.set(t.id,c),P(t,c.signal).then(s=>{h.delete(t.id),c.signal.aborted||M({type:"response",id:t.id,...s})});return}}}o(F,"handle")}o(N,"run");function P(r,i){const n=o((l,a,f)=>({status:l,headers:{"content-type":"application/json"},body_b64:Buffer.from(JSON.stringify({error:{code:a,message:f}})).toString("base64")}),"fail");return new Promise(l=>{const a=Buffer.from(r.body_b64,"base64"),f=Y({socketPath:e.socketPath,method:r.method,path:r.path,headers:{...Q(r.headers),"content-length":String(a.byteLength)},signal:i},u=>{const g=[];let M=0;u.on("data",d=>{if(M+=d.byteLength,M>C){l(n(502,"response_too_large","the answer is over 1 MiB")),f.destroy();return}g.push(d)}),u.on("end",()=>{const d={};for(const[w,y]of Object.entries(u.headers))typeof y=="string"?d[w]=y:Array.isArray(y)&&(d[w]=y.join(", "));l({status:Math.min(599,Math.max(200,u.statusCode??502)),headers:d,body_b64:Buffer.concat(g).toString("base64")})}),u.on("error",()=>l(n(502,"peer_ingress_failed","the peer ingress dropped the answer")))});f.on("error",()=>l(n(502,"peer_ingress_failed","the peer ingress could not be reached"))),f.end(a)})}return o(P,"replay"),A(),{connected:o(()=>O,"connected"),reconnectNow(){if(p)return;clearTimeout(m),m=void 0,k=e.backoffMinMs;const r=T;if(r!==void 0){L=!0,r.terminate();return}A()},async stop(){p=!0,clearTimeout(m),m=void 0,v++;for(const[,i]of h)i.abort();h.clear();const r=T;T=void 0,O=!1,r!==void 0&&await new Promise(i=>{const n=setTimeout(()=>{r.terminate(),i()},1e3);n.unref(),r.once("close",()=>{clearTimeout(n),i()});try{r.close(1e3,"going_away")}catch{r.terminate()}})}}}o(se,"startRelayClient");export{K as BACKOFF_MAX_MS,J as BACKOFF_MIN_MS,V as HELLO_TIMEOUT_MS,X as SILENCE_LIMIT_MS,U as TOKEN_REFRESH_LEAD_MS,G as relayConnectUrl,se as startRelayClient};
1
+ var I=Object.defineProperty;var o=(_,e)=>I(_,"name",{value:e,configurable:!0});import{request as Y}from"node:http";import{decodeJwt as D}from"jose";import{WebSocket as R}from"ws";import{emitEvent as b}from"../../core/events/emit.js";import{relayConnected as q}from"../../core/peer/events.js";import{peerTokens as j}from"../../core/peer/token.js";import{parseServerFrame as z,RELAY_CLOSE as B,RELAY_MAX_BODY_BYTES as C,RELAY_PROTOCOL_VERSION as H}from"./relay-protocol.js";const J=1e3,K=6e4,U=6e4,X=6e4,V=1e4,W=["authorization","crtr-envelope","crtr-version","content-type"];function G(_){const e=new URL("/v1/connect",_);return e.protocol=e.protocol==="http:"?"ws:":"wss:",e.toString()}o(G,"relayConnectUrl");const Q=o(_=>{const e={host:"peer.local"};for(const E of W){const p=_[E];p!==void 0&&(e[E]=p)}return e},"pick");function se(_){const e={backoffMinMs:J,backoffMaxMs:K,refreshLeadMs:U,silenceLimitMs:X,helloTimeoutMs:V,tokens:j,connect:o(r=>new R(r,{maxPayload:Math.ceil(C*4/3)+65536}),"connect"),..._},E=G(e.relayOrigin);let p=!1,T,O=!1,k=e.backoffMinMs,m,v=0,L=!1;const h=new Map;function A(){if(m=void 0,p)return;const r=++v;(async()=>{let i;try{i=await e.tokens.get(e.issuer,e.relayOrigin)}catch(n){return b({level:"warn",event:"daemon.peer.relay_token_failed",error:n instanceof Error?n:new Error(String(n))}),x(r)}p||r!==v||N(i,r)})()}o(A,"dial");function x(r,i){if(p||r!==v||m!==void 0)return;const n=i??k;k=Math.min(k*2,e.backoffMaxMs),m=setTimeout(A,n),m.unref()}o(x,"schedule");function N(r,i){const n=e.connect(E);T=n;let l,a,f,u=!1,g=!1;const M=o(t=>{n.readyState===R.OPEN&&n.send(JSON.stringify(t))},"send"),d=o(()=>{clearTimeout(f),f=setTimeout(()=>{b({level:"warn",event:"daemon.peer.relay_silent"}),n.terminate()},e.silenceLimitMs),f.unref()},"heard"),w=o((t,c)=>{if(g)return;g=!0,clearTimeout(l),clearTimeout(a),clearTimeout(f);for(const[,S]of h)S.abort();h.clear(),T===n&&(T=void 0),O=!1,u?b({level:"info",event:"daemon.peer.relay_disconnected",fields:{code:t,reason:c}}):b({level:"warn",event:"daemon.peer.relay_connect_failed",fields:{code:t,reason:c}}),t===B.unauthorized&&e.tokens.drop(e.issuer,e.relayOrigin);const s=L;L=!1,x(i,s?0:t===B.replaced?e.backoffMaxMs:void 0)},"finish"),y=o(t=>{clearTimeout(a);const c=Math.max(0,t*1e3-e.refreshLeadMs-Date.now());a=setTimeout(()=>{e.tokens.drop(e.issuer,e.relayOrigin),e.tokens.get(e.issuer,e.relayOrigin).then(s=>{if(g)return;M({type:"token",token:s});const S=D(s).exp;y(typeof S=="number"&&S>t?S:t+1)},s=>{b({level:"warn",event:"daemon.peer.relay_refresh_failed",error:s instanceof Error?s:new Error(String(s))}),g||(clearTimeout(a),a=setTimeout(()=>y(t),5e3),a.unref())})},c),a.unref()},"planRefresh");n.on("open",()=>{M({type:"hello",protocol:H,token:r}),l=setTimeout(()=>n.terminate(),e.helloTimeoutMs),l.unref(),d()}),n.on("message",(t,c)=>{if(d(),c)return;const s=z(Array.isArray(t)?Buffer.concat(t).toString("utf8"):Buffer.from(t).toString("utf8"));s!==null&&F(s)}),n.on("close",(t,c)=>w(t,c.toString("utf8"))),n.on("error",t=>{b({level:"debug",event:"daemon.peer.relay_socket_error",error:t}),w(1006,"error");try{n.terminate()}catch{}});function F(t){switch(t.type){case"hello_ok":{clearTimeout(l),u=!0,O=!0,k=e.backoffMinMs,y(t.expires_at),b({level:"info",event:"daemon.peer.relay_connected",fields:{relay:e.relayOrigin,machine_id:t.machine_id}}),q.emit();return}case"ping":M({type:"pong"});return;case"pong":return;case"cancel":h.get(t.id)?.abort(),h.delete(t.id);return;case"request":{if(!u)return;const c=new AbortController;h.set(t.id,c),P(t,c.signal).then(s=>{h.delete(t.id),c.signal.aborted||M({type:"response",id:t.id,...s})});return}}}o(F,"handle")}o(N,"run");function P(r,i){const n=o((l,a,f)=>({status:l,headers:{"content-type":"application/json"},body_b64:Buffer.from(JSON.stringify({error:{code:a,message:f}})).toString("base64")}),"fail");return new Promise(l=>{const a=Buffer.from(r.body_b64,"base64"),f=Y({socketPath:e.socketPath,method:r.method,path:r.path,headers:{...Q(r.headers),"content-length":String(a.byteLength)},signal:i},u=>{const g=[];let M=0;u.on("data",d=>{if(M+=d.byteLength,M>C){l(n(502,"response_too_large","the answer is over 16 MiB")),f.destroy();return}g.push(d)}),u.on("end",()=>{const d={};for(const[w,y]of Object.entries(u.headers))typeof y=="string"?d[w]=y:Array.isArray(y)&&(d[w]=y.join(", "));l({status:Math.min(599,Math.max(200,u.statusCode??502)),headers:d,body_b64:Buffer.concat(g).toString("base64")})}),u.on("error",()=>l(n(502,"peer_ingress_failed","the peer ingress dropped the answer")))});f.on("error",()=>l(n(502,"peer_ingress_failed","the peer ingress could not be reached"))),f.end(a)})}return o(P,"replay"),A(),{connected:o(()=>O,"connected"),reconnectNow(){if(p)return;clearTimeout(m),m=void 0,k=e.backoffMinMs;const r=T;if(r!==void 0){L=!0,r.terminate();return}A()},async stop(){p=!0,clearTimeout(m),m=void 0,v++;for(const[,i]of h)i.abort();h.clear();const r=T;T=void 0,O=!1,r!==void 0&&await new Promise(i=>{const n=setTimeout(()=>{r.terminate(),i()},1e3);n.unref(),r.once("close",()=>{clearTimeout(n),i()});try{r.close(1e3,"going_away")}catch{r.terminate()}})}}}o(se,"startRelayClient");export{K as BACKOFF_MAX_MS,J as BACKOFF_MIN_MS,V as HELLO_TIMEOUT_MS,X as SILENCE_LIMIT_MS,U as TOKEN_REFRESH_LEAD_MS,G as relayConnectUrl,se as startRelayClient};
@@ -1,6 +1,6 @@
1
1
  export declare const RELAY_PROTOCOL_VERSION = 1;
2
2
  /** Largest request or response body, in bytes before base64. */
3
- export declare const RELAY_MAX_BODY_BYTES = 1048576;
3
+ export declare const RELAY_MAX_BODY_BYTES: number;
4
4
  /** WebSocket close codes the relay uses. */
5
5
  export declare const RELAY_CLOSE: {
6
6
  readonly badFrame: 4000;
@@ -1 +1 @@
1
- var o=Object.defineProperty;var n=(t,e)=>o(t,"name",{value:e,configurable:!0});const c=1,l=1048576,s={badFrame:4e3,unauthorized:4001,replaced:4002,tokenExpired:4003},r=n(t=>typeof t=="string","isString"),p=n(t=>typeof t=="object"&&t!==null&&!Array.isArray(t)&&Object.values(t).every(r),"isHeaders");function u(t){let e;try{e=JSON.parse(t)}catch{return null}if(typeof e!="object"||e===null)return null;switch(e.type){case"hello_ok":return r(e.machine_id)&&typeof e.expires_at=="number"&&typeof e.heartbeat_s=="number"&&typeof e.protocol=="number"?e:null;case"request":return r(e.id)&&r(e.method)&&r(e.path)&&p(e.headers)&&r(e.body_b64)?e:null;case"cancel":return r(e.id)?{type:"cancel",id:e.id}:null;case"ping":return{type:"ping"};case"pong":return{type:"pong"};default:return null}}n(u,"parseServerFrame");export{s as RELAY_CLOSE,l as RELAY_MAX_BODY_BYTES,c as RELAY_PROTOCOL_VERSION,u as parseServerFrame};
1
+ var o=Object.defineProperty;var n=(t,e)=>o(t,"name",{value:e,configurable:!0});const c=1,l=16777216,s={badFrame:4e3,unauthorized:4001,replaced:4002,tokenExpired:4003},r=n(t=>typeof t=="string","isString"),p=n(t=>typeof t=="object"&&t!==null&&!Array.isArray(t)&&Object.values(t).every(r),"isHeaders");function u(t){let e;try{e=JSON.parse(t)}catch{return null}if(typeof e!="object"||e===null)return null;switch(e.type){case"hello_ok":return r(e.machine_id)&&typeof e.expires_at=="number"&&typeof e.heartbeat_s=="number"&&typeof e.protocol=="number"?e:null;case"request":return r(e.id)&&r(e.method)&&r(e.path)&&p(e.headers)&&r(e.body_b64)?e:null;case"cancel":return r(e.id)?{type:"cancel",id:e.id}:null;case"ping":return{type:"ping"};case"pong":return{type:"pong"};default:return null}}n(u,"parseServerFrame");export{s as RELAY_CLOSE,l as RELAY_MAX_BODY_BYTES,c as RELAY_PROTOCOL_VERSION,u as parseServerFrame};
package/dist/types.d.ts CHANGED
@@ -23,6 +23,10 @@ export type PluginTransport = {
23
23
  kind: 'http';
24
24
  endpoint: string;
25
25
  authEnv?: string;
26
+ }
27
+ /** A provider whose calls go to one of the account's `kind = device` machines over the relay; there is no endpoint. */
28
+ | {
29
+ kind: 'machine';
26
30
  };
27
31
  /** Provenance for a crouter-materialized HTTP plugin directory archive. */
28
32
  export interface PluginBundle {
package/docs/ci-runner.md CHANGED
@@ -1,10 +1,31 @@
1
1
  # CI runners and the double-run gate
2
2
 
3
- Crouter's suite runs on macOS (see the header of `.github/workflows/test.yml`). GitHub-hosted `macos-latest` is billed per minute and a full run is ~50 billed minutes, so the suite can run on a self-hosted Mac instead, and the push to `main` does not re-run it for a tree that already passed.
3
+ The suite runs on **GitHub-hosted Linux** (ubuntu, inside a root container shaped like the hosted guest), and only the tests that are about the laptop runtime run on **macOS** (the Mac mini runners). macOS minutes bill at 10x Linux, so the macOS lane is deliberately small. The push to `main` does not re-run a tree that already passed.
4
+
5
+ ## Why Linux, and why a container
6
+
7
+ On Linux crouter assumes it is the hosted guest (`src/core/layout.ts`): sockets in `/run/crtr`, data under `/runtime`, `/user` and `/apps`, brokers started through a root launcher. A plain non-root runner has none of that (every lane failed with EACCES, which is why the suite once moved to macOS). The `linux` jobs of `test.yml` therefore run as root in a container built from `docker/ci.Dockerfile`:
8
+
9
+ - **Image.** `docker/ci.Dockerfile` is the guest's runner stage (same pinned `node:24-alpine` digest, same apk list, users `crtrd` 10002 / `crtr-data` 10001, the `/run/crtr` `/runtime` `/user` `/apps` directories) plus `build-base`, `linux-headers` (native addon) and GNU `tar`. It does not contain crouter; the job checks the source out. Chosen over `hosting/guest/Dockerfile` (needs sandbox-api, chrony, the python stack and a packed crouter, so it rebuilds per commit) and `docker/unit.Dockerfile` (bakes a packed crouter). `scripts/ci-image.test.mjs` (in `test:contract`) fails when the apk list, ids, directories or node base drift from `docker/base.Dockerfile`; keep the two in step by hand when the guest changes.
10
+ - **Capabilities.** `--cap-add SYS_ADMIN --cap-add NET_ADMIN --security-opt seccomp=unconfined --security-opt apparmor=unconfined`: mount namespaces per test file and bubblewrap's namespaces.
11
+ - **One `/run/crtr` per test file.** All test files would otherwise share `/run/crtr/api.sock`, `store.sock` and `nodes/`. `scripts/ci-test-files.mjs` runs each file under `unshare -m` with a tmpfs on `/run/crtr`, and its own `HOME`, `TMPDIR`, `CRTR_RUNTIME_HOME` and `CRTR_HOME`. A failing file is retried once and reported as flaky if the retry passes (as `scripts/test-retry.mjs` does on macOS).
12
+ - **Running a list of files.** `ci-test-files.mjs <lane> --files <list>` (and `scripts/ci-linux-lane.sh <lane> <shard> --files <list>`) runs exactly the files in a text file of repo paths, one per line (seam: the `dist/…/*.test.js` path); the format is in the script header. A path that is not a test file of that lane exits 2.
13
+ - **Flaky recording.** A file that fails and then passes on the rerun is written to `flaky.json`, which each `linux` and `macos` job uploads as artifact `flaky-<attempt>-<platform>-<lane>-<job index>`. `record-flaky-tests.yml` runs on every completed Test or Publish run (on main the suite is Publish's `tests` job) and `scripts/record-flaky-tests.mjs` files one `flaky-test` issue per lane, file and failing test, or comments on the existing one.
14
+ - **No real launcher.** The root launcher starts `crtrd` on the real `/runtime` and flips code paths on its socket, so it cannot run in the test container. Tests that need it are tagged `laptopOnly` and run on macOS.
15
+
16
+ ## The `laptopOnly` tag
17
+
18
+ `src/core/__tests__/helpers/platform.ts` exports `laptopOnly(reason)`, which returns `{ skip: 'laptop runtime only (runs on macOS): <reason>' }` on Linux and `{ skip: false }` elsewhere, and `onLaptop(fn)` for the before/after hooks of a file whose tests are all tagged (node:test runs hooks of skipped tests). Pass it to `test`: `test('name', laptopOnly('needs the root launcher'), async () => {…})`. Tagged tests are never deleted; the skip message states why. `scripts/laptop-only-files.mjs <lane>` lists the files carrying tags; the macOS lane runs exactly those files (`scripts/ci-test-files.mjs <lane> --laptop-only`), so adding a tag adds the file to the macOS lane with no list to maintain.
19
+
20
+ Reasons in use: the app/cron/broker process is started through the hosted root launcher; a node-scoped CLI on Linux asks the daemon for its hook plan, which requires the root launcher; Linux brokers start bash jobs through the launcher; root bypasses the `chmod` the test relies on; Linux reads process identities from `/proc`; the branded host and the listener's `userRead` path are macOS-only; the laptop layout resolves `fileRoot` and `operations.db` differently.
21
+
22
+ ## Shards and timings
23
+
24
+ Hosted billing rounds each job up to a whole minute and every job pays fixed setup (checkout, image, node_modules, unpack), so shards are few and large: unit 3, integration 2, seam 2. See the PR for measured wall time and billed minutes.
4
25
 
5
26
  ## Where the macOS jobs run: `CRTR_CI_RUNNER`
6
27
 
7
- Repository variable `CRTR_CI_RUNNER` selects `runs-on` for the `build`, `build-output` and `lane` jobs of `test.yml`:
28
+ Repository variable `CRTR_CI_RUNNER` selects `runs-on` for the `macos` jobs of `test.yml` (the `checks`, `linux`, `suite` and `tested` jobs are always hosted ubuntu):
8
29
 
9
30
  | Value | Runs on |
10
31
  | --- | --- |
@@ -16,7 +37,7 @@ gh variable set CRTR_CI_RUNNER --body self-hosted -R Northlight-Systems/crouter
16
37
  gh variable delete CRTR_CI_RUNNER -R Northlight-Systems/crouter # fall back to macos-latest
17
38
  ```
18
39
 
19
- It takes effect on the next run; no commit is needed. **If the mini is offline, jobs queue until it returns (they do not fall back by themselves) — delete the variable.** Cancel and re-run anything already queued. The variable also changes the shard matrix: `self-hosted` uses 2 unit + 3 integration + 3 seam shards (8 lane jobs, 3 runner instances), hosted uses 3 + 6 + 6 (15).
40
+ It takes effect on the next run; no commit is needed. **If the mini is offline, jobs queue until it returns (they do not fall back by themselves) — delete the variable.** Cancel and re-run anything already queued. The macOS lane is three small jobs (`macos unit`, `macos integration`, `macos seam`) either way.
20
41
 
21
42
  A pull request from a fork always runs on `macos-latest`, whatever the variable says: a self-hosted runner must never execute fork code. The repository is private and forking is disabled today, so this only guards a later change.
22
43
 
@@ -48,7 +69,7 @@ Installing and upgrading: get a registration token (`gh api -X POST repos/Northl
48
69
 
49
70
  ## No double run on `main`
50
71
 
51
- `publish.yml` starts with a `gate` job. It reads the pushed commit's **git tree** and looks for a workflow artifact named `tested-tree-<tree>`. `test.yml`'s final `tested` job creates that artifact only after `build`, `build-output` and every `lane` shard pass, on every pull request run and on every run of Publish's own `tests` job.
72
+ `publish.yml` starts with a `gate` job. It reads the pushed commit's **git tree** and looks for a workflow artifact named `tested-tree-<tree>`. `test.yml`'s final `tested` job creates that artifact only after `checks` and every `lane` shard pass, on every pull request run and on every run of Publish's own `tests` job.
52
73
 
53
74
  - Rebase-merging a PR whose tested merge ref has the same tree as the resulting `main` commit → artifact found → `tests` is skipped and `publish` proceeds.
54
75
  - A direct push to `main`, or a merge after `main` moved (a different tree from the one the PR run tested) → no artifact → the full suite runs exactly as before. A release commit (`chore: release …`, pushed by Publish itself) changes the tree, so a PR not updated since before the latest release runs the suite once on merge; update the branch to avoid it.
@@ -56,3 +77,28 @@ Installing and upgrading: get a registration token (`gh api -X POST repos/Northl
56
77
  - Branch protection is not enabled on `main` and the gate does not depend on it: the proof is the tree, not a required check. Artifacts are kept 30 days; older trees are simply re-tested.
57
78
 
58
79
  The OIDC constraint at the top of `publish.yml` is unchanged: every publishing job is still in that file.
80
+
81
+ ## What runs when (hosted-runner spend)
82
+
83
+ | Event | ubuntu `checks` (build, contract, build-output test) | ubuntu `linux` (unit, integration, seam in the container) | `macos` (laptop-only tests) |
84
+ | --- | --- | --- | --- |
85
+ | pull request push, no `full-ci` label, or draft | yes | no | no |
86
+ | pull request with the `full-ci` label (not a draft) | yes | yes | yes |
87
+ | merge queue run (`merge_group`; inert until a queue ruleset exists) | yes | yes | yes |
88
+ | `workflow_dispatch` (`gh workflow run test.yml --ref <branch>`) | yes | yes | yes |
89
+ | push to `main` (Publish's `tests` job) | yes | yes, unless `gate` found `tested-tree-<tree>` | same |
90
+
91
+ - Add the label when the change is ready for review: `gh pr edit <n> --add-label full-ci`. Remove it while iterating on a red suite. Labels other than `full-ci` start nothing and cancel nothing.
92
+ - The `full suite` job is the single result to require. It fails if `checks` failed, and on every event except a pull request it fails unless every `linux` and `macos` job passed, so Publish (which calls this file) can only release a tree whose full suite passed.
93
+ - `checks` builds once on ubuntu and uploads the JS output; the `linux` jobs unpack it and compile the Linux native addon in the container (the build is glibc, the image musl), and the `macos` jobs unpack it as is.
94
+
95
+ ## The selector: `scripts/ci-select.mjs`
96
+
97
+ On a pull request push the selector turns the diff against the merge base into per-lane file lists (`selection.json`, `linux-<lane>-<i>of<n>.txt`, `macos-<lane>.txt`, and the job outputs `mode`, `linux_matrix`, `macos_matrix`; its header lists the contract). Main, merge queue, dispatch, a `full-ci` label and any selector error give mode `full` without a verdict from the diff. "Any" includes a missing or malformed `timing.json`: a `full` selection never depends on it (it falls back to 3 shards per lane) and always writes its outputs.
98
+
99
+ - **Static map.** One esbuild metafile over every test file and the CLI entries, with the dynamic imports of five hub modules (`build-root`, `front-door`, `command`, `setup-marker`, `command-hooks/compose`) not followed; each test's closure is recomputed on every run, nothing is cached.
100
+ - **Spawner rule.** A unit or integration test that spawns the CLI, daemon or a broker (found by scanning its source) depends on the CLI closure, and the whole seam lane runs when a changed file is in that closure.
101
+ - **Path rules** are in `classify()`; anything not named there under `src/` or `packages/` that is not TypeScript, and any unrecognized path, forces `full`. A no-test rule never beats evidence that a test reads the path: a file in some test's or the CLI's import graph selects the tests that reach it even under `scripts/` (e.g. `scripts/proof/stub-provider/*.ts`, reached through the plugin test fixture); a script whose path or file name a test mentions forces `full`; and a `bin/**` shim selects the CLI closure and its spawners plus every test that names the shim (`spawn-env.test.ts` runs `./bin/runtime-generation-manifest.mjs`).
102
+ - **Shard sizing** uses `scripts/ci-select/timing.json`: per-file local work in seconds from one measured local run, times a per-lane factor that scales it to the Linux test-step totals, targeting 240 s per shard. A committed table, not a runtime measurement, because it must exist before the first selected run and cannot come from a run that is being skipped; a file missing from the table (a new test) costs its lane's median, which only skews shard balance. Selection never reads it.
103
+ - `node scripts/ci-select.mjs --replay scripts/ci-select/replay-prs.json` replays the 44 merged PRs #1–#65 through the selector as shipped, path rules included: this is the projection to quote. Over those PRs it runs a Linux lane on 34/44 (full suite: 44/44), at 26.0 billed ubuntu minutes per PR (full suite 39), 3.0 macOS-equivalent cost including the 4 minutes of `checks` (full suite 4.3), 564 s of laptop-only local work on the minis (759), 9.6 hosted-fallback macOS minutes (13); 8 PRs are docs-only and select no lane.
104
+ - `--static-only` is a **counterfactual**, not the selector: it drops every path rule, so a workflow, manifest, helper or unrecognized path selects nothing instead of `full`. It exists only to compare with the design's replay figures, which used the import-graph-plus-spawner model alone (30/44 PRs, 21.9 ubuntu minutes). Do not read its numbers as what the selector does.
@@ -341,7 +341,7 @@ if (outcome.kind === 'result') {
341
341
  }
342
342
  ```
343
343
 
344
- The runtime validates the value before it records it. A submission that does not match is refused with its field errors and never appears in `runs.get`, an event, or a report, so the agent fixes it and resubmits. `output_schema` accepts a plain JSON Schema object or anything with `toJSONSchema()`, such as a Zod v4 object. The SDK sends it as a JSON object and validates nothing itself. A schema that is not an object, or is over 256 KB serialized, is refused with `invalid_request` and `param: 'output_schema'`, and no run is created. A submitted result can be at most 1 MiB. The agent submits with its `submit_result` tool, which needs no scope, so a run that only reasons and answers can be started with `scopes: ['crtr:llm']`. The same value is in `runs.get(runId).result`, which `runs.wait` returns unchanged, and on the run's `node.settled` event.
344
+ The runtime validates the value before it records it. A submission that does not match is refused with its field errors and never appears in `runs.get`, an event, or a report, so the agent fixes it and resubmits. `output_schema` accepts a plain JSON Schema object or anything with `toJSONSchema()`, such as a Zod v4 object. The SDK sends it as a JSON object and validates nothing itself. A schema that is not an object, or is over 256 KB serialized, is refused with `invalid_request` and `param: 'output_schema'`, and no run is created. A submitted result can be at most 1 MiB. The value is validated exactly as submitted, with no coercion: `null` is never turned into `0` or `""`, and `"5"` is not turned into `5`, so a mismatch is refused and the agent resubmits. The schema is one JSON object, so a `$ref` can point only into itself (`#/$defs/x`, `#anchor`) or at another schema document bundled under `$defs` with its own `$id` (`"$defs": { "common": { "$id": "common.schema.json", ... } }` resolves `common.schema.json#/$defs/x`). Files are never fetched. A schema with a `$ref` that resolves nowhere inside it, or one that cannot be compiled (a bad `pattern`), is refused with `invalid_request` and `param: 'output_schema'` and no run is created. The agent submits with its `submit_result` tool, which needs no scope, so a run that only reasons and answers can be started with `scopes: ['crtr:llm']`. The same value is in `runs.get(runId).result`, which `runs.wait` returns unchanged, and on the run's `node.settled` event.
345
345
 
346
346
  Do not parse assistant text out of `runs.trace` to get data back. `runs.trace` is for showing a person the transcript. Its text is whatever the model said, not a value anything checked.
347
347
 
@@ -7,6 +7,12 @@ description: Behavior changes in @north-light/sdk that an app must act on, newes
7
7
 
8
8
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
9
9
 
10
+ ## 0.3.455
11
+
12
+ ### `provider.call` can target a device (additive, no app change)
13
+
14
+ `provider.call` takes an optional `machine` (a machine id; the `runtime:` prefix is optional) for a provider served by one of the account's devices, such as `crtr device`. Without it, one device is used; with several the call is refused `machine_required` and `details.candidates` lists them. Five error codes are new and are raised only on that path: `machine_required`, `device_offline`, `device_timeout`, `device_disconnected` and `invalid_argument` (`machine` sent to a provider that is not served by a device). A call that sends no `machine` to an ordinary provider sees no change.
15
+
10
16
  ## 0.3.452
11
17
 
12
18
  ### `oauth.client(userId)` no longer offers methods the runtime does not serve (type change)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.453",
3
+ "version": "0.3.455",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -99,7 +99,7 @@
99
99
  "test:runtime-packaging": "node --test scripts/runtime-smoke.test.mjs scripts/pack-args.test.mjs scripts/ensure-native-linux.test.mjs scripts/release-version.test.mjs",
100
100
  "contract:check": "node scripts/contract.mjs check",
101
101
  "contract:surface": "node scripts/contract.mjs generate",
102
- "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs",
102
+ "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs scripts/ci-image.test.mjs scripts/ci-miss-triage.test.mjs",
103
103
  "test:build-output": "node scripts/build-concurrency-test.mjs",
104
104
  "check:cli-no-canvas": "node scripts/check-cli-no-canvas.mjs",
105
105
  "check:cli-cold-start": "node scripts/check-cli-cold-start.mjs",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  "license": "UNLICENSED",
139
139
  "dependencies": {
140
- "@north-light/identity": "0.3.453",
140
+ "@north-light/identity": "0.3.455",
141
141
  "@earendil-works/pi-agent-core": "1.1.0",
142
142
  "@earendil-works/pi-ai": "1.1.0",
143
143
  "@earendil-works/pi-coding-agent": "1.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.453",
3
+ "version": "0.3.455",
4
4
  "description": "Northlight access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.453",
3
+ "version": "0.3.455",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.453",
9
+ "version": "0.3.455",
10
10
  "hasInstallScript": true,
11
11
  "license": "UNLICENSED",
12
12
  "workspaces": [
@@ -18,7 +18,7 @@
18
18
  "@earendil-works/pi-ai": "1.1.0",
19
19
  "@earendil-works/pi-coding-agent": "1.1.0",
20
20
  "@earendil-works/pi-tui": "1.1.0",
21
- "@north-light/identity": "0.3.453",
21
+ "@north-light/identity": "0.3.455",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -12095,17 +12095,17 @@
12095
12095
  },
12096
12096
  "packages/crouter-api": {
12097
12097
  "name": "@north-light/api",
12098
- "version": "0.3.453",
12098
+ "version": "0.3.455",
12099
12099
  "license": "UNLICENSED"
12100
12100
  },
12101
12101
  "packages/crouter-env-docker": {
12102
12102
  "name": "@north-light/env-docker",
12103
- "version": "0.3.453",
12103
+ "version": "0.3.455",
12104
12104
  "license": "UNLICENSED"
12105
12105
  },
12106
12106
  "packages/crouter-identity": {
12107
12107
  "name": "@north-light/identity",
12108
- "version": "0.3.453",
12108
+ "version": "0.3.455",
12109
12109
  "license": "UNLICENSED",
12110
12110
  "dependencies": {
12111
12111
  "jose": "^6.2.1"
@@ -12113,20 +12113,20 @@
12113
12113
  },
12114
12114
  "packages/crouter-plugin": {
12115
12115
  "name": "@north-light/plugin",
12116
- "version": "0.3.453",
12116
+ "version": "0.3.455",
12117
12117
  "license": "UNLICENSED",
12118
12118
  "dependencies": {
12119
- "@north-light/api": "0.3.453",
12120
- "@north-light/identity": "0.3.453"
12119
+ "@north-light/api": "0.3.455",
12120
+ "@north-light/identity": "0.3.455"
12121
12121
  }
12122
12122
  },
12123
12123
  "packages/crouter-sdk": {
12124
12124
  "name": "@north-light/sdk",
12125
- "version": "0.3.453",
12125
+ "version": "0.3.455",
12126
12126
  "license": "UNLICENSED",
12127
12127
  "dependencies": {
12128
- "@north-light/api": "0.3.453",
12129
- "@north-light/identity": "0.3.453",
12128
+ "@north-light/api": "0.3.455",
12129
+ "@north-light/identity": "0.3.455",
12130
12130
  "jose": "^6.2.1"
12131
12131
  },
12132
12132
  "bin": {