@north-light/cli 0.3.496 → 0.3.497

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.
@@ -9,7 +9,7 @@ when-and-why-to-read: When you need Release notes, this knowledge should be read
9
9
 
10
10
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
11
11
 
12
- ## 0.3.494
12
+ ## 0.3.495
13
13
 
14
14
  `GET /v1/limits` reports how much of the data disk each app uses on a host without disk quota, and the disk as a whole. Additive: no request or response an existing app sends or reads changes.
15
15
 
@@ -18,6 +18,7 @@ Changes to `@north-light/sdk` that can change what an existing app sees. Newest
18
18
  - `AppLimitsDTO.disk_used_bytes` was `null` whenever the host could not enforce project quota (every hosted runtime on Blaxel). It now holds the bytes under the app's folder, measured by a directory walk at most a minute old. It stays `null` when the host has no launcher, the first walk has not finished (a read waits at most 5 s for it), or the walk failed.
19
19
  - `LimitsDTO.data_disk` is new and optional: `{total_bytes, used_bytes, user_bytes, runtime_bytes}` for the data volume. `crtr sys limits show` prints it.
20
20
 
21
+ ## 0.3.493
21
22
 
22
23
  An app can act on a person's hosted runtime with no sign-in of theirs, and `@north-light/plugin` can declare canvas object kinds. Everything below is additive: no request or response an existing app sends or reads changes.
23
24
 
@@ -1,11 +1,11 @@
1
- var W=Object.defineProperty;var o=(n,e)=>W(n,"name",{value:e,configurable:!0});import{envNodeId as P,envProfileId as U}from"../shared/env.js";import{existsSync as M,statSync as H}from"node:fs";import{resolve as Y}from"node:path";import{defineBranch as B,defineLeaf as h}from"../core/command.js";import{InputError as c,readStdinRaw as V}from"../core/io.js";import{parseWhen as z,parseCadence as Z,cadenceDisplay as k}from"../core/wake.js";import{profilesStateBlock as j,resolveProfileViaDaemon as J}from"./profile/daemon-profiles.js";import{cliClient as l,rethrowAsCliError as u}from"./api-client.js";async function L(n){return(await J(n.trim())).id}o(L,"canonicalProfileId");function b(n,e){return e!==void 0&&e.trim()!==""?{tz:e,now:n}:{now:n}}o(b,"parseOpts");function $(n){const e={wake_in_past:'Pick a future instant \u2014 a positive duration ("5m","2h") or an ISO time later than now.',bad_when:'Use a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00").',bad_cadence:'Use a duration ("6h"), a 5-field cron ("0 9 * * *"), or an @alias ("@daily").',unknown_zone:'Pass --tz with an IANA zone name (e.g. "America/New_York").',cadence_too_fast:'Use a cadence of at least 60s (e.g. "1m","5m","1h").'};throw new c({error:n.code,message:n.message,received:n.received,next:e[n.code]??"Fix the time value and retry."})}o($,"throwTimeError");function G(n){const e=/^(\d+)(s|m|h)?$/.exec(n.trim());if(e===null)throw new c({error:"bad_run_timeout",message:`"${n}" is not a valid run timeout`,field:"run-timeout",next:'Use a duration like "90s", "5m", "1h", or bare seconds ("300").'});const r=Number(e[1]),t=e[2]==="h"?3600:e[2]==="m"?60:1,i=r*t;if(i<=0)throw new c({error:"bad_run_timeout",message:"run timeout must be positive",field:"run-timeout",next:'Use a positive duration, e.g. "5m".'});return i}o(G,"parseRunTimeout");function f(n){const e=n??U();return e!==void 0&&e.trim()!==""?{profile:e}:void 0}o(f,"scopeQuery");function Q(n){if(n===void 0)return process.cwd();const e=Y(n);if(!M(e)||!H(e).isDirectory())throw new c({error:"bad_cwd",message:`${e} is not a directory`,field:"cwd",next:"Pass --cwd with an existing directory, or omit it to inherit this one."});return e}o(Q,"resolveCwd");function X(n){const e=Array.isArray(n)?n.map(String):typeof n=="string"&&n!==""?[n]:[];if(e.length===0)return null;const r={};for(const t of e){const i=t.indexOf("="),s=i===-1?"":t.slice(0,i);if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s))throw new c({error:"bad_env",message:`"${t}" is not KEY=VALUE`,field:"env",next:"Pass --env KEY=VALUE (repeat the flag per variable); KEY must be a shell-legal name."});r[s]=t.slice(i+1)}return JSON.stringify(r)}o(X,"resolveEnvJson");function p(n){const e=typeof n.cronId=="string"?n.cronId.trim():"";if(e==="")throw new c({error:"missing_cron_id",message:"a cron id is required",field:"cron-id",next:"Pass the cron id from `crtr cron list`."});return e}o(p,"requireCronId");function ee(n){if(n===null)return"never run";const e=n.finished??"",r=n.exit_code===0?"ok":n.exit_code===-1?"TIMEOUT":n.exit_code===null?"no exit":`exit ${n.exit_code}`;return e===""?r:`${r} ${e}`}o(ee,"healthDisplay");function ne(n){const e=n.exit_code===null?"no exit":n.exit_code===-1?"TIMEOUT":`exit ${n.exit_code}`,r=n.duration_ms!==null?`${n.duration_ms}ms`:"\u2014";return`- ${n.started} \u2014 ${e}, ${r} \u2014 ${n.delivered??"(no disposition recorded)"}`}o(ne,"renderRun");const te=h({name:"add",description:"arm a scheduled command (bash on stdin; one of --at / --every)",whenToUse:"you need to arm a new bash schedule, rather than inspect or control an existing cron.",help:{name:"cron add",summary:"arm a scheduled command",params:[{kind:"stdin",name:"command",required:!0,constraint:"Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR and the anchor node's $CRTR_CONTEXT_DIR too when anchored; an unanchored cron has no context dir). Use absolute paths: `~` is the app's HOME, not the arming node's. The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output."},{kind:"flag",name:"name",type:"string",required:!0,constraint:"Agent-legible label \u2014 how the cron shows in `cron list`."},{kind:"flag",name:"at",type:"string",required:!1,constraint:`One-shot: run the stored bash once at <when> \u2014 a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run's disposition pauses it.`},{kind:"flag",name:"every",type:"string",required:!1,constraint:'Recurring: run the stored bash at each cadence \u2014 a fixed interval ("6h" \u2014 first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.'},{kind:"flag",name:"tz",type:"string",required:!1,constraint:'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.'},{kind:"flag",name:"on-output",type:"enum",choices:["silent","on-failure","always","on-change"],required:!1,constraint:"What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody \u2014 never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run's \u2014 the shape polling wants."},{kind:"flag",name:"sink",type:"string",required:!1,constraint:`Where always/on-change stdout deliveries go: "node:<id>" (an existing node's inbox \u2014 must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff \u2014 a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.`},{kind:"flag",name:"tier",type:"enum",choices:["normal","urgent","critical"],required:!1,default:"urgent",constraint:"Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant \u2014 a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright."},{kind:"flag",name:"expires",type:"string",required:!1,constraint:"Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern. For a held one-shot (last run exited 75), expiry bounds the wait for eligibility \u2014 the row is deleted UNFIRED, never fired blind at expiry."},{kind:"flag",name:"anchor",type:"string",required:!1,constraint:"Couple the cron's lifetime to a node: the row is deleted when that node is deleted. Without an anchor it outlives every node; it ends through its one-shot run, --expires, explicit cancellation, or self-cancellation from its bash."},{kind:"flag",name:"anchor-self",type:"bool",required:!1,constraint:"Anchor to the calling node ($CRTR_NODE_ID). Errors outside a node."},{kind:"flag",name:"cancel-on-wake",type:"bool",required:!1,constraint:"Also delete the row when the anchor node revives: use only when the cron belongs to that current dormancy, not for standing work. Requires an anchor; at most one cancel-on-wake cron per anchor \u2014 arming a second replaces the anchor\u2019s pending one."},{kind:"flag",name:"run-timeout",type:"string",required:!1,default:"5m",constraint:'Kill a run that exceeds this ("90s","5m","1h", or bare seconds). A timeout is a failure.'},{kind:"flag",name:"overlap",type:"enum",choices:["skip","queue","replace"],required:!1,default:"skip",constraint:"A fire landing while the previous run is still going. skip (default): log the skip, do not start a second run. queue: run once more after the current finishes (depth 1; further fires collapse). replace: kill the running instance and start fresh."},{kind:"flag",name:"cwd",type:"path",required:!1,constraint:"Directory every run executes in. Defaults to this one, must exist now, and is snapshotted onto the row \u2014 a cron never re-resolves its context. In a hosted runtime, runs are sandboxed and see only the app's own folders: an omitted --cwd that is outside them becomes the node's directory or the app files directory, and an explicit --cwd outside them is refused now (`cron show` prints the directory the row got)."},{kind:"flag",name:"env",type:"string",required:!1,repeatable:!0,constraint:"KEY=VALUE added to every run's env (repeat per variable). Snapshotted at arm time; overlays the operational env, alongside $CRTR_CRON_ID/$CRTR_CRON_NAME/$CRTR_RUN_ID."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile the runs resolve config and credentials under, and \u2014 under --scope profile \u2014 the profile this cron lists and cancels within. A profile id or manifest name from the <profiles> list below; a close-enough name resolves to the nearest profile. Defaults to the calling node's ($CRTR_PROFILE_ID)."},{kind:"flag",name:"scope",type:"enum",choices:["profile","global"],required:!1,constraint:"Who lists and cancels this cron: profile (default when a profile is resolved) confines it to that profile; global makes it canvas-home-wide, visible from every profile."}],output:[{name:"cron_id",type:"string",required:!0,constraint:"The server-minted armed cron id \u2014 inspect with `cron show <cron_id>`, cancel with `cron cancel <cron_id>`."},{name:"fire_at",type:"string",required:!0,constraint:"Next fire (UTC ISO)."},{name:"recur",type:"string",required:!0,constraint:'Cadence display; "none" for a one-shot.'}],outputKind:"object",effects:["Arms one cron row (daemon-run; survives this process and every node).","Each fire runs the command via bash in this cwd and records a run-log entry.","A failing run (nonzero exit, timeout, undeliverable sink) pauses the cron and spawns a node to deal with it \u2014 unless --on-output silent."]},helpState:{leaf:j},run:o(async n=>{let e=typeof n.command=="string"?n.command.trim():"";if(e===""&&(e=(await V()).trim()),e==="")throw new c({error:"missing_command",message:"no command on stdin",field:"command",next:"Pipe the bash command on stdin (e.g. from a single-quoted heredoc)."});const r=typeof n.name=="string"?n.name.trim():"";if(r==="")throw new c({error:"missing_name",message:"--name is required",field:"name",next:"Pass --name <label>."});const t=typeof n.at=="string"&&n.at!==""?n.at:void 0,i=typeof n.every=="string"&&n.every!==""?n.every:void 0;if(t===void 0==(i===void 0))throw new c({error:"bad_schedule",message:t===void 0?"one of --at / --every is required":"--at and --every are mutually exclusive",next:"Pass exactly one: --at <when> for a one-shot, or --every <cadence> for a recurrence."});const s=typeof n.tz=="string"&&n.tz!==""?n.tz:void 0,m=new Date;let d,T=null;if(i!==void 0){const a=Z(i,b(m,s));"error"in a&&$(a.error),d=a.firstFireAt,T=a.recur}else{const a=z(t,b(m,s));"error"in a&&$(a.error),d=a.fireAt}let R=null;const C=typeof n.expires=="string"&&n.expires!==""?n.expires:void 0;if(C!==void 0){const a=z(C,b(m,s));"error"in a&&$(a.error),R=a.fireAt}const q=typeof n.anchor=="string"&&n.anchor!==""?n.anchor:void 0,A=n.anchorSelf===!0;if(q!==void 0&&A)throw new c({error:"bad_anchor",message:"--anchor and --anchor-self are mutually exclusive",next:"Pass one anchor or the other."});let w=q??null;if(A){const a=P();if(a===void 0||a==="")throw new c({error:"no_self_node",message:"--anchor-self requires a calling node ($CRTR_NODE_ID is unset)",next:"Run from inside a node, or pass --anchor <node-id> explicitly."});w=a}const I=n.cancelOnWake===!0;if(I&&w===null)throw new c({error:"cancel_on_wake_needs_anchor",message:"--cancel-on-wake requires an anchor",next:"Add --anchor <node-id> or --anchor-self."});const O=typeof n.runTimeout=="string"&&n.runTimeout!==""?G(n.runTimeout):void 0,E=typeof n.cwd=="string"&&n.cwd!==""?n.cwd:void 0,g=P(),S=g?await l().getNode(g).catch(u):null,K=Q(E??(S?.isolated?S.cwd:void 0)),F=X(n.env),D=typeof n.profile=="string"&&n.profile!==""?n.profile:void 0,y=U(),_=D!==void 0?await L(D):y!==void 0&&y.trim()!==""?y:void 0,N=n.scope??(_!==void 0?"profile":"global");if(N==="profile"&&_===void 0)throw new c({error:"scope_needs_profile",message:"--scope profile names the profile this cron lists and cancels within, but no profile is resolved",field:"profile",next:"Pass --profile <id>, or use --scope global for a canvas-home-wide cron."});const v=g,x=await l().armCron({name:r,command:e,fire_at:d,recur:T,tz:s??null,created_by:v!==void 0&&v!==""?v:null,cwd:K,...E===void 0?{cwd_is_default:!0}:{},env_json:F,profile:_??null,scope:N,on_output:n.onOutput,sink:typeof n.sink=="string"&&n.sink!==""?n.sink:null,tier:n.tier??"urgent",expires_at:R,anchor_node:w,cancel_on_wake:I,...O!==void 0?{run_timeout_s:O}:{},overlap:n.overlap??"skip"}).catch(u);return{cron_id:x.cron_id,fire_at:x.fire_at,recur:k(x.recur)}},"run"),render:o(n=>{const e=String(n.recur),r=e==="none"?"one-shot (row deleted after the run)":e;return`Cron armed \u2014 ${String(n.cron_id)}.
1
+ var W=Object.defineProperty;var o=(n,e)=>W(n,"name",{value:e,configurable:!0});import{envNodeId as P,envProfileId as U}from"../shared/env.js";import{existsSync as M,statSync as H}from"node:fs";import{resolve as Y}from"node:path";import{defineBranch as B,defineLeaf as h}from"../core/command.js";import{InputError as c,readStdinRaw as V}from"../core/io.js";import{parseWhen as z,parseCadence as Z,cadenceDisplay as k}from"../core/wake.js";import{profilesStateBlock as j,resolveProfileViaDaemon as J}from"./profile/daemon-profiles.js";import{cliClient as l,rethrowAsCliError as u}from"./api-client.js";async function L(n){return(await J(n.trim())).id}o(L,"canonicalProfileId");function b(n,e){return e!==void 0&&e.trim()!==""?{tz:e,now:n}:{now:n}}o(b,"parseOpts");function $(n){const e={wake_in_past:'Pick a future instant \u2014 a positive duration ("5m","2h") or an ISO time later than now.',bad_when:'Use a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00").',bad_cadence:'Use a duration ("6h"), a 5-field cron ("0 9 * * *"), or an @alias ("@daily").',unknown_zone:'Pass --tz with an IANA zone name (e.g. "America/New_York").',cadence_too_fast:'Use a cadence of at least 60s (e.g. "1m","5m","1h").'};throw new c({error:n.code,message:n.message,received:n.received,next:e[n.code]??"Fix the time value and retry."})}o($,"throwTimeError");function G(n){const e=/^(\d+)(s|m|h)?$/.exec(n.trim());if(e===null)throw new c({error:"bad_run_timeout",message:`"${n}" is not a valid run timeout`,field:"run-timeout",next:'Use a duration like "90s", "5m", "1h", or bare seconds ("300").'});const r=Number(e[1]),t=e[2]==="h"?3600:e[2]==="m"?60:1,i=r*t;if(i<=0)throw new c({error:"bad_run_timeout",message:"run timeout must be positive",field:"run-timeout",next:'Use a positive duration, e.g. "5m".'});return i}o(G,"parseRunTimeout");function f(n){const e=n??U();return e!==void 0&&e.trim()!==""?{profile:e}:void 0}o(f,"scopeQuery");function Q(n){if(n===void 0)return process.cwd();const e=Y(n);if(!M(e)||!H(e).isDirectory())throw new c({error:"bad_cwd",message:`${e} is not a directory`,field:"cwd",next:"Pass --cwd with an existing directory, or omit it to inherit this one."});return e}o(Q,"resolveCwd");function X(n){const e=Array.isArray(n)?n.map(String):typeof n=="string"&&n!==""?[n]:[];if(e.length===0)return null;const r={};for(const t of e){const i=t.indexOf("="),a=i===-1?"":t.slice(0,i);if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(a))throw new c({error:"bad_env",message:`"${t}" is not KEY=VALUE`,field:"env",next:"Pass --env KEY=VALUE (repeat the flag per variable); KEY must be a shell-legal name."});r[a]=t.slice(i+1)}return JSON.stringify(r)}o(X,"resolveEnvJson");function p(n){const e=typeof n.cronId=="string"?n.cronId.trim():"";if(e==="")throw new c({error:"missing_cron_id",message:"a cron id is required",field:"cron-id",next:"Pass the cron id from `crtr cron list`."});return e}o(p,"requireCronId");function ee(n){if(n===null)return"never run";const e=n.finished??"",r=n.exit_code===0?"ok":n.exit_code===-1?"TIMEOUT":n.exit_code===null?"no exit":`exit ${n.exit_code}`;return e===""?r:`${r} ${e}`}o(ee,"healthDisplay");function ne(n){const e=n.exit_code===null?"no exit":n.exit_code===-1?"TIMEOUT":`exit ${n.exit_code}`,r=n.duration_ms!==null?`${n.duration_ms}ms`:"\u2014";return`- ${n.started} \u2014 ${e}, ${r} \u2014 ${n.delivered??"(no disposition recorded)"}`}o(ne,"renderRun");const te=h({name:"add",description:"arm a scheduled command (bash on stdin; one of --at / --every)",whenToUse:"you need to arm a new bash schedule, rather than inspect or control an existing cron.",help:{name:"cron add",summary:"arm a scheduled command",params:[{kind:"stdin",name:"command",required:!0,constraint:"Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR and the anchor node's $CRTR_CONTEXT_DIR too when anchored; an unanchored cron has no context dir). Use absolute paths: `~` is the app's HOME, not the arming node's. The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A pipeline exits with its last stage, so a failing probe piped into grep or jq exits 0 with empty stdout unless the command starts with `set -o pipefail`; a poll that must notice its probe failing needs it (a run that fails escalates, one that exits 0 quietly does not). A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output."},{kind:"flag",name:"name",type:"string",required:!0,constraint:"Agent-legible label \u2014 how the cron shows in `cron list`."},{kind:"flag",name:"at",type:"string",required:!1,constraint:`One-shot: run the stored bash once at <when> \u2014 a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run's disposition pauses it.`},{kind:"flag",name:"every",type:"string",required:!1,constraint:'Recurring: run the stored bash at each cadence \u2014 a fixed interval ("6h" \u2014 first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.'},{kind:"flag",name:"tz",type:"string",required:!1,constraint:'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.'},{kind:"flag",name:"on-output",type:"enum",choices:["silent","on-failure","always","on-change"],required:!1,constraint:"What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody \u2014 never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run's \u2014 the shape polling wants."},{kind:"flag",name:"sink",type:"string",required:!1,constraint:`Where always/on-change stdout deliveries go: "node:<id>" (an existing node's inbox \u2014 must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff \u2014 a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.`},{kind:"flag",name:"tier",type:"enum",choices:["normal","urgent","critical"],required:!1,default:"urgent",constraint:"Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant \u2014 a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright."},{kind:"flag",name:"expires",type:"string",required:!1,constraint:"Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern. A node:<id> sink is mailed (waking) that the cron expired, with its run count and last delivery. For a held one-shot (last run exited 75), expiry bounds the wait for eligibility \u2014 the row is deleted UNFIRED, never fired blind at expiry."},{kind:"flag",name:"anchor",type:"string",required:!1,constraint:"Couple the cron's lifetime to a node: the row is deleted when that node is deleted. Without an anchor it outlives every node; it ends through its one-shot run, --expires, explicit cancellation, or self-cancellation from its bash."},{kind:"flag",name:"anchor-self",type:"bool",required:!1,constraint:"Anchor to the calling node ($CRTR_NODE_ID). Errors outside a node."},{kind:"flag",name:"cancel-on-wake",type:"bool",required:!1,constraint:"Also delete the row when the anchor node revives: use only when the cron belongs to that current dormancy, not for standing work. Requires an anchor; at most one cancel-on-wake cron per anchor \u2014 arming a second replaces the anchor\u2019s pending one."},{kind:"flag",name:"run-timeout",type:"string",required:!1,default:"5m",constraint:'Kill a run that exceeds this ("90s","5m","1h", or bare seconds). A timeout is a failure.'},{kind:"flag",name:"overlap",type:"enum",choices:["skip","queue","replace"],required:!1,default:"skip",constraint:"A fire landing while the previous run is still going. skip (default): log the skip, do not start a second run. queue: run once more after the current finishes (depth 1; further fires collapse). replace: kill the running instance and start fresh."},{kind:"flag",name:"cwd",type:"path",required:!1,constraint:"Directory every run executes in. Defaults to this one, must exist now, and is snapshotted onto the row \u2014 a cron never re-resolves its context. In a hosted runtime, runs are sandboxed and see only the app's own folders: an omitted --cwd that is outside them becomes the node's directory or the app files directory, and an explicit --cwd outside them is refused now (`cron show` prints the directory the row got)."},{kind:"flag",name:"env",type:"string",required:!1,repeatable:!0,constraint:"KEY=VALUE added to every run's env (repeat per variable). Snapshotted at arm time; overlays the operational env, alongside $CRTR_CRON_ID/$CRTR_CRON_NAME/$CRTR_RUN_ID."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile the runs resolve config and credentials under, and \u2014 under --scope profile \u2014 the profile this cron lists and cancels within. A profile id or manifest name from the <profiles> list below; a close-enough name resolves to the nearest profile. Defaults to the calling node's ($CRTR_PROFILE_ID)."},{kind:"flag",name:"scope",type:"enum",choices:["profile","global"],required:!1,constraint:"Who lists and cancels this cron: profile (default when a profile is resolved) confines it to that profile; global makes it canvas-home-wide, visible from every profile."}],output:[{name:"cron_id",type:"string",required:!0,constraint:"The server-minted armed cron id \u2014 inspect with `cron show <cron_id>`, cancel with `cron cancel <cron_id>`."},{name:"fire_at",type:"string",required:!0,constraint:"Next fire (UTC ISO)."},{name:"recur",type:"string",required:!0,constraint:'Cadence display; "none" for a one-shot.'}],outputKind:"object",effects:["Arms one cron row (daemon-run; survives this process and every node).","Each fire runs the command via bash in this cwd and records a run-log entry.","A failing run (nonzero exit, timeout, undeliverable sink) pauses the cron and spawns a node to deal with it \u2014 unless --on-output silent."]},helpState:{leaf:j},run:o(async n=>{let e=typeof n.command=="string"?n.command.trim():"";if(e===""&&(e=(await V()).trim()),e==="")throw new c({error:"missing_command",message:"no command on stdin",field:"command",next:"Pipe the bash command on stdin (e.g. from a single-quoted heredoc)."});const r=typeof n.name=="string"?n.name.trim():"";if(r==="")throw new c({error:"missing_name",message:"--name is required",field:"name",next:"Pass --name <label>."});const t=typeof n.at=="string"&&n.at!==""?n.at:void 0,i=typeof n.every=="string"&&n.every!==""?n.every:void 0;if(t===void 0==(i===void 0))throw new c({error:"bad_schedule",message:t===void 0?"one of --at / --every is required":"--at and --every are mutually exclusive",next:"Pass exactly one: --at <when> for a one-shot, or --every <cadence> for a recurrence."});const a=typeof n.tz=="string"&&n.tz!==""?n.tz:void 0,m=new Date;let d,T=null;if(i!==void 0){const s=Z(i,b(m,a));"error"in s&&$(s.error),d=s.firstFireAt,T=s.recur}else{const s=z(t,b(m,a));"error"in s&&$(s.error),d=s.fireAt}let R=null;const C=typeof n.expires=="string"&&n.expires!==""?n.expires:void 0;if(C!==void 0){const s=z(C,b(m,a));"error"in s&&$(s.error),R=s.fireAt}const q=typeof n.anchor=="string"&&n.anchor!==""?n.anchor:void 0,A=n.anchorSelf===!0;if(q!==void 0&&A)throw new c({error:"bad_anchor",message:"--anchor and --anchor-self are mutually exclusive",next:"Pass one anchor or the other."});let w=q??null;if(A){const s=P();if(s===void 0||s==="")throw new c({error:"no_self_node",message:"--anchor-self requires a calling node ($CRTR_NODE_ID is unset)",next:"Run from inside a node, or pass --anchor <node-id> explicitly."});w=s}const I=n.cancelOnWake===!0;if(I&&w===null)throw new c({error:"cancel_on_wake_needs_anchor",message:"--cancel-on-wake requires an anchor",next:"Add --anchor <node-id> or --anchor-self."});const O=typeof n.runTimeout=="string"&&n.runTimeout!==""?G(n.runTimeout):void 0,E=typeof n.cwd=="string"&&n.cwd!==""?n.cwd:void 0,g=P(),S=g?await l().getNode(g).catch(u):null,K=Q(E??(S?.isolated?S.cwd:void 0)),F=X(n.env),D=typeof n.profile=="string"&&n.profile!==""?n.profile:void 0,y=U(),_=D!==void 0?await L(D):y!==void 0&&y.trim()!==""?y:void 0,N=n.scope??(_!==void 0?"profile":"global");if(N==="profile"&&_===void 0)throw new c({error:"scope_needs_profile",message:"--scope profile names the profile this cron lists and cancels within, but no profile is resolved",field:"profile",next:"Pass --profile <id>, or use --scope global for a canvas-home-wide cron."});const v=g,x=await l().armCron({name:r,command:e,fire_at:d,recur:T,tz:a??null,created_by:v!==void 0&&v!==""?v:null,cwd:K,...E===void 0?{cwd_is_default:!0}:{},env_json:F,profile:_??null,scope:N,on_output:n.onOutput,sink:typeof n.sink=="string"&&n.sink!==""?n.sink:null,tier:n.tier??"urgent",expires_at:R,anchor_node:w,cancel_on_wake:I,...O!==void 0?{run_timeout_s:O}:{},overlap:n.overlap??"skip"}).catch(u);return{cron_id:x.cron_id,fire_at:x.fire_at,recur:k(x.recur)}},"run"),render:o(n=>{const e=String(n.recur),r=e==="none"?"one-shot (row deleted after the run)":e;return`Cron armed \u2014 ${String(n.cron_id)}.
2
2
 
3
3
  - next fire: ${String(n.fire_at)}
4
4
  - cadence: ${r}
5
5
 
6
- It runs daemon-side; nothing more to do here. Inspect it with \`crtr cron show ${String(n.cron_id)}\`; cancel with \`crtr cron cancel ${String(n.cron_id)}\` from its profile, or pass \`--profile <profile>\` from another profile.`},"render")}),re=h({name:"list",description:"what is scheduled, with next fire and recent health",whenToUse:"you want to see every cron in scope \u2014 to find an id to show/cancel, to check what fires next and what is paused, or to spot one whose last run failed.",help:{name:"cron list",summary:"every cron in scope, next-fire order",params:[],output:[{name:"crons",type:"object[]",required:!0,constraint:"One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered \u2014 null if it never ran)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async()=>({crons:(await l().listCrons(f()).catch(u)).map(e=>({cron_id:e.cron_id,name:e.name,fire_at:e.fire_at,recur:k(e.recur),state:e.state,held:e.held,on_output:e.on_output,sink:e.sink,expires_at:e.expires_at,last_run:e.last_run}))}),"run"),render:o(n=>{const e=n.crons;if(e.length===0)return"No crons armed.";const r=e.map(t=>{const i=[`next ${t.fire_at}`,t.recur==="none"?"one-shot":t.recur,t.sink!==null?`${t.on_output} \u2192 ${t.sink}`:t.on_output,`last: ${ee(t.last_run)}`];t.expires_at!==null&&i.push(`expires ${t.expires_at}`);const s=t.state==="paused"?t.held?" [PAUSED (held) \u2014 resume or cancel]":" [PAUSED \u2014 resume or cancel]":t.held?t.recur==="none"?` [HELD \u2014 last run exited 75; run manually with \`crtr cron run ${t.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`]`:" [HELD \u2014 last run exited 75; re-checks at the next cadence or when a host platform calls `POST /v1/crons/poke`]":"";return`- ${t.name} (${t.cron_id}) \u2014 ${i.join(", ")}${s}`});return`${e.length} cron(s):
6
+ It runs daemon-side; nothing more to do here. Inspect it with \`crtr cron show ${String(n.cron_id)}\`; cancel with \`crtr cron cancel ${String(n.cron_id)}\` from its profile, or pass \`--profile <profile>\` from another profile.`},"render")}),re=h({name:"list",description:"what is scheduled, with next fire and recent health",whenToUse:"you want to see every cron in scope \u2014 to find an id to show/cancel, to check what fires next and what is paused, or to spot one whose last run failed.",help:{name:"cron list",summary:"every cron in scope, next-fire order",params:[],output:[{name:"crons",type:"object[]",required:!0,constraint:"One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered \u2014 null if it never ran)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async()=>({crons:(await l().listCrons(f()).catch(u)).map(e=>({cron_id:e.cron_id,name:e.name,fire_at:e.fire_at,recur:k(e.recur),state:e.state,held:e.held,on_output:e.on_output,sink:e.sink,expires_at:e.expires_at,last_run:e.last_run}))}),"run"),render:o(n=>{const e=n.crons;if(e.length===0)return"No crons armed.";const r=e.map(t=>{const i=[`next ${t.fire_at}`,t.recur==="none"?"one-shot":t.recur,t.sink!==null?`${t.on_output} \u2192 ${t.sink}`:t.on_output,`last: ${ee(t.last_run)}`];t.expires_at!==null&&i.push(`expires ${t.expires_at}`);const a=t.state==="paused"?t.held?" [PAUSED (held) \u2014 resume or cancel]":" [PAUSED \u2014 resume or cancel]":t.held?t.recur==="none"?` [HELD \u2014 last run exited 75; run manually with \`crtr cron run ${t.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`]`:" [HELD \u2014 last run exited 75; re-checks at the next cadence or when a host platform calls `POST /v1/crons/poke`]":"";return`- ${t.name} (${t.cron_id}) \u2014 ${i.join(", ")}${a}`});return`${e.length} cron(s):
7
7
 
8
8
  ${r.join(`
9
- `)}`},"render")}),oe=h({name:"show",description:"one cron: full config plus its run history",whenToUse:'you want to inspect one cron \u2014 its exact command and settings, and what its recent runs actually did (exit codes, output heads, where deliveries went). The run log is how you answer "did it silently stop working".',help:{name:"cron show",summary:"one cron with its run-log ring",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to inspect (from `cron list`)."}],output:[{name:"cron",type:"object",required:!0,constraint:"The full cron config: command, schedule, snapshotted context (cwd, profile, scope, env var names \u2014 values stay server-side), disposition, sink, coupling, state."},{name:"runs",type:"object[]",required:!0,constraint:"The bounded run log, most recent first: started, exit_code (-1 = timeout kill), duration_ms, stdout/stderr heads, delivered (what the sink did, or why it did not)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."));return{cron:r.cron,runs:r.runs}},"run"),render:o(n=>{const e=n.cron,r=n.runs,t=e.recur!==null?k(e.recur):e.held?"one-shot":`one-shot at ${e.fire_at}`,i=e.tz!==null?` (tz ${e.tz})`:"",s=e.held?e.recur!==null?`- schedule: ${t}${i} \u2014 next re-check ${e.fire_at} (held \u2014 also re-checks when a host platform calls \`POST /v1/crons/poke\`; last run exited 75)`:e.expires_at!==null?`- schedule: ${t}${i} \u2014 no scheduled re-check \u2014 expires ${e.expires_at} (held \u2014 run manually with \`crtr cron run ${e.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`; deleted unfired at expiry)`:`- schedule: ${t}${i} \u2014 no scheduled re-check (held \u2014 run manually with \`crtr cron run ${e.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`)`:`- schedule: ${t}${i} \u2014 next fire ${e.fire_at}`,m=e.state==="paused"?e.held?" \u2014 PAUSED (held)":" \u2014 PAUSED":e.held?" \u2014 HELD":"",d=[`# ${e.name} (${e.cron_id})${m}`,"",s,`- disposition: ${e.on_output}${e.sink!==null?` \u2192 ${e.sink} (tier ${e.tier})`:""}`,`- context: cwd ${e.cwd}${e.profile!==null?`, profile ${e.profile}`:""}, scope ${e.scope}${e.env_keys.length>0?`, env ${e.env_keys.join(", ")}`:""}`,`- run: timeout ${e.run_timeout_s}s, overlap ${e.overlap}${e.run_state==="running"?" \u2014 a run is IN FLIGHT now":""}`];return e.expires_at!==null&&d.push(`- expires: ${e.expires_at}`),e.anchor_node!==null&&d.push(`- anchored to node ${e.anchor_node}${e.cancel_on_wake?" (canceled when it wakes)":""}`),e.created_by!==null&&d.push(`- created by node ${e.created_by}`),d.push("","## Command","","```bash",e.command,"```","",`## Runs (most recent first, last ${r.length})`,""),d.push(...r.length>0?r.map(ne):["(no runs yet)"]),d.join(`
10
- `)},"render")}),ie=h({name:"run",description:"execute now, out of band \u2014 does not advance the schedule",whenToUse:"you need the stored command to execute now because its own behavior has not been exercised, or you fixed the command behind a paused cron. This is not a scheduled-delivery check: once `cron add` has armed a valid row, trust the clock and node delivery rather than firing it again for reassurance.",help:{name:"cron run",summary:"fire one cron now, out of band, and report the settled run",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to run (from `cron list`)."}],output:[{name:"exit_code",type:"number",required:!1,constraint:"The run's exit code (-1 = timeout kill; null when the process failed to start)."},{name:"duration_ms",type:"number",required:!0,constraint:"Wall-clock run time."},{name:"stdout_head",type:"string",required:!1,constraint:"Captured stdout (head)."},{name:"stderr_head",type:"string",required:!1,constraint:"Captured stderr (head)."},{name:"delivered",type:"string",required:!0,constraint:"What the sink did with the output, or why it did not."}],outputKind:"object",effects:["Runs the command NOW (blocking this call until it settles) and records the run in the cron's run log.","Delivers per the cron's disposition; never advances the schedule, consumes a one-shot, or escalates a failure."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(s=>u(s,"Check the id against `crtr cron list`."));return await l({timeoutMs:r.cron.run_timeout_s*1e3+15e3}).runCron(e,f()).catch(s=>u(s))},"run"),render:o(n=>{const e=n,t=[`Run settled \u2014 exit ${e.exit_code===null?"no exit (process error)":e.exit_code===-1?"-1 (TIMEOUT)":String(e.exit_code)}, ${e.duration_ms??"?"}ms.`,"",`- delivered: ${e.delivered??"(nothing recorded)"}`];return e.stdout_head!==null&&t.push("","## stdout (head)","",e.stdout_head),e.stderr_head!==null&&t.push("","## stderr (head)","",e.stderr_head),t.join(`
11
- `)},"render")}),ae=h({name:"pause",description:"stop firing without losing config or history",whenToUse:"a cron should stop firing for now but its config and run history are worth keeping \u2014 a job under investigation, or a poll to silence temporarily. (A failing cron pauses itself.)",help:{name:"cron pause",summary:"pause one cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to pause (from `cron list`)."}],output:[],outputKind:"object",effects:["Stops the cron firing until `cron resume`. An in-flight run settles normally; the row just stops being due."]},run:o(async n=>{const e=p(n);return await l().pauseCron(e,f()).catch(r=>u(r,"Check the id against `crtr cron list`.")),{}},"run"),render:o(()=>"Cron paused.","render")}),se=h({name:"resume",description:"re-arm a paused cron",whenToUse:"a paused cron should fire again \u2014 you fixed what made it fail (verify first with `cron run`), or its silence is over. A fire that came due while paused fires immediately on resume.",help:{name:"cron resume",summary:"resume one paused cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to resume (from `cron list`)."}],output:[{name:"fire_at",type:"string",required:!0,constraint:"Next fire \u2014 if already past, it fires on the next daemon tick."}],outputKind:"object",effects:["Re-arms the cron. A past-due fire_at fires immediately (instant verification after un-wedging); a recurrence then advances naturally."]},run:o(async n=>{const e=p(n);return{fire_at:(await l().resumeCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."))).fire_at}},"run"),render:o(n=>`Cron resumed \u2014 next fire ${String(n.fire_at)} (if that is already past, it fires on the next tick).`,"render")}),ce=h({name:"cancel",description:"remove one cron (idempotent)",whenToUse:"a scheduled command should stop firing \u2014 it is obsolete, wrong, or its purpose is served. Idempotent, so a script can safely self-cancel with `crtr cron cancel $CRTR_CRON_ID` even if a racing cancel got there first.",help:{name:"cron cancel",summary:"remove one cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to remove (from `cron list`, or $CRTR_CRON_ID inside a run)."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile scope to cancel within. Defaults to the calling node's profile; pass the profile id or manifest name for a cron armed under another profile."}],output:[],outputKind:"object",effects:["Deletes the cron row and its run history; kills any in-flight run. A no-op if already gone."]},helpState:{leaf:j},run:o(async n=>{const e=p(n),r=process.env.CRTR_RUN_ID;return await l().cancelCron(e,{...f(typeof n.profile=="string"&&n.profile.trim()!==""?await L(n.profile):void 0),...r===void 0||r===""?{}:{run_id:r}}).catch(t=>u(t,"Check the id against `crtr cron list`.")),{}},"run"),render:o(()=>"Cron canceled.","render")});function _e(){return B({name:"cron",rootEntry:{concept:"one scheduling primitive \u2014 run this bash command, then",desc:"arm, inspect, and control scheduled bash commands the daemon runs on the clock",useWhen:"bash must run later or on a cadence, rather than waiting for a runtime-delivered wake."},help:{name:"cron",summary:"scheduled bash commands, daemon-run",model:"A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node's lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent \u2014 right when the recurrence is your polling cadence. Exit 75 holds the row. A held recurring row re-checks at its next natural slot, or when a host platform calls the daemon's `POST /v1/crons/poke` endpoint. A held one-shot has no scheduled re-check: it stays held until that endpoint is called, or you execute its bash explicitly with `crtr cron run <cron-id>` (which does not unhold or reschedule the row). A local filesystem condition does not call the daemon, so use --every for that condition or run the cron explicitly after it changes. Any other nonzero exit is a real failure and escalates per --on-output. Keep gate errors nonzero so a broken probe is loud instead of silently held. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run's disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it."},children:[te,re,oe,ie,ae,se,ce]})}o(_e,"registerCron");export{_e as registerCron};
9
+ `)}`},"render")}),oe=h({name:"show",description:"one cron: full config plus its run history",whenToUse:'you want to inspect one cron \u2014 its exact command and settings, and what its recent runs actually did (exit codes, output heads, where deliveries went). The run log is how you answer "did it silently stop working".',help:{name:"cron show",summary:"one cron with its run-log ring",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to inspect (from `cron list`)."}],output:[{name:"cron",type:"object",required:!0,constraint:"The full cron config: command, schedule, snapshotted context (cwd, profile, scope, env var names \u2014 values stay server-side), disposition, sink, coupling, state."},{name:"runs",type:"object[]",required:!0,constraint:"The bounded run log, most recent first: started, exit_code (-1 = timeout kill), duration_ms, stdout/stderr heads, delivered (what the sink did, or why it did not)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."));return{cron:r.cron,runs:r.runs}},"run"),render:o(n=>{const e=n.cron,r=n.runs,t=e.recur!==null?k(e.recur):e.held?"one-shot":`one-shot at ${e.fire_at}`,i=e.tz!==null?` (tz ${e.tz})`:"",a=e.held?e.recur!==null?`- schedule: ${t}${i} \u2014 next re-check ${e.fire_at} (held \u2014 also re-checks when a host platform calls \`POST /v1/crons/poke\`; last run exited 75)`:e.expires_at!==null?`- schedule: ${t}${i} \u2014 no scheduled re-check \u2014 expires ${e.expires_at} (held \u2014 run manually with \`crtr cron run ${e.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`; deleted unfired at expiry)`:`- schedule: ${t}${i} \u2014 no scheduled re-check (held \u2014 run manually with \`crtr cron run ${e.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`)`:`- schedule: ${t}${i} \u2014 next fire ${e.fire_at}`,m=e.state==="paused"?e.held?" \u2014 PAUSED (held)":" \u2014 PAUSED":e.held?" \u2014 HELD":"",d=[`# ${e.name} (${e.cron_id})${m}`,"",a,`- disposition: ${e.on_output}${e.sink!==null?` \u2192 ${e.sink} (tier ${e.tier})`:""}`,`- context: cwd ${e.cwd}${e.profile!==null?`, profile ${e.profile}`:""}, scope ${e.scope}${e.env_keys.length>0?`, env ${e.env_keys.join(", ")}`:""}`,`- run: timeout ${e.run_timeout_s}s, overlap ${e.overlap}${e.run_state==="running"?" \u2014 a run is IN FLIGHT now":""}`];return e.expires_at!==null&&d.push(`- expires: ${e.expires_at}`),e.anchor_node!==null&&d.push(`- anchored to node ${e.anchor_node}${e.cancel_on_wake?" (canceled when it wakes)":""}`),e.created_by!==null&&d.push(`- created by node ${e.created_by}`),d.push("","## Command","","```bash",e.command,"```","",`## Runs (most recent first, last ${r.length})`,""),d.push(...r.length>0?r.map(ne):["(no runs yet)"]),d.join(`
10
+ `)},"render")}),ie=h({name:"run",description:"execute now, out of band \u2014 does not advance the schedule",whenToUse:"you need the stored command to execute now because its own behavior has not been exercised, or you fixed the command behind a paused cron. This is not a scheduled-delivery check: once `cron add` has armed a valid row, trust the clock and node delivery rather than firing it again for reassurance.",help:{name:"cron run",summary:"fire one cron now, out of band, and report the settled run",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to run (from `cron list`)."}],output:[{name:"exit_code",type:"number",required:!1,constraint:"The run's exit code (-1 = timeout kill; null when the process failed to start)."},{name:"duration_ms",type:"number",required:!0,constraint:"Wall-clock run time."},{name:"stdout_head",type:"string",required:!1,constraint:"Captured stdout (head)."},{name:"stderr_head",type:"string",required:!1,constraint:"Captured stderr (head)."},{name:"delivered",type:"string",required:!0,constraint:"What the sink did with the output, or why it did not."}],outputKind:"object",effects:["Runs the command NOW (blocking this call until it settles) and records the run in the cron's run log.","Delivers per the cron's disposition; never advances the schedule, consumes a one-shot, or escalates a failure."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(a=>u(a,"Check the id against `crtr cron list`."));return await l({timeoutMs:r.cron.run_timeout_s*1e3+15e3}).runCron(e,f()).catch(a=>u(a))},"run"),render:o(n=>{const e=n,t=[`Run settled \u2014 exit ${e.exit_code===null?"no exit (process error)":e.exit_code===-1?"-1 (TIMEOUT)":String(e.exit_code)}, ${e.duration_ms??"?"}ms.`,"",`- delivered: ${e.delivered??"(nothing recorded)"}`];return e.stdout_head!==null&&t.push("","## stdout (head)","",e.stdout_head),e.stderr_head!==null&&t.push("","## stderr (head)","",e.stderr_head),t.join(`
11
+ `)},"render")}),se=h({name:"pause",description:"stop firing without losing config or history",whenToUse:"a cron should stop firing for now but its config and run history are worth keeping \u2014 a job under investigation, or a poll to silence temporarily. (A failing cron pauses itself.)",help:{name:"cron pause",summary:"pause one cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to pause (from `cron list`)."}],output:[],outputKind:"object",effects:["Stops the cron firing until `cron resume`. An in-flight run settles normally; the row just stops being due."]},run:o(async n=>{const e=p(n);return await l().pauseCron(e,f()).catch(r=>u(r,"Check the id against `crtr cron list`.")),{}},"run"),render:o(()=>"Cron paused.","render")}),ae=h({name:"resume",description:"re-arm a paused cron",whenToUse:"a paused cron should fire again \u2014 you fixed what made it fail (verify first with `cron run`), or its silence is over. A fire that came due while paused fires immediately on resume.",help:{name:"cron resume",summary:"resume one paused cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to resume (from `cron list`)."}],output:[{name:"fire_at",type:"string",required:!0,constraint:"Next fire \u2014 if already past, it fires on the next daemon tick."}],outputKind:"object",effects:["Re-arms the cron. A past-due fire_at fires immediately (instant verification after un-wedging); a recurrence then advances naturally."]},run:o(async n=>{const e=p(n);return{fire_at:(await l().resumeCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."))).fire_at}},"run"),render:o(n=>`Cron resumed \u2014 next fire ${String(n.fire_at)} (if that is already past, it fires on the next tick).`,"render")}),ce=h({name:"cancel",description:"remove one cron (idempotent)",whenToUse:"a scheduled command should stop firing \u2014 it is obsolete, wrong, or its purpose is served. Idempotent, so a script can safely self-cancel with `crtr cron cancel $CRTR_CRON_ID` even if a racing cancel got there first.",help:{name:"cron cancel",summary:"remove one cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to remove (from `cron list`, or $CRTR_CRON_ID inside a run)."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile scope to cancel within. Defaults to the calling node's profile; pass the profile id or manifest name for a cron armed under another profile."}],output:[],outputKind:"object",effects:["Deletes the cron row and its run history; kills any in-flight run. A no-op if already gone."]},helpState:{leaf:j},run:o(async n=>{const e=p(n),r=process.env.CRTR_RUN_ID;return await l().cancelCron(e,{...f(typeof n.profile=="string"&&n.profile.trim()!==""?await L(n.profile):void 0),...r===void 0||r===""?{}:{run_id:r}}).catch(t=>u(t,"Check the id against `crtr cron list`.")),{}},"run"),render:o(()=>"Cron canceled.","render")});function _e(){return B({name:"cron",rootEntry:{concept:"one scheduling primitive \u2014 run this bash command, then",desc:"arm, inspect, and control scheduled bash commands the daemon runs on the clock",useWhen:"bash must run later or on a cadence, rather than waiting for a runtime-delivered wake."},help:{name:"cron",summary:"scheduled bash commands, daemon-run",model:"A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node's lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent \u2014 right when the recurrence is your polling cadence. Exit 75 holds the row. A held recurring row re-checks at its next natural slot, or when a host platform calls the daemon's `POST /v1/crons/poke` endpoint. A held one-shot has no scheduled re-check: it stays held until that endpoint is called, or you execute its bash explicitly with `crtr cron run <cron-id>` (which does not unhold or reschedule the row). A local filesystem condition does not call the daemon, so use --every for that condition or run the cron explicitly after it changes. Any other nonzero exit is a real failure and escalates per --on-output. Keep gate errors nonzero so a broken probe is loud instead of silently held. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run's disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it."},children:[te,re,oe,ie,se,ae,ce]})}o(_e,"registerCron");export{_e as registerCron};
@@ -1 +1 @@
1
- var X=Object.defineProperty;var t=(e,n)=>X(e,"name",{value:n,configurable:!0});import{randomUUID as nn}from"node:crypto";import{relative as en,resolve as z,sep as B}from"node:path";import{ApiError as a}from"../../../api/errors.js";import{armCron as rn,cancelCron as on,getCron as y,listCrons as T,listCronRuns as G,pokeHeldCrons as tn,setCronState as F}from"../../../core/canvas/crons.js";import{getNode as h}from"../../../core/canvas/canvas.js";import{appSpace as sn,cronFolder as an}from"../../../core/layout.js";import{appProcessesSandboxed as cn,cwdIsMounted as ln}from"../../../core/spaces/process.js";import{activeGrant as dn,activeGrantForApp as un,grantFor as pn,terminalGrant as fn}from"../../../core/grants/mirror.js";import{readConfig as _n}from"../../../core/config.js";import{assertScope as L}from"../../../core/scopes.js";import{InputError as i}from"../../../core/io.js";import{nextSlotAfter as hn}from"../../../core/wake.js";import{executeCron as mn,markSelfCanceledCronRun as gn}from"../../cron-run.js";import{toCronDTO as k,toCronRunDTO as Z}from"../map.js";import{appOf as H,assertCeiling as wn,canReadNode as bn}from"../router.js";import{checkCronBirth as vn,checkCronFire as yn}from"../../cron/authority.js";import{requireUsableProfile as kn}from"./profiles.js";function A(e,n){const r=e[n];if(typeof r!="string"||r.trim()==="")throw new i({error:"missing_field",message:`cron arm request requires a non-empty "${n}"`,field:n,next:`Send a non-empty string "${n}".`});return r}t(A,"requireString");function O(e,n,r,o){if(e==null)return o;if(typeof e=="string"&&r.includes(e))return e;throw new i({error:`bad_${n}`,message:`"${String(e)}" is not a valid ${n}`,field:n,next:`Use one of: ${r.join(", ")}.`})}t(O,"requireEnum");function Sn(e){if(e==="human")return JSON.stringify({human:!0});if(e.startsWith("node:")){const n=e.slice(5).trim();if(n==="")throw new i({error:"bad_sink",message:"node sink requires a node id",field:"sink",next:'Use sink "node:<node-id>".'});if(h(n)===null)throw new i({error:"sink_node_not_found",message:`sink node ${n} does not exist on the canvas`,field:"sink",next:"Name a live node id, or use spawn:<kind> for a delivery that outlives any one node."});return JSON.stringify({node:n})}if(e.startsWith("spawn:")){const n=e.slice(6).trim();if(n==="")throw new i({error:"bad_sink",message:"spawn sink requires a node kind",field:"sink",next:'Use sink "spawn:<kind>" (e.g. spawn:general).'});return JSON.stringify({spawn:n})}throw new i({error:"bad_sink",message:`"${e}" is not a valid sink`,field:"sink",next:"Use node:<node-id>, spawn:<kind>, or human."})}t(Sn,"parseSinkSpec");const $n=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function M(e,n){if(!$n.test(e)||Number.isNaN(Date.parse(e)))throw new i({error:`bad_${n}`,message:`"${e}" is not a UTC ISO instant`,field:n,next:'Send a resolved UTC instant like "2026-06-07T09:00:00.000Z" (the client resolves the time grammar).'});return e}t(M,"requireUtcIso");function On(e){try{hn(e,new Date)}catch(n){throw new i({error:"bad_recur",message:`recur is not advanceable: ${n instanceof Error?n.message:String(n)}`,field:"recur",next:'Send recur JSON the daemon can advance: {"every":"6h"} or {"cron":"0 9 * * *","tz":"America/New_York"}.'})}return e}t(On,"requireRecur");function Cn(e){let n;try{n=JSON.parse(e)}catch{throw new i({error:"bad_env_json",message:"env_json is not valid JSON",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'})}if(typeof n!="object"||n===null||Array.isArray(n))throw new i({error:"bad_env_json",message:"env_json must be a JSON object",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'});for(const[r,o]of Object.entries(n))if(typeof o!="string")throw new i({error:"bad_env_json",message:`env_json value for "${r}" is not a string`,field:"env_json",next:"Every env value must be a string."});return e}t(Cn,"requireEnvJson");function E(e){const n=e.query.get("profile");return n!==null&&n.trim()!==""?n.trim():null}t(E,"callerProfile");function Y(e,n){return n===null||e.scope==="global"||e.profile===n}t(Y,"inScope");function K(e){return e.principal.class==="person"?null:H(e.principal)}t(K,"callerApp");function U(e,n){return bn(e.principal,n.grantee,n.created_by??void 0)}t(U,"visibleTo");function In(e){return typeof e.anchor_node=="string"&&e.anchor_node!==""?e.anchor_node:null}t(In,"anchorNodeOf");function Nn(e,n,r,o,c,u){const l=typeof e.cwd=="string"&&e.cwd.trim()!==""?z(e.cwd):null;if(!cn()){if(l===null)throw new i({error:"missing_field",message:'cron arm request requires a non-empty "cwd"',field:"cwd",next:'Send a non-empty string "cwd".'});return l}const p=o!==null&&h(o)!==null?o:void 0,d=t(N=>ln(n,N,p,c,void 0,u),"reaches");if(l!==null&&d(l))return l;const s=sn(n).files;if(l!==null&&e.cwd_is_default!==!0)throw new a(400,"invalid_request",`cwd ${l} is not inside a directory this app's cron runs can see, so every run would fail to start; use a directory under ${s}`,{cwd:l,default:s},void 0,{param:"cwd"});const m=r===null?null:h(r)?.cwd??null;return m!==null&&d(m)?m:s}t(Nn,"resolveArmedCwd");function qn(e,n,r){const o=e.principal;if(o.class==="person"){const s=fn();return{sub:s?.sub??"user:local",grant_id:s?.id??null,scopes:null,origin_run_id:null}}if(o.class==="app"){const s=un(n);if(s===null)throw new a(401,"grant_revoked",`no active grant for ${n}`);const m=e.ceiling??o.scopes;return{sub:s.sub,grant_id:s.id,scopes:[...m??s.resolved],origin_run_id:null}}if(o.class==="cron"){const s=yn({grantee:o.app,sub:o.sub,grant_id:o.grant,scopes:o.scopes});if(!s.allowed)throw new a(s.reason==="scope_missing"?403:401,s.reason==="scope_missing"?"scope_missing":"grant_revoked",s.message);return{sub:o.sub,grant_id:o.grant,scopes:o.scopes===null?[...s.effectiveScopes]:[...o.scopes],origin_run_id:o.origin_run}}if(o.class!=="broker")throw new a(401,"unauthorized","this caller cannot arm a cron");const c=r??h(o.node),u=c?.grant_id??o.grant,l=c?.sub??o.sub,p=u!=null?dn(u):pn(n,l);if(p===null){if(n==="app:terminal"&&!_n("user").runtime?.issuer)return{sub:l,grant_id:u??null,scopes:c?.scopes??o.scopes,origin_run_id:c?.run_id??o.run};throw new a(401,"grant_revoked","the node grant is not active")}const d=c?.scopes??o.scopes;return{sub:p.sub,grant_id:p.id,scopes:d===null?[...p.resolved]:[...d],origin_run_id:c?.run_id??o.run}}t(qn,"armPrincipal");function jn(e){const n=e.body??{},r=A(n,"name"),o=A(n,"command"),c=M(A(n,"fire_at"),"fire_at"),u=typeof n.recur=="string"&&n.recur!==""?On(n.recur):null,l=typeof n.expires_at=="string"&&n.expires_at!==""?M(n.expires_at,"expires_at"):null,p=typeof n.env_json=="string"&&n.env_json!==""?Cn(n.env_json):null,d=typeof n.sink=="string"&&n.sink.trim()!==""?n.sink.trim():null,s=O(n.on_output,"on_output",["silent","on-failure","always","on-change"],d?.startsWith("node:")?"on-change":"on-failure"),m=O(n.overlap,"overlap",["skip","queue","replace"],"skip"),N=O(n.tier,"tier",["normal","urgent","critical"],"normal"),R=s==="always"||s==="on-change";if(R&&d===null)throw new i({error:"sink_required",message:`on_output "${s}" delivers stdout every qualifying run \u2014 it needs a sink`,field:"sink",next:"Pass sink node:<node-id> (a living node's inbox), spawn:<kind> (birth a fresh node per delivery), or human (the humanloop inbox)."});if(!R&&d!==null)throw new i({error:"sink_unused",message:`on_output "${s}" never delivers stdout \u2014 a sink would be dead config`,field:"sink",next:'Drop the sink, or use on_output "always"/"on-change" if delivery is the point.'});const Q=d!==null?Sn(d):"{}";if(d?.startsWith("spawn:")&&(wn(e.ceiling,"crtr:act"),e.principal.class==="cron")){const _=vn({grantee:e.principal.app,sub:e.principal.sub,grant_id:e.principal.grant,scopes:e.principal.scopes});if(!_.allowed)throw new a(_.reason==="scope_missing"?403:401,_.reason==="scope_missing"?"scope_missing":"grant_revoked",_.message)}const b=H(e.principal)??"app:terminal",v=e.principal.class==="broker"?e.principal.node:e.principal.class==="cron"?null:typeof n.created_by=="string"&&n.created_by!==""?n.created_by:null;if(e.principal.class==="broker"&&n.created_by!=null&&n.created_by!==v)throw new a(403,"permission_error","a broker may only arm a cron as its own node");const g=v===null?null:h(v);if(g!==null){if(g.grantee!==b)throw new i({error:"creator_app_mismatch",message:"creator node belongs to another app",field:"created_by",next:"Use a node of the calling app."});L(g,"crtr:schedule"),d?.startsWith("spawn:")&&L(g,"crtr:act")}if(n.isolated!==void 0&&typeof n.isolated!="boolean")throw new a(400,"invalid_request","isolated must be a boolean");if(g?.isolated&&n.isolated===!1)throw new a(403,"isolated_run","a cron created by an isolated node cannot escape isolation");const D=g?.isolated===!0||e.principal.class==="cron"&&e.principal.isolated;if(D&&n.isolated===!1)throw new a(403,"isolated_run","a cron created by an isolated caller cannot escape isolation");const q=D||n.isolated===!0,w=qn(e,b,g),j=nn(),S=q?an(b,j):null;if(S!==null&&n.cwd_is_default!==!0){const _=en(S,typeof n.cwd=="string"&&n.cwd.trim()!==""?z(n.cwd):S);if(_===".."||_.startsWith(`..${B}`)||_.startsWith(B))throw new a(400,"invalid_request","an isolated cron must run in its own cron folder",{param:"cwd"})}const V=S??Nn(n,b,v,In(n),q,{sub:w.sub,grantId:w.grant_id,scopes:w.scopes}),f=typeof n.anchor_node=="string"&&n.anchor_node!==""?n.anchor_node:null;if(f!==null&&h(f)!==null&&h(f).grantee!==b)throw new i({error:"anchor_app_mismatch",message:"anchor node belongs to another app",field:"anchor_node",next:"Use an anchor of the calling app."});if(f!==null&&h(f)===null)throw new i({error:"anchor_not_found",message:`anchor node ${f} does not exist on the canvas`,field:"anchor_node",next:"Name a live node id, or drop the anchor \u2014 an unanchored cron outlives every node."});const W=n.cancel_on_wake===!0;if(W&&f===null)throw new i({error:"cancel_on_wake_needs_anchor",message:"cancel_on_wake couples the cron to a node's dormancy \u2014 it requires an anchor node",field:"anchor_node",next:"Pass anchor_node with the node whose wake should cancel this cron."});const $=n.run_timeout_s??300;if(typeof $!="number"||!Number.isInteger($)||$<=0)throw new i({error:"bad_run_timeout",message:`run_timeout_s must be a positive integer (got ${String(n.run_timeout_s)})`,field:"run_timeout_s",next:"Send the timeout in whole seconds, e.g. 300."});const P=typeof n.profile=="string"&&n.profile!==""?kn(e,n.profile).profileId:null,x=O(n.scope,"scope",["profile","global"],P!==null?"profile":"global");if(x==="profile"&&P===null)throw new i({error:"scope_needs_profile",message:'scope "profile" names the profile the cron lists and cancels within \u2014 it requires a profile',field:"profile",next:'Send a profile, or use scope "global" for a canvas-home-wide cron.'});return rn({cron_id:j,name:r,created_by:v,grantee:b,sub:w.sub,grant_id:w.grant_id,scopes:w.scopes,origin_run_id:w.origin_run_id,isolated:q,command:o,fire_at:c,recur:u,tz:typeof n.tz=="string"?n.tz:null,expires_at:l,anchor_node:f,cancel_on_wake:W,cwd:V,env_json:p,profile:P,scope:x,run_timeout_s:$,overlap:m,on_output:s,sink:Q,tier:N}),{status:201,body:k(y(j),null)}}t(jn,"handleArm");function Pn(e){const n=E(e);return{status:200,body:(n===null?T({all:!0}):[...T({profile:n}),...T({global:!0})].sort((o,c)=>o.fire_at<c.fire_at?-1:o.fire_at>c.fire_at?1:0)).filter(o=>U(e,o)).map(o=>k(o,J(o)))}}t(Pn,"handleList");function J(e){return G(e.cron_id)[0]??null}t(J,"lastRunOf");function C(e){const n=e.params.cronId,r=y(n);if(r===null||!U(e,r))throw new a(404,"cron_not_found",`no cron ${n}`);if(!Y(r,E(e)))throw new a(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);return r}t(C,"requireCron");function I(e,n){const r=K(e);if(r!==null&&n.grantee!==r)throw new a(403,"other_app_run",`cron ${n.cron_id} belongs to another app`);return n}t(I,"requireOwnCron");function Tn(e){const n=C(e),r=G(n.cron_id);return{status:200,body:{cron:k(n,r[0]??null),runs:r.map(Z)}}}t(Tn,"handleShow");function An(e){const n=I(e,C(e));F(n.cron_id,"paused");const r=y(n.cron_id);return{status:200,body:k(r,J(r))}}t(An,"handlePause");function En(e){const n=I(e,C(e));F(n.cron_id,"active");const r=y(n.cron_id);return{status:200,body:k(r,J(r))}}t(En,"handleResume");async function Un(e){const n=I(e,C(e));if(n.run_state==="running")throw new a(409,"cron_running",`cron ${n.cron_id} has a run in flight \u2014 wait for it to settle`);const r=await mn(n,{outOfBand:!0});if(r===null)throw new a(409,"cron_running",`cron ${n.cron_id} was claimed by a concurrent run \u2014 check \`cron show\` for its result`);return{status:200,body:Z(r)}}t(Un,"handleRun");function Jn(e){const n=new Date().toISOString();return{status:200,body:{unparked:tn(n,K(e)),at:n}}}t(Jn,"handlePoke");function Rn(e){const n=e.params.cronId,r=y(n);if(r!==null&&!U(e,r))throw new a(404,"cron_not_found",`no cron ${n}`);if(r!==null&&!Y(r,E(e)))throw new a(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);r!==null&&I(e,r);const o=e.query.get("run_id"),c=e.principal.class==="cron"?o===e.principal.execution&&n===e.principal.cron:!0,u=o!==null&&c&&gn(n,o);return on(n,{preserveRunningProcess:u}),{status:204}}t(Rn,"handleCancel");const re=[{method:"POST",pattern:"/v1/crons",scope:"crtr:schedule",handler:jn},{method:"GET",pattern:"/v1/crons",handler:Pn},{method:"GET",pattern:"/v1/crons/:cronId",handler:Tn},{method:"POST",pattern:"/v1/crons/:cronId/pause",scope:"crtr:schedule",handler:An},{method:"POST",pattern:"/v1/crons/:cronId/resume",scope:"crtr:schedule",handler:En},{method:"POST",pattern:"/v1/crons/poke",scope:"crtr:schedule",handler:Jn},{method:"POST",pattern:"/v1/crons/:cronId/run",scope:"crtr:schedule",handler:Un},{method:"DELETE",pattern:"/v1/crons/:cronId",scope:"crtr:schedule",handler:Rn}];export{re as cronRoutes};
1
+ var X=Object.defineProperty;var t=(e,n)=>X(e,"name",{value:n,configurable:!0});import{randomUUID as nn}from"node:crypto";import{relative as en,resolve as B,sep as G}from"node:path";import{ApiError as c}from"../../../api/errors.js";import{armCron as rn,parseCronSink as on,getCron as y,listCrons as T,listCronRuns as F,pokeHeldCrons as tn,setCronState as L}from"../../../core/canvas/crons.js";import{getNode as h}from"../../../core/canvas/canvas.js";import{appSpace as sn,cronFolder as an}from"../../../core/layout.js";import{appProcessesSandboxed as cn,cwdIsMounted as ln}from"../../../core/spaces/process.js";import{activeGrant as dn,activeGrantForApp as un,grantFor as pn,terminalGrant as fn}from"../../../core/grants/mirror.js";import{readConfig as _n}from"../../../core/config.js";import{assertScope as Z}from"../../../core/scopes.js";import{InputError as i}from"../../../core/io.js";import{nextSlotAfter as hn}from"../../../core/wake.js";import{executeCron as mn,markSelfCanceledCronRun as gn}from"../../cron-run.js";import{toCronDTO as k,toCronRunDTO as H}from"../map.js";import{appOf as A,assertCeiling as wn,canReadNode as bn}from"../router.js";import{checkCronBirth as vn,checkCronFire as yn}from"../../cron/authority.js";import{endCron as kn}from"../../cron/ended.js";import{requireUsableProfile as Sn}from"./profiles.js";function E(e,n){const r=e[n];if(typeof r!="string"||r.trim()==="")throw new i({error:"missing_field",message:`cron arm request requires a non-empty "${n}"`,field:n,next:`Send a non-empty string "${n}".`});return r}t(E,"requireString");function C(e,n,r,o){if(e==null)return o;if(typeof e=="string"&&r.includes(e))return e;throw new i({error:`bad_${n}`,message:`"${String(e)}" is not a valid ${n}`,field:n,next:`Use one of: ${r.join(", ")}.`})}t(C,"requireEnum");function $n(e){if(e==="human")return JSON.stringify({human:!0});if(e.startsWith("node:")){const n=e.slice(5).trim();if(n==="")throw new i({error:"bad_sink",message:"node sink requires a node id",field:"sink",next:'Use sink "node:<node-id>".'});if(h(n)===null)throw new i({error:"sink_node_not_found",message:`sink node ${n} does not exist on the canvas`,field:"sink",next:"Name a live node id, or use spawn:<kind> for a delivery that outlives any one node."});return JSON.stringify({node:n})}if(e.startsWith("spawn:")){const n=e.slice(6).trim();if(n==="")throw new i({error:"bad_sink",message:"spawn sink requires a node kind",field:"sink",next:'Use sink "spawn:<kind>" (e.g. spawn:general).'});return JSON.stringify({spawn:n})}throw new i({error:"bad_sink",message:`"${e}" is not a valid sink`,field:"sink",next:"Use node:<node-id>, spawn:<kind>, or human."})}t($n,"parseSinkSpec");const Cn=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function M(e,n){if(!Cn.test(e)||Number.isNaN(Date.parse(e)))throw new i({error:`bad_${n}`,message:`"${e}" is not a UTC ISO instant`,field:n,next:'Send a resolved UTC instant like "2026-06-07T09:00:00.000Z" (the client resolves the time grammar).'});return e}t(M,"requireUtcIso");function On(e){try{hn(e,new Date)}catch(n){throw new i({error:"bad_recur",message:`recur is not advanceable: ${n instanceof Error?n.message:String(n)}`,field:"recur",next:'Send recur JSON the daemon can advance: {"every":"6h"} or {"cron":"0 9 * * *","tz":"America/New_York"}.'})}return e}t(On,"requireRecur");function In(e){let n;try{n=JSON.parse(e)}catch{throw new i({error:"bad_env_json",message:"env_json is not valid JSON",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'})}if(typeof n!="object"||n===null||Array.isArray(n))throw new i({error:"bad_env_json",message:"env_json must be a JSON object",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'});for(const[r,o]of Object.entries(n))if(typeof o!="string")throw new i({error:"bad_env_json",message:`env_json value for "${r}" is not a string`,field:"env_json",next:"Every env value must be a string."});return e}t(In,"requireEnvJson");function U(e){const n=e.query.get("profile");return n!==null&&n.trim()!==""?n.trim():null}t(U,"callerProfile");function Y(e,n){return n===null||e.scope==="global"||e.profile===n}t(Y,"inScope");function K(e){return e.principal.class==="person"?null:A(e.principal)}t(K,"callerApp");function J(e,n){return bn(e.principal,n.grantee,n.created_by??void 0)}t(J,"visibleTo");function Nn(e){return typeof e.anchor_node=="string"&&e.anchor_node!==""?e.anchor_node:null}t(Nn,"anchorNodeOf");function qn(e,n,r,o,a,u){const l=typeof e.cwd=="string"&&e.cwd.trim()!==""?B(e.cwd):null;if(!cn()){if(l===null)throw new i({error:"missing_field",message:'cron arm request requires a non-empty "cwd"',field:"cwd",next:'Send a non-empty string "cwd".'});return l}const p=o!==null&&h(o)!==null?o:void 0,d=t(N=>ln(n,N,p,a,void 0,u),"reaches");if(l!==null&&d(l))return l;const s=sn(n).files;if(l!==null&&e.cwd_is_default!==!0)throw new c(400,"invalid_request",`cwd ${l} is not inside a directory this app's cron runs can see, so every run would fail to start; use a directory under ${s}`,{cwd:l,default:s},void 0,{param:"cwd"});const m=r===null?null:h(r)?.cwd??null;return m!==null&&d(m)?m:s}t(qn,"resolveArmedCwd");function jn(e,n,r){const o=e.principal;if(o.class==="person"){const s=fn();return{sub:s?.sub??"user:local",grant_id:s?.id??null,scopes:null,origin_run_id:null}}if(o.class==="app"){const s=un(n);if(s===null)throw new c(401,"grant_revoked",`no active grant for ${n}`);const m=e.ceiling??o.scopes;return{sub:s.sub,grant_id:s.id,scopes:[...m??s.resolved],origin_run_id:null}}if(o.class==="cron"){const s=yn({grantee:o.app,sub:o.sub,grant_id:o.grant,scopes:o.scopes});if(!s.allowed)throw new c(s.reason==="scope_missing"?403:401,s.reason==="scope_missing"?"scope_missing":"grant_revoked",s.message);return{sub:o.sub,grant_id:o.grant,scopes:o.scopes===null?[...s.effectiveScopes]:[...o.scopes],origin_run_id:o.origin_run}}if(o.class!=="broker")throw new c(401,"unauthorized","this caller cannot arm a cron");const a=r??h(o.node),u=a?.grant_id??o.grant,l=a?.sub??o.sub,p=u!=null?dn(u):pn(n,l);if(p===null){if(n==="app:terminal"&&!_n("user").runtime?.issuer)return{sub:l,grant_id:u??null,scopes:a?.scopes??o.scopes,origin_run_id:a?.run_id??o.run};throw new c(401,"grant_revoked","the node grant is not active")}const d=a?.scopes??o.scopes;return{sub:p.sub,grant_id:p.id,scopes:d===null?[...p.resolved]:[...d],origin_run_id:a?.run_id??o.run}}t(jn,"armPrincipal");function Pn(e){const n=e.body??{},r=E(n,"name"),o=E(n,"command"),a=M(E(n,"fire_at"),"fire_at"),u=typeof n.recur=="string"&&n.recur!==""?On(n.recur):null,l=typeof n.expires_at=="string"&&n.expires_at!==""?M(n.expires_at,"expires_at"):null,p=typeof n.env_json=="string"&&n.env_json!==""?In(n.env_json):null,d=typeof n.sink=="string"&&n.sink.trim()!==""?n.sink.trim():null,s=C(n.on_output,"on_output",["silent","on-failure","always","on-change"],d?.startsWith("node:")?"on-change":"on-failure"),m=C(n.overlap,"overlap",["skip","queue","replace"],"skip"),N=C(n.tier,"tier",["normal","urgent","critical"],"normal"),D=s==="always"||s==="on-change";if(D&&d===null)throw new i({error:"sink_required",message:`on_output "${s}" delivers stdout every qualifying run \u2014 it needs a sink`,field:"sink",next:"Pass sink node:<node-id> (a living node's inbox), spawn:<kind> (birth a fresh node per delivery), or human (the humanloop inbox)."});if(!D&&d!==null)throw new i({error:"sink_unused",message:`on_output "${s}" never delivers stdout \u2014 a sink would be dead config`,field:"sink",next:'Drop the sink, or use on_output "always"/"on-change" if delivery is the point.'});const Q=d!==null?$n(d):"{}";if(d?.startsWith("spawn:")&&(wn(e.ceiling,"crtr:act"),e.principal.class==="cron")){const _=vn({grantee:e.principal.app,sub:e.principal.sub,grant_id:e.principal.grant,scopes:e.principal.scopes});if(!_.allowed)throw new c(_.reason==="scope_missing"?403:401,_.reason==="scope_missing"?"scope_missing":"grant_revoked",_.message)}const b=A(e.principal)??"app:terminal",v=e.principal.class==="broker"?e.principal.node:e.principal.class==="cron"?null:typeof n.created_by=="string"&&n.created_by!==""?n.created_by:null;if(e.principal.class==="broker"&&n.created_by!=null&&n.created_by!==v)throw new c(403,"permission_error","a broker may only arm a cron as its own node");const g=v===null?null:h(v);if(g!==null){if(g.grantee!==b)throw new i({error:"creator_app_mismatch",message:"creator node belongs to another app",field:"created_by",next:"Use a node of the calling app."});Z(g,"crtr:schedule"),d?.startsWith("spawn:")&&Z(g,"crtr:act")}if(n.isolated!==void 0&&typeof n.isolated!="boolean")throw new c(400,"invalid_request","isolated must be a boolean");if(g?.isolated&&n.isolated===!1)throw new c(403,"isolated_run","a cron created by an isolated node cannot escape isolation");const W=g?.isolated===!0||e.principal.class==="cron"&&e.principal.isolated;if(W&&n.isolated===!1)throw new c(403,"isolated_run","a cron created by an isolated caller cannot escape isolation");const q=W||n.isolated===!0,w=jn(e,b,g),j=nn(),S=q?an(b,j):null;if(S!==null&&n.cwd_is_default!==!0){const _=en(S,typeof n.cwd=="string"&&n.cwd.trim()!==""?B(n.cwd):S);if(_===".."||_.startsWith(`..${G}`)||_.startsWith(G))throw new c(400,"invalid_request","an isolated cron must run in its own cron folder",{param:"cwd"})}const V=S??qn(n,b,v,Nn(n),q,{sub:w.sub,grantId:w.grant_id,scopes:w.scopes}),f=typeof n.anchor_node=="string"&&n.anchor_node!==""?n.anchor_node:null;if(f!==null&&h(f)!==null&&h(f).grantee!==b)throw new i({error:"anchor_app_mismatch",message:"anchor node belongs to another app",field:"anchor_node",next:"Use an anchor of the calling app."});if(f!==null&&h(f)===null)throw new i({error:"anchor_not_found",message:`anchor node ${f} does not exist on the canvas`,field:"anchor_node",next:"Name a live node id, or drop the anchor \u2014 an unanchored cron outlives every node."});const x=n.cancel_on_wake===!0;if(x&&f===null)throw new i({error:"cancel_on_wake_needs_anchor",message:"cancel_on_wake couples the cron to a node's dormancy \u2014 it requires an anchor node",field:"anchor_node",next:"Pass anchor_node with the node whose wake should cancel this cron."});const $=n.run_timeout_s??300;if(typeof $!="number"||!Number.isInteger($)||$<=0)throw new i({error:"bad_run_timeout",message:`run_timeout_s must be a positive integer (got ${String(n.run_timeout_s)})`,field:"run_timeout_s",next:"Send the timeout in whole seconds, e.g. 300."});const P=typeof n.profile=="string"&&n.profile!==""?Sn(e,n.profile).profileId:null,z=C(n.scope,"scope",["profile","global"],P!==null?"profile":"global");if(z==="profile"&&P===null)throw new i({error:"scope_needs_profile",message:'scope "profile" names the profile the cron lists and cancels within \u2014 it requires a profile',field:"profile",next:'Send a profile, or use scope "global" for a canvas-home-wide cron.'});return rn({cron_id:j,name:r,created_by:v,grantee:b,sub:w.sub,grant_id:w.grant_id,scopes:w.scopes,origin_run_id:w.origin_run_id,isolated:q,command:o,fire_at:a,recur:u,tz:typeof n.tz=="string"?n.tz:null,expires_at:l,anchor_node:f,cancel_on_wake:x,cwd:V,env_json:p,profile:P,scope:z,run_timeout_s:$,overlap:m,on_output:s,sink:Q,tier:N}),{status:201,body:k(y(j),null)}}t(Pn,"handleArm");function Tn(e){const n=U(e);return{status:200,body:(n===null?T({all:!0}):[...T({profile:n}),...T({global:!0})].sort((o,a)=>o.fire_at<a.fire_at?-1:o.fire_at>a.fire_at?1:0)).filter(o=>J(e,o)).map(o=>k(o,R(o)))}}t(Tn,"handleList");function R(e){return F(e.cron_id)[0]??null}t(R,"lastRunOf");function O(e){const n=e.params.cronId,r=y(n);if(r===null||!J(e,r))throw new c(404,"cron_not_found",`no cron ${n}`);if(!Y(r,U(e)))throw new c(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);return r}t(O,"requireCron");function I(e,n){const r=K(e);if(r!==null&&n.grantee!==r)throw new c(403,"other_app_run",`cron ${n.cron_id} belongs to another app`);return n}t(I,"requireOwnCron");function An(e){const n=O(e),r=F(n.cron_id);return{status:200,body:{cron:k(n,r[0]??null),runs:r.map(H)}}}t(An,"handleShow");function En(e){const n=I(e,O(e));L(n.cron_id,"paused");const r=y(n.cron_id);return{status:200,body:k(r,R(r))}}t(En,"handlePause");function Un(e){const n=I(e,O(e));L(n.cron_id,"active");const r=y(n.cron_id);return{status:200,body:k(r,R(r))}}t(Un,"handleResume");async function Jn(e){const n=I(e,O(e));if(n.run_state==="running")throw new c(409,"cron_running",`cron ${n.cron_id} has a run in flight \u2014 wait for it to settle`);const r=await mn(n,{outOfBand:!0});if(r===null)throw new c(409,"cron_running",`cron ${n.cron_id} was claimed by a concurrent run \u2014 check \`cron show\` for its result`);return{status:200,body:H(r)}}t(Jn,"handleRun");function Rn(e){const n=new Date().toISOString();return{status:200,body:{unparked:tn(n,K(e)),at:n}}}t(Rn,"handlePoke");function Dn(e,n){const r=e.principal;if(r.class==="cron")return r.cron===n.cron_id?null:{kind:"canceled",by:`cron ${r.cron}`};if(r.class==="broker"){const a=on(n.sink);return a?.kind==="node"&&a.node===r.node?null:{kind:"canceled",by:`node ${r.node}`}}const o=A(r);return{kind:"canceled",by:o===null||o==="app:terminal"?"the person":o}}t(Dn,"cancelNotice");function Wn(e){const n=e.params.cronId,r=y(n);if(r!==null&&!J(e,r))throw new c(404,"cron_not_found",`no cron ${n}`);if(r!==null&&!Y(r,U(e)))throw new c(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);r!==null&&I(e,r);const o=e.query.get("run_id"),a=e.principal.class==="cron"?o===e.principal.execution&&n===e.principal.cron:!0,u=o!==null&&a&&gn(n,o);return r!==null&&kn(r,Dn(e,r),{preserveRunningProcess:u}),{status:204}}t(Wn,"handleCancel");const se=[{method:"POST",pattern:"/v1/crons",scope:"crtr:schedule",handler:Pn},{method:"GET",pattern:"/v1/crons",handler:Tn},{method:"GET",pattern:"/v1/crons/:cronId",handler:An},{method:"POST",pattern:"/v1/crons/:cronId/pause",scope:"crtr:schedule",handler:En},{method:"POST",pattern:"/v1/crons/:cronId/resume",scope:"crtr:schedule",handler:Un},{method:"POST",pattern:"/v1/crons/poke",scope:"crtr:schedule",handler:Rn},{method:"POST",pattern:"/v1/crons/:cronId/run",scope:"crtr:schedule",handler:Jn},{method:"DELETE",pattern:"/v1/crons/:cronId",scope:"crtr:schedule",handler:Wn}];export{se as cronRoutes};
@@ -0,0 +1,14 @@
1
+ import { type Cron } from '../../core/canvas/crons.js';
2
+ export type CronEnd = {
3
+ kind: 'expired';
4
+ } | {
5
+ kind: 'canceled';
6
+ by: string;
7
+ };
8
+ /** Delete `c` and, in the same write, mail its node sink the reason. Returns
9
+ * whether this call deleted the row. `end: null` deletes silently (the sink
10
+ * node canceled it itself). The notice is skipped when the sink is not a node
11
+ * that can still take mail: gone, deleting, or finalized with no wake ahead. */
12
+ export declare function endCron(c: Cron, end: CronEnd | null, options?: {
13
+ preserveRunningProcess?: boolean;
14
+ }): boolean;
@@ -0,0 +1,2 @@
1
+ var p=Object.defineProperty;var r=(e,n)=>p(e,"name",{value:n,configurable:!0});import{getNode as c}from"../../core/canvas/canvas.js";import{withCanvasWrite as f}from"../../core/canvas/db.js";import{CRON_RUN_RING as h,cancelCron as m,listCronRuns as y,parseCronSink as g}from"../../core/canvas/crons.js";import{hasNoNaturalCycle as $}from"../../core/runtime/revive-all.js";import{isParked as _}from"../../core/runtime/lifecycle.js";import{enqueueNodeMail as x}from"../../core/feed/messages.js";import{cadenceDisplay as v}from"../../core/wake.js";const k=/^delivered: /,b=r(e=>e.exit_code===0&&(e.stdout_head??"")==="","EMPTY_OK");function w(e){return e.filter(n=>n.exit_code!==null||!(n.delivered??"").startsWith("skipped"))}r(w,"realRuns");function N(e,n,s){const t=w(s),i=s.length>=h,d=t.find(u=>k.test(u.delivered??"")),a=n.kind==="expired"?`expired at ${e.expires_at} and was deleted${e.held?" while held by exit 75; the pending occurrence never fired":""}`:`was canceled by ${n.by} and deleted`,l=[`Cron "${e.name}" (${e.cron_id}, ${e.recur!=null?v(e.recur):"one-shot"}) ${a}. It was delivering to you, so nothing will wake you from it any more.`,"",`- runs made: ${i?`at least ${t.length} (the run log keeps the last ${h} entries)`:t.length}`,d!==void 0?`- last delivery to you: ${d.finished}`:`- last delivery to you: ${i?`none in its last ${t.length} runs`:"never delivered"}`],o=t[0];if(o!==void 0){const u=o.exit_code===null?"no exit observed":o.exit_code===-1?"timed out":`exit ${o.exit_code}`;l.push(`- last run: ${o.finished} \u2014 ${u} \u2014 ${o.delivered??""}`)}return t.length>0&&t.every(b)&&l.push("","Every recorded run exited 0 with empty stdout, so the check may have been failing without saying so. A pipeline exits with its last stage, so `probe 2>&1 | grep \u2026 | jq \u2026` hides a failed probe unless the command starts with `set -o pipefail`."),{label:`\u23F0 cron ${e.name} ${n.kind==="expired"?"expired":"canceled"}`,body:l.join(`
2
+ `)}}r(N,"endedMessage");function C(e){return e.deleting_at!=null||e.run_id!=null&&c(e.run_id)?.deleting_at!=null}r(C,"runIsDeleting");function K(e,n,s){const t=g(e.sink),i=t?.kind==="node"?c(t.node):null,d=n!==null&&i!==null&&!C(i)&&(!$(i)||_(i));return f(()=>{const a=d?y(e.cron_id):[];if(!m(e.cron_id,s))return!1;if(d){const{label:l,body:o}=N(e,n,a);x(i.node_id,{kind:"message",label:l,senderNodeId:null,bodyJson:{body:o},urgency:"normal",wake:1,idempotencyKey:`cron-ended:${e.cron_id}`})}return!0})}r(K,"endCron");export{K as endCron};
@@ -1 +1 @@
1
- var c=Object.defineProperty;var o=(n,e)=>c(n,"name",{value:e,configurable:!0});import{randomUUID as l}from"node:crypto";import{cronDaemonInstanceId as s,liveRunFor as a}from"./live-runs.js";import{advanceCron as u,cancelCron as p,dueOverlapCrons as f,expiredIdleCrons as _,listCronRuns as m,recordCronRun as v}from"../../core/canvas/crons.js";import{emitEvent as x}from"../../core/events/emit.js";import{nextSlotAfter as h}from"../../core/wake.js";const d="skipped (overlap=skip)";function y(n){for(const e of _(n))p(e.cron_id),x({level:"info",event:"cron.expired",fields:{cron_id:e.cron_id,name:e.name,expires_at:e.expires_at}})}o(y,"expireDueCrons");function C(n){return m(n)[0]?.delivered?.startsWith(d)===!0}o(C,"latestRunIsSkip");function A(n,e){for(const r of f(e)){if(r.overlap==="queue")continue;if(r.overlap==="skip"){let t;try{t=h(r.recur,new Date(n))}catch{continue}u(r.cron_id,t),C(r.cron_id)||v({run_id:l(),cron_id:r.cron_id,started:e,finished:e,duration_ms:0,exit_code:null,stdout_head:null,stderr_head:null,delivered:`${d}: fire landed while the previous run was in flight (consecutive skips are not re-recorded until a run settles)`});continue}const i=a(r.cron_id);r.run_lease_owner===s&&i!==void 0&&!i.replaced&&(i.replaced=!0,i.kill())}}o(A,"applyOverlapPolicies");export{A as applyOverlapPolicies,y as expireDueCrons};
1
+ var c=Object.defineProperty;var o=(n,e)=>c(n,"name",{value:e,configurable:!0});import{randomUUID as l}from"node:crypto";import{cronDaemonInstanceId as s,liveRunFor as u}from"./live-runs.js";import{advanceCron as a,dueOverlapCrons as p,expiredIdleCrons as f,listCronRuns as m,recordCronRun as _}from"../../core/canvas/crons.js";import{emitEvent as v}from"../../core/events/emit.js";import{nextSlotAfter as x}from"../../core/wake.js";import{endCron as h}from"./ended.js";const d="skipped (overlap=skip)";function A(n){for(const e of f(n))h(e,{kind:"expired"})&&v({level:"info",event:"cron.expired",fields:{cron_id:e.cron_id,name:e.name,expires_at:e.expires_at}})}o(A,"expireDueCrons");function k(n){return m(n)[0]?.delivered?.startsWith(d)===!0}o(k,"latestRunIsSkip");function E(n,e){for(const r of p(e)){if(r.overlap==="queue")continue;if(r.overlap==="skip"){let t;try{t=x(r.recur,new Date(n))}catch{continue}a(r.cron_id,t),k(r.cron_id)||_({run_id:l(),cron_id:r.cron_id,started:e,finished:e,duration_ms:0,exit_code:null,stdout_head:null,stderr_head:null,delivered:`${d}: fire landed while the previous run was in flight (consecutive skips are not re-recorded until a run settles)`});continue}const i=u(r.cron_id);r.run_lease_owner===s&&i!==void 0&&!i.replaced&&(i.replaced=!0,i.kill())}}o(E,"applyOverlapPolicies");export{E as applyOverlapPolicies,A as expireDueCrons};
@@ -1 +1 @@
1
- var D=Object.defineProperty;var d=(c,t)=>D(c,"name",{value:t,configurable:!0});import{spawn as w}from"node:child_process";import{randomBytes as A}from"node:crypto";import{existsSync as S,statfsSync as _}from"node:fs";import{dirname as B,join as b}from"node:path";import{fileURLToPath as N}from"node:url";import{Cgroups as O,setOomScoreAdj as P}from"./cgroup.js";import{ADMISSION_TICKET_MS as q,APP_QUOTA_CLEANUP_FRACTION as U,BOOT_RESERVATION_BYTES as M,BOOT_WINDOW_MS as T,CONVERSATIONS_DISK_BYTES as I,appCgroupPath as C,appProjectId as v,bareApp as g,bootPendingBytes as E,budgetFor as F,conversationsProjectId as x,memoryAdmission as L,passesFraction as K,runGroupName as j,selectCachePurges as Q,tmpfsSizes as G}from"./policy.js";import{ProjectQuota as $}from"./quota.js";import{readSettings as z}from"./settings.js";function J(c,t,s,i,e){const r=b(B(N(import.meta.url)),"purge-worker.js");return new Promise((o,n)=>{const a=w("/bin/setpriv",["--reuid",String(s),"--regid",String(i),"--groups",String(i),"--no-new-privs","--",process.execPath,r,c,t,String(e)],{stdio:["ignore","pipe","pipe"]});let h="",l="";a.stdout.on("data",u=>{h+=String(u)}),a.stderr.on("data",u=>{l+=String(u)}),a.once("error",n),a.once("close",u=>{if(u!==0)return n(new Error(`purge worker exited ${u}: ${l.trim().slice(0,200)}`));try{o(JSON.parse(h))}catch(y){n(y)}})})}d(J,"spawnPurge");function V(c){return new Promise(t=>{if(!S(c))return t(null);const s=w("du",["-x","-B1","-s",c],{stdio:["ignore","pipe","ignore"],timeout:6e4});let i="";s.stdout.on("data",e=>{i+=String(e)}),s.once("error",()=>t(null)),s.once("close",(e,r)=>{const o=Number(i.split(/\s/)[0]);t(r===null&&(e===0||e===1)&&i.length>0&&Number.isFinite(o)?o:null)})})}d(V,"walkBytes");const W=6e4;function Y(c){const t=_(c),s=Number(t.blocks);return s>0?1-Number(t.bavail)/s:0}d(Y,"appsDiskUsedFraction");class at{static{d(this,"LimitsManager")}options;cgroups;quota;events=[];appliedMemory=new Map;appliedDisk=new Map;purging=new Set;purge;diskUsedFraction;setOom;now;walk;diskUsage=null;diskUsageRun=null;tickets=new Map;brokerRuns=new Map;constructor(t){this.options=t,this.cgroups=t.cgroups??new O,this.quota=t.quota??new $(t.appsRoot,t.stateDir),this.purge=t.purge??J,this.diskUsedFraction=t.diskUsedFraction??(()=>Y(t.appsRoot)),this.setOom=t.setOom??P,this.now=t.now??Date.now,this.walk=t.walk??V}note(t){this.events.push(t),this.events.length>200&&this.events.splice(0,this.events.length-200)}takeEvents(){return this.events.splice(0,this.events.length)}init(){this.cgroups.init()?this.cgroups.sweep():this.note({level:"warn",event:"limits.memory.unavailable",fields:{reason:this.cgroups.unavailable}}),this.quota.init()||this.note({level:"warn",event:"limits.disk.unavailable",fields:{reason:this.quota.unavailable}})}budget(t){return F(t,z(this.options.runtimeRoot))}applyApp(t,s,i=!1){const e=this.budget(t),r=g(t);if(this.cgroups.unavailable===null){const o=JSON.stringify([e.memoryBytes,e.pids]);if(i||this.appliedMemory.get(r)!==o)try{this.cgroups.applyApp(r,e),this.appliedMemory.set(r,o),this.note({level:"info",event:"limits.memory.applied",fields:{app:r,memory_bytes:e.memoryBytes,pids:e.pids}})}catch(n){this.note({level:"warn",event:"limits.memory.failed",fields:{app:r,error:String(n)}})}}if(this.quota.unavailable===null)try{if(this.quota.tag(r,s,{uid:0,gid:0})){const o=String(e.diskBytes);(i||this.appliedDisk.get(s)!==o)&&(this.quota.setLimit(v(s),e.diskBytes),this.quota.setLimit(x(s),I),this.appliedDisk.set(s,o),this.note({level:"info",event:"limits.disk.applied",fields:{app:r,disk_bytes:e.diskBytes}}))}}catch(o){this.note({level:"warn",event:"limits.disk.failed",fields:{app:r,error:String(o)}})}return e}admitBroker(t,s){const i={verdict:"admitted"};if(this.cgroups.unavailable!==null)return i;const e=this.now();for(const[m,f]of this.tickets)e-f.issuedAt>=q&&this.tickets.delete(m);if(this.tickets.has(s))return i;const r=g(t),o=this.cgroups.root,n=`${o}/apps`,a=C(o,r),h=[...this.tickets.values()],l=[...this.brokerRuns].map(([m,f])=>({dir:m,...f})),u=d((m,f,k)=>({max:f,current:this.cgroups.memoryCurrent(m)??0,pending:h.filter(p=>k(p.app)).length*M+l.filter(p=>k(p.app)&&e-p.startedAt<T).reduce((p,R)=>p+E(this.cgroups.memoryCurrent(R.dir)??0),0),hasBroker:h.some(p=>k(p.app))||l.some(p=>k(p.app))}),"level"),y=L({app:u(a,this.cgroups.exists(a)?this.cgroups.memoryLimit(a):this.budget(r).memoryBytes,m=>m===r),apps:u(n,this.cgroups.memoryLimit(n),()=>!0)});return y.verdict==="admitted"&&this.tickets.set(s,{app:r,issuedAt:e}),y}prepareRun(t,s,i=!1){const e=this.options.users.appFor(t)??String(t),r=this.applyApp(e,t);let o=null;if(this.cgroups.unavailable===null)try{o=this.cgroups.createRun(e,j(s,A(4).toString("hex")))}catch(n){this.note({level:"warn",event:"limits.memory.failed",nodeId:s,fields:{app:e,error:String(n)}})}return i&&(this.tickets.delete(s),o!==null&&this.brokerRuns.set(o,{app:g(e),nodeId:s,startedAt:this.now()})),this.purgeIfNearQuota(e,t,r),{cgroupDir:o,app:g(e),nodeId:s,budget:r,tmpfs:G(r)}}purgeIfNearQuota(t,s,i){if(this.quota.unavailable!==null||i.diskBytes===null)return;const e=this.quota.usage().get(v(s))?.usedBytes??0;K(e,i.diskBytes,U)&&this.purgeApp(t,s,"all").catch(()=>{})}confirmStarted(t,s){t.cgroupDir!==null&&!this.cgroups.contains(t.cgroupDir,s)&&this.note({level:"warn",event:"limits.memory.not_joined",nodeId:t.nodeId,fields:{app:t.app,pid:s}})}finishRun(t){if(t.cgroupDir===null)return{oomKilled:!1};const s=t.cgroupDir;this.brokerRuns.delete(s);const i=this.cgroups.oomKilled(s);this.cgroups.kill(s),i&&this.note({level:"warn",event:"run.oom_killed",nodeId:t.nodeId,fields:{app:t.app,memory_bytes:t.budget.memoryBytes}});let e=0;const r=d(()=>{this.cgroups.remove(s)||++e>=20||setTimeout(r,100).unref()},"retry");return r(),{oomKilled:i}}killRun(t){t?.cgroupDir&&this.cgroups.kill(t.cgroupDir)}protectDaemon(t,s){try{this.setOom(t,s)}catch(i){this.note({level:"warn",event:"limits.oom_score.failed",fields:{pid:t,error:String(i)}})}}show(){const t=this.quota.unavailable===null?this.quota.usage():new Map;return{memory:{available:this.cgroups.unavailable===null,reason:this.cgroups.unavailable},disk:{available:this.quota.unavailable===null,reason:this.quota.unavailable},apps:this.options.users.entries().map(({app:s,uid:i})=>{const e=this.budget(s);return{app:s,uid:i,memory_bytes:e.memoryBytes,disk_bytes:e.diskBytes,memory_source:e.memorySource,disk_source:e.diskSource,disk_used_bytes:t.get(v(i))?.usedBytes??null}})}}async diskUsageReport(){const t=this.diskUsage;return t&&this.now()-t.at<W?t.report:(this.diskUsageRun??=this.measureDisk().finally(()=>{this.diskUsageRun=null}),this.diskUsageRun)}async measureDisk(){const t=_(this.options.appsRoot),s=Number(t.bsize),i=Number(t.blocks)*s,e=i-Number(t.bfree)*s,r=[...new Set(this.options.users.entries().map(l=>l.app))],[o,n,a]=await Promise.all([Promise.all(r.map(async l=>({app:l,bytes:await this.walk(b(this.options.appsRoot,g(l)))??0}))),this.walk(b(B(this.options.appsRoot),"user")),this.walk(this.options.runtimeRoot)]),h={total_bytes:i,used_bytes:e,apps:o.sort((l,u)=>u.bytes-l.bytes||l.app.localeCompare(u.app)),user_bytes:n,runtime_bytes:a,measured_at:this.now()};return this.diskUsage={at:this.now(),report:h},h}async purgeApp(t,s,i){const e=g(t),r=b(this.options.appsRoot,e,"cache");if(this.purging.has(e)||!S(r))return null;this.purging.add(e);try{const o=await this.purge(r,i,s,this.options.dataGid,this.now());return this.note({level:"info",event:"app.cache.purged",fields:{app:e,mode:i,files:o.files,bytes:o.bytes,dirs:o.dirs}}),{app:e,mode:i,files:o.files,bytes:o.bytes}}catch(o){return this.note({level:"warn",event:"app.cache.purge_failed",fields:{app:e,mode:i,error:String(o)}}),null}finally{this.purging.delete(e)}}async cachePass(){const t=this.quota.unavailable===null?this.quota.usage():new Map;let s=0;try{s=this.diskUsedFraction()}catch{}const i=this.options.users.entries(),e=Q(i.map(({app:n,uid:a})=>({app:n,usedBytes:t.get(v(a))?.usedBytes??0,limitBytes:this.budget(n).diskBytes})),s),r=new Map(i.map(n=>[n.app,n.uid]));return(await Promise.all(e.map(({app:n,mode:a})=>this.purgeApp(n,r.get(n),a)))).filter(n=>n!==null)}}export{at as LimitsManager,J as spawnPurge,V as walkBytes};
1
+ var D=Object.defineProperty;var d=(c,t)=>D(c,"name",{value:t,configurable:!0});import{spawn as w}from"node:child_process";import{randomBytes as A}from"node:crypto";import{existsSync as S,statfsSync as _}from"node:fs";import{dirname as B,join as b}from"node:path";import{fileURLToPath as N}from"node:url";import{Cgroups as O,setOomScoreAdj as P}from"./cgroup.js";import{ADMISSION_TICKET_MS as q,APP_QUOTA_CLEANUP_FRACTION as U,BOOT_RESERVATION_BYTES as M,BOOT_WINDOW_MS as T,CONVERSATIONS_DISK_BYTES as I,appCgroupPath as C,appProjectId as v,bareApp as g,bootPendingBytes as E,budgetFor as F,conversationsProjectId as x,memoryAdmission as L,passesFraction as K,runGroupName as j,selectCachePurges as Q,tmpfsSizes as G}from"./policy.js";import{ProjectQuota as $}from"./quota.js";import{readSettings as z}from"./settings.js";function J(c,t,s,i,e){const r=b(B(N(import.meta.url)),"purge-worker.js");return new Promise((o,n)=>{const a=w("/bin/setpriv",["--reuid",String(s),"--regid",String(i),"--groups",String(i),"--no-new-privs","--",process.execPath,r,c,t,String(e)],{stdio:["ignore","pipe","pipe"]});let h="",l="";a.stdout.on("data",u=>{h+=String(u)}),a.stderr.on("data",u=>{l+=String(u)}),a.once("error",n),a.once("close",u=>{if(u!==0)return n(new Error(`purge worker exited ${u}: ${l.trim().slice(0,200)}`));try{o(JSON.parse(h))}catch(y){n(y)}})})}d(J,"spawnPurge");function V(c){return new Promise(t=>{if(!S(c))return t(null);const s=w("du",["-x","-B1","-s",c],{stdio:["ignore","pipe","ignore"],timeout:6e4});let i="";s.stdout.on("data",e=>{i+=String(e)}),s.once("error",()=>t(null)),s.once("close",(e,r)=>{const o=Number(i.split(/\s/)[0]);t(r===null&&(e===0||e===1)&&i.length>0&&Number.isFinite(o)?o:null)})})}d(V,"walkBytes");const W=6e4;function Y(c){const t=_(c),s=Number(t.blocks);return s>0?1-Number(t.bavail)/s:0}d(Y,"appsDiskUsedFraction");class at{static{d(this,"LimitsManager")}options;cgroups;quota;events=[];appliedMemory=new Map;appliedDisk=new Map;purging=new Set;purge;diskUsedFraction;setOom;now;walk;diskUsage=null;diskUsageRun=null;tickets=new Map;brokerRuns=new Map;constructor(t){this.options=t,this.cgroups=t.cgroups??new O,this.quota=t.quota??new $(t.appsRoot,t.stateDir),this.purge=t.purge??J,this.diskUsedFraction=t.diskUsedFraction??(()=>Y(t.appsRoot)),this.setOom=t.setOom??P,this.now=t.now??Date.now,this.walk=t.walk??V}note(t){this.events.push(t),this.events.length>200&&this.events.splice(0,this.events.length-200)}takeEvents(){return this.events.splice(0,this.events.length)}init(){this.cgroups.init()?this.cgroups.sweep():this.note({level:"warn",event:"limits.memory.unavailable",fields:{reason:this.cgroups.unavailable}}),this.quota.init()||this.note({level:"warn",event:"limits.disk.unavailable",fields:{reason:this.quota.unavailable}})}budget(t){return F(t,z(this.options.runtimeRoot))}applyApp(t,s,i=!1){const e=this.budget(t),r=g(t);if(this.cgroups.unavailable===null){const o=JSON.stringify([e.memoryBytes,e.pids]);if(i||this.appliedMemory.get(r)!==o)try{this.cgroups.applyApp(r,e),this.appliedMemory.set(r,o),this.note({level:"info",event:"limits.memory.applied",fields:{app:r,memory_bytes:e.memoryBytes,pids:e.pids}})}catch(n){this.note({level:"warn",event:"limits.memory.failed",fields:{app:r,error:String(n)}})}}if(this.quota.unavailable===null)try{if(this.quota.tag(r,s,{uid:0,gid:0})){const o=String(e.diskBytes);(i||this.appliedDisk.get(s)!==o)&&(this.quota.setLimit(v(s),e.diskBytes),this.quota.setLimit(x(s),I),this.appliedDisk.set(s,o),this.note({level:"info",event:"limits.disk.applied",fields:{app:r,disk_bytes:e.diskBytes}}))}}catch(o){this.note({level:"warn",event:"limits.disk.failed",fields:{app:r,error:String(o)}})}return e}admitBroker(t,s){const i={verdict:"admitted"};if(this.cgroups.unavailable!==null)return i;const e=this.now();for(const[m,f]of this.tickets)e-f.issuedAt>=q&&this.tickets.delete(m);if(this.tickets.has(s))return i;const r=g(t),o=this.cgroups.root,n=`${o}/apps`,a=C(o,r),h=[...this.tickets.values()],l=[...this.brokerRuns].map(([m,f])=>({dir:m,...f})),u=d((m,f,k)=>({max:f,current:this.cgroups.memoryCurrent(m)??0,pending:h.filter(p=>k(p.app)).length*M+l.filter(p=>k(p.app)&&e-p.startedAt<T).reduce((p,R)=>p+E(this.cgroups.memoryCurrent(R.dir)??0),0),hasBroker:h.some(p=>k(p.app))||l.some(p=>k(p.app))}),"level"),y=L({app:u(a,this.cgroups.exists(a)?this.cgroups.memoryLimit(a):this.budget(r).memoryBytes,m=>m===r),apps:u(n,this.cgroups.memoryLimit(n),()=>!0)});return y.verdict==="admitted"&&this.tickets.set(s,{app:r,issuedAt:e}),y}prepareRun(t,s,i=!1){const e=this.options.users.appFor(t)??String(t),r=this.applyApp(e,t);let o=null;if(this.cgroups.unavailable===null)try{o=this.cgroups.createRun(e,j(s,A(4).toString("hex")))}catch(n){this.note({level:"warn",event:"limits.memory.failed",nodeId:s,fields:{app:e,error:String(n)}})}return i&&(this.tickets.delete(s),o!==null&&this.brokerRuns.set(o,{app:g(e),nodeId:s,startedAt:this.now()})),this.purgeIfNearQuota(e,t,r),{cgroupDir:o,app:g(e),nodeId:s,budget:r,tmpfs:G(r)}}purgeIfNearQuota(t,s,i){if(this.quota.unavailable!==null||i.diskBytes===null)return;const e=this.quota.usage().get(v(s))?.usedBytes??0;K(e,i.diskBytes,U)&&this.purgeApp(t,s,"all").catch(()=>{})}confirmStarted(t,s){t.cgroupDir!==null&&!this.cgroups.contains(t.cgroupDir,s)&&this.note({level:"warn",event:"limits.memory.not_joined",nodeId:t.nodeId,fields:{app:t.app,pid:s}})}finishRun(t){if(t.cgroupDir===null)return{oomKilled:!1};const s=t.cgroupDir;this.brokerRuns.delete(s);const i=this.cgroups.oomKilled(s);this.cgroups.kill(s),i&&this.note({level:"warn",event:"run.oom_killed",nodeId:t.nodeId,fields:{app:t.app,memory_bytes:t.budget.memoryBytes}});let e=0;const r=d(()=>{this.cgroups.remove(s)||++e>=20||setTimeout(r,100).unref()},"retry");return r(),{oomKilled:i}}killRun(t){t?.cgroupDir&&this.cgroups.kill(t.cgroupDir)}protectDaemon(t,s){try{this.setOom(t,s)}catch(i){this.note({level:"warn",event:"limits.oom_score.failed",fields:{pid:t,error:String(i)}})}}show(){const t=this.quota.unavailable===null?this.quota.usage():new Map;return{memory:{available:this.cgroups.unavailable===null,reason:this.cgroups.unavailable},disk:{available:this.quota.unavailable===null,reason:this.quota.unavailable},apps:this.options.users.entries().map(({app:s,uid:i})=>{const e=this.budget(s);return{app:s,uid:i,memory_bytes:e.memoryBytes,disk_bytes:e.diskBytes,memory_source:e.memorySource,disk_source:e.diskSource,disk_used_bytes:t.get(v(i))?.usedBytes??null}})}}async diskUsageReport(){const t=this.diskUsage;return t&&this.now()-t.at<W?t.report:(this.diskUsageRun??=this.measureDisk().finally(()=>{this.diskUsageRun=null}),this.diskUsageRun)}async measureDisk(){const t=_(this.options.appsRoot),s=Number(t.bsize),i=Number(t.blocks)*s,e=i-Number(t.bfree)*s,r=[...new Set(this.options.users.entries().map(l=>l.app))],[o,n,a]=await Promise.all([Promise.all(r.map(async l=>({app:l,bytes:await this.walk(b(this.options.appsRoot,g(l)))}))),this.walk(b(B(this.options.appsRoot),"user")),this.walk(this.options.runtimeRoot)]),h={total_bytes:i,used_bytes:e,apps:o.sort((l,u)=>(u.bytes??-1)-(l.bytes??-1)||l.app.localeCompare(u.app)),user_bytes:n,runtime_bytes:a,measured_at:this.now()};return this.diskUsage={at:this.now(),report:h},h}async purgeApp(t,s,i){const e=g(t),r=b(this.options.appsRoot,e,"cache");if(this.purging.has(e)||!S(r))return null;this.purging.add(e);try{const o=await this.purge(r,i,s,this.options.dataGid,this.now());return this.note({level:"info",event:"app.cache.purged",fields:{app:e,mode:i,files:o.files,bytes:o.bytes,dirs:o.dirs}}),{app:e,mode:i,files:o.files,bytes:o.bytes}}catch(o){return this.note({level:"warn",event:"app.cache.purge_failed",fields:{app:e,mode:i,error:String(o)}}),null}finally{this.purging.delete(e)}}async cachePass(){const t=this.quota.unavailable===null?this.quota.usage():new Map;let s=0;try{s=this.diskUsedFraction()}catch{}const i=this.options.users.entries(),e=Q(i.map(({app:n,uid:a})=>({app:n,usedBytes:t.get(v(a))?.usedBytes??0,limitBytes:this.budget(n).diskBytes})),s),r=new Map(i.map(n=>[n.app,n.uid]));return(await Promise.all(e.map(({app:n,mode:a})=>this.purgeApp(n,r.get(n),a)))).filter(n=>n!==null)}}export{at as LimitsManager,J as spawnPurge,V as walkBytes};
@@ -116,10 +116,10 @@ export interface DiskUsageReport {
116
116
  /** The data volume the apps live on, from statfs. */
117
117
  total_bytes: number;
118
118
  used_bytes: number;
119
- /** Bytes under each registered app's folder, largest first. */
119
+ /** Bytes under each registered app's folder, largest first; null where the walk failed. */
120
120
  apps: Array<{
121
121
  app: string;
122
- bytes: number;
122
+ bytes: number | null;
123
123
  }>;
124
124
  user_bytes: number | null;
125
125
  runtime_bytes: number | null;
@@ -7,7 +7,7 @@ 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.494
10
+ ## 0.3.495
11
11
 
12
12
  `GET /v1/limits` reports how much of the data disk each app uses on a host without disk quota, and the disk as a whole. Additive: no request or response an existing app sends or reads changes.
13
13
 
@@ -16,6 +16,7 @@ Changes to `@north-light/sdk` that can change what an existing app sees. Newest
16
16
  - `AppLimitsDTO.disk_used_bytes` was `null` whenever the host could not enforce project quota (every hosted runtime on Blaxel). It now holds the bytes under the app's folder, measured by a directory walk at most a minute old. It stays `null` when the host has no launcher, the first walk has not finished (a read waits at most 5 s for it), or the walk failed.
17
17
  - `LimitsDTO.data_disk` is new and optional: `{total_bytes, used_bytes, user_bytes, runtime_bytes}` for the data volume. `crtr sys limits show` prints it.
18
18
 
19
+ ## 0.3.493
19
20
 
20
21
  An app can act on a person's hosted runtime with no sign-in of theirs, and `@north-light/plugin` can declare canvas object kinds. Everything below is additive: no request or response an existing app sends or reads changes.
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.496",
3
+ "version": "0.3.497",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -138,7 +138,7 @@
138
138
  },
139
139
  "license": "UNLICENSED",
140
140
  "dependencies": {
141
- "@north-light/identity": "0.3.496",
141
+ "@north-light/identity": "0.3.497",
142
142
  "@earendil-works/pi-agent-core": "1.1.0",
143
143
  "@earendil-works/pi-ai": "1.1.0",
144
144
  "@earendil-works/pi-coding-agent": "1.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.496",
3
+ "version": "0.3.497",
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.496",
3
+ "version": "0.3.497",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.496",
9
+ "version": "0.3.497",
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.496",
21
+ "@north-light/identity": "0.3.497",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -12116,17 +12116,17 @@
12116
12116
  },
12117
12117
  "packages/crouter-api": {
12118
12118
  "name": "@north-light/api",
12119
- "version": "0.3.496",
12119
+ "version": "0.3.497",
12120
12120
  "license": "UNLICENSED"
12121
12121
  },
12122
12122
  "packages/crouter-env-docker": {
12123
12123
  "name": "@north-light/env-docker",
12124
- "version": "0.3.496",
12124
+ "version": "0.3.497",
12125
12125
  "license": "UNLICENSED"
12126
12126
  },
12127
12127
  "packages/crouter-identity": {
12128
12128
  "name": "@north-light/identity",
12129
- "version": "0.3.496",
12129
+ "version": "0.3.497",
12130
12130
  "license": "UNLICENSED",
12131
12131
  "dependencies": {
12132
12132
  "jose": "^6.2.1"
@@ -12134,20 +12134,20 @@
12134
12134
  },
12135
12135
  "packages/crouter-plugin": {
12136
12136
  "name": "@north-light/plugin",
12137
- "version": "0.3.496",
12137
+ "version": "0.3.497",
12138
12138
  "license": "UNLICENSED",
12139
12139
  "dependencies": {
12140
- "@north-light/api": "0.3.496",
12141
- "@north-light/identity": "0.3.496"
12140
+ "@north-light/api": "0.3.497",
12141
+ "@north-light/identity": "0.3.497"
12142
12142
  }
12143
12143
  },
12144
12144
  "packages/crouter-sdk": {
12145
12145
  "name": "@north-light/sdk",
12146
- "version": "0.3.496",
12146
+ "version": "0.3.497",
12147
12147
  "license": "UNLICENSED",
12148
12148
  "dependencies": {
12149
- "@north-light/api": "0.3.496",
12150
- "@north-light/identity": "0.3.496",
12149
+ "@north-light/api": "0.3.497",
12150
+ "@north-light/identity": "0.3.497",
12151
12151
  "jose": "^6.2.1"
12152
12152
  },
12153
12153
  "bin": {