@north-light/cli 0.3.454 → 0.3.456

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 (48) 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/release-notes.md +6 -0
  6. package/dist/clients/attach/viewer.js +526 -526
  7. package/dist/clients/inbox/controller.js +1 -1
  8. package/dist/clients/inbox/tui/page-body.js +1 -1
  9. package/dist/clients/inbox/tui/render.js +2 -2
  10. package/dist/commands/pkg/browse/catalog.js +1 -1
  11. package/dist/commands/pkg/plugin-manage.js +1 -1
  12. package/dist/core/command-manifests/registry.d.ts +2 -1
  13. package/dist/core/command-plugins/bundle.d.ts +3 -10
  14. package/dist/core/command-plugins/bundle.js +1 -1
  15. package/dist/core/command-plugins/compose.js +1 -1
  16. package/dist/core/command-plugins/discovery.js +1 -1
  17. package/dist/core/command-plugins/endpoint.d.ts +12 -0
  18. package/dist/core/command-plugins/endpoint.js +1 -1
  19. package/dist/core/command.d.ts +13 -0
  20. package/dist/core/command.js +9 -9
  21. package/dist/core/peer/client.d.ts +4 -2
  22. package/dist/core/peer/client.js +1 -1
  23. package/dist/core/peer/directory.d.ts +7 -0
  24. package/dist/core/peer/directory.js +1 -1
  25. package/dist/core/peer/envelope.d.ts +4 -0
  26. package/dist/core/peer/envelope.js +1 -1
  27. package/dist/core/peer/home.js +1 -1
  28. package/dist/core/providers/bundles.d.ts +2 -0
  29. package/dist/core/providers/bundles.js +2 -2
  30. package/dist/core/providers/call.d.ts +2 -0
  31. package/dist/core/providers/call.js +1 -1
  32. package/dist/core/providers/device.d.ts +39 -0
  33. package/dist/core/providers/device.js +1 -0
  34. package/dist/core/providers/errors.js +1 -1
  35. package/dist/daemon/api/handlers/objects.js +2 -2
  36. package/dist/daemon/api/handlers/providers.d.ts +2 -0
  37. package/dist/daemon/api/handlers/providers.js +1 -1
  38. package/dist/daemon/peer/outbox.js +1 -1
  39. package/dist/daemon/peer/relay-client.js +1 -1
  40. package/dist/daemon/peer/relay-protocol.d.ts +1 -1
  41. package/dist/daemon/peer/relay-protocol.js +1 -1
  42. package/dist/types.d.ts +4 -0
  43. package/docs/ci-runner.md +65 -5
  44. package/docs/sdk/release-notes.md +6 -0
  45. package/package.json +3 -3
  46. package/packages/crouter-identity/package.json +1 -1
  47. package/runtime.lock.json +12 -12
  48. package/scripts/lib/isolation-env.mjs +15 -0
@@ -0,0 +1 @@
1
+ var _=Object.defineProperty;var a=(e,i)=>_(e,"name",{value:i,configurable:!0});import{directory as c,normalizeMachineId as h}from"../peer/directory.js";import{peerCall as m}from"../peer/client.js";import{ProviderError as t}from"./errors.js";const l={current:{list:a(()=>c.list(),"list"),lastList:a(()=>c.lastList(),"lastList"),call:a((e,i,n)=>m(e,i,n),"call")}},p="/v1/device/call",D=6e4,f=a(e=>({machine_id:e.machine_id,name:e.name,last_seen:e.last_seen}),"candidateView");function s(e,i){const n=e.map(r=>`${r.name} (${r.machine_id})`).join(", ");return new t("machine_required",i===void 0?`This account has ${e.length} devices: ${n}. Pick one with --machine <machine_id>.`:`${i} is not one of this account's devices. Devices: ${n}. Pick one with --machine <machine_id>.`,{param:"machine",details:{candidates:e.map(f)}})}a(s,"machineRequired");async function x(e,i=l.current){let n;try{n=await i.list()}catch(o){const d=i.lastList();if(d===null)throw new t("provider_unavailable",`The directory could not be reached to find your devices: ${o instanceof Error?o.message:String(o)}`,{origin:"directory"});n=d}const r=n.filter(o=>o.kind==="device");if(r.length===0)throw new t("provider_not_connected","No device is connected to this account. Install Northlight Desktop on your Mac and sign in.",{origin:"directory"});if(e!==void 0){const o=h(e),d=r.find(u=>u.machine_id===o);if(!d)throw s(r,e);return d}if(r.length>1)throw s(r,void 0);return r[0]}a(x,"selectDevice");function v(e){return{tool:e.tool,arguments:e.arguments,version:e.version,request_id:e.requestId,...e.runId===null?{}:{run_id:e.runId},deadline_at:e.deadlineAt,...e.upload===null?{}:{upload:e.upload}}}a(v,"deviceBody");async function A(e,i,n,r,o=l.current){const d=Math.max(1,i.deadlineAt-Date.now());return o.call(e.machine_id,{method:"POST",path:p,body:v(i)},{envelope:n,device:!0,signal:AbortSignal.any([r,AbortSignal.timeout(d)])})}a(A,"sendDeviceCall");function E(e,i,n){switch(i){case"timeout":return new t("device_timeout",`${e.name} did not answer in time.`,{origin:"runtime",details:{machine_id:e.machine_id}});case"directory_unreachable":case"no_directory":return new t("provider_unavailable",n,{origin:"directory"});case"unknown_machine":return new t("provider_not_connected",`${e.name} is no longer on this account.`,{origin:"directory"});case"not_a_crtr_peer":return new t("server_error",n);default:return new t("device_offline",`${e.name} is offline. ${n}`.trim(),{origin:"runtime",details:{machine_id:e.machine_id}})}}a(E,"deviceUnreachable");function b(e,i,n,r,o){if(i===401)return new t("device_offline",`${e.name}: Desktop is signed out. Sign in to Northlight Desktop again.`,{origin:"runtime",details:{machine_id:e.machine_id}});if(i===426){const d=o?.error?.details?.supported;return new t("peer_version_too_old",r??`${e.name} does not serve this device major. Update Northlight Desktop.`,{origin:"runtime",details:{machine_id:e.machine_id,...Array.isArray(d)?{supported:d}:{}}})}return i===502&&n==="machine_disconnected"?new t("device_disconnected",`${e.name} disconnected during the call; the action may have run.`,{origin:"runtime",details:{machine_id:e.machine_id}}):n==="payload_too_large"||n==="response_too_large"?new t("provider_error",r??"The call or its answer was too large for the relay (16 MiB).",{origin:"runtime",status:413,details:{code:n,message:r??null,status:i}}):null}a(b,"deviceFailure");export{D as DEFAULT_DEVICE_REQUEST_MS,p as DEVICE_CALL_PATH,v as deviceBody,l as deviceDeps,b as deviceFailure,E as deviceUnreachable,x as selectDevice,A as sendDeviceCall};
@@ -1 +1 @@
1
- var m=Object.defineProperty;var d=(r,i)=>m(r,"name",{value:i,configurable:!0});import{RunError as _}from"../runs/operations.js";import{errorCodes as t}from"../../api/error-codes.js";const g={provider_error:{type:"invalid_request_error",status:400,retryable:!1},provider_rate_limited:t.provider_rate_limited,provider_unavailable:t.provider_unavailable,provider_refused:t.provider_refused,provider_not_connected:t.provider_not_connected,provider_version_retired:t.provider_version_retired,storage_unavailable:t.storage_unavailable,approval_pending:{...t.approval_pending,user_action:"answer_question"},user_denied:t.user_denied},b={unauthorized:t.unauthorized,grant_revoked:t.grant_revoked,machine_revoked:t.machine_revoked,scope_missing:t.scope_missing,permission_error:t.permission_error,invalid_request:t.invalid_request,not_found:t.not_found,not_implemented:t.not_implemented,runtime_starting:t.runtime_starting,server_error:t.server_error,provider_bad_response:t.provider_bad_response};function a(r,i){return g[r]??b[r]??(Object.prototype.hasOwnProperty.call(t,r)?t[r]:void 0)??{type:i===401?"authentication_error":i===403?"permission_error":i===429?"rate_limit_error":i>=500?"server_error":"invalid_request_error",status:i,retryable:i>=500}}d(a,"codeRow");class u extends _{static{d(this,"ProviderError")}origin;type;retry_after_s;question_id;user_action;run_id;constructor(i,s,n={}){const e=a(i,n.status??500);super(n.status??e.status,i,s,n.param,n.details,n.scope,e.retryable),this.type=e.type,this.origin=n.origin??"runtime",n.retry_after_s!==void 0&&(this.retry_after_s=n.retry_after_s),n.question_id!==void 0&&(this.question_id=n.question_id),n.run_id!==void 0&&(this.run_id=n.run_id),e.user_action!==void 0&&(this.user_action=e.user_action)}}function O(r){return new u("scope_missing",`missing scope ${r}`,{scope:r})}d(O,"scopeMissing");function q(r){if(!(r instanceof Error))return null;const{status:i,code:s,details:n,retryable:e}=r;return typeof i!="number"||typeof s!="string"||i<400||i>599?null:new _(i,s,r.message,void 0,n,void 0,typeof e=="boolean"?e:a(s,i).retryable)}d(q,"shaped");function h(r){if(r.code==="unauthorized"||r.code==="grant_revoked")return"reconnect";if(r.code==="scope_missing"&&r.scope)return`grant_scope:${r.scope}`}d(h,"directoryAction");function k(r,i,s,n){const e=r instanceof _?r:q(r)??new u("server_error","provider request failed"),o=e instanceof u?e:null,v=a(e.code,e.status),c=o?.run_id??n,p=o?.user_action??(e.code==="cap_exhausted"?"raise_cap":s?h(e):void 0),f=e.code==="cap_exhausted"?e.details?.reset_at:void 0,y={code:e.code,message:e.message,...e.details===void 0?{}:{details:e.details},type:o?.type??v.type,...e.param?{param:e.param}:{},origin:o?.origin??"runtime",retryable:e.retryable,...o?.retry_after_s===void 0?{}:{retry_after_s:o.retry_after_s},...typeof f=="string"?{reset_at:f}:{},request_id:i,...c?{run_id:c}:{},...o?.question_id?{question_id:o.question_id}:{},...p?{user_action:p}:{},...e.scope?{scope:e.scope}:{}},l={"Request-Id":i};return o?.retry_after_s!==void 0&&(l["Retry-After"]=String(o.retry_after_s)),{status:e.status,body:{error:y},headers:l}}d(k,"providerErrorBody");export{g as PROVIDER_CODES,u as ProviderError,a as codeRow,k as providerErrorBody,O as scopeMissing};
1
+ var y=Object.defineProperty;var d=(i,t)=>y(i,"name",{value:t,configurable:!0});import{RunError as _}from"../runs/operations.js";import{errorCodes as r}from"../../api/error-codes.js";const g={provider_error:{type:"invalid_request_error",status:400,retryable:!1},provider_rate_limited:r.provider_rate_limited,provider_unavailable:r.provider_unavailable,provider_refused:r.provider_refused,provider_not_connected:r.provider_not_connected,provider_version_retired:r.provider_version_retired,storage_unavailable:r.storage_unavailable,approval_pending:{...r.approval_pending,user_action:"answer_question"},user_denied:r.user_denied},b={unauthorized:r.unauthorized,grant_revoked:r.grant_revoked,machine_revoked:r.machine_revoked,scope_missing:r.scope_missing,permission_error:r.permission_error,invalid_request:r.invalid_request,not_found:r.not_found,not_implemented:r.not_implemented,runtime_starting:r.runtime_starting,server_error:r.server_error,provider_bad_response:r.provider_bad_response,invalid_argument:r.invalid_argument,machine_required:r.machine_required,device_offline:r.device_offline,device_timeout:r.device_timeout,device_disconnected:r.device_disconnected,peer_version_too_old:r.peer_version_too_old};function a(i,t){return g[i]??b[i]??(Object.prototype.hasOwnProperty.call(r,i)?r[i]:void 0)??{type:t===401?"authentication_error":t===403?"permission_error":t===429?"rate_limit_error":t>=500?"server_error":"invalid_request_error",status:t,retryable:t>=500}}d(a,"codeRow");class u extends _{static{d(this,"ProviderError")}origin;type;retry_after_s;question_id;user_action;run_id;constructor(t,s,n={}){const e=a(t,n.status??500);super(n.status??e.status,t,s,n.param,n.details,n.scope,e.retryable),this.type=e.type,this.origin=n.origin??"runtime",n.retry_after_s!==void 0&&(this.retry_after_s=n.retry_after_s),n.question_id!==void 0&&(this.question_id=n.question_id),n.run_id!==void 0&&(this.run_id=n.run_id),e.user_action!==void 0&&(this.user_action=e.user_action)}}function O(i){return new u("scope_missing",`missing scope ${i}`,{scope:i})}d(O,"scopeMissing");function q(i){if(!(i instanceof Error))return null;const{status:t,code:s,details:n,retryable:e}=i;return typeof t!="number"||typeof s!="string"||t<400||t>599?null:new _(t,s,i.message,void 0,n,void 0,typeof e=="boolean"?e:a(s,t).retryable)}d(q,"shaped");function h(i){if(i.code==="unauthorized"||i.code==="grant_revoked")return"reconnect";if(i.code==="scope_missing"&&i.scope)return`grant_scope:${i.scope}`}d(h,"directoryAction");function k(i,t,s,n){const e=i instanceof _?i:q(i)??new u("server_error","provider request failed"),o=e instanceof u?e:null,v=a(e.code,e.status),c=o?.run_id??n,p=o?.user_action??(e.code==="cap_exhausted"?"raise_cap":s?h(e):void 0),f=e.code==="cap_exhausted"?e.details?.reset_at:void 0,m={code:e.code,message:e.message,...e.details===void 0?{}:{details:e.details},type:o?.type??v.type,...e.param?{param:e.param}:{},origin:o?.origin??"runtime",retryable:e.retryable,...o?.retry_after_s===void 0?{}:{retry_after_s:o.retry_after_s},...typeof f=="string"?{reset_at:f}:{},request_id:t,...c?{run_id:c}:{},...o?.question_id?{question_id:o.question_id}:{},...p?{user_action:p}:{},...e.scope?{scope:e.scope}:{}},l={"Request-Id":t};return o?.retry_after_s!==void 0&&(l["Retry-After"]=String(o.retry_after_s)),{status:e.status,body:{error:m},headers:l}}d(k,"providerErrorBody");export{g as PROVIDER_CODES,u as ProviderError,a as codeRow,k as providerErrorBody,O as scopeMissing};
@@ -1,2 +1,2 @@
1
- var we=Object.defineProperty;var u=(e,t)=>we(e,"name",{value:t,configurable:!0});import{prepared as ye}from"../../../core/sqlite-statements.js";import{ApiError as w}from"../../../api/errors.js";import{PRIVACY_LEVELS_DTO as be}from"../../../api/dto/objects.js";import{openDb as T,withCanvasWrite as y}from"../../../core/canvas/db.js";import{actorOf as V}from"../../../core/graph/documents.js";import{readerForObject as A}from"../../../core/graph/reader.js";import{canRead as ge}from"../../../core/graph/access.js";import{customObjectRef as H,authorizeCustom as B,fenceArgv as Z}from"./custom-objects.js";import{edgesOf as je}from"../../../core/graph/edges.js";import{publish as ke}from"../../../core/graph/events.js";import{objectDTO as b}from"../../../core/graph/kinds/dto.js";import{isKind as _e,kindNames as ve,kindOf as J}from"../../../core/graph/kinds/registry.js";import{listObjects as Ee,searchObjects as K}from"../../../core/graph/objects.js";import{checkoutsForCwd as Oe}from"../../../core/graph/repo-sync/repos.js";import{recordWatch as Q,unwatch as Y,watchersOf as qe,watchesOf as Re}from"../../../core/graph/watches.js";import{readerFor as g,resolveRef as L}from"../reader.js";import{invalidRequest as $,isRecord as k}from"./validate.js";import{callerNode as X,callerStub as N,forwardVerb as x,originates as ee,queryWithoutMachine as P,relayError as C,remoteStub as Te,unknownError as te,forwarding as S,withMachine as F}from"../../peer/forward.js";import{getById as ne}from"../../../core/graph/objects.js";import{isStub as re}from"../../../core/peer/stubs.js";import{isUnreachable as $e}from"../../../core/peer/client.js";import{normalizeMachineId as I}from"../../../core/peer/directory.js";const M=u(e=>t=>Oe(e,t),"reposFnFor");function oe(e,t){return{...b(e,t),...J(e,t.type).listItem?.(e,t)}}u(oe,"listItem");function Se(e){return`no object named ${e} on this runtime; it may be on another runtime, or the name may be misspelled`}u(Se,"remoteMessage");function ie(e,t,n){try{return R(e,t,n)}catch(r){if(r instanceof w&&r.code==="not_found"&&!r.details?.deleted)return null;throw r}}u(ie,"ownerFilter");function R(e,t,n){const r=L(e,t,n);if("remote"in r)throw new w(404,"not_found",Se(r.remote),{next:"Check the name with `crtr canvas search`."});if("deleted"in r)throw new w(404,"not_found",`${r.deleted.name} is deleted`,{id:r.deleted.id,deleted:!0});return r}u(R,"liveRef");const _=u(e=>decodeURIComponent(e.params.ref),"refParam"),se=u(e=>{const t=e.trim().replace(/^\[\[(.*)\]\]$/,"$1").trim(),n=t.indexOf("/");return n>0?t.slice(0,n):t},"idOf");function ae(e){const t=ne(T(),se(e));return t!==null&&re(T(),t)?t.id:null}u(ae,"stubIdOf");async function W(e,t,n,r){const o=ae(t);return o!==null?x(e,o,n):r()?{kind:"local"}:ee(e.principal)?x(e,se(t),n):{kind:"local"}}u(W,"forwardRef");function D(e,t){return y(n=>{try{return!("remote"in L(n,g(n,t),e))}catch{return!1}})}u(D,"resolvesLocally");const G=u(e=>`/v1/objects/${encodeURIComponent(e)}`,"objectPath");function ue(e,t,n,r,o){const i=t?.object;!i?.id||!i.type||y(s=>{const a=X(s,e);if(a===null)return;const c=Te(s,i.id,i.type,n,{kind:i.kind??null,name:i.name??i.id,...i.preview===void 0?{}:{preview:i.preview},owner:i.owner??null});Q(s,a,c,r,o===void 0?{}:{patterns:o})})}u(ue,"watchRemote");async function Ie(e){const t=_(e),n=["1","true"].includes(e.query.get("fields")??""),r=await W(e,t,{method:"GET",path:G(t),query:P(e)},()=>D(t,e));if(r.kind==="unknown")return{state:"unknown",ref:t,unreachable:r.unreachable};if(r.kind==="remote"){if(r.status>=400)throw C(r.status,r.body);return ue(e,r.body,r.machine.id,"read"),F(r.body,r.machine)}return y(o=>{const i=N(o,e),s=i===null?g(o,e):A(o,i,M(o)),a=L(o,s,t);if("remote"in a)return{state:"remote",remote:a.remote};if("deleted"in a)return{state:"deleted",deleted:b(o,a.deleted)};const{data:c,text:l}=J(o,a.type).read(o,s,a,{fields:n,reposFn:M(o)});return{state:"object",type:a.type,object:b(o,a),text:l,data:c}})}u(Ie,"read");function z(e,t,n,r){if(e!=null){if(!n.includes(e)){r.push({field:t,problem:`must be one of ${n.join(", ")}`,received:e});return}return e}}u(z,"oneOf");function ce(e,t,n){if(t!=null){if(typeof t!="string"||!_e(e,t)){n.push({field:"type",problem:`must be a kind: one of ${ve(e).join(", ")}`,received:t});return}return t}}u(ce,"kindName");function le(e,t,n,r){if(e==null)return r;const o=Number(e);return!Number.isInteger(o)||o<1||o>100?(n.push({field:t,problem:"must be an integer from 1 to 100",received:e}),r):o}u(le,"limitOf");function fe(e){const t=e.query,n=[],r=ce(T(),t.get("type"),n),o=z(t.get("privacy"),"privacy",be,n),i=le(t.get("limit"),"limit",n,50),s=t.get("cursor");s!==null&&!/^\d+$/.test(s)&&n.push({field:"cursor",problem:"is not a cursor from a previous page",received:s});const a=t.get("deleted");return a!==null&&!["0","1","true","false"].includes(a)&&n.push({field:"deleted",problem:"must be true or false",received:a}),n.length>0&&$("object list request",n),y(c=>{const l=g(c,e),h={...r?{type:r}:{},...o?{privacy:o}:{},...t.get("kind")!==null?{kind:t.get("kind")}:{},...a==="1"||a==="true"?{deleted:!0}:{}},p=t.get("owner");if(p!==null){const m=ie(c,l,p);if(m===null)return{items:[],next_cursor:null};h.owner=m}const v=t.get("name"),j=[];let d=s===null?0:Number(s),E=!0;for(;j.length<i&&E;){const m=Ee(c,l,h,{limit:100,after_rowid:d});E=m.length===100;for(const q of m)if(d=q.rowid,(v===null||q.name.startsWith(v))&&j.push(q),j.length===i)break}const f=j.at(-1);return{items:j.map(m=>oe(c,m)),next_cursor:j.length===i&&f?String(f.rowid):null}})}u(fe,"listLocal");const Ae={ms:3e4},Le="self",U=u((e,t)=>e.map(n=>({...n,machine:t})),"labelItems");async function de(e,t,n,r){const o=S.current;try{const i=await o.call(t.id,{method:"GET",path:"/v1/objects",query:n},o.envelope(e.principal),r);if($e(i))return{state:"unreachable",items:[],next:null};if(i.status===426)return{state:"too_old",items:[],next:null};const s=i.body;return i.status!==200||!Array.isArray(s?.items)?{state:"unreachable",items:[],next:null}:{state:"ok",items:U(s.items,t),next:s.next_cursor??null}}catch{return{state:"unreachable",items:[],next:null}}}u(de,"remotePage");async function Ne(e){const t=new Promise(n=>e.addEventListener("abort",()=>n(null),{once:!0}));return Promise.race([S.current.machines().catch(()=>null),t])}u(Ne,"machinesWithin");async function Pe(e){const t=e.query.get("machine");if(!ee(e.principal)||!S.current.joined()||t===Le)return fe(me(e));const r=S.current.self(),o=t!==null&&I(t)!==r,i=o?null:fe(me(e)),s=new AbortController,a=setTimeout(()=>s.abort(),Ae.ms);a.unref?.();try{const c=await Ne(s.signal);if(c===null){if(o)throw new w(503,"runtime_unreachable",`The directory did not answer, so ${I(t)} cannot be listed.`,{machine:I(t)});return i}const l=u(f=>({id:f,name:c.find(m=>m.machine_id===f)?.name??f}),"labelOf"),h=l(r),p=c.filter(f=>f.machine_id!==r),v=P(e);if(t!==null){if(!o)return{items:U(i.items,h),next_cursor:i.next_cursor,machines:[{machine_id:r,name:h.name,state:"ok",more:i.next_cursor!==null}]};const f=I(t);if(!p.some(q=>q.machine_id===f))throw new w(404,"not_found",`no machine ${f} on your account`,{machine:f});const m=await de(e,l(f),v,s.signal);return{items:m.items,next_cursor:m.next,machines:[{machine_id:f,name:l(f).name,state:m.state,more:m.next!==null}]}}const{cursor:j,...d}=v,E=await Promise.all(p.map(f=>de(e,l(f.machine_id),d,s.signal)));return{items:[...U(i.items,h),...E.flatMap(f=>f.items)],next_cursor:i.next_cursor,machines:[{machine_id:r,name:h.name,state:"ok",more:i.next_cursor!==null},...p.map((f,m)=>({machine_id:f.machine_id,name:f.name,state:E[m].state,more:E[m].next!==null}))]}}finally{clearTimeout(a)}}u(Pe,"list");function me(e){const t=new URLSearchParams(e.query);return t.delete("machine"),{...e,query:t}}u(me,"withoutMachine");function Ce(e){const t=[],n=k(e.body)?e.body:{};k(e.body)||t.push({field:"body",problem:"must be an object",received:e.body});const r=n.text;(typeof r!="string"||r.trim()==="")&&t.push({field:"text",problem:"is required",received:r});const o=n.grep;if(o!==void 0)if(typeof o!="string")t.push({field:"grep",problem:"must be a regular expression string",received:o});else try{new RegExp(o)}catch(a){t.push({field:"grep",problem:`is not a valid regular expression: ${a.message}`,received:o})}n.body!==void 0&&typeof n.body!="boolean"&&t.push({field:"body",problem:"must be a boolean",received:n.body});const i=ce(T(),n.type,t);n.kind!==void 0&&typeof n.kind!="string"&&t.push({field:"kind",problem:"must be a string",received:n.kind}),n.owner!==void 0&&typeof n.owner!="string"&&t.push({field:"owner",problem:"must be a string",received:n.owner});const s=le(n.limit,"limit",t,50);return t.length>0&&$("object search request",t),y(a=>{const c=g(a,e),l=typeof n.owner=="string"?ie(a,c,n.owner):null;if(typeof n.owner=="string"&&l===null)return{items:[]};const h=K(a,c,{text:r,body:n.body===!0||typeof o=="string"}),p=new Set(h.map(d=>d.object.rowid));return{items:(typeof o=="string"?K(a,c,{text:o,grep:!0}).filter(d=>p.has(d.object.rowid)):h).filter(d=>(!i||d.object.type===i)&&(n.kind===void 0||d.object.kind===n.kind)&&(!l||d.object.owner===l.rowid)).slice(0,s).map(d=>({object:oe(a,d.object),...d.line!==void 0?{matches:[{line:d.line,text:d.text??""}]}:{}}))}})}u(Ce,"search");const he=["interrupt","steer","after-turn","next-turn"],Fe=/^(\*|>|[A-Za-z0-9_-]+)(\.(\*|>|[A-Za-z0-9_-]+))*$/;function Me(e,t){if(e!==void 0){if(!Array.isArray(e)||e.length===0||e.some(n=>typeof n!="string"||!Fe.test(n)||n.includes(">")&&!n.endsWith(">"))){t.push({field:"patterns",problem:"must be a non-empty list of event patterns (dot-separated words, `*` for one word, a final `>` for the rest)",received:e});return}return e}}u(Me,"patternsOf");function We(e,t){if(e===void 0)return;if(!k(e)||Object.keys(e).length===0){t.push({field:"config",problem:"must map levels 1-10 to a delivery",received:e});return}const n=[];for(const[r,o]of Object.entries(e)){const i=Number(r);!Number.isInteger(i)||i<1||i>10?t.push({field:`config.${r}`,problem:"level must be an integer from 1 to 10"}):he.includes(o)?n.push({threshold:i,delivery:o}):t.push({field:`config.${r}`,problem:`delivery must be one of ${he.join(", ")}`,received:o})}return n.sort((r,o)=>o.threshold-r.threshold)}u(We,"configOf");function pe(e){const t=ae(e);if(t!==null)throw new w(400,"invalid_request",`${t} lives on another machine; an explicit watcher cannot watch it from here`,{id:t})}u(pe,"refuseStubWatch");async function De(e){const t=_(e);if(typeof(k(e.body)?e.body:{}).watcher=="string"&&pe(t),typeof(k(e.body)?e.body:{}).watcher!="string"){const n=await W(e,t,{method:"POST",path:`${G(t)}/watch`,body:e.body},()=>D(t,e));if(n.kind==="unknown")throw te(t,n.unreachable);if(n.kind==="remote"){if(n.status>=400)throw C(n.status,n.body);const r=n.body.patterns;return ue(e,n.body,n.machine.id,"ask",r),F(n.body,n.machine)}}return Ge(e)}u(De,"watch");function Ge(e){const t=[],n=e.body===void 0?{}:k(e.body)?e.body:(t.push({field:"body",problem:"must be an object"}),{}),r=Me(n.patterns,t),o=We(n.config,t);return n.filter!==void 0&&!k(n.filter)&&t.push({field:"filter",problem:"must be an object",received:n.filter}),n.watcher!==void 0&&typeof n.watcher!="string"&&t.push({field:"watcher",problem:"must be a custom-object ref",received:n.watcher}),t.length>0&&$("watch request",t),y(i=>{const s=typeof n.watcher=="string"?H(i,n.watcher):null;if(e.publishToken&&!s)throw new w(404,"not_found","watcher not found");if(e.principal.class==="anonymous"&&!s)throw new w(404,"not_found","watcher not found");const a=s?B(i,e,s):N(i,e)??V(g(i,e));s&&Z(i,e,s);const c=s?A(i,s):g(i,e),l=s?(()=>{const p=R(i,c,_(e));if(!ge(i,c,p))throw new w(404,"not_found","object not found");return p})():R(i,c,_(e)),h=r??[">"];return Q(i,s??a,l,s?"act":"ask",{patterns:h,...o?{config:o}:{},...n.filter!==void 0?{filter:n.filter}:{}}),ke(i,{event:"watched",object:l,actor:a,attrs:{}},M(i)),{object:b(i,l),patterns:h}})}u(Ge,"watchLocal");async function ze(e){const t=_(e);if(e.query.get("watcher")&&pe(t),!e.query.get("watcher")){const n=await W(e,t,{method:"DELETE",path:`${G(t)}/watch`,query:P(e)},()=>D(t,e));if(n.kind==="unknown")throw te(t,n.unreachable);if(n.kind==="remote"){if(n.status>=400)throw C(n.status,n.body);const r=n.body.object?.id;return y(o=>{const i=X(o,e),s=r===void 0?null:ne(o,r);i!==null&&s!==null&&re(o,s)&&Y(o,i,s,e.query.get("pattern")??void 0)}),F(n.body,n.machine)}}return Ue(e)}u(ze,"unwatchRoute");function Ue(e){const t=e.query.get("pattern"),n=e.query.get("watcher");return y(r=>{const o=n?H(r,n):null;if((e.publishToken||e.principal.class==="anonymous")&&!o)throw new w(404,"not_found","watcher not found");const i=o?B(r,e,o):N(r,e)??V(g(r,e));o&&Z(r,e,o);const s=o?A(r,o):g(r,e),a=R(r,s,_(e)),c=u(()=>ye(r,`SELECT count(*) AS n FROM watch_subscriptions s JOIN watches w ON w.rowid = s.watch
2
- WHERE w.watcher = ? AND w.object = ?`).get((o??i).rowid,a.rowid).n,"count"),l=c();return Y(r,o??i,a,t??void 0),{object:b(r,a),removed:l-c()}})}u(Ue,"unwatchLocal");function Ve(e){const t=[],n=z(e.query.get("type"),"type",["acted-on","links-to","watches"],t),r=z(e.query.get("direction"),"direction",["out","in"],t);return t.length>0&&$("edges request",t),y(o=>{const i=g(o,e),s=R(o,i,_(e)),a=[];if(n!=="watches")for(const c of je(o,i,s,{...n?{type:n}:{},direction:r??"both"}))a.push({type:c.type,src:b(o,c.src),dst:b(o,c.dst),...c.written_as?{written_as:c.written_as}:{}});if(n===void 0||n==="watches"){const c=[...r!=="in"?Re(o,i,s):[],...r!=="out"?qe(o,i,s):[]];for(const l of c)a.push({type:"watches",src:b(o,l.watcher),dst:b(o,l.object)})}return{items:a}})}u(Ve,"edges");const O=u(e=>async t=>({status:200,body:await e(t)}),"ok"),ht=[{method:"POST",pattern:"/v1/objects/search",handler:O(Ce)},{method:"GET",pattern:"/v1/objects/:ref/edges",handler:O(Ve)},{method:"POST",pattern:"/v1/objects/:ref/watch",handler:O(De)},{method:"DELETE",pattern:"/v1/objects/:ref/watch",handler:O(ze)},{method:"GET",pattern:"/v1/objects/:ref",handler:O(Ie)},{method:"GET",pattern:"/v1/objects",handler:O(Pe)}];export{Ae as listBudget,ht as objectRoutes};
1
+ var we=Object.defineProperty;var u=(e,t)=>we(e,"name",{value:t,configurable:!0});import{prepared as ye}from"../../../core/sqlite-statements.js";import{ApiError as w}from"../../../api/errors.js";import{PRIVACY_LEVELS_DTO as be}from"../../../api/dto/objects.js";import{openDb as T,withCanvasWrite as y}from"../../../core/canvas/db.js";import{actorOf as V}from"../../../core/graph/documents.js";import{readerForObject as A}from"../../../core/graph/reader.js";import{canRead as ge}from"../../../core/graph/access.js";import{customObjectRef as H,authorizeCustom as B,fenceArgv as Z}from"./custom-objects.js";import{edgesOf as je}from"../../../core/graph/edges.js";import{publish as ke}from"../../../core/graph/events.js";import{objectDTO as b}from"../../../core/graph/kinds/dto.js";import{isKind as _e,kindNames as ve,kindOf as J}from"../../../core/graph/kinds/registry.js";import{listObjects as Ee,searchObjects as K}from"../../../core/graph/objects.js";import{checkoutsForCwd as Oe}from"../../../core/graph/repo-sync/repos.js";import{recordWatch as Q,unwatch as Y,watchersOf as qe,watchesOf as Re}from"../../../core/graph/watches.js";import{readerFor as g,resolveRef as L}from"../reader.js";import{invalidRequest as $,isRecord as k}from"./validate.js";import{callerNode as X,callerStub as N,forwardVerb as x,originates as ee,queryWithoutMachine as P,relayError as C,remoteStub as Te,unknownError as te,forwarding as S,withMachine as F}from"../../peer/forward.js";import{getById as ne}from"../../../core/graph/objects.js";import{isStub as re}from"../../../core/peer/stubs.js";import{isUnreachable as $e}from"../../../core/peer/client.js";import{normalizeMachineId as I}from"../../../core/peer/directory.js";const M=u(e=>t=>Oe(e,t),"reposFnFor");function oe(e,t){return{...b(e,t),...J(e,t.type).listItem?.(e,t)}}u(oe,"listItem");function Se(e){return`no object named ${e} on this runtime; it may be on another runtime, or the name may be misspelled`}u(Se,"remoteMessage");function ie(e,t,n){try{return R(e,t,n)}catch(r){if(r instanceof w&&r.code==="not_found"&&!r.details?.deleted)return null;throw r}}u(ie,"ownerFilter");function R(e,t,n){const r=L(e,t,n);if("remote"in r)throw new w(404,"not_found",Se(r.remote),{next:"Check the name with `crtr canvas search`."});if("deleted"in r)throw new w(404,"not_found",`${r.deleted.name} is deleted`,{id:r.deleted.id,deleted:!0});return r}u(R,"liveRef");const _=u(e=>decodeURIComponent(e.params.ref),"refParam"),se=u(e=>{const t=e.trim().replace(/^\[\[(.*)\]\]$/,"$1").trim(),n=t.indexOf("/");return n>0?t.slice(0,n):t},"idOf");function ae(e){const t=ne(T(),se(e));return t!==null&&re(T(),t)?t.id:null}u(ae,"stubIdOf");async function W(e,t,n,r){const o=ae(t);return o!==null?x(e,o,n):r()?{kind:"local"}:ee(e.principal)?x(e,se(t),n):{kind:"local"}}u(W,"forwardRef");function D(e,t){return y(n=>{try{return!("remote"in L(n,g(n,t),e))}catch{return!1}})}u(D,"resolvesLocally");const G=u(e=>`/v1/objects/${encodeURIComponent(e)}`,"objectPath");function ue(e,t,n,r,o){const i=t?.object;!i?.id||!i.type||y(s=>{const a=X(s,e);if(a===null)return;const c=Te(s,i.id,i.type,n,{kind:i.kind??null,name:i.name??i.id,...i.preview===void 0?{}:{preview:i.preview},owner:i.owner??null});Q(s,a,c,r,o===void 0?{}:{patterns:o})})}u(ue,"watchRemote");async function Ie(e){const t=_(e),n=["1","true"].includes(e.query.get("fields")??""),r=await W(e,t,{method:"GET",path:G(t),query:P(e)},()=>D(t,e));if(r.kind==="unknown")return{state:"unknown",ref:t,unreachable:r.unreachable};if(r.kind==="remote"){if(r.status>=400)throw C(r.status,r.body);return ue(e,r.body,r.machine.id,"read"),F(r.body,r.machine)}return y(o=>{const i=N(o,e),s=i===null?g(o,e):A(o,i,M(o)),a=L(o,s,t);if("remote"in a)return{state:"remote",remote:a.remote};if("deleted"in a)return{state:"deleted",deleted:b(o,a.deleted)};const{data:c,text:f}=J(o,a.type).read(o,s,a,{fields:n,reposFn:M(o)});return{state:"object",type:a.type,object:b(o,a),text:f,data:c}})}u(Ie,"read");function z(e,t,n,r){if(e!=null){if(!n.includes(e)){r.push({field:t,problem:`must be one of ${n.join(", ")}`,received:e});return}return e}}u(z,"oneOf");function ce(e,t,n){if(t!=null){if(typeof t!="string"||!_e(e,t)){n.push({field:"type",problem:`must be a kind: one of ${ve(e).join(", ")}`,received:t});return}return t}}u(ce,"kindName");function le(e,t,n,r){if(e==null)return r;const o=Number(e);return!Number.isInteger(o)||o<1||o>100?(n.push({field:t,problem:"must be an integer from 1 to 100",received:e}),r):o}u(le,"limitOf");function fe(e){const t=e.query,n=[],r=ce(T(),t.get("type"),n),o=z(t.get("privacy"),"privacy",be,n),i=le(t.get("limit"),"limit",n,50),s=t.get("cursor");s!==null&&!/^\d+$/.test(s)&&n.push({field:"cursor",problem:"is not a cursor from a previous page",received:s});const a=t.get("deleted");return a!==null&&!["0","1","true","false"].includes(a)&&n.push({field:"deleted",problem:"must be true or false",received:a}),n.length>0&&$("object list request",n),y(c=>{const f=g(c,e),h={...r?{type:r}:{},...o?{privacy:o}:{},...t.get("kind")!==null?{kind:t.get("kind")}:{},...a==="1"||a==="true"?{deleted:!0}:{}},p=t.get("owner");if(p!==null){const m=ie(c,f,p);if(m===null)return{items:[],next_cursor:null};h.owner=m}const v=t.get("name"),j=[];let d=s===null?0:Number(s),E=!0;for(;j.length<i&&E;){const m=Ee(c,f,h,{limit:100,after_rowid:d});E=m.length===100;for(const q of m)if(d=q.rowid,(v===null||q.name.startsWith(v))&&j.push(q),j.length===i)break}const l=j.at(-1);return{items:j.map(m=>oe(c,m)),next_cursor:j.length===i&&l?String(l.rowid):null}})}u(fe,"listLocal");const Ae={ms:3e4},Le="self",U=u((e,t)=>e.map(n=>({...n,machine:t})),"labelItems");async function de(e,t,n,r){const o=S.current;try{const i=await o.call(t.id,{method:"GET",path:"/v1/objects",query:n},o.envelope(e.principal),r);if($e(i))return{state:"unreachable",items:[],next:null};if(i.status===426)return{state:"too_old",items:[],next:null};const s=i.body;return i.status!==200||!Array.isArray(s?.items)?{state:"unreachable",items:[],next:null}:{state:"ok",items:U(s.items,t),next:s.next_cursor??null}}catch{return{state:"unreachable",items:[],next:null}}}u(de,"remotePage");async function Ne(e){const t=new Promise(n=>e.addEventListener("abort",()=>n(null),{once:!0}));return Promise.race([S.current.machines().catch(()=>null),t])}u(Ne,"machinesWithin");async function Pe(e){const t=e.query.get("machine");if(!ee(e.principal)||!S.current.joined()||t===Le)return fe(me(e));const r=S.current.self(),o=t!==null&&I(t)!==r,i=o?null:fe(me(e)),s=new AbortController,a=setTimeout(()=>s.abort(),Ae.ms);a.unref?.();try{const c=await Ne(s.signal);if(c===null){if(o)throw new w(503,"runtime_unreachable",`The directory did not answer, so ${I(t)} cannot be listed.`,{machine:I(t)});return i}const f=u(l=>({id:l,name:c.find(m=>m.machine_id===l)?.name??l}),"labelOf"),h=f(r),p=c.filter(l=>l.machine_id!==r&&l.kind!=="device"),v=P(e);if(t!==null){if(!o)return{items:U(i.items,h),next_cursor:i.next_cursor,machines:[{machine_id:r,name:h.name,state:"ok",more:i.next_cursor!==null}]};const l=I(t);if(!p.some(q=>q.machine_id===l))throw new w(404,"not_found",`no machine ${l} on your account`,{machine:l});const m=await de(e,f(l),v,s.signal);return{items:m.items,next_cursor:m.next,machines:[{machine_id:l,name:f(l).name,state:m.state,more:m.next!==null}]}}const{cursor:j,...d}=v,E=await Promise.all(p.map(l=>de(e,f(l.machine_id),d,s.signal)));return{items:[...U(i.items,h),...E.flatMap(l=>l.items)],next_cursor:i.next_cursor,machines:[{machine_id:r,name:h.name,state:"ok",more:i.next_cursor!==null},...p.map((l,m)=>({machine_id:l.machine_id,name:l.name,state:E[m].state,more:E[m].next!==null}))]}}finally{clearTimeout(a)}}u(Pe,"list");function me(e){const t=new URLSearchParams(e.query);return t.delete("machine"),{...e,query:t}}u(me,"withoutMachine");function Ce(e){const t=[],n=k(e.body)?e.body:{};k(e.body)||t.push({field:"body",problem:"must be an object",received:e.body});const r=n.text;(typeof r!="string"||r.trim()==="")&&t.push({field:"text",problem:"is required",received:r});const o=n.grep;if(o!==void 0)if(typeof o!="string")t.push({field:"grep",problem:"must be a regular expression string",received:o});else try{new RegExp(o)}catch(a){t.push({field:"grep",problem:`is not a valid regular expression: ${a.message}`,received:o})}n.body!==void 0&&typeof n.body!="boolean"&&t.push({field:"body",problem:"must be a boolean",received:n.body});const i=ce(T(),n.type,t);n.kind!==void 0&&typeof n.kind!="string"&&t.push({field:"kind",problem:"must be a string",received:n.kind}),n.owner!==void 0&&typeof n.owner!="string"&&t.push({field:"owner",problem:"must be a string",received:n.owner});const s=le(n.limit,"limit",t,50);return t.length>0&&$("object search request",t),y(a=>{const c=g(a,e),f=typeof n.owner=="string"?ie(a,c,n.owner):null;if(typeof n.owner=="string"&&f===null)return{items:[]};const h=K(a,c,{text:r,body:n.body===!0||typeof o=="string"}),p=new Set(h.map(d=>d.object.rowid));return{items:(typeof o=="string"?K(a,c,{text:o,grep:!0}).filter(d=>p.has(d.object.rowid)):h).filter(d=>(!i||d.object.type===i)&&(n.kind===void 0||d.object.kind===n.kind)&&(!f||d.object.owner===f.rowid)).slice(0,s).map(d=>({object:oe(a,d.object),...d.line!==void 0?{matches:[{line:d.line,text:d.text??""}]}:{}}))}})}u(Ce,"search");const he=["interrupt","steer","after-turn","next-turn"],Fe=/^(\*|>|[A-Za-z0-9_-]+)(\.(\*|>|[A-Za-z0-9_-]+))*$/;function Me(e,t){if(e!==void 0){if(!Array.isArray(e)||e.length===0||e.some(n=>typeof n!="string"||!Fe.test(n)||n.includes(">")&&!n.endsWith(">"))){t.push({field:"patterns",problem:"must be a non-empty list of event patterns (dot-separated words, `*` for one word, a final `>` for the rest)",received:e});return}return e}}u(Me,"patternsOf");function We(e,t){if(e===void 0)return;if(!k(e)||Object.keys(e).length===0){t.push({field:"config",problem:"must map levels 1-10 to a delivery",received:e});return}const n=[];for(const[r,o]of Object.entries(e)){const i=Number(r);!Number.isInteger(i)||i<1||i>10?t.push({field:`config.${r}`,problem:"level must be an integer from 1 to 10"}):he.includes(o)?n.push({threshold:i,delivery:o}):t.push({field:`config.${r}`,problem:`delivery must be one of ${he.join(", ")}`,received:o})}return n.sort((r,o)=>o.threshold-r.threshold)}u(We,"configOf");function pe(e){const t=ae(e);if(t!==null)throw new w(400,"invalid_request",`${t} lives on another machine; an explicit watcher cannot watch it from here`,{id:t})}u(pe,"refuseStubWatch");async function De(e){const t=_(e);if(typeof(k(e.body)?e.body:{}).watcher=="string"&&pe(t),typeof(k(e.body)?e.body:{}).watcher!="string"){const n=await W(e,t,{method:"POST",path:`${G(t)}/watch`,body:e.body},()=>D(t,e));if(n.kind==="unknown")throw te(t,n.unreachable);if(n.kind==="remote"){if(n.status>=400)throw C(n.status,n.body);const r=n.body.patterns;return ue(e,n.body,n.machine.id,"ask",r),F(n.body,n.machine)}}return Ge(e)}u(De,"watch");function Ge(e){const t=[],n=e.body===void 0?{}:k(e.body)?e.body:(t.push({field:"body",problem:"must be an object"}),{}),r=Me(n.patterns,t),o=We(n.config,t);return n.filter!==void 0&&!k(n.filter)&&t.push({field:"filter",problem:"must be an object",received:n.filter}),n.watcher!==void 0&&typeof n.watcher!="string"&&t.push({field:"watcher",problem:"must be a custom-object ref",received:n.watcher}),t.length>0&&$("watch request",t),y(i=>{const s=typeof n.watcher=="string"?H(i,n.watcher):null;if(e.publishToken&&!s)throw new w(404,"not_found","watcher not found");if(e.principal.class==="anonymous"&&!s)throw new w(404,"not_found","watcher not found");const a=s?B(i,e,s):N(i,e)??V(g(i,e));s&&Z(i,e,s);const c=s?A(i,s):g(i,e),f=s?(()=>{const p=R(i,c,_(e));if(!ge(i,c,p))throw new w(404,"not_found","object not found");return p})():R(i,c,_(e)),h=r??[">"];return Q(i,s??a,f,s?"act":"ask",{patterns:h,...o?{config:o}:{},...n.filter!==void 0?{filter:n.filter}:{}}),ke(i,{event:"watched",object:f,actor:a,attrs:{}},M(i)),{object:b(i,f),patterns:h}})}u(Ge,"watchLocal");async function ze(e){const t=_(e);if(e.query.get("watcher")&&pe(t),!e.query.get("watcher")){const n=await W(e,t,{method:"DELETE",path:`${G(t)}/watch`,query:P(e)},()=>D(t,e));if(n.kind==="unknown")throw te(t,n.unreachable);if(n.kind==="remote"){if(n.status>=400)throw C(n.status,n.body);const r=n.body.object?.id;return y(o=>{const i=X(o,e),s=r===void 0?null:ne(o,r);i!==null&&s!==null&&re(o,s)&&Y(o,i,s,e.query.get("pattern")??void 0)}),F(n.body,n.machine)}}return Ue(e)}u(ze,"unwatchRoute");function Ue(e){const t=e.query.get("pattern"),n=e.query.get("watcher");return y(r=>{const o=n?H(r,n):null;if((e.publishToken||e.principal.class==="anonymous")&&!o)throw new w(404,"not_found","watcher not found");const i=o?B(r,e,o):N(r,e)??V(g(r,e));o&&Z(r,e,o);const s=o?A(r,o):g(r,e),a=R(r,s,_(e)),c=u(()=>ye(r,`SELECT count(*) AS n FROM watch_subscriptions s JOIN watches w ON w.rowid = s.watch
2
+ WHERE w.watcher = ? AND w.object = ?`).get((o??i).rowid,a.rowid).n,"count"),f=c();return Y(r,o??i,a,t??void 0),{object:b(r,a),removed:f-c()}})}u(Ue,"unwatchLocal");function Ve(e){const t=[],n=z(e.query.get("type"),"type",["acted-on","links-to","watches"],t),r=z(e.query.get("direction"),"direction",["out","in"],t);return t.length>0&&$("edges request",t),y(o=>{const i=g(o,e),s=R(o,i,_(e)),a=[];if(n!=="watches")for(const c of je(o,i,s,{...n?{type:n}:{},direction:r??"both"}))a.push({type:c.type,src:b(o,c.src),dst:b(o,c.dst),...c.written_as?{written_as:c.written_as}:{}});if(n===void 0||n==="watches"){const c=[...r!=="in"?Re(o,i,s):[],...r!=="out"?qe(o,i,s):[]];for(const f of c)a.push({type:"watches",src:b(o,f.watcher),dst:b(o,f.object)})}return{items:a}})}u(Ve,"edges");const O=u(e=>async t=>({status:200,body:await e(t)}),"ok"),ht=[{method:"POST",pattern:"/v1/objects/search",handler:O(Ce)},{method:"GET",pattern:"/v1/objects/:ref/edges",handler:O(Ve)},{method:"POST",pattern:"/v1/objects/:ref/watch",handler:O(De)},{method:"DELETE",pattern:"/v1/objects/:ref/watch",handler:O(ze)},{method:"GET",pattern:"/v1/objects/:ref",handler:O(Ie)},{method:"GET",pattern:"/v1/objects",handler:O(Pe)}];export{Ae as listBudget,ht as objectRoutes};
@@ -34,6 +34,8 @@ export interface ProviderCallInput {
34
34
  version?: number;
35
35
  /** Outside a run, the answered approval question the repeat names (R5). */
36
36
  approval?: string;
37
+ /** Which device machine takes the call (a `machine_id`, `runtime:` prefix optional); only a provider served by a machine accepts it. */
38
+ machine?: string;
37
39
  }
38
40
  export declare function parseProvider(value: unknown): string;
39
41
  export declare function parseCallInput(body: unknown): ProviderCallInput;
@@ -1 +1 @@
1
- var E=Object.defineProperty;var s=(e,r)=>E(e,"name",{value:r,configurable:!0});import{randomUUID as P}from"node:crypto";import{readConfig as I}from"../../../core/config.js";import{activeGrantForApp as R,localGrants as h}from"../../../core/grants/mirror.js";import{getNode as O}from"../../../core/canvas/canvas.js";import{scopeAllowed as $}from"../../../core/scopes.js";import{ProviderError as u,providerErrorBody as q,scopeMissing as x}from"../../../core/providers/errors.js";import{callProvider as G,providerTools as N}from"../../../core/providers/call.js";import{providerIdsFromGrants as T,providerInstallState as F}from"../../../core/providers/bundles.js";import{listInstalledPlugins as j}from"../../../core/resolver.js";import{listAudit as C,writeProviderAudit as D}from"../../../core/providers/audit.js";import{emitEvent as _}from"../../../core/events/emit.js";import{resolvedScopes as w}from"../router.js";const fe=new Set(["provider.tools","provider.call","audit.list"]);function M(e){if(e.class==="forwarded"||e.class==="machine")throw new u("permission_error","this operation is not available to the caller");if(e.class==="person")return{class:"person",sub:null,grantee:null,client_id:null,resolved:null,nodeScopes:null,run_id:null,node_id:null};if(e.class==="app"){const r=R(e.app);if(!r)throw new u("grant_revoked",`app ${e.app} has no active grant`);return{class:"app",sub:r.sub,grantee:e.app,client_id:null,resolved:r.resolved,nodeScopes:e.scopes,run_id:null,node_id:null}}return e.class==="cron"?{class:"app",sub:e.sub,grantee:e.app,client_id:null,resolved:w(e),nodeScopes:e.scopes,run_id:e.origin_run,node_id:null}:{class:"broker",sub:e.sub,grantee:e.app,client_id:e.client_id,resolved:w(e),nodeScopes:e.scopes,run_id:e.run??O(e.node)?.run_id??null,node_id:e.node}}s(M,"callerFromRequest");function ce(e){return{class:"app",sub:e.sub,grantee:e.grantee,client_id:e.client_id,resolved:e.resolved,nodeScopes:null,run_id:null,node_id:null}}s(ce,"callerFromApp");const U=/^([A-Za-z0-9_-]{1,64}):([A-Za-z0-9_-]{1,64})$/;function k(e){const r=U.exec(e);return r!==null&&r[1]!=="crtr"}s(k,"isProviderScope");const g=new WeakMap;function z(e){let r=g.get(e);return r===void 0&&(r=new Set(e),g.set(e,r)),r}s(z,"heldSet");function y(e,r){return e===null?!0:k(r)?z(e).has(r):$(e,r)}s(y,"holds");function S(e,r){return e.class==="person"?!0:y(e.resolved??[],r)&&y(e.nodeScopes,r)}s(S,"callerAllows");function pe(e,r,t){const n=`${r}:${t}`;if(!S(e,n))throw x(n)}s(pe,"requireGroup");function L(){return!!I("user").runtime?.issuer}s(L,"providerOperationsEnabled");const V=/^[A-Za-z0-9_-]{1,64}$/;function p(e,r){return new u("invalid_request",e,{param:r})}s(p,"invalid");function A(e){if(typeof e!="string"||!V.test(e)||e==="crtr")throw p("provider must be a provider id","provider");return e}s(A,"parseProvider");function Z(e){if(!e||typeof e!="object"||Array.isArray(e))throw new u("invalid_request","a JSON object is required");const r=e,t=A(r.provider),n=r.tool;if(typeof n!="string"||!n.trim())throw p("tool must be a non-empty string","tool");const i=r.arguments??{};if(!i||typeof i!="object"||Array.isArray(i))throw p("arguments must be an object","arguments");const a=r.version;if(a!==void 0&&(typeof a!="number"||!Number.isInteger(a)||a<1))throw p("version must be an integer >= 1","version");const o=r.approval;if(o!==void 0&&(typeof o!="string"||!o))throw p("approval must be a question id","approval");return{provider:t,tool:n.trim(),arguments:i,...a===void 0?{}:{version:a},...o===void 0?{}:{approval:o}}}s(Z,"parseCallInput");function B(e,r=new URLSearchParams){const t=s((o,f,c)=>{const l=r.get(o);if(l===null)return f;if(!/^\d+$/.test(l)||Number(l)>c||Number(l)<1)throw p(`invalid ${o}`,o);return Number(l)},"numeric"),n=r.has("after")?t("after",0,Number.MAX_SAFE_INTEGER):void 0,i=t("limit",100,1e3),a=Object.fromEntries(["operation","outcome","run_id","since","until"].filter(o=>r.has(o)).map(o=>[o,r.get(o)]));return{status:200,body:C(e.class==="person"?null:e.grantee,{after:n,limit:i,...a})}}s(B,"auditList");async function H(e,r,t){const n=performance.now(),i=new Set(j("user").filter(d=>d.enabled&&d.manifest.provider?.id===d.name).map(d=>d.name)),a=T(h()),o=[...new Set([...i,...a])].sort();if(r!==void 0&&!o.includes(r))throw new u("not_found",`provider "${r}" is not available`);const f=r===void 0?o:[r],c=e.class==="person"?h().flatMap(d=>d.status==="active"?d.resolved_scopes.map(({scope:v})=>v):[]):e.resolved??[],l=await Promise.all(f.map(async d=>{if(!c.some(m=>m.startsWith(`${d}:`)&&S(e,m)))return null;if(i.has(d)){const m=await N(e,d);return m.groups.length>0?m:null}const b=F(d);return{provider:d,display_name:d,status:b.status,...b.error?{error:b.error}:{},groups:[],commands:[]}})).then(d=>d.filter(v=>v!==null));if(_({level:"debug",event:"api.provider.tools",duration_ms:performance.now()-n,fields:{caller:e.class,provider:r??null,providers:l.length}}),r!==void 0){if(l.length===0)throw new u("not_found",`provider "${r}" is not available`);return{status:200,body:l[0]}}return{status:200,body:{providers:l}}}s(H,"providerTools");async function W(e,r,t){return G(e,r,t)}s(W,"providerCall");async function J(e,r,t,n){if(!L())throw new u("not_found","provider operations need a directory-connected runtime");if(e==="audit.list")return B(r,t.query);if(e==="provider.tools")return H(r,t.params.provider===void 0?void 0:A(t.params.provider),n);if(e==="provider.call"){let i;try{i=Z(t.body)}catch(a){const o=t.body&&typeof t.body=="object"&&!Array.isArray(t.body)?t.body:{};throw D({request_id:n.requestId,caller:r,provider:typeof o.provider=="string"?o.provider:"",tool:typeof o.tool=="string"?o.tool:"",outcome:`refused:${a instanceof u?a.code:"invalid_request"}`}),a}return W(r,i,n)}throw new u("not_found","unknown operation")}s(J,"handleProviderOperation");function X(e){const r=new AbortController,t=s(()=>{e.writableFinished||r.abort()},"abort");return e.on("close",t),{signal:r.signal,release:s(()=>e.off("close",t),"release")}}s(X,"abortOnClose");function K(e,r,t,n){const i=q(e,r,t,n??void 0);return i.status>=500&&!(e instanceof u)&&_({event:"api.provider.failed",level:"error",error:e instanceof Error?e:new Error(String(e)),fields:{request_id:r}}),i}s(K,"providerFailure");const me=[{method:"GET",pattern:"/v1/audit",name:"audit.list"},{method:"GET",pattern:"/v1/providers/tools",name:"provider.tools"},{method:"GET",pattern:"/v1/providers/:provider/tools",name:"provider.tools"},{method:"POST",pattern:"/v1/provider/call",name:"provider.call"}].map(({method:e,pattern:r,name:t})=>({method:e,pattern:r,handler:s(async n=>{const i=P(),{signal:a,release:o}=X(n.res);let f=null;try{return f=M(n.principal),{...await J(t,f,{params:n.params,body:n.body,query:new URL(n.req.url??"/","http://runtime.local").searchParams},{requestId:i,signal:a}),headers:{"Request-Id":i}}}catch(c){return K(c,i,!1,f?.run_id)}finally{o()}},"handler")}));export{fe as PROVIDER_OPERATIONS,X as abortOnClose,S as callerAllows,ce as callerFromApp,M as callerFromRequest,J as handleProviderOperation,Z as parseCallInput,A as parseProvider,K as providerFailure,L as providerOperationsEnabled,me as providerRoutes,pe as requireGroup};
1
+ var E=Object.defineProperty;var s=(e,r)=>E(e,"name",{value:r,configurable:!0});import{randomUUID as P}from"node:crypto";import{readConfig as I}from"../../../core/config.js";import{activeGrantForApp as R,localGrants as b}from"../../../core/grants/mirror.js";import{getNode as O}from"../../../core/canvas/canvas.js";import{scopeAllowed as $}from"../../../core/scopes.js";import{ProviderError as f,providerErrorBody as q,scopeMissing as x}from"../../../core/providers/errors.js";import{callProvider as G,providerTools as N}from"../../../core/providers/call.js";import{providerIdsFromGrants as T,providerInstallState as F}from"../../../core/providers/bundles.js";import{listInstalledPlugins as j}from"../../../core/resolver.js";import{listAudit as C,writeProviderAudit as D}from"../../../core/providers/audit.js";import{emitEvent as w}from"../../../core/events/emit.js";import{resolvedScopes as _}from"../router.js";const fe=new Set(["provider.tools","provider.call","audit.list"]);function M(e){if(e.class==="forwarded"||e.class==="machine")throw new f("permission_error","this operation is not available to the caller");if(e.class==="person")return{class:"person",sub:null,grantee:null,client_id:null,resolved:null,nodeScopes:null,run_id:null,node_id:null};if(e.class==="app"){const r=R(e.app);if(!r)throw new f("grant_revoked",`app ${e.app} has no active grant`);return{class:"app",sub:r.sub,grantee:e.app,client_id:null,resolved:r.resolved,nodeScopes:e.scopes,run_id:null,node_id:null}}return e.class==="cron"?{class:"app",sub:e.sub,grantee:e.app,client_id:null,resolved:_(e),nodeScopes:e.scopes,run_id:e.origin_run,node_id:null}:{class:"broker",sub:e.sub,grantee:e.app,client_id:e.client_id,resolved:_(e),nodeScopes:e.scopes,run_id:e.run??O(e.node)?.run_id??null,node_id:e.node}}s(M,"callerFromRequest");function ce(e){return{class:"app",sub:e.sub,grantee:e.grantee,client_id:e.client_id,resolved:e.resolved,nodeScopes:null,run_id:null,node_id:null}}s(ce,"callerFromApp");const U=/^([A-Za-z0-9_-]{1,64}):([A-Za-z0-9_-]{1,64})$/;function k(e){const r=U.exec(e);return r!==null&&r[1]!=="crtr"}s(k,"isProviderScope");const g=new WeakMap;function z(e){let r=g.get(e);return r===void 0&&(r=new Set(e),g.set(e,r)),r}s(z,"heldSet");function y(e,r){return e===null?!0:k(r)?z(e).has(r):$(e,r)}s(y,"holds");function S(e,r){return e.class==="person"?!0:y(e.resolved??[],r)&&y(e.nodeScopes,r)}s(S,"callerAllows");function me(e,r,t){const n=`${r}:${t}`;if(!S(e,n))throw x(n)}s(me,"requireGroup");function L(){return!!I("user").runtime?.issuer}s(L,"providerOperationsEnabled");const V=/^[A-Za-z0-9_-]{1,64}$/;function c(e,r){return new f("invalid_request",e,{param:r})}s(c,"invalid");function A(e){if(typeof e!="string"||!V.test(e)||e==="crtr")throw c("provider must be a provider id","provider");return e}s(A,"parseProvider");function Z(e){if(!e||typeof e!="object"||Array.isArray(e))throw new f("invalid_request","a JSON object is required");const r=e,t=A(r.provider),n=r.tool;if(typeof n!="string"||!n.trim())throw c("tool must be a non-empty string","tool");const i=r.arguments??{};if(!i||typeof i!="object"||Array.isArray(i))throw c("arguments must be an object","arguments");const d=r.version;if(d!==void 0&&(typeof d!="number"||!Number.isInteger(d)||d<1))throw c("version must be an integer >= 1","version");const o=r.approval;if(o!==void 0&&(typeof o!="string"||!o))throw c("approval must be a question id","approval");const l=r.machine;if(l!==void 0&&(typeof l!="string"||!l.trim()))throw c("machine must be a machine id","machine");return{provider:t,tool:n.trim(),arguments:i,...d===void 0?{}:{version:d},...o===void 0?{}:{approval:o},...l===void 0?{}:{machine:l.trim()}}}s(Z,"parseCallInput");function B(e,r=new URLSearchParams){const t=s((o,l,m)=>{const u=r.get(o);if(u===null)return l;if(!/^\d+$/.test(u)||Number(u)>m||Number(u)<1)throw c(`invalid ${o}`,o);return Number(u)},"numeric"),n=r.has("after")?t("after",0,Number.MAX_SAFE_INTEGER):void 0,i=t("limit",100,1e3),d=Object.fromEntries(["operation","outcome","run_id","since","until"].filter(o=>r.has(o)).map(o=>[o,r.get(o)]));return{status:200,body:C(e.class==="person"?null:e.grantee,{after:n,limit:i,...d})}}s(B,"auditList");async function H(e,r,t){const n=performance.now(),i=new Set(j("user").filter(a=>a.enabled&&a.manifest.provider?.id===a.name).map(a=>a.name)),d=T(b()),o=[...new Set([...i,...d])].sort();if(r!==void 0&&!o.includes(r))throw new f("not_found",`provider "${r}" is not available`);const l=r===void 0?o:[r],m=e.class==="person"?b().flatMap(a=>a.status==="active"?a.resolved_scopes.map(({scope:v})=>v):[]):e.resolved??[],u=await Promise.all(l.map(async a=>{if(!m.some(p=>p.startsWith(`${a}:`)&&S(e,p)))return null;if(i.has(a)){const p=await N(e,a);return p.groups.length>0?p:null}const h=F(a);return{provider:a,display_name:a,status:h.status,...h.error?{error:h.error}:{},groups:[],commands:[]}})).then(a=>a.filter(v=>v!==null));if(w({level:"debug",event:"api.provider.tools",duration_ms:performance.now()-n,fields:{caller:e.class,provider:r??null,providers:u.length}}),r!==void 0){if(u.length===0)throw new f("not_found",`provider "${r}" is not available`);return{status:200,body:u[0]}}return{status:200,body:{providers:u}}}s(H,"providerTools");async function W(e,r,t){return G(e,r,t)}s(W,"providerCall");async function J(e,r,t,n){if(!L())throw new f("not_found","provider operations need a directory-connected runtime");if(e==="audit.list")return B(r,t.query);if(e==="provider.tools")return H(r,t.params.provider===void 0?void 0:A(t.params.provider),n);if(e==="provider.call"){let i;try{i=Z(t.body)}catch(d){const o=t.body&&typeof t.body=="object"&&!Array.isArray(t.body)?t.body:{};throw D({request_id:n.requestId,caller:r,provider:typeof o.provider=="string"?o.provider:"",tool:typeof o.tool=="string"?o.tool:"",outcome:`refused:${d instanceof f?d.code:"invalid_request"}`}),d}return W(r,i,n)}throw new f("not_found","unknown operation")}s(J,"handleProviderOperation");function X(e){const r=new AbortController,t=s(()=>{e.writableFinished||r.abort()},"abort");return e.on("close",t),{signal:r.signal,release:s(()=>e.off("close",t),"release")}}s(X,"abortOnClose");function K(e,r,t,n){const i=q(e,r,t,n??void 0);return i.status>=500&&!(e instanceof f)&&w({event:"api.provider.failed",level:"error",error:e instanceof Error?e:new Error(String(e)),fields:{request_id:r}}),i}s(K,"providerFailure");const pe=[{method:"GET",pattern:"/v1/audit",name:"audit.list"},{method:"GET",pattern:"/v1/providers/tools",name:"provider.tools"},{method:"GET",pattern:"/v1/providers/:provider/tools",name:"provider.tools"},{method:"POST",pattern:"/v1/provider/call",name:"provider.call"}].map(({method:e,pattern:r,name:t})=>({method:e,pattern:r,handler:s(async n=>{const i=P(),{signal:d,release:o}=X(n.res);let l=null;try{return l=M(n.principal),{...await J(t,l,{params:n.params,body:n.body,query:new URL(n.req.url??"/","http://runtime.local").searchParams},{requestId:i,signal:d}),headers:{"Request-Id":i}}}catch(m){return K(m,i,!1,l?.run_id)}finally{o()}},"handler")}));export{fe as PROVIDER_OPERATIONS,X as abortOnClose,S as callerAllows,ce as callerFromApp,M as callerFromRequest,J as handleProviderOperation,Z as parseCallInput,A as parseProvider,K as providerFailure,L as providerOperationsEnabled,pe as providerRoutes,me as requireGroup};
@@ -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,22 +1,55 @@
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
  | --- | --- |
11
32
  | unset, or anything other than `self-hosted` | GitHub-hosted `macos-latest` (billed) |
12
- | `self-hosted` | runners labelled `[self-hosted, macOS, crouter]` (the Mac mini) |
33
+ | `self-hosted` | runners labelled `[self-hosted, macOS, crouter]` (the Mac mini) when one is free, otherwise `macos-latest` (see Routing below) |
13
34
 
14
35
  ```bash
15
36
  gh variable set CRTR_CI_RUNNER --body self-hosted -R Northlight-Systems/crouter # use the mini
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. **Without the `CI_RUNNER_READ_TOKEN` secret (below), routing cannot see the minis: if they are offline, jobs queue until they return — delete the variable.** Cancel and re-run anything already queued. There is one `macos` job per lane that has a selected laptop-only file (`macos unit`, `macos integration`, `macos seam`).
41
+
42
+ ### Routing: minis first, hosted when none is free
43
+
44
+ With `CRTR_CI_RUNNER=self-hosted` the `select` job's route step (`scripts/ci-route.mjs`) lists the repository's runners and sends the `macos` jobs to the minis only when at least one runner labelled `crouter` is online and idle; if every mini is busy or offline they go to `macos-latest` (billed), so a saturated mini no longer queues a pull request for half an hour. The step summary of the `select` job says which it chose and why. Any other value of the variable always means `macos-latest`. Publish's call of `test.yml` passes no secrets, so on a push to main routing follows the variable alone.
45
+
46
+ Listing runners needs more than `GITHUB_TOKEN` can do, so it reads the secret `CI_RUNNER_READ_TOKEN`. One-time setup:
47
+
48
+ 1. github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token, owned by the person who administers the repository.
49
+ 2. Resource owner `Northlight-Systems`; repository access: only `Northlight-Systems/crouter`; repository permission **Administration: Read-only** (nothing else). Choose an expiry and note the renewal date.
50
+ 3. Paste it as a repository Actions secret: `gh secret set CI_RUNNER_READ_TOKEN -R Northlight-Systems/crouter` (or Settings → Secrets and variables → Actions → New repository secret). Do not make it an environment or organization secret.
51
+
52
+ Without the secret, or when the API call fails (expired token, rate limit), routing follows the variable (minis when it says `self-hosted`) and the summary says `routing fell back to the variable`.
20
53
 
21
54
  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
55
 
@@ -48,7 +81,7 @@ Installing and upgrading: get a registration token (`gh api -X POST repos/Northl
48
81
 
49
82
  ## No double run on `main`
50
83
 
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.
84
+ `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
85
 
53
86
  - 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
87
  - 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 +89,30 @@ Installing and upgrading: get a registration token (`gh api -X POST repos/Northl
56
89
  - 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
90
 
58
91
  The OIDC constraint at the top of `publish.yml` is unchanged: every publishing job is still in that file.
92
+
93
+ ## What runs when (hosted-runner spend)
94
+
95
+ | Event | ubuntu `checks` + `select` | ubuntu `linux` (container) | `macos` (laptop-only tests) |
96
+ | --- | --- | --- | --- |
97
+ | pull request push | yes | the selected files, sharded to ~4 min; none for a docs-only change | the selected files that carry `laptopOnly` tags, if any |
98
+ | pull request with the `full-ci` label, or a change whose path forces it | yes | all of it | all of it |
99
+ | merge queue run (`merge_group`; inert until a queue ruleset exists) | yes | all of it | all of it |
100
+ | `workflow_dispatch` (`gh workflow run test.yml --ref <branch>`) | yes | all of it | all of it |
101
+ | push to `main` (Publish's `tests` job) | yes | all of it, unless `gate` found `tested-tree-<tree>` | same |
102
+
103
+ - Selected tests run on every push with no label. Add `full-ci` to force the whole suite (`gh pr edit <n> --add-label full-ci`). Labels other than `full-ci` start nothing and cancel nothing.
104
+ - On a pull request each matrix (`linux`, `macos`) is `fail-fast`: the first failing job cancels that matrix's other jobs, the matrix result is `failure`, and `full suite` still runs and is red. On main, merge queue and dispatch runs every job finishes. A Linux failure does not cancel the macOS matrix.
105
+ - `record tested tree` runs only in mode `full`, so a partial run never lets Publish skip the suite.
106
+ - The `full suite` job is the single result to require. It fails if `checks` or `select` did not succeed or any job failed or was cancelled; in mode `full` it also fails unless every `linux` and `macos` job succeeded (skipped is not enough), so Publish (which calls this file) can only release a tree whose full suite passed. In mode `none` it passes and says no tests are affected.
107
+ - `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.
108
+
109
+ ## The selector: `scripts/ci-select.mjs`
110
+
111
+ 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.
112
+
113
+ - **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.
114
+ - **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.
115
+ - **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`).
116
+ - **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.
117
+ - `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.
118
+ - `--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.
@@ -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.454",
3
+ "version": "0.3.456",
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.454",
140
+ "@north-light/identity": "0.3.456",
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.454",
3
+ "version": "0.3.456",
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.454",
3
+ "version": "0.3.456",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.454",
9
+ "version": "0.3.456",
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.454",
21
+ "@north-light/identity": "0.3.456",
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.454",
12098
+ "version": "0.3.456",
12099
12099
  "license": "UNLICENSED"
12100
12100
  },
12101
12101
  "packages/crouter-env-docker": {
12102
12102
  "name": "@north-light/env-docker",
12103
- "version": "0.3.454",
12103
+ "version": "0.3.456",
12104
12104
  "license": "UNLICENSED"
12105
12105
  },
12106
12106
  "packages/crouter-identity": {
12107
12107
  "name": "@north-light/identity",
12108
- "version": "0.3.454",
12108
+ "version": "0.3.456",
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.454",
12116
+ "version": "0.3.456",
12117
12117
  "license": "UNLICENSED",
12118
12118
  "dependencies": {
12119
- "@north-light/api": "0.3.454",
12120
- "@north-light/identity": "0.3.454"
12119
+ "@north-light/api": "0.3.456",
12120
+ "@north-light/identity": "0.3.456"
12121
12121
  }
12122
12122
  },
12123
12123
  "packages/crouter-sdk": {
12124
12124
  "name": "@north-light/sdk",
12125
- "version": "0.3.454",
12125
+ "version": "0.3.456",
12126
12126
  "license": "UNLICENSED",
12127
12127
  "dependencies": {
12128
- "@north-light/api": "0.3.454",
12129
- "@north-light/identity": "0.3.454",
12128
+ "@north-light/api": "0.3.456",
12129
+ "@north-light/identity": "0.3.456",
12130
12130
  "jose": "^6.2.1"
12131
12131
  },
12132
12132
  "bin": {
@@ -13,6 +13,21 @@ export function scrubInheritedBrokerEnv(env = process.env) {
13
13
  }
14
14
  }
15
15
 
16
+ // A test run must never reach a network git remote. The repo-document sync engine
17
+ // (`refs/crtr/docs`) pushes whatever `origin` a checkout names, and a sandbox is a copy
18
+ // of a real checkout whose `origin` is the production remote, with the account's SSH
19
+ // agent and git credentials inherited. `GIT_ALLOW_PROTOCOL=file` makes git refuse every
20
+ // non-local transport before it authenticates (`fatal: transport 'ssh' not allowed`);
21
+ // tests that need a remote use a local bare repo. Applied to the runner's env, to the
22
+ // in-process lane env, and to every fixture subprocess env, so it reaches the deepest git.
23
+ export const TEST_GIT_ALLOW_PROTOCOL = 'file';
24
+ export function confineGitToLocalTransport(env = process.env) {
25
+ env.GIT_ALLOW_PROTOCOL = TEST_GIT_ALLOW_PROTOCOL;
26
+ // Defense in depth: with no agent a stray ssh transport has nothing to authenticate with.
27
+ delete env.SSH_AUTH_SOCK;
28
+ return env;
29
+ }
30
+
16
31
  // A normal host install uses the account HOME. When HOME is overridden, no
17
32
  // working directory or configured write root may still point into that account.
18
33
  // A node's shell runs with HOME at its app-space home (`~/.crouter/apps/<app>/home`);