@turmind/halo 1.1.2 → 1.1.3
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.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/212-70bd2971392bde84.js +1 -0
- package/admin-out/_next/static/chunks/app/page-71c576815b4a30d9.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bundled-docs/design/agentcore.md +35 -0
- package/bundled-docs/design/cron.md +2 -2
- package/bundled-docs/dev/api.md +2 -2
- package/dist/index.js +145 -12
- package/package.json +1 -1
- package/templates/skills/cron/SKILL.md +8 -0
- package/templates/skills/cron/manage-cron.py +27 -3
- package/admin-out/_next/static/chunks/212-8ac9dfe9c24e512c.js +0 -1
- package/admin-out/_next/static/chunks/app/page-2076ec04359bb395.js +0 -1
- /package/admin-out/_next/static/{bZcoAqHBQyOvzwS5c84EJ → oDvBPFdNAW2vHdHcxQ0lw}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{bZcoAqHBQyOvzwS5c84EJ → oDvBPFdNAW2vHdHcxQ0lw}/_ssgManifest.js +0 -0
package/admin-out/index.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--oDvBPFdNAW2vHdHcxQ0lw--><html lang="en" class="dark"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/e4af272ccee01ff0-s.p.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="stylesheet" href="/_next/static/css/0d0ca7b3c8cc5562.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/659eccb5db697b76.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-fc1574554e7b1108.js"/><script src="/_next/static/chunks/fb4ec4d6-3fa44f69484f91b9.js" async=""></script><script src="/_next/static/chunks/635-d7fd95bff8674116.js" async=""></script><script src="/_next/static/chunks/main-app-483b436873410ddc.js" async=""></script><script src="/_next/static/chunks/212-70bd2971392bde84.js" async=""></script><script src="/_next/static/chunks/app/layout-c2fa8035998e48d5.js" async=""></script><script src="/_next/static/chunks/1d2d5650-810a3ca1ae20cab5.js" async=""></script><script src="/_next/static/chunks/438-866a1a20e9efa78a.js" async=""></script><script src="/_next/static/chunks/app/page-71c576815b4a30d9.js" async=""></script><meta name="next-size-adjust" content=""/><title>Halo - Multi-Agent Workspace</title><meta name="description" content="Multi-agent collaboration workspace for complex project delivery"/><link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/><link rel="icon" href="/icon.svg?3a6863548daca964" type="image/svg+xml" sizes="any"/><script>try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}</script><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="__variable_f367f3 font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div class="flex h-screen w-screen items-center justify-center bg-[var(--background)]"><div class="text-sm text-[var(--muted-foreground)]">Loading...</div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-fc1574554e7b1108.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[59391,[\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"177\",\"static/chunks/app/layout-c2fa8035998e48d5.js\"],\"ThemeProvider\"]\n3:I[26967,[\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"177\",\"static/chunks/app/layout-c2fa8035998e48d5.js\"],\"AppI18nProvider\"]\n4:I[66048,[],\"\"]\n5:I[29926,[],\"\"]\n6:I[57917,[],\"ClientPageRoot\"]\n7:I[61089,[\"560\",\"static/chunks/1d2d5650-810a3ca1ae20cab5.js\",\"438\",\"static/chunks/438-866a1a20e9efa78a.js\",\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"974\",\"static/chunks/app/page-71c576815b4a30d9.js\"],\"default\"]\na:I[11789,[],\"OutletBoundary\"]\nc:I[21528,[],\"AsyncMetadataOutlet\"]\ne:I[11789,[],\"ViewportBoundary\"]\n10:I[11789,[],\"MetadataBoundary\"]\n11:\"$Sreact.suspense\"\n13:I[79836,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/0d0ca7b3c8cc5562.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"oDvBPFdNAW2vHdHcxQ0lw\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/0d0ca7b3c8cc5562.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"className\":\"dark\",\"suppressHydrationWarning\":true,\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"script\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}\"}}]}],[\"$\",\"body\",null,{\"className\":\"__variable_f367f3 font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"children\":[\"$\",\"$L3\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L6\",null,{\"Component\":\"$7\",\"searchParams\":{},\"params\":{},\"promises\":[\"$@8\",\"$@9\"]}],[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/659eccb5db697b76.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"$La\",null,{\"children\":[\"$Lb\",[\"$\",\"$Lc\",null,{\"promise\":\"$@d\"}]]}]]}],{},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[null,[[\"$\",\"$Le\",null,{\"children\":\"$Lf\"}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]],[\"$\",\"$L10\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$11\",null,{\"fallback\":null,\"children\":\"$L12\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$13\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:{}\n9:\"$0:f:0:1:2:children:1:props:children:0:props:params\"\n"])</script><script>self.__next_f.push([1,"f:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nb:null\n"])</script><script>self.__next_f.push([1,"14:I[89592,[],\"IconMark\"]\nd:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Halo - Multi-Agent Workspace\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Multi-agent collaboration workspace for complex project delivery\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/icon.svg?3a6863548daca964\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}],[\"$\",\"$L14\",\"4\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"12:\"$d:metadata\"\n"])</script></body></html>
|
package/admin-out/index.txt
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
1:"$Sreact.fragment"
|
|
2
|
-
2:I[59391,["212","static/chunks/212-
|
|
3
|
-
3:I[26967,["212","static/chunks/212-
|
|
2
|
+
2:I[59391,["212","static/chunks/212-70bd2971392bde84.js","177","static/chunks/app/layout-c2fa8035998e48d5.js"],"ThemeProvider"]
|
|
3
|
+
3:I[26967,["212","static/chunks/212-70bd2971392bde84.js","177","static/chunks/app/layout-c2fa8035998e48d5.js"],"AppI18nProvider"]
|
|
4
4
|
4:I[66048,[],""]
|
|
5
5
|
5:I[29926,[],""]
|
|
6
6
|
6:I[57917,[],"ClientPageRoot"]
|
|
7
|
-
7:I[61089,["560","static/chunks/1d2d5650-810a3ca1ae20cab5.js","438","static/chunks/438-866a1a20e9efa78a.js","212","static/chunks/212-
|
|
7
|
+
7:I[61089,["560","static/chunks/1d2d5650-810a3ca1ae20cab5.js","438","static/chunks/438-866a1a20e9efa78a.js","212","static/chunks/212-70bd2971392bde84.js","974","static/chunks/app/page-71c576815b4a30d9.js"],"default"]
|
|
8
8
|
a:I[11789,[],"OutletBoundary"]
|
|
9
9
|
c:I[21528,[],"AsyncMetadataOutlet"]
|
|
10
10
|
e:I[11789,[],"ViewportBoundary"]
|
|
@@ -14,7 +14,7 @@ e:I[11789,[],"ViewportBoundary"]
|
|
|
14
14
|
:HL["/_next/static/media/e4af272ccee01ff0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
|
15
15
|
:HL["/_next/static/css/0d0ca7b3c8cc5562.css","style"]
|
|
16
16
|
:HL["/_next/static/css/659eccb5db697b76.css","style"]
|
|
17
|
-
0:{"P":null,"b":"
|
|
17
|
+
0:{"P":null,"b":"oDvBPFdNAW2vHdHcxQ0lw","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/0d0ca7b3c8cc5562.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"dark","suppressHydrationWarning":true,"children":[["$","head",null,{"children":["$","script",null,{"dangerouslySetInnerHTML":{"__html":"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}"}}]}],["$","body",null,{"className":"__variable_f367f3 font-sans antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L6",null,{"Component":"$7","searchParams":{},"params":{},"promises":["$@8","$@9"]}],[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/659eccb5db697b76.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","$La",null,{"children":["$Lb",["$","$Lc",null,{"promise":"$@d"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Le",null,{"children":"$Lf"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$L10",null,{"children":["$","div",null,{"hidden":true,"children":["$","$11",null,{"fallback":null,"children":"$L12"}]}]}]]}],false]],"m":"$undefined","G":["$13",[]],"s":false,"S":true}
|
|
18
18
|
8:{}
|
|
19
19
|
9:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
|
20
20
|
f:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
|
@@ -76,6 +76,41 @@ whole lifetime (audit B-L3). Every command is `accessLevel: 'full'` by design:
|
|
|
76
76
|
auth terminated upstream at AgentCore, so whoever reached this socket already
|
|
77
77
|
owns the isolated per-user workspace behind it.
|
|
78
78
|
|
|
79
|
+
## WS image upload protocol (chunking)
|
|
80
|
+
|
|
81
|
+
The AgentCore WS proxy hard-caps a single frame at **64KB** — send more and
|
|
82
|
+
the connection is cut with close code 1009 ("Policy violated: message size
|
|
83
|
+
limit of 64 KB for a message frame is exceeded", verified against the Tokyo
|
|
84
|
+
prod runtime). An inline base64 image blows past that instantly, so images
|
|
85
|
+
ride as chunked `image_chunk` frames, assembled server-side, then referenced
|
|
86
|
+
by id from the actual message frame:
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
// 1..N chunk frames per image (inputText empty string is required — the
|
|
90
|
+
// proxy only forwards client frames that carry an inputText key at all)
|
|
91
|
+
{"inputText":"", "type":"image_chunk", "uploadId":"u1", "seq":0, "total":8,
|
|
92
|
+
"mimeType":"image/jpeg", "data":"<base64 slice, ≤48KB>"}
|
|
93
|
+
|
|
94
|
+
// message frame referencing the finished upload(s)
|
|
95
|
+
{"inputText":"describe this", "imageRefs":["u1"]}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Limits (all enforced in `acceptChunk` / `claimImageRefs`): ≤4 images per
|
|
99
|
+
message, ≤8MB of base64 per image, ≤4 concurrent pending uploads per
|
|
100
|
+
connection, ≤256 chunks per upload. Assembly state (`Map<uploadId,
|
|
101
|
+
PendingUpload>`) is **per-connection, in-memory only** — a dropped socket
|
|
102
|
+
discards any partial uploads and the client just re-sends; there is no
|
|
103
|
+
cross-reconnect persistence by design. The server doesn't ack individual
|
|
104
|
+
chunks (that'd add N round-trips for no benefit since the proxy forwards
|
|
105
|
+
mid-stream frames fine) — it only replies with an `error` frame when a chunk
|
|
106
|
+
or `imageRefs` fails validation.
|
|
107
|
+
|
|
108
|
+
On a completed reference, the image is decoded and persisted via
|
|
109
|
+
`saveInboundMedia` into the user's workspace — the same storage path
|
|
110
|
+
telegram uses — and the message text gets a `[图片已保存: <path>]` marker
|
|
111
|
+
appended so history replay can show the image was there without re-sending
|
|
112
|
+
the bytes.
|
|
113
|
+
|
|
79
114
|
## Session lifecycle (the part everyone gets wrong)
|
|
80
115
|
|
|
81
116
|
- Idle timeout (`idleRuntimeSessionTimeout`) terminates a session whose
|
|
@@ -54,7 +54,7 @@ Each fire:
|
|
|
54
54
|
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted.
|
|
55
55
|
4. Spawn `halo cli -a <agent> -s cron-<jobId> -w <workspace>`, prompt on **stdin** (avoids Windows argv length limits). The stable `cron-<jobId>` session id means each fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. The child's pid is written back onto the `running` row (`cron_runs.pid`, nullable) — this is what makes the orphan sweep possible after a server crash.
|
|
56
56
|
5. Tee stdout + stderr live to `~/.halo/global/logs/cron/<runId>.log`; capture stdout to memory for dispatch.
|
|
57
|
-
6. Enforce a **
|
|
57
|
+
6. Enforce a **per-job timeout** (`job.timeoutSec ?? 3600`, the nullable `cron_jobs.timeout_sec` validated 60–21600 at every write surface; a change takes effect on the job's next fire, no croner re-instantiation needed) via a Node timer (cross-platform, not the Linux-only `timeout(1)`), with a **two-phase kill**: SIGTERM the direct child first — the cli has a SIGTERM handler that stops the harness, repairs + flushes the session, kills its own shell_exec process groups, and exits 143 — then, if it's still alive after a 30s grace, SIGKILL the entire descendant tree (POSIX ppid-walk over one `ps` snapshot; `taskkill /T /F` on Windows) so no grandchild survives to double-write the workspace on the next fire. A ppid walk, not a process-group kill: the cli sandbox spawns shell_exec workers `detached` into their own groups, so `kill(-pid)` would miss them. Timed-out runs still report exit code 124. Generous because overlap-of-the-same-job is already blocked up-front in step 1; this is just the long-stop reaper for a truly stuck child.
|
|
58
58
|
7. Classify: exit 124 → `timeout`; non-zero → `failed`; zero but empty stdout → `failed` ("nothing to dispatch"); else `succeeded`.
|
|
59
59
|
8. On success only, `dispatchToTargets(stdout, targets, job.workspacePath)` — the raw stdout goes down as-is; `MEDIA:` extraction happens per-target inside the dispatcher (see Dispatch model). If every target fails, downgrade to `failed`.
|
|
60
60
|
9. `finalize`: write the terminal `cron_runs` row, update `cron_jobs.lastRun*`, disable one-shot jobs, broadcast. The `_inflight` entry is released in `finally`, regardless of outcome.
|
|
@@ -111,6 +111,6 @@ interface CronChannelDispatcher {
|
|
|
111
111
|
|
|
112
112
|
## Scope
|
|
113
113
|
|
|
114
|
-
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; multi-target fan-out; stable accumulating session per job; run-now (manual trigger); admin UI history with per-run logs + dispatch results.
|
|
114
|
+
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job; run-now (manual trigger); admin UI history with per-run logs + dispatch results.
|
|
115
115
|
|
|
116
116
|
Not supported: sub-minute schedules below croner's resolution; in-process job execution (always a fresh cli child); conversation context carried *between different jobs* (each job has its own `cron-<jobId>` session).
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -170,8 +170,8 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
170
170
|
| Method | Path | Purpose |
|
|
171
171
|
|---|---|---|
|
|
172
172
|
| GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded and a live `nextRunAt` (computed from schedule/runAt) |
|
|
173
|
-
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. Returns `{ok, id}` |
|
|
174
|
-
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
173
|
+
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. Returns `{ok, id}` |
|
|
174
|
+
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
175
175
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
|
|
176
176
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
177
177
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
package/dist/index.js
CHANGED
|
@@ -2400,7 +2400,7 @@ var init_init = __esm({
|
|
|
2400
2400
|
path8.resolve(__dirname, "..", "bundled-docs"),
|
|
2401
2401
|
path8.resolve(__dirname, "..", "..", "..", ".halo", "docs")
|
|
2402
2402
|
];
|
|
2403
|
-
TEMPLATE_VERSION =
|
|
2403
|
+
TEMPLATE_VERSION = 50;
|
|
2404
2404
|
VERSION_FILE = ".template-version";
|
|
2405
2405
|
SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
|
|
2406
2406
|
BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
|
|
@@ -20492,14 +20492,75 @@ var init_commands3 = __esm({
|
|
|
20492
20492
|
import { Hono as Hono13 } from "hono";
|
|
20493
20493
|
import path52 from "node:path";
|
|
20494
20494
|
import fs44 from "node:fs";
|
|
20495
|
+
function sanitizeRuntimeId(runtimeSessionId) {
|
|
20496
|
+
return runtimeSessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80);
|
|
20497
|
+
}
|
|
20495
20498
|
function haloSessionId(runtimeSessionId) {
|
|
20496
|
-
return `agentcore_${runtimeSessionId
|
|
20499
|
+
return `agentcore_${sanitizeRuntimeId(runtimeSessionId)}`;
|
|
20497
20500
|
}
|
|
20498
20501
|
function userWorkspace(base, runtimeSessionId) {
|
|
20499
|
-
const dir = path52.join(base, "users", runtimeSessionId
|
|
20502
|
+
const dir = path52.join(base, "users", sanitizeRuntimeId(runtimeSessionId));
|
|
20500
20503
|
fs44.mkdirSync(dir, { recursive: true });
|
|
20501
20504
|
return dir;
|
|
20502
20505
|
}
|
|
20506
|
+
function acceptChunk(uploads, msg) {
|
|
20507
|
+
const uploadId = msg.uploadId;
|
|
20508
|
+
const seq = msg.seq;
|
|
20509
|
+
const total = msg.total;
|
|
20510
|
+
const mimeType = msg.mimeType;
|
|
20511
|
+
const data = msg.data;
|
|
20512
|
+
if (typeof uploadId !== "string" || !uploadId || uploadId.length > 64)
|
|
20513
|
+
return "image_chunk: uploadId must be a non-empty string (\u226464 chars)";
|
|
20514
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 0)
|
|
20515
|
+
return "image_chunk: seq must be a non-negative integer";
|
|
20516
|
+
if (typeof total !== "number" || !Number.isInteger(total) || total < 1 || total > MAX_CHUNKS_PER_UPLOAD)
|
|
20517
|
+
return `image_chunk: total must be 1..${MAX_CHUNKS_PER_UPLOAD}`;
|
|
20518
|
+
if (typeof mimeType !== "string" || typeof data !== "string" || !data)
|
|
20519
|
+
return "image_chunk: string mimeType and non-empty string data required";
|
|
20520
|
+
if (seq >= total)
|
|
20521
|
+
return "image_chunk: seq out of range";
|
|
20522
|
+
let up = uploads.get(uploadId);
|
|
20523
|
+
if (!up) {
|
|
20524
|
+
if (uploads.size >= MAX_PENDING_UPLOADS)
|
|
20525
|
+
return `too many pending uploads (max ${MAX_PENDING_UPLOADS} per connection)`;
|
|
20526
|
+
up = { chunks: new Array(total), received: 0, mimeType, size: 0 };
|
|
20527
|
+
uploads.set(uploadId, up);
|
|
20528
|
+
}
|
|
20529
|
+
if (up.chunks.length !== total)
|
|
20530
|
+
return "image_chunk: total mismatch across chunks of one uploadId";
|
|
20531
|
+
if (up.chunks[seq] !== void 0)
|
|
20532
|
+
return null;
|
|
20533
|
+
if (up.size + data.length > MAX_IMAGE_BASE64_LENGTH) {
|
|
20534
|
+
uploads.delete(uploadId);
|
|
20535
|
+
return "image too large (max 8MB base64 per image)";
|
|
20536
|
+
}
|
|
20537
|
+
up.chunks[seq] = data;
|
|
20538
|
+
up.received++;
|
|
20539
|
+
up.size += data.length;
|
|
20540
|
+
return null;
|
|
20541
|
+
}
|
|
20542
|
+
function claimImageRefs(uploads, raw) {
|
|
20543
|
+
if (raw === void 0)
|
|
20544
|
+
return [];
|
|
20545
|
+
if (!Array.isArray(raw))
|
|
20546
|
+
return { error: "imageRefs must be an array" };
|
|
20547
|
+
if (raw.length > MAX_IMAGES_PER_MESSAGE)
|
|
20548
|
+
return { error: `too many images (max ${MAX_IMAGES_PER_MESSAGE} per message)` };
|
|
20549
|
+
const images = [];
|
|
20550
|
+
for (const ref of raw) {
|
|
20551
|
+
if (typeof ref !== "string")
|
|
20552
|
+
return { error: "imageRefs entries must be strings" };
|
|
20553
|
+
const up = uploads.get(ref);
|
|
20554
|
+
if (!up)
|
|
20555
|
+
return { error: `unknown uploadId: ${ref}` };
|
|
20556
|
+
if (up.received !== up.chunks.length)
|
|
20557
|
+
return { error: `upload ${ref} incomplete (${up.received}/${up.chunks.length} chunks)` };
|
|
20558
|
+
images.push({ data: up.chunks.join(""), mimeType: up.mimeType });
|
|
20559
|
+
}
|
|
20560
|
+
for (const ref of raw)
|
|
20561
|
+
uploads.delete(ref);
|
|
20562
|
+
return images;
|
|
20563
|
+
}
|
|
20503
20564
|
async function ensureSession(sm, workspace, sessionId) {
|
|
20504
20565
|
if (sm.getSessionById(sessionId))
|
|
20505
20566
|
return;
|
|
@@ -20638,6 +20699,7 @@ function setupAgentCoreWebSocket(deps) {
|
|
|
20638
20699
|
ready ??= ensureSession(sm, userWs, sessionId).then(() => attachListener());
|
|
20639
20700
|
return ready;
|
|
20640
20701
|
};
|
|
20702
|
+
const uploads = /* @__PURE__ */ new Map();
|
|
20641
20703
|
ws.on("message", (raw) => {
|
|
20642
20704
|
let msg;
|
|
20643
20705
|
try {
|
|
@@ -20663,8 +20725,20 @@ function setupAgentCoreWebSocket(deps) {
|
|
|
20663
20725
|
});
|
|
20664
20726
|
return;
|
|
20665
20727
|
}
|
|
20728
|
+
if (msg.type === "image_chunk") {
|
|
20729
|
+
const err = acceptChunk(uploads, msg);
|
|
20730
|
+
if (err)
|
|
20731
|
+
send({ type: "error", error: err });
|
|
20732
|
+
return;
|
|
20733
|
+
}
|
|
20666
20734
|
const text5 = typeof msg.inputText === "string" ? msg.inputText.trim() : "";
|
|
20667
|
-
|
|
20735
|
+
const claimed = claimImageRefs(uploads, msg.imageRefs);
|
|
20736
|
+
if (!Array.isArray(claimed)) {
|
|
20737
|
+
send({ type: "error", error: claimed.error });
|
|
20738
|
+
return;
|
|
20739
|
+
}
|
|
20740
|
+
const images = claimed;
|
|
20741
|
+
if (!text5 && images.length === 0) {
|
|
20668
20742
|
return;
|
|
20669
20743
|
}
|
|
20670
20744
|
if (text5.startsWith("/")) {
|
|
@@ -20722,10 +20796,29 @@ function setupAgentCoreWebSocket(deps) {
|
|
|
20722
20796
|
void (async () => {
|
|
20723
20797
|
try {
|
|
20724
20798
|
await ensureOnce();
|
|
20725
|
-
|
|
20799
|
+
const notes = [];
|
|
20800
|
+
for (const img of images) {
|
|
20801
|
+
try {
|
|
20802
|
+
const savedPath = await saveInboundMedia({
|
|
20803
|
+
workspacePath: userWs,
|
|
20804
|
+
accountId: sanitizeRuntimeId(runtimeSessionId),
|
|
20805
|
+
channel: "agentcore",
|
|
20806
|
+
buffer: Buffer.from(img.data, "base64"),
|
|
20807
|
+
kind: "image",
|
|
20808
|
+
mimeType: img.mimeType
|
|
20809
|
+
});
|
|
20810
|
+
notes.push(`[\u56FE\u7247\u5DF2\u4FDD\u5B58: ${savedPath}]`);
|
|
20811
|
+
} catch (err) {
|
|
20812
|
+
console.log(`[AgentCore] image save failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
20813
|
+
notes.push("[\u56FE\u7247]");
|
|
20814
|
+
}
|
|
20815
|
+
}
|
|
20816
|
+
const fullText = notes.length > 0 ? text5 ? `${text5}
|
|
20817
|
+
${notes.join("\n")}` : notes.join("\n") : text5;
|
|
20818
|
+
sm.appendUserMessage(sessionId, fullText);
|
|
20726
20819
|
const result = await sm.sendUserMessage(sessionId, `[channel: agentcore]
|
|
20727
20820
|
|
|
20728
|
-
${
|
|
20821
|
+
${fullText}`, images.length > 0 ? images : void 0);
|
|
20729
20822
|
if (result === "queued")
|
|
20730
20823
|
send({ type: "queued" });
|
|
20731
20824
|
} catch (err) {
|
|
@@ -20736,19 +20829,25 @@ ${text5}`);
|
|
|
20736
20829
|
ws.on("close", () => {
|
|
20737
20830
|
unsubscribe?.();
|
|
20738
20831
|
unsubscribe = null;
|
|
20832
|
+
uploads.clear();
|
|
20739
20833
|
activeOverrides.delete(runtimeSessionId);
|
|
20740
20834
|
});
|
|
20741
20835
|
console.log(`[AgentCore] ws connected \u2014 session ${sessionId}`);
|
|
20742
20836
|
});
|
|
20743
20837
|
}
|
|
20744
|
-
var SESSION_HEADER, activeOverrides;
|
|
20838
|
+
var SESSION_HEADER, MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_BASE64_LENGTH, MAX_PENDING_UPLOADS, MAX_CHUNKS_PER_UPLOAD, activeOverrides;
|
|
20745
20839
|
var init_agentcore = __esm({
|
|
20746
20840
|
"packages/server/dist/routes/agentcore.js"() {
|
|
20747
20841
|
"use strict";
|
|
20748
20842
|
init_commands2();
|
|
20749
20843
|
init_ui_log_builder();
|
|
20750
20844
|
init_session_types();
|
|
20845
|
+
init_media_store();
|
|
20751
20846
|
SESSION_HEADER = "x-amzn-bedrock-agentcore-runtime-session-id";
|
|
20847
|
+
MAX_IMAGES_PER_MESSAGE = 4;
|
|
20848
|
+
MAX_IMAGE_BASE64_LENGTH = 8 * 1024 * 1024;
|
|
20849
|
+
MAX_PENDING_UPLOADS = 4;
|
|
20850
|
+
MAX_CHUNKS_PER_UPLOAD = 256;
|
|
20752
20851
|
activeOverrides = /* @__PURE__ */ new Map();
|
|
20753
20852
|
}
|
|
20754
20853
|
});
|
|
@@ -22065,6 +22164,9 @@ function createCronDb(globalDir2) {
|
|
|
22065
22164
|
if (!cols.some((c) => c.name === "run_at")) {
|
|
22066
22165
|
sqlite.exec(`ALTER TABLE cron_jobs ADD COLUMN run_at INTEGER`);
|
|
22067
22166
|
}
|
|
22167
|
+
if (!cols.some((c) => c.name === "timeout_sec")) {
|
|
22168
|
+
sqlite.exec(`ALTER TABLE cron_jobs ADD COLUMN timeout_sec INTEGER`);
|
|
22169
|
+
}
|
|
22068
22170
|
const runCols = sqlite.prepare(`PRAGMA table_info(cron_runs)`).all();
|
|
22069
22171
|
if (!runCols.some((c) => c.name === "pid")) {
|
|
22070
22172
|
sqlite.exec(`ALTER TABLE cron_runs ADD COLUMN pid INTEGER`);
|
|
@@ -22105,6 +22207,10 @@ var init_cron_db = __esm({
|
|
|
22105
22207
|
runAt: integer4("run_at"),
|
|
22106
22208
|
/** IANA timezone name; null = host timezone. */
|
|
22107
22209
|
timezone: text4("timezone"),
|
|
22210
|
+
/** Max seconds a single cron-fired cli may run before the runner kills
|
|
22211
|
+
* it. NULL = default 3600 (runner.ts CLI_TIMEOUT_SEC). Range 60–21600,
|
|
22212
|
+
* enforced at the write points (REST routes / skill helper). */
|
|
22213
|
+
timeoutSec: integer4("timeout_sec"),
|
|
22108
22214
|
/** JSON array of `{channelType, accountId}` records. Cron output (final
|
|
22109
22215
|
* assistant text) is dispatched to each one. Empty array = log only. */
|
|
22110
22216
|
targets: text4("targets").notNull().default("[]"),
|
|
@@ -22154,6 +22260,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
|
22154
22260
|
schedule TEXT NOT NULL,
|
|
22155
22261
|
run_at INTEGER,
|
|
22156
22262
|
timezone TEXT,
|
|
22263
|
+
timeout_sec INTEGER,
|
|
22157
22264
|
targets TEXT NOT NULL DEFAULT '[]',
|
|
22158
22265
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
22159
22266
|
last_run_status TEXT,
|
|
@@ -22587,6 +22694,7 @@ async function runJob(jobId, triggerKind) {
|
|
|
22587
22694
|
const sessionId = `cron-${job.id}`;
|
|
22588
22695
|
const args = ["cli", "-a", job.agentId, "-s", sessionId, "-w", job.workspacePath];
|
|
22589
22696
|
const [spawnBin, spawnArgs] = process.platform === "win32" && cli.endsWith(".cmd") ? ["cmd.exe", ["/c", cli, ...args]] : [cli, args];
|
|
22697
|
+
const timeoutSec = job.timeoutSec ?? CLI_TIMEOUT_SEC;
|
|
22590
22698
|
const teeStream = fs48.createWriteStream(logPath, { flags: "a" });
|
|
22591
22699
|
teeStream.write(`=== ${(/* @__PURE__ */ new Date()).toISOString()} cron run ${runId} job=${jobId} trigger=${triggerKind} ===
|
|
22592
22700
|
`);
|
|
@@ -22617,7 +22725,7 @@ async function runJob(jobId, triggerKind) {
|
|
|
22617
22725
|
const killTimer = setTimeout(() => {
|
|
22618
22726
|
timedOut = true;
|
|
22619
22727
|
teeStream.write(`
|
|
22620
|
-
[runner] timeout ${
|
|
22728
|
+
[runner] timeout ${timeoutSec}s \u2014 SIGTERM, grace ${KILL_GRACE_SEC}s
|
|
22621
22729
|
`);
|
|
22622
22730
|
try {
|
|
22623
22731
|
child.kill("SIGTERM");
|
|
@@ -22629,7 +22737,7 @@ async function runJob(jobId, triggerKind) {
|
|
|
22629
22737
|
if (child.pid)
|
|
22630
22738
|
killTreeHard(child.pid);
|
|
22631
22739
|
}, KILL_GRACE_SEC * 1e3);
|
|
22632
|
-
},
|
|
22740
|
+
}, timeoutSec * 1e3);
|
|
22633
22741
|
child.stdout?.on("data", (chunk) => {
|
|
22634
22742
|
stdout += chunk.toString("utf-8");
|
|
22635
22743
|
teeStream.write(chunk);
|
|
@@ -22665,7 +22773,7 @@ async function runJob(jobId, triggerKind) {
|
|
|
22665
22773
|
let failureReason = null;
|
|
22666
22774
|
if (result.exitCode === 124) {
|
|
22667
22775
|
status = "timeout";
|
|
22668
|
-
failureReason = `cli exceeded ${
|
|
22776
|
+
failureReason = `cli exceeded ${timeoutSec}s timeout`;
|
|
22669
22777
|
} else if (result.exitCode !== 0) {
|
|
22670
22778
|
status = "failed";
|
|
22671
22779
|
failureReason = stderr.slice(-500).trim() || `cli exited ${result.exitCode}`;
|
|
@@ -22813,6 +22921,15 @@ function validateSchedule(expr) {
|
|
|
22813
22921
|
return err instanceof Error ? err.message : String(err);
|
|
22814
22922
|
}
|
|
22815
22923
|
}
|
|
22924
|
+
function validateTimeoutSec(v) {
|
|
22925
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
22926
|
+
return "timeoutSec must be an integer (seconds)";
|
|
22927
|
+
}
|
|
22928
|
+
if (v < TIMEOUT_SEC_MIN || v > TIMEOUT_SEC_MAX) {
|
|
22929
|
+
return `timeoutSec must be between ${TIMEOUT_SEC_MIN} and ${TIMEOUT_SEC_MAX}`;
|
|
22930
|
+
}
|
|
22931
|
+
return null;
|
|
22932
|
+
}
|
|
22816
22933
|
function createCronRoutes() {
|
|
22817
22934
|
const router = new Hono14();
|
|
22818
22935
|
router.get("/cron/jobs", async (c) => {
|
|
@@ -22856,6 +22973,11 @@ function createCronRoutes() {
|
|
|
22856
22973
|
if (hasRunAt && body.runAt <= Date.now()) {
|
|
22857
22974
|
return c.json({ error: "runAt must be in the future" }, 400);
|
|
22858
22975
|
}
|
|
22976
|
+
if (body.timeoutSec !== void 0 && body.timeoutSec !== null) {
|
|
22977
|
+
const err = validateTimeoutSec(body.timeoutSec);
|
|
22978
|
+
if (err)
|
|
22979
|
+
return c.json({ error: err }, 400);
|
|
22980
|
+
}
|
|
22859
22981
|
const id = newJobId();
|
|
22860
22982
|
const now = Date.now();
|
|
22861
22983
|
getCronDb().insert(cronJobs).values({
|
|
@@ -22867,6 +22989,7 @@ function createCronRoutes() {
|
|
|
22867
22989
|
schedule: hasSchedule ? body.schedule : "",
|
|
22868
22990
|
runAt: hasRunAt ? body.runAt : null,
|
|
22869
22991
|
timezone: body.timezone ?? null,
|
|
22992
|
+
timeoutSec: body.timeoutSec ?? null,
|
|
22870
22993
|
targets: JSON.stringify(body.targets ?? []),
|
|
22871
22994
|
enabled: body.enabled === false ? 0 : 1,
|
|
22872
22995
|
lastRunStatus: null,
|
|
@@ -22903,6 +23026,11 @@ function createCronRoutes() {
|
|
|
22903
23026
|
if (body.runAt <= Date.now())
|
|
22904
23027
|
return c.json({ error: "runAt must be in the future" }, 400);
|
|
22905
23028
|
}
|
|
23029
|
+
if (body.timeoutSec !== void 0 && body.timeoutSec !== null) {
|
|
23030
|
+
const err = validateTimeoutSec(body.timeoutSec);
|
|
23031
|
+
if (err)
|
|
23032
|
+
return c.json({ error: err }, 400);
|
|
23033
|
+
}
|
|
22906
23034
|
const hasSchedule = typeof body.schedule === "string" && body.schedule.trim().length > 0;
|
|
22907
23035
|
const hasRunAt = typeof body.runAt === "number";
|
|
22908
23036
|
if (hasSchedule && hasRunAt) {
|
|
@@ -22930,6 +23058,8 @@ function createCronRoutes() {
|
|
|
22930
23058
|
patch.runAt = nextRunAt;
|
|
22931
23059
|
if (body.timezone !== void 0)
|
|
22932
23060
|
patch.timezone = body.timezone || null;
|
|
23061
|
+
if (body.timeoutSec !== void 0)
|
|
23062
|
+
patch.timeoutSec = body.timeoutSec;
|
|
22933
23063
|
if (body.targets !== void 0)
|
|
22934
23064
|
patch.targets = JSON.stringify(body.targets);
|
|
22935
23065
|
if (body.enabled !== void 0)
|
|
@@ -23045,6 +23175,7 @@ function computeNextRun(j) {
|
|
|
23045
23175
|
return null;
|
|
23046
23176
|
}
|
|
23047
23177
|
}
|
|
23178
|
+
var TIMEOUT_SEC_MIN, TIMEOUT_SEC_MAX;
|
|
23048
23179
|
var init_cron = __esm({
|
|
23049
23180
|
"packages/server/dist/routes/cron.js"() {
|
|
23050
23181
|
"use strict";
|
|
@@ -23052,6 +23183,8 @@ var init_cron = __esm({
|
|
|
23052
23183
|
init_runner();
|
|
23053
23184
|
init_dispatcher();
|
|
23054
23185
|
init_broadcast();
|
|
23186
|
+
TIMEOUT_SEC_MIN = 60;
|
|
23187
|
+
TIMEOUT_SEC_MAX = 21600;
|
|
23055
23188
|
}
|
|
23056
23189
|
});
|
|
23057
23190
|
|
|
@@ -28954,7 +29087,7 @@ var init_dist2 = __esm({
|
|
|
28954
29087
|
init_git_ssh();
|
|
28955
29088
|
__dirname3 = path64.dirname(fileURLToPath3(import.meta.url));
|
|
28956
29089
|
PORT = config.server.port;
|
|
28957
|
-
HALO_VERSION = "1.1.
|
|
29090
|
+
HALO_VERSION = "1.1.3";
|
|
28958
29091
|
PROJECT_ROOT = findProjectRoot();
|
|
28959
29092
|
HALO_HOME4 = path64.join(homedir24(), ".halo");
|
|
28960
29093
|
GIT_SHA = readGitSha();
|
|
@@ -29856,7 +29989,7 @@ import path65 from "node:path";
|
|
|
29856
29989
|
import fs58 from "node:fs";
|
|
29857
29990
|
import { homedir as homedir25 } from "node:os";
|
|
29858
29991
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
29859
|
-
var VERSION = "1.1.
|
|
29992
|
+
var VERSION = "1.1.3";
|
|
29860
29993
|
var HELP_TOP = `Usage: halo <command> [options]
|
|
29861
29994
|
|
|
29862
29995
|
Commands:
|
package/package.json
CHANGED
|
@@ -53,6 +53,7 @@ Sensible defaults if user didn't say:
|
|
|
53
53
|
| timezone | leave unset (host time) |
|
|
54
54
|
| targets | none (log only — the run shows in admin Cron tab) |
|
|
55
55
|
| label | summarize from prompt + schedule |
|
|
56
|
+
| timeout | leave unset (3600s) — see `--timeout-sec` below |
|
|
56
57
|
|
|
57
58
|
### 2. Translate the schedule
|
|
58
59
|
|
|
@@ -78,6 +79,13 @@ today" or "send the report next Monday morning".
|
|
|
78
79
|
|
|
79
80
|
Ambiguous time ("morning")? Ask once for the exact hour.
|
|
80
81
|
|
|
82
|
+
**Max run time (`--timeout-sec`, optional)** — each fire runs as a cli child
|
|
83
|
+
that the server kills after `timeout_sec` seconds (integer, 60–21600). Unset
|
|
84
|
+
= default 3600. Only set it when the user says so: a quick push job that
|
|
85
|
+
should fail fast ("kill it after 5 minutes" → `--timeout-sec 300`), or a
|
|
86
|
+
heavy analysis run that needs more than an hour (`--timeout-sec 7200`). On
|
|
87
|
+
`update`, pass `--timeout-sec ""` to clear back to the default.
|
|
88
|
+
|
|
81
89
|
### 3. Channels (only when delivery is wanted)
|
|
82
90
|
|
|
83
91
|
Run `channels` first to see what's available:
|
|
@@ -106,6 +106,20 @@ def parse_targets(raw: str) -> list[dict]:
|
|
|
106
106
|
return out
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def parse_timeout_sec(raw: str | None) -> int | None:
|
|
110
|
+
"""Validate --timeout-sec: integer seconds, 60–21600 (same bounds as the
|
|
111
|
+
admin REST routes). None = leave unset (runner default 3600)."""
|
|
112
|
+
if raw is None or not raw.strip():
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
v = int(raw.strip())
|
|
116
|
+
except ValueError:
|
|
117
|
+
die(f'invalid --timeout-sec "{raw}": expected an integer (seconds)')
|
|
118
|
+
if v < 60 or v > 21600:
|
|
119
|
+
die('--timeout-sec must be between 60 and 21600')
|
|
120
|
+
return v
|
|
121
|
+
|
|
122
|
+
|
|
109
123
|
def parse_run_at(raw: str | None) -> int | None:
|
|
110
124
|
"""Accepts ISO-8601 (`2026-05-24T09:00:00`) or a unix-ms integer; returns
|
|
111
125
|
epoch ms or None. Local time without offset is parsed as host TZ —
|
|
@@ -190,6 +204,7 @@ def cmd_create(args):
|
|
|
190
204
|
die('--schedule and --run-at are mutually exclusive')
|
|
191
205
|
if run_at_ms is not None and run_at_ms <= now_ms():
|
|
192
206
|
die('--run-at must be in the future')
|
|
207
|
+
timeout_sec = parse_timeout_sec(args.timeout_sec)
|
|
193
208
|
|
|
194
209
|
job_id = args.id or gen_job_id()
|
|
195
210
|
now = now_ms()
|
|
@@ -202,8 +217,8 @@ def cmd_create(args):
|
|
|
202
217
|
|
|
203
218
|
conn.execute(
|
|
204
219
|
'INSERT INTO cron_jobs(id, label, workspace_path, agent_id, user_prompt, schedule, '
|
|
205
|
-
'run_at, timezone, targets, enabled, created_at, updated_at) '
|
|
206
|
-
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
220
|
+
'run_at, timezone, timeout_sec, targets, enabled, created_at, updated_at) '
|
|
221
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
207
222
|
(
|
|
208
223
|
job_id,
|
|
209
224
|
args.label,
|
|
@@ -213,6 +228,7 @@ def cmd_create(args):
|
|
|
213
228
|
args.schedule or '',
|
|
214
229
|
run_at_ms,
|
|
215
230
|
args.timezone,
|
|
231
|
+
timeout_sec,
|
|
216
232
|
json.dumps(targets, ensure_ascii=False),
|
|
217
233
|
0 if args.disabled else 1,
|
|
218
234
|
now, now,
|
|
@@ -240,10 +256,14 @@ def cmd_update(args):
|
|
|
240
256
|
die('--run-at must be in the future')
|
|
241
257
|
sets.append(('run_at', run_at_ms))
|
|
242
258
|
if args.timezone is not None: sets.append(('timezone', args.timezone))
|
|
259
|
+
if args.timeout_sec is not None:
|
|
260
|
+
# `--timeout-sec ""` clears back to the runner default (NULL),
|
|
261
|
+
# same convention as `--run-at ""`.
|
|
262
|
+
sets.append(('timeout_sec', parse_timeout_sec(args.timeout_sec)))
|
|
243
263
|
if args.targets is not None:
|
|
244
264
|
sets.append(('targets', json.dumps(parse_targets(args.targets), ensure_ascii=False)))
|
|
245
265
|
if not sets:
|
|
246
|
-
die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--targets')
|
|
266
|
+
die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--targets')
|
|
247
267
|
sets.append(('updated_at', now_ms()))
|
|
248
268
|
|
|
249
269
|
placeholders = ', '.join(f'{k} = ?' for k, _ in sets)
|
|
@@ -388,6 +408,8 @@ def main():
|
|
|
388
408
|
c.add_argument('--run-at', dest='run_at',
|
|
389
409
|
help='one-shot fire time, ISO-8601 (e.g. 2026-05-24T09:00) or unix ms — auto-disables after fire')
|
|
390
410
|
c.add_argument('--timezone', help='IANA tz, e.g. Asia/Shanghai')
|
|
411
|
+
c.add_argument('--timeout-sec', dest='timeout_sec',
|
|
412
|
+
help='max run time in seconds, 60–21600 (default: unset = 3600)')
|
|
391
413
|
c.add_argument('--targets',
|
|
392
414
|
help='comma-separated channelType:accountId[:chatId] list, or JSON array. '
|
|
393
415
|
'chatId pins delivery to a specific chat (e.g. when scheduling from inside a chat).')
|
|
@@ -403,6 +425,8 @@ def main():
|
|
|
403
425
|
u.add_argument('--schedule')
|
|
404
426
|
u.add_argument('--run-at', dest='run_at')
|
|
405
427
|
u.add_argument('--timezone')
|
|
428
|
+
u.add_argument('--timeout-sec', dest='timeout_sec',
|
|
429
|
+
help='max run time in seconds, 60–21600; pass "" to clear back to default 3600')
|
|
406
430
|
u.add_argument('--targets')
|
|
407
431
|
u.set_defaults(func=cmd_update)
|
|
408
432
|
|